Compare commits

...

3 commits

8 changed files with 355 additions and 75 deletions

View file

@ -275,6 +275,10 @@ class Builder(PortList):
Returns: Returns:
self self
Note:
If the builder is 'dead' (see `set_dead()`), geometry generation is
skipped but ports are still updated.
Raises: Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not `PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other_names`. exist in `self.ports` or `other_names`.
@ -284,8 +288,7 @@ class Builder(PortList):
do not line up) do not line up)
""" """
if self._dead: if self._dead:
logger.error('Skipping plug() since device is dead') logger.warning('Skipping geometry for plug() since device is dead')
return self
if not isinstance(other, str | Abstract | Pattern): if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it # We got a Tree; add it into self.library and grab an Abstract for it
@ -305,6 +308,7 @@ class Builder(PortList):
set_rotation = set_rotation, set_rotation = set_rotation,
append = append, append = append,
ok_connections = ok_connections, ok_connections = ok_connections,
skip_geometry = self._dead,
) )
return self return self
@ -350,6 +354,10 @@ class Builder(PortList):
Returns: Returns:
self self
Note:
If the builder is 'dead' (see `set_dead()`), geometry generation is
skipped but ports are still updated.
Raises: Raises:
`PortError` if any ports specified in `map_in` or `map_out` do not `PortError` if any ports specified in `map_in` or `map_out` do not
exist in `self.ports` or `other.ports`. exist in `self.ports` or `other.ports`.
@ -357,8 +365,7 @@ class Builder(PortList):
are applied. are applied.
""" """
if self._dead: if self._dead:
logger.error('Skipping place() since device is dead') logger.warning('Skipping geometry for place() since device is dead')
return self
if not isinstance(other, str | Abstract | Pattern): if not isinstance(other, str | Abstract | Pattern):
# We got a Tree; add it into self.library and grab an Abstract for it # We got a Tree; add it into self.library and grab an Abstract for it
@ -378,6 +385,7 @@ class Builder(PortList):
port_map = port_map, port_map = port_map,
skip_port_check = skip_port_check, skip_port_check = skip_port_check,
append = append, append = append,
skip_geometry = self._dead,
) )
return self return self
@ -425,13 +433,18 @@ class Builder(PortList):
def set_dead(self) -> Self: 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: This is meant for debugging:
``` ```
dev.plug(a, ...) dev.plug(a, ...)
dev.set_dead() # added for debug purposes dev.set_dead() # added for debug purposes
dev.plug(b, ...) # usually raises an error, but now skipped dev.plug(b, ...) # usually raises an error, but now uses fallback port update
dev.plug(c, ...) # also skipped dev.plug(c, ...) # also updated via fallback
dev.pattern.visualize() # shows the device as of the set_dead() call dev.pattern.visualize() # shows the device as of the set_dead() call
``` ```

View file

