From 0230fb49a6d5151082f2a5efca17b4035e03724f Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 9 Jul 2026 21:38:29 -0700 Subject: [PATCH] [AutoTool] enable custom sbend endpoint_at --- masque/builder/__init__.py | 2 + masque/builder/tools.py | 56 +++++++++++++++++++++------ masque/test/test_autotool_planning.py | 56 ++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 54c6c45..5bd3914 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -48,9 +48,11 @@ from .tools import ( PathTool as PathTool, RenderStep as RenderStep, PrimitiveKind as PrimitiveKind, + GeneratedEndpointFn as GeneratedEndpointFn, PrimitiveOffer as PrimitiveOffer, StraightOffer as StraightOffer, BendOffer as BendOffer, SOffer as SOffer, UOffer as UOffer, + circular_arc_sbend_endpoint as circular_arc_sbend_endpoint, ) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 24ca9d1..32a864e 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -39,7 +39,7 @@ from collections import ChainMap from collections.abc import Sequence, Callable, Mapping from abc import ABC, abstractmethod from dataclasses import dataclass, field -from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan +from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt import numpy from numpy.typing import NDArray @@ -706,6 +706,32 @@ class Tool(ABC): GeneratedPrimitiveFn = Callable[..., Pattern | Library] +GeneratedEndpointFn = Callable[[float], Port] + + +def circular_arc_sbend_endpoint(radius: float, ptype: str) -> GeneratedEndpointFn: + """ + Return an S-bend endpoint planner for two abutting circular arcs. + + The returned callback assumes a pure generated S-bend made from two equal + circular arcs with no attached straight or non-circular pieces. Positive + and negative jogs are supported; the output rotation is always `pi`. + """ + rr = float(radius) + if not scalar_isfinite(rr) or rr <= 0: + raise BuildError(f'S-bend radius must be positive and finite, got {rr:g}') + + def endpoint(jog: float) -> Port: + jj = float(jog) + jog_magnitude = abs(jj) + if scalar_isclose(jog_magnitude, 0.0, rel_tol=1e-9, abs_tol=1e-12): + return Port((0, 0), rotation=pi, ptype=ptype) + if jog_magnitude > 2 * rr and not scalar_isclose(jog_magnitude, 2 * rr, rel_tol=1e-9, abs_tol=1e-12): + raise BuildError(f'S-bend jog magnitude {jog_magnitude:g} exceeds diameter {2 * rr:g}') + dx = sqrt(max(0.0, 4 * rr * jog_magnitude - jog_magnitude ** 2)) + return Port((dx, jj), rotation=pi, ptype=ptype) + + return endpoint @dataclass @@ -853,23 +879,29 @@ class AutoTool(Tool): out_port_name: str, *, jog_range: tuple[float, float] = (0, numpy.inf), + endpoint: GeneratedEndpointFn | None = None, ) -> Self: """ Register a generated S-bend primitive. - """ - for jog_domain in self._signed_jog_domains(jog_range): - priority_bias = len(self._s_offers) * BUILTIN_PRIORITY_STEP - def endpoint_s( - jog: float, - *, - fn: GeneratedPrimitiveFn = fn, - in_port_name: str = in_port_name, - out_port_name: str = out_port_name, - ) -> Port: + `endpoint`, when supplied, describes the generated S-bend output port + directly during planning and avoids instantiating `fn()` inside + `endpoint_at()`. + """ + if endpoint is None: + def endpoint_at(jog: float) -> Port: jog_magnitude = abs(jog) sbend_dxy = self._sbend2dxy(fn, in_port_name, out_port_name, jog_magnitude) return Port((float(sbend_dxy[0]), float(jog)), rotation=pi, ptype=ptype) + else: + def endpoint_at(jog: float) -> Port: + out_port = endpoint(jog) + if not ptypes_compatible(out_port.ptype, ptype): + raise BuildError('S-bend endpoint ptype does not match registered ptype') + return out_port + + for jog_domain in self._signed_jog_domains(jog_range): + priority_bias = len(self._s_offers) * BUILTIN_PRIORITY_STEP def data_at(jog: float) -> AutoTool.GeneratedData: return self.GeneratedData( @@ -881,7 +913,7 @@ class AutoTool(Tool): self._s_offers.append(SOffer.generated( ptype, - endpoint_s, + endpoint_at, data_at, priority_bias = priority_bias, bbox_for_data = self._bbox_for_data, diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py index 1ae17b9..8e101e6 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -5,7 +5,7 @@ import pytest from numpy import pi from numpy.testing import assert_allclose -from masque.builder.tools import AutoTool, PrimitiveOffer, RenderStep, UOffer +from masque.builder.tools import AutoTool, PrimitiveOffer, RenderStep, UOffer, circular_arc_sbend_endpoint from masque.builder.pather import Pather from masque.library import Library from masque.pattern import Pattern @@ -368,6 +368,60 @@ def make_sbend_tool(jog_range: tuple[float, float]) -> AutoTool: ) +def test_autotool_sbend_custom_endpoint_avoids_generator_during_planning() -> None: + calls = 0 + + def make_counted_sbend(jog: float) -> Pattern: + nonlocal calls + calls += 1 + return make_sbend(jog, ptype="core") + + def endpoint(jog: float) -> Port: + return Port((20, jog), rotation=pi, ptype="core") + + tool = AutoTool().add_sbend( + "core", + make_counted_sbend, + "A", + "B", + jog_range=(0, 1e8), + endpoint=endpoint, + ) + + offer = tool.primitive_offers("s", in_ptype="core")[0] + out_port = offer.endpoint_at(4) + + assert calls == 0 + assert_allclose(out_port.offset, [20, 4]) + assert out_port.rotation == pi + assert out_port.ptype == "core" + + data = offer.commit(4) + assert data.parameter == 4 + assert data.mirrored is False + + +def test_circular_arc_sbend_endpoint() -> None: + endpoint = circular_arc_sbend_endpoint(radius=5, ptype="core") + + out_port = endpoint(4) + assert_allclose(out_port.offset, [8, 4]) + assert out_port.rotation == pi + assert out_port.ptype == "core" + + mirrored_port = endpoint(-4) + assert_allclose(mirrored_port.offset, [8, -4]) + assert mirrored_port.rotation == pi + assert mirrored_port.ptype == "core" + + zero_port = endpoint(0) + assert_allclose(zero_port.offset, [0, 0]) + assert zero_port.rotation == pi + + with pytest.raises(BuildError, match="exceeds diameter"): + endpoint(11) + + def make_uturn_pattern(length: float = 10, jog: float = 4, ptype: str = "core") -> Pattern: pat = Pattern() y0, y1 = sorted((0, jog))