From cf822c7dcfefcac784defe07a7187ce7b7942b0a Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 12:23:40 -0800 Subject: [PATCH 1/3] [Port] add more logging to aid in debug --- masque/builder/renderpather.py | 2 ++ masque/pattern.py | 5 +++++ masque/ports.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 29a8173..4bc3b5f 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -430,6 +430,7 @@ class RenderPather(PatherMixin): self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) @@ -506,6 +507,7 @@ class RenderPather(PatherMixin): step = RenderStep('S', tool, port.copy(), out_port.copy(), data) self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) diff --git a/masque/pattern.py b/masque/pattern.py index 94555cc..05a8962 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -638,6 +638,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): cast('Positionable', entry).translate(offset) + self._log_bulk_update(f"translate({offset})") return self def scale_elements(self, c: float) -> Self: @@ -705,6 +706,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.rotate_elements(rotation) self.rotate_element_centers(rotation) self.translate_elements(+pivot) + self._log_bulk_update(f"rotate_around({pivot}, {rotation})") return self def rotate_element_centers(self, rotation: float) -> Self: @@ -761,6 +763,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): cast('Flippable', entry).flip_across(axis=axis) + self._log_bulk_update(f"mirror({axis})") return self def copy(self) -> Self: @@ -1160,6 +1163,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): pp.rotate_around(pivot, rotation) pp.translate(offset) self.ports[name] = pp + self._log_port_update(name) if append: if isinstance(other, Abstract): @@ -1315,6 +1319,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): # get rid of plugged ports for ki, vi in map_in.items(): del self.ports[ki] + self._log_port_removal(ki) map_out[vi] = None if isinstance(other, Pattern): diff --git a/masque/ports.py b/masque/ports.py index 45aedb5..5260b19 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -17,6 +17,7 @@ from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) +port_logger = logging.getLogger('masque.ports') @functools.total_ordering @@ -207,6 +208,19 @@ class PortList(metaclass=ABCMeta): def ports(self, value: dict[str, Port]) -> None: pass + def _log_port_update(self, name: str) -> None: + """ Log the current state of the named port """ + port_logger.info("Port %s: %s", name, self.ports[name]) + + def _log_port_removal(self, name: str) -> None: + """ Log that the named port has been removed """ + port_logger.info("Port %s: removed", name) + + def _log_bulk_update(self, label: str) -> None: + """ Log all current ports at DEBUG level """ + for name, port in self.ports.items(): + port_logger.debug("%s: Port %s: %s", label, name, port) + @overload def __getitem__(self, key: str) -> Port: pass @@ -260,6 +274,7 @@ class PortList(metaclass=ABCMeta): raise PortError(f'Port {name} already exists.') assert name not in self.ports self.ports[name] = value + self._log_port_update(name) return self def rename_ports( @@ -286,11 +301,22 @@ class PortList(metaclass=ABCMeta): if duplicates: raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') + for kk, vv in mapping.items(): + if vv is None: + self._log_port_removal(kk) + elif vv != kk: + self._log_port_removal(kk) + renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} if None in renamed: del renamed[None] self.ports.update(renamed) # type: ignore + + for vv in mapping.values(): + if vv is not None: + self._log_port_update(vv) + return self def add_port_pair( @@ -319,6 +345,8 @@ class PortList(metaclass=ABCMeta): } self.check_ports(names) self.ports.update(new_ports) + self._log_port_update(names[0]) + self._log_port_update(names[1]) return self def plugged( @@ -388,6 +416,7 @@ class PortList(metaclass=ABCMeta): for pp in chain(a_names, b_names): del self.ports[pp] + self._log_port_removal(pp) return self def check_ports( From f42e720c68510762a51c4162fc2c1ebccde48412 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 13:43:54 -0800 Subject: [PATCH 2/3] [set_dead / skip_geometry] Improve dead pathers so more "broken" layouts can be successfully executed --- masque/builder/builder.py | 8 +- masque/builder/pather.py | 74 +++++++++++++++---- masque/builder/renderpather.py | 121 +++++++++++++++++++------------ masque/pattern.py | 43 +++++++++-- masque/test/test_builder.py | 54 ++++++++++++++ masque/test/test_pather.py | 22 ++++++ masque/test/test_renderpather.py | 20 +++++ 7 files changed, 271 insertions(+), 71 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 1b534b5..3c39710 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -284,8 +284,7 @@ class Builder(PortList): do not line up) """ if self._dead: - logger.error('Skipping plug() since device is dead') - return self + logger.warning('Skipping geometry for plug() since device is dead') if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -305,6 +304,7 @@ class Builder(PortList): set_rotation = set_rotation, append = append, ok_connections = ok_connections, + skip_geometry = self._dead, ) return self @@ -357,8 +357,7 @@ class Builder(PortList): are applied. """ if self._dead: - logger.error('Skipping place() since device is dead') - return self + logger.warning('Skipping geometry for place() since device is dead') if not isinstance(other, str | Abstract | Pattern): # We got a Tree; add it into self.library and grab an Abstract for it @@ -378,6 +377,7 @@ class Builder(PortList): port_map = port_map, skip_port_check = skip_port_check, append = append, + skip_geometry = self._dead, ) return self diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 9af473d..d58b282 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -7,6 +7,8 @@ import copy import logging from pprint import pformat +from numpy import pi + from ..pattern import Pattern from ..library import ILibrary from ..error import BuildError @@ -288,14 +290,36 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping path() since device is dead') - return self + logger.warning('Skipping geometry for path() since device is dead') tool_port_names = ('A', 'B') tool = self.tools.get(portspec, self.tools[None]) in_ptype = self.pattern[portspec].ptype - tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + try: + tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + logger.warning("Tool path failed for dead pather. Using dummy extension.") + # Fallback for dead pather: manually update the port instead of plugging + port = self.pattern[portspec] + port_rot = port.rotation + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + self.pattern.ports[portspec] = out_port + self._log_port_update(portspec) + if plug_into is not None: + self.plugged({portspec: plug_into}) + return self + tname = self.library << tree if plug_into is not None: output = {plug_into: tool_port_names[1]} @@ -340,8 +364,7 @@ class Pather(Builder, PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping pathS() since device is dead') - return self + logger.warning('Skipping geometry for pathS() since device is dead') tool_port_names = ('A', 'B') @@ -353,16 +376,39 @@ class Pather(Builder, PatherMixin): # Fall back to drawing two L-bends ccw0 = jog > 0 kwargs_no_out = kwargs | {'out_ptype': None} - t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) - t_pat0 = t_tree0.top_pattern() - (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) - t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) - t_pat1 = t_tree1.top_pattern() - (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) + try: + t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) + t_pat0 = t_tree0.top_pattern() + (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) + t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) + t_pat1 = t_tree1.top_pattern() + (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + return self + except (BuildError, NotImplementedError): + if not self._dead: + raise + # Fall through to dummy extension below + except BuildError: + if not self._dead: + raise + # Fall through to dummy extension below + + if self._dead: + logger.warning("Tool pathS failed for dead pather. Using dummy extension.") + # Fallback for dead pather: manually update the port instead of plugging + port = self.pattern[portspec] + port_rot = port.rotation + out_port = Port((length, jog), rotation=pi, ptype=in_ptype) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + self.pattern.ports[portspec] = out_port + self._log_port_update(portspec) + if plug_into is not None: + self.plugged({portspec: plug_into}) return self tname = self.library << tree diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 4bc3b5f..863f3f1 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -253,8 +253,7 @@ class RenderPather(PatherMixin): do not line up) """ if self._dead: - logger.error('Skipping plug() since device is dead') - return self + logger.warning('Skipping geometry for plug() since device is dead') other_tgt: Pattern | Abstract if isinstance(other, str): @@ -262,18 +261,19 @@ class RenderPather(PatherMixin): if append and isinstance(other, Abstract): other_tgt = self.library[other.name] - # get rid of plugged ports - for kk in map_in: - if kk in self.paths: - self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) + if not self._dead: + # get rid of plugged ports + for kk in map_in: + if kk in self.paths: + self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) - plugged = map_in.values() - for name, port in other_tgt.ports.items(): - if name in plugged: - continue - new_name = map_out.get(name, name) if map_out is not None else name - if new_name is not None and new_name in self.paths: - self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + plugged = map_in.values() + for name, port in other_tgt.ports.items(): + if name in plugged: + continue + new_name = map_out.get(name, name) if map_out is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.plug( other = other_tgt, @@ -284,6 +284,7 @@ class RenderPather(PatherMixin): set_rotation = set_rotation, append = append, ok_connections = ok_connections, + skip_geometry = self._dead, ) return self @@ -334,8 +335,7 @@ class RenderPather(PatherMixin): are applied. """ if self._dead: - logger.error('Skipping place() since device is dead') - return self + logger.warning('Skipping geometry for place() since device is dead') other_tgt: Pattern | Abstract if isinstance(other, str): @@ -343,10 +343,11 @@ class RenderPather(PatherMixin): if append and isinstance(other, Abstract): other_tgt = self.library[other.name] - for name, port in other_tgt.ports.items(): - new_name = port_map.get(name, name) if port_map is not None else name - if new_name is not None and new_name in self.paths: - self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) + if not self._dead: + for name, port in other_tgt.ports.items(): + new_name = port_map.get(name, name) if port_map is not None else name + if new_name is not None and new_name in self.paths: + self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) self.pattern.place( other = other_tgt, @@ -357,6 +358,7 @@ class RenderPather(PatherMixin): port_map = port_map, skip_port_check = skip_port_check, append = append, + skip_geometry = self._dead, ) return self @@ -365,11 +367,12 @@ class RenderPather(PatherMixin): self, connections: dict[str, str], ) -> Self: - for aa, bb in connections.items(): - porta = self.ports[aa] - portb = self.ports[bb] - self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) - self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) + if not self._dead: + for aa, bb in connections.items(): + porta = self.ports[aa] + portb = self.ports[bb] + self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) + self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) PortList.plugged(self, connections) return self @@ -410,8 +413,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping path() since device is dead') - return self + logger.warning('Skipping geometry for path() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -420,14 +422,28 @@ class RenderPather(PatherMixin): tool = self.tools.get(portspec, self.tools[None]) # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype - out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + try: + out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) + except (BuildError, NotImplementedError): + if not self._dead: + raise + logger.warning("Tool planning failed for dead pather. Using dummy extension.") + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + out_port = Port((length, 0), rotation=out_rot, ptype=in_ptype) + data = None # Update port out_port.rotate_around((0, 0), pi + port_rot) out_port.translate(port.offset) - step = RenderStep('L', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) + if not self._dead: + step = RenderStep('L', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) self.pattern.ports[portspec] = out_port.copy() self._log_port_update(portspec) @@ -475,8 +491,7 @@ class RenderPather(PatherMixin): LibraryError if no valid name could be picked for the pattern. """ if self._dead: - logger.error('Skipping pathS() since device is dead') - return self + logger.warning('Skipping geometry for pathS() since device is dead') port = self.pattern[portspec] in_ptype = port.ptype @@ -492,22 +507,38 @@ class RenderPather(PatherMixin): # Fall back to drawing two L-bends ccw0 = jog > 0 kwargs_no_out = (kwargs | {'out_ptype': None}) - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes - jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] - t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) - jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] + try: + t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes + jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] + t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) + jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - return self + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + return self + except (BuildError, NotImplementedError): + if not self._dead: + raise + # Fall through to dummy extension below + except BuildError: + if not self._dead: + raise + # Fall through to dummy extension below - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - step = RenderStep('S', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - self.pattern.ports[portspec] = out_port.copy() - self._log_port_update(portspec) + if self._dead: + logger.warning("Tool planning failed for dead pather. Using dummy extension.") + out_port = Port((length, jog), rotation=pi, ptype=in_ptype) + data = None + + if out_port is not None: + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + if not self._dead: + step = RenderStep('S', tool, port.copy(), out_port.copy(), data) + self.paths[portspec].append(step) + self.pattern.ports[portspec] = out_port.copy() + self._log_port_update(portspec) if plug_into is not None: self.plugged({portspec: plug_into}) diff --git a/masque/pattern.py b/masque/pattern.py index 05a8962..04780ec 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1101,6 +1101,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map: dict[str, str | None] | None = None, skip_port_check: bool = False, append: bool = False, + skip_geometry: bool = False, ) -> Self: """ Instantiate or append the pattern `other` into the current pattern, adding its @@ -1132,6 +1133,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + skip_geometry: If `True`, only ports are added; geometry is skipped. Returns: self @@ -1165,6 +1167,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): self.ports[name] = pp self._log_port_update(name) + if skip_geometry: + return self + if append: if isinstance(other, Abstract): raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') @@ -1222,6 +1227,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): set_rotation: bool | None = None, append: bool = False, ok_connections: Iterable[tuple[str, str]] = (), + skip_geometry: bool = False, ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1276,6 +1282,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. + skip_geometry: If `True`, only ports are updated; geometry is skipped. Returns: self @@ -1308,13 +1315,32 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out = {out_port_name: next(iter(map_in.keys()))} self.check_ports(other.ports.keys(), map_in, map_out) - translation, rotation, pivot = self.find_transform( - other, - map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, - ) + try: + translation, rotation, pivot = self.find_transform( + other, + map_in, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, + ) + except PortError: + if not skip_geometry: + raise + logger.warning("Port transform failed for dead device. Using dummy transform.") + if map_in: + ki, vi = next(iter(map_in.items())) + s_port = self.ports[ki] + o_port = other.ports[vi].deepcopy() + if mirrored: + o_port.mirror() + o_port.offset[1] *= -1 + translation = s_port.offset - o_port.offset + rotation = (s_port.rotation - o_port.rotation - pi) if (s_port.rotation is not None and o_port.rotation is not None) else 0 + pivot = o_port.offset + else: + translation = numpy.zeros(2) + rotation = 0.0 + pivot = numpy.zeros(2) # get rid of plugged ports for ki, vi in map_in.items(): @@ -1323,7 +1349,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_out[vi] = None if isinstance(other, Pattern): - assert append, 'Got a name (not an abstract) but was asked to reference (not append)' + assert append or skip_geometry, 'Got a name (not an abstract) but was asked to reference (not append)' self.place( other, @@ -1334,6 +1360,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = map_out, skip_port_check = True, append = append, + skip_geometry = skip_geometry, ) return self diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 1b67c65..bfbd1df 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -73,3 +73,57 @@ def test_builder_set_dead() -> None: b.place("sub") assert not b.pattern.has_refs() + + +def test_builder_dead_ports() -> None: + lib = Library() + pat = Pattern() + pat.ports['A'] = Port((0, 0), 0) + b = Builder(lib, pattern=pat) + b.set_dead() + + # Attempt to plug a device where ports don't line up + # A has rotation 0, C has rotation 0. plug() expects opposing rotations (pi difference). + other = Pattern(ports={'C': Port((10, 10), 0), 'D': Port((20, 20), 0)}) + + # This should NOT raise PortError because b is dead + b.plug(other, map_in={'A': 'C'}, map_out={'D': 'B'}) + + # Port A should be removed, and Port B (renamed from D) should be added + assert 'A' not in b.ports + assert 'B' in b.ports + + # Verify geometry was not added + assert not b.pattern.has_refs() + assert not b.pattern.has_shapes() + + +def test_dead_plug_best_effort() -> None: + lib = Library() + pat = Pattern() + pat.ports['A'] = Port((0, 0), 0) + b = Builder(lib, pattern=pat) + b.set_dead() + + # Device with multiple ports, none of which line up correctly + other = Pattern(ports={ + 'P1': Port((10, 10), 0), # Wrong rotation (0 instead of pi) + 'P2': Port((20, 20), pi) # Correct rotation but wrong offset + }) + + # Try to plug. find_transform will fail. + # It should fall back to aligning the first pair ('A' and 'P1'). + b.plug(other, map_in={'A': 'P1'}, map_out={'P2': 'B'}) + + assert 'A' not in b.ports + assert 'B' in b.ports + + # Dummy transform aligns A (0,0) with P1 (10,10) + # A rotation 0, P1 rotation 0 -> rotation = (0 - 0 - pi) = -pi + # P2 (20,20) rotation pi: + # 1. Translate P2 so P1 is at origin: (20,20) - (10,10) = (10,10) + # 2. Rotate (10,10) by -pi: (-10,-10) + # 3. Translate by s_port.offset (0,0): (-10,-10) + assert_allclose(b.ports['B'].offset, [-10, -10], atol=1e-10) + # P2 rot pi + transform rot -pi = 0 + assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) diff --git a/masque/test/test_pather.py b/masque/test/test_pather.py index e1d28d8..336458f 100644 --- a/masque/test/test_pather.py +++ b/masque/test/test_pather.py @@ -81,3 +81,25 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N # pi/2 (North) + CCW (90 deg) -> 0 (East)? # Actual behavior results in pi (West). assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) + + +def test_pather_dead_ports() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=1) + p = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) + p.set_dead() + + # Path with negative length (impossible for PathTool, would normally raise BuildError) + p.path("in", None, -10) + + # Port 'in' should be updated by dummy extension despite tool failure + # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. + assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10) + + # Downstream path should work correctly using the dummy port location + p.path("in", None, 20) + # 10 + (-20) = -10 + assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) + + # Verify no geometry + assert not p.pattern.has_shapes() diff --git a/masque/test/test_renderpather.py b/masque/test/test_renderpather.py index b843066..3948214 100644 --- a/masque/test/test_renderpather.py +++ b/masque/test/test_renderpather.py @@ -73,3 +73,23 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar # Different tools should cause different batches/shapes assert len(rp.pattern.shapes[(1, 0)]) == 1 assert len(rp.pattern.shapes[(2, 0)]) == 1 + + +def test_renderpather_dead_ports() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=1) + rp = RenderPather(lib, ports={"in": Port((0, 0), 0)}, tools=tool) + rp.set_dead() + + # Impossible path + rp.path("in", None, -10) + + # port_rot=0, forward is -x. path(-10) means moving -10 in -x direction -> +10 in x. + assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) + + # Verify no render steps were added + assert len(rp.paths["in"]) == 0 + + # Verify no geometry + rp.render() + assert not rp.pattern.has_shapes() From 5d040061f41b77d8149ef0d37acde0a4be716059 Mon Sep 17 00:00:00 2001 From: jan Date: Mon, 16 Feb 2026 13:57:16 -0800 Subject: [PATCH 3/3] [set_dead] improve docs --- masque/builder/builder.py | 19 ++++++++++++++++--- masque/builder/pather.py | 12 ++++++++++++ masque/builder/renderpather.py | 12 ++++++++++++ masque/pattern.py | 11 +++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/masque/builder/builder.py b/masque/builder/builder.py index 3c39710..40ea109 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -275,6 +275,10 @@ class Builder(PortList): Returns: self + Note: + If the builder is 'dead' (see `set_dead()`), geometry generation is + skipped but ports are still updated. + Raises: `PortError` if any ports specified in `map_in` or `map_out` do not exist in `self.ports` or `other_names`. @@ -350,6 +354,10 @@ class Builder(PortList): Returns: self + Note: + If the builder is 'dead' (see `set_dead()`), geometry generation is + skipped but ports are still updated. + Raises: `PortError` if any ports specified in `map_in` or `map_out` do not exist in `self.ports` or `other.ports`. @@ -425,13 +433,18 @@ class Builder(PortList): def set_dead(self) -> Self: """ - Disallows further changes through `plug()` or `place()`. + Suppresses geometry generation for subsequent `plug()` and `place()` + operations. Unlike a complete skip, the port state is still tracked + and updated, using 'best-effort' fallbacks for impossible transforms. + This allows a layout script to execute through problematic sections + while maintaining valid port references for downstream code. + This is meant for debugging: ``` dev.plug(a, ...) dev.set_dead() # added for debug purposes - dev.plug(b, ...) # usually raises an error, but now skipped - dev.plug(c, ...) # also skipped + dev.plug(b, ...) # usually raises an error, but now uses fallback port update + dev.plug(c, ...) # also updated via fallback dev.pattern.visualize() # shows the device as of the set_dead() call ``` diff --git a/masque/builder/pather.py b/masque/builder/pather.py index d58b282..c23e240 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -285,6 +285,12 @@ class Pather(Builder, PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + Raises: BuildError if `distance` is too small to fit the bend (if a bend is present). LibraryError if no valid name could be picked for the pattern. @@ -359,6 +365,12 @@ class Pather(Builder, PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + Raises: BuildError if `distance` is too small to fit the s-bend (for nonzero jog). LibraryError if no valid name could be picked for the pattern. diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index 863f3f1..ca8cf8a 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -408,6 +408,12 @@ class RenderPather(PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + Raises: BuildError if `distance` is too small to fit the bend (if a bend is present). LibraryError if no valid name could be picked for the pattern. @@ -486,6 +492,12 @@ class RenderPather(PatherMixin): Returns: self + Note: + If the builder is 'dead', this operation will still attempt to update + the target port's location. If the pathing tool fails (e.g. due to an + impossible length), a dummy linear extension is used to maintain port + consistency for downstream operations. + Raises: BuildError if `distance` is too small to fit the s-bend (for nonzero jog). LibraryError if no valid name could be picked for the pattern. diff --git a/masque/pattern.py b/masque/pattern.py index 04780ec..d7bbc01 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1133,7 +1133,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). - skip_geometry: If `True`, only ports are added; geometry is skipped. + skip_geometry: If `True`, the operation only updates the port list and + skips adding any geometry (shapes, labels, or references). This + allows the pattern assembly to proceed for port-tracking purposes + even when layout generation is suppressed. Returns: self @@ -1282,7 +1285,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): any other ptypte. Non-allowed ptype connections will emit a warning. Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. - skip_geometry: If `True`, only ports are updated; geometry is skipped. + skip_geometry: If `True`, only ports are updated and geometry is + skipped. If a valid transform cannot be found (e.g. due to + misaligned ports), a 'best-effort' dummy transform is used + to ensure new ports are still added at approximate locations, + allowing downstream routing to continue. Returns: self