@ -7,6 +7,8 @@ import copy
import logging import logging
from pprint import pformat from pprint import pformat
from numpy import pi
from ..pattern import Pattern from ..pattern import Pattern
from ..library import ILibrary from ..library import ILibrary
from ..error import BuildError from ..error import BuildError
@ -283,19 +285,47 @@ class Pather(Builder, PatherMixin):
Returns: Returns:
self 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: Raises:
BuildError if `distance` is too small to fit the bend (if a bend is present). 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. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping path() since device is dead') logger.warning('Skipping geometry for path() since device is dead')
return self
tool_port_names = ('A', 'B') tool_port_names = ('A', 'B')
tool = self.tools.get(portspec, self.tools[None]) tool = self.tools.get(portspec, self.tools[None])
in_ptype = self.pattern[portspec].ptype in_ptype = self.pattern[portspec].ptype
try:
tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) 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 tname = self.library << tree
if plug_into is not None: if plug_into is not None:
output = {plug_into: tool_port_names[1]} output = {plug_into: tool_port_names[1]}
@ -335,13 +365,18 @@ class Pather(Builder, PatherMixin):
Returns: Returns:
self 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: Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog). 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. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping pathS() since device is dead') logger.warning('Skipping geometry for pathS() since device is dead')
return self
tool_port_names = ('A', 'B') tool_port_names = ('A', 'B')
@ -353,6 +388,7 @@ class Pather(Builder, PatherMixin):
# Fall back to drawing two L-bends # Fall back to drawing two L-bends
ccw0 = jog > 0 ccw0 = jog > 0
kwargs_no_out = kwargs | {'out_ptype': None} kwargs_no_out = kwargs | {'out_ptype': None}
try:
t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) 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() t_pat0 = t_tree0.top_pattern()
(_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]])
@ -364,6 +400,28 @@ class Pather(Builder, PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self 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 tname = self.library << tree
if plug_into is not None: if plug_into is not None:

View file

@ -253,8 +253,7 @@ class RenderPather(PatherMixin):
do not line up) do not line up)
""" """
if self._dead: if self._dead:
logger.error('Skipping plug() since device is dead') logger.warning('Skipping geometry for plug() since device is dead')
return self
other_tgt: Pattern | Abstract other_tgt: Pattern | Abstract
if isinstance(other, str): if isinstance(other, str):
@ -262,6 +261,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):
other_tgt = self.library[other.name] other_tgt = self.library[other.name]
if not self._dead:
# get rid of plugged ports # get rid of plugged ports
for kk in map_in: for kk in map_in:
if kk in self.paths: if kk in self.paths:
@ -284,6 +284,7 @@ class RenderPather(PatherMixin):
set_rotation = set_rotation, set_rotation = set_rotation,
append = append, append = append,
ok_connections = ok_connections, ok_connections = ok_connections,
skip_geometry = self._dead,
) )
return self return self
@ -334,8 +335,7 @@ class RenderPather(PatherMixin):
are applied. are applied.
""" """
if self._dead: if self._dead:
logger.error('Skipping place() since device is dead') logger.warning('Skipping geometry for place() since device is dead')
return self
other_tgt: Pattern | Abstract other_tgt: Pattern | Abstract
if isinstance(other, str): if isinstance(other, str):
@ -343,6 +343,7 @@ class RenderPather(PatherMixin):
if append and isinstance(other, Abstract): if append and isinstance(other, Abstract):
other_tgt = self.library[other.name] other_tgt = self.library[other.name]
if not self._dead:
for name, port in other_tgt.ports.items(): for name, port in other_tgt.ports.items():
new_name = port_map.get(name, name) if port_map is not None else name 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: if new_name is not None and new_name in self.paths:
@ -357,6 +358,7 @@ class RenderPather(PatherMixin):
port_map = port_map, port_map = port_map,
skip_port_check = skip_port_check, skip_port_check = skip_port_check,
append = append, append = append,
skip_geometry = self._dead,
) )
return self return self
@ -365,6 +367,7 @@ class RenderPather(PatherMixin):
self, self,
connections: dict[str, str], connections: dict[str, str],
) -> Self: ) -> Self:
if not self._dead:
for aa, bb in connections.items(): for aa, bb in connections.items():
porta = self.ports[aa] porta = self.ports[aa]
portb = self.ports[bb] portb = self.ports[bb]
@ -405,13 +408,18 @@ class RenderPather(PatherMixin):
Returns: Returns:
self 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: Raises:
BuildError if `distance` is too small to fit the bend (if a bend is present). 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. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping path() since device is dead') logger.warning('Skipping geometry for path() since device is dead')
return self
port = self.pattern[portspec] port = self.pattern[portspec]
in_ptype = port.ptype in_ptype = port.ptype
@ -420,16 +428,31 @@ class RenderPather(PatherMixin):
tool = self.tools.get(portspec, self.tools[None]) 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 # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype
try:
out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) 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 # Update port
out_port.rotate_around((0, 0), pi + port_rot) out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset) out_port.translate(port.offset)
if not self._dead:
step = RenderStep('L', tool, port.copy(), out_port.copy(), data) step = RenderStep('L', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step) self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy() self.pattern.ports[portspec] = out_port.copy()
self._log_port_update(portspec)
if plug_into is not None: if plug_into is not None:
self.plugged({portspec: plug_into}) self.plugged({portspec: plug_into})
@ -469,13 +492,18 @@ class RenderPather(PatherMixin):
Returns: Returns:
self 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: Raises:
BuildError if `distance` is too small to fit the s-bend (for nonzero jog). 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. LibraryError if no valid name could be picked for the pattern.
""" """
if self._dead: if self._dead:
logger.error('Skipping pathS() since device is dead') logger.warning('Skipping geometry for pathS() since device is dead')
return self
port = self.pattern[portspec] port = self.pattern[portspec]
in_ptype = port.ptype in_ptype = port.ptype
@ -491,6 +519,7 @@ class RenderPather(PatherMixin):
# Fall back to drawing two L-bends # Fall back to drawing two L-bends
ccw0 = jog > 0 ccw0 = jog > 0
kwargs_no_out = (kwargs | {'out_ptype': None}) kwargs_no_out = (kwargs | {'out_ptype': None})
try:
t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes 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] 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) t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs)
@ -500,12 +529,28 @@ class RenderPather(PatherMixin):
self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out)
self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug)
return self 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 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.rotate_around((0, 0), pi + port_rot)
out_port.translate(port.offset) out_port.translate(port.offset)
if not self._dead:
step = RenderStep('S', tool, port.copy(), out_port.copy(), data) step = RenderStep('S', tool, port.copy(), out_port.copy(), data)
self.paths[portspec].append(step) self.paths[portspec].append(step)
self.pattern.ports[portspec] = out_port.copy() self.pattern.ports[portspec] = out_port.copy()
self._log_port_update(portspec)
if plug_into is not None: if plug_into is not None:
self.plugged({portspec: plug_into}) self.plugged({portspec: plug_into})

