[RenderPather / PathTool] Improve support for port transformations

So that moving a port while in the middle of planning a path doesn't
break everything
This commit is contained in:
jan 2026-03-06 13:07:06 -08:00
commit 16875e9cd6
2 changed files with 121 additions and 20 deletions

View file

@ -9,8 +9,9 @@ from collections import defaultdict
from functools import wraps from functools import wraps
from pprint import pformat from pprint import pformat
import numpy
from numpy import pi from numpy import pi
from numpy.typing import ArrayLike from numpy.typing import ArrayLike, NDArray
from ..pattern import Pattern from ..pattern import Pattern
from ..library import ILibrary, TreeView from ..library import ILibrary, TreeView
@ -579,22 +580,54 @@ class RenderPather(PatherMixin):
def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None:
assert batch[0].tool is not None assert batch[0].tool is not None
name = lib << batch[0].tool.render(batch, port_names=tool_port_names) # Tools render in local space (first port at 0,0, rotation 0).
pat.ports[portspec] = batch[0].start_port.copy() tree = batch[0].tool.render(batch, port_names=tool_port_names)
actual_in, actual_out = tool_port_names
name = lib << tree
# To plug the segment at its intended location, we create a
# 'stationary' port in our temporary pattern that matches
# the batch's planned start.
if portspec in pat.ports:
del pat.ports[portspec]
stationary_port = batch[0].start_port.copy()
pat.ports[portspec] = stationary_port
if append: if append:
pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append) # pat.plug() translates and rotates the tool's local output to the start port.
del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened pat.plug(lib[name], {portspec: actual_in}, append=append)
del lib[name]
else: else:
pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) pat.plug(lib.abstract(name), {portspec: actual_in}, append=append)
# Rename output back to portspec for the next batch.
if portspec not in pat.ports and actual_out in pat.ports:
pat.rename_ports({actual_out: portspec}, overwrite=True)
for portspec, steps in self.paths.items(): for portspec, steps in self.paths.items():
if not steps:
continue
batch: list[RenderStep] = [] batch: list[RenderStep] = []
# Initialize continuity check with the start of the entire path.
prev_end = steps[0].start_port
for step in steps: for step in steps:
appendable_op = step.opcode in ('L', 'S', 'U') appendable_op = step.opcode in ('L', 'S', 'U')
same_tool = batch and step.tool == batch[0].tool same_tool = batch and step.tool == batch[0].tool
# Check continuity with tolerance
offsets_match = numpy.allclose(step.start_port.offset, prev_end.offset)
rotations_match = (step.start_port.rotation is None and prev_end.rotation is None) or (
step.start_port.rotation is not None and prev_end.rotation is not None and
numpy.isclose(step.start_port.rotation, prev_end.rotation)
)
continuous = offsets_match and rotations_match
# If we can't continue a batch, render it # If we can't continue a batch, render it
if batch and (not appendable_op or not same_tool): if batch and (not appendable_op or not same_tool or not continuous):
render_batch(portspec, batch, append) render_batch(portspec, batch, append)
batch = [] batch = []
@ -603,8 +636,14 @@ class RenderPather(PatherMixin):
batch.append(step) batch.append(step)
# Opcodes which break the batch go below this line # Opcodes which break the batch go below this line
if not appendable_op and portspec in pat.ports: if not appendable_op:
if portspec in pat.ports:
del pat.ports[portspec] del pat.ports[portspec]
# Plugged ports should be tracked
if step.opcode == 'P' and portspec in pat.ports:
del pat.ports[portspec]
prev_end = step.end_port
#If the last batch didn't end yet #If the last batch didn't end yet
if batch: if batch:
@ -626,7 +665,11 @@ class RenderPather(PatherMixin):
Returns: Returns:
self self
""" """
self.pattern.translate_elements(offset) offset_arr: NDArray[numpy.float64] = numpy.asarray(offset)
self.pattern.translate_elements(offset_arr)
for steps in self.paths.values():
for i, step in enumerate(steps):
steps[i] = step.transformed(offset_arr, 0, numpy.zeros(2))
return self return self
def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: def rotate_around(self, pivot: ArrayLike, angle: float) -> Self:
@ -640,7 +683,11 @@ class RenderPather(PatherMixin):
Returns: Returns:
self self
""" """
self.pattern.rotate_around(pivot, angle) pivot_arr: NDArray[numpy.float64] = numpy.asarray(pivot)
self.pattern.rotate_around(pivot_arr, angle)
for steps in self.paths.values():
for i, step in enumerate(steps):
steps[i] = step.transformed(numpy.zeros(2), angle, pivot_arr)
return self return self
def mirror(self, axis: int) -> Self: def mirror(self, axis: int) -> Self:
@ -654,6 +701,9 @@ class RenderPather(PatherMixin):
self self
""" """
self.pattern.mirror(axis) self.pattern.mirror(axis)
for steps in self.paths.values():
for i, step in enumerate(steps):
steps[i] = step.mirrored(axis)
return self return self
def set_dead(self) -> Self: def set_dead(self) -> Self:

