[AutoTool] enable custom sbend endpoint_at

This commit is contained in:
Jan Petykiewicz 2026-07-09 21:38:29 -07:00
commit 0230fb49a6
3 changed files with 101 additions and 13 deletions

View file

@ -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,
)

View file

@ -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,

View file

@ -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))