diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py index fae975a..8104a50 100644 --- a/masque/builder/renderpather.py +++ b/masque/builder/renderpather.py @@ -9,8 +9,9 @@ from collections import defaultdict from functools import wraps from pprint import pformat +import numpy from numpy import pi -from numpy.typing import ArrayLike +from numpy.typing import ArrayLike, NDArray from ..pattern import Pattern from ..library import ILibrary, TreeView @@ -579,22 +580,54 @@ class RenderPather(PatherMixin): def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: assert batch[0].tool is not None - name = lib << batch[0].tool.render(batch, port_names=tool_port_names) - pat.ports[portspec] = batch[0].start_port.copy() + # Tools render in local space (first port at 0,0, rotation 0). + 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: - pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append) - del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened + # pat.plug() translates and rotates the tool's local output to the start port. + pat.plug(lib[name], {portspec: actual_in}, append=append) + del lib[name] 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(): + if not steps: + continue + batch: list[RenderStep] = [] + # Initialize continuity check with the start of the entire path. + prev_end = steps[0].start_port + for step in steps: appendable_op = step.opcode in ('L', 'S', 'U') 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 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) batch = [] @@ -603,8 +636,14 @@ class RenderPather(PatherMixin): batch.append(step) # Opcodes which break the batch go below this line - if not appendable_op and portspec in pat.ports: - del pat.ports[portspec] + if not appendable_op: + if portspec in pat.ports: + 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 batch: @@ -626,7 +665,11 @@ class RenderPather(PatherMixin): Returns: 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 def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: @@ -640,7 +683,11 @@ class RenderPather(PatherMixin): Returns: 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 def mirror(self, axis: int) -> Self: @@ -654,6 +701,9 @@ class RenderPather(PatherMixin): self """ self.pattern.mirror(axis) + for steps in self.paths.values(): + for i, step in enumerate(steps): + steps[i] = step.mirrored(axis) return self def set_dead(self) -> Self: diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 27bc27e..3e6616a 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -47,6 +47,43 @@ class RenderStep: if self.opcode != 'P' and self.tool is None: 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: """ @@ -991,29 +1028,43 @@ class PathTool(Tool, metaclass=ABCMeta): **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: - path_vertices = [batch[0].start_port.offset] - for step in batch: + # Transform the batch so the first port is local (at 0,0) but retains its global rotation. + # 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 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': - length, bend_run = step.data + + length, _ = step.data dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) - #path_vertices.append(step.start_port.offset) path_vertices.append(step.start_port.offset + dxy) else: 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 - 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') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { - port_names[0]: batch[0].start_port.copy().rotate(pi), - port_names[1]: batch[-1].end_port.copy().rotate(pi), + port_names[0]: local_batch[0].start_port.copy().rotate(pi), + port_names[1]: local_batch[-1].end_port.copy().rotate(pi), } return tree