Compare commits

...

2 commits

4 changed files with 135 additions and 50 deletions

View file

@ -791,39 +791,32 @@ class AutoTool(Tool, metaclass=ABCMeta):
Solve for a path consisting of two L-bends connected by a straight segment. Solve for a path consisting of two L-bends connected by a straight segment.
Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2). Used for both U-turns (ccw1 == ccw2) and S-bends (ccw1 != ccw2).
""" """
for plan1 in self._iter_l_plans(ccw1, in_ptype, None):
for plan2 in self._iter_l_plans(ccw2, plan1.out_ptype, out_ptype):
# Solving for:
# X = L1_total +/- R2_actual = length
# Y = R1_actual + L2_straight + overhead_mid + overhead_b2 + L3_total = jog
# Sign for overhead_y2 depends on whether it's a U-turn or S-bend
is_u = bool(ccw1) == bool(ccw2) is_u = bool(ccw1) == bool(ccw2)
# U-turn: X = L1_total - R2 = length => L1_total = length + R2 out_rot = 0 if is_u else pi
# S-bend: X = L1_total + R2 = length => L1_total = length - R2
l1_total = length + (abs(plan2.overhead_y) if is_u else -abs(plan2.overhead_y))
l1_straight = l1_total - plan1.overhead_x
for plan1 in self._iter_l_plans(ccw1, in_ptype, None):
rot_mid = rotation_matrix_2d(pi + plan1.bend_angle)
mid_axis = rot_mid @ numpy.array((1.0, 0.0))
if not numpy.isclose(mid_axis[0], 0) or numpy.isclose(mid_axis[1], 0):
continue
if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1]:
for straight_mid in self.straights: for straight_mid in self.straights:
# overhead_mid accounts for the transition from bend1 to straight_mid
mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype) mid_ptype_pair = (plan1.out_ptype, straight_mid.ptype)
mid_trans = self.transitions.get(mid_ptype_pair, None) mid_trans = self.transitions.get(mid_ptype_pair, None)
mid_trans_dxy = self._itransition2dxy(mid_trans) mid_trans_dxy = self._itransition2dxy(mid_trans)
# b_trans2 accounts for the transition from straight_mid to bend2 for plan2 in self._iter_l_plans(ccw2, straight_mid.ptype, out_ptype):
b2_trans = None fixed_dxy = numpy.array((plan1.overhead_x, plan1.overhead_y))
if plan2.bend is not None and plan2.bend.in_port.ptype != straight_mid.ptype: fixed_dxy += rot_mid @ (
b2_trans = self.transitions.get((plan2.bend.in_port.ptype, straight_mid.ptype), None) mid_trans_dxy
b2_trans_dxy = self._itransition2dxy(b2_trans) + numpy.array((plan2.overhead_x, plan2.overhead_y))
)
l2_straight = abs(jog) - abs(plan1.overhead_y) - plan2.overhead_x - mid_trans_dxy[0] - b2_trans_dxy[0] l1_straight = length - fixed_dxy[0]
l2_straight = (jog - fixed_dxy[1]) / mid_axis[1]
if straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]: if plan1.straight.length_range[0] <= l1_straight < plan1.straight.length_range[1] \
# Found a solution! and straight_mid.length_range[0] <= l2_straight < straight_mid.length_range[1]:
# For plan2, we assume l3_straight = 0.
# We need to verify if l3=0 is valid for plan2.straight.
l3_straight = 0 l3_straight = 0
if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]: if plan2.straight.length_range[0] <= l3_straight < plan2.straight.length_range[1]:
ldata0 = self.LData( ldata0 = self.LData(
@ -832,12 +825,10 @@ class AutoTool(Tool, metaclass=ABCMeta):
) )
ldata1 = self.LData( ldata1 = self.LData(
l3_straight, plan2.straight, kwargs, ccw2, plan2.bend, l3_straight, plan2.straight, kwargs, ccw2, plan2.bend,
b2_trans, None, plan2.out_trans, plan2.in_trans, plan2.b_trans, plan2.out_trans,
) )
data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans) data = self.UData(ldata0, ldata1, straight_mid, l2_straight, mid_trans)
# out_port is at (length, jog) rot pi (for S-bend) or 0 (for U-turn) relative to input
out_rot = 0 if is_u else pi
out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype) out_port = Port((length, jog), rotation=out_rot, ptype=plan2.out_ptype)
return out_port, data return out_port, data
raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}") raise BuildError(f"Failed to find a valid double-L configuration for {length=}, {jog=}")

View file