View file

@ -47,6 +47,43 @@ class RenderStep:
if self.opcode != 'P' and self.tool is None: if self.opcode != 'P' and self.tool is None:
raise BuildError('Got tool=None but the opcode is not "P"') raise BuildError('Got tool=None but the opcode is not "P"')
def transformed(self, translation: NDArray[numpy.float64], rotation: float, pivot: NDArray[numpy.float64]) -> 'RenderStep':
"""
Return a new RenderStep with transformed start and end ports.
"""
new_start = self.start_port.copy()
new_end = self.end_port.copy()
for pp in (new_start, new_end):
pp.rotate_around(pivot, rotation)
pp.translate(translation)
return RenderStep(
opcode = self.opcode,
tool = self.tool,
start_port = new_start,
end_port = new_end,
data = self.data,
)
def mirrored(self, axis: int) -> 'RenderStep':
"""
Return a new RenderStep with mirrored start and end ports.
"""
new_start = self.start_port.copy()
new_end = self.end_port.copy()
new_start.mirror(axis)
new_end.mirror(axis)
return RenderStep(
opcode = self.opcode,
tool = self.tool,
start_port = new_start,
end_port = new_end,
data = self.data,
)
class Tool: class Tool:
""" """
@ -991,29 +1028,43 @@ class PathTool(Tool, metaclass=ABCMeta):
**kwargs, # noqa: ARG002 (unused) **kwargs, # noqa: ARG002 (unused)
) -> ILibrary: ) -> ILibrary:
path_vertices = [batch[0].start_port.offset] # Transform the batch so the first port is local (at 0,0) but retains its global rotation.
for step in batch: # This allows the path to be rendered with its original orientation, simplified by
# translation to the origin. RenderPather.render will handle the final placement
# (including rotation alignment) via `pat.plug`.
first_port = batch[0].start_port
translation = -first_port.offset
rotation = 0
pivot = first_port.offset
# Localize the batch for rendering
local_batch = [step.transformed(translation, rotation, pivot) for step in batch]
path_vertices = [local_batch[0].start_port.offset]
for step in local_batch:
assert step.tool == self assert step.tool == self
port_rot = step.start_port.rotation port_rot = step.start_port.rotation
assert port_rot is not None # Masque convention: Port rotation points INTO the device.
# So the direction of travel for the path is AWAY from the port, i.e., port_rot + pi.
if step.opcode == 'L': if step.opcode == 'L':
length, bend_run = step.data
length, _ = step.data
dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0)
#path_vertices.append(step.start_port.offset)
path_vertices.append(step.start_port.offset + dxy) path_vertices.append(step.start_port.offset + dxy)
else: else:
raise BuildError(f'Unrecognized opcode "{step.opcode}"') raise BuildError(f'Unrecognized opcode "{step.opcode}"')
if (path_vertices[-1] != batch[-1].end_port.offset).any(): # Check if the last vertex added is already at the end port location
if not numpy.allclose(path_vertices[-1], local_batch[-1].end_port.offset):
# If the path ends in a bend, we need to add the final vertex # If the path ends in a bend, we need to add the final vertex
path_vertices.append(batch[-1].end_port.offset) path_vertices.append(local_batch[-1].end_port.offset)
tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path')
pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.path(layer=self.layer, width=self.width, vertices=path_vertices)
pat.ports = { pat.ports = {
port_names[0]: batch[0].start_port.copy().rotate(pi), port_names[0]: local_batch[0].start_port.copy().rotate(pi),
port_names[1]: batch[-1].end_port.copy().rotate(pi), port_names[1]: local_batch[-1].end_port.copy().rotate(pi),
} }
return tree return tree