View file

@ -638,6 +638,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
""" """
for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()): for entry in chain(chain_elements(self.shapes, self.labels, self.refs), self.ports.values()):
cast('Positionable', entry).translate(offset) cast('Positionable', entry).translate(offset)
self._log_bulk_update(f"translate({offset})")
return self return self
def scale_elements(self, c: float) -> Self: def scale_elements(self, c: float) -> Self:
@ -705,6 +706,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
self.rotate_elements(rotation) self.rotate_elements(rotation)
self.rotate_element_centers(rotation) self.rotate_element_centers(rotation)
self.translate_elements(+pivot) self.translate_elements(+pivot)
self._log_bulk_update(f"rotate_around({pivot}, {rotation})")
return self return self
def rotate_element_centers(self, rotation: float) -> 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()): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()):
cast('Flippable', entry).flip_across(axis=axis) cast('Flippable', entry).flip_across(axis=axis)
self._log_bulk_update(f"mirror({axis})")
return self return self
def copy(self) -> Self: def copy(self) -> Self:
@ -1098,6 +1101,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map: dict[str, str | None] | None = None, port_map: dict[str, str | None] | None = None,
skip_port_check: bool = False, skip_port_check: bool = False,
append: bool = False, append: bool = False,
skip_geometry: bool = False,
) -> Self: ) -> Self:
""" """
Instantiate or append the pattern `other` into the current pattern, adding its Instantiate or append the pattern `other` into the current pattern, adding its
@ -1129,6 +1133,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
append: If `True`, `other` is appended instead of being referenced. append: If `True`, `other` is appended instead of being referenced.
Note that this does not flatten `other`, so its refs will still Note that this does not flatten `other`, so its refs will still
be refs (now inside `self`). be refs (now inside `self`).
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: Returns:
self self
@ -1160,6 +1168,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
pp.rotate_around(pivot, rotation) pp.rotate_around(pivot, rotation)
pp.translate(offset) pp.translate(offset)
self.ports[name] = pp self.ports[name] = pp
self._log_port_update(name)
if skip_geometry:
return self
if append: if append:
if isinstance(other, Abstract): if isinstance(other, Abstract):
@ -1218,6 +1230,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation: bool | None = None, set_rotation: bool | None = None,
append: bool = False, append: bool = False,
ok_connections: Iterable[tuple[str, str]] = (), ok_connections: Iterable[tuple[str, str]] = (),
skip_geometry: bool = False,
) -> Self: ) -> Self:
""" """
Instantiate or append a pattern into the current pattern, connecting Instantiate or append a pattern into the current pattern, connecting
@ -1272,6 +1285,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
any other ptypte. Non-allowed ptype connections will emit a any other ptypte. Non-allowed ptype connections will emit a
warning. Order is ignored, i.e. `(a, b)` is equivalent to warning. Order is ignored, i.e. `(a, b)` is equivalent to
`(b, a)`. `(b, a)`.
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: Returns:
self self
@ -1304,6 +1322,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
map_out = {out_port_name: next(iter(map_in.keys()))} map_out = {out_port_name: next(iter(map_in.keys()))}
self.check_ports(other.ports.keys(), map_in, map_out) self.check_ports(other.ports.keys(), map_in, map_out)
try:
translation, rotation, pivot = self.find_transform( translation, rotation, pivot = self.find_transform(
other, other,
map_in, map_in,
@ -1311,14 +1330,33 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
set_rotation = set_rotation, set_rotation = set_rotation,
ok_connections = ok_connections, 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 # get rid of plugged ports
for ki, vi in map_in.items(): for ki, vi in map_in.items():
del self.ports[ki] del self.ports[ki]
self._log_port_removal(ki)
map_out[vi] = None map_out[vi] = None
if isinstance(other, Pattern): 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( self.place(
other, other,
@ -1329,6 +1367,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable):
port_map = map_out, port_map = map_out,
skip_port_check = True, skip_port_check = True,
append = append, append = append,
skip_geometry = skip_geometry,
) )
return self return self

View file

@ -17,6 +17,7 @@ from .error import PortError, format_stacktrace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
port_logger = logging.getLogger('masque.ports')
@functools.total_ordering @functools.total_ordering
@ -207,6 +208,19 @@ class PortList(metaclass=ABCMeta):
def ports(self, value: dict[str, Port]) -> None: def ports(self, value: dict[str, Port]) -> None:
pass 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 @overload
def __getitem__(self, key: str) -> Port: def __getitem__(self, key: str) -> Port:
pass pass
@ -260,6 +274,7 @@ class PortList(metaclass=ABCMeta):
raise PortError(f'Port {name} already exists.') raise PortError(f'Port {name} already exists.')
assert name not in self.ports assert name not in self.ports
self.ports[name] = value self.ports[name] = value
self._log_port_update(name)
return self return self
def rename_ports( def rename_ports(
@ -286,11 +301,22 @@ class PortList(metaclass=ABCMeta):
if duplicates: if duplicates:
raise PortError(f'Unrenamed ports would be overwritten: {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()} renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()}
if None in renamed: if None in renamed:
del renamed[None] del renamed[None]
self.ports.update(renamed) # type: ignore self.ports.update(renamed) # type: ignore
for vv in mapping.values():
if vv is not None:
self._log_port_update(vv)
return self return self
def add_port_pair( def add_port_pair(
@ -319,6 +345,8 @@ class PortList(metaclass=ABCMeta):
} }
self.check_ports(names) self.check_ports(names)
self.ports.update(new_ports) self.ports.update(new_ports)
self._log_port_update(names[0])
self._log_port_update(names[1])
return self return self
def plugged( def plugged(
@ -388,6 +416,7 @@ class PortList(metaclass=ABCMeta):
for pp in chain(a_names, b_names): for pp in chain(a_names, b_names):
del self.ports[pp] del self.ports[pp]
self._log_port_removal(pp)
return self return self
def check_ports( def check_ports(

View file

@ -73,3 +73,57 @@ def test_builder_set_dead() -> None:
b.place("sub") b.place("sub")
assert not b.pattern.has_refs() 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)

View file

@ -81,3 +81,25 @@ def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> N
# pi/2 (North) + CCW (90 deg) -> 0 (East)? # pi/2 (North) + CCW (90 deg) -> 0 (East)?
# Actual behavior results in pi (West). # Actual behavior results in pi (West).
assert_allclose(p.ports["start"].rotation, pi, atol=1e-10) 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()

View file

@ -73,3 +73,23 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar
# Different tools should cause different batches/shapes # Different tools should cause different batches/shapes
assert len(rp.pattern.shapes[(1, 0)]) == 1 assert len(rp.pattern.shapes[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 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()