@ -231,6 +231,8 @@ class Arc(PositionableImpl, Shape):
if (num_vertices is None) and (max_arclen is None): if (num_vertices is None) and (max_arclen is None):
raise PatternError('Max number of points and arclength left unspecified' raise PatternError('Max number of points and arclength left unspecified'
+ ' (default was also overridden)') + ' (default was also overridden)')
if max_arclen is not None and (numpy.isnan(max_arclen) or max_arclen <= 0):
raise PatternError('Max arclength must be positive and not NaN')
r0, r1 = self.radii r0, r1 = self.radii
@ -257,13 +259,19 @@ class Arc(PositionableImpl, Shape):
return arc_lengths, tt return arc_lengths, tt
wh = self.width / 2.0 wh = self.width / 2.0
arclen_limits: list[float] = []
if max_arclen is not None:
arclen_limits.append(max_arclen)
if num_vertices is not None: if num_vertices is not None:
n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int) n_pts = numpy.ceil(max(self.radii + wh) / min(self.radii) * num_vertices * 100).astype(int)
perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum() perimeter_inner = get_arclens(n_pts, *a_ranges[0], dr=-wh)[0].sum()
perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum() perimeter_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum()
implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices implied_arclen = (perimeter_outer + perimeter_inner + self.width * 2) / num_vertices
max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf) if not (numpy.isnan(implied_arclen) or implied_arclen <= 0):
assert max_arclen is not None arclen_limits.append(implied_arclen)
if not arclen_limits:
raise PatternError('Arc polygonization could not determine a valid max_arclen')
max_arclen = min(arclen_limits)
def get_thetas(inner: bool) -> NDArray[numpy.float64]: def get_thetas(inner: bool) -> NDArray[numpy.float64]:
""" Figure out the parameter values at which we should place vertices to meet the arclength constraint""" """ Figure out the parameter values at which we should place vertices to meet the arclength constraint"""

View file

@ -60,6 +60,60 @@ def multi_bend_tool():
) )
return tool, lib return tool, lib
@pytest.fixture
def asymmetric_transition_tool() -> AutoTool:
lib = Library()
bend_pat = Pattern()
bend_pat.ports["in"] = Port((0, 0), 0, ptype="core")
bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="core")
lib["core_bend"] = bend_pat
trans_pat = Pattern()
trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core")
trans_pat.ports["MID"] = Port((3, 1), pi, ptype="mid")
lib["core_mid"] = trans_pat
return AutoTool(
straights=[
AutoTool.Straight(
ptype="core",
fn=lambda length: make_straight(length, ptype="core"),
in_port_name="A",
out_port_name="B",
length_range=(0, 3),
),
AutoTool.Straight(
ptype="mid",
fn=lambda length: make_straight(length, ptype="mid"),
in_port_name="A",
out_port_name="B",
length_range=(0, 1e8),
),
],
bends=[
AutoTool.Bend(lib.abstract("core_bend"), "in", "out", clockwise=True, mirror=True),
],
sbends=[],
transitions={
("mid", "core"): AutoTool.Transition(lib.abstract("core_mid"), "MID", "CORE"),
},
default_out_ptype="core",
).add_complementary_transitions()
def assert_trace_matches_plan(plan_port: Port, tree: Library, port_names: tuple[str, str] = ("A", "B")) -> None:
pat = tree.top_pattern()
out_port = pat[port_names[1]]
dxy, rot = pat[port_names[0]].measure_travel(out_port)
assert_allclose(dxy, plan_port.offset)
assert rot is not None
assert plan_port.rotation is not None
assert_allclose(rot, plan_port.rotation)
assert out_port.ptype == plan_port.ptype
def test_autotool_planL_selection(multi_bend_tool) -> None: def test_autotool_planL_selection(multi_bend_tool) -> None:
tool, _ = multi_bend_tool tool, _ = multi_bend_tool
@ -93,6 +147,19 @@ def test_autotool_planU_consistency(multi_bend_tool) -> None:
assert data.ldata1.straight_length == 0 assert data.ldata1.straight_length == 0
assert data.ldata1.bend.abstract.name == "b1" assert data.ldata1.bend.abstract.name == "b1"
def test_autotool_traceU_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
tool = asymmetric_transition_tool
plan_port, data = tool.planU(12, length=0, in_ptype="core")
assert data.ldata1.in_transition is not None
assert data.ldata1.b_transition is not None
tree = tool.traceU(12, length=0, in_ptype="core")
assert_trace_matches_plan(plan_port, tree)
def test_autotool_planS_double_L(multi_bend_tool) -> None: def test_autotool_planS_double_L(multi_bend_tool) -> None:
tool, lib = multi_bend_tool tool, lib = multi_bend_tool
@ -109,6 +176,19 @@ def test_autotool_planS_double_L(multi_bend_tool) -> None:
assert data.l2_length == 6 assert data.l2_length == 6
def test_autotool_traceS_double_l_matches_plan_with_asymmetric_transition(asymmetric_transition_tool: AutoTool) -> None:
tool = asymmetric_transition_tool
plan_port, data = tool.planS(4, 10, in_ptype="core")
assert isinstance(data, AutoTool.UData)
assert data.ldata1.in_transition is not None
assert data.ldata1.b_transition is not None
tree = tool.traceS(4, 10, in_ptype="core")
assert_trace_matches_plan(plan_port, tree)
def test_autotool_planS_pure_sbend_with_transition_dx() -> None: def test_autotool_planS_pure_sbend_with_transition_dx() -> None:
lib = Library() lib = Library()

View file

@ -122,6 +122,12 @@ def test_curve_polygonizers_clamp_large_max_arclen() -> None:
assert len(polys[0].vertices) >= 3 assert len(polys[0].vertices) >= 3
def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
arc = Arc(radii=(10, 20), angles=(0, numpy.nan), width=2)
with pytest.raises(PatternError, match='valid max_arclen'):
arc.to_polygons(num_vertices=24)
def test_ellipse_integer_radii_scale_cleanly() -> None: def test_ellipse_integer_radii_scale_cleanly() -> None:
ellipse = Ellipse(radii=(10, 20)) ellipse = Ellipse(radii=(10, 20))
ellipse.scale_by(0.5) ellipse.scale_by(0.5)