diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index b684af9..f7c3cca 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -857,6 +857,22 @@ class Solver: return False return self.request.out_ptype is None or ptypes_compatible(end_port.ptype, self.request.out_ptype) + def rotation_matches_request(self, steps: Sequence[SelectedPrimitive]) -> bool: + """ + Return true when a sequence can produce the requested output rotation. + + Primitive parameters do not affect the rotation contract, so + rotation-impossible candidates can be rejected before parameter solving. + """ + angle = 0.0 + for step in steps: + step_rotation = step.out_port.rotation + if step_rotation is None: + raise BuildError('Primitive endpoints must have rotation') + angle += float(step_rotation) + pi + rotation_delta = ((angle - pi) - self.request.out_rotation) % (2 * pi) + return is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi) + def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate: """ Try all small solve sets for one raw sequence and return the first match. @@ -874,6 +890,8 @@ class Solver: raise BuildError(f'{self.request.route_name} route requires a jog constraint') constraints.append(('y', float(self.request.jog))) route_constraints = tuple(constraints) + if not self.rotation_matches_request(steps): + raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') adjustable = self.adjustable_indices(steps) solve_sets: list[tuple[int, ...]] = [()] max_solve = min(len(route_constraints), len(adjustable)) diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py index ab8facf..06916ae 100644 --- a/masque/test/test_pather_primitive_offers.py +++ b/masque/test/test_pather_primitive_offers.py @@ -422,6 +422,53 @@ def test_pather_selects_lowest_cost_offer() -> None: assert numpy.allclose(p.ports['A'].offset, (-7, 0)) +def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: + invalid_parameters: list[float] = [] + + class RotationOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def invalid_endpoint(length: float) -> Port: + invalid_parameters.append(length) + return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype) + + def valid_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) + + return ( + StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + endpoint_planner=invalid_endpoint, + commit_planner=lambda length: {'kind': 'invalid', 'length': length}, + ), + StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + endpoint_planner=valid_endpoint, + commit_planner=lambda length: {'kind': 'valid', 'length': length}, + ), + ) + + p = Pather(Library(), tools=RotationOfferTool(), auto_render=False) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 7) + + assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7} + assert invalid_parameters == [0.0, 0.0] + + def test_pather_commits_only_selected_offer() -> None: committed: list[str] = []