diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..2d94de9 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,426 @@ +# Migration Guide + +This guide covers changes between the git tag `release` and the current tree. +At `release`, `masque.__version__` was `3.3`; the current tree reports `3.4`. + +Most downstream changes are in `masque/builder/*`, but there are a few other +API changes that may require code updates. + +## Routing API: renamed and consolidated + +The routing helpers were consolidated into a single implementation in +`masque/builder/pather.py`. + +The biggest migration point is that the old routing verbs were renamed: + +| Old API | New API | +| --- | --- | +| `Pather.path(...)` | `Pather.trace(...)` | +| `Pather.path_to(...)` | `Pather.trace_to(...)` | +| `Pather.mpath(...)` | `Pather.trace(...)` / `Pather.trace_to(...)` with multiple ports | +| `Pather.pathS(...)` | `Pather.jog(...)` | +| `Pather.pathU(...)` | `Pather.uturn(...)` | +| `Pather.path_into(...)` | `Pather.trace_into(...)` | +| `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` | +| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` | +| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` | +| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` | +| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` | +| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` | +| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` | +| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` | + +There are also new convenience wrappers: + +- `straight(...)` for `trace_to(..., ccw=None, ...)` +- `ccw(...)` for `trace_to(..., ccw=True, ...)` +- `cw(...)` for `trace_to(..., ccw=False, ...)` +- `jog(...)` for S-bends +- `uturn(...)` for U-bends + +Important: `Pather.path()` is no longer the routing API. It now forwards to +`Pattern.path()` and creates a geometric `Path` element. Any old routing code +that still calls `pather.path(...)` must be renamed. + +### Common rewrites + +```python +# old +pather.path('VCC', False, 6_000) +pather.path_to('VCC', None, x=0) +pather.mpath(['GND', 'VCC'], True, xmax=-10_000, spacing=5_000) +pather.pathS('VCC', offset=-2_000, length=8_000) +pather.pathU('VCC', offset=4_000, length=5_000) +pather.path_into('src', 'dst') +pather.path_from('src', 'dst') + +# new +pather.cw('VCC', 6_000) +pather.straight('VCC', x=0) +pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) +pather.jog('VCC', offset=-2_000, length=8_000) +pather.uturn('VCC', offset=4_000, length=5_000) +pather.trace_into('src', 'dst') +pather.at('src').trace_into('dst') +``` + +If you prefer the more explicit spelling, `trace(...)` and `trace_to(...)` +remain the underlying primitives: + +```python +pather.trace('VCC', False, 6_000) +pather.trace_to('VCC', None, x=0) +``` + +## `PortPather` and `.at(...)` + +Routing can now be written in a fluent style via `.at(...)`, which returns a +`PortPather`. + +```python +(rpather.at('VCC') + .trace(False, length=6_000) + .trace_to(None, x=0) +) +``` + +This is additive, not required for migration. Existing code can stay with the +non-fluent `Pather` methods after renaming the verbs above. + +Old `PortPather` helper names were also cleaned up: + +| Old API | New API | +| --- | --- | +| `save_copy(...)` | `mark(...)` | +| `rename_to(...)` | `rename(...)` | + +Example: + +```python +# old +pp.save_copy('branch') +pp.rename_to('feed') + +# new +pp.mark('branch') +pp.rename('feed') +``` + +## Imports and module layout + +`Pather` now provides the remaining builder/routing surface in +`masque/builder/pather.py`. The old module files +`masque/builder/builder.py` and `masque/builder/renderpather.py` were removed. + +Update imports like this: + +```python +# old +from masque.builder.builder import Builder +from masque.builder.renderpather import RenderPather + +# new +from masque.builder import Pather + +builder = Pather(...) +deferred = Pather(..., auto_render=False) +``` + +Top-level imports from `masque` also continue to work. + +`Pather` now defaults to `auto_render=True`, so plain construction replaces the +old `Builder` behavior. Use `Pather(..., auto_render=False)` where you +previously used `RenderPather`. + +## `BasicTool` was replaced + +`BasicTool` is no longer exported. Use `AutoTool` for reusable straight, bend, +transition, and S-bend primitives. + +### Old `BasicTool` + +```python +from masque.builder.tools import BasicTool + +tool = BasicTool( + straight=(make_straight, 'input', 'output'), + bend=(lib.abstract('bend'), 'input', 'output'), + transitions={ + 'm2wire': (lib.abstract('via'), 'top', 'bottom'), + }, +) +``` + +### New `AutoTool` + +```python +from masque.builder import AutoTool + +tool = ( + AutoTool() + .add_straight(make_straight, 'm1wire', 'input') + .add_bend(lib.abstract('bend'), 'input', 'output', clockwise=True) + .add_transition(lib.abstract('via'), 'top', 'bottom') +) +``` + +The key differences are: + +- `BasicTool` -> `AutoTool` +- `straight=(fn, in_name, out_name)` -> `add_straight(fn, ptype, in_name)` +- `bend=(abstract, in_name, out_name)` -> `add_bend(abstract, in_name, out_name)` +- transitions are registered with `add_transition(abstract, external_port, internal_port)` +- transitions are bidirectional by default; pass `one_way=True` to inhibit the reverse adapter + +## Custom `Tool` subclasses + +If you maintain your own `Tool` subclass, the interface changed: + +- `primitive_offers()` is now the planning boundary +- `render()` consumes committed primitive render tokens +- `Tool.path(...)`, `traceL()`, `traceS()`, `traceU()`, `planL()`, + `planS()`, and `planU()` are no longer part of the public `Tool` API + +In practice, a minimal old implementation like: + +```python +class MyTool(Tool): + def path(self, ccw, length, **kwargs): + ... +``` + +should now become: + +```python +from collections.abc import Sequence +from typing import Any + +from masque import Port +from masque.builder import RenderStep, StraightOffer, Tool + + +class MyTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): + if kind != 'straight': + return () + + def endpoint(length): + ptype = out_ptype or in_ptype + return Port((length, 0), rotation=3.141592653589793, ptype=ptype) + + def commit(length): + return {'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype or in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render(self, batch: Sequence[RenderStep], **kwargs: Any): + ... +``` + +If a tool does not provide a primitive kind, return `()` for that kind. `Pather` +will compose available primitive offers where the route family allows it. + +### Primitive offers + +Tools describe legal routing primitives through `Tool.primitive_offers()`. +`Pather` composes those primitive offers to implement `trace()`, `jog()`, +`uturn()`, and `trace_into()`. + +For custom tools, construct the concrete offer class that matches the primitive +you are exposing: + +- `StraightOffer` for non-turning length-parameterized primitives +- `BendOffer` for single-turn length-parameterized primitives +- `SOffer` for S-like jog-parameterized primitives +- `UOffer` for U-like jog-parameterized primitives + +`PrimitiveOffer` is the shared base type used for generic annotations and +common callback behavior. It is not the normal class users should instantiate. +The concrete offer classes carry the semantic fields (`length_domain`, +`jog_domain`, `ccw`) so tools do not need to encode primitive identity in +strings. + +Minimal straight-only example: + +```python +from collections.abc import Sequence +from typing import Literal + +from masque import Port +from masque.builder import RenderStep, StraightOffer, Tool + + +class MyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype=None, + out_ptype=None, + **kwargs, + ): + if kind != 'straight': + return () + + def endpoint(length): + ptype = out_ptype or in_ptype + return Port((length, 0), rotation=3.141592653589793, ptype=ptype) + + def commit(length): + return {'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype or in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render(self, batch: Sequence[RenderStep], **kwargs): + ... +``` + +Primitive offers are local planning objects: + +- `endpoint_at(parameter)` returns the local output `Port` +- `cost_at(parameter)` returns an additive scalar route-selection cost +- `bbox_at(parameter)` returns local primitive bounds when a footprint hook is supplied +- `parameterized_bbox` may carry opaque future-router footprint metadata +- `commit(parameter)` returns opaque render data consumed later by `render()` +- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton +- selected parameter values must be finite; domains may use infinite open bounds but not `NaN` +- `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer + +Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype +adapters. Requested `out_ptype` constrains only the final route endpoint; any +intermediate ptypes are chosen by the route solver. + +`Tool` subclasses must override `primitive_offers()` and return `()` themselves +for recognized unsupported kinds. There is no route-level `plan*()` fallback. +Omitted-length S/U behavior comes from direct `SOffer` and `UOffer` endpoint +domains or from composed straight/bend primitives. + +Offer constructors accept split `endpoint_planner` and `commit_planner` +callbacks. Provide both callbacks or override the offer methods in a subclass; +partial callback configurations are rejected during offer construction. + +When writing direct primitive offers, declare the actual endpoint ptype +produced by the offer if it can differ from the requested value; `Pather` +validates evaluated endpoints against the declared offer ptype. + +Stable imports for custom tool authors live in `masque.builder`. The +`masque.builder.planner` module is an internal planner implementation; do not +import it from user code. + +`trace_into()` uses the same primitive-offer route selection and now searches +bounded route topologies with up to four bend roles. This preserves the common +straight, bend, S-like, U-like, and dogleg cases while allowing routes that +need an additional bounded bend pair. Among legal bounded candidates, +`trace_into()` selects the lowest total primitive-offer cost; bend count and +step count are used only to break exact cost ties. + +Explicit-length `jog()` routes may also be satisfied by composing a straight +primitive before or after an omitted-length native S primitive. `uturn()` routes +may compose a straight primitive before an omitted-length native U primitive. +These compositions are used when they are the lowest-cost legal route for the +explicit request. + +`AutoTool` can attach `bbox_at()` hooks to its primitive offers by rendering the +selected primitive into a temporary pattern and measuring it. If the rendered +primitive contains reusable refs, pass the source library as `bbox_library=...`; +normal routing does not require this. + +### Omitted-length routing + +Single-port omitted-length calls now evaluate legal primitive routes at their +minimum legal length-like parameter, or at their intrinsic endpoint length when +the requested offset fixes the primitive geometry. Cost then selects among +those minimum-length candidates: + +```python +pather.trace('A', None) # minimum straight-like route +pather.jog('A', offset=2) # minimum S-like route for that offset +pather.uturn('A', offset=4) # minimum U-like route for that offset +``` + +For U-turns, use explicit `length=0` to request the old zero-public-length +shape: + +```python +pather.uturn('A', offset=4, length=0) +``` + +## Transform semantics changed + +The other major user-visible change is that `mirror()` and `rotate()` are now +treated more consistently as intrinsic transforms on low-level objects. + +The practical migration rule is: + +- use `mirror()` / `rotate()` when you want to change the object relative to its + own origin +- use `flip_across(...)`, `rotate_around(...)`, or container-level transforms + when you want to move the object in its parent coordinate system + +### Example: `Port` + +Old behavior: + +```python +port.mirror(0) # changed both offset and orientation +``` + +New behavior: + +```python +port.mirror(0) # changes orientation only +port.flip_across(axis=0) # old "mirror in the parent pattern" behavior +``` + +### What to audit + +Check code that calls: + +- `Port.mirror(...)` +- `Ref.rotate(...)` +- `Ref.mirror(...)` +- `Label.rotate_around(...)` / `Label.mirror(...)` + +If that code expected offsets or repetition grids to move automatically, it +needs updating. For whole-pattern transforms, prefer calling `Pattern.mirror()` +or `Pattern.rotate_around(...)` at the container level. + +## Other user-facing changes + +### DXF environments + +If you install the DXF extra, the supported `ezdxf` baseline moved from +`~=1.0.2` to `~=1.4`. Any pinned environments should be updated accordingly. + +### New exports + +These are additive, but available now from `masque` and `masque.builder`: + +- `PortPather` +- `AutoTool` +- `boolean` + +## Minimal migration checklist + +If your code uses the routing stack, do these first: + +1. Replace `path`/`path_to`/`mpath`/`path_into` calls with + `trace`/`trace_to`/multi-port `trace`/`trace_into`. +2. Replace `BasicTool` with `AutoTool`. +3. Fix imports that still reference `masque.builder.builder` or + `masque.builder.renderpather`. +4. Audit any low-level `mirror()` usage, especially on `Port` and `Ref`. + +If your code only uses `Pattern`, `Library`, `place()`, and `plug()` without the +routing helpers, you may not need any changes beyond the transform audit and any +stale imports. diff --git a/README.md b/README.md index 62b13bb..71c37f0 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ References are accomplished by listing the target's name, not its `Pattern` obje in order to create a reference, but they also need to access the pattern's ports. * One way to provide this data is through an `Abstract`, generated via `Library.abstract()` or through a `Library.abstract_view()`. - * Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates + * Another way is use `Pather.place()` or `Pather.plug()`, which automatically creates an `Abstract` from its internally-referenced `Library`. @@ -193,8 +193,8 @@ my_pattern.ref(new_name, ...) # instantiate the cell # In practice, you may do lots of my_pattern.ref(lib << make_tree(...), ...) -# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit: -my_builder = Builder(library=lib, ...) +# With a `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit: +my_builder = Pather(library=lib, ...) ... my_builder.place(make_tree(...)) ``` @@ -277,12 +277,6 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) ## TODO -* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath) * PolyCollection & arrow-based read/write -* pather and renderpather examples, including .at() (PortPather) * Bus-to-bus connections? -* Tests tests tests -* Better interface for polygon operations (e.g. with `pyclipper`) - - de-embedding - - boolean ops * tuple / string layer auto-translation diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index a51a27e..57b170c 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -6,7 +6,7 @@ from masque.file import gdsii from masque import Arc, Pattern -def main(): +def main() -> None: pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/generate_gds_perf.py b/examples/generate_gds_perf.py new file mode 100644 index 0000000..7eaebbb --- /dev/null +++ b/examples/generate_gds_perf.py @@ -0,0 +1,5 @@ +from masque.file.gdsii.perf import main + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py index de51d6a..60e0a3e 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,7 +1,5 @@ -import numpy from pyclipper import ( - Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, - scale_to_clipper, scale_from_clipper, + Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, ) p = Pyclipper() p.AddPaths([ @@ -12,8 +10,8 @@ p.AddPaths([ ], PT_SUBJECT, closed=True) #p.Execute2? #p.Execute? -p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO) -p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO) +p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) +p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) p = Pyclipper() diff --git a/examples/profile_gdsii_readers.py b/examples/profile_gdsii_readers.py new file mode 100644 index 0000000..0eb05a5 --- /dev/null +++ b/examples/profile_gdsii_readers.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import argparse +import importlib +import json +import time +from pathlib import Path +from typing import Any + +from masque import LibraryError + + +READERS: dict[str, tuple[str, tuple[str, ...]]] = { + 'gdsii': ('masque.file.gdsii', ('readfile',)), + 'gdsii_arrow': ('masque.file.gdsii.arrow', ('readfile', 'arrow_import', 'arrow_convert')), + } + + +def _summarize_library(path: Path, elapsed_s: float, info: dict[str, object], lib: object) -> dict[str, object]: + assert hasattr(lib, '__len__') + assert hasattr(lib, 'tops') + tops = lib.tops() # type: ignore[no-any-return, attr-defined] + try: + unique_top = lib.top() # type: ignore[no-any-return, attr-defined] + except LibraryError: + unique_top = None + + return { + 'path': str(path), + 'elapsed_s': elapsed_s, + 'library_name': info['name'], + 'cell_count': len(lib), # type: ignore[arg-type] + 'topcells': tops, + 'topcell': unique_top, + } + + +def _summarize_arrow_import(path: Path, elapsed_s: float, arrow_arr: Any) -> dict[str, object]: + libarr = arrow_arr[0] + return { + 'path': str(path), + 'elapsed_s': elapsed_s, + 'arrow_rows': len(arrow_arr), + 'library_name': libarr['lib_name'].as_py(), + 'cell_count': len(libarr['cells']), + 'layer_count': len(libarr['layers']), + } + + +def _profile_stage(module: Any, stage: str, path: Path) -> dict[str, object]: + start = time.perf_counter() + + if stage == 'readfile': + lib, info = module.readfile(path) + elapsed_s = time.perf_counter() - start + return _summarize_library(path, elapsed_s, info, lib) + + if stage == 'arrow_import': + if hasattr(module, 'readfile_arrow'): + libarr, _info = module.readfile_arrow(path) + elapsed_s = time.perf_counter() - start + return { + 'path': str(path), + 'elapsed_s': elapsed_s, + 'arrow_rows': 1, + 'library_name': libarr['lib_name'].as_py(), + 'cell_count': len(libarr['cells']), + 'layer_count': len(libarr['layers']), + } + + arrow_arr = module._read_to_arrow(path) + elapsed_s = time.perf_counter() - start + return _summarize_arrow_import(path, elapsed_s, arrow_arr) + + if stage == 'arrow_convert': + arrow_arr = module._read_to_arrow(path) + libarr = arrow_arr[0] + start = time.perf_counter() + lib, info = module.read_arrow(libarr) + elapsed_s = time.perf_counter() - start + return _summarize_library(path, elapsed_s, info, lib) + + raise ValueError(f'Unsupported stage {stage!r}') + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='Profile GDS readers with a stable end-to-end workload.') + parser.add_argument('--reader', choices=sorted(READERS), required=True) + parser.add_argument('--stage', default='readfile') + parser.add_argument('--path', type=Path, required=True) + parser.add_argument('--warmup', type=int, default=1) + parser.add_argument('--repeat', type=int, default=1) + parser.add_argument('--output-json', type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(argv) + + module_name, stages = READERS[args.reader] + if args.stage not in stages: + parser.error(f'reader {args.reader!r} only supports stages: {", ".join(stages)}') + + module = importlib.import_module(module_name) + path = args.path.expanduser().resolve() + + for _ in range(args.warmup): + _profile_stage(module, args.stage, path) + + runs = [] + for _ in range(args.repeat): + runs.append(_profile_stage(module, args.stage, path)) + + payload = { + 'reader': args.reader, + 'stage': args.stage, + 'warmup': args.warmup, + 'repeat': args.repeat, + 'runs': runs, + } + rendered = json.dumps(payload, indent=2, sort_keys=True) + if args.output_json is not None: + args.output_json.parent.mkdir(parents=True, exist_ok=True) + args.output_json.write_text(rendered + '\n') + print(rendered) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/examples/test_rep.py b/examples/test_rep.py index f82575d..d25fb55 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main(): +def main() -> None: lib = Library() cell_name = 'ellip_grating' diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 7210a93..aab8ad2 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,6 +1,12 @@ masque Tutorial =============== +These examples are meant to be read roughly in order. + +- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts. +- Then read `devices.py` and `library.py` for hierarchical composition and libraries. +- Read the `pather*` tutorials separately when you want routing helpers. + Contents -------- @@ -8,24 +14,30 @@ Contents * Draw basic geometry * Export to GDS - [devices](devices.py) + * Build hierarchical photonic-crystal example devices * Reference other patterns * Add ports to a pattern - * Snap ports together to build a circuit + * Use `Pather` to snap ports together into a circuit * Check for dangling references - [library](library.py) - * Create a `LazyLibrary`, which loads / generates patterns only when they are first used + * Continue from `devices.py` by declaring a mixed library with `BuildLibrary` + * Import source-backed GDS cells and register python-generated recipes together + * Call `build()` to produce a normal library and report for downstream `Pather` usage and writing * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` - * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) - [pather](pather.py) * Use `Pather` to route individual wires and wire bundles - * Use `BasicTool` to generate paths - * Use `BasicTool` to automatically transition between path types -- [renderpather](rendpather.py) - * Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py), + * Define a custom `Tool` that exposes primitive routing offers + * Use primitive offers to automatically transition between path types +- [renderpather](renderpather.py) + * Use `Pather(render='deferred')` and `PathTool` to build a layout similar to the one in [pather](pather.py), but using `Path` shapes instead of `Polygon`s. +- [port_pather](port_pather.py) + * Use `PortPather` and the `.at()` syntax for more concise routing + * Advanced port manipulation and connections -Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. +Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating +photonic-crystal lattices; it is support code rather than a step-by-step tutorial. Running @@ -37,3 +49,6 @@ cd examples/tutorial python3 basic_shapes.py klayout -e basic_shapes.gds ``` + +Some tutorials depend on outputs from earlier ones. In particular, `library.py` +expects `circuit.gds`, which is generated by `devices.py`. diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index 87baaf0..d8f7e1e 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,12 +1,9 @@ -from collections.abc import Sequence import numpy from numpy import pi -from masque import ( - layer_t, Pattern, Label, Port, - Circle, Arc, Polygon, - ) +from masque import layer_t, Pattern, Circle, Arc, Ref +from masque.repetition import Grid import masque.file.gdsii @@ -39,6 +36,45 @@ def hole( return pat +def hole_array( + radius: float, + num_x: int = 5, + num_y: int = 3, + pitch: float = 2000, + layer: layer_t = (1, 0), + ) -> Pattern: + """ + Generate an array of circular holes using `Repetition`. + + Args: + radius: Circle radius. + num_x, num_y: Number of holes in x and y. + pitch: Center-to-center spacing. + layer: Layer to draw the holes on. + + Returns: + Pattern containing a grid of holes. + """ + # First, make a pattern for a single hole + hpat = hole(radius, layer) + + # Now, create a pattern that references it multiple times using a Grid + pat = Pattern() + pat.refs['hole'] = [ + Ref( + offset=(0, 0), + repetition=Grid(a_vector=(pitch, 0), a_count=num_x, + b_vector=(0, pitch), b_count=num_y) + )] + + # We can also add transformed references (rotation, mirroring, etc.) + pat.refs['hole'].append( + Ref(offset=(0, -pitch), rotation=pi / 4, mirrored=True) + ) + + return pat, hpat + + def triangle( radius: float, layer: layer_t = (1, 0), @@ -60,9 +96,7 @@ def triangle( ]) * radius pat = Pattern() - pat.shapes[layer].extend([ - Polygon(offset=(0, 0), vertices=vertices), - ]) + pat.polygon(layer, vertices=vertices) return pat @@ -111,9 +145,13 @@ def main() -> None: lib['smile'] = smile(1000) lib['triangle'] = triangle(1000) + # Use a Grid to make many holes efficiently + lib['grid'], lib['hole'] = hole_array(1000) + masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) lib['triangle'].visualize() + lib['grid'].visualize(lib) if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 6b9cfa2..955e786 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,11 +1,19 @@ +""" +Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Pather`. + +This file uses photonic-crystal components as the concrete example, so some of +the geometry-generation code is domain-specific. The tutorial value is in the +Masque patterns around it: creating reusable cells, annotating ports, composing +hierarchy with references, and snapping ports together to build a larger circuit. +""" from collections.abc import Sequence, Mapping import numpy from numpy import pi from masque import ( - layer_t, Pattern, Ref, Label, Builder, Port, Polygon, - Library, ILibraryView, + layer_t, Pattern, Ref, Pather, Port, Polygon, + Library, ) from masque.utils import ports2data from masque.file.gdsii import writefile, check_valid_names @@ -64,9 +72,9 @@ def perturbed_l3( Provided sequence should have same length as `shifts_a`. xy_size: `(x, y)` number of mirror periods in each direction; total size is `2 * n + 1` holes in each direction. Default (10, 10). - perturbed_radius: radius of holes perturbed to form an upwards-driected beam + perturbed_radius: radius of holes perturbed to form an upwards-directed beam (multiplicative factor). Default 1.1. - trench width: Width of the undercut trenches. Default 1200. + trench_width: Width of the undercut trenches. Default 1200. Returns: `Pattern` object representing the L3 design. @@ -79,14 +87,15 @@ def perturbed_l3( shifts_a=shifts_a, shifts_r=shifts_r) - # Build L3 cavity, using references to the provided hole pattern + # Build the cavity by instancing the supplied `hole` pattern many times. + # Using references keeps the pattern compact even though it contains many holes. pat = Pattern() pat.refs[hole] += [ Ref(scale=r, offset=(lattice_constant * x, lattice_constant * y)) for x, y, r in xyr] - # Add rectangular undercut aids + # Add rectangular undercut aids based on the referenced hole extents. min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) trench_dx = max_xy[0] - min_xy[0] @@ -95,7 +104,7 @@ def perturbed_l3( Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), ] - # Ports are at outer extents of the device (with y=0) + # Define the interface in Masque terms: two ports at the left/right extents. extent = lattice_constant * xy_size[0] pat.ports = dict( input=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -125,17 +134,17 @@ def waveguide( Returns: `Pattern` object representing the waveguide. """ - # Generate hole locations + # Generate the normalized lattice locations for the line defect. xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) - # Build the pattern + # Build the pattern by placing repeated references to the same hole cell. pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Ports are at outer edges, with y=0 + # Publish the device interface as two ports at the outer edges. extent = lattice_constant * length / 2 pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -164,17 +173,17 @@ def bend( `Pattern` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ - # Generate hole locations + # Generate the normalized lattice locations for the bend. xy = pcgen.wgbend(num_mirror=mirror_periods) - # Build the pattern - pat= Pattern() + # Build the pattern by instancing the shared hole cell. + pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Figure out port locations. + # Publish the bend interface as two ports. extent = lattice_constant * mirror_periods pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -203,17 +212,17 @@ def y_splitter( `Pattern` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ - # Generate hole locations + # Generate the normalized lattice locations for the splitter. xy = pcgen.y_splitter(num_mirror=mirror_periods) - # Build pattern + # Build the pattern by instancing the shared hole cell. pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Determine port locations + # Publish the splitter interface as one input and two outputs. extent = lattice_constant * mirror_periods pat.ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -227,13 +236,13 @@ def y_splitter( def main(interactive: bool = True) -> None: - # Generate some basic hole patterns + # First make a couple of reusable primitive cells. shape_lib = { 'smile': basic_shapes.smile(RADIUS), 'hole': basic_shapes.hole(RADIUS), } - # Build some devices + # Then build a small library of higher-level devices from those primitives. a = LATTICE_CONSTANT devices = {} @@ -245,22 +254,23 @@ def main(interactive: bool = True) -> None: devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5) devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :) - # Turn our dict of devices into a Library. - # This provides some convenience functions in the future! + # Turn the device mapping into a `Library`. + # That gives us convenience helpers for hierarchy inspection and abstract views. lib = Library(devices) # # Build a circuit # - # Create a `Builder`, and add the circuit to our library as "my_circuit". - circ = Builder(library=lib, name='my_circuit') + # Create a `Pather`, and register the resulting top cell as "my_circuit". + circ = Pather(library=lib, name='my_circuit') - # Start by placing a waveguide. Call its ports "in" and "signal". + # Start by placing a waveguide and renaming its ports to match the circuit-level + # names we want to use while assembling the design. circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) - # Extend the signal path by attaching the "left" port of a waveguide. - # Since there is only one other port ("right") on the waveguide we - # are attaching (wg10), it automatically inherits the name "signal". + # Extend the signal path by attaching another waveguide. + # Because `wg10` only has one unattached port left after the plug, Masque can + # infer that it should keep the name `signal`. circ.plug('wg10', {'signal': 'left'}) # We could have done the following instead: @@ -268,8 +278,8 @@ def main(interactive: bool = True) -> None: # lib['my_circuit'] = circ_pat # circ_pat.place(lib.abstract('wg10'), ...) # circ_pat.plug(lib.abstract('wg10'), ...) - # but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar - # syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality. + # but `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps + # the assembly code focused on port-level intent. # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the @@ -281,13 +291,10 @@ def main(interactive: bool = True) -> None: circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # Add a bend to both ports. - # Our bend's ports "left" and "right" refer to the original counterclockwise - # orientation. We want the bends to turn in opposite directions, so we attach - # the "right" port to "signal1" to bend clockwise, and the "left" port - # to "signal2" to bend counterclockwise. - # We could also use `mirrored=(True, False)` to mirror one of the devices - # and then use same device port on both paths. + # Add a bend to both branches. + # Our bend primitive is defined with a specific orientation, so choosing which + # port to plug determines whether the path turns clockwise or counterclockwise. + # We could also mirror one instance instead of using opposite ports. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) @@ -296,29 +303,26 @@ def main(interactive: bool = True) -> None: circ.plug('l3cav', {'signal1': 'input'}) circ.plug('wg10', {'signal1': 'left'}) - # "signal2" just gets a single of equivalent length + # `signal2` gets a single waveguide of equivalent overall length. circ.plug('wg28', {'signal2': 'left'}) - # Now we bend both waveguides back towards each other + # Now bend both branches back towards each other. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # To join the waveguides, we attach a second y-junction. - # We plug "signal1" into the "bot" port, and "signal2" into the "top" port. - # The remaining port gets named "signal_out". - # This operation would raise an exception if the ports did not line up - # correctly (i.e. they required different rotations or translations of the - # y-junction device). + # To join the branches, attach a second y-junction. + # This succeeds only if both chosen ports agree on the same translation and + # rotation for the inserted device; otherwise Masque raises an exception. circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". circ.plug('wg10', {'signal_out': 'left'}) - # We can also add text labels for our circuit's ports. - # They will appear at the uppermost hierarchy level, while the individual - # device ports will appear further down, in their respective cells. + # Bake the top-level port metadata into labels so it survives GDS export. + # These labels appear on the circuit cell; individual child devices keep their + # own port labels in their own cells. ports_to_data(circ.pattern) # Check if we forgot to include any patterns... ooops! @@ -330,12 +334,12 @@ def main(interactive: bool = True) -> None: lib.add(shape_lib) assert not lib.dangling_refs() - # We can visualize the design. Usually it's easier to just view the GDS. + # We can visualize the design directly, though opening the written GDS is often easier. if interactive: print('Visualizing... this step may be slow') circ.pattern.visualize(lib) - #Write out to GDS, only keeping patterns referenced by our circuit (including itself) + # Write out only the subtree reachable from our top cell. subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use check_valid_names(subtree.keys()) writefile(subtree, 'circuit.gds', **GDS_OPTS) diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index eab8a12..d7a2615 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,135 +1,115 @@ +""" +Tutorial: authoring a mixed library with `BuildLibrary`. + +This example assumes you have already read `devices.py` and generated the +`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry +itself, but rather how Masque lets you combine imported GDS cells with +python-generated recipes, then turn that declaration set into a normal library +for downstream assembly and writing. +""" from typing import Any -from collections.abc import Sequence, Callable from pprint import pformat -import numpy -from numpy import pi -from masque import Pattern, Builder, LazyLibrary -from masque.file.gdsii import writefile, load_libraryfile +from masque import BuildLibrary, Pather, Pattern, cell +from masque.file.gdsii import writefile +from masque.file.gdsii.lazy import readfile -import pcgen import basic_shapes import devices -from devices import ports_to_data, data_to_ports from basic_shapes import GDS_OPTS +def make_mixed_waveguide(lib: BuildLibrary) -> Pattern: + """ + Recipe which assembles imported and generated cells behind the builder API. + """ + circ = Pather(library=lib, ports='tri_l3cav') + + # First way to specify what we are plugging in: request an explicit abstract. + circ.plug(lib.abstract('wg10'), {'input': 'right'}) + + # Second way: use an AbstractView, which behaves like a mapping of names + # to abstracts. + abstracts = lib.abstract_view() + circ.plug(abstracts['wg10'], {'output': 'left'}) + + # Third way: let Pather resolve a pattern name through its own library. + circ.plug('tri_wg10', {'input': 'right'}) + circ.plug('tri_wg10', {'output': 'left'}) + + return circ.pattern + + def main() -> None: - # Define a `LazyLibrary`, which provides lazy evaluation for generating - # patterns and lazy-loading of GDS contents. - lib = LazyLibrary() + builder = BuildLibrary() + cells = builder.cells # # Load some devices from a GDS file # - # Scan circuit.gds and prepare to lazy-load its contents - gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) + # Scan circuit.gds and prepare to lazy-load its contents. Port labels are + # imported on first materialization, but the raw source remains untouched + # until we build the final library. + gds_lib, _properties = readfile('circuit.gds') + builder.add_source(gds_lib.with_ports_from_data(layers=[(3, 0)], max_depth=1)) - # Add it into the device library by providing a way to read port info - # This maintains the lazy evaluation from above, so no patterns - # are actually read yet. - lib.add(gds_lib) - - print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys()))) + print('Registered imported cells:\n' + pformat(list(gds_lib.keys()))) # - # Add some new devices to the library, this time from python code rather than GDS + # Register some new devices, this time from python code rather than GDS. # - lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS) + cells.triangle = basic_shapes.triangle(devices.RADIUS) opts: dict[str, Any] = dict( - lattice_constant = devices.LATTICE_CONSTANT, - hole = 'triangle', - ) + lattice_constant=devices.LATTICE_CONSTANT, + hole='triangle', + ) - # Triangle-based variants. These are defined here, but they won't run until they're - # retrieved from the library. - lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts) - lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts) - lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts) - lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts) - lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts) - lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib) + cells.tri_wg10 = cell(devices.waveguide)(length=10, mirror_periods=5, **opts) + cells.tri_wg05 = cell(devices.waveguide)(length=5, mirror_periods=5, **opts) + cells.tri_wg28 = cell(devices.waveguide)(length=28, mirror_periods=5, **opts) + cells.tri_bend0 = cell(devices.bend)(mirror_periods=5, **opts) + cells.tri_ysplit = cell(devices.y_splitter)(mirror_periods=5, **opts) + cells.tri_l3cav = cell(devices.perturbed_l3)(xy_size=(4, 10), **opts, hole_lib=builder) + cells.mixed_wg_cav = cell(make_mixed_waveguide)(builder) + + print('Declared cells waiting to be built:\n' + pformat(list(builder.keys()))) # - # Build a mixed waveguide with an L3 cavity in the middle + # Build the declaration set into a normal library. # - # Immediately start building from an instance of the L3 cavity - circ2 = Builder(library=lib, ports='tri_l3cav') - - # First way to get abstracts is `lib.abstract(name)` - # We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`. - circ2.plug(lib.abstract('wg10'), {'input': 'right'}) - - # Second way to get abstracts is to use an AbstractView - # This also works directly with `Pattern.plug()` / `Pattern.place()`. - abstracts = lib.abstract_view() - circ2.plug(abstracts['wg10'], {'output': 'left'}) - - # Third way to specify an abstract works by automatically getting - # it from the library already within the Builder object. - # This wouldn't work if we only had a `Pattern` (not a `Builder`). - # Just pass the pattern name! - circ2.plug('tri_wg10', {'input': 'right'}) - circ2.plug('tri_wg10', {'output': 'left'}) - - # Add the circuit to the device library. - lib['mixed_wg_cav'] = circ2.pattern - + built, report = builder.build() + print('Built library contains:\n' + pformat(list(built.keys()))) + print('Build dependency graph:\n' + pformat(report.dependency_graph)) # - # Build a device that could plug into our mixed_wg_cav and joins the two ports + # Continue designing against the built library. # - # We'll be designing against an existing device's interface... - circ3 = Builder.interface(source=circ2) - - # ... that lets us continue from where we left off. - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('tri_wg10', {'input': 'right'}) - circ3.plug('tri_wg28', {'input': 'right'}) - circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'}) - - lib['loop_segment'] = circ3.pattern + # The built result behaves like a normal mutable library, so downstream code + # can use Pather, abstract views, and writing without going back through the + # builder interface. + circ = Pather.interface(source='mixed_wg_cav', library=built) + circ.plug('tri_bend0', {'input': 'right'}) + circ.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry + circ.plug('tri_bend0', {'input': 'right'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('bend0', {'output': 'left'}) + circ.plug('tri_wg10', {'input': 'right'}) + circ.plug('tri_wg28', {'input': 'right'}) + circ.plug('tri_wg10', {'input': 'right', 'output': 'left'}) + built['loop_segment'] = circ.pattern # - # Write all devices into a GDS file + # Write all devices into a GDS file. # print('Writing library to file...') - writefile(lib, 'library.gds', **GDS_OPTS) + writefile(built, 'library.gds', **GDS_OPTS) if __name__ == '__main__': main() - - -# -#class prout: -# def place( -# self, -# other: Pattern, -# label_layer: layer_t = 'WATLAYER', -# *, -# port_map: Dict[str, str | None] | None = None, -# **kwargs, -# ) -> 'prout': -# -# Pattern.place(self, other, port_map=port_map, **kwargs) -# name: str | None -# for name in other.ports: -# if port_map: -# assert(name is not None) -# name = port_map.get(name, name) -# if name is None: -# continue -# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer) -# return self -# diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 101fbb5..8b138a8 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,11 +1,18 @@ """ -Manual wire routing tutorial: Pather and BasicTool +Manual wire routing tutorial: Pather and primitive offers """ -from collections.abc import Callable +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Literal + +import numpy from numpy import pi -from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import BasicTool, PathTool +from masque import Pather, Library, Pattern, Port, layer_t +from masque.abstract import Abstract +from masque.builder import BendOffer, RenderStep, StraightOffer, Tool +from masque.error import BuildError from masque.file.gdsii import writefile +from masque.library import ILibrary, SINGLE_USE_PREFIX from basic_shapes import GDS_OPTS @@ -107,87 +114,327 @@ def map_layer(layer: layer_t) -> layer_t: 'M2': (20, 0), 'V1': (30, 0), } - return layer_mapping.get(layer, layer) + if isinstance(layer, str): + return layer_mapping.get(layer, layer) + return layer -# -# Now we can start building up our library (collection of static cells) and pathing tools. -# -# If any of the operations below are confusing, you can cross-reference against the `RenderPather` -# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others -# (e.g. geometry definition). -# -def main() -> None: +@dataclass(frozen=True, slots=True) +class WireStraightData: + length: float + out_transition: 'WireTransitionSpec | None' = None + + +@dataclass(frozen=True, slots=True) +class WireBendData: + straight_length: float + ccw: bool + + +@dataclass(frozen=True, slots=True) +class WireTransitionSpec: + abstract: Abstract + in_port_name: str + out_port_name: str + + @property + def in_port(self) -> Port: + return self.abstract.ports[self.in_port_name] + + @property + def out_port(self) -> Port: + return self.abstract.ports[self.out_port_name] + + +@dataclass(frozen=True, slots=True) +class WireTransitionData: + spec: WireTransitionSpec + + +@dataclass +class PrimitiveWireTool(Tool): + """ + Minimal routing tool that exposes local routing primitives directly. + + The high-level `Pather` methods below still decide how to compose straights, + bends, and ptype transitions. This tool only describes which one-step + primitives it can draw and how selected primitives should be rendered. + """ + layer: layer_t + width: float + ptype: str + bend: Abstract + transitions: Sequence[WireTransitionSpec] + + def _straight_pattern(self, length: float) -> Pattern: + return make_straight_wire(layer=self.layer, width=self.width, ptype=self.ptype, length=length) + + @staticmethod + def _transition_length(spec: WireTransitionSpec) -> float | None: + dxy, angle = spec.in_port.measure_travel(spec.out_port) + if angle is None or not numpy.isclose(angle, pi) or not numpy.isclose(dxy[1], 0): + return None + return float(dxy[0]) + + def _transition_offers(self, in_ptype: str | None) -> tuple[StraightOffer, ...]: + offers: list[StraightOffer] = [] + for index, spec in enumerate(self.transitions): + if spec.out_port.ptype != self.ptype: + continue + if in_ptype not in (None, 'unk', spec.in_port.ptype): + continue + + length = self._transition_length(spec) + if length is None: + continue + + def endpoint_planner( + parameter: float, + *, + spec: WireTransitionSpec = spec, + length: float = length, + ) -> Port: + _ = parameter + return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) + + def commit_planner( + parameter: float, + *, + spec: WireTransitionSpec = spec, + ) -> WireTransitionData: + _ = parameter + return WireTransitionData(spec) + + offers.append(StraightOffer( + in_ptype = spec.in_port.ptype, + out_ptype = spec.out_port.ptype, + priority_bias = index * 1e7, + length_domain = (length, length), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + )) + return tuple(offers) + + def _out_transition_offers(self, out_ptype: str | None) -> tuple[StraightOffer, ...]: + if out_ptype in ('unk', self.ptype): + return () + + offers: list[StraightOffer] = [] + for index, spec in enumerate(self.transitions): + if spec.in_port.ptype != self.ptype: + continue + if out_ptype is not None and spec.out_port.ptype != out_ptype: + continue + + transition_length = self._transition_length(spec) + if transition_length is None: + continue + + def endpoint_planner( + length: float, + *, + spec: WireTransitionSpec = spec, + transition_length: float = transition_length, + ) -> Port: + straight_length = length - transition_length + if straight_length < 0: + raise BuildError( + f'Asked to draw straight path with total length {length:,g}, shorter than required transition: {transition_length:,g}' + ) + return Port((length, 0), rotation=pi, ptype=spec.out_port.ptype) + + def commit_planner( + length: float, + *, + spec: WireTransitionSpec = spec, + transition_length: float = transition_length, + ) -> WireStraightData: + endpoint_planner(length) + return WireStraightData(length - transition_length, spec) + + offers.append(StraightOffer( + in_ptype = self.ptype, + out_ptype = spec.out_port.ptype, + priority_bias = index * 1e7, + length_domain = (transition_length, numpy.inf), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + )) + return tuple(offers) + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, # noqa: ARG002 (Pather validates selected output ptypes) + **kwargs: Any, + ) -> tuple[StraightOffer | BendOffer, ...]: + if kind == 'straight': + route_kwargs = dict(kwargs) + + def endpoint_planner(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=self.ptype) + + def commit_planner(length: float) -> WireStraightData: + _ = route_kwargs + return WireStraightData(length) + + native_offer = StraightOffer( + in_ptype = self.ptype, + out_ptype = self.ptype, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ) + return (*self._transition_offers(in_ptype), native_offer, *self._out_transition_offers(out_ptype)) + + if kind == 'bend': + ccw = bool(kwargs.pop('ccw')) + bend_forward = self.width / 2 + bend_run = bend_forward if ccw else -bend_forward + bend_rotation = -pi / 2 if ccw else pi / 2 + + def endpoint_planner(length: float) -> Port: + straight_length = length - bend_forward + if straight_length < 0: + raise BuildError( + f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_forward:,g}' + ) + return Port((length, bend_run), rotation=bend_rotation, ptype=self.ptype) + + def commit_planner(length: float) -> WireBendData: + endpoint_planner(length) + return WireBendData(straight_length=length - bend_forward, ccw=ccw) + + return (BendOffer( + in_ptype = self.ptype, + out_ptype = self.ptype, + ccw = ccw, + length_domain = (bend_forward, numpy.inf), + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ),) + + if kind in ('s', 'u'): + return () + + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') + + def _render_straight(self, tree: ILibrary, port_names: tuple[str, str], data: WireStraightData) -> None: + if numpy.isclose(data.length, 0) and data.out_transition is None: + return + + if not numpy.isclose(data.length, 0): + tree.top_pattern().plug( + self._straight_pattern(data.length), + {port_names[1]: 'input'}, + append=True, + ) + + if data.out_transition is not None: + self._render_transition(tree, port_names, WireTransitionData(data.out_transition)) + + def _render_bend(self, tree: ILibrary, port_names: tuple[str, str], data: WireBendData) -> None: + self._render_straight(tree, port_names, WireStraightData(data.straight_length)) + tree.top_pattern().plug( + self.bend, + {port_names[1]: 'input'}, + mirrored=data.ccw, + ) + + @staticmethod + def _render_transition(tree: ILibrary, port_names: tuple[str, str], data: WireTransitionData) -> None: + tree.top_pattern().plug( + data.spec.abstract, + {port_names[1]: data.spec.in_port_name}, + ) + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, # noqa: ARG002 (no per-render options in this example tool) + ) -> ILibrary: + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire') + pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype) + + for step in batch: + assert step.tool == self + if isinstance(step.data, WireTransitionData): + self._render_transition(tree, port_names, step.data) + elif isinstance(step.data, WireStraightData): + self._render_straight(tree, port_names, step.data) + elif isinstance(step.data, WireBendData): + self._render_bend(tree, port_names, step.data) + else: + raise BuildError(f'Unexpected primitive render data {type(step.data)}') + return tree + + +def prepare_tools() -> tuple[Library, Tool, Tool]: + """ + Create some basic library elements and tools for drawing M1 and M2 + """ # Build some patterns (static cells) using the above functions and store them in a library library = Library() library['pad'] = make_pad() library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) library['v1_via'] = make_via( - layer_top='M2', - layer_via='V1', - layer_bot='M1', - width_top=M2_WIDTH, - width_via=V1_WIDTH, - width_bot=M1_WIDTH, - ptype_bot='m1wire', - ptype_top='m2wire', + layer_top = 'M2', + layer_via = 'V1', + layer_bot = 'M1', + width_top = M2_WIDTH, + width_via = V1_WIDTH, + width_bot = M1_WIDTH, + ptype_bot = 'm1wire', + ptype_top = 'm2wire', ) # # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH - # M2_tool will route on M2, using wires with M2_WIDTH - # Both tools are able to automatically transition from the other wire type (with a via) + # M1_tool will route on M1, using wires with M1_WIDTH. + # M2_tool will route on M2, using wires with M2_WIDTH. # - # Note that while we use BasicTool for this tutorial, you can define your own `Tool` - # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, - # transmission line geometry, or other features. + # Unlike the reusable `AutoTool`, this tutorial tool exposes primitive offers + # directly: it tells `Pather` about native straight/bend primitives and about + # via adapters that can transition between M1 and M2 port types. # - M1_tool = BasicTool( - straight = ( - # First, we need a function which takes in a length and spits out an M1 wire - lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), - 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input - 'output', # and use the port named 'output' as the output - ), - bend = ( - library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier - 'input', # To orient it clockwise, use the port named 'input' as the input - 'output', # and 'output' as the output - ), - transitions = { # We can automate transitions for different (normally incompatible) port types - 'm2wire': ( # For example, when we're attaching to a port with type 'm2wire' - library.abstract('v1_via'), # we can place a V1 via - 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) - 'bottom', # and using the port named 'bottom' as the output - ), - }, - default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 + via = library.abstract('v1_via') + via_transitions = ( + WireTransitionSpec(via, 'top', 'bottom'), + WireTransitionSpec(via, 'bottom', 'top'), ) - M2_tool = BasicTool( - straight = ( - # Again, we use make_straight_wire, but this time we set parameters for M2 - lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), - 'input', - 'output', - ), - bend = ( - library.abstract('m2_bend'), # and we use an M2 bend - 'input', - 'output', - ), - transitions = { - 'm1wire': ( - library.abstract('v1_via'), # We still use the same via, - 'bottom', # but the input port is now 'bottom' - 'top', # and the output port is now 'top' - ), - }, - default_out_ptype = 'm2wire', # We default to trying to stay on M2 + M1_tool = PrimitiveWireTool( + layer = 'M1', + width = M1_WIDTH, + ptype = 'm1wire', + bend = library.abstract('m1_bend'), + transitions = via_transitions, ) + M2_tool = PrimitiveWireTool( + layer = 'M2', + width = M2_WIDTH, + ptype = 'm2wire', + bend = library.abstract('m2_bend'), + transitions = via_transitions, + ) + return library, M1_tool, M2_tool + + +# +# Now we can start building up our library (collection of static cells) and pathing tools. +# +# If any of the operations below are confusing, you can cross-reference against the deferred +# `Pather` tutorial, which handles some things more explicitly (e.g. via placement) and simplifies +# others (e.g. geometry definition). +# +def main() -> None: + library, M1_tool, M2_tool = prepare_tools() + # # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. # Then, place some pads and start routing wires! @@ -203,27 +450,25 @@ def main() -> None: # Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False) # The total distance forward (including the bend's forward component) must be 6um - pather.path('VCC', ccw=False, length=6_000) + pather.cw('VCC', 6_000) - # Now path VCC to x=0. This time, don't include any bend (ccw=None). + # Now path VCC to x=0. This time, don't include any bend. # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.path_to('VCC', ccw=None, x=0) + pather.straight('VCC', x=0) # Path GND forward by 5um, turning clockwise 90 degrees. - # This time we use shorthand (bool(0) == False) and omit the parameter labels - # Note that although ccw=0 is equivalent to ccw=False, ccw=None is not! - pather.path('GND', 0, 5_000) + pather.cw('GND', 5_000) # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.path_to('GND', None, x=pather['VCC'].offset[0]) + pather.straight('GND', x=pather['VCC'].offset[0]) # Now, start using M1_tool for GND. - # Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves. + # Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves. # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here # and achieve the same result without having to define any transitions in M1_tool. # Note that even though we have changed the tool used for GND, the via doesn't get placed until - # the next time we draw a path on GND (the pather.mpath() statement below). - pather.retool(M1_tool, keys=['GND']) + # the next time we route GND (the `pather.ccw()` call below). + pather.retool(M1_tool, keys='GND') # Bundle together GND and VCC, and path the bundle forward and counterclockwise. # Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000. @@ -231,7 +476,7 @@ def main() -> None: # # Since we recently retooled GND, its path starts with a via down to M1 (included in the distance # calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2. - pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) + pather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) # Now use M1_tool as the default tool for all ports/signals. # Since VCC does not have an explicitly assigned tool, it will now transition down to M1. @@ -241,38 +486,37 @@ def main() -> None: # The total extension (travel distance along the forward direction) for the longest segment (in # this case the segment being added to GND) should be exactly 50um. # After turning, the wire pitch should be reduced only 1.2um. - pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) + pather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) # Make a U-turn with the bundle and expand back out to 4.5um wire pitch. - # Here, emin specifies the travel distance for the shortest segment. For the first mpath() call - # that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the + # Here, emin specifies the travel distance for the shortest segment. For the first call + # that applies to VCC, and for the second call, that applies to GND; the relative lengths of the # segments depend on their starting positions and their ordering within the bundle. - pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) - pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) + pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) + pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) # Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been - # explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default. + # explicitly assigned a tool. pather.retool(M2_tool) # Now path both ports to x=-28_000. - # When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate, - # However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is + # With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is # equivalent. - pather.mpath(['GND', 'VCC'], None, xmin=-28_000) + pather.straight(['GND', 'VCC'], xmin=-28_000) # Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1. # This results in a via at the end of the wire (instead of having one at the start like we got # when using pather.retool(). - pather.path_to('VCC', None, -50_000, out_ptype='m1wire') + pather.straight('VCC', x=-50_000, out_ptype='m1wire') # Now extend GND out to x=-50_000, using M2 for a portion of the path. # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. - with pather.toolctx(M2_tool, keys=['GND']): - pather.path_to('GND', None, -40_000) - pather.path_to('GND', None, -50_000) + with pather.toolctx(M2_tool, keys='GND'): + pather.straight('GND', x=-40_000) + pather.straight('GND', x=-50_000) # Save the pather's pattern into our library - library['Pather_and_BasicTool'] = pather.pattern + library['Pather_and_PrimitiveOffers'] = pather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file library.map_layers(map_layer) diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 023079c..5c5c31b 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -2,7 +2,7 @@ Routines for creating normalized 2D lattices and common photonic crystal cavity designs. """ -from collection.abc import Sequence +from collections.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -50,7 +50,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise Exception(f'Invalid value for `origin`: {origin}') + raise ValueError(f'Invalid value for `origin`: {origin}') return xy[xy[:, 0].argsort(), :] @@ -197,12 +197,12 @@ def ln_defect( `[[x0, y0], [x1, y1], ...]` for all the holes """ if defect_length % 2 != 1: - raise Exception('defect_length must be odd!') - p = triangular_lattice([2 * d + 1 for d in mirror_dims]) + raise ValueError('defect_length must be odd!') + pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) - return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] + holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) + return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :] def ln_shift_defect( @@ -248,7 +248,7 @@ def ln_shift_defect( for sign in (-1, 1): x_val = sign * (x_removed + ind + 1) which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0) - xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) + xyr[which, :] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) return xyr @@ -309,7 +309,7 @@ def l3_shift_perturbed_defect( # which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2]) perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2))) - for row in xyr: - if numpy.fabs(row) in perturbed_holes: - row[2] = perturbed_radius + for xy in perturbed_holes: + which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) + xyr[which, 2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py new file mode 100644 index 0000000..8dabf82 --- /dev/null +++ b/examples/tutorial/port_pather.py @@ -0,0 +1,170 @@ +""" +PortPather tutorial: Using .at() syntax +""" +from masque import Pather, Pattern, Port, R90 +from masque.file.gdsii import writefile + +from basic_shapes import GDS_OPTS +from pather import map_layer, prepare_tools + + +def main() -> None: + # Reuse the same patterns (pads, bends, vias) and tools as in pather.py + library, M1_tool, M2_tool = prepare_tools() + + # Create a deferred Pather and place some initial pads (same as Pather tutorial) + rpather = Pather(library, tools=M2_tool, render='deferred') + + rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) + rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) + rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) + rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) + + # + # Routing with .at() chaining + # + # The .at(port_name) method returns a PortPather object which wraps the Pather + # and remembers the selected port(s). This allows method chaining. + + # Route VCC: 6um South, then West to x=0. + # (Note: since the port points North into the pad, trace() moves South by default) + (rpather.at('VCC') + .trace(False, length=6_000) # Move South, turn West (Clockwise) + .trace_to(None, x=0) # Continue West to x=0 + ) + + # Route GND: 5um South, then West to match VCC's x-coordinate. + rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x) + + + # + # Tool management and manual plugging + # + # We can use .retool() to change the tool for specific ports. + # We can also use .plug() directly on a PortPather. + + # Manually add a via to GND and switch to M1_tool for subsequent segments + (rpather.at('GND') + .plug('v1_via', 'top') + .retool(M1_tool) # this only retools the 'GND' port + ) + + # We can also pass multiple ports to .at(), and then route them together. + # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). + (rpather.at(['GND', 'VCC']) + .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South + .retool(M1_tool) # Retools both GND and VCC + .set_spacing(1_200) # Default bundle spacing for later bends + .trace(True, emax=50_000) # Turn East, moves 50um extension + .trace(False, emin=1_000) # U-turn back South + .trace(False, emin=2_000, spacing=4_500) # U-turn back West, overriding the default spacing + ) + + # Retool VCC back to M2 and move both to x=-28k + rpather.at('VCC').retool(M2_tool) + rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000) + + # Final segments to -50k + rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire') + with rpather.at('GND').toolctx(M2_tool): + rpather.at('GND').trace_to(None, x=-40_000) + rpather.at('GND').trace_to(None, x=-50_000) + + + # + # Branching with mark and fork + # + # .mark(new_name) creates a port copy and keeps the original selected. + # .fork(new_name) creates a port copy and selects the new one. + + # Create a tap on GND + (rpather.at('GND') + .trace(None, length=5_000) # Move GND further West + .mark('GND_TAP') # Mark this location for a later branch + .jog(offset=-10_000, length=10_000) # Continue GND with an S-bend + ) + + # Branch VCC and follow the new branch + (rpather.at('VCC') + .trace(None, length=5_000) + .fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' + .trace(True, length=5_000) # VCC_BRANCH turns South + ) + # The original 'VCC' port remains at x=-55k, y=VCC.y + + + # + # Port set management: add, drop, rename, delete + # + + # Route the GND_TAP we saved earlier. + (rpather.at('GND_TAP') + .retool(M1_tool) + .trace(True, length=10_000) # Turn South + .rename('GND_FEED') # Give it a more descriptive name + .retool(M1_tool) # Re-apply tool to the new name + ) + + # We can manage the active set of ports in a PortPather + pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) + pp.select('GND') # Now tracking 3 ports + pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND + pp.trace(None, each=5_000) # Move both 5um forward (length > transition size) + + # We can also delete ports from the pather entirely + rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) + + + # + # Advanced Connections: trace_into + # + # trace_into routes FROM the selected port TO a target port. + + # Create a destination component + dest_ports = { + 'in_A': Port((0, 0), rotation=R90, ptype='m2wire'), + 'in_B': Port((5_000, 0), rotation=R90, ptype='m2wire') + } + library['dest'] = Pattern(ports=dest_ports) + # Place dest so that its ports are to the West and South of our current wires. + # Rotating by pi/2 makes the ports face West (pointing East). + rpather.place('dest', offset=(-100_000, -100_000), rotation=R90, port_map={'in_A': 'DEST_A', 'in_B': 'DEST_B'}) + + # Connect GND_FEED to DEST_A + # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. + rpather.at('GND_FEED').trace_into('DEST_A') + + # Connect VCC_BRANCH to DEST_B + rpather.at('VCC_BRANCH').trace_into('DEST_B') + + + # + # Direct Port Transformations and Metadata + # + (rpather.at('GND') + .set_ptype('m1wire') # Change metadata + .translate((1000, 0)) # Shift the port 1um East + .rotate(R90 / 2) # Rotate it 45 degrees + .set_rotation(R90) # Force it to face West + ) + + # Demonstrate .plugged() to acknowledge a manual connection + # (Normally used when you place components so their ports perfectly overlap) + rpather.add_port_pair(offset=(0, 0), names=('TMP1', 'TMP2')) + rpather.at('TMP1').plugged('TMP2') # Removes both ports + + + # + # Rendering and Saving + # + # Since routing is deferred, we must call .render() to generate the geometry. + rpather.render() + + library['PortPather_Tutorial'] = rpather.pattern + library.map_layers(map_layer) + writefile(library, 'port_pather.gds', **GDS_OPTS) + print("Tutorial complete. Output written to port_pather.gds") + + +if __name__ == '__main__': + main() diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py index cb002f3..d048d65 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,9 +1,8 @@ """ -Manual wire routing tutorial: RenderPather an PathTool +Manual wire routing tutorial: deferred Pather and PathTool """ -from collections.abc import Callable -from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import PathTool +from masque import Pather, Library +from masque.builder import PathTool from masque.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -12,9 +11,9 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via def main() -> None: # - # To illustrate the advantages of using `RenderPather`, we use `PathTool` instead - # of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) - # but when used with `RenderPather`, it can consolidate multiple routing steps into + # To illustrate deferred routing with `Pather`, we use `PathTool` instead + # of `AutoTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) + # but when used with `Pather(render='deferred')`, it can consolidate multiple routing steps into # a single `Path` shape. # # We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py` @@ -25,66 +24,68 @@ def main() -> None: library = Library() library['pad'] = make_pad() library['v1_via'] = make_via( - layer_top='M2', - layer_via='V1', - layer_bot='M1', - width_top=M2_WIDTH, - width_via=V1_WIDTH, - width_bot=M1_WIDTH, - ptype_bot='m1wire', - ptype_top='m2wire', + layer_top = 'M2', + layer_via = 'V1', + layer_bot = 'M1', + width_top = M2_WIDTH, + width_via = V1_WIDTH, + width_bot = M1_WIDTH, + ptype_bot = 'm1wire', + ptype_top = 'm2wire', ) - # `PathTool` is more limited than `BasicTool`. It only generates one type of shape + # `PathTool` is more limited than `AutoTool`. It only generates one type of shape # (`Path`), so it only needs to know what layer to draw on, what width to draw with, # and what port type to present. M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire') M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') - rpather = RenderPather(tools=M2_ptool, library=library) + rpather = Pather(tools=M2_ptool, library=library, render='deferred') - # As in the pather tutorial, we make soem pads and labels... + # As in the pather tutorial, we make some pads and labels... rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) # ...and start routing the signals. - rpather.path('VCC', ccw=False, length=6_000) - rpather.path_to('VCC', ccw=None, x=0) - rpather.path('GND', 0, 5_000) - rpather.path_to('GND', None, x=rpather['VCC'].offset[0]) + rpather.cw('VCC', 6_000) + rpather.straight('VCC', x=0) + rpather.cw('GND', 5_000) + rpather.straight('GND', x=rpather.pattern['VCC'].x) # `PathTool` doesn't know how to transition betwen metal layers, so we have to # `plug` the via into the GND wire ourselves. rpather.plug('v1_via', {'GND': 'top'}) - rpather.retool(M1_ptool, keys=['GND']) - rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) + rpather.retool(M1_ptool, keys='GND') + rpather.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) # Same thing on the VCC wire when it goes down to M1. rpather.plug('v1_via', {'VCC': 'top'}) rpather.retool(M1_ptool) - rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) - rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) - rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) + rpather.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) + rpather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) + rpather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) # And again when VCC goes back up to M2. rpather.plug('v1_via', {'VCC': 'bottom'}) rpather.retool(M2_ptool) - rpather.mpath(['GND', 'VCC'], None, xmin=-28_000) + rpather.straight(['GND', 'VCC'], xmin=-28_000) # Finally, since PathTool has no conception of transitions, we can't # just ask it to transition to an 'm1wire' port at the end of the final VCC segment. # Instead, we have to calculate the via size ourselves, and adjust the final position # to account for it. - via_size = abs( - library['v1_via'].ports['top'].offset[0] - - library['v1_via'].ports['bottom'].offset[0] - ) - rpather.path_to('VCC', None, -50_000 + via_size) + v1pat = library['v1_via'] + via_size = abs(v1pat.ports['top'].x - v1pat.ports['bottom'].x) + + # alternatively, via_size = v1pat.ports['top'].measure_travel(v1pat.ports['bottom'])[0][0] + # would take into account the port orientations if we didn't already know they're along x + rpather.straight('VCC', x=-50_000 + via_size) rpather.plug('v1_via', {'VCC': 'top'}) + # Render the path we defined rpather.render() - library['RenderPather_and_PathTool'] = rpather.pattern + library['Deferred_Pather_and_PathTool'] = rpather.pattern # Convert from text-based layers to numeric layers for GDS, and output the file diff --git a/masque/__init__.py b/masque/__init__.py index 4ad7e69..b5dddde 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -42,6 +42,7 @@ from .error import ( from .shapes import ( Shape as Shape, Polygon as Polygon, + RectCollection as RectCollection, Path as Path, Circle as Circle, Arc as Arc, @@ -55,16 +56,23 @@ from .pattern import ( map_targets as map_targets, chain_elements as chain_elements, ) +from .utils.boolean import boolean as boolean from .library import ( ILibraryView as ILibraryView, ILibrary as ILibrary, LibraryView as LibraryView, Library as Library, + OverlayLibrary as OverlayLibrary, + PortsLibraryView as PortsLibraryView, + BuildLibrary as BuildLibrary, + BuildReport as BuildReport, + CellProvenance as CellProvenance, LazyLibrary as LazyLibrary, AbstractView as AbstractView, TreeView as TreeView, Tree as Tree, + cell as cell, ) from .ports import ( Port as Port, @@ -72,12 +80,9 @@ from .ports import ( ) from .abstract import Abstract as Abstract from .builder import ( - Builder as Builder, Tool as Tool, Pather as Pather, - RenderPather as RenderPather, RenderStep as RenderStep, - SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, PortPather as PortPather, diff --git a/masque/abstract.py b/masque/abstract.py index 7135eba..d23d7c7 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,16 +8,13 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d - -#if TYPE_CHECKING: -# from .builder import Builder, Tool -# from .library import ILibrary +from .traits import Mirrorable logger = logging.getLogger(__name__) -class Abstract(PortList): +class Abstract(PortList, Mirrorable): """ An `Abstract` is a container for a name and associated ports. @@ -131,50 +128,18 @@ class Abstract(PortList): port.rotate(rotation) return self - def mirror_port_offsets(self, across_axis: int = 0) -> Self: + def mirror(self, axis: int = 0) -> Self: """ - Mirror the offsets of all shapes, labels, and refs across an axis + Mirror the Abstract across an axis through its origin. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across (0: x-axis, 1: y-axis). Returns: self """ for port in self.ports.values(): - port.offset[across_axis - 1] *= -1 - return self - - def mirror_ports(self, across_axis: int = 0) -> Self: - """ - Mirror each port's rotation across an axis, relative to its - offset - - Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - for port in self.ports.values(): - port.mirror(across_axis) - return self - - def mirror(self, across_axis: int = 0) -> Self: - """ - Mirror the Pattern across an axis - - Args: - axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - self.mirror_ports(across_axis) - self.mirror_port_offsets(across_axis) + port.flip_across(axis=axis) return self def apply_ref_transform(self, ref: Ref) -> Self: @@ -192,6 +157,8 @@ class Abstract(PortList): self.mirror() self.rotate_ports(ref.rotation) self.rotate_port_offsets(ref.rotation) + if ref.scale != 1: + self.scale_by(ref.scale) self.translate_ports(ref.offset) return self @@ -209,6 +176,8 @@ class Abstract(PortList): # TODO test undo_ref_transform """ self.translate_ports(-ref.offset) + if ref.scale != 1: + self.scale_by(1 / ref.scale) self.rotate_port_offsets(-ref.rotation) self.rotate_ports(-ref.rotation) if ref.mirrored: diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 2fd00a4..5bd3914 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,12 +1,58 @@ -from .builder import Builder as Builder -from .pather import Pather as Pather -from .renderpather import RenderPather as RenderPather -from .pather_mixin import PortPather as PortPather +""" +Builder helpers for port-based assembly and primitive-offer routing. + +A routing `Tool` describes the primitive route families it can provide by +returning `PrimitiveOffer` objects. Each offer is a parameterized planning +candidate: it exposes legal parameter domains, endpoint behavior, ptypes, cost, +optional footprint metadata, and a commit hook for producing tool-specific +render data after a concrete parameter has been selected. + +`Pather` owns user-facing route operations such as `trace()`, `jog()`, +`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for +offers and passes those offers plus route constraints to the internal router. +The router selects a sequence of internal selected primitives, each pairing an +offer with a concrete parameter, endpoint, and cost. + +Route commit is separate from route selection. Once a route is selected, +`Pather` calls `offer.commit(parameter)` only for the selected primitives and +stores the returned opaque tool payload in `RenderStep.data`. Later, +`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to +turn those committed payloads into geometry. + +`PrimitiveOffer` and `RenderStep.data` are the tool-facing contract. +`RenderStep` is `Pather`'s deferred-render record, and +`masque.builder.planner` is an internal planner implementation rather than a +stable public API. + +The practical layering is: +- user code drives `Pather` and chooses Tools per port or by default, +- Tools describe local legal motion primitives without touching Pather state, +- the internal router composes those primitives into high-level route shapes, +- Pather applies the prepared result to ports, deferred render queues, and the + target pattern/library. + +Code outside the builder package should prefer the exports here over importing +from `masque.builder.planner`. The planner package is intentionally available +for tests and internal maintenance, but it is not the compatibility boundary +for custom Tools. +""" + +from .pather import ( + Pather as Pather, + PortPather as PortPather, +) from .utils import ell as ell from .tools import ( Tool as Tool, - RenderStep as RenderStep, - SimpleTool as SimpleTool, AutoTool as AutoTool, PathTool as PathTool, - ) + RenderStep as RenderStep, + PrimitiveKind as PrimitiveKind, + GeneratedEndpointFn as GeneratedEndpointFn, + PrimitiveOffer as PrimitiveOffer, + StraightOffer as StraightOffer, + BendOffer as BendOffer, + SOffer as SOffer, + UOffer as UOffer, + circular_arc_sbend_endpoint as circular_arc_sbend_endpoint, +) diff --git a/masque/builder/builder.py b/masque/builder/builder.py deleted file mode 100644 index 1b534b5..0000000 --- a/masque/builder/builder.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -Simplified Pattern assembly (`Builder`) -""" -from typing import Self -from collections.abc import Iterable, Sequence, Mapping -import copy -import logging -from functools import wraps - -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError -from ..ports import PortList, Port -from ..abstract import Abstract - - -logger = logging.getLogger(__name__) - - -class Builder(PortList): - """ - A `Builder` is a helper object used for snapping together multiple - lower-level patterns at their `Port`s. - - The `Builder` mostly just holds context, in the form of a `Library`, - in addition to its underlying pattern. This simplifies some calls - to `plug` and `place`, by making the library implicit. - - `Builder` can also be `set_dead()`, at which point further calls to `plug()` - and `place()` are ignored (intended for debugging). - - - Examples: Creating a Builder - =========================== - - `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes - an empty pattern, adds the given ports, and places it into `library` - under the name `'mypat'`. - - - `Builder(library)` makes an empty pattern with no ports. The pattern - is not added into `library` and must later be added with e.g. - `library['mypat'] = builder.pattern` - - - `Builder(library, pattern=pattern, name='mypat')` uses an existing - pattern (including its ports) and sets `library['mypat'] = pattern`. - - - `Builder.interface(other_pat, port_map=['A', 'B'], library=library)` - makes a new (empty) pattern, copies over ports 'A' and 'B' from - `other_pat`, and creates additional ports 'in_A' and 'in_B' facing - in the opposite directions. This can be used to build a device which - can plug into `other_pat` (using the 'in_*' ports) but which does not - itself include `other_pat` as a subcomponent. - - - `Builder.interface(other_builder, ...)` does the same thing as - `Builder.interface(other_builder.pattern, ...)` but also uses - `other_builder.library` as its library by default. - - - Examples: Adding to a pattern - ============================= - - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` - instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' - of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports - are removed and any unconnected ports from `subdevice` are added to - `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. - - - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `thru` argument is not explicitly - set to `False`, the unconnected port of `wire` is automatically renamed to - 'myport'. This allows easy extension of existing ports without changing - their names or having to provide `map_out` each time `plug` is called. - - - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` - instantiates `pad` at the specified (x, y) offset and with the specified - rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is - renamed to 'gnd' so that further routing can use this signal or net name - rather than the port name on the original `pad` device. - """ - __slots__ = ('pattern', 'library', '_dead') - - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ - Library from which patterns should be referenced - """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging)""" - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - name: str | None = None, - ) -> None: - """ - Args: - library: The library from which referenced patterns will be taken - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - name: If specified, `library[name]` is set to `self.pattern`. - """ - self._dead = False - self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if name is not None: - library[name] = self.pattern - - @classmethod - def interface( - cls: type['Builder'], - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - ) -> 'Builder': - """ - Wrapper for `Pattern.interface()`, which returns a Builder instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new builder, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library was given, and `source.library` does not have one either.') - - if isinstance(source, str): - source = library.abstract(source).ports - - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = Builder(library=library, pattern=pat, name=name) - return new - - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - - # Note: We're a superclass of `Pather`, where path() means something different, - # so we shouldn't wrap Pattern.path() - #@wraps(Pattern.path) - #def path(self, *args, **kwargs) -> Self: - # self.pattern.path(*args, **kwargs) - # return self - - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - """ - Wrapper around `Pattern.plug` which allows a string for `other`. - - The `Builder`'s library is used to dereference the string (or `Abstract`, if - one is passed with `append=True`). If a `TreeView` is passed, it is first - added into `self.library`. - - Args: - other: An `Abstract`, string, `Pattern`, or `TreeView` describing the - device to be instatiated. If it is a `TreeView`, it is first - added into `self.library`, after which the topcell is plugged; - an equivalent statement is `self.plug(self.library << other, ...)`. - map_in: dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - map_out: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in `other`. - mirrored: Enables mirroring `other` across the x axis prior to - connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices - (e.g. wires) without providing `map_out` each time `plug` is - called. See "Examples" above for more info. Default `True`. - set_rotation: If the necessary rotation cannot be determined from - the ports being connected (i.e. all pairs have at least one - port with `rotation=None`), `set_rotation` must be provided - to indicate how much `other` should be rotated. Otherwise, - `set_rotation` must remain `None`. - 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`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other_names`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - `PortError` if the specified port mapping is not achieveable (the ports - do not line up) - """ - if self._dead: - logger.error('Skipping plug() since device is dead') - return self - - if not isinstance(other, str | Abstract | Pattern): - # We got a Tree; add it into self.library and grab an Abstract for it - other = self.library << other - - if isinstance(other, str): - other = self.library.abstract(other) - if append and isinstance(other, Abstract): - other = self.library[other.name] - - self.pattern.plug( - other = other, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, - ) - return self - - def place( - self, - other: Abstract | str | Pattern | TreeView, - *, - offset: ArrayLike = (0, 0), - rotation: float = 0, - pivot: ArrayLike = (0, 0), - mirrored: bool = False, - port_map: dict[str, str | None] | None = None, - skip_port_check: bool = False, - append: bool = False, - ) -> Self: - """ - Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`. - - The `Builder`'s library is used to dereference the string (or `Abstract`, if - one is passed with `append=True`). If a `TreeView` is passed, it is first - added into `self.library`. - - Args: - other: An `Abstract`, string, `Pattern`, or `TreeView` describing the - device to be instatiated. If it is a `TreeView`, it is first - added into `self.library`, after which the topcell is plugged; - an equivalent statement is `self.plug(self.library << other, ...)`. - offset: Offset at which to place the instance. Default (0, 0). - rotation: Rotation applied to the instance before placement. Default 0. - pivot: Rotation is applied around this pivot point (default (0, 0)). - Rotation is applied prior to translation (`offset`). - mirrored: Whether theinstance should be mirrored across the x axis. - Mirroring is applied before translation and rotation. - port_map: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in the instantiated device. New names can be - `None`, which will delete those ports. - skip_port_check: Can be used to skip the internal call to `check_ports`, - in case it has already been performed elsewhere. - 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`). - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other.ports`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - """ - if self._dead: - logger.error('Skipping place() since device is dead') - return self - - if not isinstance(other, str | Abstract | Pattern): - # We got a Tree; add it into self.library and grab an Abstract for it - other = self.library << other - - if isinstance(other, str): - other = self.library.abstract(other) - if append and isinstance(other, Abstract): - other = self.library[other.name] - - self.pattern.place( - other = other, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, - ) - return self - - def translate(self, offset: ArrayLike) -> Self: - """ - Translate the pattern and all ports. - - Args: - offset: (x, y) distance to translate by - - Returns: - self - """ - self.pattern.translate_elements(offset) - return self - - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - """ - Rotate the pattern and all ports. - - Args: - angle: angle (radians, counterclockwise) to rotate by - pivot: location to rotate around - - Returns: - self - """ - self.pattern.rotate_around(pivot, angle) - for port in self.ports.values(): - port.rotate_around(pivot, angle) - return self - - def mirror(self, axis: int = 0) -> Self: - """ - Mirror the pattern and all ports across the specified axis. - - Args: - axis: Axis to mirror across (x=0, y=1) - - Returns: - self - """ - self.pattern.mirror(axis) - return self - - def set_dead(self) -> Self: - """ - Disallows further changes through `plug()` or `place()`. - 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.pattern.visualize() # shows the device as of the set_dead() call - ``` - - Returns: - self - """ - self._dead = True - return self - - def __repr__(self) -> str: - s = f'' - return s - - diff --git a/masque/builder/logging.py b/masque/builder/logging.py new file mode 100644 index 0000000..e7cfb16 --- /dev/null +++ b/masque/builder/logging.py @@ -0,0 +1,80 @@ +"""Logging helpers for Pather.""" +from typing import TYPE_CHECKING, Any +from collections.abc import Iterator, Sequence +import logging +import numpy +from contextlib import contextmanager + +if TYPE_CHECKING: + from .pather import Pather + + +def _format_log_args(**kwargs) -> str: + arg_strs = [] + for k, v in kwargs.items(): + if isinstance(v, str | int | float | bool | None): + arg_strs.append(f"{k}={v}") + elif isinstance(v, numpy.ndarray): + arg_strs.append(f"{k}={v.tolist()}") + elif isinstance(v, list | tuple) and len(v) <= 10: + arg_strs.append(f"{k}={v}") + else: + arg_strs.append(f"{k}=...") + return ", ".join(arg_strs) + + +class PatherLogger: + """ + Encapsulates state for Pather diagnostic logging. + """ + debug: bool + indent: int + depth: int + + def __init__(self, debug: bool = False) -> None: + self.debug = debug + self.indent = 0 + self.depth = 0 + + def _log(self, module_name: str, msg: str) -> None: + if self.debug and self.depth <= 1: + log_obj = logging.getLogger(module_name) + log_obj.info(' ' * self.indent + msg) + + @contextmanager + def log_operation( + self, + pather: 'Pather', + op: str, + portspec: str | Sequence[str] | None = None, + **kwargs: Any, + ) -> Iterator[None]: + if not self.debug or self.depth > 0: + self.depth += 1 + try: + yield + finally: + self.depth -= 1 + return + + target = f"({portspec})" if portspec else "" + module_name = pather.__class__.__module__ + self._log(module_name, f"Operation: {op}{target} {_format_log_args(**kwargs)}") + + before_ports = {name: port.copy() for name, port in pather.ports.items()} + self.depth += 1 + self.indent += 1 + + try: + yield + finally: + after_ports = pather.ports + for name in sorted(after_ports.keys()): + if name not in before_ports or after_ports[name] != before_ports[name]: + self._log(module_name, f"Port {name}: {pather.ports[name].describe()}") + for name in sorted(before_ports.keys()): + if name not in after_ports: + self._log(module_name, f"Port {name}: removed") + + self.indent -= 1 + self.depth -= 1 diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 9af473d..cbf87f4 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,124 +1,174 @@ """ -Manual wire/waveguide routing (`Pather`) +Unified Pattern assembly and routing (`Pather`). + +`Pather` is the public object that owns layout state. It holds the working +Pattern, Library, per-port Tool assignments, pending `RenderStep`s, and routing +side effects such as plug consumption, port renames, and automatic rendering. The +planner package is intentionally internal: custom route generators should +extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on +planner classes or search details. + +Routing is split into four ownership phases: +- snapshot: `Pather` resolves the active Tool for each requested port and + passes copied ports to the planner as `RoutePortContext` values, +- selection/preparation: the planner validates the route mode, selects a legal + primitive-offer composition, commits only the chosen offers into opaque + `RenderStep.data`, and returns prepared actions, +- application: `Pather` appends the prepared steps to its pending queue, + replaces live output ports, consumes plug destinations, applies deferred + trace-thru renames, and batches immediate rendering around the whole route, +- rendering: pending steps are grouped by live port, Tool, and continuity before + `Tool.render()` builds geometry for each compatible batch. + +This split keeps selection failures largely transactional for live Pather +state: unsupported primitive combinations can fail before the Pattern, pending +step queue, or Library are touched. Once selected offers are committed and +prepared actions are applied, later commit, plug, rename, render, or insertion +failures may leave partial output; that mutation boundary belongs to Pather, +not to Tool implementations or the route solver. """ -from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping +from typing import Self, Any, Literal, overload +from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence import copy import logging +from collections import defaultdict +from functools import wraps from pprint import pformat +from contextlib import contextmanager +from itertools import chain +from types import TracebackType + +import numpy +from numpy import pi +from numpy.typing import ArrayLike from ..pattern import Pattern -from ..library import ILibrary -from ..error import BuildError +from ..library import ILibrary, TreeView, SINGLE_USE_PREFIX +from ..error import BuildError, PortError from ..ports import PortList, Port -from ..utils import SupportsBool -from .tools import Tool -from .pather_mixin import PatherMixin -from .builder import Builder +from ..abstract import Abstract +from ..utils import SupportsBool, ptypes_compatible +from .tools import ( + Tool, + RenderStep, + ) +from .planner.interface import ( + PreparedRouteResult, + RoutePortContext, + route_error_is_fatal, + ) +from .planner import ( + RouteTieBreakStrategy, + RoutingPlanner, + ) +from .planner.bounds import resolved_position_bound +from .logging import PatherLogger logger = logging.getLogger(__name__) +RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore'] +RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore') -class Pather(Builder, PatherMixin): +class Pather(PortList): """ - An extension of `Builder` which provides functionality for routing and attaching - single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. + A `Pather` is a helper object used for snapping together multiple + lower-level patterns at their `Port`s, and for routing single-use + patterns (e.g. wires or waveguides) between them. - `Pather` is mostly concerned with calculating how long each wire should be. It calls - out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns. - `Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents - a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned. + The `Pather` holds context in the form of a `Library`, its underlying + pattern, and a set of `Tool`s for generating routing segments. + Routing operations (`trace`, `jog`, `uturn`, etc.) select primitive offers + from the active `Tool` and compose them into `RenderStep`s. By default, + geometry is rendered after each route, unless the `Pather` is used as a + context manager. Examples: Creating a Pather =========================== - - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. The pattern - is not added into `library` and must later be added with e.g. - `library['mypat'] = pather.pattern`. - The default wire/waveguide generating tool for all ports is set to `my_tool`. - - - `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)` - makes an empty pattern, adds the given ports, and places it into `library` - under the name `'mypat'`. The default wire/waveguide generating tool - for all ports is set to `my_tool` - - - `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})` - assigns specific tools to individual ports, and `my_tool` as a default for ports - which are not specified. - - - `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)` - makes a new (empty) pattern, copies over ports 'A' and 'B' from - `other_pat`, and creates additional ports 'in_A' and 'in_B' facing - in the opposite directions. This can be used to build a device which - can plug into `other_pat` (using the 'in_*' ports) but which does not - itself include `other_pat` as a subcomponent. - - - `Pather.interface(other_pather, ...)` does the same thing as - `Builder.interface(other_builder.pattern, ...)` but also uses - `other_builder.library` as its library by default. + - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. + The default routing tool for all ports is set to `my_tool`. + - `Pather(library, name='mypat')` makes an empty pattern and adds it to + `library` under the name `'mypat'`. Examples: Adding to a pattern ============================= - - `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output - port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees - counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged - into the existing `'my_port'`, causing the port to move to the wire's output. + - `pather.plug(subdevice, {'A': 'C'})` instantiates `subdevice` and + connects port 'A' of the current pattern to port 'C' of `subdevice`. - There is no formal guarantee about how far off-axis the output will be located; - there may be a significant width to the bend that is used to accomplish the 90 degree - turn. However, an error is raised if `distance` is too small to fit the bend. - - - `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length - of `distance` and `plug`s it into `'my_port'`. - - - `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at - `'my_port'` and has its output at the specified `position`, pointing 90 degrees - clockwise relative to the input. Again, the off-axis position or distance to the - output is not specified, so `position` takes the form of a single coordinate. To - ease debugging, position may be specified as `x=position` or `y=position` and an - error will be raised if the wrong coordinate is given. - - - `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path` - and `path_to` which can act on multiple ports simultaneously. Each port's wire is - generated using its own `Tool` (or the default tool if left unspecified). - The output ports are spaced out by `spacing` along the input ports' axis, unless - `ccw=None` is specified (i.e. no bends) in which case they all end at the same - destination coordinate. - - - `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - of `pather.pattern`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `inherit_name` argument is not explicitly - set to `False`, the unconnected port of `wire` is automatically renamed to - 'myport'. This allows easy extension of existing ports without changing - their names or having to provide `map_out` each time `plug` is called. - - - `pather.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` - instantiates `pad` at the specified (x, y) offset and with the specified - rotation, adding its ports to those of `pather.pattern`. Port 'A' of `pad` is - renamed to 'gnd' so that further routing can use this signal or net name - rather than the port name on the original `pad` device. - - - `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change - which tool is used for the given ports (or as the default tool). Useful - when placing vias or using multiple waveguide types along a route. + - `pather.trace('my_port', ccw=True, length=100)` plans a 100-unit bend + starting at 'my_port'. If the `Pather` is used as a context manager, + geometry is generated on clean context exit. """ - __slots__ = ('tools',) + __slots__ = ( + 'pattern', 'library', 'tools', 'planner', '_paths', + '_dead', '_logger', '_render_policy', '_render_append', '_context_depth' + ) + + pattern: Pattern + """ Layout of this device """ library: ILibrary - """ - Library from which existing patterns should be referenced, and to which - new ones should be added - """ + """ Library from which patterns should be referenced """ tools: dict[str | None, Tool] """ - Tool objects are used to dynamically generate new single-use `Pattern`s - (e.g wires or waveguides) to be plugged into this device. A key of `None` - indicates the default `Tool`. + Tool objects used to dynamically generate new routing segments. + A key of `None` indicates the default `Tool`. """ + planner: RoutingPlanner + """ + Stateless route-selection facade. + + Per-solve mutable state belongs in routing search/catalog objects rather + than on the planner instance. + """ + + _dead: bool + """ If True, geometry generation is skipped (for debugging) """ + + _logger: PatherLogger + """ Handles diagnostic logging of operations """ + + _render_policy: RenderPolicy + """ Routing render behavior """ + + _render_append: bool + """ If True, automatic render calls append geometry instead of adding references """ + + _context_depth: int + """ Number of active context-manager entries """ + + _paths: defaultdict[str, list[RenderStep]] + """ Per-port pending render steps, consumed by `render()` """ + + def _route_context(self, portspec: str) -> RoutePortContext: + """ + Snapshot the live port and selected Tool for planning. + + The port copy lets route-selection failures leave live Pather state + unchanged. Tool lookup prefers a port-specific Tool and falls back to + the `None` default. + """ + tool = self.tools.get(portspec, self.tools.get(None)) + if tool is None: + raise BuildError(f'No tool assigned for port {portspec}') + return RoutePortContext(portspec, self.pattern[portspec].copy(), tool) + + def _route_contexts(self, portspecs: Sequence[str]) -> tuple[RoutePortContext, ...]: + """Snapshot several ports in request order for bundle planning.""" + return tuple(self._route_context(portspec) for portspec in portspecs) + + @property + def ports(self) -> dict[str, Port]: + return self.pattern.ports + + @ports.setter + def ports(self, value: dict[str, Port]) -> None: + self.pattern.ports = value + def __init__( self, library: ILibrary, @@ -127,40 +177,52 @@ class Pather(Builder, PatherMixin): ports: str | Mapping[str, Port] | None = None, tools: Tool | MutableMapping[str | None, Tool] | None = None, name: str | None = None, + debug: bool = False, + render: RenderPolicy = 'auto', + render_append: bool = True, + planner: RoutingPlanner | None = None, ) -> None: """ Args: - library: The library from which referenced patterns will be taken, - and where new patterns (e.g. generated by the `tools`) will be placed. - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - tools: A mapping of {port: tool} which specifies what `Tool` should be used - to generate waveguide or wire segments when `path`/`path_to`/`mpath` - are called. Relies on `Tool.path` implementations. + library: The library for pattern references and generated segments. + pattern: The pattern to modify. If `None`, a new one is created. + ports: Initial set of ports. May be a string (name in `library`) + or a port mapping. + tools: Tool(s) to use for routing segments. name: If specified, `library[name]` is set to `self.pattern`. + debug: If True, enables detailed logging. + render: Routing render policy. `'auto'` renders after each route + outside a context manager and defers until clean context exit + inside one. Use `'immediate'` to always render after each route, + `'deferred'` to keep paths pending until `render()` or clean + context exit, `'warn'` to log pending paths on clean context + exit, `'error'` to reject pending paths on clean context exit, + or `'ignore'` to leave pending paths silent. + render_append: If an automatic render is triggered, determines + whether to append geometry or add a reference. + planner: Optional stateless route-selection planner. If omitted, + a new `RoutingPlanner` is used. """ + if render not in RENDER_POLICIES: + raise BuildError(f'Invalid render policy {render!r}; expected one of {RENDER_POLICIES}') + self._dead = False + self._logger = PatherLogger(debug=debug) + self._render_policy = render + self._render_append = render_append + self._context_depth = 0 self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() + self.pattern = pattern if pattern is not None else Pattern() + self.planner = RoutingPlanner() if planner is None else planner + self._paths = defaultdict(list) if ports is not None: if self.pattern.ports: raise BuildError('Ports supplied for pattern with pre-existing ports!') if isinstance(ports, str): ports = library.abstract(ports).ports - self.pattern.ports.update(copy.deepcopy(dict(ports))) - if name is not None: - library[name] = self.pattern - if tools is None: self.tools = {} elif isinstance(tools, Tool): @@ -168,29 +230,690 @@ class Pather(Builder, PatherMixin): else: self.tools = dict(tools) - @classmethod - def from_builder( - cls: type['Pather'], - builder: Builder, + if name is not None: + library[name] = self.pattern + + def __enter__(self) -> Self: + self._context_depth += 1 + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + _ = exc_value, traceback + self._context_depth -= 1 + if exc_type is not None or self._context_depth != 0 or not any(self._paths.values()): + return False + + if self._render_policy in ('auto', 'deferred'): + self.render(append=self._render_append) + elif self._render_policy == 'warn': + logger.warning( + 'Pather context exited with %s; call render() or use render="deferred"', + self._pending_render_summary(), + ) + elif self._render_policy == 'error': + raise BuildError(f'Pather context exited with {self._pending_render_summary()}') + return False + + def _pending_render_summary(self) -> str: + ports = [(portspec, len(steps)) for portspec, steps in self._paths.items() if steps] + port_count = len(ports) + step_count = sum(count for _portspec, count in ports) + return ( + f'{step_count} pending render step{"s" if step_count != 1 else ""} ' + f'on {port_count} port{"s" if port_count != 1 else ""}' + ) + + def __repr__(self) -> str: + s = f'' + return s + + # + # Core Pattern Operations (Immediate) + # + def _prepare_breaks(self, names: Iterable[str | None]) -> list[tuple[str, RenderStep]]: + """ Snapshot break markers to be committed after a successful mutation. """ + prepared: list[tuple[str, RenderStep]] = [] + if self._dead: + return prepared + for name in names: + if name is None: + continue + steps = self._paths.get(name) + if not steps: + continue + port = self.ports.get(name, steps[-1].end_port) + prepared.append((name, RenderStep('P', None, port.copy(), port.copy(), None))) + return prepared + + def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: + """ Append previously prepared break markers. """ + for name, step in prepared: + self._paths[name].append(step) + + def plug( + self, + other: Abstract | str | Pattern | TreeView, + map_in: dict[str, str], + map_out: dict[str, str | None] | None = None, + **kwargs, + ) -> Self: + with self._logger.log_operation(self, 'plug', list(map_in.keys()), map_out=map_out, **kwargs): + other = self.library.resolve(other, append=kwargs.get('append', False)) + + prepared_breaks: list[tuple[str, RenderStep]] = [] + if not self._dead: + other_ports = other.ports + affected = set(map_in.keys()) + plugged = set(map_in.values()) + for name in other_ports: + if name not in plugged: + new_name = (map_out or {}).get(name, name) + if new_name is not None: + affected.add(new_name) + prepared_breaks = self._prepare_breaks(affected) + elif self._logger.debug: + logger.warning("Skipping geometry for plug() since device is dead") + + self.pattern.plug(other=other, map_in=map_in, map_out=map_out, skip_geometry=self._dead, **kwargs) + self._commit_breaks(prepared_breaks) + return self + + def place( + self, + other: Abstract | str | Pattern | TreeView, + port_map: dict[str, str | None] | None = None, + **kwargs, + ) -> Self: + with self._logger.log_operation(self, 'place', None, port_map=port_map, **kwargs): + other = self.library.resolve(other, append=kwargs.get('append', False)) + + prepared_breaks: list[tuple[str, RenderStep]] = [] + if not self._dead: + other_ports = other.ports + affected = set() + for name in other_ports: + new_name = (port_map or {}).get(name, name) + if new_name is not None: + affected.add(new_name) + prepared_breaks = self._prepare_breaks(affected) + elif self._logger.debug: + logger.warning("Skipping geometry for place() since device is dead") + + self.pattern.place(other=other, port_map=port_map, skip_geometry=self._dead, **kwargs) + self._commit_breaks(prepared_breaks) + return self + + def plugged(self, connections: dict[str, str]) -> Self: + with self._logger.log_operation(self, 'plugged', list(connections.keys()), connections=connections): + prepared_breaks = self._prepare_breaks(chain(connections.keys(), connections.values())) + self.pattern.plugged(connections) + self._commit_breaks(prepared_breaks) + return self + + def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self: + with self._logger.log_operation(self, 'rename_ports', list(mapping.keys()), mapping=mapping, overwrite=overwrite): + winners = self.pattern._rename_ports_impl( + mapping, + overwrite=overwrite or self._dead, + allow_collisions=self._dead, + ) + + moved_steps = {kk: self._paths.pop(kk) for kk in mapping if kk in self._paths} + for kk, steps in moved_steps.items(): + vv = mapping[kk] + # Preserve deferred geometry even if the live port is deleted. + # `render()` can still materialize the saved steps using their stored start/end ports. + # Current semantics intentionally keep deleted ports' queued steps under the old key, + # so if a new live port later reuses that name it does not retarget the old geometry; + # the old and new routes merely share a render bucket until `render()` consumes them. + target = kk if vv is None else vv + if self._dead and vv is not None and winners.get(vv) != kk: + target = kk + self._paths[target].extend(steps) + return self + + def set_dead(self) -> Self: + self._dead = True + return self + + # + # Pattern Wrappers + # + @wraps(Pattern.label) + def label(self, *args, **kwargs) -> Self: + self.pattern.label(*args, **kwargs) + return self + + @wraps(Pattern.ref) + def ref(self, *args, **kwargs) -> Self: + self.pattern.ref(*args, **kwargs) + return self + + @wraps(Pattern.polygon) + def polygon(self, *args, **kwargs) -> Self: + self.pattern.polygon(*args, **kwargs) + return self + + @wraps(Pattern.rect) + def rect(self, *args, **kwargs) -> Self: + self.pattern.rect(*args, **kwargs) + return self + + @wraps(Pattern.path) + def path(self, *args, **kwargs) -> Self: + self.pattern.path(*args, **kwargs) + return self + + def translate(self, offset: ArrayLike) -> Self: + with self._logger.log_operation(self, 'translate', list(self.ports.keys()), offset=offset): + offset_arr = 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: + with self._logger.log_operation(self, 'rotate_around', list(self.ports.keys()), pivot=pivot, angle=angle): + pivot_arr = 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 = 0) -> Self: + with self._logger.log_operation(self, 'mirror', list(self.ports.keys()), axis=axis): + self.pattern.mirror(axis) + for steps in self._paths.values(): + for i, step in enumerate(steps): + steps[i] = step.mirrored(axis) + return self + + def mkport(self, name: str, value: Port) -> Self: + with self._logger.log_operation(self, 'mkport', name, value=value): + super().mkport(name, value) + return self + + # + # Routing Logic (Deferred / Incremental) + # + def _apply_route_result(self, result: PreparedRouteResult) -> None: + """ + Apply every action and deferred rename in a prepared route result. + + Route actions may contain several primitive render steps and port + mutations. Immediate rendering happens once after the whole prepared + result has been applied. + """ + for action in result.actions: + if not action.render_steps: + raise BuildError('Prepared route action has no render steps') + + if not self._dead: + self._paths[action.portspec].extend(action.render_steps) + + self.pattern.ports[action.portspec] = action.final_port.copy() + + if action.plug_into is not None: + self.plugged({action.portspec: action.plug_into}) + for old_name, new_name in result.renames: + self.rename_ports({old_name: new_name}) + render_immediately = ( + self._render_policy == 'immediate' + or (self._render_policy == 'auto' and self._context_depth == 0) + ) + if render_immediately and any(self._paths.values()): + self.render(append=self._render_append) + + def _apply_dead_fallback( + self, + portspec: str, + length: float, + jog: float, + ccw: SupportsBool | None, + in_ptype: str, + plug_into: str | None = None, *, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - ) -> 'Pather': + out_rot: float | None = None, + out_ptype: str | None = None, + ) -> None: """ - Construct a `Pather` by adding tools to a `Builder`. + Move a dead Pather port without generating geometry. - Args: - builder: Builder to turn into a Pather - tools: Tools for the `Pather` - - Returns: - A new Pather object, using `builder.library` and `builder.pattern`. + Dead fallback is only for debugging or dry layout flow. Fatal route + errors bypass it because they indicate an invalid Tool offer contract. """ - new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) - return new + if out_rot is None: + if ccw is None: + out_rot = pi + elif bool(ccw): + out_rot = -pi / 2 + else: + out_rot = pi / 2 + logger.warning(f"Tool planning failed for dead pather. Using dummy extension for {portspec}.") + port = self.pattern[portspec] + port_rot = port.rotation + if port_rot is None: + raise PortError('Ports must have rotation') + out_port = Port((length, jog), rotation=out_rot, ptype=out_ptype or in_ptype) + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(port.offset) + self.pattern.ports[portspec] = out_port + if plug_into is not None: + self.plugged({portspec: plug_into}) + # + # High-level Routing Methods + # + def trace( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> Self: + """ + Route one or more ports using straight segments or single 90-degree bends. + + Provide exactly one routing mode: + - `length` for a single port, + - `each` to extend each selected port independently by the same amount, or + - one bundle bound such as `xmin`, `emax`, or `min_past_furthest`. + + For a single port with no length or bound, legal primitive-offer + candidates are evaluated at their minimum legal length-like parameters, + then cost selects among those minimum-length candidates. `out_ptype`, + when provided, constrains only the final route endpoint. `strategy` + controls straight-first vs turn-first ordering only after cost and + structural tie-breakers. + + `spacing` and `set_rotation` are only valid when using a bundle bound. + """ + with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, strategy=strategy, **bounds): + if isinstance(portspec, str): + portspec = [portspec] + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or route_error_is_fatal(err): + raise + if length is not None and len(contexts) == 1: + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + if bounds.get('each') is not None: + each = bounds['each'] + for context in contexts: + self._apply_dead_fallback( + context.portspec, + each, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + raise + self._apply_route_result(result) + return self + + def trace_to( + self, + portspec: str | Sequence[str], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> Self: + """ + Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds. + + Exactly one of `p`, `pos`, `position`, `x`, or `y` may be used as a positional + bound. Positional bounds are only valid for a single port and may not be combined + with `length`, `spacing`, `each`, or bundle-bound keywords such as `xmin`/`emax`. + + With no positional or bundle bound, single-port `trace_to()` uses the + same omitted minimum-length primitive-offer behavior as `trace()`. + `strategy` controls straight-first vs turn-first ordering only after + cost and structural tie-breakers. + """ + with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, strategy=strategy, **bounds): + if isinstance(portspec, str): + portspec = [portspec] + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + raise + if bounds.get('length') is not None: + length = bounds['length'] + else: + resolved = resolved_position_bound( + contexts[0].port, + bounds, + allow_length=False, + ) + if resolved is None: + raise + _key, _value, length = resolved + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + ccw, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + self._apply_route_result(result) + return self + + def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + return self.trace_to(portspec, None, length=length, **bounds) + + def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self: + return self.trace_to(portspec, ccw, length=length, **bounds) + + def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + return self.bend(portspec, True, length, **bounds) + + def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: + return self.bend(portspec, False, length, **bounds) + + def jog( + self, + portspec: str | Sequence[str], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> Self: + """ + Route an S-bend. + + `length` is the along-travel displacement. If omitted and no positional + bound is supplied, a single-port jog evaluates legal S-like candidates + at their minimum legal length or primitive endpoint length for the + requested offset, then cost selects among those candidates. If exactly + one positional bound (`p`, `pos`, `position`, `x`, or `y`) is supplied, + the required travel distance is derived from that bound. + + Multi-port jogs require `spacing`; the innermost first-bend port uses + the base `length` or omitted-length solve, and other ports derive exact + route lengths and offsets from that base route. `out_ptype`, when + provided, constrains only each final route endpoint. `strategy` + controls straight-first vs S-first ordering only after cost and + structural tie-breakers. + """ + with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds): + if isinstance(portspec, str): + portspec = [portspec] + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds) + except (BuildError, NotImplementedError) as err: + if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + raise + if numpy.isclose(offset, 0): + if length is None: + raise + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + 0, + None, + context.port.ptype, + out_ptype = bounds.get('out_ptype'), + ) + return self + fallback_length = length if length is not None else 0 + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + fallback_length, + offset, + None, + context.port.ptype, + out_rot = pi, + out_ptype = bounds.get('out_ptype'), + ) + return self + self._apply_route_result(result) + return self + + def uturn( + self, + portspec: str | Sequence[str], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> Self: + """ + Route a U-turn. + + `length` is the along-travel displacement to the final port. If omitted, + legal U-like candidates are evaluated at their minimum legal length or + primitive endpoint length for the requested offset, then cost selects + among those candidates. Multi-port U-turns require nonzero `offset` and + `spacing`; the innermost first-bend port supplies the base route and + other ports derive exact lengths and offsets from it. Use `length=0` to + request the old zero-public-length U-turn shape. Positional and + bundle-bound keywords are not supported for this operation. `out_ptype`, + when provided, constrains only each final route endpoint. `strategy` + controls straight-first vs U-first ordering only after cost and + structural tie-breakers. + """ + with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds): + if isinstance(portspec, str): + portspec = [portspec] + contexts = self._route_contexts(portspec) + try: + result = self.planner.plan_uturn_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds) + except (BuildError, NotImplementedError) as err: + if ( + not self._dead + or len(contexts) != 1 + or length is None + or route_error_is_fatal(err) + ): + raise + context = contexts[0] + self._apply_dead_fallback( + context.portspec, + length, + offset, + None, + context.port.ptype, + out_rot = 0.0, + out_ptype = bounds.get('out_ptype'), + ) + return self + self._apply_route_result(result) + return self + + def trace_into( + self, + portspec_src: str, + portspec_dst: str, + *, + out_ptype: str | None = None, + plug_destination: bool = True, + thru: str | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **kwargs: Any, + ) -> Self: + """ + Route one port into another using a bounded primitive-offer selection. + + The current baseline searches bounded primitive-offer routes with up to + four bend roles, including straight, single-bend, S-like, U-like, and + dogleg topologies. The lowest-cost legal bounded candidate is selected; + bend count, step count, and search order are tie-breakers only. `strategy` + controls straight-first vs turn-first search order within those ties. + Route-shape kwargs such as `length`, `offset`, `ccw`, positional bounds, + bundle bounds, and `strategy` are reserved for this internal solve; other tool + kwargs are forwarded to primitive offer generation. + + If `plug_destination` is `True`, the destination port is consumed by the final step. + If `thru` is provided, that port is renamed to the source name after the route is complete. + `out_ptype` constrains only the final route endpoint. Route selection + failures occur before live port state and deferred routing steps are + mutated; failures during selected-route execution, including primitive + commit, plug/thru application, or render, may leave partial output. + """ + with self._logger.log_operation( + self, + 'trace_into', + [portspec_src, portspec_dst], + out_ptype=out_ptype, + plug_destination=plug_destination, + thru=thru, + strategy=strategy, + **kwargs, + ): + result = self.planner.plan_trace_into( + self._route_context(portspec_src), + portspec_dst, + self.pattern[portspec_dst].copy(), + out_ptype = out_ptype, + plug_destination = plug_destination, + thru = thru, + strategy = strategy, + **kwargs, + ) + self._apply_route_result(result) + return self + + # + # Rendering + # + def render(self, append: bool = True) -> Self: + """ + Generate geometry for all pending render steps. + + Consecutive compatible `RenderStep`s are batched by port and Tool, then + passed to `Tool.render()`. After insertion, the rendered output port is + checked against the endpoint that planning selected. + """ + with self._logger.log_operation(self, 'render', None, append=append): + tool_port_names = ('A', 'B') + pat = Pattern() + + def validate_tree(portspec: str, batch: list[RenderStep], tree: ILibrary) -> None: + missing = sorted( + name + for name in tree.dangling_refs(tree.top()) + if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX) + ) + if not missing: + return + + tool_name = type(batch[0].tool).__name__ + raise BuildError( + f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}' + ) + + def validate_rendered_endpoint(portspec: str, batch: list[RenderStep]) -> None: + expected = batch[-1].end_port + actual = pat.ports.get(portspec) + tool_name = type(batch[0].tool).__name__ + if actual is None: + raise BuildError( + f'Tool {tool_name}.render() did not produce output port {portspec!r}; ' + f'expected {expected.describe()}' + ) + + offsets_match = bool(numpy.allclose(actual.offset, expected.offset)) + rotations_match = ( + actual.rotation is None + or expected.rotation is None + or bool(numpy.isclose(numpy.sin(actual.rotation - expected.rotation), 0)) + ) + ptypes_match = ptypes_compatible(actual.ptype, expected.ptype) + if offsets_match and rotations_match and ptypes_match: + return + + raise BuildError( + f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: ' + f'expected {expected.describe()}, got {actual.describe()}' + ) + + def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: + assert batch[0].tool is not None + tree = batch[0].tool.render(batch, port_names=tool_port_names) + validate_tree(portspec, batch, tree) + name = self.library << tree + try: + if portspec in pat.ports: + del pat.ports[portspec] + pat.ports[portspec] = batch[0].start_port.copy() + if append: + pat.plug(self.library[name], {portspec: tool_port_names[0]}, append=True) + del self.library[name] + else: + pat.plug(self.library.abstract(name), {portspec: tool_port_names[0]}, append=False) + if portspec not in pat.ports and tool_port_names[1] in pat.ports: + pat.rename_ports({tool_port_names[1]: portspec}, overwrite=True) + validate_rendered_endpoint(portspec, batch) + except Exception: + if name in self.library: + del self.library[name] + raise + + for portspec, steps in self._paths.items(): + if not steps: + continue + batch: list[RenderStep] = [] + for step in steps: + appendable = step.opcode in ('L', 'S', 'U') + same_tool = batch and step.tool == batch[0].tool + if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)): + render_batch(portspec, batch, append) + batch = [] + if appendable: + batch.append(step) + elif step.opcode == 'P' and portspec in pat.ports: + del pat.ports[portspec] + if batch: + render_batch(portspec, batch, append) + + self._paths.clear() + pat.ports.clear() + self.pattern.append(pat) + return self + + # + # Utilities + # @classmethod def interface( - cls: type['Pather'], + cls, source: PortList | Mapping[str, Port] | str, *, library: ILibrary | None = None, @@ -199,177 +922,270 @@ class Pather(Builder, PatherMixin): out_prefix: str = '', port_map: dict[str, str] | Sequence[str] | None = None, name: str | None = None, - ) -> 'Pather': - """ - Wrapper for `Pattern.interface()`, which returns a Pather instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - tools: `Tool`s which will be used by the pather for generating new wires - or waveguides (via `path`/`path_to`/`mpath`). - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new pather, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ + **kwargs: Any, + ) -> Self: if library is None: if hasattr(source, 'library') and isinstance(source.library, ILibrary): library = source.library else: - raise BuildError('No library provided (and not present in `source.library`') - + raise BuildError('No library provided') if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): tools = source.tools - if isinstance(source, str): source = library.abstract(source).ports - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = Pather(library=library, pattern=pat, name=name, tools=tools) - return new + return cls(library=library, pattern=pat, name=name, tools=tools, **kwargs) - def __repr__(self) -> str: - s = f'' - return s - - - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance. - - The wire will travel `length` distance along the port's axis, and an unspecified - (tool-dependent) distance in the perpendicular direction. The output port will - be rotated (or not) based on the `ccw` parameter. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - 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. - """ - if self._dead: - logger.error('Skipping path() since device is dead') - return self - - 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) - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} + def retool(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Self: + if keys is None or isinstance(keys, str): + self.tools[keys] = tool else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) + for k in keys: + self.tools[k] = tool return self - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). - - The output port will have the same orientation as the source port (`portspec`). - - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. - - Args: - portspec: The name of the port into which the wire will be plugged. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - 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. - """ - if self._dead: - logger.error('Skipping pathS() since device is dead') - return self - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype + @contextmanager + def toolctx(self, tool: Tool, keys: str | Sequence[str | None] | None = None) -> Iterator[Self]: + if keys is None or isinstance(keys, str): + keys = [keys] + saved = {k: self.tools.get(k) for k in keys} try: - tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - except NotImplementedError: - # 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]]) + yield self.retool(tool, keys) + finally: + for k, t in saved.items(): + if t is None: + self.tools.pop(k, None) + else: + self.tools[k] = t - 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 - - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} - else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) + def flatten(self) -> Self: + self.pattern.flatten(self.library) return self + def at( + self, + portspec: str | Iterable[str], + *, + spacing: float | ArrayLike | None = None, + ) -> 'PortPather': + return PortPather(portspec, self, default_spacing=spacing) + + +class PortPather: + """ Port state manager for fluent pathing. """ + def __init__( + self, + ports: str | Iterable[str], + pather: Pather, + *, + default_spacing: float | ArrayLike | None = None, + ) -> None: + self.ports = [ports] if isinstance(ports, str) else list(ports) + self.pather = pather + self.default_spacing = default_spacing + + def retool(self, tool: Tool) -> Self: + self.pather.retool(tool, self.ports) + return self + + def set_spacing(self, spacing: float | ArrayLike | None) -> Self: + self.default_spacing = spacing + return self + + @contextmanager + def toolctx(self, tool: Tool) -> Iterator[Self]: + with self.pather.toolctx(tool, keys=self.ports): + yield self + + def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing + self.pather.trace(self.ports, ccw, length, **kw) + return self + + def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: + kw['spacing'] = self.default_spacing + self.pather.trace_to(self.ports, ccw, **kw) + return self + + def straight(self, length: float | None = None, **kw: Any) -> Self: + return self.trace_to(None, length=length, **kw) + + def bend(self, ccw: SupportsBool, length: float | None = None, **kw: Any) -> Self: + return self.trace_to(ccw, length=length, **kw) + + def ccw(self, length: float | None = None, **kw: Any) -> Self: + return self.bend(True, length, **kw) + + def cw(self, length: float | None = None, **kw: Any) -> Self: + return self.bend(False, length, **kw) + + def jog(self, offset: float, length: float | None = None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0): + kw['spacing'] = self.default_spacing + self.pather.jog(self.ports, offset, length, **kw) + return self + + def uturn(self, offset: float, length: float | None = None, **kw: Any) -> Self: + if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1: + kw['spacing'] = self.default_spacing + self.pather.uturn(self.ports, offset, length, **kw) + return self + + def trace_into(self, target_port: str, **kwargs) -> Self: + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit trace_into() with {len(self.ports)} (>1) ports.') + self.pather.trace_into(self.ports[0], target_port, **kwargs) + return self + + def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: + if len(self.ports) > 1: + raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' + 'Use the pather or pattern directly to plug multiple ports.') + self.pather.plug(other, {self.ports[0]: other_port}, **kwargs) + return self + + def plugged(self, other_port: str | Mapping[str, str]) -> Self: + if isinstance(other_port, Mapping): + self.pather.plugged(dict(other_port)) + elif len(self.ports) > 1: + raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') + else: + self.pather.plugged({self.ports[0]: other_port}) + return self + + # + # Delegate to port + # + # These mutate only the selected live port state. They do not rewrite already planned + # RenderSteps, so deferred geometry remains as previously planned and only future routing + # starts from the updated port. + def set_ptype(self, ptype: str) -> Self: + for port in self.ports: + self.pather.pattern[port].set_ptype(ptype) + return self + + def translate(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.pattern[port].translate(*args, **kwargs) + return self + + def mirror(self, *args, **kwargs) -> Self: + for port in self.ports: + self.pather.pattern[port].mirror(*args, **kwargs) + return self + + def rotate(self, rotation: float) -> Self: + for port in self.ports: + self.pather.pattern[port].rotate(rotation) + return self + + def set_rotation(self, rotation: float | None) -> Self: + for port in self.ports: + self.pather.pattern[port].set_rotation(rotation) + return self + + def rename(self, name: str | Mapping[str, str | None]) -> Self: + """ Rename active ports. """ + name_map: dict[str, str | None] + if isinstance(name, str): + if len(self.ports) > 1: + raise BuildError('Use a mapping to rename >1 port') + name_map = {self.ports[0]: name} + else: + name_map = dict(name) + self.pather.rename_ports(name_map) + renamed_ports: list[str] = [] + for port in self.ports: + renamed = name_map.get(port, port) + if renamed is not None and renamed not in renamed_ports: + renamed_ports.append(renamed) + self.ports = renamed_ports + return self + + def select(self, ports: str | Iterable[str]) -> Self: + """ Add ports to the selection. """ + if isinstance(ports, str): + ports = [ports] + for port in ports: + if port not in self.ports: + self.ports.append(port) + return self + + def deselect(self, ports: str | Iterable[str]) -> Self: + """ Remove ports from the selection. """ + if isinstance(ports, str): + ports = [ports] + ports_set = set(ports) + self.ports = [pp for pp in self.ports if pp not in ports_set] + return self + + def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]: + if isinstance(name, str): + if len(self.ports) > 1: + raise BuildError(f'Use a mapping to {action} >1 port') + name_map = {self.ports[0]: name} + else: + name_map = dict(name) + + missing_selected = set(name_map) - set(self.ports) + if missing_selected: + raise PortError(f'Can only {action} selected ports: {missing_selected}') + + missing_pattern = set(name_map) - set(self.pather.pattern.ports) + if missing_pattern: + raise PortError(f'Ports to {action} were not found: {missing_pattern}') + + if not self.pather._dead: + targets = list(name_map.values()) + duplicate_targets = {vv for vv in targets if targets.count(vv) > 1} + if duplicate_targets: + raise PortError(f'{action.capitalize()} targets would collide: {duplicate_targets}') + + overwritten = { + dst for src, dst in name_map.items() + if dst in self.pather.pattern.ports and dst != src + } + if overwritten: + raise PortError(f'{action.capitalize()} would overwrite existing ports: {overwritten}') + + return name_map + + def mark(self, name: str | Mapping[str, str]) -> Self: + """ Bookmark current port(s). """ + name_map = self._normalize_copy_map(name, 'mark') + source_ports = {src: self.pather.pattern[src].copy() for src in name_map} + for src, dst in name_map.items(): + self.pather.pattern.ports[dst] = source_ports[src].copy() + return self + + def fork(self, name: str | Mapping[str, str]) -> Self: + """ Split and follow new name. """ + name_map = self._normalize_copy_map(name, 'fork') + source_ports = {src: self.pather.pattern[src].copy() for src in name_map} + for src, dst in name_map.items(): + self.pather.pattern.ports[dst] = source_ports[src].copy() + self.ports = [(dst if pp == src else pp) for pp in self.ports] + self.ports = list(dict.fromkeys(self.ports)) + return self + + def drop(self) -> Self: + """ Remove selected ports from the pattern and the PortPather. """ + self.pather.rename_ports(dict.fromkeys(self.ports)) + self.ports = [] + return self + + @overload + def delete(self, name: None) -> None: ... + + @overload + def delete(self, name: str) -> Self: ... + + def delete(self, name: str | None = None) -> Self | None: + if name is None: + self.drop() + return None + self.pather.rename_ports({name: None}) + self.ports = [pp for pp in self.ports if pp != name] + return self diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py deleted file mode 100644 index 1655329..0000000 --- a/masque/builder/pather_mixin.py +++ /dev/null @@ -1,677 +0,0 @@ -from typing import Self, overload -from collections.abc import Sequence, Iterator, Iterable -import logging -from contextlib import contextmanager -from abc import abstractmethod, ABCMeta - -import numpy -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import PortError, BuildError -from ..utils import SupportsBool -from ..abstract import Abstract -from .tools import Tool -from .utils import ell -from ..ports import PortList - - -logger = logging.getLogger(__name__) - - -class PatherMixin(PortList, metaclass=ABCMeta): - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging) """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use Devices - (e.g wires or waveguides) to be plugged into this device. - """ - - @abstractmethod - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - pass - - def retool( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Self: - """ - Update the `Tool` which will be used when generating `Pattern`s for the ports - given by `keys`. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for key in keys: - self.tools[key] = tool - return self - - @contextmanager - def toolctx( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Iterator[Self]: - """ - Context manager for temporarily `retool`-ing and reverting the `retool` - upon exiting the context. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - keys = [keys] - saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` - try: - yield self.retool(tool=tool, keys=keys) - finally: - for kk, tt in saved_tools.items(): - if tt is None: - # delete if present - self.tools.pop(kk, None) - else: - self.tools[kk] = tt - - def path_to( - self, - portspec: str, - ccw: SupportsBool | None, - position: float | None = None, - *, - x: float | None = None, - y: float | None = None, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Build a "wire"/"waveguide" extending from the port `portspec`, with the aim - of ending exactly at a target position. - - The wire will travel so that the output port will be placed at exactly the target - position along the input port's axis. There can be an unspecified (tool-dependent) - offset in the perpendicular direction. The output port will be rotated (or not) - based on the `ccw` parameter. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - position: The final port position, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - Only one of `position`, `x`, and `y` may be specified. - x: The final port position along the x axis. - `portspec` must refer to a horizontal port if `x` is passed, otherwise a - BuildError will be raised. - y: The final port position along the y axis. - `portspec` must refer to a vertical port if `y` is passed, otherwise a - BuildError will be raised. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend - is present). - BuildError if `x` or `y` is specified but does not match the axis of `portspec`. - BuildError if more than one of `x`, `y`, and `position` is specified. - """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self - - pos_count = sum(vv is not None for vv in (position, x, y)) - if pos_count > 1: - raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') - if pos_count < 1: - raise BuildError('One of `position`, `x`, and `y` must be specified') - - port = self.pattern[portspec] - if port.rotation is None: - raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('path_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if y is not None: - raise BuildError('Asked to path to y-coordinate, but port is horizontal') - if position is None: - position = x - else: - if x is not None: - raise BuildError('Asked to path to x-coordinate, but port is vertical') - if position is None: - position = y - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): - raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') - length = numpy.abs(position - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): - raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') - length = numpy.abs(position - y0) - - return self.path( - portspec, - ccw, - length, - plug_into = plug_into, - **kwargs, - ) - - def path_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" traveling between the ports `portspec_src` and - `portspec_dst`, and `plug` it into both (or just the source port). - - Only unambiguous scenarios are allowed: - - Straight connector between facing ports - - Single 90 degree bend - - Jog between facing ports - (jog is done as late as possible, i.e. only 2 L-shaped segments are used) - - By default, the destination's `pytpe` will be used as the `out_ptype` for the - wire, and the `portspec_dst` will be plugged (i.e. removed). - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec_src: The name of the starting port into which the wire will be plugged. - portspec_dst: The name of the destination port. - out_ptype: Passed to the pathing tool in order to specify the desired port type - to be generated at the destination end. If `None` (default), the destination - port's `ptype` will be used. - thru: If not `None`, the port by this name will be rename to `portspec_src`. - This can be used when routing a signal through a pre-placed 2-port device. - - Returns: - self - - Raises: - PortError if either port does not have a specified rotation. - BuildError if and invalid port config is encountered: - - Non-manhattan ports - - U-bend - - Destination too close to (or behind) source - """ - if self._dead: - logger.error('Skipping path_into() since device is dead') - return self - - port_src = self.pattern[portspec_src] - port_dst = self.pattern[portspec_dst] - - if out_ptype is None: - out_ptype = port_dst.ptype - - if port_src.rotation is None: - raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') - if port_dst.rotation is None: - raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') - - if not numpy.isclose(port_src.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route from non-manhattan port') - if not numpy.isclose(port_dst.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route to non-manhattan port') - - src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) - dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) - xs, ys = port_src.offset - xd, yd = port_dst.offset - - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - - dst_extra_args = {'out_ptype': out_ptype} - if plug_destination: - dst_extra_args['plug_into'] = portspec_dst - - src_args = {**kwargs} - dst_args = {**src_args, **dst_extra_args} - if src_is_horizontal and not dst_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, x=xd, **src_args) - self.path_to(portspec_src, None, y=yd, **dst_args) - elif dst_is_horizontal and not src_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, y=yd, **src_args) - self.path_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - if src_is_horizontal and ys == yd: - # straight connector - self.path_to(portspec_src, None, x=xd, **dst_args) - elif not src_is_horizontal and xs == xd: - # straight connector - self.path_to(portspec_src, None, y=yd, **dst_args) - else: - # S-bend, delegate to implementations - (travel, jog), _ = port_src.measure_travel(port_dst) - self.pathS(portspec_src, -travel, -jog, **dst_args) - elif numpy.isclose(angle, 0): - raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') - else: - raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') - - if thru is not None: - self.rename_ports({thru: portspec_src}) - - return self - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - **kwargs, - ) -> Self: - """ - `mpath` is a superset of `path` and `path_to` which can act on bundles or buses - of "wires or "waveguides". - - The wires will travel so that the output ports will be placed at well-defined - locations along the axis of their input ports, but may have arbitrary (tool- - dependent) offsets in the perpendicular direction. - - If `ccw` is not `None`, the wire bundle will turn 90 degres in either the - clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the - bundle, the center-to-center wire spacings after the turn are set by `spacing`, - which is required when `ccw` is not `None`. The final position of bundle as a - whole can be set in a number of ways: - - =A>---------------------------V turn direction: `ccw=False` - =B>-------------V | - =C>-----------------------V | - =D=>----------------V | - | - - x---x---x---x `spacing` (can be scalar or array) - - <--------------> `emin=` - <------> `bound_type='min_past_furthest', bound=` - <--------------------------------> `emax=` - x `pmin=` - x `pmax=` - - - `emin=`, equivalent to `bound_type='min_extension', bound=` - The total extension value for the furthest-out port (B in the diagram). - - `emax=`, equivalent to `bound_type='max_extension', bound=`: - The total extension value for the closest-in port (C in the diagram). - - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: - The coordinate of the innermost bend (D's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: - The coordinate of the outermost bend (A's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `bound_type='min_past_furthest', bound=`: - The distance between furthest out-port (B) and the innermost bend (D's bend). - - If `ccw=None`, final output positions (along the input axis) of all wires will be - identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is - required. In this case, `emin=` and `emax=` are equivalent to each other, and - `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The names of the ports which are to be routed. - ccw: If `None`, the outputs should be along the same axis as the inputs. - Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` - and clockwise otherwise. - spacing: Center-to-center distance between output ports along the input port's axis. - Must be provided if (and only if) `ccw` is not `None`. - set_rotation: If the provided ports have `rotation=None`, this can be used - to set a rotation for them. - - Returns: - self - - Raises: - BuildError if the implied length for any wire is too close to fit the bend - (if a bend is requested). - BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not - match the axis of `portspec`. - BuildError if an incorrect bound type or spacing is specified. - """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self - - bound_types = set() - if 'bound_type' in kwargs: - bound_types.add(kwargs.pop('bound_type')) - bound = kwargs.pop('bound') - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in kwargs: - bound_types.add(bt) - bound = kwargs.pop(bt) - - if not bound_types: - raise BuildError('No bound type specified for mpath') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified for mpath: {bound_types}') - bound_type = tuple(bound_types)[0] - - if isinstance(portspec, str): - portspec = [portspec] - ports = self.pattern[tuple(portspec)] - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - #if container: - # assert not getattr(self, 'render'), 'Containers not implemented for RenderPather' - # bld = self.interface(source=ports, library=self.library, tools=self.tools) - # for port_name, length in extensions.items(): - # bld.path(port_name, ccw, length, **kwargs) - # self.library[container] = bld.pattern - # self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? - #else: - for port_name, length in extensions.items(): - self.path(port_name, ccw, length, **kwargs) - return self - - # TODO def bus_join()? - - def flatten(self) -> Self: - """ - Flatten the contained pattern, using the contained library to resolve references. - - Returns: - self - """ - self.pattern.flatten(self.library) - return self - - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) - - -class PortPather: - """ - Port state manager - - This class provides a convenient way to perform multiple pathing operations on a - set of ports without needing to repeatedly pass their names. - """ - ports: list[str] - pather: PatherMixin - - def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None: - self.ports = [ports] if isinstance(ports, str) else list(ports) - self.pather = pather - - # - # Delegate to pather - # - def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, keys=self.ports) - return self - - @contextmanager - def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=self.ports): - yield self - - def path(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def path_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def pathS(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use pathS_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def pathS_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def path_to(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each_to() when pathing multiple ports independently') - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def path_each_to(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def mpath(self, *args, **kwargs) -> Self: - self.pather.mpath(self.ports, *args, **kwargs) - return self - - def path_into(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the source """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.') - self.pather.path_into(self.ports[0], *args, **kwargs) - return self - - def path_from(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the destination """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.') - thru = kwargs.pop('thru', None) - self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs) - if thru is not None: - self.rename_from(thru) - return self - - def plug( - self, - other: Abstract | str, - other_port: str, - *args, - **kwargs, - ) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' - 'Use the pather or pattern directly to plug multiple ports.') - self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs) - return self - - def plugged(self, other_port: str) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') - self.pather.plugged({self.ports[0]: other_port}) - return self - - # - # Delegate to port - # - def set_ptype(self, ptype: str) -> Self: - for port in self.ports: - self.pather[port].set_ptype(ptype) - return self - - def translate(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].translate(*args, **kwargs) - return self - - def mirror(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].mirror(*args, **kwargs) - return self - - def rotate(self, rotation: float) -> Self: - for port in self.ports: - self.pather[port].rotate(rotation) - return self - - def set_rotation(self, rotation: float | None) -> Self: - for port in self.ports: - self.pather[port].set_rotation(rotation) - return self - - def rename_to(self, new_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({self.ports[0]: new_name}) - self.ports[0] = new_name - return self - - def rename_from(self, old_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({old_name: self.ports[0]}) - return self - - def rename_ports(self, name_map: dict[str, str | None]) -> Self: - self.pather.rename_ports(name_map) - self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] - return self - - def add_ports(self, ports: Iterable[str]) -> Self: - ports = list(ports) - conflicts = set(ports) & set(self.ports) - if conflicts: - raise BuildError(f'ports {conflicts} already selected') - self.ports += ports - return self - - def add_port(self, port: str, index: int | None = None) -> Self: - if port in self.ports: - raise BuildError(f'{port=} already selected') - if index is not None: - self.ports.insert(index, port) - else: - self.ports.append(port) - return self - - def drop_port(self, port: str) -> Self: - if port not in self.ports: - raise BuildError(f'{port=} already not selected') - self.ports = [pp for pp in self.ports if pp != port] - return self - - def into_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and replace it with the copy """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - self.ports = [(new_name if pp == src else pp) for pp in self.ports] - return self - - def save_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and but keep using the original """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - return self - - @overload - def delete(self, name: None) -> None: ... - - @overload - def delete(self, name: str) -> Self: ... - - def delete(self, name: str | None = None) -> Self | None: - if name is None: - for pp in self.ports: - del self.pather.ports[pp] - return None - del self.pather.ports[name] - self.ports = [pp for pp in self.ports if pp != name] - return self - diff --git a/masque/builder/planner/__init__.py b/masque/builder/planner/__init__.py new file mode 100644 index 0000000..f2df9e6 --- /dev/null +++ b/masque/builder/planner/__init__.py @@ -0,0 +1,16 @@ +""" +Simplified primitive-offer route planner used by `Pather`. + +This package is the Pather-facing route-selection implementation. It keeps +the public Tool contract narrow: offers are evaluated during planning, and +offer commits are deferred until after a complete route is selected. +""" +from .interface import ( + PreparedRouteAction as PreparedRouteAction, + PreparedRouteResult as PreparedRouteResult, + RoutePlanningError as RoutePlanningError, + RoutePortContext as RoutePortContext, + route_error_is_fatal as route_error_is_fatal, + ) +from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy +from .planner import RoutingPlanner as RoutingPlanner diff --git a/masque/builder/planner/bounds.py b/masque/builder/planner/bounds.py new file mode 100644 index 0000000..43224a0 --- /dev/null +++ b/masque/builder/planner/bounds.py @@ -0,0 +1,257 @@ +""" +Argument validation and bound resolution for Pather routing calls. + +This module keeps user-facing mode validation outside the solver. It converts +single-port positional bounds into local travel lengths and derives multi-port +S/U bundle specs before primitive offers are considered. + +The solver expects one coherent route intent at a time. This module enforces +that public routing modes are not mixed: explicit length, per-port `each`, +positional bounds, and bundle bounds are mutually constrained before any Tool +offers are queried. Multi-port S/U bundles are also normalized here into exact +per-port public lengths and offsets. +""" +from __future__ import annotations + +# ruff: noqa: TC001,TC002,TC003 +from typing import Any +from collections.abc import Mapping, Sequence +from pprint import pformat + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ...error import BuildError, PortError +from ...ports import Port +from ...utils import rotation_matrix_2d +from .interface import RoutePortContext + + +POSITION_KEYS: tuple[str, ...] = ('p', 'x', 'y', 'pos', 'position') +BUNDLE_BOUND_KEYS: tuple[str, ...] = ( + 'emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest', + ) + + +def resolved_position_bound( + port: Port, + bounds: Mapping[str, Any], + *, + allow_length: bool, + ) -> tuple[str, Any, float] | None: + """Resolve a single positional bound for a single port into a travel length.""" + present = [(key, bounds[key]) for key in POSITION_KEYS if bounds.get(key) is not None] + if not present: + return None + if len(present) > 1: + keys = ', '.join(key for key, _value in present) + raise BuildError(f'Provide exactly one positional bound; got {keys}') + if not allow_length and bounds.get('length') is not None: + raise BuildError('length cannot be combined with a positional bound') + + key, value = present[0] + if port.rotation is None: + raise BuildError('Ports must have rotation') + is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9)) + if is_horiz: + if key == 'y': + raise BuildError('Port is horizontal') + target = Port((value, port.offset[1]), rotation=None) + else: + if key == 'x': + raise BuildError('Port is vertical') + target = Port((port.offset[0], value), rotation=None) + (travel, _jog), _ = port.measure_travel(target) + return key, value, -float(travel) + + +def present_keys(bounds: Mapping[str, Any], keys: Sequence[str]) -> list[str]: + """Return keys whose bound value is explicitly present and non-None.""" + return [key for key in keys if bounds.get(key) is not None] + + +def present_bundle_bounds(bounds: Mapping[str, Any]) -> list[str]: + """Return active multi-port trace bound keys.""" + return present_keys(bounds, BUNDLE_BOUND_KEYS) + + +def validate_trace_args( + portspec: Sequence[str], + *, + length: float | None, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """ + Validate mutually-exclusive `trace()` routing modes. + + A trace request is either an explicit single-port length, an `each` length + for all ports, a single-port omitted-length solve, or a bundle solve with + exactly one bundle bound. + """ + bundle_bounds = present_bundle_bounds(bounds) + if len(bundle_bounds) > 1: + args = ', '.join(bundle_bounds) + raise BuildError(f'Provide exactly one bundle bound for trace(); got {args}') + + invalid_with_length = present_keys(bounds, ('each', 'set_rotation')) + bundle_bounds + invalid_with_each = present_keys(bounds, ('set_rotation',)) + bundle_bounds + + if length is not None: + if len(portspec) > 1: + raise BuildError('length only allowed with a single port') + if spacing is not None: + invalid_with_length.append('spacing') + if invalid_with_length: + args = ', '.join(invalid_with_length) + raise BuildError(f'length cannot be combined with other routing bounds: {args}') + return + + if bounds.get('each') is not None: + if spacing is not None: + invalid_with_each.append('spacing') + if invalid_with_each: + args = ', '.join(invalid_with_each) + raise BuildError(f'each cannot be combined with other routing bounds: {args}') + return + + if not bundle_bounds and len(portspec) == 1: + if spacing is not None: + raise BuildError('spacing cannot be combined with omitted-length single-port trace()') + invalid = present_keys(bounds, ('set_rotation',)) + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for omitted-length trace(): {args}') + return + + if not bundle_bounds: + raise BuildError('No bound type specified for trace()') + + +def validate_trace_to_positional_args( + *, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """Reject bound combinations that cannot be mixed with a single positional `trace_to()` target.""" + invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if spacing is not None: + invalid.append('spacing') + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Positional bounds cannot be combined with other routing bounds: {args}') + + +def validate_jog_args( + portspec: Sequence[str], + *, + length: float | None, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """ + Validate `jog()` mode constraints before S-route planning. + + Single-port jogs may derive length from a positional bound. Multi-port jogs + require spacing and cannot combine omitted length with positional bounds. + """ + invalid = present_keys(bounds, ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if len(portspec) == 1 and spacing is not None: + invalid.append('spacing') + if len(portspec) > 1 and length is None: + invalid += present_keys(bounds, POSITION_KEYS) + if length is not None: + invalid = present_keys(bounds, POSITION_KEYS) + invalid + if invalid: + args = ', '.join(invalid) + raise BuildError(f'length cannot be combined with other routing bounds in jog(): {args}') + return + + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for jog(): {args}') + + +def validate_uturn_args( + portspec: Sequence[str], + *, + spacing: float | ArrayLike | None, + bounds: Mapping[str, Any], + ) -> None: + """Validate `uturn()` arguments, which do not support positional or bundle-bound keywords.""" + invalid = present_keys(bounds, POSITION_KEYS + ('each', 'set_rotation')) + present_bundle_bounds(bounds) + if len(portspec) == 1 and spacing is not None: + invalid.append('spacing') + if invalid: + args = ', '.join(invalid) + raise BuildError(f'Unsupported routing bounds for uturn(): {args}') + + +def su_bundle_specs( + contexts: Sequence[RoutePortContext], + offset: float, + length: float, + spacing: float | ArrayLike | None, + *, + route_name: str, + ) -> tuple[tuple[str, float, float], ...]: + """ + Normalize a multi-port S/U bundle into per-port `(name, length, offset)` specs. + + Ports are ordered from the inside of the first bend outward. The first spec + receives the requested base route; later specs add cumulative spacing to + both route length and lateral offset so the bundle keeps the requested + separation. + """ + if spacing is None: + raise BuildError(f'Must provide spacing for multi-port {route_name}()') + + ports = {context.portspec: context.port for context in contexts} + has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool) + if not has_rotation.all(): + raise PortError(f'Ports must have rotation for multi-port {route_name}()') + + rotations = numpy.array([port.rotation for port in ports.values()], dtype=float) + if not numpy.allclose(rotations[0], rotations): + port_rotations = {name: numpy.rad2deg(port.rotation) for name, port in ports.items()} + raise BuildError( + f'Asked to find multi-port {route_name}() bundle for ports that face in different directions:\n' + + pformat(port_rotations) + ) + + direction = rotations[0] + pi + rot_matrix = rotation_matrix_2d(-direction) + orig_offsets = numpy.array([port.offset for port in ports.values()]) + rot_offsets = (rot_matrix @ orig_offsets.T).T + + first_ccw = bool(offset > 0) + y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') + + spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) + steps = numpy.zeros(len(ports), dtype=float) + if spacing_arr.size == 1: + steps[1:] = spacing_arr[0] + elif spacing_arr.size == len(ports) - 1: + steps[1:] = spacing_arr + else: + raise BuildError( + f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' + f'got length {spacing_arr.size}' + ) + if not numpy.all(numpy.isfinite(steps)): + raise BuildError('spacing must contain only finite values') + + names = tuple(ports.keys()) + ordered_spacings = numpy.cumsum(steps) + anchor_y = float(rot_offsets[y_order[0], 1]) + specs: list[tuple[str, float, float]] = [] + for order_index, port_index in enumerate(y_order): + spacing_offset = float(ordered_spacings[order_index]) + start_y = float(rot_offsets[port_index, 1]) + specs.append(( + names[port_index], + float(length) + spacing_offset, + float(offset) - start_y + anchor_y + spacing_offset, + )) + return tuple(specs) diff --git a/masque/builder/planner/interface.py b/masque/builder/planner/interface.py new file mode 100644 index 0000000..58028df --- /dev/null +++ b/masque/builder/planner/interface.py @@ -0,0 +1,83 @@ +""" +Planner/Pather exchange types. + +`Pather` snapshots live routing state into these records before calling the +planner. The planner returns prepared actions that `Pather` can apply without +needing to know solver internals. +""" + +from __future__ import annotations + +# ruff: noqa: TC001 + +from dataclasses import dataclass + +from ...error import BuildError +from ...ports import Port +from ..tools import RenderStep, Tool + + +class RoutePlanningError(BuildError): + """Route-planning error with fallback policy metadata.""" + + fatal: bool + + def __init__(self, *args: object, fatal: bool = False) -> None: + super().__init__(*args) + self.fatal = fatal + + +def route_error_is_fatal(err: Exception) -> bool: + """Return true when a planning error should bypass dead-Pather fallback.""" + return bool(getattr(err, 'fatal', False)) + + +@dataclass(frozen=True, slots=True) +class RoutePortContext: + """ + Immutable planning view of one live Pather port. + + `port` is a copy of the live port so failed route selection leaves Pather + state unchanged. `tool` is the already-resolved routing Tool for this + portspec. + """ + portspec: str + """Live Pather port name being planned.""" + port: Port + """Copied live port used as immutable route input.""" + tool: Tool + """Resolved Tool for this port.""" + + +@dataclass(frozen=True, slots=True) +class PreparedRouteAction: + """ + Prepared mutation for one routed Pather port. + + The planner has already committed selected primitive offers into + `render_steps` and computed the final live port. `plug_into`, when set, + names the destination port to consume after the route endpoint is applied. + """ + portspec: str + """Live Pather port name to update.""" + render_steps: tuple[RenderStep, ...] + """Committed route steps to append to Pather's pending render queue.""" + final_port: Port + """Final live port value after all route steps.""" + plug_into: str | None = None + """Optional destination port to consume after the final port is applied.""" + + +@dataclass(frozen=True, slots=True) +class PreparedRouteResult: + """ + Complete prepared result for one Pather routing operation. + + `actions` are applied first. `renames` are deferred until after all route + actions so trace-into/thru behavior can be represented without exposing the + solver's selected primitive sequence to Pather. + """ + actions: tuple[PreparedRouteAction, ...] + """Prepared per-port route mutations.""" + renames: tuple[tuple[str, str], ...] = () + """Deferred `(old_name, new_name)` port renames applied after actions.""" diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py new file mode 100644 index 0000000..913758e --- /dev/null +++ b/masque/builder/planner/planner.py @@ -0,0 +1,1399 @@ +""" +Primitive-offer route selection for `Pather`. + +`RoutingPlanner` is the stateless boundary between `Pather` routing calls and +Tool primitive offers. `Pather` passes copied `RoutePortContext` snapshots here; +the planner returns `PreparedRouteResult` records that describe pending +mutations without applying them to the live Pattern. + +Public routing modes and bounds are normalized by `bounds.py` before the solver +sees them. This module plans one route intent at a time: it queries Tool offers, +enumerates bounded primitive compositions, inserts ptype adapters, solves +primitive parameters, ranks candidates, and commits only the selected offers +into `RenderStep` payloads. + +All search is performed in Tool-local route coordinates. The active input port +is at the origin, travel is along +x, and positive jog is to the left. After a +candidate is selected, committed steps are transformed back into layout-space +using the copied starting port. +""" +from __future__ import annotations + +# ruff: noqa: ANN401,PLR0912,PLR0913,PLR0915,TC001,TC002,TC003 + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from itertools import combinations +from math import cos, isclose as math_isclose, sin +from typing import Any, Literal + +import numpy +from numpy import pi +from numpy.typing import ArrayLike + +from ...error import BuildError, PortError +from ...ports import Port +from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible +from ..tools import ( + BendOffer, + PrimitiveKind, + PrimitiveOffer, + RenderStep, + SOffer, + StraightOffer, + Tool, + ) +from ..utils import ell +from . import bounds as planner_bounds +from .interface import ( + PreparedRouteAction, + PreparedRouteResult, + RoutePlanningError, + RoutePortContext, + route_error_is_fatal, + ) + +RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] + + +def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStrategy: + """Return a supported route tie-break strategy or raise a routing error.""" + if strategy in ('straight_first', 'turn_first'): + return strategy + raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first') + + +def is_close(a: float, b: float) -> bool: + """Compare route-solver scalars with the planner tolerance.""" + return math_isclose(float(a), float(b), rel_tol=1e-5, abs_tol=1e-8) + + +def clean_parameter(value: float) -> float: + """Snap tiny solver noise in primitive parameters before domain checks.""" + rounded = round(float(value)) + if abs(float(value) - rounded) <= 1e-8: + return float(rounded) + if abs(float(value)) <= 1e-10: + return 0.0 + return float(value) + + +def minimum_parameter(offer: PrimitiveOffer, route_name: str) -> float: + """Return an offer's deterministic minimum legal parameter.""" + lower, upper = offer.parameter_domain + if lower != upper and lower >= upper: + raise BuildError(f'{route_name} primitive has an invalid parameter domain {offer.parameter_domain}') + if not numpy.isfinite(lower): + raise BuildError(f'{route_name} primitive has no finite minimum parameter') + return offer.canonicalize_parameter(lower) + + +def minimum_nonzero_parameters(offer: PrimitiveOffer) -> tuple[float, ...]: + """Return deterministic nonzero endpoint parameters near an offer domain edge.""" + lower, upper = offer.parameter_domain + if lower == upper: + try: + value = offer.canonicalize_parameter(lower) + except BuildError: + return () + return () if is_close(value, 0) else (value,) + + candidates: list[float] = [] + if lower > 0 and numpy.isfinite(lower): + candidates.append(float(lower)) + if upper < 0 and numpy.isfinite(upper): + candidates.append(float(numpy.nextafter(upper, -numpy.inf))) + + selected: list[float] = [] + for value in candidates: + try: + parameter = offer.canonicalize_parameter(value) + except BuildError: + continue + if not is_close(parameter, 0) and not any(is_close(parameter, prev) for prev in selected): + selected.append(parameter) + return tuple(selected) + + +def adapter_s_parameter(offer: PrimitiveOffer, residual_jog: float) -> float: + """Choose a deterministic S-adapter parameter, preferring residual-jog direction.""" + candidates = minimum_nonzero_parameters(offer) + if not candidates: + raise BuildError('S adapter has no finite deterministic parameter') + + residual_sign = numpy.sign(residual_jog) + + def key(item: tuple[int, float]) -> tuple[float, int, int]: + index, value = item + sign = numpy.sign(value) + sign_rank = 0 if is_close(residual_jog, 0) or sign == residual_sign else 1 + return round(abs(value), 9), sign_rank, index + + return min(enumerate(candidates), key=key)[1] + + +def is_adapter_offer(offer: PrimitiveOffer) -> bool: + """Return true for straight/S offers that intentionally change concrete ptype.""" + return ( + isinstance(offer, StraightOffer | SOffer) + and ptype_match(offer.in_ptype, offer.in_ptype) is PTypeMatch.EXACT + and ptype_match(offer.out_ptype, offer.out_ptype) is PTypeMatch.EXACT + and ptype_match(offer.in_ptype, offer.out_ptype) is PTypeMatch.MISMATCH + ) + + +def raise_if_fatal(err: Exception) -> None: + """Propagate fatal planning errors while allowing normal candidate rejection.""" + if getattr(err, 'fatal', False): + raise err + + +def solve_small_lstsq( + matrix: Sequence[Sequence[float]], + residual: Sequence[float], + ) -> tuple[float, ...] | None: + """ + Solve tiny least-squares systems without always paying NumPy setup cost. + + Route parameter solving only uses one or two constraints and one or two + adjustable primitive parameters. Closed forms keep common cases simple; + NumPy remains the fallback for degenerate or future larger systems. + """ + rows = len(matrix) + cols = len(matrix[0]) if rows else 0 + if rows == 1 and cols == 1: + a = matrix[0][0] + return None if a == 0 else (residual[0] / a,) + if rows == 1 and cols == 2: + a, b = matrix[0] + denom = a * a + b * b + return None if denom == 0 else (residual[0] * a / denom, residual[0] * b / denom) + if rows == 2 and cols == 1: + a = matrix[0][0] + b = matrix[1][0] + denom = a * a + b * b + return None if denom == 0 else ((a * residual[0] + b * residual[1]) / denom,) + if rows == 2 and cols == 2: + a, b = matrix[0] + c, d = matrix[1] + determinant = a * d - b * c + if determinant != 0: + return ( + (d * residual[0] - b * residual[1]) / determinant, + (-c * residual[0] + a * residual[1]) / determinant, + ) + matrix_array = numpy.array(matrix) + deltas, _residuals, _rank, _singular = numpy.linalg.lstsq(matrix_array, numpy.array(residual), rcond=None) + return tuple(float(delta) for delta in deltas) + + +@dataclass(frozen=True, slots=True) +class SelectedPrimitive: + """ + One evaluated primitive offer in a candidate route. + + `out_port` is still in route-local coordinates. `role` distinguishes + primitives that satisfy the requested route shape from ptype adapters that + the grammar may insert around those primitives. + """ + offer: PrimitiveOffer + """Offer selected for this primitive step.""" + parameter: float + """Canonicalized offer parameter used for endpoint, cost, and commit.""" + out_port: Port + """Route-local endpoint produced by the selected offer.""" + cost: float + """Finite additive planning cost reported by the offer.""" + role: Literal['main', 'adapter'] = 'main' + """Whether this step satisfies route geometry or adapts ptype.""" + route_kind: PrimitiveKind | None = None + """Primitive kind used when querying the Tool for this step.""" + + +@dataclass(frozen=True, slots=True) +class Candidate: + """A fully solved primitive sequence with its composed local endpoint.""" + steps: tuple[SelectedPrimitive, ...] + """Ordered primitive sequence selected by the grammar.""" + end_port: Port + """Composed route-local endpoint.""" + cost: float + """Sum of primitive costs.""" + order: int + """Deterministic discovery order used as the final tie-breaker.""" + public_length: float + """Length reported back to bundle planning for omitted-length anchors.""" + + +@dataclass(frozen=True, slots=True) +class RouteRequest: + """ + Normalized solver input for one route leg. + + Public Pather calls are converted into this smaller shape before grammar + enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints; + `route_kwargs` are forwarded to Tool primitive-offer generation. + """ + family: PrimitiveKind + """High-level route family being solved.""" + tool: Tool + """Tool queried for primitive offers.""" + in_ptype: str | None + """Input ptype at the start of the route.""" + route_kwargs: Mapping[str, Any] + """Tool kwargs forwarded to primitive-offer generation.""" + length: float | None = None + """Requested local x displacement, when constrained.""" + jog: float | None = None + """Requested local y displacement, when constrained.""" + ccw: SupportsBool | None = None + """Requested bend direction for single-bend routes.""" + out_ptype: str | None = None + """Requested final endpoint ptype.""" + constrain_jog: bool = False + """Whether bend-family trace_into routes must also match `jog`.""" + max_bends: int | None = None + """Optional override for grammar bend budget.""" + strategy: RouteTieBreakStrategy = 'straight_first' + """Discovery-order tie-break strategy for straight-vs-turn placement.""" + + @property + def route_name(self) -> str: + if self.family in ('straight', 'bend'): + return 'trace' + if self.family == 's': + return 'S-bend' + return 'U-turn' + + @property + def out_rotation(self) -> float: + if self.family == 'straight': + return pi + if self.family == 'bend': + return -pi / 2 if bool(self.ccw) else pi / 2 + if self.family == 's': + return pi + return 0.0 + + @property + def bend_budget(self) -> int: + if self.max_bends is not None: + return self.max_bends + if self.family == 'straight': + return 0 + if self.family == 'bend': + return 1 + return 2 + + +class Solver: + """ + Bounded grammar solver for composed primitive routes. + + The grammar is `A? (N A? (B|S|U) A?)* N A?`, where `A` is a ptype adapter, + `N` is a normal straight-like primitive, and the middle term is either a + bend primitive, a Tool-provided S/U primitive, or a composed S/U route made + from bend primitives. Parameter solving happens after a sequence is + enumerated so fixed and adjustable offers share the same path. + """ + + def __init__(self, request: RouteRequest) -> None: + self.request = request + self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str, PrimitiveKind | None], SelectedPrimitive] = {} + self.offer_cache: dict[ + tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]], + tuple[PrimitiveOffer, ...], + ] = {} + self.seen_candidate_keys: set[tuple[Any, ...]] = set() + self.order = 0 + + @staticmethod + def route_bend_count(steps: Sequence[SelectedPrimitive]) -> int: + """Return the route bend budget consumed by non-adapter primitives.""" + count = 0 + for step in steps: + if step.role == 'adapter': + continue + if step.route_kind == 'bend': + count += 1 + elif step.route_kind in ('s', 'u'): + count += 2 + return count + + def candidate_key(self, candidate: Candidate) -> tuple[Any, ...]: + """Return a deterministic key for duplicate solved candidates.""" + def endpoint_key(port: Port) -> tuple[float, float, float | None, str | None]: + return ( + round(float(port.x), 9), + round(float(port.y), 9), + None if port.rotation is None else round(float(port.rotation), 9), + port.ptype, + ) + + def offer_key(offer: PrimitiveOffer) -> tuple[Any, ...]: + return ( + type(offer).__qualname__, + offer.in_ptype, + offer.out_ptype, + round(float(offer.priority_bias), 9), + tuple(round(float(value), 9) for value in offer.parameter_domain), + getattr(offer, 'ccw', None), + id(offer.endpoint_planner), + id(offer.commit_planner), + ) + + return ( + endpoint_key(candidate.end_port), + tuple(( + offer_key(step.offer), + step.role, + step.route_kind, + round(float(step.parameter), 9), + endpoint_key(step.out_port), + ) for step in candidate.steps), + ) + + def solve( + self, + *, + min_bends: int = 0, + max_bends: int | None = None, + ) -> Candidate: + """ + Enumerate, finalize, deduplicate, and rank legal candidates. + + Non-fatal candidate errors are accumulated so the failure message can + preserve useful Tool feedback. Fatal offer-contract errors stop the + solve immediately. + """ + if max_bends is None: + max_bends = self.request.bend_budget + candidates: list[Candidate] = [] + errors: list[Exception] = [] + for steps in self.enumerate_grammar(max_bends): + if not steps: + continue + if self.route_bend_count(steps) < min_bends: + continue + if any(first.role == 'adapter' and second.role == 'adapter' for first, second in zip(steps, steps[1:], strict=False)): + continue + try: + candidate = self.finalize(steps) + except (BuildError, NotImplementedError, PortError) as err: + raise_if_fatal(err) + errors.append(err) + continue + key = self.candidate_key(candidate) + if key in self.seen_candidate_keys: + continue + self.seen_candidate_keys.add(key) + candidates.append(candidate) + + if not candidates: + for err in errors: + if getattr(err, 'fatal', False): + raise err + if errors: + last_error = errors[-1] + if self.request.route_name in str(last_error): + raise last_error + raise BuildError(f'{self.request.route_name} route is unsupported: {last_error}') from last_error + raise BuildError(f'No legal primitive offer for {self.request.route_name}') + + return min( + candidates, + key=lambda candidate: ( + round(float(candidate.cost), 9), + sum(step.role == 'adapter' for step in candidate.steps), + len(candidate.steps), + candidate.order, + ), + ) + + def primitive_offers( + self, + kind: PrimitiveKind, + in_ptype: str | None, + *, + out_ptype: str | None = None, + extra: Mapping[str, Any] | None = None, + ) -> tuple[PrimitiveOffer, ...]: + """Query the active Tool with route kwargs and per-query overrides.""" + kwargs = dict(self.request.route_kwargs) + kwargs.pop('out_ptype', None) + if extra: + kwargs.update(extra) + try: + cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items()))) + hash(cache_key) + except TypeError: + return self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + + cached = self.offer_cache.get(cache_key) + if cached is not None: + return cached + offers = self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + self.offer_cache[cache_key] = offers + return offers + + def evaluate( + self, + offer: PrimitiveOffer, + parameter: float, + in_ptype: str | None, + *, + out_ptype: str | None, + role: Literal['main', 'adapter'], + route_kind: PrimitiveKind | None, + route_name: str | None = None, + ) -> SelectedPrimitive: + """ + Canonicalize and validate one offer evaluation. + + This is the single point where the solver checks ptype compatibility, + endpoint declarations, selected endpoint ptype, finite cost, and + zero-jog S rejection. + """ + route_name = self.request.route_name if route_name is None else route_name + selected = offer.canonicalize_parameter(clean_parameter(parameter)) + key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name, route_kind) + cached = self.eval_cache.get(key) + if cached is not None: + return cached + + if not ptypes_compatible(in_ptype, offer.in_ptype): + raise BuildError('primitive input ptype is incompatible') + if isinstance(offer, SOffer) and is_close(selected, 0): + raise BuildError('zero-jog S primitive candidates are not allowed') + out_port = offer.endpoint_at(selected) + if not ptypes_compatible(out_port.ptype, offer.out_ptype): + raise RoutePlanningError( + f'{route_name} primitive endpoint ptype does not match declared offer out_ptype', + fatal=True, + ) + if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype): + raise RoutePlanningError( + 'Requested out_ptype does not match primitive endpoint ptype', + fatal=True, + ) + cost = float(offer.cost_at(selected)) + if not numpy.isfinite(cost): + raise BuildError(f'{route_name} primitive returned non-finite cost') + if cost < 0: + raise BuildError(f'{route_name} primitive returned negative cost') + primitive = SelectedPrimitive( + offer, + selected, + out_port, + cost, + role=role, + route_kind=route_kind, + ) + self.eval_cache[key] = primitive + return primitive + + def compose_endpoint(self, steps: Sequence[SelectedPrimitive]) -> Port: + """ + Compose local primitive endpoints into one local route endpoint. + + Primitive output rotations follow Masque's port convention: the port + points back into the primitive, so each step advances orientation by + the primitive output rotation plus pi. + """ + x = 0.0 + y = 0.0 + angle = 0.0 + ptype: str | None = None + for step in steps: + out_port = step.out_port + if out_port.rotation is None: + raise BuildError('Primitive endpoints must have rotation') + angle_cos = cos(angle) + angle_sin = sin(angle) + x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y) + y += angle_sin * float(out_port.x) + angle_cos * float(out_port.y) + angle += out_port.rotation + pi + ptype = out_port.ptype + return Port((x, y), rotation=angle - pi, ptype=ptype) + + def current_ptype(self, steps: Sequence[SelectedPrimitive]) -> str | None: + return self.request.in_ptype if not steps else self.compose_endpoint(steps).ptype + + def adapter_options( + self, + steps: Sequence[SelectedPrimitive], + *, + residual_jog: float, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return no-adapter plus single straight/S ptype adapter options.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [()] + for kind in ('straight', 's'): + try: + offers = self.primitive_offers(kind, current_ptype, out_ptype=None) + except NotImplementedError: + continue + for offer in offers: + if not is_adapter_offer(offer): + continue + try: + parameter = ( + minimum_parameter(offer, 'straight adapter') + if kind == 'straight' + else adapter_s_parameter(offer, residual_jog) + ) + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='adapter', + route_kind=kind, + route_name=f'{kind} adapter', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def straight_options( + self, + steps: Sequence[SelectedPrimitive], + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return no-straight plus minimum-parameter non-adapter straight options.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [()] + try: + offers = self.primitive_offers('straight', current_ptype, out_ptype=None) + except NotImplementedError: + return tuple(options) + for offer in offers: + if is_adapter_offer(offer): + continue + try: + parameter = minimum_parameter(offer, 'trace') + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind='straight', + route_name='trace', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def bend_options( + self, + steps: Sequence[SelectedPrimitive], + ccw: SupportsBool, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return legal fixed-direction bend options for the current ptype.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [] + try: + offers = self.primitive_offers('bend', current_ptype, out_ptype=None, extra={'ccw': ccw}) + except NotImplementedError: + return () + for offer in offers: + if not isinstance(offer, BendOffer): + continue + if bool(offer.ccw) != bool(ccw): + continue + try: + parameter = minimum_parameter(offer, 'trace') + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind='bend', + route_name='trace', + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def su_primitive_options( + self, + steps: Sequence[SelectedPrimitive], + kind: Literal['s', 'u'], + jog: float | None = None, + ) -> tuple[tuple[SelectedPrimitive, ...], ...]: + """Return Tool-provided S/U primitive options for candidate jogs.""" + current_ptype = self.current_ptype(steps) + options: list[tuple[SelectedPrimitive, ...]] = [] + try: + offers = self.primitive_offers(kind, current_ptype, out_ptype=None) + except NotImplementedError: + return () + route_name = 'S-bend' if kind == 's' else 'U-turn' + for offer in offers: + if kind == 's' and not isinstance(offer, SOffer): + continue + for parameter in self.su_parameters(offer, kind, jog): + try: + selected = self.evaluate( + offer, + parameter, + current_ptype, + out_ptype=None, + role='main', + route_kind=kind, + route_name=route_name, + ) + except BuildError as err: + raise_if_fatal(err) + continue + except NotImplementedError: + continue + options.append((selected,)) + return tuple(options) + + def su_parameters( + self, + offer: PrimitiveOffer, + kind: Literal['s', 'u'], + requested_jog: float | None, + ) -> tuple[float, ...]: + """Build deterministic jog-parameter probes for Tool-provided S/U offers.""" + candidates: list[float] = [] + if requested_jog is not None and (kind == 'u' or not is_close(requested_jog, 0)): + candidates.append(float(requested_jog)) + if kind == 'u': + lower, _upper = offer.parameter_domain + if numpy.isfinite(lower): + candidates.append(float(lower)) + else: + candidates.append(0.0) + candidates.extend(minimum_nonzero_parameters(offer)) + + selected: list[float] = [] + for candidate in candidates: + try: + parameter = offer.canonicalize_parameter(candidate) + except BuildError: + continue + if kind == 's' and is_close(parameter, 0): + continue + if not any(is_close(parameter, existing) for existing in selected): + selected.append(parameter) + return tuple(selected) + + def turn_options( + self, + steps: Sequence[SelectedPrimitive], + remaining_bends: int, + ) -> tuple[tuple[tuple[SelectedPrimitive, ...], int], ...]: + """Return bend-family options paired with their consumed bend budget.""" + options: list[tuple[tuple[SelectedPrimitive, ...], int]] = [] + if remaining_bends >= 1: + for ccw in (False, True): + options.extend((turn, 1) for turn in self.bend_options(steps, ccw)) + if remaining_bends >= 2: + jog = self.request.jog + options.extend((turn, 2) for turn in self.su_primitive_options(steps, 's', jog)) + options.extend((turn, 2) for turn in self.su_primitive_options(steps, 'u', jog)) + return tuple(options) + + def enumerate_grammar(self, max_bends: int) -> Iterable[tuple[SelectedPrimitive, ...]]: + """Yield raw primitive sequences allowed by the bounded route grammar.""" + residual_jog = 0.0 if self.request.jog is None else float(self.request.jog) + base: tuple[SelectedPrimitive, ...] = () + for prefix_adapter in self.adapter_options(base, residual_jog=residual_jog): + prefix = (*base, *prefix_adapter) + yield from self.enumerate_segments(prefix, max_bends, residual_jog=residual_jog) + + def enumerate_segments( + self, + steps: tuple[SelectedPrimitive, ...], + remaining_bends: int, + *, + residual_jog: float, + ) -> Iterable[tuple[SelectedPrimitive, ...]]: + """Recursively enumerate normal/adapter/turn blocks within the bend budget.""" + straight_options = self.straight_options(steps) + if self.request.strategy == 'straight_first': + straight_options = (*straight_options[1:], straight_options[0]) + for normal in straight_options: + after_normal = (*steps, *normal) + suffix_options = ( + self.adapter_options(after_normal, residual_jog=0) + if self.request.out_ptype is not None + else ((),) + ) + for suffix in suffix_options: + yield (*after_normal, *suffix) + + if remaining_bends <= 0: + continue + for core_adapter in self.adapter_options(after_normal, residual_jog=residual_jog): + before_core = (*after_normal, *core_adapter) + for turn, bend_count in self.turn_options(before_core, remaining_bends): + after_turn = (*before_core, *turn) + for post_adapter in self.adapter_options(after_turn, residual_jog=residual_jog): + yield from self.enumerate_segments( + (*after_turn, *post_adapter), + remaining_bends - bend_count, + residual_jog=residual_jog, + ) + + def adjustable_indices(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]: + """Return non-adapter primitive indices whose parameter can still move.""" + adjustable: list[int] = [] + for index, step in enumerate(steps): + if step.role == 'adapter': + continue + parameter = step.parameter + lower, upper = step.offer.parameter_domain + probes = [ + parameter + max(1e-6, abs(parameter) * 1e-6), + parameter - max(1e-6, abs(parameter) * 1e-6), + ] + if numpy.isfinite(upper): + probes.append(numpy.nextafter(upper, -numpy.inf)) + if numpy.isfinite(lower): + probes.append(lower) + for probe in probes: + try: + step.offer.canonicalize_parameter(probe) + except BuildError: + continue + if abs(float(probe) - float(parameter)) > 1e-12: + adjustable.append(index) + break + return tuple(adjustable) + + def reevaluate(self, steps: Sequence[SelectedPrimitive], parameters: Sequence[float]) -> tuple[SelectedPrimitive, ...]: + """Re-evaluate a primitive sequence with new parameters and flowing ptypes.""" + selected: list[SelectedPrimitive] = [] + current_ptype = self.request.in_ptype + for step, parameter in zip(steps, parameters, strict=True): + selected_step = self.evaluate( + step.offer, + parameter, + current_ptype, + out_ptype=None, + role=step.role, + route_kind=step.route_kind, + ) + selected.append(selected_step) + current_ptype = selected_step.out_port.ptype + return tuple(selected) + + def solve_parameters( + self, + steps: Sequence[SelectedPrimitive], + solve_indices: Sequence[int], + constraints: Sequence[tuple[Literal['x', 'y'], float]], + ) -> tuple[tuple[SelectedPrimitive, ...], Port] | None: + """ + Adjust selected primitive parameters to satisfy endpoint constraints. + + The solver estimates each adjustable parameter's local linear effect by + probing the composed endpoint, solves the tiny least-squares system, + then re-evaluates with canonicalized parameters. + """ + parameters = [step.parameter for step in steps] + for _iteration in range(3): + selected_steps = self.reevaluate(steps, parameters) + base_end = self.compose_endpoint(selected_steps) + if not solve_indices: + return selected_steps, base_end + + matrix = [[0.0 for _index in solve_indices] for _constraint in constraints] + for column, solve_index in enumerate(solve_indices): + step = steps[solve_index] + parameter = parameters[solve_index] + probe = parameter + max(1e-6, abs(parameter) * 1e-6) + try: + probe = step.offer.canonicalize_parameter(probe) + except BuildError: + probe = numpy.nextafter(parameter, -numpy.inf) + try: + probe = step.offer.canonicalize_parameter(probe) + except BuildError: + return None + if abs(float(probe) - float(parameter)) <= 1e-12: + return None + probe_parameters = list(parameters) + probe_parameters[solve_index] = probe + probe_steps = self.reevaluate(steps, probe_parameters) + probe_end = self.compose_endpoint(probe_steps) + for row, (axis, _target) in enumerate(constraints): + matrix[row][column] = (float(getattr(probe_end, axis)) - float(getattr(base_end, axis))) / (probe - parameter) + + residual = [target - float(getattr(base_end, axis)) for axis, target in constraints] + if all(abs(value) <= 1e-9 for value in residual): + return selected_steps, base_end + deltas = solve_small_lstsq(matrix, residual) + if deltas is None: + return None + changed = False + for solve_index, delta in zip(solve_indices, deltas, strict=True): + parameter = steps[solve_index].offer.canonicalize_parameter( + clean_parameter(parameters[solve_index] + float(delta)), + ) + changed = changed or abs(parameter - parameters[solve_index]) > 1e-12 + parameters[solve_index] = parameter + if not changed: + return selected_steps, base_end + selected_steps = self.reevaluate(steps, parameters) + return selected_steps, self.compose_endpoint(selected_steps) + + def endpoint_matches( + self, + end_port: Port, + constraints: Sequence[tuple[Literal['x', 'y'], float]], + ) -> bool: + """Return true when a composed endpoint satisfies requested position, rotation, and ptype.""" + for axis, target in constraints: + if not is_close(getattr(end_port, axis), target): + return False + if end_port.rotation is None: + return False + rotation_delta = (float(end_port.rotation) - self.request.out_rotation) % (2 * pi) + if not (is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi)): + return False + return self.request.out_ptype is None or ptypes_compatible(end_port.ptype, self.request.out_ptype) + + def rotation_matches_request(self, steps: Sequence[SelectedPrimitive]) -> bool: + """ + Return true when a sequence can produce the requested output rotation. + + Primitive parameters do not affect the rotation contract, so + rotation-impossible candidates can be rejected before parameter solving. + """ + angle = 0.0 + for step in steps: + step_rotation = step.out_port.rotation + if step_rotation is None: + raise BuildError('Primitive endpoints must have rotation') + angle += float(step_rotation) + pi + rotation_delta = ((angle - pi) - self.request.out_rotation) % (2 * pi) + return is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi) + + def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate: + """ + Try all small solve sets for one raw sequence and return the first match. + + Solve-set order is deterministic and becomes part of the candidate + ordering only after cost and structural tie-breakers. + """ + constraints: list[tuple[Literal['x', 'y'], float]] = [] + if self.request.length is not None: + constraints.append(('x', float(self.request.length))) + if self.request.family == 'straight': + constraints.append(('y', 0.0)) + elif self.request.family in ('s', 'u') or self.request.constrain_jog: + if self.request.jog is None: + raise BuildError(f'{self.request.route_name} route requires a jog constraint') + constraints.append(('y', float(self.request.jog))) + route_constraints = tuple(constraints) + if not self.rotation_matches_request(steps): + raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') + adjustable = self.adjustable_indices(steps) + solve_sets: list[tuple[int, ...]] = [()] + max_solve = min(len(route_constraints), len(adjustable)) + for solve_size in range(1, max_solve + 1): + solve_sets.extend(combinations(adjustable, solve_size)) + + for solve_indices in solve_sets: + solved = self.solve_parameters(steps, solve_indices, route_constraints) + if solved is None: + continue + selected_steps, end_port = solved + if not self.endpoint_matches(end_port, route_constraints): + continue + order = self.order + self.order += 1 + public_length = float(end_port.x) if self.request.length is None else float(self.request.length) + return Candidate( + tuple(selected_steps), + end_port, + sum(step.cost for step in selected_steps), + order, + public_length, + ) + raise BuildError(f'{self.request.route_name} composed primitive route is unsupported') + + +@dataclass(frozen=True, slots=True) +class RouteLeg: + """ + One solved route leg tied to the Pather port it will update. + + `start_port` is the copied route start used for all layout transforms. + `tool` is stored with the leg so prepared render steps cannot be stamped + with a mismatched Tool after bundle ordering. + """ + portspec: str + """Pather port name this leg will update.""" + start_port: Port + """Copied layout-space route start.""" + tool: Tool + """Tool used for all render steps in this leg.""" + candidate: Candidate + """Solved local primitive candidate.""" + plug_into: str | None = None + """Optional destination port to consume after applying the final endpoint.""" + + +class RoutingPlanner: + """ + Pather-facing stateless route-selection facade. + + Public Pather methods call this class with copied port contexts. Returned + `PreparedRouteResult`s contain only committed render steps, final ports, + plug targets, and renames needed for Pather state mutation. + """ + + TRACE_INTO_MAX_BENDS: int = 4 + DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first' + + def __init__(self, strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY) -> None: + self.strategy = validate_strategy(strategy) + + def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy: + """Return the per-route strategy or the planner default.""" + if strategy is None: + return getattr(self, 'strategy', self.DEFAULT_STRATEGY) + return validate_strategy(strategy) + + def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]: + """Return non-overlapping trace_into bend-budget bands for staged solving.""" + max_bends = self.TRACE_INTO_MAX_BENDS + if family == 'bend': + return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends) + bands: list[tuple[int, int]] = [] + if max_bends >= 0: + bands.append((0, 2 if max_bends >= 2 else 0)) + if max_bends >= 4: + bands.append((4, 4)) + return tuple(bands) + + def route_request( + self, + family: PrimitiveKind, + context: RoutePortContext, + *, + length: float | None = None, + jog: float | None = None, + ccw: SupportsBool | None = None, + constrain_jog: bool = False, + max_bends: int | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **kwargs: Any, + ) -> RouteRequest: + """Build normalized solver input for one route leg.""" + return RouteRequest( + family=family, + tool=context.tool, + in_ptype=context.port.ptype, + route_kwargs=kwargs, + length=length, + jog=jog, + ccw=ccw, + out_ptype=kwargs.get('out_ptype'), + constrain_jog=constrain_jog, + max_bends=max_bends, + strategy=self.resolve_strategy(strategy), + ) + + def solver_for_request(self, request: RouteRequest) -> Solver: + """Construct the solver for a route request.""" + return Solver(request) + + def route_leg_from_candidate( + self, + context: RoutePortContext, + candidate: Candidate, + *, + plug_into: str | None, + ) -> RouteLeg: + """Attach a solved candidate to its source Pather context.""" + return RouteLeg( + portspec=context.portspec, + start_port=context.port.copy(), + tool=context.tool, + candidate=candidate, + plug_into=plug_into, + ) + + def plan_leg( + self, + family: PrimitiveKind, + context: RoutePortContext, + *, + length: float | None = None, + jog: float | None = None, + ccw: SupportsBool | None = None, + plug_into: str | None = None, + constrain_jog: bool = False, + max_bends: int | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **kwargs: Any, + ) -> RouteLeg: + """Solve one route leg and attach it to its source Pather context.""" + request = self.route_request( + family=family, + context=context, + length=length, + jog=jog, + ccw=ccw, + constrain_jog=constrain_jog, + max_bends=max_bends, + strategy=strategy, + **kwargs, + ) + try: + candidate = self.solver_for_request(request).solve() + except BuildError as err: + if family == 'u' and length is None and not getattr(err, 'fatal', False): + raise BuildError('No legal primitive offer for omitted-length U-turn') from err + raise + return self.route_leg_from_candidate(context, candidate, plug_into=plug_into) + + def prepared_route_action_from_leg( + self, + leg: RouteLeg, + ) -> PreparedRouteAction: + """ + Convert a solved leg into committed render steps and a final live port. + + Offer commits happen here, after route selection succeeds. Each + primitive endpoint is transformed from route-local coordinates using + the previous step's layout-space output port. + """ + current = leg.start_port.copy() + render_steps: list[RenderStep] = [] + for selected in leg.candidate.steps: + port_rot = current.rotation + if port_rot is None: + raise PortError('Ports must have rotation') + out_port = selected.out_port.copy() + out_port.rotate_around((0, 0), pi + port_rot) + out_port.translate(current.offset) + render_steps.append(RenderStep( + selected.offer.opcode, + leg.tool, + current.copy(), + out_port.copy(), + selected.offer.commit(selected.parameter), + )) + current = out_port + if not render_steps: + raise BuildError('Route leg has no primitive steps') + return PreparedRouteAction( + portspec=leg.portspec, + render_steps=tuple(render_steps), + final_port=current.copy(), + plug_into=leg.plug_into, + ) + + def prepared_result_from_legs( + self, + legs: Sequence[RouteLeg], + *, + renames: tuple[tuple[str, str], ...] = (), + ) -> PreparedRouteResult: + """Build a prepared result from solved legs plus deferred port renames.""" + return PreparedRouteResult( + actions=tuple(self.prepared_route_action_from_leg(leg) for leg in legs), + renames=renames, + ) + + def plan_trace_route( + self, + contexts: Sequence[RoutePortContext], + ccw: SupportsBool | None, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan straight or single-bend traces, including `each` and bundle-bound modes.""" + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds) + family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' + if length is not None: + leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, strategy=strategy, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + if route_bounds.get('each') is not None: + each = route_bounds.pop('each') + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg( + self.plan_leg(family, context, length=each, ccw=ccw, strategy=strategy, **route_bounds), + ) + for context in contexts + )) + + bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds) + if not bundle_bounds: + leg = self.plan_leg(family, contexts[0], length=None, ccw=ccw, strategy=strategy, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + bound_type = bundle_bounds[0] + bound_value = route_bounds.pop(bound_type) + set_rotation = route_bounds.pop('set_rotation', None) + extensions = ell( + {context.portspec: context.port for context in contexts}, + ccw, + spacing=spacing, + bound=bound_value, + bound_type=bound_type, + set_rotation=set_rotation, + ) + actions = [] + for port_name, route_length in extensions.items(): + context = next(context for context in contexts if context.portspec == port_name) + leg = self.plan_leg(family, context, length=route_length, ccw=ccw, strategy=strategy, **route_bounds) + actions.append(self.prepared_route_action_from_leg(leg)) + return PreparedRouteResult(tuple(actions)) + + def plan_trace_to_route( + self, + contexts: Sequence[RoutePortContext], + ccw: SupportsBool | None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" + route_bounds = dict(bounds) + if len(contexts) == 1: + resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=False) + else: + resolved = None + if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS): + raise BuildError('Position bounds only allowed with a single port') + if resolved is None: + return self.plan_trace_route(contexts, ccw, spacing=spacing, strategy=strategy, **route_bounds) + + planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds) + _key, _value, length = resolved + other_bounds = { + key: value + for key, value in route_bounds.items() + if key not in planner_bounds.POSITION_KEYS and key != 'length' + } + family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend' + leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, strategy=strategy, **other_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_jog_route( + self, + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan S-bend routes for single ports or spaced bundles.""" + if numpy.isclose(offset, 0): + return self.plan_trace_to_route(contexts, None, length=length, spacing=spacing, strategy=strategy, **bounds) + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds) + other_bounds = dict(route_bounds) + if length is None and len(contexts) == 1: + resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=True) + if resolved is not None: + _key, _value, length = resolved + other_bounds = {key: value for key, value in route_bounds.items() if key not in planner_bounds.POSITION_KEYS} + if len(contexts) > 1: + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg(leg) + for leg in self.plan_su_bundle_routes('s', contexts, offset, length, spacing, strategy=strategy, **other_bounds) + )) + leg = self.plan_leg('s', contexts[0], length=length, jog=offset, strategy=strategy, **other_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_uturn_route( + self, + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + **bounds: Any, + ) -> PreparedRouteResult: + """Plan U-turn routes for single ports or spaced bundles.""" + route_bounds = dict(bounds) + portspec = tuple(context.portspec for context in contexts) + planner_bounds.validate_uturn_args(portspec, spacing=spacing, bounds=route_bounds) + if len(contexts) > 1: + return PreparedRouteResult(tuple( + self.prepared_route_action_from_leg(leg) + for leg in self.plan_su_bundle_routes('u', contexts, offset, length, spacing, strategy=strategy, **route_bounds) + )) + leg = self.plan_leg('u', contexts[0], length=length, jog=offset, strategy=strategy, **route_bounds) + return self.prepared_result_from_legs((leg,)) + + def plan_su_bundle_routes( + self, + kind: Literal['s', 'u'], + contexts: Sequence[RoutePortContext], + offset: float, + length: float | None, + spacing: float | ArrayLike | None, + *, + strategy: RouteTieBreakStrategy | str | None = None, + **kwargs: Any, + ) -> tuple[RouteLeg, ...]: + """ + Solve the anchor S/U route and derive exact routes for the rest of a bundle. + + The anchor may determine the public length when omitted. Once known, + `su_bundle_specs()` normalizes every other port into an exact length + and offset so all legs can be planned independently. + """ + if len(contexts) == 1: + return (self.plan_leg(kind, contexts[0], length=length, jog=offset, strategy=strategy, **kwargs),) + route_name = 'jog' if kind == 's' else 'uturn' + if kind == 'u' and is_close(offset, 0): + raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering') + contexts_by_name = {context.portspec: context for context in contexts} + initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name) + anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0] + anchor = self.plan_leg(kind, contexts_by_name[anchor_portspec], length=length, jog=offset, strategy=strategy, **kwargs) + base_length = anchor.candidate.public_length + specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name) + first_portspec, _first_length, _first_offset = specs[0] + routes_by_name = {first_portspec: anchor} + for spec_portspec, spec_length, spec_offset in specs[1:]: + if kind == 's' and is_close(spec_offset, 0): + routes_by_name[spec_portspec] = self.plan_leg( + 'straight', + contexts_by_name[spec_portspec], + length=spec_length, + strategy=strategy, + **kwargs, + ) + else: + routes_by_name[spec_portspec] = self.plan_leg( + kind, + contexts_by_name[spec_portspec], + length=spec_length, + jog=spec_offset, + strategy=strategy, + **kwargs, + ) + return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs) + + def plan_trace_into( + self, + context_src: RoutePortContext, + portspec_dst: str, + port_dst: Port, + *, + out_ptype: str | None, + plug_destination: bool, + thru: str | None, + strategy: RouteTieBreakStrategy | str | None = None, + **kwargs: Any, + ) -> PreparedRouteResult: + """Plan a bounded route from one source port into a destination port.""" + reserved = { + 'portspec', 'ccw', 'length', 'offset', 'plug_into', 'spacing', 'each', 'set_rotation', + *planner_bounds.POSITION_KEYS, + *planner_bounds.BUNDLE_BOUND_KEYS, + } + collisions = sorted(set(kwargs) & reserved) + if collisions: + raise BuildError(f'trace_into() kwargs cannot override route arguments: {", ".join(collisions)}') + if out_ptype is None: + out_ptype = port_dst.ptype + if context_src.port.rotation is None or port_dst.rotation is None: + raise PortError('Ports must have rotation') + desired = port_dst.copy() + desired.rotation = port_dst.rotation - pi + desired.ptype = out_ptype + family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) + request = self.route_request( + family, + context_src, + length=length, + jog=jog, + ccw=ccw, + constrain_jog=family == 'bend', + max_bends=self.TRACE_INTO_MAX_BENDS, + strategy=strategy, + **(dict(kwargs) | {'out_ptype': out_ptype}), + ) + solver = self.solver_for_request(request) + candidate = None + last_error: Exception | None = None + for min_bends, max_bends in self.trace_into_bend_bands(family): + try: + candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) + break + except (BuildError, NotImplementedError) as err: + if route_error_is_fatal(err): + raise + last_error = err + if candidate is None: + if last_error is not None: + raise last_error + raise BuildError('No legal primitive offer for trace_into route') + leg = self.route_leg_from_candidate( + context_src, + candidate, + plug_into=portspec_dst if plug_destination else None, + ) + renames = ((thru, context_src.portspec),) if thru is not None else () + return self.prepared_result_from_legs( + (leg,), + renames=renames, + ) + + def trace_into_spec( + self, + start_port: Port, + end_port: Port, + ) -> tuple[PrimitiveKind, float, float, SupportsBool | None]: + """Convert source/destination geometry into a primitive route family and constraints.""" + def quarter_turn(rotation: float) -> int: + normalized = rotation % (2 * pi) + if is_close(normalized, 2 * pi): + normalized = 0.0 + quarter = int(round(normalized / (pi / 2))) % 4 + if not is_close(normalized, (quarter * pi / 2) % (2 * pi)): + raise BuildError('trace_into() only supports Manhattan port rotations') + return quarter + + travel_jog, _angle = start_port.measure_travel(end_port) + travel, jog = travel_jog + length = -float(travel) + offset = -float(jog) + if start_port.rotation is None or end_port.rotation is None: + raise PortError('Ports must have rotation') + relative_quarter = (quarter_turn(end_port.rotation) - quarter_turn(start_port.rotation)) % 4 + if relative_quarter == 0: + return ('straight', length, 0.0, None) if is_close(offset, 0) else ('s', length, offset, None) + if relative_quarter == 1: + return 'bend', length, offset, True + if relative_quarter == 2: + return 'u', length, offset, None + return 'bend', length, offset, False diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py deleted file mode 100644 index 7f18e77..0000000 --- a/masque/builder/renderpather.py +++ /dev/null @@ -1,646 +0,0 @@ -""" -Pather with batched (multi-step) rendering -""" -from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping, Iterable -import copy -import logging -from collections import defaultdict -from functools import wraps -from pprint import pformat - -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError -from ..ports import PortList, Port -from ..abstract import Abstract -from ..utils import SupportsBool -from .tools import Tool, RenderStep -from .pather_mixin import PatherMixin - - -logger = logging.getLogger(__name__) - - -class RenderPather(PatherMixin): - """ - `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` - functions to plan out wire paths without incrementally generating the layout. Instead, - it waits until `render` is called, at which point it draws all the planned segments - simultaneously. This allows it to e.g. draw each wire using a single `Path` or - `Polygon` shape instead of multiple rectangles. - - `RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific - dimensions and build the final geometry for each wire. `Tool.planL` provides the - output port data (relative to the input) for each segment. The tool, input and output - ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for - each port. When `render` is called, it bundles `RenderStep`s into batches which use - the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build - the geometry. - - See `Pather` for routing examples. After routing is complete, `render` must be called - to generate the final geometry. - """ - __slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', ) - - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging) """ - - paths: defaultdict[str, list[RenderStep]] - """ Per-port list of operations, to be used by `render` """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use Devices - (e.g wires or waveguides) to be plugged into this device. - """ - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - ) -> None: - """ - Args: - library: The library from which referenced patterns will be taken, - and where new patterns (e.g. generated by the `tools`) will be placed. - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - tools: A mapping of {port: tool} which specifies what `Tool` should be used - to generate waveguide or wire segments when `path`/`path_to`/`mpath` - are called. Relies on `Tool.planL` and `Tool.render` implementations. - name: If specified, `library[name]` is set to `self.pattern`. - """ - self._dead = False - self.paths = defaultdict(list) - self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if name is not None: - library[name] = self.pattern - - if tools is None: - self.tools = {} - elif isinstance(tools, Tool): - self.tools = {None: tools} - else: - self.tools = dict(tools) - - @classmethod - def interface( - cls: type['RenderPather'], - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - ) -> 'RenderPather': - """ - Wrapper for `Pattern.interface()`, which returns a RenderPather instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - tools: `Tool`s which will be used by the pather for generating new wires - or waveguides (via `path`/`path_to`/`mpath`). - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new `RenderPather`, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library provided (and not present in `source.library`') - - if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): - tools = source.tools - - if isinstance(source, str): - source = library.abstract(source).ports - - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = RenderPather(library=library, pattern=pat, name=name, tools=tools) - return new - - def __repr__(self) -> str: - s = f'' - return s - - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - """ - Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' - for any affected ports. This separates any future `RenderStep`s on the - same port into a new batch, since the plugged device interferes with drawing. - - Args: - other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. - map_in: dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - map_out: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in `other`. - mirrored: Enables mirroring `other` across the x axis prior to - connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices - (e.g. wires) without providing `map_out` each time `plug` is - called. See "Examples" above for more info. Default `True`. - set_rotation: If the necessary rotation cannot be determined from - the ports being connected (i.e. all pairs have at least one - port with `rotation=None`), `set_rotation` must be provided - to indicate how much `other` should be rotated. Otherwise, - `set_rotation` must remain `None`. - 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`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. - - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other_names`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - `PortError` if the specified port mapping is not achieveable (the ports - do not line up) - """ - if self._dead: - logger.error('Skipping plug() since device is dead') - return self - - other_tgt: Pattern | Abstract - if isinstance(other, str): - other_tgt = self.library.abstract(other) - 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)) - - 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, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, - ) - - return self - - def place( - self, - other: Abstract | str, - *, - offset: ArrayLike = (0, 0), - rotation: float = 0, - pivot: ArrayLike = (0, 0), - mirrored: bool = False, - port_map: dict[str, str | None] | None = None, - skip_port_check: bool = False, - append: bool = False, - ) -> Self: - """ - Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P' - for any affected ports. This separates any future `RenderStep`s on the - same port into a new batch, since the placed device interferes with drawing. - - Note that mirroring is applied before rotation; translation (`offset`) is applied last. - - Args: - other: An `Abstract` or `Pattern` describing the device to be instatiated. - offset: Offset at which to place the instance. Default (0, 0). - rotation: Rotation applied to the instance before placement. Default 0. - pivot: Rotation is applied around this pivot point (default (0, 0)). - Rotation is applied prior to translation (`offset`). - mirrored: Whether theinstance should be mirrored across the x axis. - Mirroring is applied before translation and rotation. - port_map: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in the instantiated pattern. New names can be - `None`, which will delete those ports. - skip_port_check: Can be used to skip the internal call to `check_ports`, - in case it has already been performed elsewhere. - 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`). - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other.ports`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - """ - if self._dead: - logger.error('Skipping place() since device is dead') - return self - - other_tgt: Pattern | Abstract - if isinstance(other, str): - other_tgt = self.library.abstract(other) - 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)) - - self.pattern.place( - other = other_tgt, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, - ) - - return self - - def plugged( - 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)) - PortList.plugged(self, connections) - return self - - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim - of traveling exactly `length` distance. - - The wire will travel `length` distance along the port's axis, an an unspecified - (tool-dependent) distance in the perpendicular direction. The output port will - be rotated (or not) based on the `ccw` parameter. - - `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - 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. - """ - if self._dead: - logger.error('Skipping path() since device is dead') - return self - - port = self.pattern[portspec] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? - - 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) - - # 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) - - self.pattern.ports[portspec] = out_port.copy() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - - return self - - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). - - The output port will have the same orientation as the source port (`portspec`). - - `RenderPather.render` must be called after all paths have been fully planned. - - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. - - Args: - portspec: The name of the port into which the wire will be plugged. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - 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. - """ - if self._dead: - logger.error('Skipping pathS() since device is dead') - return self - - port = self.pattern[portspec] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? - - tool = self.tools.get(portspec, self.tools[None]) - - # check feasibility, get output port and data - try: - out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) - except NotImplementedError: - # 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] - - 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 - - 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() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - - - def render( - self, - append: bool = True, - ) -> Self: - """ - Generate the geometry which has been planned out with `path`/`path_to`/etc. - - Args: - append: If `True`, the rendered geometry will be directly appended to - `self.pattern`. Note that it will not be flattened, so if only one - layer of hierarchy is eliminated. - - Returns: - self - """ - lib = self.library - tool_port_names = ('A', 'B') - pat = Pattern() - - 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() - 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 - else: - pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) - - for portspec, steps in self.paths.items(): - batch: list[RenderStep] = [] - for step in steps: - appendable_op = step.opcode in ('L', 'S', 'U') - same_tool = batch and step.tool == batch[0].tool - - # If we can't continue a batch, render it - if batch and (not appendable_op or not same_tool): - render_batch(portspec, batch, append) - batch = [] - - # batch is emptied already if we couldn't continue it - if appendable_op: - 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 the last batch didn't end yet - if batch: - render_batch(portspec, batch, append) - - self.paths.clear() - pat.ports.clear() - self.pattern.append(pat) - - return self - - def translate(self, offset: ArrayLike) -> Self: - """ - Translate the pattern and all ports. - - Args: - offset: (x, y) distance to translate by - - Returns: - self - """ - self.pattern.translate_elements(offset) - return self - - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - """ - Rotate the pattern and all ports. - - Args: - angle: angle (radians, counterclockwise) to rotate by - pivot: location to rotate around - - Returns: - self - """ - self.pattern.rotate_around(pivot, angle) - return self - - def mirror(self, axis: int) -> Self: - """ - Mirror the pattern and all ports across the specified axis. - - Args: - axis: Axis to mirror across (x=0, y=1) - - Returns: - self - """ - self.pattern.mirror(axis) - return self - - def set_dead(self) -> Self: - """ - Disallows further changes through `plug()` or `place()`. - 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.pattern.visualize() # shows the device as of the set_dead() call - ``` - - Returns: - self - """ - self._dead = True - return self - - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6bd7547..40097a0 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,44 +1,585 @@ """ -Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) +Routing Tool contracts and built-in Tool implementations. -# TODO document all tools +A `Tool` is the user-extensible side of Pather routing. It does not receive +Pather state and it does not choose high-level route topology. Instead, +`primitive_offers()` exposes the local primitive moves that are legal for the +Tool: parameter domains, endpoint ptypes and geometry, additive costs, +optional footprint bounds, and a commit hook for the selected parameter. +`masque.builder.planner` composes those offers into `trace()`, `jog()`, +`uturn()`, and `trace_into()` routes. + +Offer geometry is described in local route coordinates. The current input port +is `(0, 0)` with rotation `0`; length-like parameters advance along +x; positive +jog is left of travel; returned endpoint ports describe the primitive output in +that same local frame. The planner transforms selected endpoints into layout +coordinates only after a complete route has been chosen. + +Tool authors should treat offer planning callbacks as pure descriptions. The +solver may call `endpoint_at()`, `cost_at()`, and `bbox_at()` many times while +enumerating candidate compositions, ptype adapters, and parameter solutions. +Those callbacks should be deterministic and should not mutate a Library, +Pattern, or live Pather. `commit()` is the first selected-offer hook: it runs +only for primitives in the chosen route and returns the opaque value stored in +`RenderStep.data`. + +`render()` is the geometry-construction hook. Pather calls it later with a +compatible batch of committed `RenderStep`s, already expressed in layout +coordinates and grouped by Tool/continuity. The returned single-top tree is +plugged into the pending route by Pather, which also validates that the rendered +output port matches the endpoint selected during planning. + +Routing uses the Tool assigned to the routed input port. The solver does not +search across Tools or infer `Pather.retool()` boundaries; transitions, +cross-ptype routes, and adapter shapes must be exposed by the active Tool as +primitive offers. """ from typing import Literal, Any, Self -from collections.abc import Sequence, Callable -from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? -from dataclasses import dataclass +from collections import ChainMap +from collections.abc import Sequence, Callable, Mapping +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt import numpy from numpy.typing import NDArray from numpy import pi -from ..utils import SupportsBool, rotation_matrix_2d, layer_t +from ..utils import ( + layer_t, + ptypes_compatible as ptypes_compatible, + rotation_matrix_2d, + ) from ..ports import Port from ..pattern import Pattern from ..abstract import Abstract from ..library import ILibrary, Library, SINGLE_USE_PREFIX from ..error import BuildError +from ..shapes import Path + + +def _canonicalize_domain_value( + value: float, + domain: tuple[float, float], + *, + rtol: float = 1e-9, + atol: float = 1e-12, + ) -> float: + """ + Canonicalize a solved primitive parameter against a route domain. + + Normal domains are half-open `[min, max)`. A `(value, value)` domain is a + special closed singleton used for fixed parameters. + """ + vv = float(value) + lower, upper = (float(domain[0]), float(domain[1])) + + if scalar_isnan(lower) or scalar_isnan(upper): + raise BuildError(f'Parameter domain must not contain NaN values: {domain}') + if lower > upper: + raise BuildError(f'Parameter domain lower bound must not exceed upper bound: {domain}') + if not scalar_isfinite(vv): + raise BuildError(f'Parameter {vv:g} must be finite') + + if lower == upper: + if not scalar_isfinite(lower): + raise BuildError(f'Singleton parameter domain must be finite: {domain}') + if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): + return lower + raise BuildError(f'Parameter {vv:g} is outside singleton domain {{{lower:g}}}') + + if scalar_isclose(vv, lower, rel_tol=rtol, abs_tol=atol): + vv = lower + if vv < lower or vv >= upper: + raise BuildError(f'Parameter {vv:g} is outside half-open domain [{lower:g}, {upper:g})') + return vv + + +EndpointCallable = Callable[[float], Port] +CommitCallable = Callable[[float], Any] +BBoxCallable = Callable[[float], NDArray[numpy.float64]] +DataCallable = Callable[[float], Any] +BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]] +PrimitiveKind = Literal['straight', 'bend', 's', 'u'] +BUILTIN_PRIORITY_STEP = 1e7 + + +def _generated_offer_callbacks( + endpoint_at: EndpointCallable, + data_at: DataCallable, + bbox_for_data: BBoxForDataCallable | None, + ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: + def commit(parameter: float) -> Any: + return data_at(parameter) + + if bbox_for_data is None: + return endpoint_at, commit, None + + def bbox(parameter: float) -> NDArray[numpy.float64]: + return bbox_for_data(data_at(parameter)) + + return endpoint_at, commit, bbox + + +def _prebuilt_offer_callbacks( + endpoint: Port, + data: Any, + bbox_for_data: BBoxForDataCallable | None, + ) -> tuple[EndpointCallable, CommitCallable, BBoxCallable | None]: + prebuilt_endpoint = endpoint.copy() + + def endpoint_at(parameter: float) -> Port: + _ = parameter + return prebuilt_endpoint.copy() + + def commit(parameter: float) -> Any: + _ = parameter + return data + + if bbox_for_data is None: + return endpoint_at, commit, None + + def bbox(parameter: float) -> NDArray[numpy.float64]: + _ = parameter + return bbox_for_data(data) + + return endpoint_at, commit, bbox + + +@dataclass(frozen=True, slots=True) +class PrimitiveOffer(ABC): + """ + Shared base type for local routing primitives made available by a `Tool`. + + Custom tools normally construct one of the concrete offer classes: + `StraightOffer`, `BendOffer`, `SOffer`, or `UOffer`. `PrimitiveOffer` + exists to hold common callback, ptype, cost, and footprint behavior and is + useful for annotations when handling offers generically. + + Offers are pure planning objects. `endpoint_at()` returns a local output + `Port`, `cost_at()` returns an additive scalar cost, `bbox_at()` returns + local primitive bounds when a footprint hook is available, and `commit()` + returns opaque render data only after an offer has been selected. These + methods should be deterministic and must not mutate the user's target + library. + + Parameter domains are half-open `[min, max)` ranges, except `(value, value)` + is a closed singleton for fixed-size primitives. `None` and `"unk"` ptypes + are wildcards; incompatible concrete ptypes are rejected by `Pather`. + """ + in_ptype: str | None + out_ptype: str | None + priority_bias: float = 0.0 + bbox_planner: BBoxCallable | None = None + parameterized_bbox: Any | None = None + endpoint_planner: EndpointCallable | None = None + commit_planner: CommitCallable | None = None + + def __post_init__(self) -> None: + has_endpoint = self.endpoint_planner is not None + has_commit = self.commit_planner is not None + + if has_endpoint != has_commit: + raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner') + if not numpy.isfinite(self.priority_bias) or self.priority_bias < 0: + raise BuildError(f'PrimitiveOffer priority_bias must be nonnegative and finite, got {self.priority_bias:g}') + + @property + @abstractmethod + def opcode(self) -> Literal['L', 'S', 'U']: + raise NotImplementedError + + @property + @abstractmethod + def parameter_domain(self) -> tuple[float, float]: + raise NotImplementedError + + def canonicalize_parameter(self, parameter: float) -> float: + """Return a finite selected parameter inside this offer's domain.""" + return _canonicalize_domain_value(parameter, self.parameter_domain) + + def endpoint_at(self, parameter: float) -> Port: + """ + Evaluate the local endpoint for a candidate parameter. + + The returned port is in Tool-local public route coordinates. It must + not depend on live Pather state or mutate the user's target library. + """ + selected = self.canonicalize_parameter(parameter) + if self.endpoint_planner is not None: + return self.endpoint_planner(selected) + raise NotImplementedError + + def cost_at(self, parameter: float) -> float: + """ + Return this primitive's additive planning cost. + + Lower cost is preferred before internal tie-breakers. The default cost + is based on local endpoint displacement plus `priority_bias`. + """ + selected = self.canonicalize_parameter(parameter) + if self.endpoint_planner is not None: + out_port = self.endpoint_planner(selected) + else: + out_port = self.endpoint_at(selected) + return self.priority_bias + abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y)) + + def bbox_at(self, parameter: float) -> NDArray[numpy.float64]: + """ + Return local primitive bounds for footprint-aware planning. + + Tools may omit this hook by raising `NotImplementedError`; when present + it must return a finite `(2, 2)` min/max array in local coordinates. + """ + if self.bbox_planner is None: + raise NotImplementedError + + bounds = numpy.asarray( + self.bbox_planner(self.canonicalize_parameter(parameter)), + dtype=float, + ) + if bounds.shape != (2, 2): + raise BuildError(f'Primitive bbox must have shape (2, 2), got {bounds.shape}') + if not numpy.all(numpy.isfinite(bounds)): + raise BuildError('Primitive bbox must contain only finite values') + if numpy.any(bounds[0, :] > bounds[1, :]): + raise BuildError('Primitive bbox minimum corner must not exceed maximum corner') + return bounds + + def commit(self, parameter: float) -> Any: + """ + Produce opaque render data for a selected primitive. + + `Pather` calls this only for selected primitives while preparing + `RenderStep.data`. Unselected candidates are evaluated by endpoint/cost + only and should not need commit-side work. + """ + selected = self.canonicalize_parameter(parameter) + if self.commit_planner is not None: + return self.commit_planner(selected) + raise NotImplementedError + + +@dataclass(frozen=True, slots=True) +class StraightOffer(PrimitiveOffer): + """Straight or straight-like primitive parameterized by public route length.""" + length_domain: tuple[float, float] = (0.0, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + length_domain: tuple[float, float] = (0.0, numpy.inf), + ) -> Self: + """ + Build a generated straight offer with the default route-frame endpoint. + """ + def endpoint_at(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=ptype) + + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + length_domain = length_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt straight-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + length_domain = (float(endpoint.x), float(endpoint.x)), + ) + + @property + def opcode(self) -> Literal['L']: + return 'L' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.length_domain + + +@dataclass(frozen=True, slots=True) +class BendOffer(PrimitiveOffer): + """Single-turn L-route primitive parameterized by public route length.""" + ccw: bool = True + length_domain: tuple[float, float] = (0.0, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + ccw: bool, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + length_domain: tuple[float, float] = (0.0, numpy.inf), + ) -> Self: + """ + Build a generated bend offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ccw = ccw, + length_domain = length_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + ccw: bool, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt bend offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + ccw = ccw, + length_domain = (float(endpoint.x), float(endpoint.x)), + ) + + @property + def opcode(self) -> Literal['L']: + return 'L' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.length_domain + + +@dataclass(frozen=True, slots=True) +class SOffer(PrimitiveOffer): + """Non-turning S-route primitive parameterized by jog for a fixed route length.""" + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), + ) -> Self: + """ + Build a generated S-like offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = jog_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt S-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = (float(endpoint.y), float(endpoint.y)), + ) + + @property + def opcode(self) -> Literal['S']: + return 'S' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.jog_domain + + +@dataclass(frozen=True, slots=True) +class UOffer(PrimitiveOffer): + """U-turn-like primitive parameterized by jog for a fixed route length.""" + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) + + @classmethod + def generated( + cls, + ptype: str | None, + endpoint_at: EndpointCallable, + data_at: DataCallable, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf), + ) -> Self: + """ + Build a generated U-like offer from endpoint and render-data callbacks. + """ + endpoint_planner, commit_planner, bbox_planner = _generated_offer_callbacks( + endpoint_at, + data_at, + bbox_for_data, + ) + return cls( + in_ptype = ptype, + out_ptype = ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = jog_domain, + ) + + @classmethod + def prebuilt( + cls, + in_ptype: str | None, + out_ptype: str | None, + endpoint: Port, + data: Any, + *, + priority_bias: float = 0.0, + bbox_for_data: BBoxForDataCallable | None = None, + ) -> Self: + """ + Build a prebuilt U-like offer backed by precomputed render data. + """ + endpoint_planner, commit_planner, bbox_planner = _prebuilt_offer_callbacks( + endpoint, + data, + bbox_for_data, + ) + return cls( + in_ptype = in_ptype, + out_ptype = out_ptype, + priority_bias = priority_bias, + bbox_planner = bbox_planner, + endpoint_planner = endpoint_planner, + commit_planner = commit_planner, + jog_domain = (float(endpoint.y), float(endpoint.y)), + ) + + @property + def opcode(self) -> Literal['U']: + return 'U' + + @property + def parameter_domain(self) -> tuple[float, float]: + return self.jog_domain @dataclass(frozen=True, slots=True) class RenderStep: """ - Representation of a single saved operation, used by `RenderPather` and passed - to `Tool.render()` when `RenderPather.render()` is called. + A single deferred routing operation. + + `Pather(render='deferred')` stores these records while routing and later + passes batches of compatible steps to `Tool.render()` when `Pather.render()` + is called. """ opcode: Literal['L', 'S', 'U', 'P'] """ What operation is being performed. - L: planL (straight, optionally with a single bend) - S: planS (s-bend) - U: planU (u-bend) + L: straight or single-bend primitive + S: S-like primitive + U: U-like primitive P: plug """ tool: 'Tool | None' - """ The current tool. May be `None` if `opcode='P'` """ + """ Tool that produced this step, or `None` for `opcode='P'`. """ start_port: Port + """ Input-side port before this step is rendered. """ + end_port: Port + """ Output-side port after this step is rendered. """ data: Any """ Arbitrary tool-specific data""" @@ -47,809 +588,791 @@ class RenderStep: if self.opcode != 'P' and self.tool is None: raise BuildError('Got tool=None but the opcode is not "P"') + def is_continuous_with(self, other: 'RenderStep') -> bool: + """ + Check if another RenderStep can be appended to this one. + """ + # Check continuity with tolerance + offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset)) + rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or ( + other.start_port.rotation is not None and self.end_port.rotation is not None and + bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation)) + ) + return offsets_match and rotations_match -class Tool: + 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.flip_across(axis=axis) + new_end.flip_across(axis=axis) + + return RenderStep( + opcode = self.opcode, + tool = self.tool, + start_port = new_start, + end_port = new_end, + data = self.data, + ) + + +class Tool(ABC): """ Interface for path (e.g. wire or waveguide) generation. - Note that subclasses may implement only a subset of the methods and leave others - unimplemented (e.g. in cases where they don't make sense or the required components - are impractical or unavailable). + Subclasses must override `primitive_offers()` and explicitly return `()` + for recognized primitive kinds they do not support. + + Custom tools should return concrete offer objects (`StraightOffer`, + `BendOffer`, `SOffer`, or `UOffer`) rather than parsing offer identity from + strings after construction. """ - def path( + @abstractmethod + def primitive_offers( self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port. - - Used by `Pather` and `RenderPather`. - - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested L-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'path() not implemented for {type(self)}') - - def pathS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port, and `jog` distance on the perpendicular axis. - `jog` is positive when moving left of the direction of travel (from input to ouput port). - - Used by `Pather` and `RenderPather`. - - The output port should be rotated to face the input port (i.e. plugging the device - into a port will move that port but keep its orientation). - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total distance from input to output, along the second axis. Positive indicates - a leftward shift when moving from input to output port. - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested S-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'path() not implemented for {type(self)}') - - def planL( - self, - ccw: SupportsBool | None, - length: float, + kind: PrimitiveKind, *, in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> tuple[Port, Any]: + ) -> tuple[PrimitiveOffer, ...]: """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port. + Return local primitive offers available for the requested route role. - Used by `RenderPather`. + Tools override this to expose multiple legal primitive variants with explicit domains and costs. + Direct offer implementations should declare the actual endpoint ptype produced by the offer when it + can differ from the requested value. - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. + `kind` is one of: + - `'straight'`: a non-turning `StraightOffer` + - `'bend'`: a 90-degree `BendOffer`; `ccw` is supplied in `kwargs` + - `'s'`: a non-turning `SOffer` + - `'u'`: an `UOffer` - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. + `Pather` applies any requested `out_ptype` to the final route endpoint, + not to every primitive in the route. Intermediate ptypes are + solver-selected, and heterogeneous straight/S offers may be used as + adapters when legal. """ - raise NotImplementedError(f'planL() not implemented for {type(self)}') - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend). - - Used by `RenderPather`. - - The output port must have an orientation rotated by pi from the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a rightwards shift (i.e. clockwise bend followed - by a counterclockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'planS() not implemented for {type(self)}') - - def planU( - self, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - # NOTE: TODO: U-bend is WIP; this interface may change in the future. - - Plan a wire or waveguide that travels exactly `jog` distance along the axis - perpendicular to its input port (i.e. a U-bend). - - Used by `RenderPather`. - - The output port must have an orientation identical to the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise bend - followed by a clockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'planU() not implemented for {type(self)}') + raise NotImplementedError + @abstractmethod def render( self, batch: Sequence[RenderStep], *, - port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) + port_names: tuple[str, str] = ('A', 'B'), + **kwargs, ) -> ILibrary: """ - Render the provided `batch` of `RenderStep`s into geometry, returning a tree - (a Library with a single topcell). + Render a compatible batch of selected route steps into geometry. + + `Pather.render()` passes batches that share one Tool and are continuous + in layout coordinates. The returned tree must have one top cell whose + input and output ports are named by `port_names`; `Pather` plugs the + input port into the pending route start and validates the output port + against the planned final endpoint. Args: - batch: A sequence of `RenderStep` objects containing the ports and data - provided by this tool's `planL`/`planS`/`planU` functions. + batch: A sequence of `RenderStep` objects containing committed + primitive render data. port_names: The topcell's input and output ports should be named `port_names[0]` and `port_names[1]` respectively. kwargs: Custom tool-specific parameters. """ - assert not batch or batch[0].tool == self - raise NotImplementedError(f'render() not implemented for {type(self)}') + raise NotImplementedError -abstract_tuple_t = tuple[Abstract, str, str] +GeneratedPrimitiveFn = Callable[..., Pattern | Library] +GeneratedEndpointFn = Callable[[float], Port] + + +def circular_arc_sbend_endpoint(radius: float, ptype: str) -> GeneratedEndpointFn: + """ + Return an S-bend endpoint planner for two abutting circular arcs. + + The returned callback assumes a pure generated S-bend made from two equal + circular arcs with no attached straight or non-circular pieces. Positive + and negative jogs are supported; the output rotation is always `pi`. + """ + rr = float(radius) + if not scalar_isfinite(rr) or rr <= 0: + raise BuildError(f'S-bend radius must be positive and finite, got {rr:g}') + + def endpoint(jog: float) -> Port: + jj = float(jog) + jog_magnitude = abs(jj) + if scalar_isclose(jog_magnitude, 0.0, rel_tol=1e-9, abs_tol=1e-12): + return Port((0, 0), rotation=pi, ptype=ptype) + if jog_magnitude > 2 * rr and not scalar_isclose(jog_magnitude, 2 * rr, rel_tol=1e-9, abs_tol=1e-12): + raise BuildError(f'S-bend jog magnitude {jog_magnitude:g} exceeds diameter {2 * rr:g}') + dx = sqrt(max(0.0, 4 * rr * jog_magnitude - jog_magnitude ** 2)) + return Port((dx, jj), rotation=pi, ptype=ptype) + + return endpoint @dataclass -class SimpleTool(Tool, metaclass=ABCMeta): +class AutoTool(Tool): """ - A simple tool which relies on a single pre-rendered `bend` pattern, a function - for generating straight paths, and a table of pre-rendered `transitions` for converting - from non-native ptypes. + A routing tool assembled from reusable path primitives. + + `AutoTool` chooses among prioritized straight generators, pre-rendered bends, + optional generated S-bend primitives, pre-rendered U-turns, and + pre-rendered transitions registered through `add_straight()`, + `add_bend()`, `add_sbend()`, `add_uturn()`, and `add_transition()`. + + Registration call order defines primitive priority. + + Straight and bend offers use one straight and, if turning, one bend. + `add_sbend()` exposes generated S-bend primitives. `add_uturn()` exposes + reusable U-turn primitives; otherwise U-turns are left to `Pather`'s + composed-route planning. + + Transitions are bidirectional by default: `add_transition(external, + internal)` exposes adapter offers in both directions. Pass `one_way=True` + when only the declared direction should be available. + + Straight and S-bend generator functions may return either a `Pattern` or a + single-top `Library`. Extra keyword arguments passed to `render()` are + forwarded to those generators. """ - straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str] - """ `create_straight(length: float), in_port_name, out_port_name` """ - - bend: abstract_tuple_t # Assumed to be clockwise - """ `clockwise_bend_abstract, in_port_name, out_port_name` """ - - default_out_ptype: str - """ Default value for out_ptype """ - - mirror_bend: bool = True - """ Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """ @dataclass(frozen=True, slots=True) - class LData: - """ Data for planL """ - straight_length: float - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None + class GeneratedData: + """ Deferred render data for one generated primitive offer. """ + fn: GeneratedPrimitiveFn + port_name: str + parameter: float + mirrored: bool = False - def planL( + @dataclass(frozen=True, slots=True) + class ReusableData: + """ Deferred render data for one reusable abstract primitive offer. """ + abstract: Abstract + port_name: str + mirrored: bool = False + + bbox_library: Mapping[str, Pattern] | None = None + """ Optional source library used to resolve reusable refs during `bbox_at()` measurement. """ + + _straight_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _bend_offers: tuple[list[PrimitiveOffer], list[PrimitiveOffer]] = field( + default_factory = lambda: ([], []), + init = False, + repr = False, + ) + _s_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _u_offers: list[PrimitiveOffer] = field( + default_factory = list, + init = False, + repr = False, + ) + _transition_adapter_offers_by_key: dict[tuple[Literal['straight', 's'], str], list[PrimitiveOffer]] = field( + default_factory=dict, + init = False, + repr = False, + ) + _transition_adapter_offer_keys: set[tuple[int, str, str]] = field( + default_factory=set, + init = False, + repr = False, + ) + + @staticmethod + def _sample_positive_parameter(parameter_domain: tuple[float, float], route_name: str) -> float: + """Choose a finite positive value for generator metadata inference.""" + lower, upper = (float(parameter_domain[0]), float(parameter_domain[1])) + if lower > upper or (lower == upper and lower <= 0): + raise BuildError(f'{route_name} inference requires a positive in-domain sample') + if lower == upper: + return lower + sample_lower = max(lower, 0.0) + preferred = sample_lower if sample_lower > 0 else 1.0 + if numpy.isfinite(upper): + if upper <= sample_lower: + raise BuildError(f'{route_name} inference requires a positive in-domain sample') + return preferred if preferred < upper else (sample_lower + upper) / 2 + return preferred + + @staticmethod + def _generated_pattern(fn: GeneratedPrimitiveFn, parameter: float) -> Pattern: + generated = fn(parameter) + return generated if isinstance(generated, Pattern) else generated.top_pattern() + + @staticmethod + def _two_port_names(pattern: Pattern, route_name: str) -> tuple[str, str]: + port_names = tuple(pattern.ports.keys()) + if len(port_names) != 2: + raise BuildError(f'{route_name} inference requires a generated example with exactly two ports') + return port_names + + @staticmethod + def _resolve_equivalent_ptype( + in_port: Port, + out_port: Port, + ptype: str | None, + route_name: str, + ) -> str | None: + if not ptypes_compatible(in_port.ptype, out_port.ptype): + raise BuildError(f'{route_name} inference requires equivalent port ptypes') + if ptype is not None: + if not ptypes_compatible(in_port.ptype, ptype) or not ptypes_compatible(out_port.ptype, ptype): + raise BuildError(f'{route_name} ptype does not match generated example ports') + return ptype + return in_port.ptype if in_port.ptype not in (None, 'unk') else out_port.ptype + + @staticmethod + def _measure_opposite_ports( + in_port: Port, + out_port: Port, + route_name: str, + ) -> tuple[NDArray[numpy.float64], float]: + dxy, angle = in_port.measure_travel(out_port) + if angle is None: + raise BuildError(f'{route_name} inference requires generated ports with rotations') + normalized_angle = angle % (2 * pi) + if not (numpy.isclose(normalized_angle, pi)): + raise BuildError(f'{route_name} inference requires opposite generated port rotations') + return dxy, angle + + def _infer_straight_metadata( self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, LData]: - if ccw is not None: - bend, bport_in, bport_out = self.bend + fn: GeneratedPrimitiveFn, + length_range: tuple[float, float], + ptype: str | None, + in_port_name: str | None, + ) -> tuple[str | None, str]: + if ptype is not None and in_port_name is not None: + return ptype, in_port_name - angle_in = bend.ports[bport_in].rotation - angle_out = bend.ports[bport_out].rotation - assert angle_in is not None - assert angle_out is not None - - bend_dxy = rotation_matrix_2d(-angle_in) @ ( - bend.ports[bport_out].offset - - bend.ports[bport_in].offset + sample = self._sample_positive_parameter(length_range, 'straight') + pattern = self._generated_pattern(fn, sample) + first_name, second_name = self._two_port_names(pattern, 'straight') + candidate_names = ( + (in_port_name, second_name if in_port_name == first_name else first_name), + ) if in_port_name is not None else ( + (first_name, second_name), + (second_name, first_name), ) + for candidate_in, candidate_out in candidate_names: + if candidate_in not in pattern.ports or candidate_out not in pattern.ports: + continue + in_port = pattern.ports[candidate_in] + out_port = pattern.ports[candidate_out] + dxy, _angle = self._measure_opposite_ports(in_port, out_port, 'straight') + if dxy[0] > 0 and numpy.isclose(dxy[1], 0): + return ( + self._resolve_equivalent_ptype(in_port, out_port, ptype, 'straight'), + candidate_in, + ) + raise BuildError('straight inference requires an equivalent two-port straight example') - bend_angle = angle_out - angle_in + def _infer_sbend_metadata( + self, + fn: GeneratedPrimitiveFn, + jog_range: tuple[float, float], + ptype: str | None, + in_port_name: str | None, + out_port_name: str | None, + ) -> tuple[str | None, str, str]: + if ptype is not None and in_port_name is not None and out_port_name is not None: + return ptype, in_port_name, out_port_name - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi + sample = self._sample_positive_parameter(jog_range, 'S-bend') + pattern = self._generated_pattern(fn, sample) + first_name, second_name = self._two_port_names(pattern, 'S-bend') + if in_port_name is not None and out_port_name is None: + out_port_name = second_name if in_port_name == first_name else first_name + elif in_port_name is None and out_port_name is not None: + in_port_name = second_name if out_port_name == first_name else first_name - if ccw is not None: - out_ptype_actual = bend.ports[bport_out].ptype - else: - out_ptype_actual = self.default_out_ptype - - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] - - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' + candidate_names = ( + (in_port_name, out_port_name), + ) if in_port_name is not None and out_port_name is not None else ( + (first_name, second_name), + (second_name, first_name), ) + for candidate_in, candidate_out in candidate_names: + if candidate_in not in pattern.ports or candidate_out not in pattern.ports: + continue + in_port = pattern.ports[candidate_in] + out_port = pattern.ports[candidate_out] + dxy, _angle = self._measure_opposite_ports(in_port, out_port, 'S-bend') + if dxy[0] > 0 and numpy.isclose(dxy[1], sample): + return ( + self._resolve_equivalent_ptype(in_port, out_port, ptype, 'S-bend'), + candidate_in, + candidate_out, + ) + raise BuildError('S-bend inference requires an equivalent two-port S-bend example') - data = self.LData(straight_length, kwargs, ccw) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data - - def _renderL( + def add_straight( self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - gen_straight, sport_in, _sport_out = self.straight - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: sport_in} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - mirrored = self.mirror_bend and bool(data.ccw) - inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out - pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, + fn: GeneratedPrimitiveFn, + ptype: str | None = None, + in_port_name: str | None = None, *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, + length_range: tuple[float, float] = (0, numpy.inf), + ) -> Self: + """ + Register a generated straight primitive. + + If `ptype` or `in_port_name` is omitted, one in-domain example is + generated and the missing metadata is inferred from an equivalent + two-port straight. + """ + ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name) + priority_bias = len(self._straight_offers) * BUILTIN_PRIORITY_STEP + + def data_at(length: float) -> AutoTool.GeneratedData: + return self.GeneratedData(fn, in_port_name, length) + + self._straight_offers.append(StraightOffer.generated( + ptype, + data_at, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + length_domain = length_range, + )) + return self + + def add_bend( + self, + abstract: Abstract, + in_port_name: str | None = None, + out_port_name: str | None = None, + *, + clockwise: bool | None = None, + mirror: bool = True, + ) -> Self: + """ + Register a reusable L-bend primitive. + + If the bend has exactly two ports, port names may be omitted. The bend + direction is inferred from the selected port orientations; `clockwise`, + when provided, is checked against that inferred direction. + """ + if (in_port_name is None) != (out_port_name is None): + raise BuildError('Bend port names must be provided together') + if in_port_name is None or out_port_name is None: + port_names = tuple(abstract.ports.keys()) + if len(port_names) != 2: + raise BuildError(f'Bend port names are required for {len(port_names)}-port abstracts') + in_port_name, out_port_name = port_names + + priority_bias = len(self._bend_offers[0]) * BUILTIN_PRIORITY_STEP + in_port = abstract.ports[in_port_name] + out_port = abstract.ports[out_port_name] + out_ptype = out_port.ptype + source_clockwise = self._bend_clockwise(in_port, out_port) + if clockwise is not None and bool(clockwise) != source_clockwise: + raise BuildError('Bend clockwise argument does not match port orientations') + + for ccw in (False, True): + target_clockwise = not bool(ccw) + bend_dxy, bend_angle = self._bend2dxy(in_port, out_port, source_clockwise, target_clockwise) + bend_dx = float(bend_dxy[0]) + bend_dy = float(bend_dxy[1]) + source_matches_target = source_clockwise == target_clockwise + mirrored = mirror and not source_matches_target + port_name = in_port_name if (mirror or source_matches_target) else out_port_name + reusable_data = self.ReusableData(abstract, port_name, mirrored) + endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype) + + self._bend_offers[int(ccw)].append(BendOffer.prebuilt( + in_ptype = in_port.ptype, + out_ptype = out_ptype, + endpoint = endpoint, + data = reusable_data, + ccw = ccw, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + return self + + def add_sbend( + self, + fn: GeneratedPrimitiveFn, + ptype: str | None = None, + in_port_name: str | None = None, + out_port_name: str | None = None, + *, + jog_range: tuple[float, float] = (0, numpy.inf), + endpoint: GeneratedEndpointFn | None = None, + ) -> Self: + """ + Register a generated S-bend primitive. + + `endpoint`, when supplied, describes the generated S-bend output port + directly during planning and avoids instantiating `fn()` inside + `endpoint_at()`. + + If `ptype` or port names are omitted, one in-domain example is generated + and the missing metadata is inferred from an equivalent two-port S-bend. + """ + ptype, in_port_name, out_port_name = self._infer_sbend_metadata( + fn, + jog_range, + ptype, + in_port_name, + out_port_name, ) + if endpoint is None: + def endpoint_at(jog: float) -> Port: + jog_magnitude = abs(jog) + sbend_dxy = self._sbend2dxy(fn, in_port_name, out_port_name, jog_magnitude) + return Port((float(sbend_dxy[0]), float(jog)), rotation=pi, ptype=ptype) + else: + def endpoint_at(jog: float) -> Port: + out_port = endpoint(jog) + if not ptypes_compatible(out_port.ptype, ptype): + raise BuildError('S-bend endpoint ptype does not match registered ptype') + return out_port - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree + for jog_domain in self._signed_jog_domains(jog_range): + priority_bias = len(self._s_offers) * BUILTIN_PRIORITY_STEP - def render( + def data_at(jog: float) -> AutoTool.GeneratedData: + return self.GeneratedData( + fn, + in_port_name, + abs(jog), + mirrored = jog < 0, + ) + + self._s_offers.append(SOffer.generated( + ptype, + endpoint_at, + data_at, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + jog_domain = jog_domain, + )) + return self + + def add_uturn( self, - batch: Sequence[RenderStep], + abstract: Abstract, + in_port_name: str, + out_port_name: str, *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=(port_names[0], port_names[1])) - - for step in batch: - assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - -@dataclass -class AutoTool(Tool, metaclass=ABCMeta): - """ - A simple tool which relies on a single pre-rendered `bend` pattern, a function - for generating straight paths, and a table of pre-rendered `transitions` for converting - from non-native ptypes. - """ - - @dataclass(frozen=True, slots=True) - class Straight: - """ Description of a straight-path generator """ - ptype: str - fn: Callable[[float], Pattern] | Callable[[float], Library] - in_port_name: str - out_port_name: str - length_range: tuple[float, float] = (0, numpy.inf) - - @dataclass(frozen=True, slots=True) - class SBend: - """ Description of an s-bend generator """ - ptype: str - - fn: Callable[[float], Pattern] | Callable[[float], Library] + mirror: bool = True, + ) -> Self: """ - Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel - and may be negative for a jog in the opposite direction. Won't be called if jog=0. + Register a reusable U-turn primitive. """ + in_port = abstract.ports[in_port_name] + out_port = abstract.ports[out_port_name] + dxy, angle = in_port.measure_travel(out_port) + if angle is None: + raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') + normalized_angle = angle % (2 * pi) + if not (numpy.isclose(normalized_angle, 0) or numpy.isclose(normalized_angle, 2 * pi)): + raise BuildError('U-turn primitive output port must have the same route-frame rotation as its input port') - in_port_name: str - out_port_name: str - jog_range: tuple[float, float] = (0, numpy.inf) + length = float(dxy[0]) + jog = float(dxy[1]) + out_ptype = out_port.ptype - @dataclass(frozen=True, slots=True) - class Bend: - """ Description of a pre-rendered bend """ - abstract: Abstract - in_port_name: str - out_port_name: str - clockwise: bool = True # Is in-to-out clockwise? - mirror: bool = True # Should we mirror to get the other rotation? + def add_offer( + offer_jog: float, + *, + mirrored: bool, + ) -> None: + reusable_data = self.ReusableData(abstract, in_port_name, mirrored) + priority_bias = len(self._u_offers) * BUILTIN_PRIORITY_STEP + endpoint = Port((length, offer_jog), rotation=0, ptype=out_ptype) - @property - def in_port(self) -> Port: - return self.abstract.ports[self.in_port_name] + self._u_offers.append(UOffer.prebuilt( + in_ptype = in_port.ptype, + out_ptype = out_ptype, + endpoint = endpoint, + data = reusable_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) - @property - def out_port(self) -> Port: - return self.abstract.ports[self.out_port_name] + add_offer(jog, mirrored=False) + if mirror and not numpy.isclose(jog, 0): + add_offer(-jog, mirrored=True) + return self - @dataclass(frozen=True, slots=True) - class Transition: - """ Description of a pre-rendered transition """ - abstract: Abstract - their_port_name: str - our_port_name: str + def add_transition( + self, + abstract: Abstract, + their_port_name: str | None = None, + our_port_name: str | None = None, + *, + one_way: bool = False, + ) -> Self: + """ + Register a reusable port-type transition and expose it as router-visible adapter offers. - @property - def our_port(self) -> Port: - return self.abstract.ports[self.our_port_name] + If the transition has exactly two ports and is bidirectional, port names + may be omitted. + """ + if (their_port_name is None) != (our_port_name is None): + raise BuildError('Transition port names must be provided together') + if their_port_name is None or our_port_name is None: + if one_way: + raise BuildError('one-way transitions require explicit port names') + port_names = tuple(abstract.ports.keys()) + if len(port_names) != 2: + raise BuildError(f'Transition port names are required for {len(port_names)}-port abstracts') + their_port_name, our_port_name = port_names - @property - def their_port(self) -> Port: - return self.abstract.ports[self.their_port_name] - - def reversed(self) -> Self: - return type(self)(self.abstract, self.our_port_name, self.their_port_name) - - @dataclass(frozen=True, slots=True) - class LData: - """ Data for planL """ - straight_length: float - straight: 'AutoTool.Straight' - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - bend: 'AutoTool.Bend | None' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - @dataclass(frozen=True, slots=True) - class SData: - """ Data for planS """ - straight_length: float - straight: 'AutoTool.Straight' - gen_kwargs: dict[str, Any] - jog_remaining: float - sbend: 'AutoTool.SBend' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - straights: list[Straight] - """ List of straight-generators to choose from, in order of priority """ - - bends: list[Bend] - """ List of bends to choose from, in order of priority """ - - sbends: list[SBend] - """ List of S-bend generators to choose from, in order of priority """ - - transitions: dict[tuple[str, str], Transition] - """ `{(external_ptype, internal_ptype): Transition, ...}` """ - - default_out_ptype: str - """ Default value for out_ptype """ - - def add_complementary_transitions(self) -> Self: - for iioo in list(self.transitions.keys()): - ooii = (iioo[1], iioo[0]) - self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) + self._add_transition_direction(abstract, their_port_name, our_port_name) + if not one_way: + self._add_transition_direction(abstract, our_port_name, their_port_name) return self @staticmethod - def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: - if ccw is None: - return numpy.zeros(2), pi - bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) + def _bend_clockwise(in_port: Port, out_port: Port) -> bool: + """Return true when the selected reusable bend port order turns clockwise.""" + _bend_dxy, bend_angle = in_port.measure_travel(out_port) + if bend_angle is None: + raise BuildError('Bend primitive output port must have a 90-degree rotation from its input port') + normalized_angle = bend_angle % (2 * pi) + if numpy.isclose(normalized_angle, pi / 2): + return True + if numpy.isclose(normalized_angle, 3 * pi / 2): + return False + raise BuildError('Bend primitive output port must have a 90-degree rotation from its input port') + + @staticmethod + def _bend2dxy( + in_port: Port, + out_port: Port, + source_clockwise: bool, + target_clockwise: bool, + ) -> tuple[NDArray[numpy.float64], float]: + bend_dxy, bend_angle = in_port.measure_travel(out_port) assert bend_angle is not None - if bool(ccw): + if source_clockwise != target_clockwise: bend_dxy[1] *= -1 bend_angle *= -1 return bend_dxy, bend_angle @staticmethod - def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: - if numpy.isclose(jog, 0): + def _wildcard_ptype_key(ptype: str | None) -> str: + return 'unk' if ptype in (None, 'unk') else ptype + + @staticmethod + def _sbend2dxy( + fn: GeneratedPrimitiveFn, + in_port_name: str, + out_port_name: str, + jog_magnitude: float, + ) -> NDArray[numpy.float64]: + if numpy.isclose(jog_magnitude, 0): return numpy.zeros(2) - sbend_pat_or_tree = sbend.fn(abs(jog)) + sbend_pat_or_tree = fn(jog_magnitude) sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) + dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name]) return dxy - @staticmethod - def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: - if in_transition is None: - return numpy.zeros(2) - dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) - return dxy + def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], None]) -> NDArray[numpy.float64]: + port_names = ('A', 'B') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_bbox') + pat.add_port_pair(names=port_names) + render(tree, port_names) - @staticmethod - def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: - if out_transition is None: - return numpy.zeros(2) - orot = out_transition.our_port.rotation - assert orot is not None - otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset) - return otrans_dxy + if self.bbox_library is None: + library: Mapping[str, Pattern] = tree + else: + library = ChainMap(dict(tree), self.bbox_library) - def planL( + try: + bounds = pat.get_bounds(library=library) + except KeyError as err: + raise NotImplementedError( + 'AutoTool bbox_at() requires bbox_library to resolve reusable primitive refs' + ) from err + if bounds is None: + return numpy.zeros((2, 2), dtype=float) + return numpy.asarray(bounds, dtype=float) + + def _bbox_for_data(self, data: Any) -> NDArray[numpy.float64]: + return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names, {})) + + def _add_transition_direction( self, - ccw: SupportsBool | None, - length: float, + abstract: Abstract, + their_port_name: str, + our_port_name: str, + ) -> None: + their_port = abstract.ports[their_port_name] + our_port = abstract.ports[our_port_name] + transition_data = self.ReusableData(abstract, their_port_name) + + key = ( + id(abstract), + their_port_name, + our_port_name, + ) + if key in self._transition_adapter_offer_keys: + return + self._transition_adapter_offer_keys.add(key) + + dxy, angle = their_port.measure_travel(our_port) + if angle is None or not numpy.isclose(angle, pi): + return + + dx = float(dxy[0]) + dy = float(dxy[1]) + kind: Literal['straight', 's'] = 'straight' if numpy.isclose(dy, 0) else 's' + in_key = self._wildcard_ptype_key(their_port.ptype) + endpoint = Port((dx, dy), rotation=pi, ptype=our_port.ptype) + + offers = self._transition_adapter_offers_by_key.setdefault((kind, in_key), []) + priority_bias = len(offers) * BUILTIN_PRIORITY_STEP + if kind == 'straight': + offers.append(StraightOffer.prebuilt( + in_ptype = their_port.ptype, + out_ptype = our_port.ptype, + endpoint = endpoint, + data = transition_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + return + + offers.append(SOffer.prebuilt( + in_ptype = their_port.ptype, + out_ptype = our_port.ptype, + endpoint = endpoint, + data = transition_data, + priority_bias = priority_bias, + bbox_for_data = self._bbox_for_data, + )) + + @staticmethod + def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]: + lower, upper = (float(magnitude_range[0]), float(magnitude_range[1])) + if lower < 0 or lower > upper: + return () + + if lower == upper: + if lower == 0: + return ((0.0, 0.0),) + return ((lower, lower), (-lower, -lower)) + + positive = (lower, upper) + neg_lower = -numpy.inf if numpy.isinf(upper) else float(numpy.nextafter(-upper, numpy.inf)) + negative = (neg_lower, -lower) + domains: list[tuple[float, float]] = [positive] + if neg_lower < -lower: + domains.append(negative) + if lower > 0: + domains.append((-lower, -lower)) + return tuple(domains) + + def primitive_offers( + self, + kind: PrimitiveKind, *, in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> tuple[Port, LData]: - - success = False - for straight in self.straights: - for bend in self.bends: - bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else bend.out_port.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) - - b_transition = None - if ccw is not None and bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - if success: - break - else: - # Failed to break - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n' - f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' - f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' + ) -> tuple[PrimitiveOffer, ...]: + _ = out_ptype + if kind == 'straight': + in_key = self._wildcard_ptype_key(in_ptype) + return ( + *self._transition_adapter_offers_by_key.get(('straight', in_key), ()), + *self._straight_offers, ) - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif ccw is not None: - out_ptype_actual = bend.out_port.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype + if kind == 'bend': + return tuple(self._bend_offers[int(bool(kwargs['ccw']))]) - data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data + if kind == 's': + in_key = self._wildcard_ptype_key(in_ptype) + return ( + *self._transition_adapter_offers_by_key.get(('s', in_key), ()), + *self._s_offers, + ) - def _renderL( + if kind == 'u': + return tuple(self._u_offers) + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') + + def _render_generated( self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - pat.plug(straight_pat_or_tree, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - bend = data.bend - assert bend is not None - mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise) - inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name - pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - - success = False - for straight in self.straights: - for sbend in self.sbends: - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if numpy.isclose(jog, 0) else sbend.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, pi) - - # Assume we'll need a straight segment with transitions, then discard them if they don't fit - # We do this before generating the s-bend because the transitions might have some dy component - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - b_transition = None - if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype: - b_transition = self.transitions.get((sbend.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]: - # `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()` - # note some S-bends may have 0 length, so we can't be more restrictive - jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - - # Straight didn't work, see if just the s-bend is enough - if sbend.ptype != straight.ptype: - # Need to use a different in-transition for sbend (vs straight) - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) - if success: - b_transition = None - straight_length = 0 - break - if success: - break - - if not success: - try: - ccw0 = jog > 0 - p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype) - p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype) - - dx = p_test1.x - length / 2 - p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype) - p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype) - success = True - except BuildError as err: - l2_err: BuildError | None = err - else: - l2_err = None - raise NotImplementedError('TODO need to handle ldata below') - - if not success: - # Failed to break - raise BuildError( - f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' - ) from l2_err - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif not numpy.isclose(jog_remaining, 0): - out_ptype_actual = sbend.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype - - data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition) - out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual) - return out_port, data - - def _renderS( - self, - data: SData, + data: GeneratedData, tree: ILibrary, port_names: tuple[str, str], gen_kwargs: dict[str, Any], ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ + if numpy.isclose(data.parameter, 0): + return tree + pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if not numpy.isclose(data.jog_remaining, 0): - sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.sbend.in_port_name} - if isinstance(sbend_pat_or_tree, Pattern): - pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0) - else: - sbend_tree = sbend_pat_or_tree - top = sbend_tree.top() - sbend_tree.flatten(top, dangling_ok=True) - pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) + generated = data.fn(data.parameter, **gen_kwargs) + pmap = {port_names[1]: data.port_name} + if isinstance(generated, Pattern): + pat.plug(generated, pmap, append=True, mirrored=data.mirrored) + else: + top = generated.top() + generated.flatten(top, dangling_ok=True) + pat.plug(generated[top], pmap, append=True, mirrored=data.mirrored) return tree - def pathS( + def _render_reusable( self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planS( - length, - jog, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + data: ReusableData, + tree: ILibrary, + port_names: tuple[str, str], + ) -> ILibrary: + pat = tree.top_pattern() + pat.plug(data.abstract, {port_names[1]: data.port_name}, mirrored=data.mirrored) return tree + def _render_data( + self, + data: Any, + tree: ILibrary, + port_names: tuple[str, str], + gen_kwargs: dict[str, Any], + ) -> ILibrary: + if isinstance(data, self.GeneratedData): + return self._render_generated(data=data, tree=tree, port_names=port_names, gen_kwargs=gen_kwargs) + if isinstance(data, self.ReusableData): + return self._render_reusable(data=data, tree=tree, port_names=port_names) + raise BuildError(f'Unexpected AutoTool render data {type(data).__name__}') + def render( self, batch: Sequence[RenderStep], @@ -858,114 +1381,156 @@ class AutoTool(Tool, metaclass=ABCMeta): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') pat.add_port_pair(names=(port_names[0], port_names[1])) for step in batch: assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - elif step.opcode == 'S': - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) + self._render_data(step.data, tree, port_names, kwargs) return tree @dataclass -class PathTool(Tool, metaclass=ABCMeta): +class PathTool(Tool): """ - A tool which draws `Path` geometry elements. + Tool that renders routes directly as `Pattern.path()` geometry. - If `planL` / `render` are used, the `Path` elements can cover >2 vertices; - with `path` only individual rectangles will be drawn. + `PathTool` supports L and S primitive offers. `render()` combines a + compatible batch of L/S `RenderStep`s into one multi-vertex path. U routes + are left to `Pather` synthesis or to a different tool. """ layer: layer_t - """ Layer to draw on """ + """ Layer to draw generated path geometry on. """ width: float - """ `Path` width """ + """ Width of generated path geometry. """ ptype: str = 'unk' - """ ptype for any ports in patterns generated by this tool """ + """ Port type for generated input and output ports. """ - #@dataclass(frozen=True, slots=True) - #class LData: - # dxy: NDArray[numpy.float64] + def _bend_radius(self) -> float: + return self.width / 2 - #def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None: - # Tool.__init__(self) - # self.layer = layer - # self.width = width - # self.ptype: str + def _plan_l_vertices(self, length: float, bend_run: float) -> NDArray[numpy.float64]: + vertices = [(0.0, 0.0), (length, 0.0)] + if not numpy.isclose(bend_run, 0): + vertices.append((length, bend_run)) + return numpy.array(vertices, dtype=float) - def path( + def _plan_s_vertices(self, length: float, jog: float) -> NDArray[numpy.float64]: + if numpy.isclose(jog, 0): + return numpy.array([(0.0, 0.0), (length, 0.0)], dtype=float) + + if length < self.width: + raise BuildError( + f'Asked to draw S-path with total length {length:,g}, shorter than required bend: {self.width:,g}' + ) + + # Match AutoTool's straight-then-s-bend placement so the jog happens + # width/2 before the end while still allowing smaller lateral offsets. + jog_x = length - self._bend_radius() + vertices = [ + (0.0, 0.0), + (jog_x, 0.0), + (jog_x, jog), + (length, jog), + ] + return numpy.array(vertices, dtype=float) + + def _path_bbox(self, vertices: NDArray[numpy.float64]) -> NDArray[numpy.float64]: + return Path(vertices=vertices, width=self.width).get_bounds_single() + + def primitive_offers( self, - ccw: SupportsBool | None, - length: float, + kind: PrimitiveKind, *, in_ptype: str | None = None, out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) - ) -> Library: - out_port, dxy = self.planL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - ) + **kwargs, + ) -> tuple[PrimitiveOffer, ...]: + if kind == 'u': + return () - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) - - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - - pat.ports = { - port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), - } - - return tree - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, NDArray[numpy.float64]]: - # TODO check all the math for L-shaped bends - - if out_ptype and out_ptype != self.ptype: + if not ptypes_compatible(out_ptype, self.ptype): raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') + ptype = self.ptype - if ccw is not None: - bend_dxy = numpy.array([1, -1]) * self.width / 2 - bend_angle = pi / 2 + if kind == 'straight': + def straight_data(length: float) -> NDArray[numpy.float64]: + return numpy.array((length, 0.0)) - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi + def endpoint_straight(length: float) -> Port: + data = straight_data(length) + return Port(data, rotation=pi, ptype=ptype) - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] + def bbox_straight(length: float) -> NDArray[numpy.float64]: + data = straight_data(length) + return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' - ) - data = numpy.array((length, bend_run)) - out_port = Port(data, rotation=bend_angle, ptype=self.ptype) - return out_port, data + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + bbox_planner=bbox_straight, + endpoint_planner=endpoint_straight, + commit_planner=straight_data, + ),) + + if kind == 'bend': + ccw = kwargs['ccw'] + radius = self._bend_radius() + bend_run = radius if bool(ccw) else -radius + bend_angle = -pi / 2 if bool(ccw) else pi / 2 + + def bend_data(length: float) -> NDArray[numpy.float64]: + _ = length + return numpy.array((length, bend_run)) + + def endpoint_bend(length: float) -> Port: + data = bend_data(length) + return Port(data, rotation=bend_angle, ptype=ptype) + + def bbox_bend(length: float) -> NDArray[numpy.float64]: + data = bend_data(length) + return self._path_bbox(self._plan_l_vertices(float(data[0]), float(data[1]))) + + return (BendOffer( + in_ptype=in_ptype, + out_ptype=ptype, + ccw=bool(ccw), + length_domain=(radius, radius), + bbox_planner=bbox_bend, + endpoint_planner=endpoint_bend, + commit_planner=bend_data, + ),) + + if kind == 's': + def minimum_length(jog: float) -> float: + if numpy.isclose(jog, 0): + return 0.0 + return self.width + + def s_data(jog: float) -> NDArray[numpy.float64]: + length = minimum_length(jog) + self._plan_s_vertices(length, jog) + return numpy.array((length, jog)) + + def endpoint_s(jog: float) -> Port: + data = s_data(jog) + return Port(data, rotation=pi, ptype=ptype) + + def bbox_s(jog: float) -> NDArray[numpy.float64]: + data = s_data(jog) + return self._path_bbox(self._plan_s_vertices(float(data[0]), float(data[1]))) + + return (SOffer( + in_ptype=in_ptype, + out_ptype=ptype, + bbox_planner=bbox_s, + endpoint_planner=endpoint_s, + commit_planner=s_data, + ),) + + raise BuildError(f'Unrecognized primitive offer kind {kind!r}') def render( self, @@ -975,29 +1540,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. Pather.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 + # 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. assert port_rot is not None - + transform = rotation_matrix_2d(port_rot + pi) + delta = step.end_port.offset - step.start_port.offset + local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta if step.opcode == 'L': - length, bend_run = 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) + local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1])) + elif step.opcode == 'S': + local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1])) else: raise BuildError(f'Unrecognized opcode "{step.opcode}"') - if (path_vertices[-1] != batch[-1].end_port.offset).any(): - # If the path ends in a bend, we need to add the final vertex - path_vertices.append(batch[-1].end_port.offset) + for vertex in local_vertices[1:]: + path_vertices.append(step.start_port.offset + transform @ vertex) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') 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 diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 3109f46..ca36fff 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -46,7 +46,7 @@ def ell( ccw: Turn direction. `True` means counterclockwise, `False` means clockwise, and `None` means no bend. If `None`, spacing must remain `None` or `0` (default), Otherwise, spacing must be set to a non-`None` value. - bound_method: Method used for determining the travel distance; see diagram above. + bound_type: Method used for determining the travel distance; see diagram above. Valid values are: - 'min_extension' or 'emin': The total extension value for the furthest-out port (B in the diagram). @@ -64,7 +64,7 @@ def ell( the x- and y- axes. If specifying a position, it is projected onto the extension direction. - bound_value: Value associated with `bound_type`, see above. + bound: Value associated with `bound_type`, see above. spacing: Distance between adjacent channels. Can be scalar, resulting in evenly spaced channels, or a vector with length one less than `ports`, allowing non-uniform spacing. @@ -84,7 +84,7 @@ def ell( raise BuildError('Empty port list passed to `ell()`') if ccw is None: - if spacing is not None and not numpy.isclose(spacing, 0): + if spacing is not None and not numpy.allclose(spacing, 0): raise BuildError('Spacing must be 0 or None when ccw=None') spacing = 0 elif spacing is None: @@ -106,7 +106,7 @@ def ell( raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + pformat(port_rotations)) else: - if set_rotation is not None: + if set_rotation is None: raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) @@ -132,8 +132,17 @@ def ell( if spacing is None: ch_offsets = numpy.zeros_like(y_order) else: + spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) steps = numpy.zeros_like(y_order) - steps[1:] = spacing + if spacing_arr.size == 1: + steps[1:] = spacing_arr[0] + elif spacing_arr.size == len(ports) - 1: + steps[1:] = spacing_arr + else: + raise BuildError( + f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' + f'got length {spacing_arr.size}' + ) ch_offsets = numpy.cumsum(steps)[y_ind] x_start = rot_offsets[:, 0] diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0f6dd32..237b1d8 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -16,7 +16,7 @@ import gzip import numpy import ezdxf from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert +from ezdxf.entities import LWPolyline, Polyline, Text, Insert, Solid, Trace from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -55,8 +55,7 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - DXF does not support shape repetition (only block repeptition). Please call - library.wrap_repeated_shapes() before writing to file. + Shape repetitions are expanded into individual DXF entities. Other functions you may want to call: - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names @@ -193,8 +192,37 @@ def read( top_name, top_pat = _read_block(msp) mlib = Library({top_name: top_pat}) + + blocks_by_name = { + bb.name: bb + for bb in lib.blocks + if not bb.is_any_layout + } + + referenced: set[str] = set() + pending = [msp] + seen_blocks: set[str] = set() + while pending: + block = pending.pop() + block_name = getattr(block, 'name', None) + if block_name is not None and block_name in seen_blocks: + continue + if block_name is not None: + seen_blocks.add(block_name) + for element in block: + if not isinstance(element, Insert): + continue + target = element.dxfattribs().get('name') + if target is None or target in referenced: + continue + referenced.add(target) + if target in blocks_by_name: + pending.append(blocks_by_name[target]) + for bb in lib.blocks: - if bb.name == '*Model_Space': + if bb.is_any_layout: + continue + if bb.name.startswith('_') and bb.name not in referenced: continue name, pat = _read_block(bb) mlib[name] = pat @@ -213,32 +241,60 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> if isinstance(element, LWPolyline | Polyline): if isinstance(element, LWPolyline): points = numpy.asarray(element.get_points()) - elif isinstance(element, Polyline): + is_closed = element.closed + else: points = numpy.asarray([pp.xyz for pp in element.points()]) + is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - if points.shape[1] == 2: - raise PatternError('Invalid or unimplemented polygon?') + width = 0 + if isinstance(element, LWPolyline): + # ezdxf 1.4+ get_points() returns (x, y, start_width, end_width, bulge) + if points.shape[1] >= 5: + if (points[:, 4] != 0).any(): + raise PatternError('LWPolyline has bulge (not yet representable in masque!)') + if (points[:, 2] != points[:, 3]).any() or (points[:, 2] != points[0, 2]).any(): + raise PatternError('LWPolyline has non-constant width (not yet representable in masque!)') + width = points[0, 2] + elif points.shape[1] == 3: + # width used to be in column 2 + width = points[0, 2] - if points.shape[1] > 2: - if (points[0, 2] != points[:, 2]).any(): - raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') - if points.shape[1] == 4 and (points[:, 3] != 0).any(): - raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') + if width == 0: + width = attr.get('const_width', 0) - width = points[0, 2] - if width == 0: - width = attr.get('const_width', 0) + verts = points[:, :2] + if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): + verts = numpy.vstack((verts, verts[0])) - shape: Path | Polygon - if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): - shape = Polygon(vertices=points[:-1, :2]) + shape: Path | Polygon + if width == 0 and is_closed: + # Use Polygon if it has at least 3 unique vertices + shape_verts = verts[:-1] if len(verts) > 1 else verts + if len(shape_verts) >= 3: + shape = Polygon(vertices=shape_verts) else: - shape = Path(width=width, vertices=points[:, :2]) + shape = Path(width=width, vertices=verts) + else: + shape = Path(width=width, vertices=verts) pat.shapes[layer].append(shape) - + elif isinstance(element, Solid | Trace): + attr = element.dxfattribs() + layer = attr.get('layer', DEFAULT_LAYER) + points = numpy.array([element.get_dxf_attrib(f'vtx{i}') for i in range(4) + if element.has_dxf_attrib(f'vtx{i}')]) + if len(points) >= 3: + # If vtx2 == vtx3, it's a triangle. ezdxf handles this. + if len(points) == 4 and numpy.allclose(points[2], points[3]): + verts = points[:3, :2] + # DXF Solid/Trace uses 0-1-3-2 vertex order for quadrilaterals! + elif len(points) == 4: + verts = points[[0, 1, 3, 2], :2] + else: + verts = points[:, :2] + pat.shapes[layer].append(Polygon(vertices=verts)) elif isinstance(element, Text): args = dict( offset=numpy.asarray(element.get_placement()[1])[:2], @@ -273,12 +329,57 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> ) if 'column_count' in attr: - args['repetition'] = Grid( - a_vector=(attr['column_spacing'], 0), - b_vector=(0, attr['row_spacing']), - a_count=attr['column_count'], - b_count=attr['row_count'], + col_spacing = attr['column_spacing'] + row_spacing = attr['row_spacing'] + col_count = attr['column_count'] + row_count = attr['row_count'] + local_x = numpy.array((col_spacing, 0.0)) + local_y = numpy.array((0.0, row_spacing)) + inv_rot = rotation_matrix_2d(-rotation) + + candidates = ( + (inv_rot @ local_x, inv_rot @ local_y, col_count, row_count), + (inv_rot @ local_y, inv_rot @ local_x, row_count, col_count), ) + repetition = None + for a_vector, b_vector, a_count, b_count in candidates: + rotated_a = rotation_matrix_2d(rotation) @ a_vector + rotated_b = rotation_matrix_2d(rotation) @ b_vector + if (numpy.isclose(rotated_a[1], 0, atol=1e-8) + and numpy.isclose(rotated_b[0], 0, atol=1e-8) + and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8) + and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8) + and a_count == col_count + and b_count == row_count): + repetition = Grid( + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, + ) + break + if (numpy.isclose(rotated_a[0], 0, atol=1e-8) + and numpy.isclose(rotated_b[1], 0, atol=1e-8) + and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8) + and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8) + and b_count == col_count + and a_count == row_count): + repetition = Grid( + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, + ) + break + + if repetition is None: + repetition = Grid( + a_vector=inv_rot @ local_x, + b_vector=inv_rot @ local_y, + a_count=col_count, + b_count=row_count, + ) + args['repetition'] = repetition pat.ref(**args) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') @@ -303,15 +404,23 @@ def _mrefs_to_drefs( elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - rotated_a = rotation_matrix_2d(-ref.rotation) @ a - rotated_b = rotation_matrix_2d(-ref.rotation) @ b - if rotated_a[1] == 0 and rotated_b[0] == 0: + # In masque, the grid basis vectors are NOT rotated by the reference's rotation. + # In DXF, the grid basis vectors are [column_spacing, 0] and [0, row_spacing], + # which ARE then rotated by the block reference's rotation. + # Therefore, we can only use a DXF array if ref.rotation is 0 (or a multiple of 90) + # AND the grid is already manhattan. + + # Rotate basis vectors by the reference rotation to see where they end up in the DXF frame + rotated_a = rotation_matrix_2d(ref.rotation) @ a + rotated_b = rotation_matrix_2d(ref.rotation) @ b + + if numpy.isclose(rotated_a[1], 0, atol=1e-8) and numpy.isclose(rotated_b[0], 0, atol=1e-8): attribs['column_count'] = rep.a_count attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) - elif rotated_a[0] == 0 and rotated_b[1] == 0: + elif numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -344,16 +453,23 @@ def _shapes_to_elements( for layer, sseq in shapes.items(): attribs = dict(layer=_mlayer2dxf(layer)) for shape in sseq: + displacements = [numpy.zeros(2)] if shape.repetition is not None: - raise PatternError( - 'Shape repetitions are not supported by DXF.' - ' Please call library.wrap_repeated_shapes() before writing to file.' - ) + displacements = shape.repetition.displacements - for polygon in shape.to_polygons(): - xy_open = polygon.vertices - xy_closed = numpy.vstack((xy_open, xy_open[0, :])) - block.add_lwpolyline(xy_closed, dxfattribs=attribs) + for dd in displacements: + if isinstance(shape, Path): + # preserve path. + # Note: DXF paths don't support endcaps well, so this is still a bit limited. + xy = shape.vertices + dd + attribs_path = {**attribs} + if shape.width > 0: + attribs_path['const_width'] = shape.width + block.add_lwpolyline(xy, dxfattribs=attribs_path) + else: + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + dd + block.add_lwpolyline(xy_open, close=True, dxfattribs=attribs) def _labels_to_texts( @@ -363,11 +479,17 @@ def _labels_to_texts( for layer, lseq in labels.items(): attribs = dict(layer=_mlayer2dxf(layer)) for label in lseq: - xy = label.offset - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) + if label.repetition is None: + block.add_text( + label.string, + dxfattribs=attribs + ).set_placement(label.offset, align=TextEntityAlignment.BOTTOM_LEFT) + else: + for dd in label.repetition.displacements: + block.add_text( + label.string, + dxfattribs=attribs + ).set_placement(label.offset + dd, align=TextEntityAlignment.BOTTOM_LEFT) def _mlayer2dxf(layer: layer_t) -> str: diff --git a/masque/file/gdsii/__init__.py b/masque/file/gdsii/__init__.py new file mode 100644 index 0000000..09a0b03 --- /dev/null +++ b/masque/file/gdsii/__init__.py @@ -0,0 +1,10 @@ +""" +GDSII file format readers and writers. +""" +from .klamath import check_valid_names as check_valid_names +from .klamath import read as read +from .klamath import read_elements as read_elements +from .klamath import readfile as readfile +from .klamath import rint_cast as rint_cast +from .klamath import write as write +from .klamath import writefile as writefile diff --git a/masque/file/gdsii/arrow.py b/masque/file/gdsii/arrow.py new file mode 100644 index 0000000..c0d9c61 --- /dev/null +++ b/masque/file/gdsii/arrow.py @@ -0,0 +1,878 @@ +# ruff: noqa: ARG001, F401 +""" +GDSII file format readers and writers using the `TODO` library. + +Note that GDSII references follow the same convention as `masque`, + with this order of operations: + 1. Mirroring + 2. Rotation + 3. Scaling + 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) + + Scaling, rotation, and mirroring apply to individual instances, not grid + vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations + * GDS creation/modification/access times are set to 1900-01-01 for reproducibility. + * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) + + TODO writing + TODO warn on boxes, nodes +""" +from typing import IO, cast, Any +from collections.abc import Iterable, Mapping, Callable +from importlib.machinery import EXTENSION_SUFFIXES +import importlib.util +import mmap +import logging +import os +import pathlib +import gzip +import string +import sys +import tempfile +from pprint import pformat + +from klamath.basic import KlamathError +import numpy +from numpy.typing import NDArray +import pyarrow +from pyarrow.cffi import ffi + +from ..utils import is_gzipped, tmpfile +from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape +from ...shapes import Polygon, Path, PolyCollection, RectCollection +from ...repetition import Grid +from ...utils import layer_t, annotations_t +from ...library import LazyLibrary, Library, ILibrary, ILibraryView + + +logger = logging.getLogger(__name__) + +ffi.cdef( + """ + const char* last_error_message(void); + int read_path(const char* path, struct ArrowArray* array, struct ArrowSchema* schema); + int scan_bytes(uint8_t* data, size_t size, struct ArrowArray* array, struct ArrowSchema* schema); + int read_cells_bytes( + uint8_t* data, + size_t size, + uint64_t* ranges, + size_t range_count, + struct ArrowArray* array, + struct ArrowSchema* schema + ); + """ +) + +clib: Any | None = None + + +path_cap_map = { + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } + + +def _packed_layer_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int16]: + layer = (values >> numpy.uint32(16)).astype(numpy.uint16).view(numpy.int16) + dtype = (values & numpy.uint32(0xffff)).astype(numpy.uint16).view(numpy.int16) + return numpy.stack((layer, dtype), axis=-1) + + +def _packed_counts_u32_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int64]: + a_count = (values >> numpy.uint32(16)).astype(numpy.uint16).astype(numpy.int64) + b_count = (values & numpy.uint32(0xffff)).astype(numpy.uint16).astype(numpy.int64) + return numpy.stack((a_count, b_count), axis=-1) + + +def _packed_xy_u64_to_pairs(values: NDArray[numpy.unsignedinteger[Any]]) -> NDArray[numpy.int32]: + xx = (values >> numpy.uint64(32)).astype(numpy.uint32).view(numpy.int32) + yy = (values & numpy.uint64(0xffff_ffff)).astype(numpy.uint32).view(numpy.int32) + return numpy.stack((xx, yy), axis=-1) + + +def _local_library_filename() -> str: + if sys.platform.startswith('linux'): + return 'libklamath_rs_ext.so' + if sys.platform == 'darwin': + return 'libklamath_rs_ext.dylib' + if sys.platform == 'win32': + return 'klamath_rs_ext.dll' + raise OSError(f'Unsupported platform for klamath_rs_ext: {sys.platform!r}') + + +def _installed_library_candidates() -> list[pathlib.Path]: + candidates: list[pathlib.Path] = [] + + try: + spec = importlib.util.find_spec('klamath_rs_ext.klamath_rs_ext') + except ModuleNotFoundError: + spec = None + if spec is not None and spec.origin is not None: + candidates.append(pathlib.Path(spec.origin)) + + try: + pkg_spec = importlib.util.find_spec('klamath_rs_ext') + except ModuleNotFoundError: + pkg_spec = None + if pkg_spec is not None and pkg_spec.submodule_search_locations is not None: + for location in pkg_spec.submodule_search_locations: + pkg_dir = pathlib.Path(location) + for suffix in EXTENSION_SUFFIXES: + candidates.extend(sorted(pkg_dir.glob(f'klamath_rs_ext*{suffix}'))) + + return candidates + + +def _repo_library_candidates() -> list[pathlib.Path]: + repo_root = pathlib.Path(__file__).resolve().parents[3] + library_name = _local_library_filename() + return [ + repo_root / 'klamath-rs' / 'target' / 'release' / library_name, + repo_root / 'klamath-rs' / 'target' / 'debug' / library_name, + ] + + +def find_klamath_rs_library() -> pathlib.Path | None: + env_path = os.environ.get('KLAMATH_RS_EXT_LIB') + if env_path: + candidate = pathlib.Path(env_path).expanduser() + if candidate.exists(): + return candidate.resolve() + + seen: set[pathlib.Path] = set() + for candidate in _installed_library_candidates() + _repo_library_candidates(): + resolved = candidate.expanduser() + if resolved in seen: + continue + seen.add(resolved) + if resolved.exists(): + return resolved.resolve() + return None + + +def is_available() -> bool: + return find_klamath_rs_library() is not None + + +def _get_clib() -> Any: + global clib # noqa: PLW0603 + if clib is None: + lib_path = find_klamath_rs_library() + if lib_path is None: + raise ImportError( + 'Could not locate klamath_rs_ext shared library. ' + 'Build klamath-rs with `cargo build --release --manifest-path klamath-rs/Cargo.toml` ' + 'or set KLAMATH_RS_EXT_LIB to the built library path.' + ) + clib = ffi.dlopen(str(lib_path)) + return clib + + +def _read_annotations( + prop_offs: NDArray[numpy.integer[Any]], + prop_key: NDArray[numpy.integer[Any]], + prop_val: list[str], + ee: int, + ) -> annotations_t: + prop_ii, prop_ff = prop_offs[ee], prop_offs[ee + 1] + if prop_ii >= prop_ff: + return None + return {str(prop_key[off]): [prop_val[off]] for off in range(prop_ii, prop_ff)} + + +def _read_to_arrow( + filename: str | pathlib.Path, + ) -> pyarrow.Array: + path = pathlib.Path(filename).expanduser().resolve() + ptr_array = ffi.new('struct ArrowArray[]', 1) + ptr_schema = ffi.new('struct ArrowSchema[]', 1) + if is_gzipped(path): + with gzip.open(path, mode='rb') as src: + data = src.read() + with tempfile.NamedTemporaryFile(suffix='.gds', delete=False) as tmp_stream: + tmp_stream.write(data) + tmp_name = tmp_stream.name + try: + _call_native(_get_clib().read_path(tmp_name.encode(), ptr_array, ptr_schema), 'read_path') + finally: + pathlib.Path(tmp_name).unlink(missing_ok=True) + else: + _call_native(_get_clib().read_path(str(path).encode(), ptr_array, ptr_schema), 'read_path') + return _import_arrow_array(ptr_array, ptr_schema) + + +def _import_arrow_array(ptr_array: Any, ptr_schema: Any) -> pyarrow.Array: + iptr_schema = int(ffi.cast('uintptr_t', ptr_schema)) + iptr_array = int(ffi.cast('uintptr_t', ptr_array)) + return pyarrow.Array._import_from_c(iptr_array, iptr_schema) + + +def _call_native(status: int, action: str) -> None: + if status == 0: + return + + err_ptr = _get_clib().last_error_message() + if err_ptr == ffi.NULL: + raise KlamathError(f'{action} failed') + + message = ffi.string(err_ptr).decode(errors='replace') + raise KlamathError(message) + + +def _scan_buffer_to_arrow(buffer: bytes | mmap.mmap | memoryview) -> pyarrow.Array: + ptr_array = ffi.new('struct ArrowArray[]', 1) + ptr_schema = ffi.new('struct ArrowSchema[]', 1) + buf_view = memoryview(buffer) + cbuf = ffi.from_buffer('uint8_t[]', buf_view) + _call_native(_get_clib().scan_bytes(cbuf, len(buf_view), ptr_array, ptr_schema), 'scan_bytes') + return _import_arrow_array(ptr_array, ptr_schema) + + +def _read_selected_cells_to_arrow( + buffer: bytes | mmap.mmap | memoryview, + ranges: NDArray[numpy.uint64], + ) -> pyarrow.Array: + ptr_array = ffi.new('struct ArrowArray[]', 1) + ptr_schema = ffi.new('struct ArrowSchema[]', 1) + buf_view = memoryview(buffer) + cbuf = ffi.from_buffer('uint8_t[]', buf_view) + flat_ranges = numpy.require(ranges, dtype=numpy.uint64, requirements=('C_CONTIGUOUS', 'ALIGNED')) + cranges = ffi.from_buffer('uint64_t[]', flat_ranges) + _call_native( + _get_clib().read_cells_bytes(cbuf, len(buf_view), cranges, int(flat_ranges.shape[0]), ptr_array, ptr_schema), + 'read_cells_bytes', + ) + return _import_arrow_array(ptr_array, ptr_schema) + + +def readfile( + filename: str | pathlib.Path, + ) -> tuple[Library, dict[str, Any]]: + """ + Read a GDSII file from a path into `masque.Library` / `Pattern` objects. + + Will automatically decompress gzipped files. + + Args: + filename: Filename to read. + + For callers that can consume Arrow directly, prefer `readfile_arrow()` + to skip Python `Pattern` construction entirely. + """ + arrow_arr = _read_to_arrow(filename) + assert len(arrow_arr) == 1 + + results = read_arrow(arrow_arr[0]) + + return results + + +def readfile_arrow( + filename: str | pathlib.Path, + ) -> tuple[pyarrow.StructScalar, dict[str, Any]]: + """ + Read a GDSII file into the native Arrow representation without converting + it into `masque.Library` / `Pattern` objects. + + This is the lowest-overhead public read path exposed by this module. + + Args: + filename: Filename to read. + + Returns: + - Arrow struct scalar for the library payload + - dict of GDSII library info + """ + arrow_arr = _read_to_arrow(filename) + assert len(arrow_arr) == 1 + libarr = arrow_arr[0] + return libarr, _read_header(libarr) + + +def read_arrow( + libarr: pyarrow.Array, + ) -> tuple[Library, dict[str, Any]]: + """ + # TODO check GDSII file for cycles! + Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are + translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs + are translated into Ref objects. + + Additional library info is returned in a dict, containing: + 'name': name of the library + 'meters_per_unit': number of meters per database unit (all values are in database units) + 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) + per database unit + + Args: + libarr: Arrow library payload as returned by `readfile_arrow()`. + + Returns: + - dict of pattern_name:Patterns generated from GDSII structures + - dict of GDSII library info + """ + library_info = _read_header(libarr) + + layer_names_np = _packed_layer_u32_to_pairs(libarr['layers'].values.to_numpy()) + layer_tups = [(int(pair[0]), int(pair[1])) for pair in layer_names_np] + + cell_ids = libarr['cells'].values.field('id').to_numpy() + cell_names = libarr['cell_names'].as_py() + + def get_geom(libarr: pyarrow.Array, geom_type: str) -> dict[str, Any]: + el = libarr['cells'].values.field(geom_type) + elem = dict( + offsets = el.offsets.to_numpy(), + xy_arr = el.values.field('xy').values.to_numpy().reshape((-1, 2)), + xy_off = el.values.field('xy').offsets.to_numpy() // 2, + layer_inds = el.values.field('layer').to_numpy(), + prop_off = el.values.field('properties').offsets.to_numpy(), + prop_key = el.values.field('properties').values.field('key').to_numpy(), + prop_val = el.values.field('properties').values.field('value').to_pylist(), + ) + return elem + + def get_boundary_batches(libarr: pyarrow.Array) -> dict[str, Any]: + batches = libarr['cells'].values.field('boundary_batches') + return dict( + offsets = batches.offsets.to_numpy(), + layer_inds = batches.values.field('layer').to_numpy(), + vert_arr = batches.values.field('vertices').values.to_numpy().reshape((-1, 2)), + vert_off = batches.values.field('vertices').offsets.to_numpy() // 2, + poly_off = batches.values.field('vertex_offsets').offsets.to_numpy(), + poly_offsets = batches.values.field('vertex_offsets').values.to_numpy(), + ) + + def get_rect_batches(libarr: pyarrow.Array) -> dict[str, Any]: + batches = libarr['cells'].values.field('rect_batches') + return dict( + offsets = batches.offsets.to_numpy(), + layer_inds = batches.values.field('layer').to_numpy(), + rect_arr = batches.values.field('rects').values.to_numpy().reshape((-1, 4)), + rect_off = batches.values.field('rects').offsets.to_numpy() // 4, + ) + + def get_boundary_props(libarr: pyarrow.Array) -> dict[str, Any]: + boundaries = libarr['cells'].values.field('boundary_props') + return dict( + offsets = boundaries.offsets.to_numpy(), + layer_inds = boundaries.values.field('layer').to_numpy(), + vert_arr = boundaries.values.field('vertices').values.to_numpy().reshape((-1, 2)), + vert_off = boundaries.values.field('vertices').offsets.to_numpy() // 2, + prop_off = boundaries.values.field('properties').offsets.to_numpy(), + prop_key = boundaries.values.field('properties').values.field('key').to_numpy(), + prop_val = boundaries.values.field('properties').values.field('value').to_pylist(), + ) + + def get_refs(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]: + refs = libarr['cells'].values.field(geom_type) + values = refs.values + elem = dict( + offsets = refs.offsets.to_numpy(), + targets = values.field('target').to_numpy(), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), + invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), + angle_rad = values.field('angle_rad').to_numpy(), + scale = values.field('scale').to_numpy(), + ) + if has_repetition: + elem.update(dict( + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), + counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), + )) + return elem + + def get_ref_props(libarr: pyarrow.Array, geom_type: str, has_repetition: bool) -> dict[str, Any]: + refs = libarr['cells'].values.field(geom_type) + values = refs.values + elem = dict( + offsets = refs.offsets.to_numpy(), + targets = values.field('target').to_numpy(), + xy = _packed_xy_u64_to_pairs(values.field('xy').to_numpy()), + invert_y = values.field('invert_y').to_numpy(zero_copy_only=False), + angle_rad = values.field('angle_rad').to_numpy(), + scale = values.field('scale').to_numpy(), + prop_off = values.field('properties').offsets.to_numpy(), + prop_key = values.field('properties').values.field('key').to_numpy(), + prop_val = values.field('properties').values.field('value').to_pylist(), + ) + if has_repetition: + elem.update(dict( + xy0 = _packed_xy_u64_to_pairs(values.field('xy0').to_numpy()), + xy1 = _packed_xy_u64_to_pairs(values.field('xy1').to_numpy()), + counts = _packed_counts_u32_to_pairs(values.field('counts').to_numpy()), + )) + return elem + + txt = libarr['cells'].values.field('texts') + texts = dict( + offsets = txt.offsets.to_numpy(), + layer_inds = txt.values.field('layer').to_numpy(), + xy = _packed_xy_u64_to_pairs(txt.values.field('xy').to_numpy()), + string = txt.values.field('string').to_pylist(), + prop_off = txt.values.field('properties').offsets.to_numpy(), + prop_key = txt.values.field('properties').values.field('key').to_numpy(), + prop_val = txt.values.field('properties').values.field('value').to_pylist(), + ) + + elements = dict( + srefs = get_refs(libarr, 'srefs', has_repetition=False), + arefs = get_refs(libarr, 'arefs', has_repetition=True), + sref_props = get_ref_props(libarr, 'sref_props', has_repetition=False), + aref_props = get_ref_props(libarr, 'aref_props', has_repetition=True), + rect_batches = get_rect_batches(libarr), + boundary_batches = get_boundary_batches(libarr), + boundary_props = get_boundary_props(libarr), + paths = get_geom(libarr, 'paths'), + texts = texts, + ) + + paths = libarr['cells'].values.field('paths') + elements['paths'].update(dict( + width = paths.values.field('width').fill_null(0).to_numpy(), + path_type = paths.values.field('path_type').fill_null(0).to_numpy(), + extensions = numpy.stack(( + paths.values.field('extension_start').fill_null(0).to_numpy(), + paths.values.field('extension_end').fill_null(0).to_numpy(), + ), axis=-1), + )) + + global_args = dict( + cell_names = cell_names, + layer_tups = layer_tups, + ) + + mlib = Library() + for cc in range(len(libarr['cells'])): + name = cell_names[int(cell_ids[cc])] + pat = Pattern() + _rect_batches_to_rectcollections(pat, global_args, elements['rect_batches'], cc) + _boundary_batches_to_polygons(pat, global_args, elements['boundary_batches'], cc) + _boundary_props_to_polygons(pat, global_args, elements['boundary_props'], cc) + _gpaths_to_mpaths(pat, global_args, elements['paths'], cc) + _srefs_to_mrefs(pat, global_args, elements['srefs'], cc) + _arefs_to_mrefs(pat, global_args, elements['arefs'], cc) + _sref_props_to_mrefs(pat, global_args, elements['sref_props'], cc) + _aref_props_to_mrefs(pat, global_args, elements['aref_props'], cc) + _texts_to_labels(pat, global_args, elements['texts'], cc) + mlib[name] = pat + + return mlib, library_info + + +def _read_header(libarr: pyarrow.Array) -> dict[str, Any]: + """ + Read the file header and create the library_info dict. + """ + library_info = dict( + name = libarr['lib_name'].as_py(), + meters_per_unit = libarr['meters_per_db_unit'].as_py(), + logical_units_per_unit = libarr['user_units_per_db_unit'].as_py(), + ) + return library_info + + +def _srefs_to_mrefs( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + cell_names = global_args['cell_names'] + elem_off = elem['offsets'] + elem_count = elem_off[cc + 1] - elem_off[cc] + if elem_count == 0: + return + + start = elem_off[cc] + stop = elem_off[cc + 1] + elem_targets = elem['targets'][start:stop] + elem_xy = elem['xy'][start:stop] + elem_invert_y = elem['invert_y'][start:stop] + elem_angle_rad = elem['angle_rad'][start:stop] + elem_scale = elem['scale'][start:stop] + + _append_plain_refs_sorted( + pat=pat, + cell_names=cell_names, + elem_targets=elem_targets, + elem_xy=elem_xy, + elem_invert_y=elem_invert_y, + elem_angle_rad=elem_angle_rad, + elem_scale=elem_scale, + ) + + +def _append_plain_refs_sorted( + *, + pat: Pattern, + cell_names: list[str], + elem_targets: NDArray[numpy.integer[Any]], + elem_xy: NDArray[numpy.integer[Any]], + elem_invert_y: NDArray[numpy.bool_ | numpy.bool], + elem_angle_rad: NDArray[numpy.floating[Any]], + elem_scale: NDArray[numpy.floating[Any]], + ) -> None: + elem_count = len(elem_targets) + if elem_count == 0: + return + + target_start = 0 + while target_start < elem_count: + target_id = int(elem_targets[target_start]) + target_stop = target_start + 1 + while target_stop < elem_count and elem_targets[target_stop] == target_id: + target_stop += 1 + + append_refs = pat.refs[cell_names[target_id]].extend + append_refs( + Ref._from_raw( + offset=elem_xy[ee], + mirrored=elem_invert_y[ee], + rotation=elem_angle_rad[ee], + scale=elem_scale[ee], + repetition=None, + annotations=None, + ) + for ee in range(target_start, target_stop) + ) + + target_start = target_stop + + +def _arefs_to_mrefs( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + cell_names = global_args['cell_names'] + elem_off = elem['offsets'] + elem_count = elem_off[cc + 1] - elem_off[cc] + if elem_count == 0: + return + + start = elem_off[cc] + stop = elem_off[cc + 1] + elem_targets = elem['targets'][start:stop] + elem_xy = elem['xy'][start:stop] + elem_invert_y = elem['invert_y'][start:stop] + elem_angle_rad = elem['angle_rad'][start:stop] + elem_scale = elem['scale'][start:stop] + elem_xy0 = elem['xy0'][start:stop] + elem_xy1 = elem['xy1'][start:stop] + elem_counts = elem['counts'][start:stop] + + if len(elem_targets) == 0: + return + + target = None + append_ref: Callable[[Ref], Any] | None = None + for ee in range(len(elem_targets)): + target_id = int(elem_targets[ee]) + if target != target_id: + target = target_id + append_ref = pat.refs[cell_names[target_id]].append + assert append_ref is not None + a_count, b_count = elem_counts[ee] + append_ref(Ref._from_raw( + offset=elem_xy[ee], + mirrored=elem_invert_y[ee], + rotation=elem_angle_rad[ee], + scale=elem_scale[ee], + repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count), + annotations=None, + )) + + +def _sref_props_to_mrefs( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + cell_names = global_args['cell_names'] + elem_off = elem['offsets'] + prop_key = elem['prop_key'] + prop_val = elem['prop_val'] + + elem_count = elem_off[cc + 1] - elem_off[cc] + if elem_count == 0: + return + + elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) + prop_offs = elem['prop_off'][elem_slc] + elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]] + elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]] + elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]] + elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]] + elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]] + + for ee in range(elem_count): + annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) + ref = Ref._from_raw( + offset=elem_xy[ee], + mirrored=elem_invert_y[ee], + rotation=elem_angle_rad[ee], + scale=elem_scale[ee], + repetition=None, + annotations=annotations, + ) + pat.refs[cell_names[int(elem_targets[ee])]].append(ref) + + +def _aref_props_to_mrefs( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + cell_names = global_args['cell_names'] + elem_off = elem['offsets'] + prop_key = elem['prop_key'] + prop_val = elem['prop_val'] + + elem_count = elem_off[cc + 1] - elem_off[cc] + if elem_count == 0: + return + + elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) + prop_offs = elem['prop_off'][elem_slc] + elem_targets = elem['targets'][elem_off[cc]:elem_off[cc + 1]] + elem_xy = elem['xy'][elem_off[cc]:elem_off[cc + 1]] + elem_invert_y = elem['invert_y'][elem_off[cc]:elem_off[cc + 1]] + elem_angle_rad = elem['angle_rad'][elem_off[cc]:elem_off[cc + 1]] + elem_scale = elem['scale'][elem_off[cc]:elem_off[cc + 1]] + elem_xy0 = elem['xy0'][elem_off[cc]:elem_off[cc + 1]] + elem_xy1 = elem['xy1'][elem_off[cc]:elem_off[cc + 1]] + elem_counts = elem['counts'][elem_off[cc]:elem_off[cc + 1]] + + for ee in range(elem_count): + a_count, b_count = elem_counts[ee] + annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) + ref = Ref._from_raw( + offset=elem_xy[ee], + mirrored=elem_invert_y[ee], + rotation=elem_angle_rad[ee], + scale=elem_scale[ee], + repetition=Grid._from_raw(a_vector=elem_xy0[ee], b_vector=elem_xy1[ee], a_count=a_count, b_count=b_count), + annotations=annotations, + ) + pat.refs[cell_names[int(elem_targets[ee])]].append(ref) + + +def _texts_to_labels( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + elem_off = elem['offsets'] # which elements belong to each cell + xy = elem['xy'] + layer_tups = global_args['layer_tups'] + layer_inds = elem['layer_inds'] + prop_key = elem['prop_key'] + prop_val = elem['prop_val'] + + elem_count = elem_off[cc + 1] - elem_off[cc] + elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem + prop_offs = elem['prop_off'][elem_slc] # which props belong to each element + elem_xy = xy[elem_slc][:elem_count] + elem_layer_inds = layer_inds[elem_slc][:elem_count] + elem_strings = elem['string'][elem_slc][:elem_count] + + for ee in range(elem_count): + layer = layer_tups[int(elem_layer_inds[ee])] + offset = elem_xy[ee] + string = elem_strings[ee] + + annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) + mlabel = Label._from_raw(string=string, offset=offset, annotations=annotations) + pat.labels[layer].append(mlabel) + + +def _gpaths_to_mpaths( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + elem_off = elem['offsets'] # which elements belong to each cell + xy_val = elem['xy_arr'] + layer_tups = global_args['layer_tups'] + layer_inds = elem['layer_inds'] + prop_key = elem['prop_key'] + prop_val = elem['prop_val'] + + elem_count = elem_off[cc + 1] - elem_off[cc] + elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) # +1 to capture ending location for last elem + xy_offs = elem['xy_off'][elem_slc] # which xy coords belong to each element + prop_offs = elem['prop_off'][elem_slc] # which props belong to each element + elem_layer_inds = layer_inds[elem_slc][:elem_count] + elem_widths = elem['width'][elem_slc][:elem_count] + elem_path_types = elem['path_type'][elem_slc][:elem_count] + elem_extensions = elem['extensions'][elem_slc][:elem_count] + + for ee in range(elem_count): + layer = layer_tups[int(elem_layer_inds[ee])] + vertices = xy_val[xy_offs[ee]:xy_offs[ee + 1]] + width = elem_widths[ee] + cap_int = int(elem_path_types[ee]) + if cap_int not in path_cap_map: + raise PatternError(f'Unrecognized path type: {cap_int}') + cap = path_cap_map[cap_int] + if cap_int == 4: + cap_extensions = elem_extensions[ee] + else: + cap_extensions = None + + annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) + path = Path._from_raw( + vertices=vertices, + width=width, + cap=cap, + cap_extensions=cap_extensions, + annotations=annotations, + ) + pat.shapes[layer].append(path) + + +def _boundary_batches_to_polygons( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + elem_off = elem['offsets'] # which elements belong to each cell + vert_arr = elem['vert_arr'] + vert_off = elem['vert_off'] + layer_inds = elem['layer_inds'] + layer_tups = global_args['layer_tups'] + poly_off = elem['poly_off'] + poly_offsets = elem['poly_offsets'] + + batch_count = elem_off[cc + 1] - elem_off[cc] + if batch_count == 0: + return + + elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) # +1 to capture ending location for last elem + elem_vert_off = vert_off[elem_slc] + elem_poly_off = poly_off[elem_slc] + elem_layer_inds = layer_inds[elem_slc][:batch_count] + + for bb in range(batch_count): + layer = layer_tups[int(elem_layer_inds[bb])] + vertices = vert_arr[elem_vert_off[bb]:elem_vert_off[bb + 1]] + vertex_offsets = poly_offsets[elem_poly_off[bb]:elem_poly_off[bb + 1]] + + if vertex_offsets.size == 1: + poly = Polygon._from_raw(vertices=vertices, annotations=None) + pat.shapes[layer].append(poly) + else: + polys = PolyCollection._from_raw(vertex_lists=vertices, vertex_offsets=vertex_offsets, annotations=None) + pat.shapes[layer].append(polys) + + +def _rect_batches_to_rectcollections( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + elem_off = elem['offsets'] + rect_arr = elem['rect_arr'] + rect_off = elem['rect_off'] + layer_inds = elem['layer_inds'] + layer_tups = global_args['layer_tups'] + + batch_count = elem_off[cc + 1] - elem_off[cc] + if batch_count == 0: + return + + elem_slc = slice(elem_off[cc], elem_off[cc] + batch_count + 1) + elem_rect_off = rect_off[elem_slc] + elem_layer_inds = layer_inds[elem_slc][:batch_count] + + for bb in range(batch_count): + layer = layer_tups[int(elem_layer_inds[bb])] + rects = rect_arr[elem_rect_off[bb]:elem_rect_off[bb + 1]] + rect_collection = RectCollection._from_raw(rects=rects, annotations=None) + pat.shapes[layer].append(rect_collection) + + +def _boundary_props_to_polygons( + pat: Pattern, + global_args: dict[str, Any], + elem: dict[str, Any], + cc: int, + ) -> None: + elem_off = elem['offsets'] + vert_arr = elem['vert_arr'] + vert_off = elem['vert_off'] + layer_inds = elem['layer_inds'] + layer_tups = global_args['layer_tups'] + prop_key = elem['prop_key'] + prop_val = elem['prop_val'] + + elem_count = elem_off[cc + 1] - elem_off[cc] + if elem_count == 0: + return + + elem_slc = slice(elem_off[cc], elem_off[cc] + elem_count + 1) + elem_vert_off = vert_off[elem_slc] + prop_offs = elem['prop_off'][elem_slc] + elem_layer_inds = layer_inds[elem_slc][:elem_count] + + for ee in range(elem_count): + layer = layer_tups[int(elem_layer_inds[ee])] + vertices = vert_arr[elem_vert_off[ee]:elem_vert_off[ee + 1]] + annotations = _read_annotations(prop_offs, prop_key, prop_val, ee) + poly = Polygon._from_raw(vertices=vertices, annotations=annotations) + pat.shapes[layer].append(poly) + + +#def _properties_to_annotations(properties: pyarrow.Array) -> annotations_t: +# return {prop['key'].as_py(): prop['value'].as_py() for prop in properties} + + +def check_valid_names( + names: Iterable[str], + max_length: int = 32, + ) -> None: + """ + Check all provided names to see if they're valid GDSII cell names. + + Args: + names: Collection of names to check + max_length: Max allowed length + + """ + allowed_chars = set(string.ascii_letters + string.digits + '_?$') + + bad_chars = [ + name for name in names + if not set(name).issubset(allowed_chars) + ] + + bad_lengths = [ + name for name in names + if len(name) > max_length + ] + + if bad_chars: + logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) + + if bad_lengths: + logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) + + if bad_chars or bad_lengths: + raise LibraryError('Library contains invalid names, see log above') diff --git a/masque/file/gdsii.py b/masque/file/gdsii/klamath.py similarity index 77% rename from masque/file/gdsii.py rename to masque/file/gdsii/klamath.py index 6972cfa..13d9e81 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii/klamath.py @@ -22,8 +22,6 @@ Notes: from typing import IO, cast, Any from collections.abc import Iterable, Mapping, Callable from types import MappingProxyType -import io -import mmap import logging import pathlib import gzip @@ -35,12 +33,12 @@ from numpy.typing import ArrayLike, NDArray import klamath from klamath import records -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape -from ..shapes import Polygon, Path -from ..repetition import Grid -from ..utils import layer_t, annotations_t -from ..library import LazyLibrary, Library, ILibrary, ILibraryView +from ..utils import is_gzipped, tmpfile +from ... import Pattern, Ref, PatternError, LibraryError, Label, Shape +from ...shapes import Polygon, Path, RectCollection +from ...repetition import Grid +from ...utils import layer_t, annotations_t +from ...library import Library, ILibrary logger = logging.getLogger(__name__) @@ -82,7 +80,7 @@ def write( datatype is chosen to be `shape.layer[1]` if available, otherwise `0` - GDS does not support shape repetition (only cell repeptition). Please call + GDS does not support shape repetition (only cell repetition). Please call `library.wrap_repeated_shapes()` before writing to file. Other functions you may want to call: @@ -323,26 +321,40 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_ else: raise PatternError(f'Unrecognized path type: {gpath.path_type}') - mpath = Path( - vertices=gpath.xy.astype(float), - width=gpath.width, - cap=cap, - offset=numpy.zeros(2), - annotations=_properties_to_annotations(gpath.properties), - raw=raw_mode, - ) + vertices = gpath.xy.astype(float) + annotations = _properties_to_annotations(gpath.properties) + cap_extensions = None if cap == Path.Cap.SquareCustom: - mpath.cap_extensions = gpath.extension + cap_extensions = numpy.asarray(gpath.extension, dtype=float) + + if raw_mode: + mpath = Path._from_raw( + vertices=vertices, + width=gpath.width, + cap=cap, + cap_extensions=cap_extensions, + annotations=annotations, + ) + else: + mpath = Path( + vertices=vertices, + width=gpath.width, + cap=cap, + cap_extensions=cap_extensions, + offset=numpy.zeros(2), + annotations=annotations, + ) return gpath.layer, mpath def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]: - return boundary.layer, Polygon( - vertices=boundary.xy[:-1].astype(float), - offset=numpy.zeros(2), - annotations=_properties_to_annotations(boundary.properties), - raw=raw_mode, - ) + vertices = boundary.xy[:-1].astype(float) + annotations = _properties_to_annotations(boundary.properties) + if raw_mode: + poly = Polygon._from_raw(vertices=vertices, annotations=annotations) + else: + poly = Polygon(vertices=vertices, offset=numpy.zeros(2), annotations=annotations) + return boundary.layer, poly def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]: @@ -453,7 +465,7 @@ def _shapes_to_elements( extension: tuple[int, int] if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(shape.cap_extensions) # type: ignore + extension = tuple(rint_cast(shape.cap_extensions)) else: extension = (0, 0) @@ -466,6 +478,20 @@ def _shapes_to_elements( properties=properties, ) elements.append(path) + elif isinstance(shape, RectCollection): + for rect in shape.rects: + xy_closed = numpy.empty((5, 2), dtype=numpy.int32) + xy_closed[0] = rint_cast((rect[0], rect[1])) + xy_closed[1] = rint_cast((rect[0], rect[3])) + xy_closed[2] = rint_cast((rect[2], rect[3])) + xy_closed[3] = rint_cast((rect[2], rect[1])) + xy_closed[4] = xy_closed[0] + boundary = klamath.elements.Boundary( + layer=(layer, data_type), + xy=xy_closed, + properties=properties, + ) + elements.append(boundary) elif isinstance(shape, Polygon): polygon = shape xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) @@ -514,112 +540,6 @@ def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.element return texts -def load_library( - stream: IO[bytes], - *, - full_load: bool = False, - postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None - ) -> tuple[LazyLibrary, dict[str, Any]]: - """ - Scan a GDSII stream to determine what structures are present, and create - a library from them. This enables deferred reading of structures - on an as-needed basis. - All structures are loaded as secondary - - Args: - stream: Seekable stream. Position 0 should be the start of the file. - The caller should leave the stream open while the library - is still in use, since the library will need to access it - in order to read the structure contents. - full_load: If True, force all structures to be read immediately rather - than as-needed. Since data is read sequentially from the file, this - will be faster than using the resulting library's `precache` method. - postprocess: If given, this function is used to post-process each - pattern *upon first load only*. - - Returns: - LazyLibrary object, allowing for deferred load of structures. - Additional library info (dict, same format as from `read`). - """ - stream.seek(0) - lib = LazyLibrary() - - if full_load: - # Full load approach (immediately load everything) - patterns, library_info = read(stream) - for name, pattern in patterns.items(): - if postprocess is not None: - lib[name] = postprocess(lib, name, pattern) - else: - lib[name] = pattern - return lib, library_info - - # Normal approach (scan and defer load) - library_info = _read_header(stream) - structs = klamath.library.scan_structs(stream) - - for name_bytes, pos in structs.items(): - name = name_bytes.decode('ASCII') - - def mkstruct(pos: int = pos, name: str = name) -> Pattern: - stream.seek(pos) - pat = read_elements(stream, raw_mode=True) - if postprocess is not None: - pat = postprocess(lib, name, pat) - return pat - - lib[name] = mkstruct - - return lib, library_info - - -def load_libraryfile( - filename: str | pathlib.Path, - *, - use_mmap: bool = True, - full_load: bool = False, - postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None - ) -> tuple[LazyLibrary, dict[str, Any]]: - """ - Wrapper for `load_library()` that takes a filename or path instead of a stream. - - Will automatically decompress the file if it is gzipped. - - NOTE that any streams/mmaps opened will remain open until ALL of the - `PatternGenerator` objects in the library are garbage collected. - - Args: - path: filename or path to read from - use_mmap: If `True`, will attempt to memory-map the file instead - of buffering. In the case of gzipped files, the file - is decompressed into a python `bytes` object in memory - and reopened as an `io.BytesIO` stream. - full_load: If `True`, immediately loads all data. See `load_library`. - postprocess: Passed to `load_library` - - Returns: - LazyLibrary object, allowing for deferred load of structures. - Additional library info (dict, same format as from `read`). - """ - path = pathlib.Path(filename) - stream: IO[bytes] - if is_gzipped(path): - if use_mmap: - logger.info('Asked to mmap a gzipped file, reading into memory instead...') - gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 - stream = io.BytesIO(gz_stream.read()) # type: ignore - else: - gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 - stream = io.BufferedReader(gz_stream) # type: ignore - else: # noqa: PLR5501 - if use_mmap: - base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115 - stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore - else: - stream = path.open(mode='rb') # noqa: SIM115 - return load_library(stream, full_load=full_load, postprocess=postprocess) - - def check_valid_names( names: Iterable[str], max_length: int = 32, @@ -632,6 +552,7 @@ def check_valid_names( max_length: Max allowed length """ + names = tuple(names) allowed_chars = set(string.ascii_letters + string.digits + '_?$') bad_chars = [ @@ -648,7 +569,7 @@ def check_valid_names( logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) if bad_lengths: - logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) + logger.error(f'Names too long (>{max_length}):\n' + pformat(bad_lengths)) if bad_chars or bad_lengths: raise LibraryError('Library contains invalid names, see log above') diff --git a/masque/file/gdsii/lazy.py b/masque/file/gdsii/lazy.py new file mode 100644 index 0000000..1439306 --- /dev/null +++ b/masque/file/gdsii/lazy.py @@ -0,0 +1,414 @@ +""" +Classic source-backed lazy GDSII reader built on the pure-python klamath path. + +This module provides the non-Arrow half of Masque's lazy GDS architecture: + +- `GdsLibrarySource` scans a GDS stream once to discover library metadata, + struct order, and child edges without materializing every cell. +- cells are materialized on demand through the classic `gdsii` decoder + whenever a caller indexes the lazy view +- the source can be wrapped in `PortsLibraryView` or merged through + `OverlayLibrary` + +The public surface intentionally parallels `gdsii.lazy_arrow` closely so that +callers can swap between the classic and Arrow-backed implementations with +minimal changes. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import IO, TYPE_CHECKING, Any, cast +from collections import defaultdict +import gzip +import io +import logging +import mmap +import pathlib + +import klamath +import numpy +from klamath import records + +from . import klamath as gdsii +from .lazy_write import write as write, writefile as writefile +from ..utils import is_gzipped +from ...error import LibraryError +from ...library import ( + ILibraryView, + LibraryView, + PortsLibraryView, + dangling_mode_t, +) +from ...utils import apply_transforms + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + + from numpy.typing import NDArray + + from ...pattern import Pattern + from ...ports import Port + + +logger = logging.getLogger(__name__) + + +@dataclass +class _SourceHandle: + """ Owns the underlying stream and any companion file handle for a source. """ + path: pathlib.Path | None + stream: IO[bytes] + handle: IO[bytes] | None = None + + def close(self) -> None: + self.stream.close() + if self.handle is not None and self.handle is not self.stream: + self.handle.close() + self.handle = None + + +@dataclass(frozen=True) +class _CellScan: + """ Scan-time metadata for one cell in the source stream. """ + offset: int + children: set[str] + + +def _open_source_stream( + filename: str | pathlib.Path, + *, + use_mmap: bool, + ) -> _SourceHandle: + path = pathlib.Path(filename).expanduser().resolve() + if is_gzipped(path): + if use_mmap: + logger.info('Asked to mmap a gzipped file, reading into memory instead...') + with gzip.open(path, mode='rb') as stream: + data = stream.read() + return _SourceHandle(path=path, stream=io.BytesIO(data)) + stream = cast('IO[bytes]', gzip.open(path, mode='rb')) # noqa: SIM115 + return _SourceHandle(path=path, stream=stream) + + if use_mmap: + handle = path.open(mode='rb', buffering=0) + mapped = cast('IO[bytes]', mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)) + return _SourceHandle(path=path, stream=mapped, handle=handle) + + stream = path.open(mode='rb') + return _SourceHandle(path=path, stream=stream) + + +def _scan_library( + stream: IO[bytes], + ) -> tuple[dict[str, Any], list[str], dict[str, _CellScan]]: + library_info = gdsii._read_header(stream) + order: list[str] = [] + cells: dict[str, _CellScan] = {} + + found_struct = records.BGNSTR.skip_past(stream) + while found_struct: + name = records.STRNAME.skip_and_read(stream).decode('ASCII') + offset = stream.tell() + elements = klamath.library.read_elements(stream) + children = { + element.struct_name.decode('ASCII') + for element in elements + if isinstance(element, klamath.elements.Reference) + } + order.append(name) + cells[name] = _CellScan(offset=offset, children=children) + found_struct = records.BGNSTR.skip_past(stream) + + return library_info, order, cells + + +class GdsLibrarySource(ILibraryView): + """ + Read-only library backed by a seekable GDS stream. + + Cells are scanned once up front to discover order and child edges, then + materialized one at a time through the classic `gdsii.read_elements` path. + + The source owns the stream lifetime, preserves on-disk ordering through + `source_order()`, and answers graph queries from scan metadata whenever + possible so callers can inspect hierarchy without forcing a full load. + """ + + def __init__( + self, + *, + source: _SourceHandle, + library_info: dict[str, Any], + cell_order: Sequence[str], + cells: dict[str, _CellScan], + ) -> None: + self.path = source.path + self.library_info = library_info + self._source = source + self._cell_order = tuple(cell_order) + self._cells = cells + self._cache: dict[str, Pattern] = {} + self._lookups_in_progress: list[str] = [] + + @classmethod + def from_file( + cls, + filename: str | pathlib.Path, + *, + use_mmap: bool = True, + ) -> GdsLibrarySource: + source = _open_source_stream(filename, use_mmap=use_mmap) + source.stream.seek(0) + library_info, cell_order, cells = _scan_library(source.stream) + return cls(source=source, library_info=library_info, cell_order=cell_order, cells=cells) + + def __getitem__(self, key: str) -> Pattern: + return self._materialize_pattern(key, persist=True) + + def __iter__(self) -> Iterator[str]: + return iter(self._cell_order) + + def __len__(self) -> int: + return len(self._cell_order) + + def __contains__(self, key: object) -> bool: + return key in self._cells + + def source_order(self) -> tuple[str, ...]: + return self._cell_order + + def materialize_many( + self, + names: Sequence[str], + *, + persist: bool = True, + ) -> LibraryView: + mats = { + name: self._materialize_pattern(name, persist=persist) + for name in dict.fromkeys(names) + } + return LibraryView(mats) + + def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern: + if name in self._cache: + return self._cache[name] + + if name not in self._cells: + raise KeyError(name) + + if name in self._lookups_in_progress: + chain = ' -> '.join(self._lookups_in_progress + [name]) + raise LibraryError( + f'Detected circular reference or recursive lookup of "{name}".\n' + f'Lookup chain: {chain}\n' + 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' + 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' + ) + + self._lookups_in_progress.append(name) + try: + self._source.stream.seek(self._cells[name].offset) + pat = gdsii.read_elements(self._source.stream, raw_mode=True) + finally: + self._lookups_in_progress.pop() + + if persist: + self._cache[name] = pat + return pat + + def _raw_children(self, name: str) -> set[str]: + return set(self._cells[name].children) + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {} + for name in self._cell_order: + if name in self._cache: + graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs} + else: + graph[name] = self._raw_children(name) + + existing = set(graph) + dangling_refs = set().union(*(children - existing for children in graph.values())) + if dangling == 'error': + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph') + return graph + if dangling == 'ignore': + return {name: {child for child in children if child in existing} for name, children in graph.items()} + + for child in dangling_refs: + graph.setdefault(cast('str', child), set()) + return graph + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore') + existing = set(self.keys()) + igraph: dict[str, set[str]] = {name: set() for name in child_graph} + for parent, children in child_graph.items(): + for child in children: + if child in existing or dangling == 'include': + igraph.setdefault(child, set()).add(parent) + if dangling == 'error': + raw = self.child_graph(dangling='include') + dangling_refs = set().union(*(children - existing for children in raw.values())) + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph') + return igraph + + def subtree( + self, + tops: str | Sequence[str], + ) -> ILibraryView: + if isinstance(tops, str): + tops = (tops,) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) + keep |= set(tops) + return self.materialize_many(tuple(keep), persist=True) + + def tops(self) -> list[str]: + graph = self.child_graph(dangling='ignore') + names = set(graph) + not_toplevel: set[str] = set() + for children in graph.values(): + not_toplevel |= children + return list(names - not_toplevel) + + def with_ports_from_data( + self, + *, + layers: Sequence[tuple[int, int] | int], + max_depth: int = 0, + skip_subcells: bool = True, + ports: Mapping[str, Mapping[str, Port]] | None = None, + replace: bool = False, + ) -> PortsLibraryView: + return PortsLibraryView( + self, + layers=layers, + max_depth=max_depth, + skip_subcells=skip_subcells, + ports=ports, + replace=replace, + ) + + def with_port_overrides( + self, + ports: Mapping[str, Mapping[str, Port]], + *, + replace: bool = False, + ) -> PortsLibraryView: + return PortsLibraryView( + self, + ports=ports, + replace=replace, + ) + + def find_refs_local( + self, + name: str, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[str, list[NDArray[numpy.float64]]]: + instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list) + if parent_graph is None: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return instances + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') + if dangling == 'ignore': + return instances + + for parent in parent_graph.get(name, set()): + if parent in self._cache: + for ref in self._cache[parent].refs.get(name, []): + instances[parent].append(ref.as_transforms()) + continue + pat = self._materialize_pattern(parent, persist=False) + for ref in pat.refs.get(name, []): + instances[parent].append(ref.as_transforms()) + return instances + + def find_refs_global( + self, + name: str, + order: list[str] | None = None, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + if order is None: + order = self.child_order(dangling=graph_mode) + if parent_graph is None: + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return {} + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') + if dangling == 'ignore': + return {} + + self_keys = set(self.keys()) + transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]] + transforms = defaultdict(list) + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): + transforms[parent] = [((name,), numpy.concatenate(vals))] + + for next_name in order: + if next_name not in transforms: + continue + if not parent_graph.get(next_name, set()) & self_keys: + continue + + outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + inners = transforms.pop(next_name) + for parent, outer in outers.items(): + outer_tf = numpy.concatenate(outer) + for path, inner in inners: + combined = apply_transforms(outer_tf, inner) + transforms[parent].append(((next_name,) + path, combined)) + + result = {} + for parent, targets in transforms.items(): + for path, instances in targets: + result[(parent,) + path] = instances + return result + + def close(self) -> None: + self._source.close() + + def __enter__(self) -> GdsLibrarySource: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +def read( + stream: IO[bytes], + ) -> tuple[GdsLibrarySource, dict[str, Any]]: + source = _SourceHandle(path=None, stream=stream) + stream.seek(0) + library_info, cell_order, cells = _scan_library(stream) + lib = GdsLibrarySource(source=source, library_info=library_info, cell_order=cell_order, cells=cells) + return lib, library_info + + +def readfile( + filename: str | pathlib.Path, + *, + use_mmap: bool = True, + ) -> tuple[GdsLibrarySource, dict[str, Any]]: + lib = GdsLibrarySource.from_file(filename, use_mmap=use_mmap) + return lib, lib.library_info diff --git a/masque/file/gdsii/lazy_arrow.py b/masque/file/gdsii/lazy_arrow.py new file mode 100644 index 0000000..acfd454 --- /dev/null +++ b/masque/file/gdsii/lazy_arrow.py @@ -0,0 +1,541 @@ +""" +Lazy GDSII readers and writers backed by native Arrow scan/materialize paths. + +This module is intentionally separate from `gdsii.arrow` so the eager read path +keeps its current behavior and performance profile. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import IO, TYPE_CHECKING, Any, cast +from collections import defaultdict +import gzip +import logging +import mmap +import pathlib + +import numpy + +from . import arrow +from .lazy_write import write as write, writefile as writefile +from ..utils import is_gzipped +from ...library import ( + ILibraryView, + LibraryView, + PortsLibraryView, + dangling_mode_t, +) +from ...utils import apply_transforms + +if TYPE_CHECKING: + from collections.abc import Iterator, Mapping, Sequence + + from numpy.typing import NDArray + import pyarrow + + from ...pattern import Pattern + from ...ports import Port + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _StructRange: + start: int + end: int + + +@dataclass +class _SourceBuffer: + path: pathlib.Path + data: bytes | mmap.mmap + handle: IO[bytes] | None = None + + def raw_slice(self, start: int, end: int) -> bytes: + return self.data[start:end] + + +@dataclass +class _ScanRefs: + offsets: NDArray[numpy.integer[Any]] + targets: NDArray[numpy.integer[Any]] + xy: NDArray[numpy.int32] + xy0: NDArray[numpy.int32] + xy1: NDArray[numpy.int32] + counts: NDArray[numpy.int64] + invert_y: NDArray[numpy.bool_ | numpy.bool] + angle_rad: NDArray[numpy.floating[Any]] + scale: NDArray[numpy.floating[Any]] + + +@dataclass(frozen=True) +class _CellScan: + cell_id: int + struct_range: _StructRange + ref_start: int + ref_stop: int + children: set[str] + + +@dataclass +class _ScanPayload: + libarr: pyarrow.StructScalar + library_info: dict[str, Any] + cell_names: list[str] + cell_order: list[str] + cells: dict[str, _CellScan] + refs: _ScanRefs + +def is_available() -> bool: + return arrow.is_available() + + +def _open_source_buffer(path: pathlib.Path) -> _SourceBuffer: + if is_gzipped(path): + with gzip.open(path, mode='rb') as stream: + data = stream.read() + return _SourceBuffer(path=path, data=data) + + handle = path.open(mode='rb', buffering=0) + mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ) + return _SourceBuffer(path=path, data=mapped, handle=handle) + + +def _extract_scan_payload(libarr: pyarrow.StructScalar) -> _ScanPayload: + library_info = arrow._read_header(libarr) + cell_names = libarr['cell_names'].as_py() + + cells = libarr['cells'] + cell_values = cells.values + cell_ids = cell_values.field('id').to_numpy() + struct_starts = cell_values.field('struct_start_offset').to_numpy() + struct_ends = cell_values.field('struct_end_offset').to_numpy() + + refs = cell_values.field('refs') + ref_values = refs.values + ref_offsets = refs.offsets.to_numpy() + targets = ref_values.field('target').to_numpy() + xy = arrow._packed_xy_u64_to_pairs(ref_values.field('xy').to_numpy()) + xy0 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy0').to_numpy()) + xy1 = arrow._packed_xy_u64_to_pairs(ref_values.field('xy1').to_numpy()) + counts = arrow._packed_counts_u32_to_pairs(ref_values.field('counts').to_numpy()) + invert_y = ref_values.field('invert_y').to_numpy(zero_copy_only=False) + angle_rad = ref_values.field('angle_rad').to_numpy() + scale = ref_values.field('scale').to_numpy() + + ref_payload = _ScanRefs( + offsets=ref_offsets, + targets=targets, + xy=xy, + xy0=xy0, + xy1=xy1, + counts=counts, + invert_y=invert_y, + angle_rad=angle_rad, + scale=scale, + ) + + cell_order = [cell_names[int(cell_id)] for cell_id in cell_ids] + cell_scan: dict[str, _CellScan] = {} + for cc, name in enumerate(cell_order): + ref_start = int(ref_offsets[cc]) + ref_stop = int(ref_offsets[cc + 1]) + children = { + cell_names[int(target)] + for target in targets[ref_start:ref_stop] + } + cell_scan[name] = _CellScan( + cell_id=int(cell_ids[cc]), + struct_range=_StructRange(int(struct_starts[cc]), int(struct_ends[cc])), + ref_start=ref_start, + ref_stop=ref_stop, + children=children, + ) + + return _ScanPayload( + libarr=libarr, + library_info=library_info, + cell_names=cell_names, + cell_order=cell_order, + cells=cell_scan, + refs=ref_payload, + ) + +def _make_ref_rows( + xy: NDArray[numpy.integer[Any]], + angle_rad: NDArray[numpy.floating[Any]], + invert_y: NDArray[numpy.bool_ | numpy.bool], + scale: NDArray[numpy.floating[Any]], + ) -> NDArray[numpy.float64]: + rows = numpy.empty((len(xy), 5), dtype=float) + rows[:, :2] = xy + rows[:, 2] = angle_rad + rows[:, 3] = invert_y.astype(float) + rows[:, 4] = scale + return rows + + +def _expand_aref_row( + xy: NDArray[numpy.integer[Any]], + xy0: NDArray[numpy.integer[Any]], + xy1: NDArray[numpy.integer[Any]], + counts: NDArray[numpy.integer[Any]], + angle_rad: float, + invert_y: bool, + scale: float, + ) -> NDArray[numpy.float64]: + a_count = int(counts[0]) + b_count = int(counts[1]) + aa, bb = numpy.meshgrid(numpy.arange(a_count), numpy.arange(b_count), indexing='ij') + displacements = aa.reshape(-1, 1) * xy0[None, :] + bb.reshape(-1, 1) * xy1[None, :] + rows = numpy.empty((displacements.shape[0], 5), dtype=float) + rows[:, :2] = xy + displacements + rows[:, 2] = angle_rad + rows[:, 3] = float(invert_y) + rows[:, 4] = scale + return rows + + +class ArrowLibrary(ILibraryView): + """ + Read-only library backed by the native lazy Arrow scan schema. + + Materializing a cell via `__getitem__` caches a real `Pattern` for that cell. + Cached cells are treated as edited for future writes from this module. + """ + + path: pathlib.Path + library_info: dict[str, Any] + + def __init__( + self, + *, + path: pathlib.Path, + payload: _ScanPayload, + source: _SourceBuffer, + ) -> None: + self.path = path + self.library_info = payload.library_info + self._payload = payload + self._source = source + self._cache: dict[str, Pattern] = {} + + @classmethod + def from_file(cls, filename: str | pathlib.Path) -> ArrowLibrary: + path = pathlib.Path(filename).expanduser().resolve() + source = _open_source_buffer(path) + scan_arr = arrow._scan_buffer_to_arrow(source.data) + assert len(scan_arr) == 1 + payload = _extract_scan_payload(scan_arr[0]) + return cls(path=path, payload=payload, source=source) + + def __getitem__(self, key: str) -> Pattern: + return self._materialize_pattern(key, persist=True) + + def __iter__(self) -> Iterator[str]: + return iter(self._payload.cell_order) + + def __len__(self) -> int: + return len(self._payload.cell_order) + + def __contains__(self, key: object) -> bool: + return key in self._payload.cells + + def source_order(self) -> tuple[str, ...]: + return tuple(self._payload.cell_order) + + def raw_struct_bytes(self, name: str) -> bytes: + struct_range = self._payload.cells[name].struct_range + return self._source.raw_slice(struct_range.start, struct_range.end) + + def can_copy_raw_struct(self, name: str) -> bool: + return name not in self._cache + + def materialize_many( + self, + names: Sequence[str], + *, + persist: bool = True, + ) -> LibraryView: + mats = self._materialize_patterns(names, persist=persist) + return LibraryView(mats) + + def _materialize_patterns( + self, + names: Sequence[str], + *, + persist: bool, + ) -> dict[str, Pattern]: + ordered_names = list(dict.fromkeys(names)) + missing = [name for name in ordered_names if name not in self._payload.cells] + if missing: + raise KeyError(missing[0]) + + materialized: dict[str, Pattern] = {} + uncached = [name for name in ordered_names if name not in self._cache] + if uncached: + ranges = numpy.asarray( + [ + [ + self._payload.cells[name].struct_range.start, + self._payload.cells[name].struct_range.end, + ] + for name in uncached + ], + dtype=numpy.uint64, + ) + arrow_arr = arrow._read_selected_cells_to_arrow(self._source.data, ranges) + assert len(arrow_arr) == 1 + selected_lib, _info = arrow.read_arrow(arrow_arr[0]) + for name in uncached: + pat = selected_lib[name] + materialized[name] = pat + if persist: + self._cache[name] = pat + + for name in ordered_names: + if name in self._cache: + materialized[name] = self._cache[name] + return materialized + + def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern: + return self._materialize_patterns((name,), persist=persist)[name] + + def _raw_children(self, name: str) -> set[str]: + return set(self._payload.cells[name].children) + + def _collect_raw_transforms(self, cell: _CellScan, target_id: int) -> list[NDArray[numpy.float64]]: + refs = self._payload.refs + start = cell.ref_start + stop = cell.ref_stop + if stop <= start: + return [] + + targets = refs.targets[start:stop] + mask = targets == target_id + if not mask.any(): + return [] + + rows: list[NDArray[numpy.float64]] = [] + counts = refs.counts[start:stop] + unit_mask = mask & (counts[:, 0] == 1) & (counts[:, 1] == 1) + if unit_mask.any(): + rows.append(_make_ref_rows( + refs.xy[start:stop][unit_mask], + refs.angle_rad[start:stop][unit_mask], + refs.invert_y[start:stop][unit_mask], + refs.scale[start:stop][unit_mask], + )) + + aref_indices = numpy.nonzero(mask & ~unit_mask)[0] + for idx in aref_indices: + abs_idx = start + int(idx) + rows.append(_expand_aref_row( + xy=refs.xy[abs_idx], + xy0=refs.xy0[abs_idx], + xy1=refs.xy1[abs_idx], + counts=refs.counts[abs_idx], + angle_rad=float(refs.angle_rad[abs_idx]), + invert_y=bool(refs.invert_y[abs_idx]), + scale=float(refs.scale[abs_idx]), + )) + return rows + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {} + for name in self._payload.cell_order: + if name in self._cache: + graph[name] = {child for child, refs in self._cache[name].refs.items() if child is not None and refs} + else: + graph[name] = self._raw_children(name) + + existing = set(graph) + dangling_refs = set().union(*(children - existing for children in graph.values())) + if dangling == 'error': + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph') + return graph + if dangling == 'ignore': + return {name: {child for child in children if child in existing} for name, children in graph.items()} + + for child in dangling_refs: + graph.setdefault(cast('str', child), set()) + return graph + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore') + existing = set(self.keys()) + igraph: dict[str, set[str]] = {name: set() for name in child_graph} + for parent, children in child_graph.items(): + for child in children: + if child in existing or dangling == 'include': + igraph.setdefault(child, set()).add(parent) + if dangling == 'error': + raw = self.child_graph(dangling='include') + dangling_refs = set().union(*(children - existing for children in raw.values())) + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph') + return igraph + + def subtree( + self, + tops: str | Sequence[str], + ) -> ILibraryView: + if isinstance(tops, str): + tops = (tops,) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) + keep |= set(tops) + return self.materialize_many(tuple(keep), persist=True) + + def tops(self) -> list[str]: + graph = self.child_graph(dangling='ignore') + names = set(graph) + not_toplevel: set[str] = set() + for children in graph.values(): + not_toplevel |= children + return list(names - not_toplevel) + + def with_ports_from_data( + self, + *, + layers: Sequence[tuple[int, int] | int], + max_depth: int = 0, + skip_subcells: bool = True, + ports: Mapping[str, Mapping[str, Port]] | None = None, + replace: bool = False, + ) -> PortsLibraryView: + return PortsLibraryView( + self, + layers=layers, + max_depth=max_depth, + skip_subcells=skip_subcells, + ports=ports, + replace=replace, + ) + + def with_port_overrides( + self, + ports: Mapping[str, Mapping[str, Port]], + *, + replace: bool = False, + ) -> PortsLibraryView: + return PortsLibraryView( + self, + ports=ports, + replace=replace, + ) + + def close(self) -> None: + data = self._source.data + if isinstance(data, mmap.mmap): + data.close() + if self._source.handle is not None: + self._source.handle.close() + self._source.handle = None + + def __enter__(self) -> ArrowLibrary: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + def find_refs_local( + self, + name: str, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[str, list[NDArray[numpy.float64]]]: + instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list) + if parent_graph is None: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return instances + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') + if dangling == 'ignore': + return instances + + target_id = self._payload.cells.get(name) + for parent in parent_graph.get(name, set()): + if parent in self._cache: + for ref in self._cache[parent].refs.get(name, []): + instances[parent].append(ref.as_transforms()) + continue + + if target_id is None or parent not in self._payload.cells: + continue + rows = self._collect_raw_transforms(self._payload.cells[parent], target_id.cell_id) + if rows: + instances[parent].extend(rows) + return instances + + def find_refs_global( + self, + name: str, + order: list[str] | None = None, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + if order is None: + order = self.child_order(dangling=graph_mode) + if parent_graph is None: + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return {} + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') + if dangling == 'ignore': + return {} + + self_keys = set(self.keys()) + transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]] + transforms = defaultdict(list) + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): + transforms[parent] = [((name,), numpy.concatenate(vals))] + + for next_name in order: + if next_name not in transforms: + continue + if not parent_graph.get(next_name, set()) & self_keys: + continue + + outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + inners = transforms.pop(next_name) + for parent, outer in outers.items(): + outer_tf = numpy.concatenate(outer) + for path, inner in inners: + combined = apply_transforms(outer_tf, inner) + transforms[parent].append(((next_name,) + path, combined)) + + result = {} + for parent, targets in transforms.items(): + for path, instances in targets: + full_path = (parent,) + path + result[full_path] = instances + return result + + +def readfile( + filename: str | pathlib.Path, + ) -> tuple[ArrowLibrary, dict[str, Any]]: + lib = ArrowLibrary.from_file(filename) + return lib, lib.library_info + + +def load_libraryfile( + filename: str | pathlib.Path, + ) -> tuple[ArrowLibrary, dict[str, Any]]: + return readfile(filename) diff --git a/masque/file/gdsii/lazy_write.py b/masque/file/gdsii/lazy_write.py new file mode 100644 index 0000000..225787a --- /dev/null +++ b/masque/file/gdsii/lazy_write.py @@ -0,0 +1,170 @@ +""" +GDS write helpers for source-backed lazy GDS views. + +The generic mutable overlay and ports-importing view live in `masque.library`. +This module preserves source-backed GDS copy-through behavior where possible, +falling back to normal pattern serialization when a cell has been materialized +or remapped. +""" +from __future__ import annotations + +from typing import IO, TYPE_CHECKING, Any, cast +import gzip +import logging +import pathlib + +import klamath + +from . import klamath as gdsii +from ..utils import tmpfile +from ...error import LibraryError +from ...library import ILibraryView, OverlayLibrary +from ...library.overlay import _SourceEntry, _materialize_detached_pattern + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ...pattern import Pattern + + +logger = logging.getLogger(__name__) + + +def _get_write_info( + library: Mapping[str, Pattern] | ILibraryView, + *, + meters_per_unit: float | None, + logical_units_per_unit: float | None, + library_name: str | None, + ) -> tuple[float, float, str]: + if meters_per_unit is not None and logical_units_per_unit is not None and library_name is not None: + return meters_per_unit, logical_units_per_unit, library_name + + infos: list[dict[str, Any]] = [] + stack: list[Mapping[str, Pattern] | ILibraryView] = [library] + while stack: + current = stack.pop() + info = getattr(current, 'library_info', None) + if isinstance(info, dict): + infos.append(info) + if isinstance(current, OverlayLibrary): + stack.extend(reversed([layer.library for layer in current._layers])) + + if infos: + unit_pairs = {(info['meters_per_unit'], info['logical_units_per_unit']) for info in infos} + if len(unit_pairs) > 1: + raise LibraryError('Merged lazy GDS sources must have identical units before writing') + info = infos[0] + meters = info['meters_per_unit'] if meters_per_unit is None else meters_per_unit + logical = info['logical_units_per_unit'] if logical_units_per_unit is None else logical_units_per_unit + name = info['name'] if library_name is None else library_name + return meters, logical, name + + if meters_per_unit is None or logical_units_per_unit is None or library_name is None: + raise LibraryError('meters_per_unit, logical_units_per_unit, and library_name are required for non-GDS-backed lazy writes') + return meters_per_unit, logical_units_per_unit, library_name + + +def _can_copy_raw_cell(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bool: + can_copy = getattr(library, 'can_copy_raw_struct', None) + if not callable(can_copy): + return False + return bool(can_copy(name)) + + +def _raw_struct_bytes(library: Mapping[str, Pattern] | ILibraryView, name: str) -> bytes: + reader = getattr(library, 'raw_struct_bytes', None) + if not callable(reader): + raise TypeError('raw_struct_bytes') + return cast('bytes', reader(name)) + + +def _write_pattern_struct(stream: IO[bytes], name: str, pat: Pattern) -> None: + elements: list[klamath.elements.Element] = [] + elements += gdsii._shapes_to_elements(pat.shapes) + elements += gdsii._labels_to_texts(pat.labels) + elements += gdsii._mrefs_to_grefs(pat.refs) + klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements) + + +def write( + library: Mapping[str, Pattern] | ILibraryView, + stream: IO[bytes], + *, + meters_per_unit: float | None = None, + logical_units_per_unit: float | None = None, + library_name: str | None = None, + ) -> None: + meters_per_unit, logical_units_per_unit, library_name = _get_write_info( + library, + meters_per_unit=meters_per_unit, + logical_units_per_unit=logical_units_per_unit, + library_name=library_name, + ) + + header = klamath.library.FileHeader( + name=library_name.encode('ASCII'), + user_units_per_db_unit=logical_units_per_unit, + meters_per_db_unit=meters_per_unit, + ) + header.write(stream) + + if isinstance(library, OverlayLibrary): + for name in library.source_order(): + entry = library._entries[name] + can_copy_overlay = False + if isinstance(entry, _SourceEntry) and name == entry.source_name: + layer = library._layers[entry.layer_index] + children = layer.child_graph.get(entry.source_name, set()) + can_copy_overlay = ( + _can_copy_raw_cell(layer.library, entry.source_name) + and all(library._effective_target(layer, child) == child for child in children) + ) + if can_copy_overlay: + stream.write(_raw_struct_bytes(layer.library, entry.source_name)) + else: + _write_pattern_struct(stream, name, library._materialize_pattern(name, persist=False)) + klamath.records.ENDLIB.write(stream, None) + return + + if hasattr(library, 'raw_struct_bytes'): + for name in library.source_order(): + if _can_copy_raw_cell(library, name): + stream.write(_raw_struct_bytes(library, name)) + else: + _write_pattern_struct(stream, name, _materialize_detached_pattern(cast('ILibraryView', library), name)) + klamath.records.ENDLIB.write(stream, None) + return + + gdsii.write(cast('Mapping[str, Pattern]', library), stream, meters_per_unit, logical_units_per_unit, library_name) + + +def writefile( + library: Mapping[str, Pattern] | ILibraryView, + filename: str | pathlib.Path, + *, + meters_per_unit: float | None = None, + logical_units_per_unit: float | None = None, + library_name: str | None = None, + ) -> None: + path = pathlib.Path(filename) + + with tmpfile(path) as base_stream: + streams: tuple[Any, ...] = (base_stream,) + if path.suffix == '.gz': + stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6)) + streams = (stream,) + streams + else: + stream = base_stream + + try: + write( + library, + stream, + meters_per_unit=meters_per_unit, + logical_units_per_unit=logical_units_per_unit, + library_name=library_name, + ) + finally: + for ss in streams: + ss.close() diff --git a/masque/file/gdsii/perf.py b/masque/file/gdsii/perf.py new file mode 100644 index 0000000..64bd7ab --- /dev/null +++ b/masque/file/gdsii/perf.py @@ -0,0 +1,626 @@ +""" +Synthetic GDS fixture generation for reader/writer performance testing. + +The presets here are intentionally hierarchical and deterministic. They aim to +approximate a pair of real-world layout families discussed during GDS reader and +writer work: + +* `many_cells`: tens of thousands of cells, moderate reference count, very heavy + box usage after flattening, and moderate polygon density. +* `many_instances`: a much smaller cell library with very high reference count, + similar box density, and far fewer polygons. + +Fixtures are written by streaming structures through `klamath` directly so large +benchmark files can be produced without first materializing an equally large +`masque.Library` in Python. +""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +import argparse +import json +import math + +import numpy +import klamath +from klamath import elements + + +EMPTY_PROPERTIES: dict[int, bytes] = {} +METERS_PER_DB_UNIT = 1e-9 +USER_UNITS_PER_DB_UNIT = 1e-3 +TOTAL_LAYERS = 200 + + +@dataclass(frozen=True) +class FixturePreset: + name: str + total_layers: int + box_layers: int + heavy_box_layers: int + polygon_layers: int + box_cells: int + poly_cells: int + box_wrappers: int + poly_wrappers: int + box_clusters: int + poly_clusters: int + box_cluster_refs: int + poly_cluster_refs: int + top_direct_box_refs: int + top_direct_poly_refs: int + heavy_boxes_per_cell: int + regular_boxes_per_cell: int + polygons_per_cell: int + path_stride: int + text_stride: int + box_cluster_array: tuple[int, int] + top_box_array: tuple[int, int] + poly_cluster_array: tuple[int, int] + top_poly_array: tuple[int, int] + rare_annotation_stride: int + + +PRESETS: dict[str, FixturePreset] = { + 'many_cells': FixturePreset( + name='many_cells', + total_layers=TOTAL_LAYERS, + box_layers=20, + heavy_box_layers=3, + polygon_layers=20, + box_cells=17_000, + poly_cells=6_000, + box_wrappers=18_000, + poly_wrappers=6_000, + box_clusters=2_000, + poly_clusters=999, + box_cluster_refs=24, + poly_cluster_refs=16, + top_direct_box_refs=21_000, + top_direct_poly_refs=7_000, + heavy_boxes_per_cell=6, + regular_boxes_per_cell=2, + polygons_per_cell=50, + path_stride=2, + text_stride=3, + box_cluster_array=(24, 16), + top_box_array=(8, 8), + poly_cluster_array=(4, 2), + top_poly_array=(3, 2), + rare_annotation_stride=1_250, + ), + 'many_instances': FixturePreset( + name='many_instances', + total_layers=TOTAL_LAYERS, + box_layers=25, + heavy_box_layers=3, + polygon_layers=10, + box_cells=2_500, + poly_cells=500, + box_wrappers=1_000, + poly_wrappers=500, + box_clusters=1_000, + poly_clusters=499, + box_cluster_refs=1_200, + poly_cluster_refs=400, + top_direct_box_refs=102_001, + top_direct_poly_refs=0, + heavy_boxes_per_cell=40, + regular_boxes_per_cell=16, + polygons_per_cell=60, + path_stride=1, + text_stride=2, + box_cluster_array=(1, 1), + top_box_array=(1, 1), + poly_cluster_array=(1, 1), + top_poly_array=(1, 1), + rare_annotation_stride=250, + ), + } + + +@dataclass(frozen=True) +class FixtureManifest: + preset: str + scale: float + gds_path: str + library_name: str + cells: int + refs: int + layers: int + box_layers: int + heavy_box_layers: list[list[int]] + polygon_layers: list[list[int]] + hierarchical_boxes_per_heavy_layer: int + hierarchical_boxes_per_regular_layer: int + hierarchical_polygons_total: int + hierarchical_paths_total: int + hierarchical_texts_total: int + flattened_box_placements: int + flattened_poly_placements: int + estimated_flat_boxes_per_heavy_layer: int + estimated_flat_polygons_per_active_polygon_layer: int + + +def _scaled_count(value: int, scale: float, minimum: int = 0) -> int: + if value == 0: + return 0 + scaled = int(math.ceil(value * scale)) + return max(minimum, scaled) + + +def _scaled_preset(preset: FixturePreset, scale: float) -> FixturePreset: + if scale <= 0: + raise ValueError(f'scale must be positive, got {scale!r}') + + return FixturePreset( + name=preset.name, + total_layers=preset.total_layers, + box_layers=min(preset.box_layers, preset.total_layers), + heavy_box_layers=min(preset.heavy_box_layers, preset.box_layers), + polygon_layers=min(preset.polygon_layers, preset.total_layers), + box_cells=_scaled_count(preset.box_cells, scale, minimum=1), + poly_cells=_scaled_count(preset.poly_cells, scale, minimum=1), + box_wrappers=_scaled_count(preset.box_wrappers, scale), + poly_wrappers=_scaled_count(preset.poly_wrappers, scale), + box_clusters=_scaled_count(preset.box_clusters, scale, minimum=1), + poly_clusters=_scaled_count(preset.poly_clusters, scale, minimum=1), + box_cluster_refs=_scaled_count(preset.box_cluster_refs, scale, minimum=1), + poly_cluster_refs=_scaled_count(preset.poly_cluster_refs, scale, minimum=1), + top_direct_box_refs=_scaled_count(preset.top_direct_box_refs, scale), + top_direct_poly_refs=_scaled_count(preset.top_direct_poly_refs, scale), + heavy_boxes_per_cell=max(1, preset.heavy_boxes_per_cell), + regular_boxes_per_cell=max(1, preset.regular_boxes_per_cell), + polygons_per_cell=max(1, preset.polygons_per_cell), + path_stride=max(1, preset.path_stride), + text_stride=max(1, preset.text_stride), + box_cluster_array=preset.box_cluster_array, + top_box_array=preset.top_box_array, + poly_cluster_array=preset.poly_cluster_array, + top_poly_array=preset.top_poly_array, + rare_annotation_stride=max(1, _scaled_count(preset.rare_annotation_stride, scale, minimum=1)), + ) + + +def _rect_xy(xmin: int, ymin: int, xmax: int, ymax: int) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]: + return numpy.array( + [[xmin, ymin], [xmin, ymax], [xmax, ymax], [xmax, ymin], [xmin, ymin]], + dtype=numpy.int32, + ) + + +def _poly_xy(points: list[tuple[int, int]]) -> numpy.ndarray[Any, numpy.dtype[numpy.int32]]: + closed = points + [points[0]] + return numpy.array(closed, dtype=numpy.int32) + + +def _sref( + target: str, + xy: tuple[int, int], + properties: dict[int, bytes] | None = None, + ) -> elements.Reference: + return klamath.library.Reference( + struct_name=target.encode('ASCII'), + invert_y=False, + mag=1.0, + angle_deg=0.0, + xy=numpy.array([xy], dtype=numpy.int32), + colrow=None, + properties=EMPTY_PROPERTIES if properties is None else properties, + ) + + +def _aref( + target: str, + origin: tuple[int, int], + counts: tuple[int, int], + step: tuple[int, int], + properties: dict[int, bytes] | None = None, + ) -> elements.Reference: + cols, rows = counts + dx, dy = step + xy = numpy.array( + [ + origin, + (origin[0] + cols * dx, origin[1]), + (origin[0], origin[1] + rows * dy), + ], + dtype=numpy.int32, + ) + return klamath.library.Reference( + struct_name=target.encode('ASCII'), + invert_y=False, + mag=1.0, + angle_deg=0.0, + xy=xy, + colrow=(cols, rows), + properties=EMPTY_PROPERTIES if properties is None else properties, + ) + + +def _annotation(index: int) -> dict[int, bytes]: + return {1: f'perf-{index}'.encode('ASCII')} + + +def _make_box_cell(index: int, cfg: FixturePreset) -> list[elements.Element]: + cell_elements: list[elements.Element] = [] + xbase = (index % 17) * 600 + ybase = (index // 17) * 180 + + for layer in range(cfg.heavy_box_layers): + for box_idx in range(cfg.heavy_boxes_per_cell): + x0 = xbase + box_idx * 22 + y0 = ybase + layer * 40 + width = 10 + ((index + box_idx + layer) % 7) * 6 + height = 10 + ((index * 3 + box_idx + layer) % 5) * 8 + properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 and box_idx == 0 and layer == 0 else EMPTY_PROPERTIES + cell_elements.append(elements.Boundary( + layer=(layer, 0), + xy=_rect_xy(x0, y0, x0 + width, y0 + height), + properties=properties, + )) + + for layer in range(cfg.heavy_box_layers, cfg.box_layers): + for box_idx in range(cfg.regular_boxes_per_cell): + x0 = xbase + box_idx * 38 + y0 = ybase + (layer - cfg.heavy_box_layers) * 28 + 400 + width = 18 + ((index + layer + box_idx) % 9) * 4 + height = 12 + ((index + 2 * layer + box_idx) % 6) * 5 + cell_elements.append(elements.Boundary( + layer=(layer, 0), + xy=_rect_xy(x0, y0, x0 + width, y0 + height), + properties=EMPTY_PROPERTIES, + )) + + return cell_elements + + +def _make_poly_cell(index: int, cfg: FixturePreset) -> list[elements.Element]: + cell_elements: list[elements.Element] = [] + xbase = (index % 19) * 900 + ybase = (index // 19) * 260 + + for poly_idx in range(cfg.polygons_per_cell): + layer = poly_idx % cfg.polygon_layers + dx = xbase + (poly_idx % 5) * 120 + dy = ybase + (poly_idx // 5) * 80 + size = 18 + ((index + poly_idx + layer) % 11) * 7 + points = [ + (dx, dy), + (dx + size, dy + size // 5), + (dx + size + size // 3, dy + size), + (dx + size // 2, dy + size + size // 2), + (dx - size // 4, dy + size // 2), + ] + properties = _annotation(index) if poly_idx == 0 and index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES + cell_elements.append(elements.Boundary( + layer=(layer, 0), + xy=_poly_xy(points), + properties=properties, + )) + + if index % cfg.path_stride == 0: + layer = index % cfg.polygon_layers + cell_elements.append(elements.Path( + layer=(layer, 1), + path_type=2, + width=12 + (index % 5) * 4, + extension=(0, 0), + xy=numpy.array( + [ + [xbase, ybase + 900], + [xbase + 240, ybase + 930], + [xbase + 420, ybase + 960], + ], + dtype=numpy.int32, + ), + properties=EMPTY_PROPERTIES, + )) + + if index % cfg.text_stride == 0: + layer = index % cfg.polygon_layers + properties = _annotation(index) if index % cfg.rare_annotation_stride == 0 else EMPTY_PROPERTIES + cell_elements.append(elements.Text( + layer=(layer, 2), + presentation=0, + path_type=0, + width=0, + invert_y=False, + mag=1.0, + angle_deg=0.0, + xy=numpy.array([[xbase + 64, ybase + 1536]], dtype=numpy.int32), + string=f'T{index:05d}'.encode('ASCII'), + properties=properties, + )) + + return cell_elements + + +def _write_struct(stream: Any, name: str, cell_elements: list[elements.Element]) -> None: + klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=cell_elements) + + +def _box_name(index: int) -> str: + return f'box_{index:05d}' + + +def _poly_name(index: int) -> str: + return f'poly_{index:05d}' + + +def _box_wrapper_name(index: int) -> str: + return f'box_wrap_{index:05d}' + + +def _poly_wrapper_name(index: int) -> str: + return f'poly_wrap_{index:05d}' + + +def _box_cluster_name(index: int) -> str: + return f'box_cluster_{index:05d}' + + +def _poly_cluster_name(index: int) -> str: + return f'poly_cluster_{index:05d}' + + +def _write_box_cells(stream: Any, cfg: FixturePreset) -> None: + for idx in range(cfg.box_cells): + _write_struct(stream, _box_name(idx), _make_box_cell(idx, cfg)) + + +def _write_poly_cells(stream: Any, cfg: FixturePreset) -> None: + for idx in range(cfg.poly_cells): + _write_struct(stream, _poly_name(idx), _make_poly_cell(idx, cfg)) + + +def _write_wrappers(stream: Any, cfg: FixturePreset) -> None: + for idx in range(cfg.box_wrappers): + target = _box_name(idx % cfg.box_cells) + origin = ((idx % 97) * 2_000, (idx // 97) * 2_000) + _write_struct(stream, _box_wrapper_name(idx), [_sref(target, origin)]) + + for idx in range(cfg.poly_wrappers): + target = _poly_name(idx % cfg.poly_cells) + origin = ((idx % 61) * 3_200, (idx // 61) * 3_200) + _write_struct(stream, _poly_wrapper_name(idx), [_sref(target, origin)]) + + +def _write_box_clusters(stream: Any, cfg: FixturePreset) -> None: + array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4)) + for idx in range(cfg.box_clusters): + cell_elements: list[elements.Element] = [] + for ref_idx in range(cfg.box_cluster_refs): + target = _box_name((idx * cfg.box_cluster_refs + ref_idx) % cfg.box_cells) + origin = ( + (ref_idx % 6) * 48_000, + (ref_idx // 6) * 48_000, + ) + if ref_idx < array_refs: + cell_elements.append(_aref(target, origin, cfg.box_cluster_array, (720, 900))) + else: + cell_elements.append(_sref(target, origin)) + _write_struct(stream, _box_cluster_name(idx), cell_elements) + + +def _write_poly_clusters(stream: Any, cfg: FixturePreset) -> None: + array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2) + for idx in range(cfg.poly_clusters): + cell_elements: list[elements.Element] = [] + for ref_idx in range(cfg.poly_cluster_refs): + target = _poly_name((idx * cfg.poly_cluster_refs + ref_idx) % cfg.poly_cells) + origin = ( + (ref_idx % 10) * 96_000, + (ref_idx // 10) * 96_000, + ) + if ref_idx < array_refs: + cell_elements.append(_aref(target, origin, cfg.poly_cluster_array, (12_000, 8_500))) + else: + cell_elements.append(_sref(target, origin)) + _write_struct(stream, _poly_cluster_name(idx), cell_elements) + + +def _top_box_refs(cfg: FixturePreset) -> list[elements.Reference]: + refs: list[elements.Reference] = [] + + for idx in range(cfg.box_wrappers): + refs.append(_sref( + _box_wrapper_name(idx), + ((idx % 240) * 240_000, (idx // 240) * 240_000), + )) + + for idx in range(cfg.box_clusters): + refs.append(_sref( + _box_cluster_name(idx), + ((idx % 100) * 800_000, (idx // 100) * 800_000 + 14_000_000), + )) + + for idx in range(cfg.top_direct_box_refs): + target = _box_name(idx % cfg.box_cells) + origin = ( + (idx % 150) * 160_000, + (idx // 150) * 160_000 + 26_000_000, + ) + if cfg.top_box_array == (1, 1): + refs.append(_sref(target, origin)) + else: + refs.append(_aref(target, origin, cfg.top_box_array, (1_100, 1_350))) + + return refs + + +def _top_poly_refs(cfg: FixturePreset) -> list[elements.Reference]: + refs: list[elements.Reference] = [] + + for idx in range(cfg.poly_wrappers): + refs.append(_sref( + _poly_wrapper_name(idx), + ((idx % 180) * 360_000, (idx // 180) * 360_000 + 44_000_000), + )) + + for idx in range(cfg.poly_clusters): + refs.append(_sref( + _poly_cluster_name(idx), + ((idx % 70) * 1_100_000, (idx // 70) * 1_100_000 + 58_000_000), + )) + + for idx in range(cfg.top_direct_poly_refs): + target = _poly_name(idx % cfg.poly_cells) + origin = ( + (idx % 110) * 420_000, + (idx // 110) * 420_000 + 72_000_000, + ) + if cfg.top_poly_array == (1, 1): + refs.append(_sref(target, origin)) + else: + refs.append(_aref(target, origin, cfg.top_poly_array, (16_000, 14_000))) + + return refs + + +def _write_top(stream: Any, cfg: FixturePreset) -> None: + cell_elements: list[elements.Element] = [] + cell_elements.extend(_top_box_refs(cfg)) + cell_elements.extend(_top_poly_refs(cfg)) + _write_struct(stream, 'TOP', cell_elements) + + +def fixture_manifest(path: str | Path, preset: str, scale: float = 1.0) -> FixtureManifest: + base = PRESETS[preset] + cfg = _scaled_preset(base, scale) + + box_cluster_array_refs = min(cfg.box_cluster_refs, max(1, (3 * cfg.box_cluster_refs) // 4)) + box_cluster_array_mult = cfg.box_cluster_array[0] * cfg.box_cluster_array[1] + box_cluster_ref_instances = ( + box_cluster_array_refs * box_cluster_array_mult + + (cfg.box_cluster_refs - box_cluster_array_refs) + ) + poly_cluster_array_refs = min(cfg.poly_cluster_refs, cfg.poly_cluster_refs // 2) + poly_cluster_array_mult = cfg.poly_cluster_array[0] * cfg.poly_cluster_array[1] + poly_cluster_ref_instances = ( + poly_cluster_array_refs * poly_cluster_array_mult + + (cfg.poly_cluster_refs - poly_cluster_array_refs) + ) + + flattened_box_placements = ( + cfg.box_wrappers + + cfg.box_clusters * box_cluster_ref_instances + + cfg.top_direct_box_refs * cfg.top_box_array[0] * cfg.top_box_array[1] + ) + flattened_poly_placements = ( + cfg.poly_wrappers + + cfg.poly_clusters * poly_cluster_ref_instances + + cfg.top_direct_poly_refs * cfg.top_poly_array[0] * cfg.top_poly_array[1] + ) + polygon_layers = max(1, cfg.polygon_layers) + polys_per_layer = (cfg.poly_cells * cfg.polygons_per_cell) // polygon_layers + + return FixtureManifest( + preset=cfg.name, + scale=scale, + gds_path=str(Path(path)), + library_name=f'masque-perf-{cfg.name}', + cells=cfg.box_cells + cfg.poly_cells + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters + 1, + refs=( + cfg.box_wrappers + + cfg.poly_wrappers + + cfg.box_clusters * cfg.box_cluster_refs + + cfg.poly_clusters * cfg.poly_cluster_refs + + cfg.box_wrappers + cfg.poly_wrappers + cfg.box_clusters + cfg.poly_clusters + + cfg.top_direct_box_refs + cfg.top_direct_poly_refs + ), + layers=cfg.total_layers, + box_layers=cfg.box_layers, + heavy_box_layers=[[layer, 0] for layer in range(cfg.heavy_box_layers)], + polygon_layers=[[layer, 0] for layer in range(cfg.polygon_layers)], + hierarchical_boxes_per_heavy_layer=cfg.box_cells * cfg.heavy_boxes_per_cell, + hierarchical_boxes_per_regular_layer=cfg.box_cells * cfg.regular_boxes_per_cell, + hierarchical_polygons_total=cfg.poly_cells * cfg.polygons_per_cell, + hierarchical_paths_total=(cfg.poly_cells - 1) // cfg.path_stride + 1, + hierarchical_texts_total=(cfg.poly_cells - 1) // cfg.text_stride + 1, + flattened_box_placements=flattened_box_placements, + flattened_poly_placements=flattened_poly_placements, + estimated_flat_boxes_per_heavy_layer=flattened_box_placements * cfg.heavy_boxes_per_cell, + estimated_flat_polygons_per_active_polygon_layer=flattened_poly_placements * polys_per_layer // cfg.poly_cells if cfg.poly_cells else 0, + ) + + +def write_fixture( + path: str | Path, + *, + preset: str, + scale: float = 1.0, + write_manifest: bool = True, + ) -> FixtureManifest: + if preset not in PRESETS: + known = ', '.join(sorted(PRESETS)) + raise KeyError(f'unknown preset {preset!r}; expected one of: {known}') + + manifest = fixture_manifest(path, preset, scale) + cfg = _scaled_preset(PRESETS[preset], scale) + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + + with output.open('wb') as stream: + header = klamath.library.FileHeader( + name=manifest.library_name.encode('ASCII'), + user_units_per_db_unit=USER_UNITS_PER_DB_UNIT, + meters_per_db_unit=METERS_PER_DB_UNIT, + ) + header.write(stream) + _write_box_cells(stream, cfg) + _write_poly_cells(stream, cfg) + _write_wrappers(stream, cfg) + _write_box_clusters(stream, cfg) + _write_poly_clusters(stream, cfg) + _write_top(stream, cfg) + klamath.records.ENDLIB.write(stream, None) + + if write_manifest: + manifest_path = output.with_suffix(output.suffix + '.json') + manifest_path.write_text(json.dumps(asdict(manifest), indent=2, sort_keys=True) + '\n') + + return manifest + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='Generate synthetic GDS fixtures for GDS reader/writer performance work.') + parser.add_argument( + 'preset', + nargs='?', + default='many_cells', + choices=sorted(PRESETS), + help='Fixture family to generate.', + ) + parser.add_argument( + 'output', + nargs='?', + help='Output .gds path. Defaults to build/gds_perf/.gds', + ) + parser.add_argument( + '--scale', + type=float, + default=1.0, + help='Scale the preset counts down or up while keeping the same shape mix. Default: 1.0', + ) + parser.add_argument( + '--no-manifest', + action='store_true', + help='Do not write the sidecar JSON manifest.', + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(argv) + output = Path(args.output) if args.output is not None else Path('build/gds_perf') / f'{args.preset}.gds' + manifest = write_fixture(output, preset=args.preset, scale=args.scale, write_manifest=not args.no_manifest) + print(json.dumps(asdict(manifest), indent=2, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 672af25..b5d0cd8 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -120,10 +120,10 @@ def build( layer, data_type = _mlayer2oas(layer_num) lib.layers += [ fatrec.LayerName( - nstring=name, - layer_interval=(layer, layer), - type_interval=(data_type, data_type), - is_textlayer=tt, + nstring = name, + layer_interval = (layer, layer), + type_interval = (data_type, data_type), + is_textlayer = tt, ) for tt in (True, False)] @@ -182,8 +182,8 @@ def writefile( Args: library: A {name: Pattern} mapping of patterns to write. filename: Filename to save to. - *args: passed to `oasis.write` - **kwargs: passed to `oasis.write` + *args: passed to `oasis.build()` + **kwargs: passed to `oasis.build()` """ path = pathlib.Path(filename) @@ -213,9 +213,9 @@ def readfile( Will automatically decompress gzipped files. Args: - filename: Filename to save to. - *args: passed to `oasis.read` - **kwargs: passed to `oasis.read` + filename: Filename to load from. + *args: passed to `oasis.read()` + **kwargs: passed to `oasis.read()` """ path = pathlib.Path(filename) if is_gzipped(path): @@ -286,11 +286,11 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - annotations=annotations, - repetition=repetition, + vertices = vertices, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + annotations = annotations, + repetition = repetition, ) elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) @@ -310,13 +310,13 @@ def read( annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.path( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - width=element.get_half_width() * 2, - cap=cap, + vertices = vertices, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + annotations = annotations, + width = element.get_half_width() * 2, + cap = cap, **path_args, ) @@ -325,11 +325,11 @@ def read( height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations=annotations, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations = annotations, ) elif isinstance(element, fatrec.Trapezoid): @@ -440,11 +440,11 @@ def read( else: string = str_or_ref.string pat.label( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - string=string, + layer = element.get_layer_tuple(), + offset = element.get_xy(), + repetition = repetition, + annotations = annotations, + string = string, ) else: @@ -549,33 +549,35 @@ def _shapes_to_elements( offset = rint_cast(shape.offset + rep_offset) radius = rint_cast(shape.radius) circle = fatrec.Circle( - layer=layer, - datatype=datatype, - radius=cast('int', radius), - x=offset[0], - y=offset[1], - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + radius = cast('int', radius), + x = offset[0], + y = offset[1], + properties = properties, + repetition = repetition, ) elements.append(circle) elif isinstance(shape, Path): xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset) deltas = rint_cast(numpy.diff(shape.vertices, axis=0)) half_width = rint_cast(shape.width / 2) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + path_type = next((k for k, v in path_cap_map.items() if v == shape.cap), None) # reverse lookup + if path_type is None: + raise PatternError(f'OASIS writer does not support path cap {shape.cap}') extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) path = fatrec.Path( - layer=layer, - datatype=datatype, - point_list=cast('Sequence[Sequence[int]]', deltas), - half_width=cast('int', half_width), - x=xy[0], - y=xy[1], - extension_start=extension_start, # TODO implement multiple cap types? - extension_end=extension_end, - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + point_list = cast('Sequence[Sequence[int]]', deltas), + half_width = cast('int', half_width), + x = xy[0], + y = xy[1], + extension_start = extension_start, # TODO implement multiple cap types? + extension_end = extension_end, + properties = properties, + repetition = repetition, ) elements.append(path) else: @@ -583,13 +585,13 @@ def _shapes_to_elements( xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) points = rint_cast(numpy.diff(polygon.vertices, axis=0)) elements.append(fatrec.Polygon( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - point_list=cast('list[list[int]]', points), - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + x = xy[0], + y = xy[1], + point_list = cast('list[list[int]]', points), + properties = properties, + repetition = repetition, )) return elements @@ -606,13 +608,13 @@ def _labels_to_texts( xy = rint_cast(label.offset + rep_offset) properties = annotations_to_properties(label.annotations) texts.append(fatrec.Text( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - string=label.string, - properties=properties, - repetition=repetition, + layer = layer, + datatype = datatype, + x = xy[0], + y = xy[1], + string = label.string, + properties = properties, + repetition = repetition, )) return texts @@ -622,10 +624,12 @@ def repetition_fata2masq( ) -> Repetition | None: mrep: Repetition | None if isinstance(rep, fatamorgana.GridRepetition): - mrep = Grid(a_vector=rep.a_vector, - b_vector=rep.b_vector, - a_count=rep.a_count, - b_count=rep.b_count) + mrep = Grid( + a_vector = rep.a_vector, + b_vector = rep.b_vector, + a_count = rep.a_count, + b_count = rep.b_count, + ) elif isinstance(rep, fatamorgana.ArbitraryRepetition): displacements = numpy.cumsum(numpy.column_stack(( rep.x_displacements, @@ -647,14 +651,19 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None - a_count = rint_cast(rep.a_count) - b_count = rint_cast(rep.b_count) if rep.b_count is not None else None + a_count = int(rep.a_count) + if rep.b_count > 1: + b_vector = rint_cast(rep.b_vector) + b_count = int(rep.b_count) + else: + b_vector = None + b_count = None + frep = fatamorgana.GridRepetition( - a_vector=cast('list[int]', a_vector), - b_vector=cast('list[int] | None', b_vector), - a_count=cast('int', a_count), - b_count=cast('int | None', b_count), + a_vector = a_vector, + b_vector = b_vector, + a_count = a_count, + b_count = b_count, ) offset = (0, 0) elif isinstance(rep, Arbitrary): @@ -707,13 +716,9 @@ def properties_to_annotations( string = repr(value) logger.warning(f'Converting property value for key ({key}) to string ({string})') values.append(string) - annotations[key] = values + annotations.setdefault(key, []).extend(values) return annotations - properties = [fatrec.Property(key, vals, is_standard=False) - for key, vals in annotations.items()] - return properties - def check_valid_names( names: Iterable[str], diff --git a/masque/file/svg.py b/masque/file/svg.py index 859c074..772aa39 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,25 +10,59 @@ import svgwrite # type: ignore from .utils import mangle_name from .. import Pattern +from ..utils import rotation_matrix_2d logger = logging.getLogger(__name__) +def _ref_to_svg_transform(ref) -> str: + linear = rotation_matrix_2d(ref.rotation) * ref.scale + if ref.mirrored: + linear = linear @ numpy.diag((1.0, -1.0)) + + a = linear[0, 0] + b = linear[1, 0] + c = linear[0, 1] + d = linear[1, 1] + e = ref.offset[0] + f = ref.offset[1] + return f'matrix({a:g} {b:g} {c:g} {d:g} {e:g} {f:g})' + + +def _make_svg_ids(names: Mapping[str, Pattern]) -> dict[str, str]: + svg_ids: dict[str, str] = {} + seen_ids: set[str] = set() + for name in names: + base_id = mangle_name(name) + svg_id = base_id + suffix = 1 + while svg_id in seen_ids: + suffix += 1 + svg_id = f'{base_id}_{suffix}' + seen_ids.add(svg_id) + svg_ids[name] = svg_id + return svg_ids + + +def _detached_library(library: Mapping[str, Pattern]) -> dict[str, Pattern]: + return {name: pat.deepcopy() for name, pat in library.items()} + + def writefile( library: Mapping[str, Pattern], top: str, filename: str, custom_attributes: bool = False, + annotate_ports: bool = False, ) -> None: """ - Write a Pattern to an SVG file, by first calling .polygonize() on it + Write a Pattern to an SVG file, by first calling .polygonize() on a detached + materialized copy to change the shapes into polygons, and then writing patterns as SVG groups (, inside ), polygons as paths (), and refs as elements. - Note that this function modifies the Pattern. - If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute is written to the relevant elements. @@ -40,17 +74,21 @@ def writefile( prior to calling this function. Args: - pattern: Pattern to write to file. Modified by this function. + library: Mapping of pattern names to patterns. + top: Name of the top-level pattern to render. filename: Filename to write to. custom_attributes: Whether to write non-standard `pattern_layer` attribute to the SVG elements. + annotate_ports: If True, draw an arrow for each port (similar to + `Pattern.visualize(..., ports=True)`). """ - pattern = library[top] + detached = _detached_library(library) + pattern = detached[top] # Polygonize pattern pattern.polygonize() - bounds = pattern.get_bounds(library=library) + bounds = pattern.get_bounds(library=detached) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) @@ -63,10 +101,11 @@ def writefile( # Create file svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string, debug=(not custom_attributes)) + svg_ids = _make_svg_ids(detached) # Now create a group for each pattern and add in any Boundary and Use elements - for name, pat in library.items(): - svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red') + for name, pat in detached.items(): + svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red') for layer, shapes in pat.shapes.items(): for shape in shapes: @@ -79,16 +118,37 @@ def writefile( svg_group.add(path) + if annotate_ports: + # Draw arrows for the ports, pointing into the device (per port definition) + for port_name, port in pat.ports.items(): + if port.rotation is not None: + p1 = port.offset + angle = port.rotation + size = 1.0 # arrow size + p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) + + # head + head_angle = 0.5 + h1 = p1 + 0.7 * size * numpy.array([numpy.cos(angle + head_angle), numpy.sin(angle + head_angle)]) + h2 = p1 + 0.7 * size * numpy.array([numpy.cos(angle - head_angle), numpy.sin(angle - head_angle)]) + + line = svg.line(start=p1, end=p2, stroke='green', stroke_width=0.2) + head = svg.polyline(points=[h1, p1, h2], fill='none', stroke='green', stroke_width=0.2) + + svg_group.add(line) + svg_group.add(head) + svg_group.add(svg.text(port_name, insert=p2, font_size=0.5, fill='green')) + for target, refs in pat.refs.items(): if target is None: continue for ref in refs: - transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' - use = svg.use(href='#' + mangle_name(target), transform=transform) + transform = _ref_to_svg_transform(ref) + use = svg.use(href='#' + svg_ids[target], transform=transform) svg_group.add(use) svg.defs.add(svg_group) - svg.add(svg.use(href='#' + mangle_name(top))) + svg.add(svg.use(href='#' + svg_ids[top])) svg.save() @@ -103,21 +163,21 @@ def writefile_inverted( box and drawing the polygons with reverse vertex order inside it, all within one `` element. - Note that this function modifies the Pattern. - If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` prior to calling this function. Args: - pattern: Pattern to write to file. Modified by this function. + library: Mapping of pattern names to patterns. + top: Name of the top-level pattern to render. filename: Filename to write to. """ - pattern = library[top] + detached = _detached_library(library) + pattern = detached[top] # Polygonize and flatten pattern - pattern.polygonize().flatten(library) + pattern.polygonize().flatten(detached) - bounds = pattern.get_bounds(library=library) + bounds = pattern.get_bounds(library=detached) if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) diff --git a/masque/file/utils.py b/masque/file/utils.py index 33f68d4..58c7573 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -33,6 +33,12 @@ def preflight( Run a standard set of useful operations and checks, usually done immediately prior to writing to a file (or immediately after reading). + Note that this helper is not copy-isolating. When `sort=True`, it constructs a new + `Library` wrapper around the same `Pattern` objects after sorting them in place, so + later mutating preflight steps such as `prune_empty_patterns` and + `wrap_repeated_shapes` may still mutate caller-owned patterns. Callers that need + isolation should deep-copy the library before calling `preflight()`. + Args: sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents. Default True. Useful for reproducible builds. @@ -75,7 +81,8 @@ def preflight( raise PatternError('Non-numeric layers found:' + pformat(named_layers)) if prune_empty_patterns: - pruned = lib.prune_empty() + prune_dangling = 'error' if allow_dangling_refs is False else 'ignore' + pruned = lib.prune_empty(dangling=prune_dangling) if pruned: logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.debug('Pruned: ' + pformat(pruned)) @@ -144,7 +151,11 @@ def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]: path = pathlib.Path(path) suffixes = ''.join(path.suffixes) with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream: - yield tmp_stream + try: + yield tmp_stream + except Exception: + pathlib.Path(tmp_stream.name).unlink(missing_ok=True) + raise try: shutil.move(tmp_stream.name, path) diff --git a/masque/label.py b/masque/label.py index 711ef35..d220fee 100644 --- a/masque/label.py +++ b/masque/label.py @@ -7,12 +7,12 @@ from numpy.typing import ArrayLike, NDArray from .repetition import Repetition from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key -from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded +from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded, Flippable from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -53,17 +53,36 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl self.repetition = repetition self.annotations = annotations if annotations is not None else {} + @classmethod + def _from_raw( + cls, + string: str, + *, + offset: NDArray[numpy.float64], + repetition: Repetition | None = None, + annotations: annotations_t | None = None, + ) -> Self: + new = cls.__new__(cls) + new._string = string + new._offset = offset + new._repetition = repetition + new._annotations = annotations + return new + def __copy__(self) -> Self: return type(self)( string=self.string, offset=self.offset.copy(), repetition=self.repetition, + annotations=copy.copy(self.annotations), ) def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() + new._repetition = copy.deepcopy(self._repetition, memo) + new._annotations = copy.deepcopy(self._annotations, memo) return new def __lt__(self, other: 'Label') -> bool: @@ -76,6 +95,8 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl return annotations_lt(self.annotations, other.annotations) def __eq__(self, other: Any) -> bool: + if type(self) is not type(other): + return False return ( self.string == other.string and numpy.array_equal(self.offset, other.offset) @@ -96,10 +117,34 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl """ pivot = numpy.asarray(pivot, dtype=float) self.translate(-pivot) + if self.repetition is not None: + self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self + def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: + """ + Extrinsic transformation: Flip the label across a line in the pattern's + coordinate system. This affects both the label's offset and its + repetition grid. + + Args: + axis: Axis to mirror across. 0: x-axis (flip y), 1: y-axis (flip x). + x: Vertical line x=val to mirror across. + y: Horizontal line y=val to mirror across. + + Returns: + self + """ + axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) + self.translate(-pivot) + if self.repetition is not None: + self.repetition.mirror(axis) + self.offset[1 - axis] *= -1 + self.translate(+pivot) + return self + def get_bounds_single(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. diff --git a/masque/library/__init__.py b/masque/library/__init__.py new file mode 100644 index 0000000..b05b020 --- /dev/null +++ b/masque/library/__init__.py @@ -0,0 +1,29 @@ +"""Library classes for managing name-to-pattern mappings.""" +from .utils import ( + SINGLE_USE_PREFIX as SINGLE_USE_PREFIX, + Tree as Tree, + TreeView as TreeView, + b64suffix as b64suffix, + dangling_mode_t as dangling_mode_t, + visitor_function_t as visitor_function_t, +) +from .base import ( + AbstractView as AbstractView, + ILibrary as ILibrary, + ILibraryView as ILibraryView, +) +from .mapping import ( + Library as Library, + LibraryView as LibraryView, +) +from .overlay import ( + OverlayLibrary as OverlayLibrary, + PortsLibraryView as PortsLibraryView, +) +from .build import ( + BuildLibrary as BuildLibrary, + BuildReport as BuildReport, + CellProvenance as CellProvenance, + cell as cell, +) +from .lazy import LazyLibrary as LazyLibrary diff --git a/masque/library.py b/masque/library/base.py similarity index 72% rename from masque/library.py rename to masque/library/base.py index 0ed5271..283ca6d 100644 --- a/masque/library.py +++ b/masque/library/base.py @@ -1,98 +1,36 @@ -""" -Library classes for managing unique name->pattern mappings and deferred loading or execution. +"""Core library interfaces.""" +from __future__ import annotations -Classes include: -- `ILibraryView`: Defines a general interface for read-only name->pattern mappings. -- `LibraryView`: An implementation of `ILibraryView` backed by an arbitrary `Mapping`. - Can be used to wrap any arbitrary `Mapping` to give it all the functionality in `ILibraryView` -- `ILibrary`: Defines a general interface for mutable name->pattern mappings. -- `Library`: An implementation of `ILibrary` backed by an arbitrary `MutableMapping`. - Can be used to wrap any arbitrary `MutableMapping` to give it all the functionality in `ILibrary`. - By default, uses a `dict` as the underylingmapping. -- `LazyLibrary`: An implementation of `ILibrary` which enables on-demand loading or generation - of patterns. -- `AbstractView`: Provides a way to use []-indexing to generate abstracts for patterns in the linked - library. Generated with `ILibraryView.abstract_view()`. -""" -from typing import Self, TYPE_CHECKING, cast, TypeAlias, Protocol, Literal -from collections.abc import Iterator, Mapping, MutableMapping, Sequence, Callable +from abc import ABCMeta, abstractmethod +from collections import defaultdict +from collections.abc import Callable, Mapping, MutableMapping, Sequence +from graphlib import CycleError, TopologicalSorter +from pprint import pformat +from typing import TYPE_CHECKING, Self, cast +import copy import logging import re -import copy -from pprint import pformat -from collections import defaultdict -from abc import ABCMeta, abstractmethod -from graphlib import TopologicalSorter import numpy -from numpy.typing import ArrayLike, NDArray -from .error import LibraryError, PatternError -from .utils import layer_t, apply_transforms -from .shapes import Shape, Polygon -from .label import Label -from .abstract import Abstract -from .pattern import map_layers +from ..abstract import Abstract +from ..error import LibraryError, PatternError +from ..pattern import Pattern, map_layers +from ..shapes import Polygon, Shape +from ..utils import apply_transforms, layer_t +from .utils import SINGLE_USE_PREFIX, TreeView, b64suffix, dangling_mode_t, _rename_patterns, visitor_function_t if TYPE_CHECKING: - from .pattern import Pattern + from collections.abc import Iterator + + from numpy.typing import ArrayLike, NDArray + + from ..label import Label logger = logging.getLogger(__name__) -class visitor_function_t(Protocol): - """ Signature for `Library.dfs()` visitor functions. """ - def __call__( - self, - pattern: 'Pattern', - hierarchy: tuple[str | None, ...], - memo: dict, - transform: NDArray[numpy.float64] | Literal[False], - ) -> 'Pattern': - ... - - -TreeView: TypeAlias = Mapping[str, 'Pattern'] -""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """ - -Tree: TypeAlias = MutableMapping[str, 'Pattern'] -""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ - - -SINGLE_USE_PREFIX = '_' -""" -Names starting with this prefix are assumed to refer to single-use patterns, -which may be renamed automatically by `ILibrary.add()` (via -`rename_theirs=_rename_patterns()` ) -""" -# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere? - - -def _rename_patterns(lib: 'ILibraryView', name: str) -> str: - """ - The default `rename_theirs` function for `ILibrary.add`. - - Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as - "one-offs" for which name conflicts should be automatically resolved. - Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)` - where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`. - Names lacking the prefix are directly returned (not renamed). - - Args: - lib: The library into which `name` is to be added (but is presumed to conflict) - name: The original name, to be modified - - Returns: - The new name, not guaranteed to be conflict-free! - """ - if not name.startswith(SINGLE_USE_PREFIX): - return name - - stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0] - return lib.get_name(SINGLE_USE_PREFIX + stem) - - class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ Interface for a read-only library. @@ -109,7 +47,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): def __repr__(self) -> str: return '' - def abstract_view(self) -> 'AbstractView': + def abstract_view(self) -> AbstractView: """ Returns: An AbstractView into this library @@ -128,6 +66,15 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ return Abstract(name=name, ports=self[name].ports) + def source_order(self) -> tuple[str, ...]: + """ + Return names in the library's preferred source order. + + Source-backed views may override this to preserve on-disk ordering + without materializing patterns. + """ + return tuple(self.keys()) + def dangling_refs( self, tops: str | Sequence[str] | None = None, @@ -177,6 +124,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): if isinstance(tops, str): tops = (tops,) + tops = set(tops) + skip |= tops # don't re-visit tops # Get referenced patterns for all tops targets = set() @@ -186,16 +135,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): # Perform recursive lookups, but only once for each name for target in targets - skip: assert target is not None + skip.add(target) if target in self: targets |= self.referenced_patterns(target, skip=skip) - skip.add(target) return targets def subtree( self, tops: str | Sequence[str], - ) -> 'ILibraryView': + ) -> ILibraryView: """ Return a new `ILibraryView`, containing only the specified patterns and the patterns they reference (recursively). @@ -213,6 +162,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): keep = cast('set[str]', self.referenced_patterns(tops) - {None}) keep |= set(tops) + from .mapping import LibraryView # noqa: PLC0415 + filtered = {kk: vv for kk, vv in self.items() if kk in keep} new = LibraryView(filtered) return new @@ -264,7 +215,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): tops: str | Sequence[str], flatten_ports: bool = False, dangling_ok: bool = False, - ) -> dict[str, 'Pattern']: + ) -> dict[str, Pattern]: """ Returns copies of all `tops` patterns with all refs removed and replaced with equivalent shapes. @@ -291,8 +242,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): def flatten_single(name: str) -> None: flattened[name] = None pat = self[name].deepcopy() + refs_by_target = tuple((target, tuple(refs)) for target, refs in pat.refs.items()) - for target in pat.refs: + for target, refs in refs_by_target: if target is None: continue if dangling_ok and target not in self: @@ -303,10 +255,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): target_pat = flattened[target] if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue - for ref in pat.refs[target]: + for ref in refs: + if flatten_ports and ref.repetition is not None and target_pat.ports: + raise PatternError( + f'Cannot flatten ports from repeated ref to {target!r}; ' + 'flatten with flatten_ports=False or expand/rename the ports manually first.' + ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -403,7 +361,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}') return tops[0] - def top_pattern(self) -> 'Pattern': + def top_pattern(self) -> Pattern: """ Shorthand for self[self.top()] @@ -412,9 +370,24 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ return self[self.top()] + @staticmethod + def _dangling_refs_error(dangling: set[str], context: str) -> LibraryError: + dangling_list = sorted(dangling) + return LibraryError(f'Dangling refs found while {context}: ' + pformat(dangling_list)) + + def _raw_child_graph(self) -> tuple[dict[str, set[str]], set[str]]: + existing = set(self.keys()) + graph: dict[str, set[str]] = {} + dangling: set[str] = set() + for name, pat in self.items(): + children = {child for child, refs in pat.refs.items() if child is not None and refs} + graph[name] = children + dangling |= children - existing + return graph, dangling + def dfs( self, - pattern: 'Pattern', + pattern: Pattern, visit_before: visitor_function_t | None = None, visit_after: visitor_function_t | None = None, *, @@ -466,9 +439,11 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.zeros(4) + transform = numpy.array([0, 0, 0, 0, 1], dtype=float) elif transform is not False: transform = numpy.asarray(transform, dtype=float) + if transform.size == 4: + transform = numpy.append(transform, 1.0) original_pattern = pattern @@ -490,12 +465,12 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): for ref_transform in ref_transforms: self.dfs( - pattern=self[target], - visit_before=visit_before, - visit_after=visit_after, - hierarchy=hierarchy + (target,), - transform=ref_transform, - memo=memo, + pattern = self[target], + visit_before = visit_before, + visit_after = visit_after, + hierarchy = hierarchy + (target,), + transform = ref_transform, + memo = memo, ) if visit_after is not None: @@ -511,50 +486,99 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): raise LibraryError('visit_* functions returned a new `Pattern` object' ' but no top-level name was provided in `hierarchy`') + del cast('ILibrary', self)[name] cast('ILibrary', self)[name] = pattern return self - def child_graph(self) -> dict[str, set[str | None]]: + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: """ Return a mapping from pattern name to a set of all child patterns (patterns it references). + Only non-empty ref lists with non-`None` targets are treated as graph edges. + + Args: + dangling: How refs to missing targets are handled. `'error'` raises, + `'ignore'` drops those edges, and `'include'` exposes them as + synthetic leaf nodes. + Returns: Mapping from pattern name to a set of all pattern names it references. """ - graph = {name: set(pat.refs.keys()) for name, pat in self.items()} + graph, dangling_refs = self._raw_child_graph() + if dangling == 'error': + if dangling_refs: + raise self._dangling_refs_error(dangling_refs, 'building child graph') + return graph + if dangling == 'ignore': + existing = set(graph) + return {name: {child for child in children if child in existing} for name, children in graph.items()} + + for target in dangling_refs: + graph.setdefault(target, set()) return graph - def parent_graph(self) -> dict[str, set[str]]: + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: """ Return a mapping from pattern name to a set of all parent patterns (patterns which reference it). + Args: + dangling: How refs to missing targets are handled. `'error'` raises, + `'ignore'` drops those targets, and `'include'` adds them as + synthetic keys whose values are their existing parents. + Returns: Mapping from pattern name to a set of all patterns which reference it. """ - igraph: dict[str, set[str]] = {name: set() for name in self} - for name, pat in self.items(): - for child, reflist in pat.refs.items(): - if reflist and child is not None: - igraph[child].add(name) + child_graph, dangling_refs = self._raw_child_graph() + if dangling == 'error' and dangling_refs: + raise self._dangling_refs_error(dangling_refs, 'building parent graph') + + existing = set(child_graph) + igraph: dict[str, set[str]] = {name: set() for name in existing} + for parent, children in child_graph.items(): + for child in children: + if child in existing: + igraph[child].add(parent) + elif dangling == 'include': + igraph.setdefault(child, set()).add(parent) return igraph - def child_order(self) -> list[str]: + def child_order( + self, + dangling: dangling_mode_t = 'error', + ) -> list[str]: """ - Return a topologically sorted list of all contained pattern names. + Return a topologically sorted list of graph node names. Child (referenced) patterns will appear before their parents. + Args: + dangling: Passed to `child_graph()`. + Return: Topologically sorted list of pattern names. """ - return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order())) + try: + return cast('list[str]', list(TopologicalSorter(self.child_graph(dangling=dangling)).static_order())) + except CycleError as exc: + cycle = exc.args[1] if len(exc.args) > 1 else None + if cycle is None: + raise LibraryError('Cycle found while building child order') from exc + raise LibraryError(f'Cycle found while building child order: {cycle}') from exc def find_refs_local( self, name: str, parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', ) -> dict[str, list[NDArray[numpy.float64]]]: """ Find the location and orientation of all refs pointing to `name`. @@ -567,6 +591,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). + dangling: How refs to missing targets are handled if `parent_graph` + is not provided. `'include'` also allows querying missing names. Returns: Mapping of {parent_name: transform_list}, where transform_list @@ -575,8 +601,18 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ instances = defaultdict(list) if parent_graph is None: - parent_graph = self.parent_graph() - for parent in parent_graph[name]: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return instances + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') + if dangling == 'ignore': + return instances + + for parent in parent_graph.get(name, set()): if parent not in self: # parent_graph may be a for a superset of self continue for ref in self[parent].refs[name]: @@ -589,6 +625,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): name: str, order: list[str] | None = None, parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: """ Find the absolute (top-level) location and orientation of all refs (including @@ -605,18 +642,28 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): The provided graph may be for a superset of `self` (i.e. it may contain additional patterns which are not present in self; they will be ignored). + dangling: How refs to missing targets are handled if `order` or + `parent_graph` are not provided. `'include'` also allows + querying missing names. Returns: Mapping of `{hierarchy: transform_list}`, where `hierarchy` is a tuple of the form `(toplevel_pattern, lvl1_pattern, ..., name)` and `transform_list` is an Nx4 ndarray with rows `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. """ - if name not in self: - return {} + graph_mode = 'ignore' if dangling == 'ignore' else 'include' if order is None: - order = self.child_order() + order = self.child_order(dangling=graph_mode) if parent_graph is None: - parent_graph = self.parent_graph() + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return {} + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') + if dangling == 'ignore': + return {} self_keys = set(self.keys()) @@ -625,16 +672,16 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): NDArray[numpy.float64] ]]] transforms = defaultdict(list) - for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).items(): + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): transforms[parent] = [((name,), numpy.concatenate(vals))] for next_name in order: if next_name not in transforms: continue - if not parent_graph[next_name] & self_keys: + if not parent_graph.get(next_name, set()) & self_keys: continue - outers = self.find_refs_local(next_name, parent_graph=parent_graph) + outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) inners = transforms.pop(next_name) for parent, outer in outers.items(): for path, inner in inners: @@ -670,7 +717,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def __setitem__( self, key: str, - value: 'Pattern | Callable[[], Pattern]', + value: Pattern | Callable[[], Pattern], ) -> None: pass @@ -679,9 +726,36 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): pass @abstractmethod - def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: pass + def resolve( + self, + other: Abstract | str | Pattern | TreeView, + append: bool = False, + ) -> Abstract | Pattern: + """ + Resolve another device (name, Abstract, Pattern, or TreeView) into an Abstract or Pattern. + If it is a TreeView, it is first added into this library. + + Args: + other: The device to resolve. + append: If True and `other` is an `Abstract`, returns the full `Pattern` from the library. + + Returns: + An `Abstract` or `Pattern` object. + """ + from ..pattern import Pattern # noqa: PLC0415 + if not isinstance(other, str | Abstract | Pattern): + # We got a TreeView; add it into self and grab its topcell as an Abstract + other = self << other + + if isinstance(other, str): + other = self.abstract(other) + if append and isinstance(other, Abstract): + other = self[other.name] + return other + def rename( self, old_name: str, @@ -700,6 +774,11 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ + if old_name not in self: + raise LibraryError(f'"{old_name}" does not exist in the library.') + if old_name == new_name: + return self + self[new_name] = self[old_name] del self[old_name] if move_references: @@ -724,6 +803,9 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ + if old_target == new_target: + return self + for pattern in self.values(): if old_target in pattern.refs: pattern.refs[new_target].extend(pattern.refs[old_target]) @@ -752,7 +834,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): pattern.labels = map_layers(pattern.labels, map_layer) return self - def mkpat(self, name: str) -> tuple[str, 'Pattern']: + def mkpat(self, name: str) -> tuple[str, Pattern]: """ Convenience method to create an empty pattern, add it to the library, and return both the pattern and name. @@ -763,15 +845,15 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: (name, pattern) tuple """ - from .pattern import Pattern + from ..pattern import Pattern # noqa: PLC0415 pat = Pattern() self[name] = pat return name, pat def add( self, - other: Mapping[str, 'Pattern'], - rename_theirs: Callable[['ILibraryView', str], str] = _rename_patterns, + other: Mapping[str, Pattern], + rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns, mutate_other: bool = False, ) -> dict[str, str]: """ @@ -797,18 +879,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): (default). Returns: - A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged - names map to themselves. + A mapping of `{old_name: new_name}` for all names in `other` which were + renamed while being added. Unchanged names are omitted. Raises: `LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`. """ - from .pattern import map_targets + from ..pattern import map_targets # noqa: PLC0415 + from .mapping import Library # noqa: PLC0415 + duplicates = set(self.keys()) & set(other.keys()) if not duplicates: - for key in other: - self._merge(key, other, key) + if mutate_other: + temp = other + else: + temp = Library(copy.deepcopy(dict(other))) + + for key in temp: + self._merge(key, temp, key) return {} if mutate_other: @@ -847,6 +936,8 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Raises: LibraryError if there is more than one topcell in `other`. """ + from .mapping import LibraryView # noqa: PLC0415 + if len(other) == 1: name = next(iter(other)) else: @@ -863,7 +954,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): new_name = rename_map.get(name, name) return new_name - def __le__(self, other: Mapping[str, 'Pattern']) -> Abstract: + def __le__(self, other: Mapping[str, Pattern]) -> Abstract: """ Perform the same operation as `__lshift__` / `<<`, but return an `Abstract` instead of just the pattern's name. @@ -909,7 +1000,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): # This currently simplifies globally (same shape in different patterns is # merged into the same ref target). - from .pattern import Pattern + from ..pattern import Pattern # noqa: PLC0415 if exclude_types is None: exclude_types = () @@ -918,6 +1009,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def label2name(label: tuple) -> str: # noqa: ARG001 return self.get_name(SINGLE_USE_PREFIX + 'shape') + used_names = set(self.keys()) + + def reserve_target_name(label: tuple) -> str: + base_name = label2name(label) + name = base_name + ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0 + while name in used_names or name == '': + name = base_name + b64suffix(ii) + ii += 1 + used_names.add(name) + return name + shape_counts: MutableMapping[tuple, int] = defaultdict(int) shape_funcs = {} @@ -934,6 +1037,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): shape_counts[label] += 1 shape_pats = {} + target_names = {} for label, count in shape_counts.items(): if count < threshold: continue @@ -942,6 +1046,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): shape_pat = Pattern() shape_pat.shapes[label[-1]] += [shape_func()] shape_pats[label] = shape_pat + target_names[label] = reserve_target_name(label) # ## Second pass ## for pat in tuple(self.values()): @@ -966,14 +1071,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): # For repeated shapes, create a `Pattern` holding a normalized shape object, # and add `pat.refs` entries for each occurrence in pat. Also, note down that # we should delete the `pat.shapes` entries for which we made `Ref`s. - shapes_to_remove = [] for label, shape_entries in shape_table.items(): layer = label[-1] - target = label2name(label) + target = target_names[label] + shapes_to_remove = [] for ii, values in shape_entries: offset, scale, rotation, mirror_x = values pat.ref(target=target, offset=offset, scale=scale, - rotation=rotation, mirrored=(mirror_x, False)) + rotation=rotation, mirrored=mirror_x) shapes_to_remove.append(ii) # Remove any shapes for which we have created refs. @@ -981,13 +1086,13 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): del pat.shapes[layer][ii] for ll, pp in shape_pats.items(): - self[label2name(ll)] = pp + self[target_names[ll]] = pp return self def wrap_repeated_shapes( self, - name_func: Callable[['Pattern', Shape | Label], str] | None = None, + name_func: Callable[[Pattern, Shape | Label], str] | None = None, ) -> Self: """ Wraps all shapes and labels with a non-`None` `repetition` attribute @@ -1002,7 +1107,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - from .pattern import Pattern + from ..pattern import Pattern # noqa: PLC0415 if name_func is None: def name_func(_pat: Pattern, _shape: Shape | Label) -> str: @@ -1036,6 +1141,25 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): return self + def resolve_repeated_refs(self, name: str | None = None) -> Self: + """ + Expand all repeated references into multiple individual references. + Alters the library in-place. + + Args: + name: If specified, only resolve repeated refs in this pattern. + Otherwise, resolve in all patterns. + + Returns: + self + """ + if name is not None: + self[name].resolve_repeated_refs() + else: + for pat in self.values(): + pat.resolve_repeated_refs() + return self + def subtree( self, tops: str | Sequence[str], @@ -1065,17 +1189,19 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def prune_empty( self, repeat: bool = True, + dangling: dangling_mode_t = 'error', ) -> set[str]: """ Delete any empty patterns (i.e. where `Pattern.is_empty` returns `True`). Args: repeat: Also recursively delete any patterns which only contain(ed) empty patterns. + dangling: Passed to `parent_graph()`. Returns: A set containing the names of all deleted patterns """ - parent_graph = self.parent_graph() + parent_graph = self.parent_graph(dangling=dangling) empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() while empty: @@ -1112,246 +1238,6 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): del pat.refs[key] return self - -class LibraryView(ILibraryView): - """ - Default implementation for a read-only library. - - A library is a mapping from unique names (str) to collections of geometry (`Pattern`). - This library is backed by an arbitrary python object which implements the `Mapping` interface. - """ - mapping: Mapping[str, 'Pattern'] - - def __init__( - self, - mapping: Mapping[str, 'Pattern'], - ) -> None: - self.mapping = mapping - - def __getitem__(self, key: str) -> 'Pattern': - return self.mapping[key] - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def __repr__(self) -> str: - return f'' - - -class Library(ILibrary): - """ - Default implementation for a writeable library. - - A library is a mapping from unique names (str) to collections of geometry (`Pattern`). - This library is backed by an arbitrary python object which implements the `MutableMapping` interface. - """ - mapping: MutableMapping[str, 'Pattern'] - - def __init__( - self, - mapping: MutableMapping[str, 'Pattern'] | None = None, - ) -> None: - if mapping is None: - self.mapping = {} - else: - self.mapping = mapping - - def __getitem__(self, key: str) -> 'Pattern': - return self.mapping[key] - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def __setitem__( - self, - key: str, - value: 'Pattern | Callable[[], Pattern]', - ) -> None: - if key in self.mapping: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - - value = value() if callable(value) else value - self.mapping[key] = value - - def __delitem__(self, key: str) -> None: - del self.mapping[key] - - def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: - self[key_self] = other[key_other] - - def __repr__(self) -> str: - return f'' - - @classmethod - def mktree(cls: type[Self], name: str) -> tuple[Self, 'Pattern']: - """ - Create a new Library and immediately add a pattern - - Args: - name: The name for the new pattern (usually the name of the topcell). - - Returns: - The newly created `Library` and the newly created `Pattern` - """ - from .pattern import Pattern - tree = cls() - pat = Pattern() - tree[name] = pat - return tree, pat - - -class LazyLibrary(ILibrary): - """ - This class is usually used to create a library of Patterns by mapping names to - functions which generate or load the relevant `Pattern` object as-needed. - - TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid? - """ - mapping: dict[str, Callable[[], 'Pattern']] - cache: dict[str, 'Pattern'] - _lookups_in_progress: set[str] - - def __init__(self) -> None: - self.mapping = {} - self.cache = {} - self._lookups_in_progress = set() - - def __setitem__( - self, - key: str, - value: 'Pattern | Callable[[], Pattern]', - ) -> None: - if key in self.mapping: - raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') - - if callable(value): - value_func = value - else: - value_func = lambda: cast('Pattern', value) # noqa: E731 - - self.mapping[key] = value_func - if key in self.cache: - del self.cache[key] - - def __delitem__(self, key: str) -> None: - del self.mapping[key] - if key in self.cache: - del self.cache[key] - - def __getitem__(self, key: str) -> 'Pattern': - logger.debug(f'loading {key}') - if key in self.cache: - logger.debug(f'found {key} in cache') - return self.cache[key] - - if key in self._lookups_in_progress: - raise LibraryError( - f'Detected multiple simultaneous lookups of "{key}".\n' - 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' - 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' # TODO give advice on finding cycles - ) - - self._lookups_in_progress.add(key) - func = self.mapping[key] - pat = func() - self._lookups_in_progress.remove(key) - self.cache[key] = pat - return pat - - def __iter__(self) -> Iterator[str]: - return iter(self.mapping) - - def __len__(self) -> int: - return len(self.mapping) - - def __contains__(self, key: object) -> bool: - return key in self.mapping - - def _merge(self, key_self: str, other: Mapping[str, 'Pattern'], key_other: str) -> None: - if isinstance(other, LazyLibrary): - self.mapping[key_self] = other.mapping[key_other] - if key_other in other.cache: - self.cache[key_self] = other.cache[key_other] - else: - self[key_self] = other[key_other] - - def __repr__(self) -> str: - return '' - - def rename( - self, - old_name: str, - new_name: str, - move_references: bool = False, - ) -> Self: - """ - Rename a pattern. - - Args: - old_name: Current name for the pattern - new_name: New name for the pattern - move_references: Whether to scan all refs in the pattern and - move them to point to `new_name` as necessary. - Default `False`. - - Returns: - self - """ - self[new_name] = self.mapping[old_name] # copy over function - if old_name in self.cache: - self.cache[new_name] = self.cache[old_name] - del self[old_name] - - if move_references: - self.move_references(old_name, new_name) - - return self - - def move_references(self, old_target: str, new_target: str) -> Self: - """ - Change all references pointing at `old_target` into references pointing at `new_target`. - - Args: - old_target: Current reference target - new_target: New target for the reference - - Returns: - self - """ - self.precache() - for pattern in self.cache.values(): - if old_target in pattern.refs: - pattern.refs[new_target].extend(pattern.refs[old_target]) - del pattern.refs[old_target] - return self - - def precache(self) -> Self: - """ - Force all patterns into the cache - - Returns: - self - """ - for key in self.mapping: - _ = self[key] # want to trigger our own __getitem__ - return self - - def __deepcopy__(self, memo: dict | None = None) -> 'LazyLibrary': - raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)') - - class AbstractView(Mapping[str, Abstract]): """ A read-only mapping from names to `Abstract` objects. @@ -1371,20 +1257,3 @@ class AbstractView(Mapping[str, Abstract]): def __len__(self) -> int: return self.library.__len__() - - -def b64suffix(ii: int) -> str: - """ - Turn an integer into a base64-equivalent suffix. - - This could be done with base64.b64encode, but this way is faster for many small `ii`. - """ - def i2a(nn: int) -> str: - return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn] - - parts = ['$', i2a(ii % 64)] - ii >>= 6 - while ii: - parts.append(i2a(ii % 64)) - ii >>= 6 - return ''.join(parts) diff --git a/masque/library/build.py b/masque/library/build.py new file mode 100644 index 0000000..8582a52 --- /dev/null +++ b/masque/library/build.py @@ -0,0 +1,742 @@ +"""Two-phase build library implementation.""" +from __future__ import annotations + +from collections import defaultdict +from contextvars import ContextVar +from dataclasses import dataclass, replace +from functools import wraps +from pprint import pformat +from typing import TYPE_CHECKING, Any, Literal, Self, cast +import copy + +from ..error import BuildError, LibraryError +from .base import ILibrary, ILibraryView +from .utils import TreeView, dangling_mode_t, _plan_source_names, _rename_patterns, _source_rename_map +from .mapping import Library, LibraryView +from .overlay import OverlayLibrary + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, Sequence + + from ..abstract import Abstract + from ..pattern import Pattern + + +_ACTIVE_BUILD_SESSIONS: ContextVar[dict[int, _BuildSessionLibrary] | None] = ContextVar( + 'masque_active_build_sessions', + default=None, +) + + +@dataclass(frozen=True) +class CellProvenance: + """ + Provenance record for one cell in a completed build output. + + Each output name in a `BuildReport` maps to one `CellProvenance`. The + record captures both where the cell came from and how its visible name was + chosen. + + Attributes: + requested_name: First name requested for this cell during the build. + kind: Whether the cell came from a declaration, helper emission, or an + imported source library. + owner_declared_name: Declared cell responsible for this output cell, if + any. Imported source cells leave this as `None`. + build_chain: Declared-cell dependency chain that was active when the + cell was emitted. + """ + requested_name: str + kind: Literal['declared', 'helper', 'source'] + owner_declared_name: str | None + build_chain: tuple[str, ...] + + +@dataclass(frozen=True) +class BuildReport: + """ + Immutable summary of one `BuildLibrary.validate()` or `.build()` run. + + The report is designed to answer two questions after a build completes: + which declared cells depended on which other declared cells, and where each + output cell came from. + + Attributes: + requested_roots: Roots explicitly requested for the run. A full + `build()` uses all declared cells. + provenance: Mapping from final output name to provenance metadata. + dependency_graph: Declared-cell dependency graph discovered through + library-mediated reads and explicit recipe hints. + """ + requested_roots: tuple[str, ...] + provenance: Mapping[str, CellProvenance] + dependency_graph: Mapping[str, frozenset[str]] + +@dataclass +class _BuildRecipe: + """ Captured deferred call to a pattern factory. """ + func: Callable[..., Pattern] + args: tuple[Any, ...] + kwargs: dict[str, Any] + explicit_dependencies: tuple[str, ...] = () + + def depends_on(self, *names: str) -> _BuildRecipe: + self.explicit_dependencies += tuple(names) + return self + + +def cell(func: Callable[..., Pattern]) -> Callable[..., _BuildRecipe]: + """ + Wrap a plain pattern factory so calls return deferred build recipes. + + Use as either `cell(fn)(...)` or `@cell`. + """ + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> _BuildRecipe: + return _BuildRecipe(func=func, args=args, kwargs=kwargs) + + return wrapper + + +class BuildCellsView: + """ + Attribute-based declaration namespace for `BuildLibrary`. + + This is the ergonomic authoring surface exposed as `builder.cells`. It is + intentionally write-focused: attribute assignment and deletion register + declarations, while attribute reads fail with guidance to build first and + use the returned library. + """ + + def __init__(self, library: BuildLibrary) -> None: + object.__setattr__(self, '_library', library) + + def __getattr__(self, name: str) -> Pattern: + raise BuildError( + f'BuildLibrary.cells.{name} is write-only during authoring. ' + 'Call build() and index the returned library instead.' + ) + + def __setattr__(self, name: str, value: Pattern | _BuildRecipe) -> None: + if name.startswith('_'): + object.__setattr__(self, name, value) + return + self._library[name] = value + + def __delattr__(self, name: str) -> None: + if name.startswith('_'): + raise AttributeError(name) + del self._library[name] + + +class BuildLibrary(ILibrary): + """ + Two-phase declaration surface for mixed imported/generated libraries. + + A `BuildLibrary` collects three kinds of inputs: + - direct declared `Pattern` objects + - deferred recipes created with `cell(...)` + - imported source-backed library views added with `add_source(...)` + + The builder itself is not a normal readable library during authoring. + Instead, `validate()` and `build()` create a temporary build-session library + that recipes can read from and write helper cells into while dependencies + are resolved. `build()` then freezes the builder on success and returns the + built library plus a `BuildReport`. + """ + + def __init__(self) -> None: + self.cells = BuildCellsView(self) + self._frozen = False + self._declarations: dict[str, Pattern | _BuildRecipe] = {} + self._sources: list[tuple[ILibraryView, dict[str, str]]] = [] + self._names: dict[str, None] = {} + + def _active_session(self) -> _BuildSessionLibrary | None: + sessions = _ACTIVE_BUILD_SESSIONS.get() + if sessions is None: + return None + return sessions.get(id(self)) + + def _require_active_session(self, operation: str) -> _BuildSessionLibrary: + session = self._active_session() + if session is None: + raise BuildError( + f'BuildLibrary.{operation}() is only available while validate() or build() is running. ' + 'Use the built output library for reads.' + ) + return session + + def _assert_editable(self) -> None: + if self._frozen: + raise BuildError('This BuildLibrary has already been built successfully and is now frozen.') + + def __iter__(self) -> Iterator[str]: + session = self._active_session() + if session is not None: + return iter(session) + return iter(self._names) + + def __len__(self) -> int: + session = self._active_session() + if session is not None: + return len(session) + return len(self._names) + + def __contains__(self, key: object) -> bool: + session = self._active_session() + if session is not None: + return key in session + return key in self._names + + def __getitem__(self, key: str) -> Pattern: + return self._require_active_session('__getitem__')[key] + + def __setitem__( + self, + key: str, + value: Pattern | _BuildRecipe, + ) -> None: + session = self._active_session() + if session is not None: + session[key] = value + return + + self._assert_editable() + if key in self._names: + raise LibraryError(f'"{key}" already exists in the builder. Overwriting is not allowed!') + + if isinstance(value, _BuildRecipe): + declaration = value + else: + if callable(value): + raise TypeError('BuildLibrary recipes must be wrapped with cell(fn)(...) or @cell.') + declaration = value + + self._declarations[key] = declaration + self._names[key] = None + + def __delitem__(self, key: str) -> None: + session = self._active_session() + if session is not None: + del session[key] + return + + self._assert_editable() + if key not in self._declarations: + raise KeyError(key) + del self._declarations[key] + del self._names[key] + + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + session = self._active_session() + if session is not None: + session._merge(key_self, other, key_other) + return + self[key_self] = copy.deepcopy(other[key_other]) + + def add( + self, + other: Mapping[str, Pattern], + rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns, + mutate_other: bool = False, + ) -> dict[str, str]: + from ..pattern import map_targets # noqa: PLC0415 + + session = self._active_session() + if session is not None: + return session.add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) + + self._assert_editable() + + source_backed = isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView) + if source_backed: + if mutate_other: + raise BuildError('BuildLibrary.add(..., mutate_other=True) is not supported for source-backed inputs.') + return self.add_source( + other, + rename_theirs = rename_theirs, + rename_when = 'conflict', + ) + + source_order = tuple(other.keys()) + source_to_visible = _plan_source_names( + self, + source_order, + self._names, + rename_theirs = rename_theirs, + rename_when = 'conflict', + ) + rename_map = _source_rename_map(source_to_visible) + + if mutate_other: + temp = other + else: + temp = Library(copy.deepcopy(dict(other))) + + for source_name in source_order: + visible_name = source_to_visible[source_name] + pattern = temp[source_name] + if rename_map: + pattern.refs = map_targets( + pattern.refs, + lambda target: cast('dict[str | None, str | None]', rename_map).get(target, target), + ) + self[visible_name] = pattern + + return rename_map + + def __lshift__(self, other: TreeView) -> str: + session = self._active_session() + if session is not None: + return session << other + + self._assert_editable() + if len(other) == 1: + name = next(iter(other)) + elif isinstance(other, ILibraryView) and not isinstance(other, Library | LibraryView): + source_order = other.source_order() + child_graph = other.child_graph(dangling='include') + referenced = set().union(*child_graph.values()) if child_graph else set() + tops = [candidate for candidate in source_order if candidate not in referenced] + if len(tops) != 1: + raise LibraryError(f'Asked for the single topcell, but found the following: {pformat(tops)}') + name = tops[0] + else: + return super().__lshift__(other) + + rename_map = self.add(other) + return rename_map.get(name, name) + + def __le__(self, other: Mapping[str, Pattern]) -> Abstract: + if self._active_session() is not None: + return super().__le__(other) + raise BuildError('BuildLibrary.__le__() is only available while validate() or build() is running.') + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + """ + Rename a helper cell during an active build session. + + During authoring, declared cells must be registered under their + intended final names and imported source cells must be renamed through + `add_source(...)`. + """ + session = self._active_session() + if session is not None: + session.rename(old_name, new_name, move_references=move_references) + return self + + self._assert_editable() + if old_name == new_name: + return self + if old_name in self._declarations: + raise BuildError( + f'Cannot rename declared build cell "{old_name}" during authoring. ' + 'Register it under the intended final name instead.' + ) + if old_name not in self._names: + raise LibraryError(f'"{old_name}" does not exist in the builder.') + raise BuildError( + f'Cannot rename imported source cell "{old_name}" during authoring. ' + 'Choose visible source names with add_source(..., rename_theirs=..., rename_when=...).' + ) + + def abstract(self, name: str) -> Abstract: + return self._require_active_session('abstract').abstract(name) + + def resolve( + self, + other: Abstract | str | Pattern | TreeView, + append: bool = False, + ) -> Abstract | Pattern: + return self._require_active_session('resolve').resolve(other, append=append) + + def add_source( + self, + source: Mapping[str, Pattern] | ILibraryView, + *, + rename_theirs: Callable[[ILibraryView, str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', + ) -> dict[str, str]: + """ + Register an imported source-backed library with the builder. + + The source is not materialized immediately. Its names are scanned once + to reserve visible builder names, then the source is read again when a + build session starts. The source's cell membership must not be + structurally mutated between `add_source()` and `build()`/`validate()`. + Source cells may be renamed on entry to avoid collisions with existing + declarations or other imported sources. + + Args: + rename_theirs: Function used to choose visible names for imported + source cells. + rename_when: If `'conflict'`, only conflicting names are renamed. + If `'always'`, every imported source name is passed through + `rename_theirs`. + + Returns: + Mapping of `{source_name: visible_name}` for imported names that + were renamed while being added. + """ + if self._active_session() is not None: + raise BuildError('BuildLibrary.add_source() is only available while authoring, not during validate() or build().') + self._assert_editable() + + view = source if isinstance(source, ILibraryView) else LibraryView(source) + source_order = tuple(view.source_order()) + source_to_visible = _plan_source_names( + self, + source_order, + self._names, + rename_theirs = rename_theirs, + rename_when = rename_when, + ) + + self._sources.append((view, dict(source_to_visible))) + for source_name in source_order: + visible = source_to_visible[source_name] + self._names[visible] = None + return _source_rename_map(source_to_visible) + + def validate( + self, + names: Sequence[str] | None = None, + *, + allow_dangling: bool = False, + ) -> BuildReport: + """ + Run the full build logic and return a `BuildReport` without producing output. + + This is a dry run over the same dependency resolution and recipe + execution path used by `build()`. Any generated library is discarded + after validation completes. + """ + _session, report = self._run_build(names=names, allow_dangling=allow_dangling) + return report + + def build( + self, + *, + output: Literal['overlay', 'library'] = 'overlay', + allow_dangling: bool = False, + ) -> tuple[ILibrary, BuildReport]: + """ + Materialize declarations and return a usable output library plus report. + + Args: + output: `'overlay'` preserves imported source-backed cells where + possible, while `'library'` eagerly materializes the full + result. + allow_dangling: If `False`, fail the build when the completed + library still contains dangling references. + """ + if output not in ('overlay', 'library'): + raise ValueError(f'Unknown build output mode: {output!r}') + self._assert_editable() + session, report = self._run_build(names=None, allow_dangling=allow_dangling) + if output == 'library': + built_output = session.to_library() + else: + built_output = session.to_overlay() + self._frozen = True + return built_output, report + + def _run_build( + self, + *, + names: Sequence[str] | None, + allow_dangling: bool, + ) -> tuple[_BuildSessionLibrary, BuildReport]: + roots = tuple(dict.fromkeys(names if names is not None else self._declarations.keys())) + unknown = [name for name in roots if name not in self._names] + if unknown: + raise BuildError(f'Unknown build roots requested: {unknown}') + + session = _BuildSessionLibrary(self) + sessions = dict(_ACTIVE_BUILD_SESSIONS.get() or {}) + sessions[id(self)] = session + token = _ACTIVE_BUILD_SESSIONS.set(sessions) + try: + session.materialize_many(roots) + if not allow_dangling: + session.child_graph(dangling='error') + finally: + _ACTIVE_BUILD_SESSIONS.reset(token) + + report = session.build_report(roots) + return session, report + + +class _BuildSessionLibrary(ILibrary): + """ + Internal overlay-backed library used while a `BuildLibrary` is executing. + + This object provides the mutable-library surface that recipes expect while + also tracking declared-cell dependencies, helper-cell provenance, and + imported source cells. It exists only for the duration of a validation or + build run. + """ + + def __init__(self, builder: BuildLibrary) -> None: + self._builder = builder + self._overlay = OverlayLibrary() + self._built: set[str] = set() + self._declared_stack: list[str] = [] + self._names = dict(builder._names) + self._provenance: dict[str, CellProvenance] = {} + self._dependency_graph: defaultdict[str, set[str]] = defaultdict(set) + self._install_sources() + + def _install_sources(self) -> None: + for source_library, source_to_visible in self._builder._sources: + source_order = source_library.source_order() + expected_names = set(source_to_visible) + actual_names = set(source_order) + if actual_names != expected_names: + added_names = sorted(actual_names - expected_names) + removed_names = sorted(expected_names - actual_names) + detail = [] + if added_names: + detail.append(f'added={added_names}') + if removed_names: + detail.append(f'removed={removed_names}') + raise BuildError( + 'Imported source library changed after add_source() was called ' + f'({", ".join(detail)}). ' + 'Do not structurally mutate source libraries between add_source() and build()/validate().' + ) + + def rename_source(_lib: ILibraryView, name: str, *, mapping: Mapping[str, str] = source_to_visible) -> str: + return mapping[name] + + self._overlay.add_source( + source_library, + rename_theirs = rename_source, + rename_when = 'always', + ) + + for source_name in source_order: + visible_name = source_to_visible[source_name] + self._provenance[visible_name] = CellProvenance( + requested_name = source_name, + kind = 'source', + owner_declared_name = None, + build_chain = (), + ) + + def __iter__(self) -> Iterator[str]: + return iter(self._names) + + def __len__(self) -> int: + return len(self._names) + + def __contains__(self, key: object) -> bool: + return key in self._names + + def _current_declared(self) -> str | None: + if not self._declared_stack: + return None + return self._declared_stack[-1] + + def _record_dependency(self, target: str) -> None: + current = self._current_declared() + if current is None or current == target or target not in self._builder._declarations: + return + self._dependency_graph[current].add(target) + + def _guard_mutable_output_name(self, key: str, *, operation: str) -> None: + if key in self._builder._declarations: + raise BuildError(f'Cannot {operation} declared build cell "{key}" during an active build session.') + + provenance = self._provenance.get(key) + if provenance is not None and provenance.kind == 'source': + raise BuildError(f'Cannot {operation} imported source cell "{key}" during an active build session.') + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + if old_name == new_name: + return self + if old_name not in self._overlay: + if old_name in self._builder._declarations: + self._guard_mutable_output_name(old_name, operation='rename') + raise LibraryError(f'"{old_name}" does not exist in the library.') + + self._guard_mutable_output_name(old_name, operation='rename') + if new_name in self._names: + raise LibraryError(f'"{new_name}" already exists in the library.') + + self._overlay.rename(old_name, new_name, move_references=move_references) + self._names = { + new_name if name == old_name else name: None + for name in self._names + } + + provenance = self._provenance.pop(old_name) + self._provenance[new_name] = provenance + return self + + def __getitem__(self, key: str) -> Pattern: + if key in self._builder._declarations: + self._record_dependency(key) + self._ensure_declared(key) + return self._overlay[key] + + def __setitem__( + self, + key: str, + value: Pattern | Callable[[], Pattern], + ) -> None: + if key in self._overlay: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + current = self._current_declared() + if key in self._builder._declarations and key != current: + raise LibraryError(f'"{key}" is reserved for a declared cell and cannot be used as a helper name.') + + pattern = value() if callable(value) else value + self._overlay[key] = pattern + self._names.setdefault(key, None) + + kind: Literal['declared', 'helper'] + if current is not None and key == current: + kind = 'declared' + else: + kind = 'helper' + + self._provenance[key] = CellProvenance( + requested_name = key, + kind = kind, + owner_declared_name = current if kind == 'helper' else key, + build_chain = tuple(self._declared_stack), + ) + + def __delitem__(self, key: str) -> None: + if key not in self._overlay: + if key in self._builder._declarations: + self._guard_mutable_output_name(key, operation='delete') + raise KeyError(key) + + self._guard_mutable_output_name(key, operation='delete') + if key in self._overlay: + del self._overlay[key] + self._names.pop(key, None) + self._provenance.pop(key, None) + + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + self[key_self] = copy.deepcopy(other[key_other]) + + def add( + self, + other: Mapping[str, Pattern], + rename_theirs: Callable[[ILibraryView, str], str] = _rename_patterns, + mutate_other: bool = False, + ) -> dict[str, str]: + rename_map = super().add(other, rename_theirs=rename_theirs, mutate_other=mutate_other) + + current = self._current_declared() + for old_name, new_name in rename_map.items(): + if new_name in self._provenance: + self._provenance[new_name] = replace( + self._provenance[new_name], + requested_name = old_name, + owner_declared_name = current if current is not None else self._provenance[new_name].owner_declared_name, + ) + return rename_map + + def _wrap_error(self, name: str, exc: Exception) -> BuildError: + chain = tuple(self._declared_stack) + msg = [f'Failed while building declared cell "{name}"'] + if chain: + msg.append(f'Dependency chain: {" -> ".join(chain)}') + msg.append(f'Cause: {exc}') + return BuildError('\n'.join(msg)) + + def _ensure_named(self, name: str) -> None: + if name in self._builder._declarations: + self._record_dependency(name) + self._ensure_declared(name) + return + if name in self._overlay: + return + raise BuildError(f'Missing dependency "{name}"') + + def _ensure_declared(self, name: str) -> None: + from ..pattern import Pattern # noqa: PLC0415 + + if name in self._built: + return + if name in self._declared_stack: + chain = ' -> '.join(self._declared_stack + [name]) + raise BuildError(f'Cycle detected while building declared cells: {chain}') + + declaration = self._builder._declarations[name] + self._declared_stack.append(name) + try: + if isinstance(declaration, _BuildRecipe): + for dep in declaration.explicit_dependencies: + self._ensure_named(dep) + pattern = declaration.func(*declaration.args, **declaration.kwargs) + if not isinstance(pattern, Pattern): + raise BuildError(f'Recipe for "{name}" returned {type(pattern).__name__}, expected Pattern') # noqa: TRY301 + else: + pattern = declaration.deepcopy() + + if name in self._overlay: + if self._overlay[name] is not pattern: + raise BuildError( # noqa: TRY301 + f'Recipe for "{name}" wrote a different pattern into the session under its own name.' + ) + else: + self[name] = pattern + self._built.add(name) + except Exception as exc: + raise self._wrap_error(name, exc) from exc + finally: + self._declared_stack.pop() + + def materialize_many(self, names: Sequence[str]) -> None: + for name in dict.fromkeys(names): + self._ensure_named(name) + + def source_order(self) -> tuple[str, ...]: + return self._overlay.source_order() + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._overlay.child_graph(dangling=dangling) + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._overlay.parent_graph(dangling=dangling) + + def build_report(self, requested_roots: Sequence[str]) -> BuildReport: + dependency_graph = { + name: frozenset(self._dependency_graph.get(name, set())) + for name in self._builder._declarations + if name in self._dependency_graph or name in requested_roots + } + return BuildReport( + requested_roots = tuple(dict.fromkeys(requested_roots)), + provenance = dict(self._provenance), + dependency_graph = dependency_graph, + ) + + def to_overlay(self) -> ILibrary: + return self._overlay + + def to_library(self) -> Library: + mapping = {name: self._overlay[name] for name in self._overlay.source_order()} + return Library(mapping) diff --git a/masque/library/lazy.py b/masque/library/lazy.py new file mode 100644 index 0000000..973a431 --- /dev/null +++ b/masque/library/lazy.py @@ -0,0 +1,169 @@ +"""Closure-backed lazy library implementation.""" +from __future__ import annotations + +from pprint import pformat +from typing import TYPE_CHECKING, Self, cast +import logging + +from ..error import LibraryError +from .base import ILibrary + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + + from ..pattern import Pattern + + +logger = logging.getLogger(__name__) + + +class LazyLibrary(ILibrary): + """ + This class is usually used to create a library of Patterns by mapping names to + functions which generate or load the relevant `Pattern` object as-needed. + + TODO: lots of stuff causes recursive loads (e.g. data_to_ports?). What should you avoid? + """ + mapping: dict[str, Callable[[], Pattern]] + cache: dict[str, Pattern] + _lookups_in_progress: list[str] + + def __init__(self) -> None: + self.mapping = {} + self.cache = {} + self._lookups_in_progress = [] + + def __setitem__( + self, + key: str, + value: Pattern | Callable[[], Pattern], + ) -> None: + if key in self.mapping: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + + if callable(value): + value_func = value + else: + value_func = lambda: cast('Pattern', value) # noqa: E731 + + self.mapping[key] = value_func + if key in self.cache: + del self.cache[key] + + def __delitem__(self, key: str) -> None: + del self.mapping[key] + if key in self.cache: + del self.cache[key] + + def __getitem__(self, key: str) -> Pattern: + logger.debug(f'loading {key}') + if key in self.cache: + logger.debug(f'found {key} in cache') + return self.cache[key] + + if key in self._lookups_in_progress: + chain = ' -> '.join(self._lookups_in_progress + [key]) + raise LibraryError( + f'Detected circular reference or recursive lookup of "{key}".\n' + f'Lookup chain: {chain}\n' + 'This may be caused by an invalid (cyclical) reference, or buggy code.\n' + 'If you are lazy-loading a file, try a non-lazy load and check for reference cycles.' + ) + + self._lookups_in_progress.append(key) + try: + func = self.mapping[key] + pat = func() + finally: + self._lookups_in_progress.pop() + self.cache[key] = pat + return pat + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + if isinstance(other, LazyLibrary): + self.mapping[key_self] = other.mapping[key_other] + if key_other in other.cache: + self.cache[key_self] = other.cache[key_other] + else: + self[key_self] = other[key_other] + + def __repr__(self) -> str: + return '' + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> Self: + """ + Rename a pattern. + + Args: + old_name: Current name for the pattern + new_name: New name for the pattern + move_references: Whether to scan all refs in the pattern and + move them to point to `new_name` as necessary. + Default `False`. + + Returns: + self + """ + if old_name not in self.mapping: + raise LibraryError(f'"{old_name}" does not exist in the library.') + if old_name == new_name: + return self + + self[new_name] = self.mapping[old_name] # copy over function + if old_name in self.cache: + self.cache[new_name] = self.cache[old_name] + del self[old_name] + + if move_references: + self.move_references(old_name, new_name) + + return self + + def move_references(self, old_target: str, new_target: str) -> Self: + """ + Change all references pointing at `old_target` into references pointing at `new_target`. + + Args: + old_target: Current reference target + new_target: New target for the reference + + Returns: + self + """ + if old_target == new_target: + return self + + self.precache() + for pattern in self.cache.values(): + if old_target in pattern.refs: + pattern.refs[new_target].extend(pattern.refs[old_target]) + del pattern.refs[old_target] + return self + + def precache(self) -> Self: + """ + Force all patterns into the cache + + Returns: + self + """ + for key in self.mapping: + _ = self[key] # want to trigger our own __getitem__ + return self + + def __deepcopy__(self, memo: dict | None = None) -> LazyLibrary: + raise LibraryError('LazyLibrary cannot be deepcopied (deepcopy doesn\'t descend into closures)') diff --git a/masque/library/mapping.py b/masque/library/mapping.py new file mode 100644 index 0000000..95c7e73 --- /dev/null +++ b/masque/library/mapping.py @@ -0,0 +1,112 @@ +"""Concrete mapping-backed library implementations.""" +from __future__ import annotations + +from pprint import pformat +from typing import TYPE_CHECKING, Self + +from ..error import LibraryError +from .base import ILibrary, ILibraryView + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, MutableMapping + + from ..pattern import Pattern + + +class LibraryView(ILibraryView): + """ + Default implementation for a read-only library. + + A library is a mapping from unique names (str) to collections of geometry (`Pattern`). + This library is backed by an arbitrary python object which implements the `Mapping` interface. + """ + mapping: Mapping[str, Pattern] + + def __init__( + self, + mapping: Mapping[str, Pattern], + ) -> None: + self.mapping = mapping + + def __getitem__(self, key: str) -> Pattern: + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def __repr__(self) -> str: + return f'' + + +class Library(ILibrary): + """ + Default implementation for a writeable library. + + A library is a mapping from unique names (str) to collections of geometry (`Pattern`). + This library is backed by an arbitrary python object which implements the `MutableMapping` interface. + """ + mapping: MutableMapping[str, Pattern] + + def __init__( + self, + mapping: MutableMapping[str, Pattern] | None = None, + ) -> None: + if mapping is None: + self.mapping = {} + else: + self.mapping = mapping + + def __getitem__(self, key: str) -> Pattern: + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def __setitem__( + self, + key: str, + value: Pattern | Callable[[], Pattern], + ) -> None: + if key in self.mapping: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + + value = value() if callable(value) else value + self.mapping[key] = value + + def __delitem__(self, key: str) -> None: + del self.mapping[key] + + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + self[key_self] = other[key_other] + + def __repr__(self) -> str: + return f'' + + @classmethod + def mktree(cls: type[Self], name: str) -> tuple[Self, Pattern]: + """ + Create a new Library and immediately add a pattern + + Args: + name: The name for the new pattern (usually the name of the topcell). + + Returns: + The newly created `Library` and the newly created `Pattern` + """ + from ..pattern import Pattern # noqa: PLC0415 + tree = cls() + pat = Pattern() + tree[name] = pat + return tree, pat diff --git a/masque/library/overlay.py b/masque/library/overlay.py new file mode 100644 index 0000000..a325bc6 --- /dev/null +++ b/masque/library/overlay.py @@ -0,0 +1,531 @@ +"""Overlay and ports-importing library views.""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, cast +import copy + +import numpy + +from ..error import LibraryError +from ..pattern import Pattern, map_targets +from ..utils import apply_transforms, layer_t +from .base import ILibrary, ILibraryView +from .utils import dangling_mode_t, _plan_source_names, _source_rename_map +from .mapping import LibraryView + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping, Sequence + + from numpy.typing import NDArray + + from ..ports import Port + + +@dataclass +class _SourceLayer: + """ One imported source layer tracked by an `OverlayLibrary`. """ + library: ILibraryView + source_to_visible: dict[str, str] + visible_to_source: dict[str, str] + child_graph: dict[str, set[str]] + order: list[str] + + +@dataclass(frozen=True) +class _SourceEntry: + """ Reference to a single visible source-backed cell in an overlay. """ + layer_index: int + source_name: str + + +def _materialize_detached_pattern(view: ILibraryView, name: str) -> Pattern: + func = getattr(view, '_materialize_pattern', None) + if callable(func): + return cast('Pattern', func(name, persist=False)) + return view[name].deepcopy() + + +class PortsLibraryView(ILibraryView): + """ + Read-only view which imports or applies ports on first materialization. + + The wrapped source remains untouched; this view owns a separate processed + cache so direct-copy workflows can continue to use the raw source view. + + Graph queries, source ordering, and copy-through capabilities are delegated + to the wrapped source whenever possible, while `__getitem__` and + `materialize_many()` return port-imported patterns. + """ + + def __init__( + self, + source: ILibraryView, + *, + layers: Sequence[layer_t] = (), + max_depth: int = 0, + skip_subcells: bool = True, + ports: Mapping[str, Mapping[str, Port]] | None = None, + replace: bool = False, + ) -> None: + self._source = source + self._layers = tuple(layers) + self._max_depth = max_depth + self._skip_subcells = skip_subcells + self._ports = { + name: copy.deepcopy(dict(cell_ports)) + for name, cell_ports in (ports or {}).items() + } + self._replace = replace + self._cache: dict[str, Pattern] = {} + self._lookups_in_progress: list[str] = [] + if hasattr(source, 'library_info'): + self.library_info = cast('dict[str, Any]', source.library_info) + + def __getitem__(self, key: str) -> Pattern: + return self._materialize_pattern(key, persist=True) + + def __iter__(self) -> Iterator[str]: + return iter(self._source) + + def __len__(self) -> int: + return len(self._source) + + def __contains__(self, key: object) -> bool: + return key in self._source + + def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern: + from ..utils.ports2data import data_to_ports # noqa: PLC0415 + + if name in self._cache: + return self._cache[name] + + if name in self._lookups_in_progress: + chain = ' -> '.join(self._lookups_in_progress + [name]) + raise LibraryError( + f'Detected circular reference or recursive lookup of "{name}".\n' + f'Lookup chain: {chain}\n' + 'This may be caused by an invalid (cyclical) reference, or buggy code.' + ) + + self._lookups_in_progress.append(name) + try: + pat = _materialize_detached_pattern(self._source, name) + if self._layers: + pat = data_to_ports( + layers=self._layers, + library=self, + pattern=pat, + name=name, + max_depth=self._max_depth, + skip_subcells=self._skip_subcells, + ) + if name in self._ports: + ports = copy.deepcopy(self._ports[name]) + if self._replace: + pat.ports = ports + else: + pat.ports.update(ports) + finally: + self._lookups_in_progress.pop() + + if persist: + self._cache[name] = pat + return pat + + def materialize_many( + self, + names: Sequence[str], + *, + persist: bool = True, + ) -> LibraryView: + mats = { + name: self._materialize_pattern(name, persist=persist) + for name in dict.fromkeys(names) + } + return LibraryView(mats) + + def source_order(self) -> tuple[str, ...]: + return self._source.source_order() + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._source.child_graph(dangling=dangling) + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + return self._source.parent_graph(dangling=dangling) + + def subtree( + self, + tops: str | Sequence[str], + ) -> ILibraryView: + if isinstance(tops, str): + tops = (tops,) + keep = cast('set[str]', self._source.referenced_patterns(tops) - {None}) + keep |= set(tops) + return self.materialize_many(tuple(keep), persist=True) + + def tops(self) -> list[str]: + return self._source.tops() + + def find_refs_local( + self, + name: str, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[str, list[NDArray[numpy.float64]]]: + finder = getattr(self._source, 'find_refs_local', None) + if callable(finder): + return cast('dict[str, list[NDArray[numpy.float64]]]', finder(name, parent_graph=parent_graph, dangling=dangling)) + return super().find_refs_local(name, parent_graph=parent_graph, dangling=dangling) + + def find_refs_global( + self, + name: str, + order: list[str] | None = None, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: + finder = getattr(self._source, 'find_refs_global', None) + if callable(finder): + return cast( + 'dict[tuple[str, ...], NDArray[numpy.float64]]', + finder(name, order=order, parent_graph=parent_graph, dangling=dangling), + ) + return super().find_refs_global(name, order=order, parent_graph=parent_graph, dangling=dangling) + + def raw_struct_bytes(self, name: str) -> bytes: + reader = getattr(self._source, 'raw_struct_bytes', None) + if not callable(reader): + raise TypeError('raw_struct_bytes') + return cast('bytes', reader(name)) + + def can_copy_raw_struct(self, name: str) -> bool: + can_copy = getattr(self._source, 'can_copy_raw_struct', None) + if not callable(can_copy): + return False + return bool(can_copy(name)) + + def close(self) -> None: + closer = getattr(self._source, 'close', None) + if callable(closer): + closer() + + def __enter__(self) -> PortsLibraryView: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +class OverlayLibrary(ILibrary): + """ + Mutable overlay over one or more source libraries. + + Source-backed cells remain lazy until accessed through `__getitem__`, at + which point that visible cell is promoted into an overlay-owned materialized + `Pattern`. + """ + + def __init__(self) -> None: + self._layers: list[_SourceLayer] = [] + self._entries: dict[str, Pattern | _SourceEntry] = {} + self._order: list[str] = [] + self._target_remap: dict[str, str] = {} + + def __iter__(self) -> Iterator[str]: + return (name for name in self._order if name in self._entries) + + def __len__(self) -> int: + return len(self._entries) + + def __contains__(self, key: object) -> bool: + return key in self._entries + + def __getitem__(self, key: str) -> Pattern: + return self._materialize_pattern(key, persist=True) + + def __setitem__( + self, + key: str, + value: Pattern | Callable[[], Pattern], + ) -> None: + if key in self._entries: + raise LibraryError(f'"{key}" already exists in the library. Overwriting is not allowed!') + pattern = value() if callable(value) else value + self._entries[key] = pattern + if key not in self._order: + self._order.append(key) + + def __delitem__(self, key: str) -> None: + if key not in self._entries: + raise KeyError(key) + del self._entries[key] + + def _merge(self, key_self: str, other: Mapping[str, Pattern], key_other: str) -> None: + self[key_self] = copy.deepcopy(other[key_other]) + + def add_source( + self, + source: Mapping[str, Pattern] | ILibraryView, + *, + rename_theirs: Callable[[ILibraryView, str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', + ) -> dict[str, str]: + """ + Add a source-backed library layer. + + Args: + rename_theirs: Function used to choose visible names for imported + source cells. + rename_when: If `'conflict'`, only conflicting names are renamed. + If `'always'`, every imported source name is passed through + `rename_theirs`. + """ + view = source if isinstance(source, ILibraryView) else LibraryView(source) + source_order = list(view.source_order()) + child_graph = view.child_graph(dangling='include') + + source_to_visible = _plan_source_names( + self, + source_order, + self._entries, + rename_theirs = rename_theirs, + rename_when = rename_when, + ) + visible_to_source = {visible: source_name for source_name, visible in source_to_visible.items()} + + layer = _SourceLayer( + library=view, + source_to_visible=source_to_visible, + visible_to_source=visible_to_source, + child_graph=child_graph, + order=[source_to_visible[name] for name in source_order], + ) + layer_index = len(self._layers) + self._layers.append(layer) + + for source_name, visible_name in source_to_visible.items(): + self._entries[visible_name] = _SourceEntry(layer_index=layer_index, source_name=source_name) + if visible_name not in self._order: + self._order.append(visible_name) + + return _source_rename_map(source_to_visible) + + def rename( + self, + old_name: str, + new_name: str, + move_references: bool = False, + ) -> OverlayLibrary: + if old_name not in self._entries: + raise LibraryError(f'"{old_name}" does not exist in the library.') + if old_name == new_name: + return self + if new_name in self._entries: + raise LibraryError(f'"{new_name}" already exists in the library.') + + entry = self._entries.pop(old_name) + self._entries[new_name] = entry + if isinstance(entry, _SourceEntry): + layer = self._layers[entry.layer_index] + layer.source_to_visible[entry.source_name] = new_name + del layer.visible_to_source[old_name] + layer.visible_to_source[new_name] = entry.source_name + + idx = self._order.index(old_name) + self._order[idx] = new_name + + if move_references: + self.move_references(old_name, new_name) + return self + + def _resolve_target(self, target: str) -> str: + seen: set[str] = set() + current = target + while current in self._target_remap: + if current in seen: + raise LibraryError(f'Cycle encountered while resolving target remap for {target!r}') + seen.add(current) + current = self._target_remap[current] + return current + + def _set_target_remap(self, old_target: str, new_target: str) -> None: + resolved_new = self._resolve_target(new_target) + if resolved_new == old_target: + raise LibraryError(f'Ref target remap would create a cycle: {old_target!r} -> {new_target!r}') + self._target_remap[old_target] = resolved_new + for key in list(self._target_remap): + self._target_remap[key] = self._resolve_target(self._target_remap[key]) + + def move_references(self, old_target: str, new_target: str) -> OverlayLibrary: + if old_target == new_target: + return self + self._set_target_remap(old_target, new_target) + for entry in list(self._entries.values()): + if isinstance(entry, Pattern) and old_target in entry.refs: + entry.refs[new_target].extend(entry.refs[old_target]) + del entry.refs[old_target] + return self + + def _effective_target(self, layer: _SourceLayer, target: str) -> str: + visible = layer.source_to_visible.get(target, target) + return self._resolve_target(visible) + + def _materialize_pattern(self, name: str, *, persist: bool) -> Pattern: + if name not in self._entries: + raise KeyError(name) + entry = self._entries[name] + if isinstance(entry, Pattern): + return entry + + layer = self._layers[entry.layer_index] + source_pat = layer.library[entry.source_name].deepcopy() + + def remap(target: str | None) -> str | None: + return None if target is None else self._effective_target(layer, target) + + if source_pat.refs: + source_pat.refs = map_targets(source_pat.refs, remap) + pat = source_pat + if persist: + self._entries[name] = pat + return pat + + def child_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + graph: dict[str, set[str]] = {} + for name in self._order: + if name not in self._entries: + continue + entry = self._entries[name] + if isinstance(entry, Pattern): + graph[name] = {child for child, refs in entry.refs.items() if child is not None and refs} + continue + layer = self._layers[entry.layer_index] + children = {self._effective_target(layer, child) for child in layer.child_graph.get(entry.source_name, set())} + graph[name] = children + + existing = set(graph) + dangling_refs = set().union(*(children - existing for children in graph.values())) + if dangling == 'error': + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building child graph') + return graph + if dangling == 'ignore': + return {name: {child for child in children if child in existing} for name, children in graph.items()} + + for child in dangling_refs: + graph.setdefault(cast('str', child), set()) + return graph + + def parent_graph( + self, + dangling: dangling_mode_t = 'error', + ) -> dict[str, set[str]]: + child_graph = self.child_graph(dangling='include' if dangling == 'include' else 'ignore') + existing = set(self.keys()) + igraph: dict[str, set[str]] = {name: set() for name in child_graph} + for parent, children in child_graph.items(): + for child in children: + if child in existing or dangling == 'include': + igraph.setdefault(child, set()).add(parent) + if dangling == 'error': + raw = self.child_graph(dangling='include') + dangling_refs = set().union(*(children - existing for children in raw.values())) + if dangling_refs: + raise self._dangling_refs_error(cast('set[str]', dangling_refs), 'building parent graph') + return igraph + + def subtree( + self, + tops: str | Sequence[str], + ) -> ILibraryView: + if isinstance(tops, str): + tops = (tops,) + keep = cast('set[str]', self.referenced_patterns(tops) - {None}) + keep |= set(tops) + return LibraryView({name: self[name] for name in keep}) + + def find_refs_local( + self, + name: str, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[str, list[NDArray[numpy.float64]]]: + instances: dict[str, list[NDArray[numpy.float64]]] = defaultdict(list) + if parent_graph is None: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return instances + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding local refs for {name!r}') + if dangling == 'ignore': + return instances + + for parent in parent_graph.get(name, set()): + pat = self._materialize_pattern(parent, persist=False) + for ref in pat.refs.get(name, []): + instances[parent].append(ref.as_transforms()) + return instances + + def find_refs_global( + self, + name: str, + order: list[str] | None = None, + parent_graph: dict[str, set[str]] | None = None, + dangling: dangling_mode_t = 'error', + ) -> dict[tuple[str, ...], NDArray[numpy.float64]]: + graph_mode = 'ignore' if dangling == 'ignore' else 'include' + if order is None: + order = self.child_order(dangling=graph_mode) + if parent_graph is None: + parent_graph = self.parent_graph(dangling=graph_mode) + + if name not in self: + if name not in parent_graph: + return {} + if dangling == 'error': + raise self._dangling_refs_error({name}, f'finding global refs for {name!r}') + if dangling == 'ignore': + return {} + + self_keys = set(self.keys()) + transforms: dict[str, list[tuple[tuple[str, ...], NDArray[numpy.float64]]]] + transforms = defaultdict(list) + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph, dangling=dangling).items(): + transforms[parent] = [((name,), numpy.concatenate(vals))] + + for next_name in order: + if next_name not in transforms: + continue + if not parent_graph.get(next_name, set()) & self_keys: + continue + + outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + inners = transforms.pop(next_name) + for parent, outer in outers.items(): + outer_tf = numpy.concatenate(outer) + for path, inner in inners: + combined = apply_transforms(outer_tf, inner) + transforms[parent].append(((next_name,) + path, combined)) + + result = {} + for parent, targets in transforms.items(): + for path, instances in targets: + result[(parent,) + path] = instances + return result + + def source_order(self) -> tuple[str, ...]: + return tuple(name for name in self._order if name in self._entries) diff --git a/masque/library/utils.py b/masque/library/utils.py new file mode 100644 index 0000000..138c91a --- /dev/null +++ b/masque/library/utils.py @@ -0,0 +1,124 @@ +"""Shared types and helpers for library implementations.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Protocol, TypeAlias +from collections.abc import Callable, Container, Mapping, MutableMapping, Sequence + +from ..error import LibraryError + +if TYPE_CHECKING: + import numpy + from numpy.typing import NDArray + + from ..pattern import Pattern + from .base import ILibraryView + + +class visitor_function_t(Protocol): + """ Signature for `Library.dfs()` visitor functions. """ + def __call__( + self, + pattern: Pattern, + hierarchy: tuple[str | None, ...], + memo: dict, + transform: NDArray[numpy.float64] | Literal[False], + ) -> Pattern: + ... + + +TreeView: TypeAlias = Mapping[str, 'Pattern'] +""" A name-to-`Pattern` mapping which is expected to have only one top-level cell """ + +Tree: TypeAlias = MutableMapping[str, 'Pattern'] +""" A mutable name-to-`Pattern` mapping which is expected to have only one top-level cell """ + +dangling_mode_t: TypeAlias = Literal['error', 'ignore', 'include'] +""" How helpers should handle refs whose targets are not present in the library. """ +SINGLE_USE_PREFIX = '_' +""" +Names starting with this prefix are assumed to refer to single-use patterns, +which may be renamed automatically by `ILibrary.add()` (via +`rename_theirs=_rename_patterns()` ) +""" +# TODO what are the consequences of making '_' special? maybe we can make this decision everywhere? + + +def _rename_patterns(lib: ILibraryView, name: str) -> str: + """ + The default `rename_theirs` function for `ILibrary.add`. + + Treats names starting with `SINGLE_USE_PREFIX` (default: one underscore) as + "one-offs" for which name conflicts should be automatically resolved. + Conflicts are resolved by calling `lib.get_name(SINGLE_USE_PREFIX + stem)` + where `stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0]`. + Names lacking the prefix are directly returned (not renamed). + + Args: + lib: The library into which `name` is to be added (but is presumed to conflict) + name: The original name, to be modified + + Returns: + The new name, not guaranteed to be conflict-free! + """ + if not name.startswith(SINGLE_USE_PREFIX): + return name + + stem = name.removeprefix(SINGLE_USE_PREFIX).split('$')[0] + return lib.get_name(SINGLE_USE_PREFIX + stem) + + +def _plan_source_names( + target: ILibraryView, + source_order: Sequence[str], + existing_names: Container[str], + *, + rename_theirs: Callable[[ILibraryView, str], str] | None = None, + rename_when: Literal['conflict', 'always'] = 'conflict', + ) -> dict[str, str]: + if rename_when not in ('conflict', 'always'): + raise ValueError(f'Unknown source rename mode: {rename_when!r}') + if rename_when == 'always' and rename_theirs is None: + raise TypeError('rename_theirs is required when rename_when="always"') + + source_to_visible: dict[str, str] = {} + visible_names: set[str] = set() + + for name in source_order: + visible = name + if rename_when == 'always': + assert rename_theirs is not None + visible = rename_theirs(target, name) + elif visible in existing_names or visible in visible_names: + if rename_theirs is None: + raise LibraryError(f'Conflicting name while adding source: {name!r}') + visible = rename_theirs(target, name) + if visible in existing_names or visible in visible_names: + raise LibraryError(f'Unresolved duplicate key encountered while adding source: {name!r} -> {visible!r}') + source_to_visible[name] = visible + visible_names.add(visible) + + return source_to_visible + + +def _source_rename_map(source_to_visible: Mapping[str, str]) -> dict[str, str]: + return { + source_name: visible_name + for source_name, visible_name in source_to_visible.items() + if source_name != visible_name + } + +def b64suffix(ii: int) -> str: + """ + Turn an integer into a base64-equivalent suffix. + + This could be done with base64.b64encode, but this way is faster for many small `ii`. + """ + def i2a(nn: int) -> str: + return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$?'[nn] + + parts = ['$', i2a(ii % 64)] + ii >>= 6 + while ii: + parts.append(i2a(ii % 64)) + ii >>= 6 + return ''.join(parts) diff --git a/masque/pattern.py b/masque/pattern.py index dc7d058..daf78e8 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -26,6 +26,7 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab from .ports import Port, PortList + logger = logging.getLogger(__name__) @@ -37,8 +38,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): or provide equivalent functions. `Pattern` also stores a dict of `Port`s, which can be used to "snap" together points. - See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes - `builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`. + See `Pattern.plug()` and `Pattern.place()`, as well as `builder.Pather` + and `ports.PortsList`. For convenience, ports can be read out using square brackets: - `pattern['A'] == Port((0, 0), 0)` @@ -171,7 +172,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') + logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' + 'Consider using .deepcopy() if this was not intended.') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -198,7 +200,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def __lt__(self, other: 'Pattern') -> bool: self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -212,7 +214,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return refs_ours < refs_theirs self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -221,21 +223,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(other.shapes[layer])) if shapes_ours != shapes_theirs: return shapes_ours < shapes_theirs self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return self_txtlayerkeys < other_txtlayerkeys - for _, _, layer in self_layerkeys: + for _, _, layer in self_txtlayerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(other.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -252,7 +254,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] - other_nonempty_targets = [target for target, reflist in self.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in other.refs.items() if reflist] self_tgtkeys = tuple(sorted((target is None, target) for target in self_nonempty_targets)) other_tgtkeys = tuple(sorted((target is None, target) for target in other_nonempty_targets)) @@ -266,7 +268,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return False self_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] - other_nonempty_layers = [ll for ll, elems in self.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in other.shapes.items() if elems] self_layerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_layers)) other_layerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_layers)) @@ -275,21 +277,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(self.shapes[layer])) + shapes_theirs = tuple(sorted(other.shapes[layer])) if shapes_ours != shapes_theirs: return False self_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] - other_nonempty_txtlayers = [ll for ll, elems in self.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in other.labels.items() if elems] self_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in self_nonempty_txtlayers)) other_txtlayerkeys = tuple(sorted(layer2key(ll) for ll in other_nonempty_txtlayers)) if self_txtlayerkeys != other_txtlayerkeys: return False - for _, _, layer in self_layerkeys: + for _, _, layer in self_txtlayerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(self.labels[layer])) + labels_theirs = tuple(sorted(other.labels[layer])) if labels_ours != labels_theirs: return False @@ -347,6 +349,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ + annotation_conflicts: set[str] = set() + if other_pattern.annotations is not None and self.annotations is not None: + annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) + if annotation_conflicts: + raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') + + port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) + if port_conflicts: + raise PatternError(f'Port names overlap: {port_conflicts}') + for target, rseq in other_pattern.refs.items(): self.refs[target].extend(rseq) for layer, sseq in other_pattern.shapes.items(): @@ -357,14 +369,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if other_pattern.annotations is not None: if self.annotations is None: self.annotations = {} - annotation_conflicts = set(self.annotations.keys()) & set(other_pattern.annotations.keys()) - if annotation_conflicts: - raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') self.annotations.update(other_pattern.annotations) - - port_conflicts = set(self.ports.keys()) & set(other_pattern.ports.keys()) - if port_conflicts: - raise PatternError(f'Port names overlap: {port_conflicts}') self.ports.update(other_pattern.ports) return self @@ -499,6 +504,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): ] return polys + def layer_as_polygons( + self, + layer: layer_t, + flatten: bool = True, + library: Mapping[str, 'Pattern'] | None = None, + ) -> list[Polygon]: + """ + Collect all geometry effectively on a given layer as a list of polygons. + + If `flatten=True`, it recursively gathers shapes on `layer` from all `self.refs`. + `Repetition` objects are expanded, and non-polygon shapes are converted + to `Polygon` approximations. + + Args: + layer: The layer to collect geometry from. + flatten: If `True`, include geometry from referenced patterns. + library: Required if `flatten=True` to resolve references. + + Returns: + A list of `Polygon` objects. + """ + if flatten and self.has_refs() and library is None: + raise PatternError("Must provide a library to layer_as_polygons() when flatten=True") + + polys: list[Polygon] = [] + + # Local shapes + for shape in self.shapes.get(layer, []): + for p in shape.to_polygons(): + # expand repetitions + if p.repetition is not None: + for offset in p.repetition.displacements: + polys.append(p.deepcopy().translate(offset).set_repetition(None)) + else: + polys.append(p.deepcopy()) + + if flatten and self.has_refs(): + assert library is not None + for target, refs in self.refs.items(): + if target is None: + continue + target_pat = library[target] + for ref in refs: + # Get polygons from target pattern on the same layer + ref_polys = target_pat.layer_as_polygons(layer, flatten=True, library=library) + # Apply ref transformations + for p in ref_polys: + p_pat = ref.as_pattern(Pattern(shapes={layer: [p]})) + # as_pattern expands repetition of the ref itself + # but we need to pull the polygons back out + for p_transformed in p_pat.shapes[layer]: + polys.append(cast('Polygon', p_transformed)) + + return polys + def referenced_patterns(self) -> set[str | None]: """ Get all pattern namers referenced by this pattern. Non-recursive. @@ -635,6 +695,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!r})") return self def scale_elements(self, c: float) -> Self: @@ -688,7 +749,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the Pattern around the a location. + Extrinsic transformation: Rotate the Pattern around the a location in the + container's coordinate system. This affects all elements' offsets and + their repetition grids. Args: pivot: (x, y) location to rotate around @@ -702,11 +765,14 @@ 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: """ - Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) + Extrinsic transformation part: Rotate the offsets and repetition grids of all + shapes, labels, refs, and ports around (0, 0) in the container's + coordinate system. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -717,11 +783,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): old_offset = cast('Positionable', entry).offset cast('Positionable', entry).offset = numpy.dot(rotation_matrix_2d(rotation), old_offset) + if isinstance(entry, Repeatable) and entry.repetition is not None: + entry.repetition.rotate(rotation) return self def rotate_elements(self, rotation: float) -> Self: """ - Rotate each shape, ref, and port around its origin (offset) + Intrinsic transformation part: Rotate each shape, ref, label, and port around its + origin (offset) in the container's coordinate system. This does NOT + affect their repetition grids. Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -729,54 +799,61 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Rotatable', entry).rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + if isinstance(entry, Rotatable): + entry.rotate(rotation) return self - def mirror_element_centers(self, across_axis: int = 0) -> Self: + def mirror_element_centers(self, axis: int = 0) -> Self: """ - Mirror the offsets of all shapes, labels, and refs across an axis + Extrinsic transformation part: Mirror the offsets and repetition grids of all + shapes, labels, refs, and ports relative to the container's origin. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across (0: x-axis, 1: y-axis) Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - across_axis] *= -1 + cast('Positionable', entry).offset[1 - axis] *= -1 + if isinstance(entry, Repeatable) and entry.repetition is not None: + entry.repetition.mirror(axis) return self - def mirror_elements(self, across_axis: int = 0) -> Self: + def mirror_elements(self, axis: int = 0) -> Self: """ - Mirror each shape, ref, and pattern across an axis, relative - to its offset + Intrinsic transformation part: Mirror each shape, ref, label, and port relative + to its offset. This does NOT affect their repetition grids. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across + 0: mirror across x axis (flip y), + 1: mirror across y axis (flip x) Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): - cast('Mirrorable', entry).mirror(across_axis) + for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): + if isinstance(entry, Mirrorable): + entry.mirror(axis=axis) + self._log_bulk_update(f"mirror_elements({axis})") return self - def mirror(self, across_axis: int = 0) -> Self: + def mirror(self, axis: int = 0) -> Self: """ - Mirror the Pattern across an axis + Extrinsic transformation: Mirror the Pattern across an axis through its origin. + This affects all elements' offsets and their internal orientations. Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) + axis: Axis to mirror across (0: x-axis, 1: y-axis). Returns: self """ - self.mirror_elements(across_axis) - self.mirror_element_centers(across_axis) + self.mirror_elements(axis=axis) + self.mirror_element_centers(axis=axis) + self._log_bulk_update(f"mirror({axis})") return self def copy(self) -> Self: @@ -787,7 +864,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return copy.deepcopy(self) + return self.deepcopy() def deepcopy(self) -> Self: """ @@ -930,6 +1007,28 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): del self.labels[layer] return self + def resolve_repeated_refs(self) -> Self: + """ + Expand all repeated references into multiple individual references. + Alters the current pattern in-place. + + Returns: + self + """ + new_refs: defaultdict[str | None, list[Ref]] = defaultdict(list) + for target, rseq in self.refs.items(): + for ref in rseq: + if ref.repetition is None: + new_refs[target].append(ref) + else: + for dd in ref.repetition.displacements: + new_ref = ref.deepcopy() + new_ref.offset = ref.offset + dd + new_ref.repetition = None + new_refs[target].append(new_ref) + self.refs = new_refs + return self + def prune_refs(self) -> Self: """ Remove empty ref lists in `self.refs`. @@ -981,10 +1080,16 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - if target_pat.is_empty(): # avoid some extra allocations + ports_only = flatten_ports and bool(target_pat.ports) + if target_pat.is_empty() and not ports_only: # avoid some extra allocations continue for ref in refs: + if flatten_ports and ref.repetition is not None and target_pat.ports: + raise PatternError( + f'Cannot flatten ports from repeated ref to {target!r}; ' + 'flatten with flatten_ports=False or expand/rename the ports manually first.' + ) p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -1003,6 +1108,8 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): line_color: str = 'k', fill_color: str = 'none', overdraw: bool = False, + filename: str | None = None, + ports: bool = False, ) -> None: """ Draw a picture of the Pattern and wait for the user to inspect it @@ -1013,15 +1120,18 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): klayout or a different GDS viewer! Args: - offset: Coordinates to offset by before drawing - line_color: Outlines are drawn with this color (passed to `matplotlib.collections.PolyCollection`) - fill_color: Interiors are drawn with this color (passed to `matplotlib.collections.PolyCollection`) - overdraw: Whether to create a new figure or draw on a pre-existing one + library: Mapping of {name: Pattern} for resolving references. Required if `self.has_refs()`. + offset: Coordinates to offset by before drawing. + line_color: Outlines are drawn with this color. + fill_color: Interiors are drawn with this color. + overdraw: Whether to create a new figure or draw on a pre-existing one. + filename: If provided, save the figure to this file instead of showing it. + ports: If True, annotate the plot with arrows representing the ports. """ # TODO: add text labels to visualize() try: - from matplotlib import pyplot # type: ignore - import matplotlib.collections # type: ignore + from matplotlib import pyplot # type: ignore #noqa: PLC0415 + import matplotlib.collections # type: ignore #noqa: PLC0415 except ImportError: logger.exception('Pattern.visualize() depends on matplotlib!\n' + 'Make sure to install masque with the [visualize] option to pull in the needed dependencies.') @@ -1030,48 +1140,155 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if self.has_refs() and library is None: raise PatternError('Must provide a library when visualizing a pattern with refs') - offset = numpy.asarray(offset, dtype=float) + # Cache for {Pattern object ID: List of local polygon vertex arrays} + # Polygons are stored relative to the pattern's origin (offset included) + poly_cache: dict[int, list[NDArray[numpy.float64]]] = {} + def get_local_polys(pat: 'Pattern') -> list[NDArray[numpy.float64]]: + pid = id(pat) + if pid not in poly_cache: + polys = [] + for shape in chain.from_iterable(pat.shapes.values()): + for ss in shape.to_polygons(): + # Shape.to_polygons() returns Polygons with their own offsets and vertices. + # We need to expand any shape-level repetition here. + v_base = ss.vertices + ss.offset + if ss.repetition is not None: + for disp in ss.repetition.displacements: + polys.append(v_base + disp) + else: + polys.append(v_base) + poly_cache[pid] = polys + return poly_cache[pid] + + all_polygons: list[NDArray[numpy.float64]] = [] + port_info: list[tuple[str, NDArray[numpy.float64], float]] = [] + + def collect_polys_recursive( + pat: 'Pattern', + c_offset: NDArray[numpy.float64], + c_rotation: float, + c_mirrored: bool, + c_scale: float, + ) -> None: + # Current transform: T(c_offset) * R(c_rotation) * M(c_mirrored) * S(c_scale) + + # 1. Transform and collect local polygons + local_polys = get_local_polys(pat) + if local_polys: + rot_mat = rotation_matrix_2d(c_rotation) + for v in local_polys: + vt = v * c_scale + if c_mirrored: + vt = vt.copy() + vt[:, 1] *= -1 + vt = (rot_mat @ vt.T).T + c_offset + all_polygons.append(vt) + + # 2. Collect ports if requested + if ports: + for name, p in pat.ports.items(): + pt_v = p.offset * c_scale + if c_mirrored: + pt_v = pt_v.copy() + pt_v[1] *= -1 + pt_v = rotation_matrix_2d(c_rotation) @ pt_v + c_offset + + if p.rotation is not None: + pt_rot = p.rotation + if c_mirrored: + pt_rot = -pt_rot + pt_rot += c_rotation + port_info.append((name, pt_v, pt_rot)) + + # 3. Recurse into refs + for target, refs in pat.refs.items(): + if target is None: + continue + assert library is not None + target_pat = library[target] + for ref in refs: + # Ref order of operations: mirror, rotate, scale, translate, repeat + + # Combined scale and mirror + r_scale = c_scale * ref.scale + r_mirrored = c_mirrored ^ ref.mirrored + + # Combined rotation: push c_mirrored and c_rotation through ref.rotation + r_rot_relative = -ref.rotation if c_mirrored else ref.rotation + r_rotation = c_rotation + r_rot_relative + + # Offset composition helper + def get_full_offset(rel_offset: NDArray[numpy.float64]) -> NDArray[numpy.float64]: + o = rel_offset * c_scale + if c_mirrored: + o = o.copy() + o[1] *= -1 + return rotation_matrix_2d(c_rotation) @ o + c_offset + + if ref.repetition is not None: + for disp in ref.repetition.displacements: + collect_polys_recursive( + target_pat, + get_full_offset(ref.offset + disp), + r_rotation, + r_mirrored, + r_scale + ) + else: + collect_polys_recursive( + target_pat, + get_full_offset(ref.offset), + r_rotation, + r_mirrored, + r_scale + ) + + # Start recursive collection + collect_polys_recursive(self, numpy.asarray(offset, dtype=float), 0.0, False, 1.0) + + # Plotting if not overdraw: figure = pyplot.figure() - pyplot.axis('equal') else: figure = pyplot.gcf() axes = figure.gca() - polygons = [] - for shape in chain.from_iterable(self.shapes.values()): - polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()] + if all_polygons: + mpl_poly_collection = matplotlib.collections.PolyCollection( + all_polygons, + facecolors = fill_color, + edgecolors = line_color, + ) + axes.add_collection(mpl_poly_collection) - mpl_poly_collection = matplotlib.collections.PolyCollection( - polygons, - facecolors=fill_color, - edgecolors=line_color, - ) - axes.add_collection(mpl_poly_collection) - pyplot.axis('equal') + if ports: + for port_name, pt_v, pt_rot in port_info: + p1 = pt_v + angle = pt_rot + size = 1.0 # arrow size + p2 = p1 + size * numpy.array([numpy.cos(angle), numpy.sin(angle)]) - for target, refs in self.refs.items(): - if target is None: - continue - if not refs: - continue - assert library is not None - target_pat = library[target] - for ref in refs: - ref.as_pattern(target_pat).visualize( - library=library, - offset=offset, - overdraw=True, - line_color=line_color, - fill_color=fill_color, + axes.annotate( + port_name, + xy = tuple(p1), + xytext = tuple(p2), + arrowprops = dict(arrowstyle="->", color='g', linewidth=1), + color = 'g', + fontsize = 8, ) + axes.autoscale_view() + axes.set_aspect('equal') + if not overdraw: - pyplot.xlabel('x') - pyplot.ylabel('y') - pyplot.show() + axes.set_xlabel('x') + axes.set_ylabel('y') + if filename: + figure.savefig(filename) + else: + figure.show() # @overload # def place( @@ -1114,6 +1331,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 @@ -1145,6 +1363,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`, 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 @@ -1159,7 +1381,26 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = {} if not skip_port_check: - self.check_ports(other.ports.keys(), map_in=None, map_out=port_map) + port_map, overwrite_targets = self._resolve_insert_mapping( + other.ports.keys(), + map_in=None, + map_out=port_map, + allow_conflicts=skip_geometry, + ) + for target in overwrite_targets: + self.ports.pop(target, None) + + if not skip_geometry: + if append: + if isinstance(other, Abstract): + raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') + if other.annotations is not None and self.annotations is not None: + annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) + if annotation_conflicts: + raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') + elif isinstance(other, Pattern): + raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' + 'Use `append=True` if you intended to append the full geometry.') ports = {} for name, port in other.ports.items(): @@ -1176,10 +1417,12 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): pp.rotate_around(pivot, rotation) pp.translate(offset) 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!') other_copy = other.deepcopy() other_copy.ports.clear() if mirrored: @@ -1188,7 +1431,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): other_copy.translate_elements(offset) self.append(other_copy) else: - assert not isinstance(other, Pattern) ref = Ref(mirrored=mirrored) ref.rotate_around(pivot, rotation) ref.translate(offset) @@ -1234,6 +1476,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 @@ -1283,11 +1526,15 @@ 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`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. 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 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 @@ -1319,23 +1566,59 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) 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_out, overwrite_targets = self._resolve_insert_mapping( + other.ports.keys(), map_in, - mirrored = mirrored, - set_rotation = set_rotation, - ok_connections = ok_connections, + map_out, + allow_conflicts=skip_geometry, ) + if not skip_geometry: + if append: + if isinstance(other, Abstract): + raise PatternError('Must provide a full `Pattern` (not an `Abstract`) when appending!') + if other.annotations is not None and self.annotations is not None: + annotation_conflicts = set(self.annotations.keys()) & set(other.annotations.keys()) + if annotation_conflicts: + raise PatternError(f'Annotation keys overlap: {annotation_conflicts}') + elif isinstance(other, Pattern): + raise PatternError('Must provide an `Abstract` (not a `Pattern`) when creating a reference. ' + 'Use `append=True` if you intended to append the full geometry.') + 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) + + for target in overwrite_targets: + self.ports.pop(target, None) # 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): - assert append, 'Got a name (not an abstract) but was asked to reference (not append)' - self.place( other, offset = translation, @@ -1345,6 +1628,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = map_out, skip_port_check = True, append = append, + skip_geometry = skip_geometry, ) return self @@ -1378,7 +1662,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): current device. Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) + source: A collection of ports (e.g. Pattern, Pather, or dict) from which to create the interface. in_prefix: Prepended to port names for newly-created ports with reversed directions compared to the current device. @@ -1406,9 +1690,13 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): else: raise PatternError(f'Unable to get ports from {type(source)}: {source}') - if port_map: + if port_map is not None: if isinstance(port_map, dict): missing_inkeys = set(port_map.keys()) - set(orig_ports.keys()) + port_targets = list(port_map.values()) + duplicate_targets = {vv for vv in port_targets if port_targets.count(vv) > 1} + if duplicate_targets: + raise PortError(f'Duplicate targets in `port_map`: {duplicate_targets}') mapped_ports = {port_map[k]: v for k, v in orig_ports.items() if k in port_map} else: port_set = set(port_map) diff --git a/masque/ports.py b/masque/ports.py index 0211723..bbd18b7 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -2,6 +2,7 @@ from typing import overload, Self, NoReturn, Any from collections.abc import Iterable, KeysView, ValuesView, Mapping import logging import functools +import copy from collections import Counter from abc import ABCMeta, abstractmethod from itertools import chain @@ -10,16 +11,17 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable -from .utils import rotate_offsets_around, rotation_matrix_2d +from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable +from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d from .error import PortError, format_stacktrace logger = logging.getLogger(__name__) +port_logger = logging.getLogger('masque.ports') @functools.total_ordering -class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): +class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): """ A point at which a `Device` can be snapped to another `Device`. @@ -91,6 +93,12 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): def copy(self) -> Self: return self.deepcopy() + def __deepcopy__(self, memo: dict | None = None) -> Self: + memo = {} if memo is None else memo + new = copy.copy(self) + new._offset = self._offset.copy() + return new + def get_bounds(self) -> NDArray[numpy.float64]: return numpy.vstack((self.offset, self.offset)) @@ -99,6 +107,27 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): self.ptype = ptype return self + def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: + """ + Mirror the object across a line in the container's coordinate system. + + Note this operation is performed relative to the pattern's origin and modifies the port's offset. + + Args: + axis: Axis to mirror across. 0 mirrors across y=0. 1 mirrors across x=0. + x: Vertical line x=val to mirror across. + y: Horizontal line y=val to mirror across. + + Returns: + self + """ + axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) + self.translate(-pivot) + self.mirror(axis) + self.offset[1 - axis] *= -1 + self.translate(+pivot) + return self + def mirror(self, axis: int = 0) -> Self: if self.rotation is not None: self.rotation *= -1 @@ -114,6 +143,34 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): self.rotation = rotation return self + def describe(self) -> str: + """ + Returns a human-readable description of the port's state including cardinal directions. + """ + deg = numpy.rad2deg(self.rotation) if self.rotation is not None else None + + cardinal = "" + travel_dir = "" + + if self.rotation is not None: + dirs = {0: "East (+x)", 90: "North (+y)", 180: "West (-x)", 270: "South (-y)"} + # normalize to [0, 360) + deg_norm = deg % 360 + + # Find closest cardinal + closest = min(dirs.keys(), key=lambda x: abs((deg_norm - x + 180) % 360 - 180)) + if numpy.isclose((deg_norm - closest + 180) % 360 - 180, 0, atol=1e-3): + cardinal = f" ({dirs[closest]})" + + # Travel direction (rotation + 180) + t_deg = (deg_norm + 180) % 360 + closest_t = min(dirs.keys(), key=lambda x: abs((t_deg - x + 180) % 360 - 180)) + if numpy.isclose((t_deg - closest_t + 180) % 360 - 180, 0, atol=1e-3): + travel_dir = f" (Travel -> {dirs[closest_t]})" + + deg_text = 'any' if deg is None else f'{deg:g}' + return f"pos=({self.x:g}, {self.y:g}), rot={deg_text}{cardinal}{travel_dir}" + def __repr__(self) -> str: if self.rotation is None: rot = 'any' @@ -179,6 +236,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.debug("Port %s: %s", name, self.ports[name].describe()) + + def _log_port_removal(self, name: str) -> None: + """ Log that the named port has been removed """ + port_logger.debug("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 @@ -203,6 +273,12 @@ class PortList(metaclass=ABCMeta): else: # noqa: RET505 return {k: self.ports[k] for k in key} + def measure_travel(self, src: str, dst: str) -> tuple[NDArray[numpy.float64], float | None]: + """ + Convenience wrapper for measuring travel between two named ports. + """ + return self[src].measure_travel(self[dst]) + def __contains__(self, key: str) -> NoReturn: raise NotImplementedError('PortsList.__contains__ is left unimplemented. Use `key in container.ports` instead.') @@ -232,6 +308,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( @@ -253,17 +330,147 @@ class PortList(metaclass=ABCMeta): Returns: self """ + self._rename_ports_impl(mapping, overwrite=overwrite) + return self + + @staticmethod + def _normalize_target_mapping( + ordered_targets: Iterable[tuple[str, str | None]], + explicit_map: Mapping[str, str | None] | None = None, + ) -> dict[str, str | None]: + ordered_targets = list(ordered_targets) + normalized = {} if explicit_map is None else copy.deepcopy(dict(explicit_map)) + winners = { + target: source + for source, target in ordered_targets + if target is not None + } + for source, target in ordered_targets: + if target is not None and winners[target] != source: + normalized[source] = None + return normalized + + def _resolve_insert_mapping( + self, + other_names: Iterable[str], + map_in: Mapping[str, str] | None = None, + map_out: Mapping[str, str | None] | None = None, + *, + allow_conflicts: bool = False, + ) -> tuple[dict[str, str | None], set[str]]: + if map_in is None: + map_in = {} + + normalized_map_out = {} if map_out is None else copy.deepcopy(dict(map_out)) + other_names = list(other_names) + other = set(other_names) + + missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) + if missing_inkeys: + raise PortError(f'`map_in` keys not present in device: {missing_inkeys}') + + missing_invals = set(map_in.values()) - other + if missing_invals: + raise PortError(f'`map_in` values not present in other device: {missing_invals}') + + map_in_counts = Counter(map_in.values()) + conflicts_in = {kk for kk, vv in map_in_counts.items() if vv > 1} + if conflicts_in: + raise PortError(f'Duplicate values in `map_in`: {conflicts_in}') + + missing_outkeys = set(normalized_map_out.keys()) - other + if missing_outkeys: + raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}') + + connected_outkeys = set(normalized_map_out.keys()) & set(map_in.values()) + if connected_outkeys: + raise PortError(f'`map_out` keys conflict with connected ports: {connected_outkeys}') + + orig_remaining = set(self.ports.keys()) - set(map_in.keys()) + connected = set(map_in.values()) + if allow_conflicts: + ordered_targets = [ + (name, normalized_map_out.get(name, name)) + for name in other_names + if name not in connected + ] + normalized_map_out = self._normalize_target_mapping(ordered_targets, normalized_map_out) + final_targets = { + normalized_map_out.get(name, name) + for name in other_names + if name not in connected and normalized_map_out.get(name, name) is not None + } + overwrite_targets = {target for target in final_targets if target in orig_remaining} + return normalized_map_out, overwrite_targets + + other_remaining = other - set(normalized_map_out.keys()) - connected + mapped_vals = set(normalized_map_out.values()) + mapped_vals.discard(None) + + conflicts_final = orig_remaining & (other_remaining | mapped_vals) + if conflicts_final: + raise PortError(f'Device ports conflict with existing ports: {conflicts_final}') + + conflicts_partial = other_remaining & mapped_vals + if conflicts_partial: + raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') + + map_out_counts = Counter(normalized_map_out.values()) + map_out_counts[None] = 0 + conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1} + if conflicts_out: + raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}') + return normalized_map_out, set() + + def _rename_ports_impl( + self, + mapping: Mapping[str, str | None], + *, + overwrite: bool = False, + allow_collisions: bool = False, + ) -> dict[str, str]: if not overwrite: duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) if duplicates: raise PortError(f'Unrenamed ports would be overwritten: {duplicates}') + missing = set(mapping) - set(self.ports) + if missing: + raise PortError(f'Ports to rename were not found: {missing}') + renamed_targets = [vv for vv in mapping.values() if vv is not None] + if not allow_collisions: + duplicate_targets = {vv for vv in renamed_targets if renamed_targets.count(vv) > 1} + if duplicate_targets: + raise PortError(f'Renamed ports would collide: {duplicate_targets}') - renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} - if None in renamed: - del renamed[None] + winners = { + target: source + for source, target in mapping.items() + if target is not None + } + overwritten = { + target + for target, source in winners.items() + if target in self.ports and target not in mapping and target != source + } + for kk, vv in mapping.items(): + if vv is None or vv != kk: + self._log_port_removal(kk) + + source_ports = {kk: self.ports.pop(kk) for kk in mapping} + for target in overwritten: + self.ports.pop(target, None) + + renamed = { + vv: source_ports[kk] + for kk, vv in mapping.items() + if vv is not None and winners[vv] == kk + } self.ports.update(renamed) # type: ignore - return self + + for vv in winners: + self._log_port_update(vv) + return winners def add_port_pair( self, @@ -285,12 +492,16 @@ class PortList(metaclass=ABCMeta): Returns: self """ + if names[0] == names[1]: + raise PortError(f'Port names must be distinct: {names[0]!r}') new_ports = { names[0]: Port(offset, rotation=rotation, ptype=ptype), names[1]: Port(offset, rotation=rotation + pi, ptype=ptype), } 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( @@ -313,13 +524,25 @@ class PortList(metaclass=ABCMeta): Raises: `PortError` if the ports are not properly aligned. """ + if not connections: + raise PortError('Must provide at least one port connection') + missing_a = set(connections) - set(self.ports) + if missing_a: + raise PortError(f'Connection source ports were not found: {missing_a}') + missing_b = set(connections.values()) - set(self.ports) + if missing_b: + raise PortError(f'Connection destination ports were not found: {missing_b}') a_names, b_names = list(zip(*connections.items(), strict=True)) + used_names = list(chain(a_names, b_names)) + duplicate_names = {name for name in used_names if used_names.count(name) > 1} + if duplicate_names: + raise PortError(f'Each port may appear in at most one connection: {duplicate_names}') a_ports = [self.ports[pp] for pp in a_names] b_ports = [self.ports[pp] for pp in b_names] a_types = [pp.ptype for pp in a_ports] b_types = [pp.ptype for pp in b_ports] - type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt) + type_conflicts = numpy.array([not ptypes_compatible(at, bt) for at, bt in zip(a_types, b_types, strict=True)]) if type_conflicts.any(): @@ -360,6 +583,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( @@ -390,45 +614,7 @@ class PortList(metaclass=ABCMeta): `PortError` if there are any duplicate names after `map_in` and `map_out` are applied. """ - if map_in is None: - map_in = {} - - if map_out is None: - map_out = {} - - other = set(other_names) - - missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) - if missing_inkeys: - raise PortError(f'`map_in` keys not present in device: {missing_inkeys}') - - missing_invals = set(map_in.values()) - other - if missing_invals: - raise PortError(f'`map_in` values not present in other device: {missing_invals}') - - missing_outkeys = set(map_out.keys()) - other - if missing_outkeys: - raise PortError(f'`map_out` keys not present in other device: {missing_outkeys}') - - orig_remaining = set(self.ports.keys()) - set(map_in.keys()) - other_remaining = other - set(map_out.keys()) - set(map_in.values()) - mapped_vals = set(map_out.values()) - mapped_vals.discard(None) - - conflicts_final = orig_remaining & (other_remaining | mapped_vals) - if conflicts_final: - raise PortError(f'Device ports conflict with existing ports: {conflicts_final}') - - conflicts_partial = other_remaining & mapped_vals - if conflicts_partial: - raise PortError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') - - map_out_counts = Counter(map_out.values()) - map_out_counts[None] = 0 - conflicts_out = {kk for kk, vv in map_out_counts.items() if vv > 1} - if conflicts_out: - raise PortError(f'Duplicate targets in `map_out`: {conflicts_out}') - + self._resolve_insert_mapping(other_names, map_in, map_out) return self def find_transform( @@ -455,11 +641,10 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `other` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. Non-allowed ptype connections will log a warning. + Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -468,6 +653,8 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ + if not map_in: + raise PortError('Must provide at least one port connection') s_ports = self[map_in.keys()] o_ports = other[map_in.values()] return self.find_port_transform( @@ -506,11 +693,10 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `o_ports` should be rotated. Otherwise, `set_rotation` must remain `None`. - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will log a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. + ok_connections: Set of additional allowed ptype combinations. + Ptypes accepted by the shared compatibility policy are always + allowed. Non-allowed ptype connections will log a warning. + Order is ignored, i.e. `(a, b)` is equivalent to `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -519,6 +705,8 @@ class PortList(metaclass=ABCMeta): The rotation should be performed before the translation. """ + if not map_in: + raise PortError('Must provide at least one port connection') s_offsets = numpy.array([p.offset for p in s_ports.values()]) o_offsets = numpy.array([p.offset for p in o_ports.values()]) s_types = [p.ptype for p in s_ports.values()] @@ -535,8 +723,10 @@ class PortList(metaclass=ABCMeta): o_rotations *= -1 ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]} - type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs) - for st, ot in zip(s_types, o_types, strict=True)]) + type_conflicts = numpy.array([ + not ptypes_compatible(st, ot) and tuple(sorted((st, ot))) not in ok_pairs + for st, ot in zip(s_types, o_types, strict=True) + ]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' for nn, (kk, vv) in enumerate(map_in.items()): @@ -548,7 +738,7 @@ class PortList(metaclass=ABCMeta): rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) if not has_rot.any(): if set_rotation is None: - PortError('Must provide set_rotation if rotation is indeterminate') + raise PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] @@ -573,4 +763,3 @@ class PortList(metaclass=ABCMeta): raise PortError(msg) return translations[0], rotations[0], o_offsets[0] - diff --git a/masque/ref.py b/masque/ref.py index b3a684c..0cc911f 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -15,7 +15,8 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + FlippableImpl, ) @@ -25,8 +26,9 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, + FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + PositionableImpl, RotatableImpl, ScalableImpl, + Copyable, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -42,7 +44,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', 'scale', '_repetition', '_annotations', + '_offset', '_rotation', '_scale', '_repetition', '_annotations', ) _mirrored: bool @@ -84,24 +86,48 @@ class Ref( self.repetition = repetition self.annotations = annotations if annotations is not None else {} + @classmethod + def _from_raw( + cls, + *, + offset: NDArray[numpy.float64], + rotation: float, + mirrored: bool, + scale: float, + repetition: Repetition | None, + annotations: annotations_t | None, + ) -> Self: + new = cls.__new__(cls) + new._offset = offset + new._rotation = rotation % (2 * pi) + new._scale = scale + new._mirrored = mirrored + new._repetition = repetition + new._annotations = annotations + return new + def __copy__(self) -> 'Ref': new = Ref( offset=self.offset.copy(), rotation=self.rotation, scale=self.scale, mirrored=self.mirrored, - repetition=copy.deepcopy(self.repetition), - annotations=copy.deepcopy(self.annotations), + repetition=self.repetition, + annotations=self.annotations, ) return new def __deepcopy__(self, memo: dict | None = None) -> 'Ref': memo = {} if memo is None else memo new = copy.copy(self) - #new.repetition = copy.deepcopy(self.repetition, memo) - #new.annotations = copy.deepcopy(self.annotations, memo) + new._offset = self._offset.copy() + new.repetition = copy.deepcopy(self.repetition, memo) + new.annotations = copy.deepcopy(self.annotations, memo) return new + def copy(self) -> 'Ref': + return self.deepcopy() + def __lt__(self, other: 'Ref') -> bool: if (self.offset != other.offset).any(): return tuple(self.offset) < tuple(other.offset) @@ -116,6 +142,8 @@ class Ref( return annotations_lt(self.annotations, other.annotations) def __eq__(self, other: Any) -> bool: + if type(self) is not type(other): + return False return ( numpy.array_equal(self.offset, other.offset) and self.mirrored == other.mirrored @@ -160,16 +188,16 @@ class Ref( return pattern def rotate(self, rotation: float) -> Self: + """ + Intrinsic transformation: Rotate the target pattern relative to this Ref's + origin. This does NOT affect the repetition grid. + """ self.rotation += rotation - if self.repetition is not None: - self.repetition.rotate(rotation) return self def mirror(self, axis: int = 0) -> Self: self.mirror_target(axis) self.rotation *= -1 - if self.repetition is not None: - self.repetition.mirror(axis) return self def mirror_target(self, axis: int = 0) -> Self: @@ -187,10 +215,11 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 4)) + transforms = numpy.empty((xys.shape[0], 5)) transforms[:, :2] = xys transforms[:, 2] = self.rotation transforms[:, 3] = self.mirrored + transforms[:, 4] = self.scale return transforms def get_bounds_single( @@ -227,7 +256,10 @@ class Ref( bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * self.scale + [self.offset] return bounds - return self.as_pattern(pattern=pattern).get_bounds(library) + + single_ref = self.deepcopy() + single_ref.repetition = None + return single_ref.as_pattern(pattern=pattern).get_bounds(library) def __repr__(self) -> str: rotation = f' r{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' diff --git a/masque/repetition.py b/masque/repetition.py index 5e7a7f0..9e8af26 100644 --- a/masque/repetition.py +++ b/masque/repetition.py @@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A pass @abstractmethod - def __le__(self, other: 'Repetition') -> bool: + def __lt__(self, other: 'Repetition') -> bool: pass @abstractmethod @@ -64,7 +64,7 @@ class Grid(Repetition): _a_count: int """ Number of instances along the direction specified by the `a_vector` """ - _b_vector: NDArray[numpy.float64] | None + _b_vector: NDArray[numpy.float64] """ Vector `[x, y]` specifying a second lattice vector for the grid. Specifies center-to-center spacing between adjacent elements. Can be `None` for a 1D array. @@ -113,6 +113,22 @@ class Grid(Repetition): self.a_count = a_count self.b_count = b_count + @classmethod + def _from_raw( + cls: type[GG], + *, + a_vector: NDArray[numpy.float64], + a_count: int, + b_vector: NDArray[numpy.float64], + b_count: int, + ) -> GG: + new = cls.__new__(cls) + new._a_vector = a_vector + new._b_vector = b_vector + new._a_count = int(a_count) + new._b_count = int(b_count) + return new + @classmethod def aligned( cls: type[GG], @@ -184,6 +200,8 @@ class Grid(Repetition): def a_count(self, val: int) -> None: if val != int(val): raise PatternError('a_count must be convertable to an int!') + if int(val) < 1: + raise PatternError(f'Repetition has too-small a_count: {val}') self._a_count = int(val) # b_count property @@ -195,13 +213,12 @@ class Grid(Repetition): def b_count(self, val: int) -> None: if val != int(val): raise PatternError('b_count must be convertable to an int!') + if int(val) < 1: + raise PatternError(f'Repetition has too-small b_count: {val}') self._b_count = int(val) @property def displacements(self) -> NDArray[numpy.float64]: - if self.b_vector is None: - return numpy.arange(self.a_count)[:, None] * self.a_vector[None, :] - aa, bb = numpy.meshgrid(numpy.arange(self.a_count), numpy.arange(self.b_count), indexing='ij') return (aa.flatten()[:, None] * self.a_vector[None, :] + bb.flatten()[:, None] * self.b_vector[None, :]) # noqa @@ -291,7 +308,7 @@ class Grid(Repetition): return False return True - def __le__(self, other: Repetition) -> bool: + def __lt__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Grid', other) @@ -301,12 +318,8 @@ class Grid(Repetition): return self.b_count < other.b_count if not numpy.array_equal(self.a_vector, other.a_vector): return tuple(self.a_vector) < tuple(other.a_vector) - if self.b_vector is None: - return other.b_vector is not None - if other.b_vector is None: - return False if not numpy.array_equal(self.b_vector, other.b_vector): - return tuple(self.a_vector) < tuple(other.a_vector) + return tuple(self.b_vector) < tuple(other.b_vector) return False @@ -332,7 +345,22 @@ class Arbitrary(Repetition): @displacements.setter def displacements(self, val: ArrayLike) -> None: - vala = numpy.array(val, dtype=float) + try: + vala = numpy.array(val, dtype=float) + except (TypeError, ValueError) as exc: + raise PatternError('displacements must be convertible to an Nx2 ndarray') from exc + + if vala.size == 0: + self._displacements = numpy.empty((0, 2), dtype=float) + return + + if vala.ndim == 1: + if vala.size != 2: + raise PatternError('displacements must be convertible to an Nx2 ndarray') + vala = vala.reshape(1, 2) + elif vala.ndim != 2 or vala.shape[1] != 2: + raise PatternError('displacements must be convertible to an Nx2 ndarray') + order = numpy.lexsort(vala.T[::-1]) # sortrows self._displacements = vala[order] @@ -350,11 +378,11 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if not type(other) is not type(self): + if type(other) is not type(self): return False return numpy.array_equal(self.displacements, other.displacements) - def __le__(self, other: Repetition) -> bool: + def __lt__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Arbitrary', other) @@ -391,7 +419,9 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements[1 - axis] *= -1 + new_displacements = self.displacements.copy() + new_displacements[:, 1 - axis] *= -1 + self.displacements = new_displacements return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -402,6 +432,8 @@ class Arbitrary(Repetition): Returns: `[[x_min, y_min], [x_max, y_max]]` or `None` """ + if self.displacements.size == 0: + return None xy_min = numpy.min(self.displacements, axis=0) xy_max = numpy.max(self.displacements, axis=0) return numpy.array((xy_min, xy_max)) @@ -416,6 +448,5 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements *= c + self.displacements = self.displacements * c return self - diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index fd66c59..ac3a14b 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -11,6 +11,7 @@ from .shape import ( from .polygon import Polygon as Polygon from .poly_collection import PolyCollection as PolyCollection +from .rect_collection import RectCollection as RectCollection from .circle import Circle as Circle from .ellipse import Ellipse as Ellipse from .arc import Arc as Arc diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 480835e..53fadb8 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -1,6 +1,7 @@ from typing import Any, cast import copy import functools +from enum import Enum import numpy from numpy import pi @@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re from ..traits import PositionableImpl +@functools.total_ordering +class ArcAngleRef(Enum): + Center = 'center' + FocusPos = 'focus_pos' + FocusNeg = 'focus_neg' + + def __lt__(self, other: Any) -> bool: + if self.__class__ is not other.__class__: + return self.__class__.__name__ < other.__class__.__name__ + order = { + ArcAngleRef.Center: 0, + ArcAngleRef.FocusPos: 1, + ArcAngleRef.FocusNeg: 2, + } + return order[self] < order[other] + + @functools.total_ordering class Arc(PositionableImpl, Shape): """ - An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its - center. It has a position, two radii, a start and stop angle, a rotation, and a width. + An elliptical arc, formed by cutting off an elliptical ring with two rays. + By default the rays exit from its center, but they can optionally exit from one of the + foci of the nominal ellipse. It has a position, two radii, a start and stop angle, + a rotation, and a width. The radii define an ellipse; the ring is formed with radii +/- width/2. The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius. The start and stop angle are measured counterclockwise from the first (x) radius. """ __slots__ = ( - '_radii', '_angles', '_width', '_rotation', + '_radii', '_angles', '_width', '_rotation', '_angle_ref', # Inherited '_offset', '_repetition', '_annotations', ) @@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape): _width: float """ Width of the arc """ + _angle_ref: ArcAngleRef + """ Origin used by start/stop rays """ + + AngleRef = ArcAngleRef + # radius properties @property def radii(self) -> NDArray[numpy.float64]: @@ -54,8 +79,8 @@ class Arc(PositionableImpl, Shape): val = numpy.array(val, dtype=float).flatten() if not val.size == 2: raise PatternError('Radii must have length 2') - if not val.min() >= 0: - raise PatternError('Radii must be non-negative') + if not val.min() > 0: + raise PatternError('Radii must be positive') self._radii = val @property @@ -64,8 +89,8 @@ class Arc(PositionableImpl, Shape): @radius_x.setter def radius_x(self, val: float) -> None: - if not val >= 0: - raise PatternError('Radius must be non-negative') + if not val > 0: + raise PatternError('Radius must be positive') self._radii[0] = val @property @@ -74,8 +99,8 @@ class Arc(PositionableImpl, Shape): @radius_y.setter def radius_y(self, val: float) -> None: - if not val >= 0: - raise PatternError('Radius must be non-negative') + if not val > 0: + raise PatternError('Radius must be positive') self._radii[1] = val # arc start/stop angle properties @@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape): def stop_angle(self, val: float) -> None: self.angles = (self.angles[0], val) + # Angle reference property + @property + def angle_ref(self) -> ArcAngleRef: + """ + Origin used to interpret start and stop angle rays. + """ + return self._angle_ref + + @angle_ref.setter + def angle_ref(self, val: ArcAngleRef | str) -> None: + self._angle_ref = ArcAngleRef(val) + # Rotation property @property def rotation(self) -> float: @@ -159,27 +196,40 @@ class Arc(PositionableImpl, Shape): rotation: float = 0, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, + angle_ref: ArcAngleRef | str = ArcAngleRef.Center, ) -> None: - if raw: - assert isinstance(radii, numpy.ndarray) - assert isinstance(angles, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) - self._radii = radii - self._angles = angles - self._width = width - self._offset = offset - self._rotation = rotation - self._repetition = repetition - self._annotations = annotations - else: - self.radii = radii - self.angles = angles - self.width = width - self.offset = offset - self.rotation = rotation - self.repetition = repetition - self.annotations = annotations + self.radii = radii + self.angles = angles + self.width = width + self.offset = offset + self.rotation = rotation + self.angle_ref = angle_ref + self.repetition = repetition + self.annotations = annotations + + @classmethod + def _from_raw( + cls, + *, + radii: NDArray[numpy.float64], + angles: NDArray[numpy.float64], + width: float, + offset: NDArray[numpy.float64], + rotation: float, + annotations: annotations_t = None, + repetition: Repetition | None = None, + angle_ref: ArcAngleRef | str = ArcAngleRef.Center, + ) -> 'Arc': + new = cls.__new__(cls) + new._radii = radii + new._angles = angles + new._width = width + new._offset = offset + new._rotation = rotation % (2 * pi) + new._angle_ref = ArcAngleRef(angle_ref) + new._repetition = repetition + new._annotations = annotations + return new def __deepcopy__(self, memo: dict | None = None) -> 'Arc': memo = {} if memo is None else memo @@ -187,6 +237,7 @@ class Arc(PositionableImpl, Shape): new._offset = self._offset.copy() new._radii = self._radii.copy() new._angles = self._angles.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -198,6 +249,7 @@ class Arc(PositionableImpl, Shape): and numpy.array_equal(self.angles, other.angles) and self.width == other.width and self.rotation == other.rotation + and self.angle_ref == other.angle_ref and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) ) @@ -214,6 +266,8 @@ class Arc(PositionableImpl, Shape): return tuple(self.radii) < tuple(other.radii) if not numpy.array_equal(self.angles, other.angles): return tuple(self.angles) < tuple(other.angles) + if self.angle_ref != other.angle_ref: + return self.angle_ref < other.angle_ref if not numpy.array_equal(self.offset, other.offset): return tuple(self.offset) < tuple(other.offset) if self.rotation != other.rotation: @@ -230,6 +284,8 @@ class Arc(PositionableImpl, Shape): if (num_vertices is None) and (max_arclen is None): raise PatternError('Max number of points and arclength left unspecified' + ' (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 @@ -256,29 +312,38 @@ class Arc(PositionableImpl, Shape): return arc_lengths, tt 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: 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_outer = get_arclens(n_pts, *a_ranges[1], dr= wh)[0].sum() 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) - assert max_arclen is not None + if not (numpy.isnan(implied_arclen) or implied_arclen <= 0): + 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]: """ Figure out the parameter values at which we should place vertices to meet the arclength constraint""" dr = -wh if inner else wh - n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int) + n_pts = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen))) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - removable = (numpy.cumsum(arc_lengths) <= max_arclen) - start = 1 + start = 0 while start < arc_lengths.size: - next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? + removable = (numpy.cumsum(arc_lengths[start:]) <= max_arclen) + if not removable.any(): + next_to_keep = start + 1 + else: + next_to_keep = start + numpy.where(removable)[0][-1] + 1 keep.append(next_to_keep) - removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) - start = next_to_keep + 1 + start = next_to_keep + if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) @@ -310,81 +375,59 @@ class Arc(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - """ - Equation for rotated ellipse is - `x = x0 + a * cos(t) * cos(rot) - b * sin(t) * sin(phi)` - `y = y0 + a * cos(t) * sin(rot) + b * sin(t) * cos(rot)` - where `t` is our parameter. - - Differentiating and solving for 0 slope wrt. `t`, we find - `tan(t) = -+ b/a cot(phi)` - where -+ is for x, y cases, so that's where the extrema are. - - If the extrema are innaccessible due to arc constraints, check the arc endpoints instead. - """ a_ranges = cast('_array2x2_t', self._angles_to_parameters()) + sin_r = numpy.sin(self.rotation) + cos_r = numpy.cos(self.rotation) - mins = [] - maxs = [] + def point(rx: float, ry: float, tt: float) -> NDArray[numpy.float64]: + return numpy.array(( + rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r, + rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r, + )) + + def points_in_interval(rx: float, ry: float, a0: float, a1: float) -> list[NDArray[numpy.float64]]: + candidates = [a0, a1] + if rx != 0 and ry != 0: + tx = numpy.arctan2(-ry * sin_r, rx * cos_r) + ty = numpy.arctan2(ry * cos_r, rx * sin_r) + candidates.extend((tx, tx + pi, ty, ty + pi)) + + lo = min(a0, a1) + hi = max(a0, a1) + pts = [] + for base in candidates: + k_min = int(numpy.floor((lo - base) / (2 * pi))) - 1 + k_max = int(numpy.ceil((hi - base) / (2 * pi))) + 1 + for kk in range(k_min, k_max + 1): + tt = base + kk * 2 * pi + if lo <= tt <= hi: + pts.append(point(rx, ry, tt)) + return pts + + pts = [] for aa, sgn in zip(a_ranges, (-1, +1), strict=True): wh = sgn * self.width / 2 rx = self.radius_x + wh ry = self.radius_y + wh - if rx == 0 or ry == 0: - # Single point, at origin - mins.append([0, 0]) - maxs.append([0, 0]) + pts.append(numpy.zeros(2)) continue + pts.extend(points_in_interval(rx, ry, aa[0], aa[1])) - a0, a1 = aa - a0_offset = a0 - (a0 % (2 * pi)) - - sin_r = numpy.sin(self.rotation) - cos_r = numpy.cos(self.rotation) - sin_a = numpy.sin(aa) - cos_a = numpy.cos(aa) - - # Cutoff angles - xpt = (-self.rotation) % (2 * pi) + a0_offset - ypt = (pi / 2 - self.rotation) % (2 * pi) + a0_offset - xnt = (xpt - pi) % (2 * pi) + a0_offset - ynt = (ypt - pi) % (2 * pi) + a0_offset - - # Points along coordinate axes - rx2_inv = 1 / (rx * rx) - ry2_inv = 1 / (ry * ry) - xr = numpy.abs(cos_r * cos_r * rx2_inv + sin_r * sin_r * ry2_inv) ** -0.5 - yr = numpy.abs(-sin_r * -sin_r * rx2_inv + cos_r * cos_r * ry2_inv) ** -0.5 - - # Arc endpoints - xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) - yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) - - # If our arc subtends a coordinate axis, use the extremum along that axis - if a0 < xpt < a1 or a0 < xpt + 2 * pi < a1: - xp = xr - - if a0 < xnt < a1 or a0 < xnt + 2 * pi < a1: - xn = -xr - - if a0 < ypt < a1 or a0 < ypt + 2 * pi < a1: - yp = yr - - if a0 < ynt < a1 or a0 < ynt + 2 * pi < a1: - yn = -yr - - mins.append([xn, yn]) - maxs.append([xp, yp]) - return numpy.vstack((numpy.min(mins, axis=0) + self.offset, - numpy.max(maxs, axis=0) + self.offset)) + all_pts = numpy.asarray(pts) + self.offset + return numpy.vstack((numpy.min(all_pts, axis=0), + numpy.max(all_pts, axis=0))) def rotate(self, theta: float) -> 'Arc': self.rotation += theta return self def mirror(self, axis: int = 0) -> 'Arc': - self.offset[axis - 1] *= -1 + if self.angle_ref != ArcAngleRef.Center: + x_major = self.radius_x > self.radius_y + y_major = self.radius_y > self.radius_x + if (axis == 0 and y_major) or (axis == 1 and x_major): + self._swap_focus_ref() self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 @@ -396,6 +439,7 @@ class Arc(PositionableImpl, Shape): return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + angle_ref = self.angle_ref if self.radius_x < self.radius_y: radii = self.radii / self.radius_x scale = self.radius_x @@ -406,23 +450,26 @@ class Arc(PositionableImpl, Shape): scale = self.radius_y rotation = self.rotation + pi / 2 angles = self.angles - pi / 2 + angle_ref = _swapped_focus_ref(angle_ref) delta_angle = angles[1] - angles[0] start_angle = angles[0] % (2 * pi) if start_angle >= pi: start_angle -= pi rotation += pi + angle_ref = _swapped_focus_ref(angle_ref) norm_angles = (start_angle, start_angle + delta_angle) rotation %= 2 * pi width = self.width - return ((type(self), radii, norm_angles, width / norm_value), + return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value), (self.offset, scale / norm_value, rotation, False), lambda: Arc( radii=radii * norm_value, angles=norm_angles, width=width * norm_value, + angle_ref=angle_ref, )) def get_cap_edges(self) -> NDArray[numpy.float64]: @@ -433,27 +480,16 @@ class Arc(PositionableImpl, Shape): [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. ``` """ - a_ranges = cast('_array2x2_t', self._angles_to_parameters()) + a_ranges = self._angles_to_parameters() - mins = [] - maxs = [] - for aa, sgn in zip(a_ranges, (-1, +1), strict=True): - wh = sgn * self.width / 2 - rx = self.radius_x + wh - ry = self.radius_y + wh - - sin_r = numpy.sin(self.rotation) - cos_r = numpy.cos(self.rotation) - sin_a = numpy.sin(aa) - cos_a = numpy.cos(aa) - - # arc endpoints - xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a) - yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a) - - mins.append([xn, yn]) - maxs.append([xp, yp]) - return numpy.array([mins, maxs]) + self.offset + cuts = [] + for index in range(2): + edge = [] + for aa, sgn in zip(a_ranges, (-1, +1), strict=True): + wh = sgn * self.width / 2 + edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index])) + cuts.append(edge) + return numpy.array(cuts) + self.offset def _angles_to_parameters(self) -> NDArray[numpy.float64]: """ @@ -464,22 +500,111 @@ class Arc(PositionableImpl, Shape): `[[a_min_inner, a_max_inner], [a_min_outer, a_max_outer]]` """ aa = [] + d_angle = self.angles[1] - self.angles[0] + if abs(d_angle) >= 2 * pi: + # Full ring + return numpy.tile([0, 2 * pi], (2, 1)).astype(float) + for sgn in (-1, +1): wh = sgn * self.width / 2.0 rx = self.radius_x + wh ry = self.radius_y + wh - a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles) - sign = numpy.sign(self.angles[1] - self.angles[0]) + a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles) + sign = numpy.sign(d_angle) if sign != numpy.sign(a1 - a0): a1 += sign * 2 * pi aa.append((a0, a1)) return numpy.array(aa, dtype=float) + def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float: + """ + Convert an angle-reference ray to the ellipse parameter for one boundary edge. + + Center-referenced arcs convert the ray angle from polar coordinates about the origin. + Focus-referenced arcs solve the forward ray/ellipse intersection from the selected + nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`. + """ + if self.angle_ref == ArcAngleRef.Center: + return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle)) + + focus = self._focus_point() + if rx <= 0 or ry <= 0: + raise PatternError('Focus-referenced arc boundary radii must be positive') + + fx, fy = focus + origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry) + if origin_position >= 1: + raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses') + + dx = numpy.cos(angle) + dy = numpy.sin(angle) + aa = dx * dx / (rx * rx) + dy * dy / (ry * ry) + bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry)) + cc = origin_position - 1 + determinant = bb * bb - 4 * aa * cc + if determinant < 0: + raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse') + + roots = numpy.array(( + (-bb - numpy.sqrt(determinant)) / (2 * aa), + (-bb + numpy.sqrt(determinant)) / (2 * aa), + )) + positive_roots = roots[roots > 0] + if positive_roots.size != 1: + raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection') + + point = focus + positive_roots[0] * numpy.array((dx, dy)) + return numpy.arctan2(point[1] / ry, point[0] / rx) + + def _focus_point(self) -> NDArray[numpy.float64]: + """ + Return the selected nominal focus in the arc's unrotated local coordinates. + + `FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles + have coincident foci, so both focus modes intentionally collapse to the center. + """ + if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y: + return numpy.zeros(2) + + sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1 + if self.radius_x > self.radius_y: + return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0)) + return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x))) + + def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]: + """ + Return a rotated local-space point on a boundary ellipse, before applying offset. + """ + sin_r = numpy.sin(self.rotation) + cos_r = numpy.cos(self.rotation) + return numpy.array(( + rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r, + rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r, + )) + + def _swap_focus_ref(self) -> None: + """ + Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged. + """ + self.angle_ref = _swapped_focus_ref(self.angle_ref) + def __repr__(self) -> str: angles = f' a°{numpy.rad2deg(self.angles)}' rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' - return f'' + angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else '' + return f'' + + +def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef: + """ + Return the opposite focus reference, or center for center-referenced arcs. + """ + if angle_ref == ArcAngleRef.FocusPos: + return ArcAngleRef.FocusNeg + if angle_ref == ArcAngleRef.FocusNeg: + return ArcAngleRef.FocusPos + return angle_ref _array2x2_t = tuple[tuple[float, float], tuple[float, float]] diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index b20a681..d7591db 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -50,24 +50,33 @@ class Circle(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: - if raw: - assert isinstance(offset, numpy.ndarray) - self._radius = radius - self._offset = offset - self._repetition = repetition - self._annotations = annotations - else: - self.radius = radius - self.offset = offset - self.repetition = repetition - self.annotations = annotations + self.radius = radius + self.offset = offset + self.repetition = repetition + self.annotations = annotations + + @classmethod + def _from_raw( + cls, + *, + radius: float, + offset: NDArray[numpy.float64], + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> 'Circle': + new = cls.__new__(cls) + new._radius = radius + new._offset = offset + new._repetition = repetition + new._annotations = annotations + return new def __deepcopy__(self, memo: dict | None = None) -> 'Circle': memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -108,7 +117,7 @@ class Circle(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [2 * pi * self.radius / max_arclen] - num_vertices = int(round(max(n))) + num_vertices = max(3, int(round(max(n)))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) xs = numpy.cos(thetas) * self.radius ys = numpy.sin(thetas) * self.radius @@ -124,7 +133,6 @@ class Circle(PositionableImpl, Shape): return self def mirror(self, axis: int = 0) -> 'Circle': # noqa: ARG002 (axis unused) - self.offset[axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Circle': diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 6029f2f..52a3297 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -42,7 +42,7 @@ class Ellipse(PositionableImpl, Shape): @radii.setter def radii(self, val: ArrayLike) -> None: - val = numpy.array(val).flatten() + val = numpy.array(val, dtype=float).flatten() if not val.size == 2: raise PatternError('Radii must have length 2') if not val.min() >= 0: @@ -95,28 +95,37 @@ class Ellipse(PositionableImpl, Shape): rotation: float = 0, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: - if raw: - assert isinstance(radii, numpy.ndarray) - assert isinstance(offset, numpy.ndarray) - self._radii = radii - self._offset = offset - self._rotation = rotation - self._repetition = repetition - self._annotations = annotations - else: - self.radii = radii - self.offset = offset - self.rotation = rotation - self.repetition = repetition - self.annotations = annotations + self.radii = radii + self.offset = offset + self.rotation = rotation + self.repetition = repetition + self.annotations = annotations + + @classmethod + def _from_raw( + cls, + *, + radii: NDArray[numpy.float64], + offset: NDArray[numpy.float64], + rotation: float, + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._radii = radii + new._offset = offset + new._rotation = rotation % pi + new._repetition = repetition + new._annotations = annotations + return new def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() new._radii = self._radii.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -168,7 +177,7 @@ class Ellipse(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [perimeter / max_arclen] - num_vertices = int(round(max(n))) + num_vertices = max(3, int(round(max(n)))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas)) @@ -180,16 +189,19 @@ class Ellipse(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii) - return numpy.vstack((self.offset - rot_radii[0], - self.offset + rot_radii[1])) + cos_r = numpy.cos(self.rotation) + sin_r = numpy.sin(self.rotation) + x_extent = numpy.sqrt((self.radius_x * cos_r) ** 2 + (self.radius_y * sin_r) ** 2) + y_extent = numpy.sqrt((self.radius_x * sin_r) ** 2 + (self.radius_y * cos_r) ** 2) + extents = numpy.array((x_extent, y_extent)) + return numpy.vstack((self.offset - extents, + self.offset + extents)) def rotate(self, theta: float) -> Self: self.rotation += theta return self def mirror(self, axis: int = 0) -> Self: - self.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi return self @@ -207,7 +219,7 @@ class Ellipse(PositionableImpl, Shape): radii = self.radii[::-1] / self.radius_y scale = self.radius_y angle = (self.rotation + pi / 2) % pi - return ((type(self), radii), + return ((type(self), tuple(radii.tolist())), (self.offset, scale / norm_value, angle, False), lambda: Ellipse(radii=radii * norm_value)) diff --git a/masque/shapes/path.py b/masque/shapes/path.py index 7778428..a1e04af 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -24,7 +24,16 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - return self.value == other.value + if self.__class__ is not other.__class__: + return self.__class__.__name__ < other.__class__.__name__ + # Order: Flush, Square, Circle, SquareCustom + order = { + PathCap.Flush: 0, + PathCap.Square: 1, + PathCap.Circle: 2, + PathCap.SquareCustom: 3, + } + return order[self] < order[other] @functools.total_ordering @@ -79,10 +88,10 @@ class Path(Shape): def cap(self, val: PathCap) -> None: self._cap = PathCap(val) if self.cap != PathCap.SquareCustom: - self.cap_extensions = None - elif self.cap_extensions is None: + self._cap_extensions = None + elif self._cap_extensions is None: # just got set to SquareCustom - self.cap_extensions = numpy.zeros(2) + self._cap_extensions = numpy.zeros(2) # cap_extensions property @property @@ -192,37 +201,50 @@ class Path(Shape): rotation: float = 0, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: self._cap_extensions = None # Since .cap setter might access it - if raw: - assert isinstance(vertices, numpy.ndarray) - assert isinstance(cap_extensions, numpy.ndarray) or cap_extensions is None - self._vertices = vertices - self._repetition = repetition - self._annotations = annotations - self._width = width - self._cap = cap - self._cap_extensions = cap_extensions + self.vertices = vertices + self.repetition = repetition + self.annotations = annotations + self._cap = cap + if cap == PathCap.SquareCustom and cap_extensions is None: + self._cap_extensions = numpy.zeros(2) else: - self.vertices = vertices - self.repetition = repetition - self.annotations = annotations - self.width = width - self.cap = cap self.cap_extensions = cap_extensions + self.width = width if rotation: self.rotate(rotation) if numpy.any(offset): self.translate(offset) + @classmethod + def _from_raw( + cls, + *, + vertices: NDArray[numpy.float64], + width: float, + cap: PathCap, + cap_extensions: NDArray[numpy.float64] | None = None, + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._vertices = vertices + new._width = width + new._cap = cap + new._cap_extensions = cap_extensions + new._repetition = repetition + new._annotations = annotations + return new + def __deepcopy__(self, memo: dict | None = None) -> 'Path': memo = {} if memo is None else memo new = copy.copy(self) new._vertices = self._vertices.copy() new._cap = copy.deepcopy(self._cap, memo) new._cap_extensions = copy.deepcopy(self._cap_extensions, memo) + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -253,6 +275,14 @@ class Path(Shape): if self.cap_extensions is None: return True return tuple(self.cap_extensions) < tuple(other.cap_extensions) + if not numpy.array_equal(self.vertices, other.vertices): + min_len = min(self.vertices.shape[0], other.vertices.shape[0]) + eq_mask = self.vertices[:min_len] != other.vertices[:min_len] + eq_lt = self.vertices[:min_len] < other.vertices[:min_len] + eq_lt_masked = eq_lt[eq_mask] + if eq_lt_masked.size > 0: + return eq_lt_masked.flat[0] + return self.vertices.shape[0] < other.vertices.shape[0] if self.repetition != other.repetition: return rep2key(self.repetition) < rep2key(other.repetition) return annotations_lt(self.annotations, other.annotations) @@ -303,9 +333,30 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False) + v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) dv = numpy.diff(v, axis=0) - dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] + norms = numpy.sqrt((dv * dv).sum(axis=1)) + + # Filter out zero-length segments if any remained after remove_colinear_vertices + valid = (norms > 1e-18) + if not numpy.all(valid): + # This shouldn't happen much if remove_colinear_vertices is working + v = v[numpy.append(valid, True)] + dv = numpy.diff(v, axis=0) + norms = norms[valid] + + if dv.shape[0] == 0: + # All vertices were the same. It's a point. + if self.width == 0: + return [Polygon(vertices=numpy.zeros((3, 2)))] # Area-less degenerate + if self.cap == PathCap.Circle: + return Circle(radius=self.width / 2, offset=v[0]).to_polygons(num_vertices=num_vertices, max_arclen=max_arclen) + if self.cap == PathCap.Square: + return [Polygon.square(side_length=self.width, offset=v[0])] + # Flush or CustomSquare + return [Polygon(vertices=numpy.zeros((3, 2)))] + + dvdir = dv / norms[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -324,11 +375,21 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] - rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] + try: + # Vectorized solve for all intersections + # solve supports broadcasting: As (N-2, 2, 2), bs (N-2, 2, 1) + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0, 0] + except numpy.linalg.LinAlgError: + # Fallback to slower lstsq if some segments are parallel (singular matrix) + rp = numpy.zeros(As.shape[0]) + rn = numpy.zeros(As.shape[0]) + for ii in range(As.shape[0]): + rp[ii] = numpy.linalg.lstsq(As[ii], bs[ii, :, None], rcond=1e-12)[0][0, 0] + rn[ii] = numpy.linalg.lstsq(As[ii], ds[ii, :, None], rcond=1e-12)[0][0, 0] - intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn * dv[:-1] - perp[:-1] + intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] + intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] towards_perp = (dv[1:] * perp[:-1]).sum(axis=1) > 0 # path bends towards previous perp? # straight = (dv[1:] * perp[:-1]).sum(axis=1) == 0 # path is straight @@ -396,12 +457,14 @@ class Path(Shape): return self def mirror(self, axis: int = 0) -> 'Path': - self.vertices[:, axis - 1] *= -1 + self.vertices[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> 'Path': self.vertices *= c self.width *= c + if self.cap_extensions is not None: + self.cap_extensions *= c return self def normalized_form(self, norm_value: float) -> normalized_shape_tuple: @@ -418,21 +481,22 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min = rotated_vertices[:, 0].argmin() - if not is_scalar(x_min): - y_min = rotated_vertices[x_min, 1].argmin() - x_min = cast('Sequence', x_min)[y_min] - reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) + # Canonical ordering for open paths: pick whichever of (v) or (v[::-1]) is smaller + if tuple(rotated_vertices.flat) > tuple(rotated_vertices[::-1].flat): + reordered_vertices = rotated_vertices[::-1] + else: + reordered_vertices = rotated_vertices width0 = self.width / norm_value + cap_extensions0 = None if self.cap_extensions is None else tuple(float(v) / norm_value for v in self.cap_extensions) - return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap, cap_extensions0), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=self.width * norm_value, + width=width0 * norm_value, cap=self.cap, + cap_extensions=None if cap_extensions0 is None else tuple(v * norm_value for v in cap_extensions0), )) def clean_vertices(self) -> 'Path': @@ -462,7 +526,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) return self def _calculate_cap_extensions(self) -> NDArray[numpy.float64]: diff --git a/masque/shapes/poly_collection.py b/masque/shapes/poly_collection.py index 6048f24..f1c840a 100644 --- a/masque/shapes/poly_collection.py +++ b/masque/shapes/poly_collection.py @@ -34,7 +34,7 @@ class PolyCollection(Shape): _vertex_lists: NDArray[numpy.float64] """ 2D NDArray ((N+M+...) x 2) of vertices `[[xa0, ya0], [xa1, ya1], ..., [xb0, yb0], [xb1, yb1], ... ]` """ - _vertex_offsets: NDArray[numpy.intp] + _vertex_offsets: NDArray[numpy.integer[Any]] """ 1D NDArray specifying the starting offset for each polygon """ @property @@ -45,7 +45,7 @@ class PolyCollection(Shape): return self._vertex_lists @property - def vertex_offsets(self) -> NDArray[numpy.intp]: + def vertex_offsets(self) -> NDArray[numpy.integer[Any]]: """ Starting offset (in `vertex_lists`) for each polygon """ @@ -56,12 +56,14 @@ class PolyCollection(Shape): """ Iterator which provides slices which index vertex_lists """ + if self._vertex_offsets.size == 0: + return for ii, ff in zip( self._vertex_offsets, - chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), + chain(self._vertex_offsets[1:], [self._vertex_lists.shape[0]]), strict=True, ): - yield slice(ii, ff) + yield slice(int(ii), int(ff)) @property def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: @@ -82,7 +84,7 @@ class PolyCollection(Shape): def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('PolyCollection offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -98,30 +100,38 @@ class PolyCollection(Shape): rotation: float = 0.0, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: - if raw: - assert isinstance(vertex_lists, numpy.ndarray) - assert isinstance(vertex_offsets, numpy.ndarray) - self._vertex_lists = vertex_lists - self._vertex_offsets = vertex_offsets - self._repetition = repetition - self._annotations = annotations - else: - self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) - self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) - self.repetition = repetition - self.annotations = annotations + self._vertex_lists = numpy.asarray(vertex_lists, dtype=float) + self._vertex_offsets = numpy.asarray(vertex_offsets, dtype=numpy.intp) + self.repetition = repetition + self.annotations = annotations if rotation: self.rotate(rotation) if numpy.any(offset): self.translate(offset) + @classmethod + def _from_raw( + cls, + *, + vertex_lists: NDArray[numpy.float64], + vertex_offsets: NDArray[numpy.integer[Any]], + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._vertex_lists = vertex_lists + new._vertex_offsets = vertex_offsets + new._repetition = repetition + new._annotations = annotations + return new + def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._vertex_lists = self._vertex_lists.copy() new._vertex_offsets = self._vertex_offsets.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -129,7 +139,7 @@ class PolyCollection(Shape): return ( type(self) is type(other) and numpy.array_equal(self._vertex_lists, other._vertex_lists) - and numpy.array_equal(self._vertex_offsets, other._vertex_offsets) + and numpy.array_equal(self.vertex_offsets, other.vertex_offsets) and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) ) @@ -168,7 +178,9 @@ class PolyCollection(Shape): annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] - def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition + def get_bounds_single(self) -> NDArray[numpy.float64] | None: # TODO note shape get_bounds doesn't include repetition + if self._vertex_lists.size == 0: + return None return numpy.vstack((numpy.min(self._vertex_lists, axis=0), numpy.max(self._vertex_lists, axis=0))) @@ -179,7 +191,7 @@ class PolyCollection(Shape): return self def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, axis - 1] *= -1 + self._vertex_lists[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> Self: @@ -210,11 +222,11 @@ class PolyCollection(Shape): # TODO: normalize mirroring? - return ((type(self), rotated_vertices.data.tobytes() + self._vertex_offsets.tobytes()), + return ((type(self), rotated_vertices.data.tobytes() + self.vertex_offsets.tobytes()), (offset, scale / norm_value, rotation, False), lambda: PolyCollection( vertex_lists=rotated_vertices * norm_value, - vertex_offsets=self._vertex_offsets, + vertex_offsets=self.vertex_offsets.copy(), ), ) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index c8c3ddd..06e5c2b 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self +from typing import Any, cast, TYPE_CHECKING, Self, Literal import copy import functools @@ -96,11 +96,11 @@ class Polygon(Shape): @offset.setter def offset(self, val: ArrayLike) -> None: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('Polygon offset is forced to (0, 0)') def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Path offset is forced to (0, 0)') + raise PatternError('Polygon offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -115,26 +115,34 @@ class Polygon(Shape): rotation: float = 0.0, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: - if raw: - assert isinstance(vertices, numpy.ndarray) - self._vertices = vertices - self._repetition = repetition - self._annotations = annotations - else: - self.vertices = vertices - self.repetition = repetition - self.annotations = annotations + self.vertices = vertices + self.repetition = repetition + self.annotations = annotations if rotation: self.rotate(rotation) if numpy.any(offset): self.translate(offset) + @classmethod + def _from_raw( + cls, + *, + vertices: NDArray[numpy.float64], + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._vertices = vertices + new._repetition = repetition + new._annotations = annotations + return new + def __deepcopy__(self, memo: dict | None = None) -> 'Polygon': memo = {} if memo is None else memo new = copy.copy(self) new._vertices = self._vertices.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -321,7 +329,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) return poly @staticmethod @@ -394,7 +402,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, axis - 1] *= -1 + self.vertices[:, 1 - axis] *= -1 return self def scale_by(self, c: float) -> 'Polygon': @@ -417,11 +425,15 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - x_min = rotated_vertices[:, 0].argmin() - if not is_scalar(x_min): - y_min = rotated_vertices[x_min, 1].argmin() - x_min = cast('Sequence', x_min)[y_min] - reordered_vertices = numpy.roll(rotated_vertices, -x_min, axis=0) + x_min_val = rotated_vertices[:, 0].min() + x_min_inds = numpy.where(rotated_vertices[:, 0] == x_min_val)[0] + if x_min_inds.size > 1: + y_min_val = rotated_vertices[x_min_inds, 1].min() + tie_breaker = numpy.where(rotated_vertices[x_min_inds, 1] == y_min_val)[0][0] + start_ind = x_min_inds[tie_breaker] + else: + start_ind = x_min_inds[0] + reordered_vertices = numpy.roll(rotated_vertices, -start_ind, axis=0) # TODO: normalize mirroring? @@ -462,3 +474,23 @@ class Polygon(Shape): def __repr__(self) -> str: centroid = self.vertices.mean(axis=0) return f'' + + def boolean( + self, + other: Any, + operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', + scale: float = 1e6, + ) -> list['Polygon']: + """ + Perform a boolean operation using this polygon as the subject. + + Args: + other: Polygon, Iterable[Polygon], or raw vertices acting as the CLIP. + operation: 'union', 'intersection', 'difference', 'xor'. + scale: Scaling factor for integer conversion. + + Returns: + A list of resulting Polygons. + """ + from ..utils.boolean import boolean #noqa: PLC0415 + return boolean([self], other, operation=operation, scale=scale) diff --git a/masque/shapes/rect_collection.py b/masque/shapes/rect_collection.py new file mode 100644 index 0000000..eaf028f --- /dev/null +++ b/masque/shapes/rect_collection.py @@ -0,0 +1,249 @@ +from typing import Any, cast, Self +from collections.abc import Iterator +import copy +import functools + +import numpy +from numpy import pi +from numpy.typing import NDArray, ArrayLike + +from . import Shape, normalized_shape_tuple +from .polygon import Polygon +from ..error import PatternError +from ..repetition import Repetition +from ..utils import annotations_lt, annotations_eq, rep2key, annotations_t + + +def _normalize_rects(rects: ArrayLike) -> NDArray[numpy.float64]: + arr = numpy.asarray(rects, dtype=float) + if arr.ndim != 2 or arr.shape[1] != 4: + raise PatternError('Rectangles must be an Nx4 array of [xmin, ymin, xmax, ymax]') + if numpy.any(arr[:, 0] > arr[:, 2]) or numpy.any(arr[:, 1] > arr[:, 3]): + raise PatternError('Rectangles must satisfy xmin <= xmax and ymin <= ymax') + if arr.shape[0] <= 1: + return arr + order = numpy.lexsort((arr[:, 3], arr[:, 2], arr[:, 1], arr[:, 0])) + return arr[order] + + +def _renormalize_rects_in_place(rects: NDArray[numpy.float64]) -> None: + x0 = numpy.minimum(rects[:, 0], rects[:, 2]) + x1 = numpy.maximum(rects[:, 0], rects[:, 2]) + y0 = numpy.minimum(rects[:, 1], rects[:, 3]) + y1 = numpy.maximum(rects[:, 1], rects[:, 3]) + rects[:, 0] = x0 + rects[:, 1] = y0 + rects[:, 2] = x1 + rects[:, 3] = y1 + + +@functools.total_ordering +class RectCollection(Shape): + """ + A collection of axis-aligned rectangles, stored as an Nx4 array of + `[xmin, ymin, xmax, ymax]` rows. + """ + __slots__ = ( + '_rects', + '_repetition', '_annotations', + ) + + _rects: NDArray[numpy.float64] + + @property + def rects(self) -> NDArray[numpy.float64]: + return self._rects + + @rects.setter + def rects(self, val: ArrayLike) -> None: + self._rects = _normalize_rects(val) + + @property + def offset(self) -> NDArray[numpy.float64]: + return numpy.zeros(2) + + @offset.setter + def offset(self, val: ArrayLike) -> None: + if numpy.any(val): + raise PatternError('RectCollection offset is forced to (0, 0)') + + def set_offset(self, val: ArrayLike) -> Self: + if numpy.any(val): + raise PatternError('RectCollection offset is forced to (0, 0)') + return self + + def translate(self, offset: ArrayLike) -> Self: + delta = numpy.asarray(offset, dtype=float).reshape(2) + self._rects[:, [0, 2]] += delta[0] + self._rects[:, [1, 3]] += delta[1] + return self + + def __init__( + self, + rects: ArrayLike, + *, + offset: ArrayLike = (0.0, 0.0), + rotation: float = 0.0, + repetition: Repetition | None = None, + annotations: annotations_t = None, + ) -> None: + self.rects = rects + self.repetition = repetition + self.annotations = annotations + if rotation: + self.rotate(rotation) + if numpy.any(offset): + self.translate(offset) + + @classmethod + def _from_raw( + cls, + *, + rects: NDArray[numpy.float64], + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._rects = rects + new._repetition = repetition + new._annotations = annotations + return new + + @property + def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: + for rect in self._rects: + xmin, ymin, xmax, ymax = rect + yield numpy.array([ + [xmin, ymin], + [xmin, ymax], + [xmax, ymax], + [xmax, ymin], + ], dtype=float) + + def __deepcopy__(self, memo: dict | None = None) -> Self: + memo = {} if memo is None else memo + new = copy.copy(self) + new._rects = self._rects.copy() + new._repetition = copy.deepcopy(self._repetition, memo) + new._annotations = copy.deepcopy(self._annotations) + return new + + def _sorted_rects(self) -> NDArray[numpy.float64]: + if self._rects.shape[0] <= 1: + return self._rects + order = numpy.lexsort((self._rects[:, 3], self._rects[:, 2], self._rects[:, 1], self._rects[:, 0])) + return self._rects[order] + + def __eq__(self, other: Any) -> bool: + return ( + type(self) is type(other) + and numpy.array_equal(self._sorted_rects(), other._sorted_rects()) + and self.repetition == other.repetition + and annotations_eq(self.annotations, other.annotations) + ) + + def __lt__(self, other: Shape) -> bool: + if type(self) is not type(other): + if repr(type(self)) != repr(type(other)): + return repr(type(self)) < repr(type(other)) + return id(type(self)) < id(type(other)) + + other = cast('RectCollection', other) + self_rects = self._sorted_rects() + other_rects = other._sorted_rects() + if not numpy.array_equal(self_rects, other_rects): + min_len = min(self_rects.shape[0], other_rects.shape[0]) + eq_mask = self_rects[:min_len] != other_rects[:min_len] + eq_lt = self_rects[:min_len] < other_rects[:min_len] + eq_lt_masked = eq_lt[eq_mask] + if eq_lt_masked.size > 0: + return bool(eq_lt_masked.flat[0]) + return self_rects.shape[0] < other_rects.shape[0] + if self.repetition != other.repetition: + return rep2key(self.repetition) < rep2key(other.repetition) + return annotations_lt(self.annotations, other.annotations) + + def to_polygons( + self, + num_vertices: int | None = None, # unused # noqa: ARG002 + max_arclen: float | None = None, # unused # noqa: ARG002 + ) -> list[Polygon]: + return [ + Polygon( + vertices=vertices, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), + ) + for vertices in self.polygon_vertices + ] + + def get_bounds_single(self) -> NDArray[numpy.float64] | None: + if self._rects.size == 0: + return None + mins = self._rects[:, :2].min(axis=0) + maxs = self._rects[:, 2:].max(axis=0) + return numpy.vstack((mins, maxs)) + + def rotate(self, theta: float) -> Self: + quarter_turns = int(numpy.rint(theta / (pi / 2))) + if not numpy.isclose(theta, quarter_turns * (pi / 2)): + raise PatternError('RectCollection only supports Manhattan rotations') + turns = quarter_turns % 4 + if turns == 0 or self._rects.size == 0: + return self + + corners = numpy.stack(( + self._rects[:, [0, 1]], + self._rects[:, [0, 3]], + self._rects[:, [2, 3]], + self._rects[:, [2, 1]], + ), axis=1) + flat = corners.reshape(-1, 2) + if turns == 1: + rotated = numpy.column_stack((-flat[:, 1], flat[:, 0])) + elif turns == 2: + rotated = -flat + else: + rotated = numpy.column_stack((flat[:, 1], -flat[:, 0])) + corners = rotated.reshape(corners.shape) + self._rects[:, 0] = corners[:, :, 0].min(axis=1) + self._rects[:, 1] = corners[:, :, 1].min(axis=1) + self._rects[:, 2] = corners[:, :, 0].max(axis=1) + self._rects[:, 3] = corners[:, :, 1].max(axis=1) + return self + + def mirror(self, axis: int = 0) -> Self: + if axis not in (0, 1): + raise PatternError('Axis must be 0 or 1') + if axis == 0: + self._rects[:, [1, 3]] *= -1 + else: + self._rects[:, [0, 2]] *= -1 + _renormalize_rects_in_place(self._rects) + return self + + def scale_by(self, c: float) -> Self: + self._rects *= c + _renormalize_rects_in_place(self._rects) + return self + + def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + rects = self._sorted_rects() + centers = 0.5 * (rects[:, :2] + rects[:, 2:]) + offset = centers.mean(axis=0) + zeroed = rects.copy() + zeroed[:, [0, 2]] -= offset[0] + zeroed[:, [1, 3]] -= offset[1] + normed = zeroed / norm_value + return ( + (type(self), normed.data.tobytes()), + (offset, 1.0, 0.0, False), + lambda: RectCollection(rects=normed * norm_value), + ) + + def __repr__(self) -> str: + if self._rects.size == 0: + return '' + centers = 0.5 * (self._rects[:, :2] + self._rects[:, 2:]) + centroid = centers.mean(axis=0) + return f'' diff --git a/masque/shapes/shape.py b/masque/shapes/shape.py index 90bca2b..efc0859 100644 --- a/masque/shapes/shape.py +++ b/masque/shapes/shape.py @@ -6,8 +6,8 @@ import numpy from numpy.typing import NDArray, ArrayLike from ..traits import ( - Rotatable, Mirrorable, Copyable, Scalable, - Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Copyable, Scalable, FlippableImpl, + PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,8 +26,9 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, - PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): +class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, + Copyable, Scalable, + metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ @@ -73,7 +74,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, pass @abstractmethod - def normalized_form(self, norm_value: int) -> normalized_shape_tuple: + def normalized_form(self, norm_value: float) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. @@ -120,7 +121,7 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon + from . import Polygon #noqa: PLC0415 gx = numpy.unique(grid_x) gy = numpy.unique(grid_y) @@ -138,22 +139,24 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, for v, v_next in zip(p_verts, numpy.roll(p_verts, -1, axis=0), strict=True): dv = v_next - v - # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape + # Find x-index bounds for the line gxi_range = numpy.digitize([v[0], v_next[0]], gx) - gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1) - gxi_max = numpy.max(gxi_range).clip(0, len(gx)) + gxi_min = int(numpy.min(gxi_range - 1).clip(0, len(gx) - 1)) + gxi_max = int(numpy.max(gxi_range).clip(0, len(gx))) - err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) - err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) + if gxi_min < len(gx) - 1: + err_xmin = (min(v[0], v_next[0]) - gx[gxi_min]) / (gx[gxi_min + 1] - gx[gxi_min]) + if err_xmin >= 0.5: + gxi_min += 1 - if err_xmin >= 0.5: - gxi_min += 1 - if err_xmax >= 0.5: - gxi_max += 1 + if gxi_max > 0 and gxi_max < len(gx): + err_xmax = (max(v[0], v_next[0]) - gx[gxi_max - 1]) / (gx[gxi_max] - gx[gxi_max - 1]) + if err_xmax >= 0.5: + gxi_max += 1 if abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope - xi = [gxi_min, gxi_max - 1] + xi = [gxi_min, max(gxi_min, gxi_max - 1)] ys = numpy.array([v[1], v_next[1]]) yi = numpy.digitize(ys, gy).clip(1, len(gy) - 1) err_y = (ys - gy[yi]) / (gy[yi] - gy[yi - 1]) @@ -249,9 +252,9 @@ class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon - import skimage.measure # type: ignore - import float_raster + from . import Polygon #noqa: PLC0415 + import skimage.measure #noqa: PLC0415 + import float_raster #noqa: PLC0415 grx = numpy.unique(grid_x) gry = numpy.unique(grid_y) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 78632f6..c078879 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -70,31 +70,48 @@ class Text(PositionableImpl, RotatableImpl, Shape): *, offset: ArrayLike = (0.0, 0.0), rotation: float = 0.0, + mirrored: bool = False, repetition: Repetition | None = None, annotations: annotations_t = None, - raw: bool = False, ) -> None: - if raw: - assert isinstance(offset, numpy.ndarray) - self._offset = offset - self._string = string - self._height = height - self._rotation = rotation - self._repetition = repetition - self._annotations = annotations - else: - self.offset = offset - self.string = string - self.height = height - self.rotation = rotation - self.repetition = repetition - self.annotations = annotations + self.offset = offset + self.string = string + self.height = height + self.rotation = rotation + self.mirrored = mirrored + self.repetition = repetition + self.annotations = annotations self.font_path = font_path + @classmethod + def _from_raw( + cls, + *, + string: str, + height: float, + font_path: str, + offset: NDArray[numpy.float64], + rotation: float, + mirrored: bool, + annotations: annotations_t = None, + repetition: Repetition | None = None, + ) -> Self: + new = cls.__new__(cls) + new._offset = offset + new._string = string + new._height = height + new._rotation = rotation % (2 * pi) + new._mirrored = mirrored + new._repetition = repetition + new._annotations = annotations + new.font_path = font_path + return new + def __deepcopy__(self, memo: dict | None = None) -> Self: memo = {} if memo is None else memo new = copy.copy(self) new._offset = self._offset.copy() + new._repetition = copy.deepcopy(self._repetition, memo) new._annotations = copy.deepcopy(self._annotations) return new @@ -105,6 +122,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): and self.string == other.string and self.height == other.height and self.font_path == other.font_path + and self.mirrored == other.mirrored and self.rotation == other.rotation and self.repetition == other.repetition and annotations_eq(self.annotations, other.annotations) @@ -124,6 +142,8 @@ class Text(PositionableImpl, RotatableImpl, Shape): return self.font_path < other.font_path if not numpy.array_equal(self.offset, other.offset): return tuple(self.offset) < tuple(other.offset) + if self.mirrored != other.mirrored: + return self.mirrored < other.mirrored if self.rotation != other.rotation: return self.rotation < other.rotation if self.repetition != other.repetition: @@ -146,7 +166,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.offset = self.offset + [total_advance, 0] + poly.translate(self.offset + [total_advance, 0]) poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] @@ -171,22 +191,25 @@ class Text(PositionableImpl, RotatableImpl, Shape): (self.offset, self.height / norm_value, rotation, bool(self.mirrored)), lambda: Text( string=self.string, - height=self.height * norm_value, + height=norm_value, font_path=self.font_path, rotation=rotation, ).mirror2d(across_x=self.mirrored), ) - def get_bounds_single(self) -> NDArray[numpy.float64]: + def get_bounds_single(self) -> NDArray[numpy.float64] | None: # rotation makes this a huge pain when using slot.advance and glyph.bbox(), so # just convert to polygons instead polys = self.to_polygons() + if not polys: + return None + pbounds = numpy.full((len(polys), 2, 2), nan) for pp, poly in enumerate(polys): pbounds[pp] = poly.get_bounds_nonempty() bounds = numpy.vstack(( - numpy.min(pbounds[: 0, :], axis=0), - numpy.max(pbounds[: 1, :], axis=0), + numpy.min(pbounds[:, 0, :], axis=0), + numpy.max(pbounds[:, 1, :], axis=0), )) return bounds @@ -202,8 +225,8 @@ def get_char_as_polygons( char: str, resolution: float = 48 * 64, ) -> tuple[list[NDArray[numpy.float64]], float]: - from freetype import Face # type: ignore - from matplotlib.path import Path # type: ignore + from freetype import Face # type: ignore #noqa: PLC0415 + from matplotlib.path import Path # type: ignore #noqa: PLC0415 """ Get a list of polygons representing a single character. diff --git a/masque/test/__init__.py b/masque/test/__init__.py new file mode 100644 index 0000000..e02b636 --- /dev/null +++ b/masque/test/__init__.py @@ -0,0 +1,3 @@ +""" +Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) +""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py new file mode 100644 index 0000000..3116ee2 --- /dev/null +++ b/masque/test/conftest.py @@ -0,0 +1,13 @@ +""" + +Test fixtures + +""" + +# ruff: noqa: ARG001 +from typing import Any +import numpy + + +FixtureRequest = Any +PRNG = numpy.random.RandomState(12345) diff --git a/masque/test/helpers.py b/masque/test/helpers.py new file mode 100644 index 0000000..2ccea1a --- /dev/null +++ b/masque/test/helpers.py @@ -0,0 +1,146 @@ +from typing import Any +from collections.abc import Callable +from copy import deepcopy + +import numpy +from numpy.typing import ArrayLike, NDArray +from numpy.testing import assert_allclose + +from masque import Pather, Port +from masque.builder.tools import RenderStep + + +def closed_edge_lengths(vertices: ArrayLike) -> NDArray[numpy.float64]: + """ + Return lengths for each edge of an implicitly closed vertex loop. + """ + vv = numpy.asarray(vertices, dtype=float) + return numpy.sqrt(numpy.sum(numpy.diff(vv, axis=0, append=vv[:1]) ** 2, axis=1)) + + +def assert_closed_edges_within(vertices: ArrayLike, max_len: float, *, atol: float = 1e-6) -> None: + """ + Assert that every edge in an implicitly closed vertex loop is no longer than `max_len`. + """ + assert numpy.all(closed_edge_lengths(vertices) <= max_len + atol) + + +def assert_bounds_close(shape_or_polygon: Any, expected: ArrayLike, *, atol: float = 1e-10) -> None: + """ + Assert that an object's single-shape bounds match `expected`. + """ + assert_allclose(shape_or_polygon.get_bounds_single(), expected, atol=atol) + + +def normalized_route_data(data: Any) -> Any: + """ + Return a deterministic, comparison-friendly representation of route data. + """ + if isinstance(data, dict): + return tuple((key, normalized_route_data(value)) for key, value in sorted(data.items(), key=lambda item: repr(item[0]))) + if isinstance(data, list | tuple): + return tuple(normalized_route_data(value) for value in data) + if isinstance(data, numpy.ndarray): + return tuple(normalized_route_data(value) for value in data.tolist()) + if isinstance(data, numpy.generic): + return data.item() + try: + hash(data) + except TypeError: + return repr(data) + return data + + +def route_step_signature(step: RenderStep) -> tuple[Any, ...]: + """ + Return the stable planning-relevant portion of one rendered route step. + """ + return ( + step.opcode, + tuple(round(float(value), 9) for value in step.start_port.offset), + None if step.start_port.rotation is None else round(float(step.start_port.rotation), 9), + step.start_port.ptype, + tuple(round(float(value), 9) for value in step.end_port.offset), + None if step.end_port.rotation is None else round(float(step.end_port.rotation), 9), + step.end_port.ptype, + normalized_route_data(step.data), + ) + + +def route_signature(pather: Pather, portspec: str) -> tuple[tuple[Any, ...], ...]: + """ + Return a deterministic signature for a pather route. + """ + return tuple(route_step_signature(step) for step in pather._paths[portspec]) + + +def route_endpoint(pather: Pather, portspec: str) -> Port: + """ + Return the endpoint of a routed port, falling back to the live port for empty routes. + """ + steps = pather._paths[portspec] + if not steps: + return pather.pattern[portspec] + return steps[-1].end_port + + +def assert_route_endpoint( + pather: Pather, + portspec: str, + expected: Port, + *, + atol: float = 1e-8, + ) -> None: + """ + Assert that a route endpoint matches an expected port pose and ptype. + """ + actual = route_endpoint(pather, portspec) + assert_allclose(actual.offset, expected.offset, atol=atol) + if expected.rotation is None: + assert actual.rotation is None + else: + assert actual.rotation is not None + assert numpy.isclose(actual.rotation, expected.rotation, atol=atol) + assert actual.ptype == expected.ptype + + +def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> None: + """ + Assert a simple render-step bend budget for route signatures. + """ + bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation) + assert bend_count <= max_bends + + +def assert_route_deterministic( + make_pather: Callable[[], Pather], + route: Callable[[Pather], None], + portspec: str, + ) -> None: + """ + Assert that the same route operation produces the same route signature twice. + """ + first = make_pather() + route(first) + first_signature = route_signature(first, portspec) + + second = make_pather() + route(second) + assert route_signature(second, portspec) == first_signature + + +def assert_route_failure_does_not_mutate( + pather: Pather, + route: Callable[[], None], + expected_exception: type[BaseException], + ) -> BaseException: + """ + Assert that a failing route operation leaves pending route steps untouched. + """ + before = deepcopy(dict(pather._paths)) + try: + route() + except expected_exception as err: + assert dict(pather._paths) == before + return err + raise AssertionError(f'Expected {expected_exception.__name__}') diff --git a/masque/test/test_abstract.py b/masque/test/test_abstract.py new file mode 100644 index 0000000..d2f54ed --- /dev/null +++ b/masque/test/test_abstract.py @@ -0,0 +1,85 @@ +from numpy.testing import assert_allclose +from numpy import pi + +from ..abstract import Abstract +from ..ports import Port +from ..ref import Ref + + +def test_abstract_init() -> None: + ports = {"A": Port((0, 0), 0), "B": Port((10, 0), pi)} + abs_obj = Abstract("test", ports) + assert abs_obj.name == "test" + assert len(abs_obj.ports) == 2 + assert abs_obj.ports["A"] is not ports["A"] # Should be deepcopied + + +def test_abstract_transform() -> None: + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + # Rotate 90 deg around (0,0) + abs_obj.rotate_around((0, 0), pi / 2) + # (10, 0) rot 0 -> (0, 10) rot pi/2 + assert_allclose(abs_obj.ports["A"].offset, [0, 10], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) + + # Mirror across x axis (axis 0): flips y-offset + abs_obj.mirror(0) + # (0, 10) mirrored(0) -> (0, -10) + # rotation pi/2 mirrored(0) -> -pi/2 == 3pi/2 + assert_allclose(abs_obj.ports["A"].offset, [0, -10], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, 3 * pi / 2, atol=1e-10) + + +def test_abstract_ref_transform() -> None: + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) + + # Apply ref transform + abs_obj.apply_ref_transform(ref) + # Ref order: mirror, rotate, scale, translate + + # 1. mirror (across x: y -> -y) + # (10, 0) rot 0 -> (10, 0) rot 0 + + # 2. rotate pi/2 around (0,0) + # (10, 0) rot 0 -> (0, 10) rot pi/2 + + # 3. translate (100, 100) + # (0, 10) -> (100, 110) + + assert_allclose(abs_obj.ports["A"].offset, [100, 110], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) + + +def test_abstract_ref_transform_scales_offsets() -> None: + abs_obj = Abstract("test", {"A": Port((10, 0), 0)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) + + abs_obj.apply_ref_transform(ref) + + assert_allclose(abs_obj.ports["A"].offset, [100, 120], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, pi / 2, atol=1e-10) + + +def test_abstract_undo_transform() -> None: + abs_obj = Abstract("test", {"A": Port((100, 110), pi / 2)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True) + + abs_obj.undo_ref_transform(ref) + assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) + + +def test_abstract_undo_transform_scales_offsets() -> None: + abs_obj = Abstract("test", {"A": Port((100, 120), pi / 2)}) + ref = Ref(offset=(100, 100), rotation=pi / 2, mirrored=True, scale=2) + + abs_obj.undo_ref_transform(ref) + assert_allclose(abs_obj.ports["A"].offset, [10, 0], atol=1e-10) + assert abs_obj.ports["A"].rotation is not None + assert_allclose(abs_obj.ports["A"].rotation, 0, atol=1e-10) diff --git a/masque/test/test_arc.py b/masque/test/test_arc.py new file mode 100644 index 0000000..dc23144 --- /dev/null +++ b/masque/test/test_arc.py @@ -0,0 +1,87 @@ +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..error import PatternError +from ..shapes import Arc +from .helpers import assert_closed_edges_within + + +def test_arc_init() -> None: + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, offset=(0, 0)) + assert_equal(a.radii, [10, 10]) + assert_equal(a.angles, [0, pi / 2]) + assert a.width == 2 + +def test_arc_to_polygons() -> None: + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + polys = a.to_polygons(num_vertices=32) + assert len(polys) == 1 + + # Quarter-circle ring section with outer radius 11 and inner radius 9. + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10) + +def test_arc_focus_to_polygons() -> None: + a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos) + polys = a.to_polygons(num_vertices=32) + assert len(polys) == 1 + + focus = numpy.array([8.0, 0.0]) + cuts = a.get_cap_edges() + for angle, cut in zip(a.angles, cuts, strict=True): + direction = numpy.array([numpy.cos(angle), numpy.sin(angle)]) + for point in cut: + delta = point - focus + assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10) + assert numpy.dot(direction, delta) > 0 + +def test_arc_circle_focus_matches_center() -> None: + center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos) + + assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices, + center.to_polygons(num_vertices=32)[0].vertices, + atol=1e-10) + +def test_arc_edge_cases() -> None: + a = Arc(radii=(10, 10), angles=(0, 3 * pi), width=2) + a.to_polygons(num_vertices=64) + bounds = a.get_bounds_single() + assert_allclose(bounds, [[-11, -11], [11, 11]], atol=1e-10) + +def test_rotated_arc_bounds_match_polygonized_geometry() -> None: + arc = Arc(radii=(10, 20), angles=(0, pi), width=2, rotation=pi / 4, offset=(100, 200)) + bounds = arc.get_bounds_single() + poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + +def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None: + arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4, + offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos) + bounds = arc.get_bounds_single() + poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-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_focus_arc_rejects_focus_outside_inner_boundary() -> None: + arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos) + with pytest.raises(PatternError, match='inside both arc boundary ellipses'): + arc.to_polygons(num_vertices=24) + +def test_focus_arc_max_arclen_limits_segments() -> None: + arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg) + assert_closed_edges_within(arc.to_polygons(max_arclen=2)[0].vertices, 2) + +def test_arc_rejects_zero_radii_up_front() -> None: + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(0, 5), angles=(0, 1), width=1) + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(5, 0), angles=(0, 1), width=1) + with pytest.raises(PatternError, match='Radii must be positive'): + Arc(radii=(0, 0), angles=(0, 1), width=1) diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py new file mode 100644 index 0000000..f29b741 --- /dev/null +++ b/masque/test/test_autotool_planning.py @@ -0,0 +1,1219 @@ +from contextlib import suppress +from typing import Any + +import pytest +from numpy import pi +from numpy.testing import assert_allclose + +from masque.builder.tools import AutoTool, PrimitiveOffer, RenderStep, UOffer, circular_arc_sbend_endpoint +from masque.builder.pather import Pather +from masque.library import Library +from masque.pattern import Pattern +from masque.ports import Port +from masque.error import BuildError + + +def commit_offer(offer: PrimitiveOffer, parameter: float) -> tuple[Port, Any]: + return offer.endpoint_at(parameter), offer.commit(parameter) + + +def selected_offer(tool: AutoTool, kind: str, parameter: float, **kwargs: Any) -> tuple[PrimitiveOffer, Port, Any]: + valid = [] + for offer in tool.primitive_offers(kind, **kwargs): + try: + out_port, data = commit_offer(offer, parameter) + except BuildError: + continue + valid.append((offer, out_port, data)) + + assert valid + return min(valid, key=lambda item: item[0].cost_at(parameter)) + + +def selected_matching_offer( + tool: AutoTool, + kind: str, + parameter: float, + predicate: Any, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, Port, Any]: + valid = [] + for offer in tool.primitive_offers(kind, **kwargs): + try: + out_port, data = commit_offer(offer, parameter) + except BuildError: + continue + if predicate(offer, out_port, data): + valid.append((offer, out_port, data)) + + assert valid + return min(valid, key=lambda item: item[0].cost_at(parameter)) + + +def rendered_offer_tree( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_ptype: str | None = None, + ) -> Library: + start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk") + end, data = commit_offer(offer, parameter) + return tool.render((RenderStep(offer.opcode, tool, start, end, data),)) + + +def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["in"] = Port((0, 0), 0, ptype=ptype) + pat.ports["out"] = Port((length, 0), pi, ptype=ptype) + return pat + + +@pytest.fixture +def autotool_setup() -> tuple[Pather, AutoTool, Library]: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["in"] = Port((0, 0), 0, ptype="wire") + bend_pat.ports["out"] = Port((2, -2), pi / 2, ptype="wire") + lib["bend"] = bend_pat + lib.abstract("bend") + + via_pat = Pattern() + via_pat.ports["m1"] = Port((0, 0), 0, ptype="wire_m1") + via_pat.ports["m2"] = Port((1, 0), pi, ptype="wire_m2") + lib["via"] = via_pat + via_abs = lib.abstract("via") + + tool_m1 = ( + AutoTool(bbox_library=lib) + .add_straight( + lambda length: _make_transition_straight(length, ptype="wire_m1"), + "wire_m1", + "in", + ) + .add_transition(via_abs, "m2", "m1") + ) + + p = Pather(lib, tools=tool_m1) + p.ports["start"] = Port((0, 0), pi, ptype="wire_m2") + + return p, tool_m1, lib + +def test_autotool_transition(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: + p, _tool, _lib = autotool_setup + + p.straight("start", 10) + + # Via length is 1, so the remaining wire_m1 straight length is 9. + assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) + assert p.ports["start"].ptype == "wire_m1" + + +def test_autotool_route_level_methods_removed(autotool_setup: tuple[Pather, AutoTool, Library]) -> None: + _p, tool, _lib = autotool_setup + + for name in ("planL", "planS", "planU", "traceL", "traceS", "traceU"): + assert not hasattr(tool, name) + + +def test_autotool_straight_offer_supports_requested_output_transition( + autotool_setup: tuple[Pather, AutoTool, Library], + ) -> None: + _p, tool, lib = autotool_setup + + p = Pather(lib, tools=tool, render='deferred') + p.ports["start"] = Port((0, 0), pi, ptype="wire_m1") + p.straight("start", 10, out_ptype="wire_m2") + + assert_allclose(p.ports["start"].offset, [10, 0], atol=1e-10) + assert p.ports["start"].ptype == "wire_m2" + assert [type(step.data) for step in p._paths["start"]] == [AutoTool.GeneratedData, AutoTool.ReusableData] + assert p._paths["start"][0].data.parameter == 9 + assert p._paths["start"][1].data.port_name == "m1" + + +def test_pather_straight_topology_allows_transition_offset_cancellation() -> None: + lib = Library() + + trans_in = Pattern() + trans_in.ports["EXT"] = Port((0, 0), 0, ptype="ext_in") + trans_in.ports["CORE"] = Port((1, 1), pi, ptype="core") + lib["trans_in"] = trans_in + + trans_out = Pattern() + trans_out.ports["EXT"] = Port((0, 0), 0, ptype="ext_out") + trans_out.ports["CORE"] = Port((1, -1), pi, ptype="core") + lib["trans_out"] = trans_out + + tool = ( + AutoTool(bbox_library=lib) + .add_straight( + lambda length: _make_transition_straight(length, ptype="core"), + "core", + "in", + length_range=(0, 1e8), + ) + .add_transition(lib.abstract("trans_in"), "EXT", "CORE") + .add_transition(lib.abstract("trans_out"), "EXT", "CORE") + ) + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), pi, ptype="ext_in") + p.trace("A", None, length=10, out_ptype="ext_out") + + assert_allclose(p.ports["A"].offset, [10, 0], atol=1e-10) + assert p.ports["A"].ptype == "ext_out" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.parameter == 8 + + +def test_autotool_add_transition_dedupes_bidirectional_adapter_offers() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + trans_abs = lib.abstract("trans") + + tool = AutoTool(bbox_library=lib) + tool.add_transition(trans_abs, "EXT", "CORE") + tool.add_transition(trans_abs, "EXT", "CORE") + + ext_offers = tool.primitive_offers("straight", in_ptype="ext") + core_offers = tool.primitive_offers("straight", in_ptype="core") + + assert len(ext_offers) == 1 + assert len(core_offers) == 1 + + _ext_port, ext_data = commit_offer(ext_offers[0], 2) + _core_port, core_data = commit_offer(core_offers[0], 2) + assert isinstance(ext_data, AutoTool.ReusableData) + assert isinstance(core_data, AutoTool.ReusableData) + assert ext_data.port_name == "EXT" + assert core_data.port_name == "CORE" + + +def test_autotool_add_transition_infers_two_port_bidirectional_transition() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + + tool = AutoTool(bbox_library=lib) + tool.add_transition(lib.abstract("trans")) + + ext_offers = tool.primitive_offers("straight", in_ptype="ext") + core_offers = tool.primitive_offers("straight", in_ptype="core") + + assert len(ext_offers) == 1 + assert len(core_offers) == 1 + + ext_port, ext_data = commit_offer(ext_offers[0], 2) + core_port, core_data = commit_offer(core_offers[0], 2) + assert_allclose(ext_port.offset, [2, 0]) + assert_allclose(core_port.offset, [2, 0]) + assert isinstance(ext_data, AutoTool.ReusableData) + assert isinstance(core_data, AutoTool.ReusableData) + assert ext_data.port_name == "EXT" + assert core_data.port_name == "CORE" + + +def test_autotool_add_transition_one_way_inhibits_reverse_adapter_offer() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + trans_abs = lib.abstract("trans") + + tool = AutoTool(bbox_library=lib) + tool.add_transition(trans_abs, "EXT", "CORE", one_way=True) + + ext_offers = tool.primitive_offers("straight", in_ptype="ext") + core_offers = tool.primitive_offers("straight", in_ptype="core") + + assert len(ext_offers) == 1 + assert core_offers == () + + _ext_port, ext_data = commit_offer(ext_offers[0], 2) + assert isinstance(ext_data, AutoTool.ReusableData) + assert ext_data.port_name == "EXT" + + +def test_autotool_add_transition_requires_explicit_names_for_one_way() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + + with pytest.raises(BuildError, match='one-way transitions require explicit port names'): + AutoTool().add_transition(lib.abstract("trans"), one_way=True) + + +def test_autotool_add_transition_rejects_partial_port_names() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["trans"] = trans_pat + + with pytest.raises(BuildError, match='Transition port names must be provided together'): + AutoTool().add_transition(lib.abstract("trans"), "EXT") + + +def test_autotool_add_transition_requires_explicit_names_for_non_two_port_abstract() -> None: + lib = Library() + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + trans_pat.ports["TAP"] = Port((1, 1), pi, ptype="tap") + lib["trans"] = trans_pat + + with pytest.raises(BuildError, match='Transition port names are required for 3-port abstracts'): + AutoTool().add_transition(lib.abstract("trans")) + + +def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((length, 0), pi, ptype=ptype) + return pat + + +def make_bend(R: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: + pat = Pattern() + # Rectangular approximation of a 90 degree bend. + if clockwise: + pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) + pat.rect((1, 0), xctr=R, lx=width, ymin=-R, ymax=0) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((R, -R), pi / 2, ptype=ptype) + else: + pat.rect((1, 0), xmin=0, xmax=R, yctr=0, ly=width) + pat.rect((1, 0), xctr=R, lx=width, ymin=0, ymax=R) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((R, R), -pi / 2, ptype=ptype) + return pat + + +def make_sbend(jog: float, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((10, jog), pi, ptype=ptype) + return pat + + +@pytest.fixture +def multi_bend_tool() -> tuple[AutoTool, Library]: + lib = Library() + + lib["b1"] = make_bend(2, ptype="wire") + b1_abs = lib.abstract("b1") + lib["b2"] = make_bend(5, ptype="wire") + b2_abs = lib.abstract("b2") + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(make_straight, "wire", "A", length_range=(0, 10)) + .add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8)) + .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) + .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) + ) + 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(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 3)) + .add_straight(lambda length: make_straight(length, ptype="mid"), "mid", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "in", "out", clockwise=True, mirror=True) + .add_transition(lib.abstract("core_mid"), "MID", "CORE") + ) + +@pytest.fixture +def wildcard_transition_tool() -> tuple[AutoTool, Library]: + lib = Library() + + def make_core_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.ports["WILD"] = Port((0, 0), 0, ptype="unk") + trans_pat.ports["CORE"] = Port((2, 0), pi, ptype="core") + lib["wild_core"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(-1e8, 1e8)) + .add_transition(lib.abstract("wild_core"), "WILD", "CORE") + ) + return tool, lib + +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 assert_rendered_offer_endpoint_matches_plan( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_ptype: str | None = None, + ) -> None: + out_port = offer.endpoint_at(parameter) + tree = rendered_offer_tree(tool, offer, parameter, source_ptype) + assert_trace_matches_plan(out_port, tree) + + +def assert_offer_bbox_matches_trace(offer: PrimitiveOffer, parameter: float, tree: Library, source_lib: Library) -> None: + expected = tree.top_pattern().get_bounds(library=source_lib) + assert expected is not None + assert_allclose(offer.bbox_at(parameter), expected) + + +def assert_offer_bbox_matches_rendered_offer( + tool: AutoTool, + offer: PrimitiveOffer, + parameter: float, + source_lib: Library, + ) -> None: + tree = rendered_offer_tree(tool, offer, parameter) + assert_offer_bbox_matches_trace(offer, parameter, tree, source_lib) + + +def make_sbend_tool(jog_range: tuple[float, float]) -> AutoTool: + def make_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + return ( + AutoTool() + .add_straight(make_straight, "core", "A", length_range=(0, 1e8)) + .add_sbend(make_sbend, "core", "A", "B", jog_range=jog_range) + ) + + +def test_autotool_add_straight_infers_metadata_from_generated_example() -> None: + calls: list[float] = [] + + def make_counted_straight(length: float) -> Pattern: + calls.append(length) + return make_straight(length, ptype="core") + + tool = AutoTool().add_straight(make_counted_straight, length_range=(0, 10)) + + assert calls == [1] + offer = tool.primitive_offers("straight", in_ptype="core")[0] + out_port, data = commit_offer(offer, 7) + assert out_port.ptype == "core" + assert isinstance(data, AutoTool.GeneratedData) + assert data.port_name == "A" + assert data.parameter == 7 + assert calls == [1] + + +def test_autotool_add_straight_explicit_metadata_does_not_sample_generator() -> None: + calls: list[float] = [] + + def make_counted_straight(length: float) -> Pattern: + calls.append(length) + return make_straight(length, ptype="core") + + tool = AutoTool().add_straight(make_counted_straight, "core", "A", length_range=(0, 10)) + + assert calls == [] + offer = tool.primitive_offers("straight", in_ptype="core")[0] + _out_port, data = commit_offer(offer, 7) + assert isinstance(data, AutoTool.GeneratedData) + assert data.port_name == "A" + assert calls == [] + + +def test_autotool_add_sbend_infers_metadata_from_generated_example() -> None: + calls: list[float] = [] + + def make_counted_sbend(jog: float) -> Pattern: + calls.append(jog) + return make_sbend(jog, ptype="core") + + tool = AutoTool().add_sbend(make_counted_sbend, jog_range=(0, 10)) + + assert calls == [1] + offer = tool.primitive_offers("s", in_ptype="core")[0] + data = offer.commit(4) + assert offer.in_ptype == "core" + assert offer.out_ptype == "core" + assert isinstance(data, AutoTool.GeneratedData) + assert data.port_name == "A" + assert data.parameter == 4 + assert calls == [1] + + +def test_autotool_sbend_custom_endpoint_avoids_generator_during_planning() -> None: + calls = 0 + + def make_counted_sbend(jog: float) -> Pattern: + nonlocal calls + calls += 1 + return make_sbend(jog, ptype="core") + + def endpoint(jog: float) -> Port: + return Port((20, jog), rotation=pi, ptype="core") + + tool = AutoTool().add_sbend( + make_counted_sbend, + "core", + "A", + "B", + jog_range=(0, 1e8), + endpoint=endpoint, + ) + + offer = tool.primitive_offers("s", in_ptype="core")[0] + out_port = offer.endpoint_at(4) + + assert calls == 0 + assert_allclose(out_port.offset, [20, 4]) + assert out_port.rotation == pi + assert out_port.ptype == "core" + + data = offer.commit(4) + assert data.parameter == 4 + assert data.mirrored is False + + +def test_circular_arc_sbend_endpoint() -> None: + endpoint = circular_arc_sbend_endpoint(radius=5, ptype="core") + + out_port = endpoint(4) + assert_allclose(out_port.offset, [8, 4]) + assert out_port.rotation == pi + assert out_port.ptype == "core" + + mirrored_port = endpoint(-4) + assert_allclose(mirrored_port.offset, [8, -4]) + assert mirrored_port.rotation == pi + assert mirrored_port.ptype == "core" + + zero_port = endpoint(0) + assert_allclose(zero_port.offset, [0, 0]) + assert zero_port.rotation == pi + + with pytest.raises(BuildError, match="exceeds diameter"): + endpoint(11) + + +def make_uturn_pattern(length: float = 10, jog: float = 4, ptype: str = "core") -> Pattern: + pat = Pattern() + y0, y1 = sorted((0, jog)) + pat.rect((2, 0), xmin=0, xmax=length, ymin=y0 - 1, ymax=y1 + 1) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((length, jog), 0, ptype=ptype) + return pat + + +def make_uturn_tool() -> tuple[AutoTool, Library]: + lib = Library() + lib["u"] = make_uturn_pattern() + tool = AutoTool(bbox_library=lib).add_uturn(lib.abstract("u"), "A", "B") + return tool, lib + + +@pytest.mark.parametrize("in_ptype", [None, "unk"]) +def test_autotool_transition_offer_wildcard_input_key_treats_none_and_unk_equivalently( + wildcard_transition_tool: tuple[AutoTool, Library], + in_ptype: str | None, + ) -> None: + tool, _lib = wildcard_transition_tool + + valid = [] + for offer in tool.primitive_offers("straight", in_ptype=in_ptype, out_ptype="core"): + try: + out_port, data = commit_offer(offer, 2) + except BuildError: + continue + valid.append((offer, out_port, data)) + + assert valid + _offer, out_port, data = min(valid, key=lambda item: item[0].cost_at(2)) + assert isinstance(data, AutoTool.ReusableData) + assert_allclose(out_port.offset, [2, 0]) + assert out_port.ptype == "core" + + +@pytest.mark.parametrize("out_ptype", [None, "unk"]) +def test_autotool_transition_offer_wildcard_output_key_treats_none_and_unk_equivalently( + wildcard_transition_tool: tuple[AutoTool, Library], + out_ptype: str | None, + ) -> None: + tool, _lib = wildcard_transition_tool + + _offer, out_port, data = selected_matching_offer( + tool, + "straight", + 2, + lambda _offer, out_port, data: out_port.ptype == "unk" and isinstance(data, AutoTool.ReusableData), + in_ptype="core", + out_ptype=out_ptype, + ) + + assert_allclose(out_port.offset, [2, 0]) + assert out_port.ptype == "unk" + + +def test_autotool_l_offer_selection_uses_primitive_cost_and_domains( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, _lib = multi_bend_tool + + small_straight_offer, small_straight_port, small_straight_data = selected_offer(tool, "straight", 5) + assert small_straight_offer.parameter_domain == (0, 10) + assert small_straight_data.parameter == 5 + assert_allclose(small_straight_port.offset, [5, 0]) + + large_straight_offer, large_straight_port, large_straight_data = selected_offer(tool, "straight", 15) + assert large_straight_offer.parameter_domain == (10, 1e8) + assert large_straight_data.parameter == 15 + assert_allclose(large_straight_port.offset, [15, 0]) + + _small_bend_offer, small_bend_port, small_bend_data = selected_offer(tool, "bend", 2, ccw=True) + assert small_bend_data.abstract.name == "b1" + assert_allclose(small_bend_port.offset, [2, 2]) + + large_bend_offer, large_bend_port, large_bend_data = selected_offer(tool, "bend", 5, ccw=True) + assert large_bend_data.abstract.name == "b2" + assert_allclose(large_bend_port.offset, [5, 5]) + valid_costs = [] + for offer in tool.primitive_offers("straight"): + with suppress(BuildError): + valid_costs.append(offer.cost_at(15)) + assert large_straight_offer.cost_at(15) == min(valid_costs) + assert large_bend_offer.parameter_domain == (5, 5) + + +def test_autotool_add_bend_infers_two_port_clockwise_bend() -> None: + lib = Library() + lib["bend"] = make_bend(2, ptype="wire", clockwise=True) + + tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend")) + + _cw_offer, cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) + _ccw_offer, ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) + assert_allclose(cw_port.offset, [2, -2]) + assert_allclose(ccw_port.offset, [2, 2]) + assert isinstance(cw_data, AutoTool.ReusableData) + assert isinstance(ccw_data, AutoTool.ReusableData) + assert cw_data.port_name == "A" + assert not cw_data.mirrored + assert ccw_data.port_name == "A" + assert ccw_data.mirrored + + +def test_autotool_add_bend_infers_two_port_counterclockwise_bend() -> None: + lib = Library() + lib["bend"] = make_bend(2, ptype="wire", clockwise=False) + + tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend")) + + _cw_offer, cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) + _ccw_offer, ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) + assert_allclose(cw_port.offset, [2, -2]) + assert_allclose(ccw_port.offset, [2, 2]) + assert isinstance(cw_data, AutoTool.ReusableData) + assert isinstance(ccw_data, AutoTool.ReusableData) + assert cw_data.port_name == "A" + assert cw_data.mirrored + assert ccw_data.port_name == "A" + assert not ccw_data.mirrored + + +def test_autotool_add_bend_inferred_names_allow_rotational_reuse_without_mirror() -> None: + lib = Library() + lib["bend"] = make_bend(2, ptype="wire", clockwise=True) + + tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False) + + cw_offer, _cw_port, cw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=False) + ccw_offer, _ccw_port, ccw_data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=True) + assert isinstance(cw_data, AutoTool.ReusableData) + assert isinstance(ccw_data, AutoTool.ReusableData) + assert cw_data.port_name == "A" + assert not cw_data.mirrored + assert ccw_data.port_name == "B" + assert not ccw_data.mirrored + assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "wire") + assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "wire") + + +def test_autotool_add_bend_rejects_clockwise_mismatch() -> None: + lib = Library() + lib["bend"] = make_bend(2, ptype="wire", clockwise=True) + + with pytest.raises(BuildError, match='Bend clockwise argument does not match port orientations'): + AutoTool().add_bend(lib.abstract("bend"), "A", "B", clockwise=False) + + +def test_autotool_add_bend_rejects_partial_port_names() -> None: + lib = Library() + lib["bend"] = make_bend(2, ptype="wire", clockwise=True) + + with pytest.raises(BuildError, match='Bend port names must be provided together'): + AutoTool().add_bend(lib.abstract("bend"), "A") + + +def test_autotool_add_bend_requires_explicit_names_for_non_two_port_abstract() -> None: + lib = Library() + bend = make_bend(2, ptype="wire", clockwise=True) + bend.ports["TAP"] = Port((1, 1), pi, ptype="wire") + lib["bend"] = bend + + with pytest.raises(BuildError, match='Bend port names are required for 3-port abstracts'): + AutoTool().add_bend(lib.abstract("bend")) + + +def test_autotool_l_offer_bbox_matches_rendered_primitive(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + offer, _out_port, _data = selected_offer(tool, "bend", 2, ccw=True) + + assert_offer_bbox_matches_rendered_offer(tool, offer, 2, lib) + + +def test_autotool_generated_straight_endpoint_matches_rendered_offer( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, _lib = multi_bend_tool + offer, _out_port, data = selected_offer(tool, "straight", 7, in_ptype="wire") + + assert isinstance(data, AutoTool.GeneratedData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 7, "wire") + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_reusable_bend_endpoint_matches_rendered_offer( + multi_bend_tool: tuple[AutoTool, Library], + ccw: bool, + ) -> None: + tool, _lib = multi_bend_tool + offer, _out_port, data = selected_offer(tool, "bend", 2, in_ptype="wire", ccw=ccw) + + assert isinstance(data, AutoTool.ReusableData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 2, "wire") + + +def test_autotool_transition_offer_bbox_matches_rendered_primitive() -> None: + lib = Library() + + lib["core_bend"] = make_bend(2, ptype="core") + trans_pat = Pattern() + trans_pat.rect((2, 0), xmin=0, xmax=3, yctr=2, ly=2) + trans_pat.ports["CORE"] = Port((0, 0), 0, ptype="core") + trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") + lib["out_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("out_trans"), "EXT", "CORE") + ) + offer, _out_port, data = selected_offer(tool, "s", 1, in_ptype="core", out_ptype="ext") + + assert isinstance(data, AutoTool.ReusableData) + assert_offer_bbox_matches_rendered_offer(tool, offer, 1, lib) + + +def test_autotool_transition_endpoint_matches_rendered_offer( + autotool_setup: tuple[Pather, AutoTool, Library], + ) -> None: + _pather, tool, _lib = autotool_setup + offer, _out_port, data = selected_matching_offer( + tool, + "straight", + 1, + lambda _offer, _out_port, data: isinstance(data, AutoTool.ReusableData), + in_ptype="wire_m1", + out_ptype="wire_m2", + ) + + assert isinstance(data, AutoTool.ReusableData) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 1, "wire_m1") + + +def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None: + lib = Library() + + def make_wide_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.rect((2, 0), xmin=0, xmax=10, yctr=jog / 2, ly=abs(jog) + 2) + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.rect((2, 0), xmin=0, xmax=5, yctr=0, ly=2) + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") + lib["xin"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(make_straight, "core", "A", length_range=(1, 1e8)) + .add_sbend(make_wide_sbend, "core", "A", "B", jog_range=(0, 1e8)) + .add_transition(lib.abstract("xin"), "EXT", "CORE") + ) + offer, _out_port, _data = selected_offer(tool, "s", 4, length=15, in_ptype="core") + + assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) + + pather = Pather(lib, tools=tool, render='deferred') + pather.ports["A"] = Port((0, 0), 0, ptype="ext") + pather.jog("A", 4) + + assert_allclose(pather.ports["A"].offset, [-15, -4]) + assert [step.opcode for step in pather._paths["A"]] == ['L', 'S'] + assert isinstance(pather._paths["A"][0].data, AutoTool.ReusableData) + assert isinstance(pather._paths["A"][1].data, AutoTool.GeneratedData) + + +def test_autotool_s_offer_uses_absolute_jog_range_for_both_signs() -> None: + tool = make_sbend_tool((0, 1e8)) + offers = tool.primitive_offers('s', in_ptype="core") + + valid_positive = [] + valid_negative = [] + for offer in offers: + try: + if offer.endpoint_at(4).y == 4: + valid_positive.append(offer) + except BuildError: + pass + try: + if offer.endpoint_at(-4).y == -4: + valid_negative.append(offer) + except BuildError: + pass + + assert valid_positive + assert valid_negative + + pather = Pather(Library(), tools=tool, render='deferred') + pather.ports["A"] = Port((0, 0), 0, ptype="core") + pather.jog("A", -4) + assert_allclose(pather.ports["A"].offset, [-10, 4]) + assert isinstance(pather._paths["A"][0].data, AutoTool.GeneratedData) + + +def test_autotool_sbend_registration_order_sets_priority() -> None: + def first_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((20, jog), pi, ptype="core") + return pat + + def second_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((5, jog), pi, ptype="core") + return pat + + tool = ( + AutoTool() + .add_sbend(first_sbend, "core", "A", "B", jog_range=(0, 1e8)) + .add_sbend(second_sbend, "core", "A", "B", jog_range=(0, 1e8)) + ) + + _offer, out_port, data = selected_offer(tool, "s", 4, in_ptype="core") + + assert isinstance(data, AutoTool.GeneratedData) + assert data.fn is first_sbend + assert_allclose(out_port.offset, [20, 4]) + + +def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None: + tool = make_sbend_tool((4, 4)) + offers = tool.primitive_offers('s', in_ptype="core") + + assert sorted(offer.jog_domain for offer in offers) == [(-4.0, -4.0), (4.0, 4.0)] + assert [offer.endpoint_at(4).y for offer in offers if offer.jog_domain == (4.0, 4.0)] == [4] + assert [offer.endpoint_at(-4).y for offer in offers if offer.jog_domain == (-4.0, -4.0)] == [-4] + + for jog, expected_y in ((4, -4), (-4, 4)): + pather = Pather(Library(), tools=tool) + pather.ports["A"] = Port((0, 0), 0, ptype="core") + pather.jog("A", jog) + assert_allclose(pather.ports["A"].offset, [-10, expected_y]) + + +def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None: + tool = make_sbend_tool((-4, 4)) + + assert tool.primitive_offers('s', in_ptype="core") == () + + +def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None: + tool, lib = make_uturn_tool() + offer, out_port, data = selected_offer(tool, "u", 4, in_ptype="core") + + assert isinstance(offer, UOffer) + assert isinstance(data, AutoTool.ReusableData) + assert data.abstract.name == "u" + assert not data.mirrored + assert_allclose(out_port.offset, [10, 4]) + assert out_port.rotation is not None + assert_allclose(out_port.rotation, 0) + assert_rendered_offer_endpoint_matches_plan(tool, offer, 4, "core") + assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) + + +def test_autotool_uturn_offer_mirror_exposes_both_signs() -> None: + tool, _lib = make_uturn_tool() + offers = tool.primitive_offers('u', in_ptype="core") + + assert sorted(offer.jog_domain for offer in offers if isinstance(offer, UOffer)) == [ + (-4.0, -4.0), + (4.0, 4.0), + ] + for offer in offers: + assert isinstance(offer, UOffer) + jog = offer.jog_domain[0] + data = offer.commit(jog) + assert isinstance(data, AutoTool.ReusableData) + assert data.mirrored == (jog < 0) + assert_allclose(offer.endpoint_at(jog).offset, [10, jog]) + + +def test_pather_autotool_uses_prebaked_uturn_offer() -> None: + tool, lib = make_uturn_tool() + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="core") + + p.uturn("A", 4, length=10) + + assert_allclose(p.ports["A"].offset, [-10, -4]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [step.opcode for step in p._paths["A"]] == ['U'] + assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) + + +def test_autotool_uturn_rejects_non_uturn_orientation() -> None: + lib = Library() + pat = make_uturn_pattern() + pat.ports["B"].rotation = pi + lib["bad_u"] = pat + + with pytest.raises(BuildError, match="U-turn primitive output port"): + AutoTool().add_uturn(lib.abstract("bad_u"), "A", "B") + + +def test_pather_autotool_omitted_uturn_composes_l_offers( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.uturn("A", 20) + + assert_allclose(p.ports["A"].offset, [0, -20]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + + +def test_pather_autotool_explicit_uturn_composes_l_offers( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.uturn("A", 20, length=10) + + assert_allclose(p.ports["A"].offset, [-10, -20]) + assert p.ports["A"].rotation is not None + assert_allclose(p.ports["A"].rotation, pi) + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.GeneratedData, + AutoTool.ReusableData, + ] + + +def test_autotool_offer_bbox_rejects_invalid_parameter(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, _lib = multi_bend_tool + offer = tool.primitive_offers('bend', ccw=True)[0] + + with pytest.raises(BuildError, match='outside singleton domain'): + offer.bbox_at(offer.parameter_domain[0] - 1) + +def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.trace("A", True, length=15) + + assert_allclose(p.ports["A"].offset, [-15, -2]) + straight_step, bend_step = p._paths["A"] + assert isinstance(straight_step.data, AutoTool.GeneratedData) + assert isinstance(bend_step.data, AutoTool.ReusableData) + assert straight_step.data.parameter == 13 + assert bend_step.data.abstract.name == "b1" + + +def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None: + markers: list[str | None] = [] + + def make_marked_straight(length: float, marker: str | None = None) -> Pattern: + markers.append(marker) + return make_straight(length, ptype="wire") + + tool = AutoTool().add_straight(make_marked_straight, "wire", "A", length_range=(0, 1e8)) + p = Pather(Library(), tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="wire") + + p.straight("A", 5, marker="route") + p.render() + + assert markers == [None] + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_requested_output_transition(ccw: bool) -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="core") + bend_pat.ports["B"] = 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["EXT"] = Port((3, 1), pi, ptype="ext") + lib["out_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("core_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("out_trans"), "EXT", "CORE") + ) + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=10, out_ptype="ext") + + assert p.ports["A"].ptype == "ext" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][2].data.port_name == "CORE" + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_bend_input_transition(ccw: bool) -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + trans_pat = Pattern() + trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") + lib["bend_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + ) + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=10, out_ptype="mid") + + assert p.ports["A"].ptype == "mid" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.port_name == "CORE" + + +def test_pather_accepts_bend_offer_with_zero_lateral_endpoint() -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + trans_pat = Pattern() + trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + trans_pat.ports["CORE"] = Port((1, -2), pi, ptype="core") + lib["bend_trans"] = trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + ) + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", True, length=10, out_ptype="mid") + + assert_allclose(p.ports["A"].offset, [-10, 0], atol=1e-10) + assert p.ports["A"].ptype == "mid" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + + +@pytest.mark.parametrize("ccw", [False, True]) +def test_autotool_bend_offer_supports_bend_and_output_transitions(ccw: bool) -> None: + lib = Library() + + bend_pat = Pattern() + bend_pat.ports["A"] = Port((0, 0), 0, ptype="mid") + bend_pat.ports["B"] = Port((2, -2), pi / 2, ptype="mid") + lib["mid_bend"] = bend_pat + + bend_trans_pat = Pattern() + bend_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + bend_trans_pat.ports["CORE"] = Port((1, 0), pi, ptype="core") + lib["bend_trans"] = bend_trans_pat + + out_trans_pat = Pattern() + out_trans_pat.ports["MID"] = Port((0, 0), 0, ptype="mid") + out_trans_pat.ports["EXT"] = Port((3, 1), pi, ptype="ext") + lib["out_trans"] = out_trans_pat + + tool = ( + AutoTool(bbox_library=lib) + .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) + .add_bend(lib.abstract("mid_bend"), "A", "B", clockwise=True, mirror=True) + .add_transition(lib.abstract("bend_trans"), "MID", "CORE") + .add_transition(lib.abstract("out_trans"), "EXT", "MID") + ) + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="core") + p.trace("A", ccw, length=12, out_ptype="ext") + + assert p.ports["A"].ptype == "ext" + assert [type(step.data) for step in p._paths["A"]] == [ + AutoTool.GeneratedData, + AutoTool.ReusableData, + AutoTool.ReusableData, + AutoTool.ReusableData, + ] + assert p._paths["A"][1].data.port_name == "CORE" + assert p._paths["A"][3].data.port_name == "MID" + + +def test_pather_autotool_pure_sbend_with_transition_dx() -> None: + lib = Library() + + def make_core_straight(length: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((length, 0), pi, ptype="core") + return pat + + def make_core_sbend(jog: float) -> Pattern: + pat = Pattern() + pat.ports["A"] = Port((0, 0), 0, ptype="core") + pat.ports["B"] = Port((10, jog), pi, ptype="core") + return pat + + trans_pat = Pattern() + trans_pat.ports["EXT"] = Port((0, 0), 0, ptype="ext") + trans_pat.ports["CORE"] = Port((5, 0), pi, ptype="core") + lib["xin"] = trans_pat + + tool = ( + AutoTool() + .add_straight(make_core_straight, "core", "A", length_range=(1, 1e8)) + .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8)) + .add_transition(lib.abstract("xin"), "EXT", "CORE") + ) + + transition_offer, trans_port, trans_data = selected_offer( + tool, + "straight", + 5, + in_ptype="ext", + out_ptype="core", + ) + assert transition_offer.out_ptype == "core" + assert_allclose(trans_port.offset, [5, 0]) + assert isinstance(trans_data, AutoTool.ReusableData) + + s_offer, s_port, s_data = selected_offer(tool, "s", 4, length=15, in_ptype="core") + assert_allclose(s_port.offset, [10, 4]) + assert isinstance(s_data, AutoTool.GeneratedData) + assert s_offer.out_ptype == "core" + + p = Pather(lib, tools=tool, render='deferred') + p.ports["A"] = Port((0, 0), 0, ptype="ext") + p.jog("A", 4) + + assert_allclose(p.ports["A"].offset, [-15, -4]) + assert [step.opcode for step in p._paths["A"]] == ['L', 'S'] + assert isinstance(p._paths["A"][0].data, AutoTool.ReusableData) + assert isinstance(p._paths["A"][1].data, AutoTool.GeneratedData) diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py new file mode 100644 index 0000000..e2fd7ea --- /dev/null +++ b/masque/test/test_boolean.py @@ -0,0 +1,242 @@ +# ruff: noqa: PLC0415 +import pytest +import numpy +from numpy.testing import assert_allclose +from masque.pattern import Pattern +from masque.shapes.polygon import Polygon +from masque.repetition import Grid +from masque.library import Library +from masque.error import PatternError + + +def _poly_area(poly: Polygon) -> float: + verts = poly.vertices + x = verts[:, 0] + y = verts[:, 1] + return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) + +def test_layer_as_polygons_basic() -> None: + pat = Pattern() + pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) + + polys = pat.layer_as_polygons((1, 0), flatten=False) + assert len(polys) == 1 + assert isinstance(polys[0], Polygon) + assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + +def test_layer_as_polygons_repetition() -> None: + pat = Pattern() + rep = Grid(a_vector=(2, 0), a_count=2) + pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]], repetition=rep) + + polys = pat.layer_as_polygons((1, 0), flatten=False) + assert len(polys) == 2 + # First polygon at (0,0) + assert_allclose(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + # Second polygon at (2,0) + assert_allclose(polys[1].vertices, [[2, 0], [3, 0], [3, 1], [2, 1]]) + +def test_layer_as_polygons_flatten() -> None: + lib = Library() + + child = Pattern() + child.polygon((1, 0), [[0, 0], [1, 0], [1, 1]]) + lib['child'] = child + + parent = Pattern() + parent.ref('child', offset=(10, 10), rotation=numpy.pi/2) + + polys = parent.layer_as_polygons((1, 0), flatten=True, library=lib) + assert len(polys) == 1 + # Child vertices are rotated by the ref and then translated by the ref offset. + expected = numpy.array([[10, 10], [10, 11], [9, 11]]) + assert_allclose(polys[0].vertices, expected, atol=1e-10) + +def test_boolean_import_error() -> None: + from masque import boolean + # If pyclipper is not installed, this should raise ImportError + try: + import pyclipper # noqa: F401 + pytest.skip("pyclipper is installed, cannot test ImportError") + except ImportError: + with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): + boolean([], [], operation='union') + +def test_polygon_boolean_shortcut() -> None: + poly = Polygon([[0, 0], [1, 0], [1, 1]]) + # This should also raise ImportError if pyclipper is missing + try: + import pyclipper # noqa: F401 + pytest.skip("pyclipper is installed") + except ImportError: + with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): + poly.boolean(poly) + + +def test_boolean_intersection_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + result = boolean( + [Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])], + [Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])], + operation='intersection', + ) + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_polygon_boolean_shortcut_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + + poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) + result = poly.boolean( + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_boolean_union_difference_and_xor_with_pyclipper() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]) + rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) + + union = boolean([rect_a], [rect_b], operation='union') + assert len(union) == 1 + assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10) + assert_allclose(_poly_area(union[0]), 7, atol=1e-10) + + difference = boolean([rect_a], [rect_b], operation='difference') + assert len(difference) == 1 + assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10) + assert_allclose(_poly_area(difference[0]), 3, atol=1e-10) + + xor = boolean([rect_a], [rect_b], operation='xor') + assert len(xor) == 2 + assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10) + xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor) + assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))] + + +def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + raw_result = boolean( + [numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])], + numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + assert len(raw_result) == 1 + assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10) + + single_shape_result = boolean( + Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + operation='intersection', + ) + assert len(single_shape_result) == 1 + assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10) + + +def test_boolean_handles_multi_polygon_inputs() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + result = boolean( + [ + Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]), + Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]), + ], + [ + Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]), + Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]), + ], + operation='intersection', + ) + assert len(result) == 2 + assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10) + result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result) + assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))] + + +def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) + hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) + result = boolean([outer], [hole], operation='difference') + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) + assert_allclose(_poly_area(result[0]), 64, atol=1e-10) + + +def test_boolean_nested_hole_and_island_case() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]]) + hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]]) + island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]]) + + result = boolean([outer, island], [hole], operation='union') + + assert len(result) == 1 + assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10) + assert_allclose(_poly_area(result[0]), 100, atol=1e-10) + + +def test_boolean_empty_inputs_follow_set_semantics() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]) + + union = boolean([], [rect], operation='union') + assert len(union) == 1 + assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + intersection = boolean([], [rect], operation='intersection') + assert intersection == [] + + difference = boolean([], [rect], operation='difference') + assert difference == [] + + xor = boolean([], [rect], operation='xor') + assert len(xor) == 1 + assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_union = boolean([rect], [], operation='union') + assert len(clip_empty_union) == 1 + assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_intersection = boolean([rect], [], operation='intersection') + assert clip_empty_intersection == [] + + clip_empty_difference = boolean([rect], [], operation='difference') + assert len(clip_empty_difference) == 1 + assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + clip_empty_xor = boolean([rect], [], operation='xor') + assert len(clip_empty_xor) == 1 + assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10) + + +def test_boolean_invalid_inputs_raise_pattern_error() -> None: + pytest.importorskip("pyclipper") + from masque.utils.boolean import boolean + + rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) + + for bad in (123, object(), [123]): + with pytest.raises(PatternError, match='Unsupported type'): + boolean([rect], bad, operation='intersection') diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py new file mode 100644 index 0000000..6a0712e --- /dev/null +++ b/masque/test/test_build_library.py @@ -0,0 +1,485 @@ +from collections.abc import Iterator + +import pytest + +from ..builder import Pather +from ..error import BuildError +from ..library import BuildLibrary, BuildReport, ILibraryView, Library, cell, dangling_mode_t +from ..pattern import Pattern +from ..ports import Port + + +def _owned_by(report: BuildReport, owner: str) -> set[str]: + return { + name for name, prov in report.provenance.items() + if prov.owner_declared_name == owner + } + + +class _MetadataSource(ILibraryView): + def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: + self.mapping = mapping + self._child_graph = child_graph + self.loads = 0 + + def __getitem__(self, key: str) -> Pattern: + self.loads += 1 + return self.mapping[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.mapping) + + def __len__(self) -> int: + return len(self.mapping) + + def __contains__(self, key: object) -> bool: + return key in self.mapping + + def source_order(self) -> tuple[str, ...]: + return tuple(self.mapping) + + def child_graph(self, dangling: dangling_mode_t = 'error') -> dict[str, set[str]]: # noqa: ARG002 + return self._child_graph + + +def test_build_library_traces_declared_dependencies_out_of_order() -> None: + builder = BuildLibrary() + + def make_parent(lib: BuildLibrary) -> Pattern: + pat = Pattern() + pat.ref("child") + assert lib.abstract("child").name == "child" + return pat + + builder.cells.parent = cell(make_parent)(builder) + builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) + + built, report = builder.build() + + assert "parent" in built + assert "child" in built + assert report.dependency_graph["parent"] == frozenset({"child"}) + assert report.provenance["parent"].kind == "declared" + + +def test_build_library_tracks_helper_provenance_and_tree_merge_renames() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + tree = Library({"_helper": Pattern()}) + name_a = lib << tree + name_b = lib << tree + top = Pattern() + top.ref(name_a) + top.ref(name_b) + return top + + builder.cells.top = cell(make_top)(builder) + _built, report = builder.build() + + helpers = [ + (name, prov) for name, prov in report.provenance.items() + if prov.owner_declared_name == "top" and prov.kind == "helper" + ] + + assert "top" in _owned_by(report, "top") + assert len(helpers) == 2 + assert any(name != prov.requested_name for name, prov in helpers) + + +def test_build_library_authoring_tree_merge_renames_repeated_single_use_names() -> None: + builder = BuildLibrary() + tree = Library({"_helper": Pattern()}) + + name_a = builder << tree + name_b = builder << tree + built, report = builder.build() + + assert name_a == "_helper" + assert name_b != "_helper" + assert name_a in built + assert name_b in built + assert report.provenance[name_b].requested_name == name_b + + +def test_build_library_authoring_tree_merge_remaps_internal_refs() -> None: + builder = BuildLibrary() + builder["_helper"] = Pattern() + helper = Pattern() + top = Pattern() + top.ref("_helper") + + top_name = builder << Library({"_helper": helper, "top": top}) + built, _report = builder.build() + + assert top_name == "top" + assert "_helper" not in built[top_name].refs + assert any(name != "_helper" for name in built[top_name].refs) + + +def test_build_library_requires_build_session_for_reads_and_freezes_after_build() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(BuildError, match="validate\\(\\) or build\\(\\)"): + _ = builder["leaf"] + + with pytest.raises(BuildError, match="write-only"): + _ = builder.cells.leaf + + built, report = builder.build(output="library") + + assert isinstance(built, Library) + assert report.requested_roots == ("leaf",) + + with pytest.raises(BuildError, match="frozen"): + builder["later"] = Pattern() + with pytest.raises(BuildError, match="frozen"): + builder.build() + + +def test_build_library_validate_is_retryable_after_failure() -> None: + builder = BuildLibrary() + + def make_parent(lib: BuildLibrary) -> Pattern: + pat = Pattern() + pat.ref("child") + lib.abstract("child") + return pat + + builder.cells.parent = cell(make_parent)(builder) + + with pytest.raises(BuildError, match='Failed while building declared cell "parent"'): + builder.validate() + + builder["child"] = Pattern(ports={"p": Port((0, 0), 0)}) + report = builder.validate() + + assert report.dependency_graph["parent"] == frozenset({"child"}) + + +def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: + builder = BuildLibrary() + builder["child"] = Pattern() + + def make_parent() -> Pattern: + pat = Pattern() + pat.ref("child") + return pat + + builder.cells.parent = cell(make_parent)().depends_on("child") + report = builder.validate(names=("parent",)) + + assert report.requested_roots == ("parent",) + assert report.dependency_graph["parent"] == frozenset({"child"}) + + +def test_build_library_validate_rejects_removed_output_argument() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(TypeError): + builder.validate(output="library") # type: ignore[call-arg] + + +def test_build_library_rejects_unknown_build_output_mode() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + + with pytest.raises(ValueError, match="Unknown build output mode"): + builder.build(output="bad") # type: ignore[arg-type] + + +def test_build_library_allows_helper_writes_via_pather() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern(ports={"a": Port((0, 0), 0)}) + + def make_top(lib: BuildLibrary) -> Pattern: + helper = Pather(library=lib, ports="leaf", name="_route") + top = Pattern() + top.ref("_route") + top.ref("leaf") + top.ports.update(helper.pattern.ports) + return top + + builder.cells.top = cell(make_top)(builder) + _built, report = builder.build() + + helper_prov = report.provenance["_route"] + assert helper_prov.kind == "helper" + assert helper_prov.owner_declared_name == "top" + + +def test_build_library_contains_tracks_active_session_names() -> None: + builder = BuildLibrary() + builder["leaf"] = Pattern() + builder.add_source(Library({"src": Pattern()})) + + def make_top(lib: BuildLibrary) -> Pattern: + assert "leaf" in lib + assert "src" in lib + assert "_helper" not in lib + lib["_helper"] = Pattern() + assert "_helper" in lib + return Pattern() + + builder.cells.top = cell(make_top)(builder) + built, _report = builder.build() + + assert "_helper" in built + + +def test_build_library_preserves_source_cells_and_records_source_provenance() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + builder.cells.top = cell(lambda: Pattern())() + + built, report = builder.build() + + assert "src" in built + assert report.provenance["src"].kind == "source" + + +def test_build_library_add_source_can_rename_every_source_cell() -> None: + source = Library() + source["child"] = Pattern() + parent = Pattern() + parent.ref("child") + source["parent"] = parent + + builder = BuildLibrary() + rename_map = builder.add_source( + source, + rename_theirs=lambda _lib, name: f"mapped_{name}", + rename_when="always", + ) + built, report = builder.build() + + assert rename_map == { + "child": "mapped_child", + "parent": "mapped_parent", + } + assert "mapped_child" in built["mapped_parent"].refs + assert report.provenance["mapped_child"].requested_name == "child" + + +def test_build_library_authoring_tree_merge_keeps_source_view_lazy() -> None: + child = Pattern() + top = Pattern() + top.ref("child") + source = _MetadataSource( + {"child": child, "top": top}, + {"child": set(), "top": {"child"}}, + ) + + builder = BuildLibrary() + top_name = builder << source + built, _report = builder.build() + + assert top_name == "top" + assert "top" in built + assert source.loads == 0 + + +def test_build_library_authoring_source_tree_merge_returns_renamed_top() -> None: + existing = Pattern() + source_top = Pattern() + source = _MetadataSource( + {"_helper": source_top}, + {"_helper": set()}, + ) + + builder = BuildLibrary() + builder["_helper"] = existing + top_name = builder << source + built, _report = builder.build() + + assert top_name != "_helper" + assert top_name in built + assert source.loads == 0 + + +def test_build_library_authoring_source_tree_merge_remaps_renamed_child_on_materialization() -> None: + source_helper = Pattern() + source_top = Pattern() + source_top.ref("_helper") + source = _MetadataSource( + {"_helper": source_helper, "top": source_top}, + {"_helper": set(), "top": {"_helper"}}, + ) + + builder = BuildLibrary() + builder["_helper"] = Pattern() + top_name = builder << source + built, _report = builder.build(output="library") + + assert top_name == "top" + assert "_helper" not in built[top_name].refs + assert source.loads == 2 + + +def test_build_library_rejects_authoring_tree_le_before_mutating() -> None: + builder = BuildLibrary() + + with pytest.raises(BuildError, match="__le__"): + _abstract = builder <= Library({"leaf": Pattern()}) + + assert list(builder) == [] + + +def test_build_library_rejects_source_cells_added_after_add_source() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + source["late"] = Pattern() + + with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): + builder.build() + + +def test_build_library_rejects_source_cells_removed_after_add_source() -> None: + source = Library({"src": Pattern()}) + builder = BuildLibrary() + builder.add_source(source) + del source["src"] + + with pytest.raises(BuildError, match="Do not structurally mutate source libraries"): + builder.build() + + +def test_build_library_rejects_add_source_during_build() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib.add_source(Library({"src": Pattern()})) + return Pattern() + + builder.cells.top = cell(make_top)(builder) + + with pytest.raises(BuildError, match="add_source"): + builder.build() + + +def test_build_library_rejects_renaming_imported_source_cells_during_authoring() -> None: + builder = BuildLibrary() + builder.add_source(Library({"src": Pattern()})) + + with pytest.raises(BuildError, match="add_source"): + builder.rename("src", "renamed_src", move_references=True) + + +def test_build_library_rejects_renaming_declared_cells_during_authoring() -> None: + builder = BuildLibrary() + builder["declared"] = Pattern() + + with pytest.raises(BuildError, match='Cannot rename declared build cell "declared"'): + builder.rename("declared", "renamed_declared") + + +def test_build_library_helper_rename_updates_provenance_owner() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib["_helper"] = Pattern() + lib.rename("_helper", "final_helper") + top = Pattern() + top.ref("final_helper") + return top + + builder.cells.top = cell(make_top)(builder) + built, report = builder.build() + + assert "final_helper" in built + assert "_helper" not in built + owned = _owned_by(report, "top") + assert "final_helper" in owned + assert "_helper" not in owned + prov = report.provenance["final_helper"] + assert prov.kind == "helper" + assert prov.requested_name == "_helper" + + +def test_build_library_helper_delete_removes_provenance_and_ownership() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + lib["_helper"] = Pattern() + del lib["_helper"] + return Pattern() + + builder.cells.top = cell(make_top)(builder) + built, report = builder.build() + + assert "_helper" not in built + assert "_helper" not in report.provenance + assert _owned_by(report, "top") == {"top"} + + +def test_build_library_helper_rename_after_auto_rename_preserves_requested_name() -> None: + builder = BuildLibrary() + + def make_top(lib: BuildLibrary) -> Pattern: + tree = Library({"_helper": Pattern()}) + _ = lib << tree + renamed = lib << tree + lib.rename(renamed, "final_helper") + top = Pattern() + top.ref("_helper") + top.ref("final_helper") + return top + + builder.cells.top = cell(make_top)(builder) + built, report = builder.build() + + assert "final_helper" in built + prov = report.provenance["final_helper"] + assert prov.requested_name == "_helper" + + +def test_build_library_rejects_renaming_declared_or_source_cells_during_build() -> None: + declared = BuildLibrary() + declared["leaf"] = Pattern() + + def rename_declared(lib: BuildLibrary) -> Pattern: + lib.rename("leaf", "renamed_leaf") + return Pattern() + + declared.cells.top = cell(rename_declared)(declared) + with pytest.raises(BuildError, match='Cannot rename declared build cell "leaf"'): + declared.build() + + source = BuildLibrary() + source.add_source(Library({"src": Pattern()})) + + def rename_source(lib: BuildLibrary) -> Pattern: + lib.rename("src", "renamed_src") + return Pattern() + + source.cells.top = cell(rename_source)(source) + with pytest.raises(BuildError, match='Cannot rename imported source cell "src"'): + source.build() + + +def test_build_library_rejects_deleting_declared_or_source_cells_during_build() -> None: + declared = BuildLibrary() + declared["leaf"] = Pattern() + + def delete_declared(lib: BuildLibrary) -> Pattern: + del lib["leaf"] + return Pattern() + + declared.cells.top = cell(delete_declared)(declared) + with pytest.raises(BuildError, match='Cannot delete declared build cell "leaf"'): + declared.build() + + source = BuildLibrary() + source.add_source(Library({"src": Pattern()})) + + def delete_source(lib: BuildLibrary) -> Pattern: + del lib["src"] + return Pattern() + + source.cells.top = cell(delete_source)(source) + with pytest.raises(BuildError, match='Cannot delete imported source cell "src"'): + source.build() diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py new file mode 100644 index 0000000..53c0a8c --- /dev/null +++ b/masque/test/test_builder.py @@ -0,0 +1,173 @@ +import numpy +import pytest +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..builder import Pather +from ..builder.utils import ell +from ..error import BuildError +from ..library import Library +from ..pattern import Pattern +from ..ports import Port + + +def test_builder_public_imports() -> None: + from masque import PortPather as TopPortPather + from masque import RenderStep as TopRenderStep + from masque.builder import PortPather as BuilderPortPather + from masque.builder import RenderStep as BuilderRenderStep + + assert TopPortPather is BuilderPortPather + assert TopRenderStep is BuilderRenderStep + + +def test_builder_init() -> None: + lib = Library() + b = Pather(lib, name="mypat") + assert b.pattern is lib["mypat"] + assert b.library is lib + + +def test_builder_place() -> None: + lib = Library() + child = Pattern() + child.ports["A"] = Port((0, 0), 0) + lib["child"] = child + + b = Pather(lib) + b.place("child", offset=(10, 20), port_map={"A": "child_A"}) + + assert "child_A" in b.ports + assert_equal(b.ports["child_A"].offset, [10, 20]) + assert "child" in b.pattern.refs + + +def test_builder_plug() -> None: + lib = Library() + + wire = Pattern() + wire.ports["in"] = Port((0, 0), 0) + wire.ports["out"] = Port((10, 0), pi) + lib["wire"] = wire + + b = Pather(lib) + b.ports["start"] = Port((100, 100), 0) + + # Plug wire's "in" port into builder's "start" port + # Wire's "out" port should be renamed to "start" because thru=True (default) and wire has 2 ports + # builder start: (100, 100) rotation 0 + # wire in: (0, 0) rotation 0 + # wire out: (10, 0) rotation pi + # Plugging wire in (rot 0) to builder start (rot 0) means wire is rotated by pi (180 deg) + # so wire in is at (100, 100), wire out is at (100 - 10, 100) = (90, 100) + b.plug("wire", map_in={"start": "in"}) + + assert "start" in b.ports + assert_equal(b.ports["start"].offset, [90, 100]) + assert b.ports["start"].rotation is not None + assert_allclose(b.ports["start"].rotation, 0, atol=1e-10) + + +def test_builder_interface() -> None: + lib = Library() + source = Pattern() + source.ports["P1"] = Port((0, 0), 0) + lib["source"] = source + + b = Pather.interface("source", library=lib, name="iface") + assert "in_P1" in b.ports + assert "P1" in b.ports + assert b.pattern is lib["iface"] + + +def test_builder_set_dead() -> None: + lib = Library() + lib["sub"] = Pattern() + b = Pather(lib) + b.set_dead() + + 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 = Pather(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 = Pather(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 b.ports['B'].rotation is not None + assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) + + +def test_ell_validates_spacing_length() -> None: + ports = { + 'A': Port((0, 0), 0), + 'B': Port((0, 1), 0), + 'C': Port((0, 2), 0), + } + + with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): + ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3]) + + with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): + ell(ports, True, 'min_extension', 5, spacing=[]) + + +def test_ell_handles_array_spacing_when_ccw_none() -> None: + ports = { + 'A': Port((0, 0), 0), + 'B': Port((0, 1), 0), + } + + scalar = ell(ports, None, 'min_extension', 5, spacing=0) + array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0])) + assert scalar == array_zero + + with pytest.raises(BuildError, match='Spacing must be 0 or None'): + ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0])) diff --git a/masque/test/test_circle.py b/masque/test/test_circle.py new file mode 100644 index 0000000..1b4158e --- /dev/null +++ b/masque/test/test_circle.py @@ -0,0 +1,17 @@ +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Circle, Polygon + + +def test_circle_init() -> None: + c = Circle(radius=10, offset=(5, 5)) + assert c.radius == 10 + assert_equal(c.offset, [5, 5]) + +def test_circle_to_polygons() -> None: + c = Circle(radius=10) + polys = c.to_polygons(num_vertices=32) + assert len(polys) == 1 + assert isinstance(polys[0], Polygon) + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -10], [10, 10]], atol=1e-10) diff --git a/masque/test/test_curve_polygonization.py b/masque/test/test_curve_polygonization.py new file mode 100644 index 0000000..aaf4070 --- /dev/null +++ b/masque/test/test_curve_polygonization.py @@ -0,0 +1,26 @@ +from numpy import pi + +from ..shapes import Arc, Circle, Ellipse +from .helpers import assert_closed_edges_within + + +def test_shape_arclen() -> None: + e = Ellipse(radii=(10, 5)) + polys = e.to_polygons(max_arclen=5) + v = polys[0].vertices + assert_closed_edges_within(v, 5) + assert len(v) > 10 + + a = Arc(radii=(10, 10), angles=(0, pi / 2), width=2) + polys = a.to_polygons(max_arclen=2) + assert_closed_edges_within(polys[0].vertices, 2) + +def test_curve_polygonizers_clamp_large_max_arclen() -> None: + for shape in ( + Circle(radius=10), + Ellipse(radii=(10, 20)), + Arc(radii=(10, 20), angles=(0, 1), width=2), + ): + polys = shape.to_polygons(num_vertices=None, max_arclen=1e9) + assert len(polys) == 1 + assert len(polys[0].vertices) >= 3 diff --git a/masque/test/test_dxf.py b/masque/test/test_dxf.py new file mode 100644 index 0000000..442b62f --- /dev/null +++ b/masque/test/test_dxf.py @@ -0,0 +1,168 @@ +import io +import numpy +import ezdxf +from numpy.testing import assert_allclose +from pathlib import Path + +from ..pattern import Pattern +from ..library import Library +from ..shapes import Path as MPath, Polygon +from ..repetition import Grid +from ..file import dxf + + +def _matches_open_path(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: + return bool( + numpy.allclose(actual, expected) + or numpy.allclose(actual, expected[::-1]) + ) + + +def _matches_closed_vertices(actual: numpy.ndarray, expected: numpy.ndarray) -> bool: + return {tuple(row) for row in actual.tolist()} == {tuple(row) for row in expected.tolist()} + + +def test_dxf_roundtrip(tmp_path: Path): + lib = Library() + pat = Pattern() + + poly_verts = numpy.array([[0, 0], [10, 0], [10, 10], [0, 10]]) + pat.polygon("1", vertices=poly_verts) + + path_verts = numpy.array([[20, 0], [30, 0], [30, 10]]) + pat.path("2", vertices=path_verts, width=2) + + # Two-point paths remain paths rather than being polygonized. + path2_verts = numpy.array([[40, 0], [50, 10]]) + pat.path("3", vertices=path2_verts, width=0) + + subpat = Pattern() + subpat.polygon("sub", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = subpat + + pat.ref("sub", offset=(100, 100), repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=3)) + + lib["top"] = pat + + dxf_file = tmp_path / "test.dxf" + dxf.writefile(lib, "top", dxf_file) + + read_lib, _ = dxf.readfile(dxf_file) + + top_pat = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] + + polys = [s for s in top_pat.shapes["1"] if isinstance(s, Polygon)] + assert len(polys) >= 1 + poly_read = polys[0] + assert _matches_closed_vertices(poly_read.vertices, poly_verts) + + paths = [s for s in top_pat.shapes["2"] if isinstance(s, MPath)] + assert len(paths) >= 1 + path_read = paths[0] + assert _matches_open_path(path_read.vertices, path_verts) + assert path_read.width == 2 + + paths2 = [s for s in top_pat.shapes["3"] if isinstance(s, MPath)] + assert len(paths2) >= 1 + path2_read = paths2[0] + assert _matches_open_path(path2_read.vertices, path2_verts) + assert path2_read.width == 0 + + assert "sub" in read_lib + + found_grid = False + for target, reflist in top_pat.refs.items(): + if target.upper() == "SUB": + for ref in reflist: + if isinstance(ref.repetition, Grid): + assert ref.repetition.a_count == 2 + assert ref.repetition.b_count == 3 + assert_allclose(ref.repetition.a_vector, (10, 0)) + assert_allclose(ref.repetition.b_vector, (0, 10)) + found_grid = True + assert found_grid, f"Manhattan Grid repetition should have been preserved. Targets: {list(top_pat.refs.keys())}" + +def test_dxf_manhattan_precision(tmp_path: Path): + lib = Library() + sub = Pattern() + sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = sub + + top = Pattern() + angle = numpy.pi / 2 # 90 degrees + top.ref("sub", offset=(0, 0), rotation=angle, + repetition=Grid(a_vector=(10, 0), a_count=2, b_vector=(0, 10), b_count=2)) + + lib["top"] = top + + dxf_file = tmp_path / "precision.dxf" + dxf.writefile(lib, "top", dxf_file) + + # Near-integer rotated basis vectors round-trip as a Manhattan Grid. + read_lib, _ = dxf.readfile(dxf_file) + read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] + + target_name = next(k for k in read_top.refs if k.upper() == "SUB") + ref = read_top.refs[target_name][0] + assert isinstance(ref.repetition, Grid), "Grid should be preserved for 90-degree rotation" + + +def test_dxf_rotated_grid_roundtrip_preserves_basis_and_counts(tmp_path: Path): + lib = Library() + sub = Pattern() + sub.polygon("1", vertices=[[0, 0], [1, 0], [1, 1]]) + lib["sub"] = sub + + top = Pattern() + top.ref( + "sub", + offset=(0, 0), + rotation=numpy.pi / 2, + repetition=Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2), + ) + lib["top"] = top + + dxf_file = tmp_path / "rotated_grid.dxf" + dxf.writefile(lib, "top", dxf_file) + + read_lib, _ = dxf.readfile(dxf_file) + read_top = read_lib.get("Model") or read_lib.get("top") or list(read_lib.values())[0] + + target_name = next(k for k in read_top.refs if k.upper() == "SUB") + ref = read_top.refs[target_name][0] + assert isinstance(ref.repetition, Grid) + actual = ref.repetition.displacements + expected = Grid(a_vector=(10, 0), a_count=3, b_vector=(0, 20), b_count=2).displacements + assert_allclose( + actual[numpy.lexsort((actual[:, 1], actual[:, 0]))], + expected[numpy.lexsort((expected[:, 1], expected[:, 0]))], + ) + + +def test_dxf_read_legacy_polyline() -> None: + doc = ezdxf.new() + msp = doc.modelspace() + msp.add_polyline2d([(0, 0), (10, 0), (10, 10)], dxfattribs={"layer": "legacy"}).close(True) + + stream = io.StringIO() + doc.write(stream) + stream.seek(0) + + read_lib, _ = dxf.read(stream) + top_pat = read_lib.get("Model") or list(read_lib.values())[0] + + polys = [shape for shape in top_pat.shapes["legacy"] if isinstance(shape, Polygon)] + assert len(polys) == 1 + assert _matches_closed_vertices(polys[0].vertices, numpy.array([[0, 0], [10, 0], [10, 10]])) + + +def test_dxf_read_ignores_unreferenced_setup_blocks() -> None: + lib = Library({"top": Pattern()}) + stream = io.StringIO() + + dxf.write(lib, "top", stream) + stream.seek(0) + + read_lib, _ = dxf.read(stream) + + assert set(read_lib) == {"Model"} diff --git a/masque/test/test_ellipse.py b/masque/test/test_ellipse.py new file mode 100644 index 0000000..1125fb1 --- /dev/null +++ b/masque/test/test_ellipse.py @@ -0,0 +1,29 @@ +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Ellipse + + +def test_ellipse_init() -> None: + e = Ellipse(radii=(10, 5), offset=(1, 2), rotation=pi / 4) + assert_equal(e.radii, [10, 5]) + assert_equal(e.offset, [1, 2]) + assert e.rotation == pi / 4 + +def test_ellipse_to_polygons() -> None: + e = Ellipse(radii=(10, 5)) + polys = e.to_polygons(num_vertices=64) + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_allclose(bounds, [[-10, -5], [10, 5]], atol=1e-10) + +def test_rotated_ellipse_bounds_match_polygonized_geometry() -> None: + ellipse = Ellipse(radii=(10, 20), rotation=pi / 4, offset=(100, 200)) + bounds = ellipse.get_bounds_single() + poly_bounds = ellipse.to_polygons(num_vertices=8192)[0].get_bounds_single() + assert_allclose(bounds, poly_bounds, atol=1e-3) + +def test_ellipse_integer_radii_scale_cleanly() -> None: + ellipse = Ellipse(radii=(10, 20)) + ellipse.scale_by(0.5) + assert_allclose(ellipse.radii, [5, 10]) diff --git a/masque/test/test_fdfd.py b/masque/test/test_fdfd.py new file mode 100644 index 0000000..2b4f3d3 --- /dev/null +++ b/masque/test/test_fdfd.py @@ -0,0 +1,24 @@ +# ruff: noqa +# ruff: noqa: ARG001 + + +import dataclasses +import pytest # type: ignore +import numpy +from numpy import pi +from numpy.typing import NDArray +# from numpy.testing import assert_allclose, assert_array_equal + +from .. import Pattern, Arc, Circle + + +def test_circle_mirror(): + cc = Circle(radius=4, offset=(10, 20)) + cc.flip_across(axis=0) # flip across y=0 + assert cc.offset[0] == 10 + assert cc.offset[1] == -20 + assert cc.radius == 4 + cc.flip_across(axis=1) # flip across x=0 + assert cc.offset[0] == -10 + assert cc.offset[1] == -20 + assert cc.radius == 4 diff --git a/masque/test/test_file_format_roundtrip.py b/masque/test/test_file_format_roundtrip.py new file mode 100644 index 0000000..36c4ce4 --- /dev/null +++ b/masque/test/test_file_format_roundtrip.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import cast +import pytest +from numpy.testing import assert_allclose + +from ..pattern import Pattern +from ..library import Library +from ..shapes import Path as MPath, Circle, Polygon, RectCollection +from ..repetition import Grid, Arbitrary + +def create_test_library(for_gds: bool = False) -> Library: + lib = Library() + + pat_poly = Pattern() + pat_poly.polygon((1, 0), vertices=[[0, 0], [10, 0], [5, 10]]) + lib["polygons"] = pat_poly + + pat_paths = Pattern() + pat_paths.path((2, 0), vertices=[[0, 0], [20, 0]], width=2, cap=MPath.Cap.Flush) + pat_paths.path((2, 1), vertices=[[0, 10], [20, 10]], width=2, cap=MPath.Cap.Square) + if for_gds: + pat_paths.path((2, 2), vertices=[[0, 20], [20, 20]], width=2, cap=MPath.Cap.Circle) + pat_paths.path((2, 3), vertices=[[0, 30], [20, 30]], width=2, cap=MPath.Cap.SquareCustom, cap_extensions=(1, 5)) + lib["paths"] = pat_paths + + pat_circles = Pattern() + if for_gds: + pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10)).to_polygons()[0]) + else: + pat_circles.shapes[(3, 0)].append(Circle(radius=5, offset=(10, 10))) + lib["circles"] = pat_circles + + pat_refs = Pattern() + pat_refs.ref("polygons", offset=(0, 0)) + pat_refs.ref("polygons", offset=(100, 0), repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 20), b_count=2)) + pat_refs.ref("polygons", offset=(0, 100), repetition=Arbitrary(displacements=[[0, 0], [10, 20], [30, -10]])) + lib["refs"] = pat_refs + + pat_rep_shapes = Pattern() + poly_rep = Polygon(vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], repetition=Grid(a_vector=(10, 0), a_count=5)) + pat_rep_shapes.shapes[(4, 0)].append(poly_rep) + lib["rep_shapes"] = pat_rep_shapes + + if for_gds: + lib.wrap_repeated_shapes() + + return lib + +def test_gdsii_full_roundtrip(tmp_path: Path) -> None: + from ..file import gdsii + lib = create_test_library(for_gds=True) + gds_file = tmp_path / "full_test.gds" + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, _ = gdsii.readfile(gds_file) + + for name in lib: + assert name in read_lib + + read_paths = read_lib["paths"] + p_flush = cast("MPath", read_paths.shapes[(2, 0)][0]) + assert p_flush.cap == MPath.Cap.Flush + + p_square = cast("MPath", read_paths.shapes[(2, 1)][0]) + assert p_square.cap == MPath.Cap.Square + + p_circle = cast("MPath", read_paths.shapes[(2, 2)][0]) + assert p_circle.cap == MPath.Cap.Circle + + p_custom = cast("MPath", read_paths.shapes[(2, 3)][0]) + assert p_custom.cap == MPath.Cap.SquareCustom + assert p_custom.cap_extensions is not None + assert_allclose(p_custom.cap_extensions, (1, 5)) + + read_refs = read_lib["refs"] + assert len(read_refs.refs["polygons"]) >= 3 # Simple, Grid (becomes 1 AREF), Arbitrary (becomes 3 SREFs) + + arefs = [r for r in read_refs.refs["polygons"] if r.repetition is not None] + assert len(arefs) == 1 + assert isinstance(arefs[0].repetition, Grid) + assert arefs[0].repetition.a_count == 3 + assert arefs[0].repetition.b_count == 2 + + # GDS stores repeated shapes through refs created by wrap_repeated_shapes(). + assert len(read_lib["rep_shapes"].refs) > 0 + +def test_oasis_full_roundtrip(tmp_path: Path) -> None: + pytest.importorskip("fatamorgana") + from ..file import oasis + lib = create_test_library(for_gds=False) + oas_file = tmp_path / "full_test.oas" + oasis.writefile(lib, oas_file, units_per_micron=1000) + + read_lib, _ = oasis.readfile(oas_file) + + for name in lib: + assert name in read_lib + + read_circles = read_lib["circles"] + assert isinstance(read_circles.shapes[(3, 0)][0], Circle) + assert read_circles.shapes[(3, 0)][0].radius == 5 + + read_paths = read_lib["paths"] + assert cast("MPath", read_paths.shapes[(2, 0)][0]).cap == MPath.Cap.Flush + assert cast("MPath", read_paths.shapes[(2, 1)][0]).cap == MPath.Cap.Square + + read_rep_shapes = read_lib["rep_shapes"] + poly = read_rep_shapes.shapes[(4, 0)][0] + assert poly.repetition is not None + assert isinstance(poly.repetition, Grid) + assert poly.repetition.a_count == 5 + + +def test_gdsii_rect_collection_roundtrip(tmp_path: Path) -> None: + from ..file import gdsii + + lib = Library() + pat = Pattern() + pat.shapes[(5, 0)].append( + RectCollection( + rects=[[0, 0, 10, 5], [20, -5, 30, 10]], + annotations={'1': ['rects']}, + ) + ) + lib['rects'] = pat + + gds_file = tmp_path / 'rect_collection.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, _ = gdsii.readfile(gds_file) + polys = read_lib['rects'].shapes[(5, 0)] + + assert len(polys) == 2 + assert all(isinstance(poly, Polygon) for poly in polys) + assert_allclose(polys[0].vertices, [[0, 0], [0, 5], [10, 5], [10, 0]]) + assert_allclose(polys[1].vertices, [[20, -5], [20, 10], [30, 10], [30, -5]]) + assert polys[0].annotations == {'1': ['rects']} + assert polys[1].annotations == {'1': ['rects']} diff --git a/masque/test/test_gdsii.py b/masque/test/test_gdsii.py new file mode 100644 index 0000000..7a2f5b1 --- /dev/null +++ b/masque/test/test_gdsii.py @@ -0,0 +1,80 @@ +from pathlib import Path +from typing import cast +import numpy +import pytest +from numpy.testing import assert_equal, assert_allclose + +from ..error import LibraryError +from ..pattern import Pattern +from ..library import Library +from ..file import gdsii +from ..shapes import Path as MPath, Polygon + + +def test_gdsii_roundtrip(tmp_path: Path) -> None: + lib = Library() + + # Simple polygon cell + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["poly_cell"] = pat1 + + # Path cell + pat2 = Pattern() + pat2.path((2, 5), vertices=[[0, 0], [100, 0]], width=10) + lib["path_cell"] = pat2 + + # Cell with Ref + pat3 = Pattern() + pat3.ref("poly_cell", offset=(50, 50), rotation=numpy.pi / 2) + lib["ref_cell"] = pat3 + + gds_file = tmp_path / "test.gds" + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, info = gdsii.readfile(gds_file) + + assert "poly_cell" in read_lib + assert "path_cell" in read_lib + assert "ref_cell" in read_lib + + # Check polygon + read_poly = cast("Polygon", read_lib["poly_cell"].shapes[(1, 0)][0]) + # GDSII closes polygons, so it might have an extra vertex or different order + assert len(read_poly.vertices) >= 4 + # Check bounds as a proxy for geometry correctness + assert_equal(read_lib["poly_cell"].get_bounds(), [[0, 0], [10, 10]]) + + # Check path + read_path = cast("MPath", read_lib["path_cell"].shapes[(2, 5)][0]) + assert isinstance(read_path, MPath) + assert read_path.width == 10 + assert_equal(read_path.vertices, [[0, 0], [100, 0]]) + + # Check Ref + read_ref = read_lib["ref_cell"].refs["poly_cell"][0] + assert_equal(read_ref.offset, [50, 50]) + assert_allclose(read_ref.rotation, numpy.pi / 2, atol=1e-5) + + +def test_gdsii_annotations(tmp_path: Path) -> None: + lib = Library() + pat = Pattern() + # GDS only supports integer keys in range [1, 126] for properties + pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]], annotations={"1": ["hello"]}) + lib["cell"] = pat + + gds_file = tmp_path / "test_ann.gds" + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + read_lib, _ = gdsii.readfile(gds_file) + read_ann = read_lib["cell"].shapes[(1, 0)][0].annotations + assert read_ann is not None + assert read_ann["1"] == ["hello"] + + +def test_gdsii_check_valid_names_validates_generator_lengths() -> None: + names = (name for name in ("a" * 40,)) + + with pytest.raises(LibraryError, match="invalid names"): + gdsii.check_valid_names(names) diff --git a/masque/test/test_gdsii_arrow.py b/masque/test/test_gdsii_arrow.py new file mode 100644 index 0000000..47f9ac8 --- /dev/null +++ b/masque/test/test_gdsii_arrow.py @@ -0,0 +1,604 @@ +from pathlib import Path +import subprocess +import sys +import textwrap + +import klamath +import numpy +import pytest + +pytest.importorskip('pyarrow') + +from .. import Ref, Label, PatternError +from ..library import Library +from ..pattern import Pattern +from ..repetition import Grid +from ..shapes import Path as MPath, Polygon, PolyCollection, RectCollection +from ..file import gdsii +from ..file.gdsii import arrow as gdsii_arrow +from ..file.gdsii.perf import write_fixture + + +if not gdsii_arrow.is_available(): + pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) + + +def _annotations_key(annotations: dict[str, list[object]] | None) -> tuple[tuple[str, tuple[object, ...]], ...] | None: + if not annotations: + return None + return tuple(sorted((key, tuple(values)) for key, values in annotations.items())) + + +def _coord_key(values: object) -> tuple[int, ...] | tuple[tuple[int, int], ...]: + arr = numpy.rint(numpy.asarray(values, dtype=float)).astype(int) + if arr.ndim == 1: + return tuple(arr.tolist()) + return tuple(tuple(row.tolist()) for row in arr) + + +def _canonical_polygon_key(vertices: object) -> tuple[tuple[int, int], ...]: + arr = numpy.rint(numpy.asarray(vertices, dtype=float)).astype(int) + rows = [tuple(tuple(row.tolist()) for row in numpy.roll(arr, -shift, axis=0)) for shift in range(arr.shape[0])] + rev = arr[::-1] + rows.extend(tuple(tuple(row.tolist()) for row in numpy.roll(rev, -shift, axis=0)) for shift in range(rev.shape[0])) + return min(rows) + + +def _shape_key(shape: object, layer: tuple[int, int]) -> list[tuple[object, ...]]: + if isinstance(shape, MPath): + cap_extensions = None if shape.cap_extensions is None else _coord_key(shape.cap_extensions) + return [( + 'path', + layer, + _coord_key(shape.vertices), + _coord_key(shape.offset), + int(round(float(shape.width))), + shape.cap.name, + cap_extensions, + _annotations_key(shape.annotations), + )] + + keys = [] + for poly in shape.to_polygons(): + keys.append(( + 'polygon', + layer, + _canonical_polygon_key(poly.vertices), + _coord_key(poly.offset), + _annotations_key(poly.annotations), + )) + return keys + + +def _ref_keys(target: str, ref: object) -> list[tuple[object, ...]]: + keys = [] + for transform in ref.as_transforms(): + keys.append(( + target, + _coord_key(transform[:2]), + round(float(transform[2]), 8), + round(float(transform[4]), 8), + bool(int(round(float(transform[3])))), + _annotations_key(ref.annotations), + )) + return keys + + +def _label_key(layer: tuple[int, int], label: object) -> tuple[object, ...]: + return ( + layer, + label.string, + _coord_key(label.offset), + _annotations_key(label.annotations), + ) + + +def _pattern_summary(pattern: Pattern) -> dict[str, object]: + shape_keys: list[tuple[object, ...]] = [] + for layer, shapes in pattern.shapes.items(): + for shape in shapes: + shape_keys.extend(_shape_key(shape, layer)) + + ref_keys: list[tuple[object, ...]] = [] + for target, refs in pattern.refs.items(): + for ref in refs: + ref_keys.extend(_ref_keys(target, ref)) + + label_keys = [ + _label_key(layer, label) + for layer, labels in pattern.labels.items() + for label in labels + ] + + return { + 'shapes': sorted(shape_keys), + 'refs': sorted(ref_keys), + 'labels': sorted(label_keys), + } + + +def _library_summary(lib: Library) -> dict[str, dict[str, object]]: + return {name: _pattern_summary(pattern) for name, pattern in lib.items()} + + +def _make_arrow_test_library() -> Library: + lib = Library() + + leaf = Pattern() + leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]], annotations={'1': ['leaf-poly']}) + leaf.polygon((2, 0), vertices=[[40, 0], [50, 0], [50, 10], [40, 10]]) + leaf.polygon((1, 0), vertices=[[20, 0], [30, 0], [30, 10], [20, 10]]) + leaf.polygon((1, 0), vertices=[[80, 0], [90, 0], [90, 10], [80, 10]]) + leaf.polygon((2, 0), vertices=[[60, 0], [70, 0], [70, 10], [60, 10]], annotations={'18': ['leaf-poly-2']}) + leaf.label((10, 0), string='LEAF', offset=(3, 4), annotations={'10': ['leaf-label']}) + lib['leaf'] = leaf + + child = Pattern() + child.path( + (2, 0), + vertices=[[0, 0], [15, 5], [30, 5]], + width=6, + cap=MPath.Cap.SquareCustom, + cap_extensions=(2, 4), + annotations={'2': ['child-path']}, + ) + child.label((11, 0), string='CHILD', offset=(7, 8), annotations={'11': ['child-label']}) + child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25, annotations={'12': ['child-ref']}) + lib['child'] = child + + sibling = Pattern() + sibling.polygon((3, 0), vertices=[[0, 0], [5, 0], [5, 6], [0, 6]]) + sibling.label((12, 0), string='SIB', offset=(1, 2), annotations={'13': ['sib-label']}) + sibling.ref( + 'leaf', + offset=(-50, 60), + repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2), + annotations={'14': ['sib-ref']}, + ) + lib['sibling'] = sibling + + fanout = Pattern() + fanout.ref('leaf', offset=(0, 0)) + fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1) + fanout.ref('leaf', offset=(20, 0)) + fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3)) + fanout.ref('child', offset=(40, 0), mirrored=True, rotation=numpy.pi / 4, scale=1.2, + repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2)) + fanout.ref('leaf', offset=(50, 0), repetition=Grid(a_vector=(6, 0), a_count=3, b_vector=(0, 8), b_count=2)) + fanout.ref('leaf', offset=(60, 0), annotations={'19': ['fanout-sref']}) + fanout.ref('child', offset=(70, 0), repetition=Grid(a_vector=(4, 0), a_count=2, b_vector=(0, 5), b_count=2), + annotations={'20': ['fanout-aref']}) + lib['fanout'] = fanout + + top = Pattern() + top.ref('child', offset=(500, 600), annotations={'15': ['top-child-ref']}) + top.ref('sibling', offset=(-100, 50), rotation=numpy.pi, annotations={'16': ['top-sibling-ref']}) + top.ref('fanout', offset=(250, -75)) + top.label((13, 0), string='TOP', offset=(0, 0), annotations={'17': ['top-label']}) + lib['top'] = top + + return lib + + +def _write_invalid_path_type_fixture(path: Path) -> None: + with path.open('wb') as stream: + header = klamath.library.FileHeader( + name=b'test', + user_units_per_db_unit=1.0, + meters_per_db_unit=1e-9, + ) + header.write(stream) + elem = klamath.elements.Path( + layer=(1, 0), + path_type=3, + width=10, + extension=(0, 0), + xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32), + properties={}, + ) + klamath.library.write_struct(stream, name=b'top', elements=[elem]) + klamath.records.ENDLIB.write(stream, None) + + +def test_gdsii_arrow_matches_gdsii_readfile(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'arrow_roundtrip.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + canonical_lib, canonical_info = gdsii.readfile(gds_file) + arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file) + + assert canonical_info == arrow_info + assert _library_summary(canonical_lib) == _library_summary(arrow_lib) + + +def test_gdsii_arrow_matches_gdsii_readfile_for_gzipped_file(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'arrow_roundtrip.gds.gz' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + canonical_lib, canonical_info = gdsii.readfile(gds_file) + arrow_lib, arrow_info = gdsii_arrow.readfile(gds_file) + + assert canonical_info == arrow_info + assert _library_summary(canonical_lib) == _library_summary(arrow_lib) + + +def test_gdsii_arrow_readfile_arrow_returns_native_payload(tmp_path: Path) -> None: + gds_file = tmp_path / 'many_cells_native.gds' + manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) + + libarr, info = gdsii_arrow.readfile_arrow(gds_file) + + assert info['name'] == manifest.library_name + assert libarr['lib_name'].as_py() == manifest.library_name + assert len(libarr['cells']) == manifest.cells + assert 0 < len(libarr['layers']) <= manifest.layers + + +def test_gdsii_arrow_readfile_arrow_reads_gzipped_file(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'native_payload.gds.gz' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr, info = gdsii_arrow.readfile_arrow(gds_file) + + assert info['name'] == 'masque-klamath' + assert libarr['lib_name'].as_py() == 'masque-klamath' + assert len(libarr['cells']) == len(lib) + assert len(libarr['layers']) > 0 + + +def test_gdsii_arrow_removed_raw_mode_arg(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'removed_raw_mode.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr, _ = gdsii_arrow.readfile_arrow(gds_file) + + with pytest.raises(TypeError): + gdsii_arrow.readfile(gds_file, raw_mode=False) + + with pytest.raises(TypeError): + gdsii_arrow.read_arrow(libarr, raw_mode=False) + + +def test_gdsii_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None: + gds_file = tmp_path / 'invalid.gds' + gds_file.write_bytes(b'not-a-gds') + + script = textwrap.dedent(f""" + from masque.file.gdsii import arrow as gdsii_arrow + try: + gdsii_arrow.readfile({str(gds_file)!r}) + except Exception as exc: + print(type(exc).__module__) + print(type(exc).__qualname__) + print(exc) + else: + raise SystemExit('expected gdsii_arrow.readfile() to fail') + """) + result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False) + + assert result.returncode == 0, result.stderr + assert 'klamath.basic' in result.stdout + assert 'KlamathError' in result.stdout + + +def test_gdsii_arrow_reads_small_perf_fixture(tmp_path: Path) -> None: + gds_file = tmp_path / 'many_cells_smoke.gds' + manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) + + lib, info = gdsii_arrow.readfile(gds_file) + + assert info['name'] == manifest.library_name + assert len(lib) == manifest.cells + assert 'TOP' in lib + assert sum(len(refs) for refs in lib['TOP'].refs.values()) > 0 + + +def test_gdsii_arrow_degenerate_aref_decodes_as_single_transform(tmp_path: Path) -> None: + lib = Library() + leaf = Pattern() + leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) + lib['leaf'] = leaf + + top = Pattern() + top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1)) + lib['top'] = top + + gds_file = tmp_path / 'degenerate_aref.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + canonical_lib, _ = gdsii.readfile(gds_file) + arrow_lib, _ = gdsii_arrow.readfile(gds_file) + assert _library_summary(arrow_lib) == _library_summary(canonical_lib) + + decoded_ref = arrow_lib['top'].refs['leaf'][0] + assert decoded_ref.repetition is None + + +def test_gdsii_arrow_plain_srefs_decode_without_arbitrary(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'plain_srefs.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + arrow_lib, _ = gdsii_arrow.readfile(gds_file) + fanout = arrow_lib['fanout'] + + plain_leaf_refs = [ + ref + for ref in fanout.refs['leaf'] + if ref.annotations is None and ref.repetition is None + ] + assert len(plain_leaf_refs) == 2 + assert all(type(ref.repetition) is not Grid for ref in plain_leaf_refs) + + +def test_gdsii_arrow_degenerate_aref_schema_normalizes_to_sref(tmp_path: Path) -> None: + lib = Library() + leaf = Pattern() + leaf.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) + lib['leaf'] = leaf + + top = Pattern() + top.ref('leaf', offset=(100, 200), repetition=Grid(a_vector=(7, 0), a_count=1, b_vector=(0, 9), b_count=1)) + lib['top'] = top + + gds_file = tmp_path / 'degenerate_aref_schema.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr = gdsii_arrow._read_to_arrow(gds_file)[0] + cells = libarr['cells'].values + cell_ids = cells.field('id').to_numpy() + cell_names = libarr['cell_names'].as_py() + top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top') + + srefs = cells.field('srefs')[top_index].as_py() + arefs = cells.field('arefs')[top_index].as_py() + + assert len(srefs) == 1 + assert len(arefs) == 0 + assert cell_names[srefs[0]['target']] == 'leaf' + + +def test_gdsii_arrow_boundary_batch_schema(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'arrow_batches.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr = gdsii_arrow._read_to_arrow(gds_file)[0] + cells = libarr['cells'].values + cell_ids = cells.field('id').to_numpy() + cell_names = libarr['cell_names'].as_py() + layer_table = [ + ((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF) + for layer in libarr['layers'].values.to_numpy() + ] + + leaf_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'leaf') + + rect_batches = cells.field('rect_batches')[leaf_index].as_py() + boundary_batches = cells.field('boundary_batches')[leaf_index].as_py() + boundary_props = cells.field('boundary_props')[leaf_index].as_py() + + assert len(rect_batches) == 2 + assert len(boundary_batches) == 0 + assert len(boundary_props) == 2 + + rects_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in rect_batches} + assert rects_by_layer[(1, 0)]['rects'] == [20, 0, 30, 10, 80, 0, 90, 10] + assert rects_by_layer[(2, 0)]['rects'] == [40, 0, 50, 10] + + props_by_layer = {tuple(layer_table[entry['layer']]): entry for entry in boundary_props} + assert sorted(props_by_layer) == [(1, 0), (2, 0)] + assert props_by_layer[(1, 0)]['properties'][0]['value'] == 'leaf-poly' + assert props_by_layer[(2, 0)]['properties'][0]['value'] == 'leaf-poly-2' + + +def test_gdsii_arrow_rect_batch_schema_for_mixed_layer(tmp_path: Path) -> None: + lib = Library() + top = Pattern() + top.shapes[(1, 0)].append(RectCollection(rects=[[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10], [60, 0, 70, 10]])) + top.polygon((1, 0), vertices=[[80, 0], [85, 10], [90, 0]]) + top.polygon((1, 0), vertices=[[100, 0], [105, 10], [110, 0]]) + lib['top'] = top + + gds_file = tmp_path / 'arrow_rect_batches.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr = gdsii_arrow._read_to_arrow(gds_file)[0] + cells = libarr['cells'].values + cell_ids = cells.field('id').to_numpy() + cell_names = libarr['cell_names'].as_py() + layer_table = [ + ((int(layer) >> 16) & 0xFFFF, int(layer) & 0xFFFF) + for layer in libarr['layers'].values.to_numpy() + ] + top_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'top') + + rect_batches = cells.field('rect_batches')[top_index].as_py() + boundary_batches = cells.field('boundary_batches')[top_index].as_py() + + assert len(rect_batches) == 1 + assert tuple(layer_table[rect_batches[0]['layer']]) == (1, 0) + assert rect_batches[0]['rects'] == [ + 0, 0, 10, 10, + 20, 0, 30, 10, + 40, 0, 50, 10, + 60, 0, 70, 10, + ] + + assert len(boundary_batches) == 1 + assert tuple(layer_table[boundary_batches[0]['layer']]) == (1, 0) + assert boundary_batches[0]['vertex_offsets'] == [0, 3] + + +def test_gdsii_arrow_ref_schema(tmp_path: Path) -> None: + lib = _make_arrow_test_library() + gds_file = tmp_path / 'arrow_ref_batches.gds' + gdsii.writefile(lib, gds_file, meters_per_unit=1e-9) + + libarr = gdsii_arrow._read_to_arrow(gds_file)[0] + cells = libarr['cells'].values + cell_ids = cells.field('id').to_numpy() + cell_names = libarr['cell_names'].as_py() + + fanout_index = next(ii for ii, cell_id in enumerate(cell_ids) if cell_names[cell_id] == 'fanout') + + srefs = cells.field('srefs')[fanout_index].as_py() + arefs = cells.field('arefs')[fanout_index].as_py() + sref_props = cells.field('sref_props')[fanout_index].as_py() + aref_props = cells.field('aref_props')[fanout_index].as_py() + + sref_target_ids = [entry['target'] for entry in srefs] + sref_targets = [cell_names[target] for target in sref_target_ids] + assert sorted(sref_targets) == ['child', 'leaf', 'leaf'] + assert sref_target_ids == sorted(sref_target_ids) + sref_by_target = {} + for entry in srefs: + sref_by_target.setdefault(cell_names[entry['target']], []).append(entry) + assert [entry['invert_y'] for entry in sref_by_target['child']] == [True] + assert [entry['scale'] for entry in sref_by_target['child']] == pytest.approx([1.1]) + assert len(sref_by_target['leaf']) == 2 + + aref_target_ids = [entry['target'] for entry in arefs] + aref_targets = [cell_names[target] for target in aref_target_ids] + assert sorted(aref_targets) == ['child', 'leaf', 'leaf'] + assert aref_target_ids == sorted(aref_target_ids) + aref_by_target = {} + for entry in arefs: + aref_by_target.setdefault(cell_names[entry['target']], []).append(entry) + assert [entry['invert_y'] for entry in aref_by_target['child']] == [True] + assert [entry['scale'] for entry in aref_by_target['child']] == pytest.approx([1.2]) + assert len(aref_by_target['leaf']) == 2 + + assert len(sref_props) == 1 + assert cell_names[sref_props[0]['target']] == 'leaf' + assert sref_props[0]['properties'][0]['value'] == 'fanout-sref' + + assert len(aref_props) == 1 + assert cell_names[aref_props[0]['target']] == 'child' + assert aref_props[0]['properties'][0]['value'] == 'fanout-aref' + + +def test_gdsii_arrow_invalid_path_type_matches_gdsii(tmp_path: Path) -> None: + gds_file = tmp_path / 'invalid_path_type.gds' + _write_invalid_path_type_fixture(gds_file) + + with pytest.raises(PatternError, match='Unrecognized path type: 3'): + gdsii.readfile(gds_file) + + with pytest.raises(PatternError, match='Unrecognized path type: 3'): + gdsii_arrow.readfile(gds_file) + + +def test_raw_ref_grid_label_constructors_match_public() -> None: + raw_grid = Grid._from_raw( + a_vector=numpy.array([20, 0]), + a_count=3, + b_vector=numpy.array([0, 30]), + b_count=2, + ) + public_grid = Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2) + assert raw_grid == public_grid + + raw_poly = Polygon._from_raw( + vertices=numpy.array([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]), + annotations={'1': ['poly']}, + ) + public_poly = Polygon( + vertices=[[0, 0], [5, 0], [5, 5], [0, 5]], + annotations={'1': ['poly']}, + ) + assert raw_poly == public_poly + + raw_poly_collection = PolyCollection._from_raw( + vertex_lists=numpy.array([ + [0.0, 0.0], [2.0, 0.0], [2.0, 2.0], + [10.0, 10.0], [12.0, 10.0], [12.0, 12.0], + ]), + vertex_offsets=numpy.array([0, 3], dtype=numpy.uint32), + annotations={'2': ['pc']}, + ) + public_poly_collection = PolyCollection( + vertex_lists=[[0, 0], [2, 0], [2, 2], [10, 10], [12, 10], [12, 12]], + vertex_offsets=[0, 3], + annotations={'2': ['pc']}, + ) + assert raw_poly_collection == public_poly_collection + assert [tuple(s.indices(len(raw_poly_collection.vertex_lists))) for s in raw_poly_collection.vertex_slices] == [(0, 3, 1), (3, 6, 1)] + + raw_rect_collection = RectCollection._from_raw( + rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]), + annotations={'3': ['rects']}, + ) + public_rect_collection = RectCollection( + rects=[[0, 0, 5, 5], [10, 10, 12, 12]], + annotations={'3': ['rects']}, + ) + assert raw_rect_collection == public_rect_collection + + raw_ref_empty = Ref._from_raw( + offset=numpy.array([100, 200]), + rotation=numpy.pi / 2, + mirrored=False, + scale=1.0, + repetition=None, + annotations=None, + ) + public_ref_empty = Ref( + offset=(100, 200), + rotation=numpy.pi / 2, + mirrored=False, + scale=1.0, + repetition=None, + annotations=None, + ) + assert raw_ref_empty.annotations is None + assert raw_ref_empty == public_ref_empty + + raw_ref = Ref._from_raw( + offset=numpy.array([100, 200]), + rotation=numpy.pi / 2, + mirrored=True, + scale=1.25, + repetition=raw_grid, + annotations={'12': ['child-ref']}, + ) + public_ref = Ref( + offset=(100, 200), + rotation=numpy.pi / 2, + mirrored=True, + scale=1.25, + repetition=public_grid, + annotations={'12': ['child-ref']}, + ) + assert raw_ref == public_ref + assert numpy.array_equal(raw_ref.as_transforms(), public_ref.as_transforms()) + + raw_label_empty = Label._from_raw( + 'LEAF', + offset=numpy.array([3, 4]), + annotations=None, + ) + public_label_empty = Label( + 'LEAF', + offset=(3, 4), + annotations=None, + ) + assert raw_label_empty.annotations is None + assert raw_label_empty == public_label_empty + + raw_label = Label._from_raw( + 'LEAF', + offset=numpy.array([3, 4]), + annotations={'10': ['leaf-label']}, + ) + public_label = Label( + 'LEAF', + offset=(3, 4), + annotations={'10': ['leaf-label']}, + ) + assert raw_label == public_label + assert numpy.array_equal(raw_label.get_bounds_single(), public_label.get_bounds_single()) diff --git a/masque/test/test_gdsii_lazy.py b/masque/test/test_gdsii_lazy.py new file mode 100644 index 0000000..b9da929 --- /dev/null +++ b/masque/test/test_gdsii_lazy.py @@ -0,0 +1,230 @@ +from pathlib import Path + +import numpy +import pytest +from numpy.testing import assert_allclose + +from ..file import gdsii +from ..file.gdsii import lazy as gdsii_lazy +from ..pattern import Pattern +from ..ports import Port +from ..library import Library, OverlayLibrary + + +def _make_lazy_port_library() -> Library: + lib = Library() + + leaf = Pattern() + leaf.label(layer=(10, 0), string='A:type1 0', offset=(5, 0)) + lib['leaf'] = leaf + + child = Pattern() + child.ref('leaf', offset=(10, 20), rotation=numpy.pi / 2) + lib['child'] = child + + top = Pattern() + top.ref('child', offset=(100, 200)) + lib['top'] = top + + return lib + + +def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_source.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-lazy') + + lib, info = gdsii_lazy.readfile(gds_file) + + assert info['name'] == 'classic-lazy' + assert lib.source_order() == ('leaf', 'child', 'top') + assert lib.child_graph(dangling='ignore') == { + 'leaf': set(), + 'child': {'leaf'}, + 'top': {'child'}, + } + assert not lib._cache + + child = lib['child'] + assert list(child.refs.keys()) == ['leaf'] + assert set(lib._cache) == {'child'} + + +def test_gdsii_lazy_ports_view_keeps_raw_source_unmodified(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_ports.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2) + + top = processed['top'] + assert set(top.ports) == {'A'} + assert_allclose(top.ports['A'].offset, [110, 225], atol=1e-10) + assert not raw._cache + + raw_top = raw['top'] + assert not raw_top.ports + + +def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_port_overrides.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_port_overrides({ + 'top': { + 'P': Port((1, 2), rotation=0, ptype='wire'), + }, + }) + + top = processed['top'] + assert set(top.ports) == {'P'} + assert_allclose(top.ports['P'].offset, [1, 2], atol=1e-10) + assert top.ports['P'].rotation == 0 + assert top.ports['P'].ptype == 'wire' + assert not raw._cache + + raw_top = raw['top'] + assert not raw_top.ports + + +def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_ports_override_extracted.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_ports_from_data( + layers=[(10, 0)], + max_depth=2, + ports={ + 'top': { + 'A': Port((1, 2), rotation=numpy.pi, ptype='manual'), + 'B': Port((3, 4), rotation=None, ptype=None), + }, + }, + ) + + top = processed['top'] + assert set(top.ports) == {'A', 'B'} + assert_allclose(top.ports['A'].offset, [1, 2], atol=1e-10) + assert top.ports['A'].rotation == numpy.pi + assert top.ports['A'].ptype == 'manual' + assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10) + assert top.ports['B'].rotation is None + assert top.ports['B'].ptype is None + assert not raw._cache + + +def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_ports_replace.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_ports_from_data( + layers=[(10, 0)], + max_depth=2, + ports={ + 'top': { + 'B': Port((3, 4), rotation=None, ptype=None), + }, + }, + replace=True, + ) + + top = processed['top'] + assert set(top.ports) == {'B'} + assert_allclose(top.ports['B'].offset, [3, 4], atol=1e-10) + assert not raw._cache + + +def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_overlay.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2) + + overlay = OverlayLibrary() + overlay.add_source(processed) + + assert not raw._cache + assert not processed._cache + + abstract = overlay.abstract('top') + assert set(abstract.ports) == {'A'} + + +def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_overlay_override.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_port_overrides({ + 'top': { + 'P': Port((1, 2), rotation=0, ptype='wire'), + }, + }) + + overlay = OverlayLibrary() + overlay.add_source(processed) + + assert not raw._cache + assert not processed._cache + + abstract = overlay.abstract('top') + assert set(abstract.ports) == {'P'} + assert_allclose(abstract.ports['P'].offset, [1, 2], atol=1e-10) + + +def test_gdsii_lazy_overlay_add_source_can_rename_every_source_cell() -> None: + src = _make_lazy_port_library() + overlay = OverlayLibrary() + + rename_map = overlay.add_source( + src, + rename_theirs=lambda _lib, name: f'mapped_{name}', + rename_when='always', + ) + + assert rename_map == { + 'leaf': 'mapped_leaf', + 'child': 'mapped_child', + 'top': 'mapped_top', + } + assert tuple(overlay.keys()) == ('mapped_leaf', 'mapped_child', 'mapped_top') + assert 'mapped_leaf' in overlay['mapped_child'].refs + + +def test_gdsii_lazy_overlay_add_source_rename_when_validation() -> None: + src = _make_lazy_port_library() + + with pytest.raises(TypeError, match='rename_theirs'): + OverlayLibrary().add_source(src, rename_when='always') + + with pytest.raises(ValueError, match='rename mode'): + OverlayLibrary().add_source(src, rename_when='sometimes') # type: ignore[arg-type] + + +def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path: Path) -> None: + gds_file = tmp_path / 'lazy_roundtrip.gds' + src = _make_lazy_port_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip') + + raw, _ = gdsii_lazy.readfile(gds_file) + processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2) + + out_file = tmp_path / 'lazy_roundtrip_out.gds' + gdsii_lazy.writefile(processed, out_file) + + assert out_file.read_bytes() == gds_file.read_bytes() + + +def test_gdsii_removed_closure_based_lazy_loader() -> None: + assert not hasattr(gdsii, 'load_library') + assert not hasattr(gdsii, 'load_libraryfile') diff --git a/masque/test/test_gdsii_lazy_arrow.py b/masque/test/test_gdsii_lazy_arrow.py new file mode 100644 index 0000000..0447921 --- /dev/null +++ b/masque/test/test_gdsii_lazy_arrow.py @@ -0,0 +1,335 @@ +from pathlib import Path +import subprocess +import sys +import textwrap + +import klamath +import numpy +import pytest + +pytest.importorskip('pyarrow') + +from .. import PatternError +from ..library import Library, OverlayLibrary +from ..pattern import Pattern +from ..repetition import Grid +from ..file import gdsii +from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow +from ..file.gdsii.perf import write_fixture + + +if not gdsii_lazy_arrow.is_available(): + pytest.skip('klamath_rs_ext shared library is not available', allow_module_level=True) + + +def _make_small_library() -> Library: + lib = Library() + + leaf = Pattern() + leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 5], [0, 5]]) + lib['leaf'] = leaf + + mid = Pattern() + mid.ref('leaf', offset=(10, 20)) + mid.ref('leaf', offset=(40, 0), repetition=Grid(a_vector=(12, 0), a_count=2, b_vector=(0, 9), b_count=2)) + lib['mid'] = mid + + top = Pattern() + top.ref('mid', offset=(100, 200)) + lib['top'] = top + return lib + + +def _make_complex_ref_library() -> Library: + lib = Library() + + leaf = Pattern() + leaf.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib['leaf'] = leaf + + child = Pattern() + child.ref('leaf', offset=(100, 200), rotation=numpy.pi / 2, mirrored=True, scale=1.25) + lib['child'] = child + + sibling = Pattern() + sibling.ref( + 'leaf', + offset=(-50, 60), + repetition=Grid(a_vector=(20, 0), a_count=3, b_vector=(0, 30), b_count=2), + ) + lib['sibling'] = sibling + + fanout = Pattern() + fanout.ref('leaf', offset=(0, 0)) + fanout.ref('child', offset=(10, 0), mirrored=True, rotation=numpy.pi / 6, scale=1.1) + fanout.ref('leaf', offset=(30, 0), repetition=Grid(a_vector=(5, 0), a_count=2, b_vector=(0, 7), b_count=3)) + fanout.ref( + 'child', + offset=(40, 0), + mirrored=True, + rotation=numpy.pi / 4, + scale=1.2, + repetition=Grid(a_vector=(9, 0), a_count=2, b_vector=(0, 11), b_count=2), + ) + lib['fanout'] = fanout + + top = Pattern() + top.ref('child', offset=(500, 600)) + top.ref('sibling', offset=(-100, 50), rotation=numpy.pi) + top.ref('fanout', offset=(250, -75)) + lib['top'] = top + + return lib + + +def _write_invalid_path_type_fixture(path: Path) -> None: + with path.open('wb') as stream: + header = klamath.library.FileHeader( + name=b'test', + user_units_per_db_unit=1.0, + meters_per_db_unit=1e-9, + ) + header.write(stream) + elem = klamath.elements.Path( + layer=(1, 0), + path_type=3, + width=10, + extension=(0, 0), + xy=numpy.array([[0, 0], [10, 0]], dtype=numpy.int32), + properties={}, + ) + klamath.library.write_struct(stream, name=b'top', elements=[elem]) + klamath.records.ENDLIB.write(stream, None) + + +def _transform_rows_key(values: numpy.ndarray) -> tuple[tuple[object, ...], ...]: + arr = numpy.asarray(values, dtype=float) + arr = numpy.atleast_2d(arr) + rows = [ + ( + round(float(row[0]), 8), + round(float(row[1]), 8), + round(float(row[2]), 8), + bool(int(round(float(row[3])))), + round(float(row[4]), 8), + ) + for row in arr + ] + return tuple(sorted(rows)) + + +def _local_refs_key(refs: dict[str, list[numpy.ndarray]]) -> dict[str, tuple[tuple[object, ...], ...]]: + return { + parent: _transform_rows_key(numpy.concatenate(transforms)) + for parent, transforms in refs.items() + } + + +def _global_refs_key(refs: dict[tuple[str, ...], numpy.ndarray]) -> dict[tuple[str, ...], tuple[tuple[object, ...], ...]]: + return { + path: _transform_rows_key(transforms) + for path, transforms in refs.items() + } + + +def test_gdsii_lazy_arrow_loads_perf_fixture(tmp_path: Path) -> None: + gds_file = tmp_path / 'many_cells_lazy.gds' + manifest = write_fixture(gds_file, preset='many_cells', scale=0.001) + + lib, info = gdsii_lazy_arrow.readfile(gds_file) + + assert info['name'] == manifest.library_name + assert len(lib) == manifest.cells + assert lib.top() == 'TOP' + assert 'TOP' in lib.child_graph(dangling='ignore') + + +def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None: + gds_file = tmp_path / 'refs.gds' + src = _make_small_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-refs') + + lib, _ = gdsii_lazy_arrow.readfile(gds_file) + + local = lib.find_refs_local('leaf') + assert set(local) == {'mid'} + assert sum(arr.shape[0] for arr in local['mid']) == 5 + + global_refs = lib.find_refs_global('leaf') + assert set(global_refs) == {('top', 'mid', 'leaf')} + assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5 + + +def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None: + gds_file = tmp_path / 'complex_refs.gds' + src = _make_complex_ref_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='lazy-complex-refs') + + eager, _ = gdsii.readfile(gds_file) + lazy, _ = gdsii_lazy_arrow.readfile(gds_file) + + for name in ('leaf', 'child'): + assert _local_refs_key(lazy.find_refs_local(name)) == _local_refs_key(eager.find_refs_local(name)) + assert _global_refs_key(lazy.find_refs_global(name)) == _global_refs_key(eager.find_refs_global(name)) + + +def test_gdsii_lazy_arrow_invalid_input_raises_klamath_error(tmp_path: Path) -> None: + gds_file = tmp_path / 'invalid.gds' + gds_file.write_bytes(b'not-a-gds') + + script = textwrap.dedent(f""" + from masque.file.gdsii import lazy_arrow as gdsii_lazy_arrow + try: + gdsii_lazy_arrow.readfile({str(gds_file)!r}) + except Exception as exc: + print(type(exc).__module__) + print(type(exc).__qualname__) + print(exc) + else: + raise SystemExit('expected gdsii_lazy_arrow.readfile() to fail') + """) + result = subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, check=False) + + assert result.returncode == 0, result.stderr + assert 'klamath.basic' in result.stdout + assert 'KlamathError' in result.stdout + + +def test_gdsii_lazy_arrow_invalid_path_type_raises_pattern_error(tmp_path: Path) -> None: + gds_file = tmp_path / 'invalid_path_type.gds' + _write_invalid_path_type_fixture(gds_file) + + lib, _ = gdsii_lazy_arrow.readfile(gds_file) + + with pytest.raises(PatternError, match='Unrecognized path type: 3'): + lib['top'] + + +def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> None: + gds_file = tmp_path / 'copy_source.gds' + src = _make_small_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through') + + lib, info = gdsii_lazy_arrow.readfile(gds_file) + out_file = tmp_path / 'copy_out.gds' + gdsii_lazy_arrow.writefile( + lib, + out_file, + meters_per_unit=info['meters_per_unit'], + logical_units_per_unit=info['logical_units_per_unit'], + library_name=info['name'], + ) + + assert out_file.read_bytes() == gds_file.read_bytes() + + +def test_gdsii_lazy_arrow_gzipped_copy_through(tmp_path: Path) -> None: + gds_file = tmp_path / 'copy_source.gds.gz' + src = _make_small_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='copy-through-gz') + + lib, info = gdsii_lazy_arrow.readfile(gds_file) + out_file = tmp_path / 'copy_out.gds.gz' + gdsii_lazy_arrow.writefile( + lib, + out_file, + meters_per_unit=info['meters_per_unit'], + logical_units_per_unit=info['logical_units_per_unit'], + library_name=info['name'], + ) + + assert out_file.read_bytes() == gds_file.read_bytes() + + +def test_gdsii_lazy_overlay_merge_and_write(tmp_path: Path) -> None: + base_a = Library() + leaf_a = Pattern() + leaf_a.polygon((1, 0), vertices=[[0, 0], [8, 0], [8, 8], [0, 8]]) + base_a['leaf'] = leaf_a + top_a = Pattern() + top_a.ref('leaf', offset=(0, 0)) + base_a['top_a'] = top_a + + base_b = Library() + leaf_b = Pattern() + leaf_b.polygon((2, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) + base_b['leaf'] = leaf_b + top_b = Pattern() + top_b.ref('leaf', offset=(20, 30)) + base_b['top_b'] = top_b + + gds_a = tmp_path / 'a.gds' + gds_b = tmp_path / 'b.gds' + gdsii.writefile(base_a, gds_a, meters_per_unit=1e-9, library_name='overlay') + gdsii.writefile(base_b, gds_b, meters_per_unit=1e-9, library_name='overlay') + + lib_a, _ = gdsii_lazy_arrow.readfile(gds_a) + lib_b, _ = gdsii_lazy_arrow.readfile(gds_b) + + overlay = OverlayLibrary() + overlay.add_source(lib_a) + rename_map = overlay.add_source(lib_b, rename_theirs=lambda lib, name: lib.get_name(name)) + renamed_leaf = rename_map['leaf'] + + assert rename_map == {'leaf': renamed_leaf} + assert renamed_leaf != 'leaf' + assert len(lib_a._cache) == 0 + assert len(lib_b._cache) == 0 + + overlay.move_references('leaf', renamed_leaf) + + out_file = tmp_path / 'overlay_out.gds' + gdsii_lazy_arrow.writefile(overlay, out_file) + + roundtrip, _ = gdsii.readfile(out_file) + assert set(roundtrip.keys()) == {'leaf', renamed_leaf, 'top_a', 'top_b'} + assert 'top_b' in roundtrip + assert list(roundtrip['top_b'].refs.keys()) == [renamed_leaf] + + +def test_gdsii_writer_accepts_overlay_library(tmp_path: Path) -> None: + gds_file = tmp_path / 'overlay_source.gds' + src = _make_small_library() + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='overlay-src') + + lib, info = gdsii_lazy_arrow.readfile(gds_file) + + overlay = OverlayLibrary() + overlay.add_source(lib) + overlay.rename('leaf', 'leaf_copy', move_references=True) + + out_file = tmp_path / 'overlay_via_eager_writer.gds' + gdsii.writefile( + overlay, + out_file, + meters_per_unit=info['meters_per_unit'], + logical_units_per_unit=info['logical_units_per_unit'], + library_name=info['name'], + ) + + roundtrip, _ = gdsii.readfile(out_file) + assert set(roundtrip.keys()) == {'leaf_copy', 'mid', 'top'} + assert list(roundtrip['mid'].refs.keys()) == ['leaf_copy'] + + +def test_svg_writer_uses_detached_materialized_copy(tmp_path: Path) -> None: + pytest.importorskip('svgwrite') + from ..file import svg + from ..shapes import Path as MPath + + gds_file = tmp_path / 'svg_source.gds' + src = _make_small_library() + src['top'].path((3, 0), vertices=[[0, 0], [0, 20]], width=4) + gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='svg-src') + + lib, _ = gdsii_lazy_arrow.readfile(gds_file) + top_pat = lib['top'] + assert list(top_pat.refs.keys()) == ['mid'] + assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)]) + + svg_path = tmp_path / 'lazy.svg' + svg.writefile(lib, 'top', str(svg_path)) + + assert svg_path.exists() + assert list(top_pat.refs.keys()) == ['mid'] + assert any(isinstance(shape, MPath) for shape in top_pat.shapes[(3, 0)]) diff --git a/masque/test/test_gdsii_perf.py b/masque/test/test_gdsii_perf.py new file mode 100644 index 0000000..09b3a5a --- /dev/null +++ b/masque/test/test_gdsii_perf.py @@ -0,0 +1,24 @@ +from dataclasses import asdict +import json +from pathlib import Path + +from ..file import gdsii +from ..file.gdsii.perf import fixture_manifest, write_fixture + + +def test_gdsii_perf_fixture_smoke(tmp_path: Path) -> None: + output = tmp_path / 'many_cells.gds' + manifest = write_fixture(output, preset='many_cells', scale=0.002) + expected = fixture_manifest(output, preset='many_cells', scale=0.002) + + assert output.exists() + assert manifest == expected + + sidecar = json.loads(output.with_suffix('.gds.json').read_text()) + assert sidecar == asdict(manifest) + + read_lib, info = gdsii.readfile(output) + assert info['name'] == manifest.library_name + assert len(read_lib) == manifest.cells + assert 'TOP' in read_lib + assert len(read_lib['TOP'].refs) > 0 diff --git a/masque/test/test_label.py b/masque/test/test_label.py new file mode 100644 index 0000000..f4f364b --- /dev/null +++ b/masque/test/test_label.py @@ -0,0 +1,54 @@ +import copy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..label import Label +from ..repetition import Grid +from ..utils import annotations_eq + + +def test_label_init() -> None: + lbl = Label("test", offset=(10, 20)) + assert lbl.string == "test" + assert_equal(lbl.offset, [10, 20]) + + +def test_label_transform() -> None: + lbl = Label("test", offset=(10, 0)) + # Rotate 90 deg CCW around (0,0) + lbl.rotate_around((0, 0), pi / 2) + assert_allclose(lbl.offset, [0, 10], atol=1e-10) + + # Translate + lbl.translate((5, 5)) + assert_allclose(lbl.offset, [5, 15], atol=1e-10) + + +def test_label_repetition() -> None: + rep = Grid(a_vector=(10, 0), a_count=3) + lbl = Label("rep", offset=(0, 0), repetition=rep) + assert lbl.repetition is rep + assert_equal(lbl.get_bounds_single(), [[0, 0], [0, 0]]) + # Note: Bounded.get_bounds_nonempty() for labels with repetition doesn't + # seem to automatically include repetition bounds in label.py itself, + # it's handled during pattern bounding. + + +def test_label_copy() -> None: + l1 = Label("test", offset=(1, 2), annotations={"a": [1]}) + l2 = copy.deepcopy(l1) + + print(f"l1: string={l1.string}, offset={l1.offset}, repetition={l1.repetition}, annotations={l1.annotations}") + print(f"l2: string={l2.string}, offset={l2.offset}, repetition={l2.repetition}, annotations={l2.annotations}") + print(f"annotations_eq: {annotations_eq(l1.annotations, l2.annotations)}") + + assert l1 == l2 + assert l1 is not l2 + l2.offset[0] = 100 + assert l1.offset[0] == 1 + + +def test_label_eq_unrelated_objects_is_false() -> None: + lbl = Label("test") + assert not (lbl == None) + assert not (lbl == object()) diff --git a/masque/test/test_library.py b/masque/test/test_library.py new file mode 100644 index 0000000..ce564aa --- /dev/null +++ b/masque/test/test_library.py @@ -0,0 +1,483 @@ +import pytest +from typing import cast, TYPE_CHECKING +from numpy.testing import assert_allclose +from ..library import Library, LazyLibrary +from ..pattern import Pattern +from ..error import LibraryError, PatternError +from ..ports import Port +from ..repetition import Grid +from ..shapes import Arc, Ellipse, Path, Text +from ..file.utils import preflight + +if TYPE_CHECKING: + from ..shapes import Polygon + + +def test_library_basic() -> None: + lib = Library() + pat = Pattern() + lib["cell1"] = pat + + assert "cell1" in lib + assert lib["cell1"] is pat + assert len(lib) == 1 + + with pytest.raises(LibraryError): + lib["cell1"] = Pattern() # Overwriting not allowed + + +def test_library_tops() -> None: + lib = Library() + lib["child"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("child") + + assert set(lib.tops()) == {"parent"} + assert lib.top() == "parent" + + +def test_library_dangling() -> None: + lib = Library() + lib["parent"] = Pattern() + lib["parent"].ref("missing") + + assert lib.dangling_refs() == {"missing"} + + +def test_library_dangling_graph_modes() -> None: + lib = Library() + lib["parent"] = Pattern() + lib["parent"].ref("missing") + + with pytest.raises(LibraryError, match="Dangling refs found"): + lib.child_graph() + with pytest.raises(LibraryError, match="Dangling refs found"): + lib.parent_graph() + with pytest.raises(LibraryError, match="Dangling refs found"): + lib.child_order() + + assert lib.child_graph(dangling="ignore") == {"parent": set()} + assert lib.parent_graph(dangling="ignore") == {"parent": set()} + assert lib.child_order(dangling="ignore") == ["parent"] + + assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()} + assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}} + assert lib.child_order(dangling="include") == ["missing", "parent"] + + +def test_find_refs_with_dangling_modes() -> None: + lib = Library() + lib["target"] = Pattern() + + mid = Pattern() + mid.ref("target", offset=(2, 0)) + lib["mid"] = mid + + top = Pattern() + top.ref("mid", offset=(5, 0)) + top.ref("missing", offset=(9, 0)) + lib["top"] = top + + assert lib.find_refs_local("missing", dangling="ignore") == {} + assert lib.find_refs_global("missing", dangling="ignore") == {} + + local_missing = lib.find_refs_local("missing", dangling="include") + assert set(local_missing) == {"top"} + assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]]) + + global_missing = lib.find_refs_global("missing", dangling="include") + assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]]) + + with pytest.raises(LibraryError, match="missing"): + lib.find_refs_local("missing") + with pytest.raises(LibraryError, match="missing"): + lib.find_refs_global("missing") + + global_target = lib.find_refs_global("target") + assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]]) + + +def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None: + def make_lib() -> Library: + lib = Library() + lib["empty"] = Pattern() + lib["top"] = Pattern() + lib["top"].ref("missing") + return lib + + caplog.set_level("WARNING") + warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True) + assert "empty" not in warned + assert any("Dangling refs found" in record.message for record in caplog.records) + + allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True) + assert "empty" not in allowed + + with pytest.raises(LibraryError, match="Dangling refs found"): + preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True) + + +def test_library_flatten() -> None: + lib = Library() + child = Pattern() + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + lib["parent"] = parent + + flat_lib = lib.flatten("parent") + flat_parent = flat_lib["parent"] + + assert not flat_parent.has_refs() + assert len(flat_parent.shapes[(1, 0)]) == 1 + # Transformations are baked into vertices for Polygon + assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices + assert tuple(assert_vertices[0]) == (10.0, 10.0) + + +def test_library_flatten_preserves_ports_only_child() -> None: + lib = Library() + child = Pattern(ports={"P1": Port((1, 2), 0)}) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + lib["parent"] = parent + + flat_parent = lib.flatten("parent", flatten_ports=True)["parent"] + + assert set(flat_parent.ports) == {"P1"} + assert cast("Port", flat_parent.ports["P1"]).rotation == 0 + assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0) + + +def test_library_flatten_repeated_ref_with_ports_raises() -> None: + lib = Library() + child = Pattern(ports={"P1": Port((1, 2), 0)}) + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + parent = Pattern() + parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) + lib["parent"] = parent + + with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): + lib.flatten("parent", flatten_ports=True) + + +def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None: + lib = Library() + child = Pattern() + child.ref("missing") + lib["child"] = child + + parent = Pattern() + parent.ref("child") + lib["parent"] = parent + + flat = lib.flatten("parent", dangling_ok=True) + + assert set(flat["child"].refs) == {"missing"} + assert flat["child"].has_refs() + assert set(flat["parent"].refs) == {"missing"} + assert flat["parent"].has_refs() + + +def test_lazy_library() -> None: + lib = LazyLibrary() + called = 0 + + def make_pat() -> Pattern: + nonlocal called + called += 1 + return Pattern() + + lib["lazy"] = make_pat + assert called == 0 + + pat = lib["lazy"] + assert called == 1 + assert isinstance(pat, Pattern) + + # Second access should be cached + pat2 = lib["lazy"] + assert called == 1 + assert pat is pat2 + + +def test_library_rename() -> None: + lib = Library() + lib["old"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("old") + + lib.rename("old", "new", move_references=True) + + assert "old" not in lib + assert "new" in lib + assert "new" in lib["parent"].refs + assert "old" not in lib["parent"].refs + + +@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) +def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: + lib = library_cls() + lib["top"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("top") + + lib.rename("top", "top", move_references=True) + + assert set(lib.keys()) == {"top", "parent"} + assert "top" in lib["parent"].refs + assert len(lib["parent"].refs["top"]) == 1 + + +@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) +def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: + lib = library_cls() + lib["top"] = Pattern() + + lib.rename_top("top") + + assert list(lib.keys()) == ["top"] + + +@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) +def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None: + lib = library_cls() + lib["top"] = Pattern() + + with pytest.raises(LibraryError, match="does not exist"): + lib.rename("missing", "new") + + +@pytest.mark.parametrize("library_cls", (Library, LazyLibrary)) +def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None: + lib = library_cls() + lib["top"] = Pattern() + lib["parent"] = Pattern() + lib["parent"].ref("top") + + lib.move_references("top", "top") + + assert "top" in lib["parent"].refs + assert len(lib["parent"].refs["top"]) == 1 + + +def test_library_dfs_can_replace_existing_patterns() -> None: + lib = Library() + child = Pattern() + lib["child"] = child + top = Pattern() + top.ref("child") + lib["top"] = top + + replacement_top = Pattern(ports={"T": Port((1, 2), 0)}) + replacement_child = Pattern(ports={"C": Port((3, 4), 0)}) + + def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001 + if hierarchy[-1] == "child": + return replacement_child + if hierarchy[-1] == "top": + return replacement_top + return pattern + + lib.dfs(lib["top"], visit_after=visit_after, hierarchy=("top",), transform=True) + + assert lib["top"] is replacement_top + assert lib["child"] is replacement_child + + +def test_lazy_library_dfs_can_replace_existing_patterns() -> None: + lib = LazyLibrary() + lib["child"] = lambda: Pattern() + lib["top"] = lambda: Pattern(refs={"child": []}) + + top = lib["top"] + top.ref("child") + + replacement_top = Pattern(ports={"T": Port((1, 2), 0)}) + replacement_child = Pattern(ports={"C": Port((3, 4), 0)}) + + def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001 + if hierarchy[-1] == "child": + return replacement_child + if hierarchy[-1] == "top": + return replacement_top + return pattern + + lib.dfs(top, visit_after=visit_after, hierarchy=("top",), transform=True) + + assert lib["top"] is replacement_top + assert lib["child"] is replacement_child + + +def test_library_add_no_duplicates_respects_mutate_other_false() -> None: + src_pat = Pattern(ports={"A": Port((0, 0), 0)}) + lib = Library({"a": Pattern()}) + + lib.add({"b": src_pat}, mutate_other=False) + + assert lib["b"] is not src_pat + lib["b"].ports["A"].offset[0] = 123 + assert tuple(src_pat.ports["A"].offset) == (0.0, 0.0) + + +def test_library_add_returns_only_renamed_entries() -> None: + lib = Library({"a": Pattern(), "_shape": Pattern()}) + + assert lib.add({"b": Pattern(), "c": Pattern()}, mutate_other=False) == {} + + rename_map = lib.add({"_shape": Pattern(), "keep": Pattern()}, mutate_other=False) + + assert set(rename_map) == {"_shape"} + assert rename_map["_shape"] != "_shape" + assert "keep" not in rename_map + + +def test_library_subtree() -> None: + lib = Library() + lib["a"] = Pattern() + lib["b"] = Pattern() + lib["c"] = Pattern() + lib["a"].ref("b") + + sub = lib.subtree("a") + assert "a" in sub + assert "b" in sub + assert "c" not in sub + + +def test_library_child_order_cycle_raises_library_error() -> None: + lib = Library() + lib["a"] = Pattern() + lib["a"].ref("b") + lib["b"] = Pattern() + lib["b"].ref("a") + + with pytest.raises(LibraryError, match="Cycle found while building child order"): + lib.child_order() + + +def test_library_find_refs_global_cycle_raises_library_error() -> None: + lib = Library() + lib["a"] = Pattern() + lib["a"].ref("a") + + with pytest.raises(LibraryError, match="Cycle found while building child order"): + lib.find_refs_global("a") + + +def test_library_get_name() -> None: + lib = Library() + lib["cell"] = Pattern() + + name1 = lib.get_name("cell") + assert name1 != "cell" + assert name1.startswith("cell") + + name2 = lib.get_name("other") + assert name2 == "other" + + +def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)), + Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)), + ] + lib["top"] = pat + + lib.dedup(norm_value=1, threshold=2) + + assert not lib["top"].refs + assert len(lib["top"].shapes[(1, 0)]) == 2 + + +def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Text("A", 10, "dummy.ttf", offset=(0, 0)), + Text("A", 10, "dummy.ttf", offset=(100, 0)), + ] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=5, threshold=2) + + target_name = next(iter(lib["top"].refs)) + refs = lib["top"].refs[target_name] + assert [ref.mirrored for ref in refs] == [False, False] + assert [ref.scale for ref in refs] == [2.0, 2.0] + assert cast("Text", lib[target_name].shapes[(1, 0)][0]).height == 5 + + flat = lib.flatten("top")["top"] + assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10] + + +def test_library_dedup_handles_arc_and_ellipse_labels() -> None: + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [ + Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(0, 0)), + Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(50, 0)), + ] + pat.shapes[(2, 0)] += [ + Ellipse(radii=(10, 20), offset=(0, 0)), + Ellipse(radii=(10, 20), offset=(50, 0)), + ] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + assert len(lib["top"].refs) == 2 + assert lib["top"].shapes[(1, 0)] == [] + assert lib["top"].shapes[(2, 0)] == [] + + flat = lib.flatten("top")["top"] + assert sum(isinstance(shape, Arc) for shape in flat.shapes[(1, 0)]) == 2 + assert sum(isinstance(shape, Ellipse) for shape in flat.shapes[(2, 0)]) == 2 + + +def test_library_dedup_handles_multiple_duplicate_groups() -> None: + from ..shapes import Circle + + lib = Library() + pat = Pattern() + pat.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] + pat.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] + lib["top"] = pat + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + assert len(lib["top"].refs) == 2 + assert all(len(refs) == 2 for refs in lib["top"].refs.values()) + assert len(lib["top"].shapes[(1, 0)]) == 0 + assert len(lib["top"].shapes[(2, 0)]) == 0 + + +def test_library_dedup_uses_stable_target_names_per_label() -> None: + from ..shapes import Circle + + lib = Library() + + p1 = Pattern() + p1.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))] + lib["p1"] = p1 + + p2 = Pattern() + p2.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)] + lib["p2"] = p2 + + lib.dedup(exclude_types=(), norm_value=1, threshold=2) + + circle_target = next(iter(lib["p1"].refs)) + path_target = next(iter(lib["p2"].refs)) + + assert circle_target != path_target + assert all(isinstance(shape, Circle) for shapes in lib[circle_target].shapes.values() for shape in shapes) + assert all(isinstance(shape, Path) for shapes in lib[path_target].shapes.values() for shape in shapes) diff --git a/masque/test/test_manhattanize.py b/masque/test/test_manhattanize.py new file mode 100644 index 0000000..d0f4fa7 --- /dev/null +++ b/masque/test/test_manhattanize.py @@ -0,0 +1,17 @@ +import pytest +import numpy + +from ..shapes import Polygon + + +def test_manhattanize() -> None: + pytest.importorskip("float_raster") + pytest.importorskip("skimage.measure") + poly = Polygon([[0, 5], [5, 10], [10, 5], [5, 0]]) + grid = numpy.arange(0, 11, 1) + + manhattan_polys = poly.manhattanize(grid, grid) + assert len(manhattan_polys) >= 1 + for mp in manhattan_polys: + dv = numpy.diff(mp.vertices, axis=0) + assert numpy.all((dv[:, 0] == 0) | (dv[:, 1] == 0)) diff --git a/masque/test/test_oasis.py b/masque/test/test_oasis.py new file mode 100644 index 0000000..f549db7 --- /dev/null +++ b/masque/test/test_oasis.py @@ -0,0 +1,60 @@ +import io +from pathlib import Path +import pytest +from numpy.testing import assert_equal + +from ..error import PatternError +from ..pattern import Pattern +from ..library import Library +from ..shapes import Path as MPath + + +def test_oasis_roundtrip(tmp_path: Path) -> None: + # Skip if fatamorgana is not installed + pytest.importorskip("fatamorgana") + from ..file import oasis + + lib = Library() + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["cell1"] = pat1 + + oas_file = tmp_path / "test.oas" + # OASIS needs units_per_micron + oasis.writefile(lib, oas_file, units_per_micron=1000) + + read_lib, info = oasis.readfile(oas_file) + assert "cell1" in read_lib + + # Check bounds + assert_equal(read_lib["cell1"].get_bounds(), [[0, 0], [10, 10]]) + + +def test_oasis_properties_to_annotations_merges_repeated_keys() -> None: + pytest.importorskip("fatamorgana") + import fatamorgana.records as fatrec + from ..file.oasis import properties_to_annotations + + annotations = properties_to_annotations( + [ + fatrec.Property("k", [1], is_standard=False), + fatrec.Property("k", [2, 3], is_standard=False), + ], + {}, + {}, + ) + + assert annotations == {"k": [1, 2, 3]} + + +def test_oasis_write_rejects_circle_path_caps() -> None: + pytest.importorskip("fatamorgana") + from ..file import oasis + + lib = Library() + pat = Pattern() + pat.path((1, 0), vertices=[[0, 0], [10, 0]], width=2, cap=MPath.Cap.Circle) + lib["cell1"] = pat + + with pytest.raises(PatternError, match="does not support path cap"): + oasis.write(lib, io.BytesIO(), units_per_micron=1000) diff --git a/masque/test/test_pack2d.py b/masque/test/test_pack2d.py new file mode 100644 index 0000000..914c23e --- /dev/null +++ b/masque/test/test_pack2d.py @@ -0,0 +1,96 @@ +from ..utils.pack2d import maxrects_bssf, guillotine_bssf_sas, pack_patterns +from ..library import Library +from ..pattern import Pattern + + +def test_maxrects_bssf_simple() -> None: + # Pack two 10x10 squares into one 20x10 container + rects = [[10, 10], [10, 10]] + containers = [[0, 0, 20, 10]] + + locs, rejects = maxrects_bssf(rects, containers) + + assert not rejects + # They should be at (0,0) and (10,0) + assert {tuple(loc) for loc in locs} == {(0.0, 0.0), (10.0, 0.0)} + + +def test_maxrects_bssf_reject() -> None: + # Try to pack a too-large rectangle + rects = [[10, 10], [30, 30]] + containers = [[0, 0, 20, 20]] + + locs, rejects = maxrects_bssf(rects, containers, allow_rejects=True) + assert 1 in rejects # Second rect rejected + assert 0 not in rejects + + +def test_maxrects_bssf_exact_fill_rejects_remaining() -> None: + rects = [[20, 20], [1, 1]] + containers = [[0, 0, 20, 20]] + + locs, rejects = maxrects_bssf(rects, containers, presort=False, allow_rejects=True) + + assert tuple(locs[0]) == (0.0, 0.0) + assert rejects == {1} + + +def test_maxrects_bssf_presort_reject_mapping() -> None: + rects = [[10, 12], [19, 14], [13, 11]] + containers = [[0, 0, 20, 20]] + + _locs, rejects = maxrects_bssf(rects, containers, presort=True, allow_rejects=True) + + assert rejects == {0, 2} + + +def test_guillotine_bssf_sas_presort_reject_mapping() -> None: + rects = [[2, 1], [17, 15], [16, 11]] + containers = [[0, 0, 20, 20]] + + _locs, rejects = guillotine_bssf_sas(rects, containers, presort=True, allow_rejects=True) + + assert rejects == {2} + + +def test_pack_patterns() -> None: + lib = Library() + p1 = Pattern() + p1.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10], [0, 10]]) + lib["p1"] = p1 + + p2 = Pattern() + p2.polygon((1, 0), vertices=[[0, 0], [5, 0], [5, 5], [0, 5]]) + lib["p2"] = p2 + + # Containers: one 20x20 + containers = [[0, 0, 20, 20]] + # 2um spacing + pat, rejects = pack_patterns(lib, ["p1", "p2"], containers, spacing=(2, 2)) + + assert not rejects + assert len(pat.refs) == 2 + assert "p1" in pat.refs + assert "p2" in pat.refs + + # Check that they don't overlap (simple check via bounds) + # p1 size 10x10, effectively 12x12 + # p2 size 5x5, effectively 7x7 + # Both should fit in 20x20 + + +def test_pack_patterns_reject_names_match_original_patterns() -> None: + lib = Library() + for name, (lx, ly) in { + "p0": (10, 12), + "p1": (19, 14), + "p2": (13, 11), + }.items(): + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=lx, ymin=0, ymax=ly) + lib[name] = pat + + pat, rejects = pack_patterns(lib, ["p0", "p1", "p2"], [[0, 0, 20, 20]], spacing=(0, 0)) + + assert set(rejects) == {"p0", "p2"} + assert set(pat.refs) == {"p1"} diff --git a/masque/test/test_path.py b/masque/test/test_path.py new file mode 100644 index 0000000..f8685f4 --- /dev/null +++ b/masque/test/test_path.py @@ -0,0 +1,101 @@ +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Path, Path as MPath + + +def test_path_init() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) + assert_equal(p.vertices, [[0, 0], [10, 0]]) + assert p.width == 2 + assert p.cap == Path.Cap.Flush + + +def test_path_to_polygons_flush() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Flush) + polys = p.to_polygons() + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[0, -1], [10, 1]]) + + +def test_path_to_polygons_square() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Square) + polys = p.to_polygons() + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[-1, -1], [11, 1]]) + + +def test_path_to_polygons_circle() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.Circle) + polys = p.to_polygons(num_vertices=32) + assert len(polys) >= 3 + + bounds = p.get_bounds_single() + assert_equal(bounds, [[-1, -1], [11, 1]]) + + +def test_path_custom_cap() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(5, 10)) + polys = p.to_polygons() + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[-5, -1], [20, 1]]) + + +def test_path_bend() -> None: + p = Path(vertices=[[0, 0], [10, 0], [10, 10]], width=2) + polys = p.to_polygons() + assert len(polys) == 1 + bounds = polys[0].get_bounds_single() + assert_equal(bounds, [[0, -1], [11, 10]]) + + +def test_path_mirror() -> None: + p = Path(vertices=[[10, 5], [20, 10]], width=2) + p.mirror(0) + assert_equal(p.vertices, [[10, -5], [20, -10]]) + + +def test_path_scale() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2) + p.scale_by(2) + assert_equal(p.vertices, [[0, 0], [20, 0]]) + assert p.width == 4 + + +def test_path_scale_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + p.scale_by(3) + + assert_equal(p.vertices, [[0, 0], [30, 0]]) + assert p.width == 6 + assert p.cap_extensions is not None + assert_allclose(p.cap_extensions, [3, 6]) + assert_equal(p.to_polygons()[0].get_bounds_single(), [[-3, -3], [36, 3]]) + + +def test_path_normalized_form_preserves_width_and_custom_cap_extensions() -> None: + p = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + + intrinsic, _extrinsic, ctor = p.normalized_form(5) + q = ctor() + + assert intrinsic[-1] == (0.2, 0.4) + assert q.width == 2 + assert q.cap_extensions is not None + assert_allclose(q.cap_extensions, [1, 2]) + + +def test_path_normalized_form_distinguishes_custom_caps() -> None: + p1 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)) + p2 = Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)) + + assert p1.normalized_form(1)[0] != p2.normalized_form(1)[0] + + +def test_path_edge_cases() -> None: + p = MPath(vertices=[[0, 0], [0, 0], [10, 0]], width=2) + polys = p.to_polygons() + assert len(polys) == 1 + assert_equal(polys[0].get_bounds_single(), [[0, -1], [10, 1]]) diff --git a/masque/test/test_pather_autotool.py b/masque/test/test_pather_autotool.py new file mode 100644 index 0000000..fe8c0a6 --- /dev/null +++ b/masque/test/test_pather_autotool.py @@ -0,0 +1,107 @@ +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import AutoTool + + +def make_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: + pat = Pattern() + pat.rect((1, 0), xmin=0, xmax=length, yctr=0, ly=width) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((length, 0), pi, ptype=ptype) + return pat + +def make_bend(radius: float, width: float = 2, ptype: str = "wire", clockwise: bool = True) -> Pattern: + pat = Pattern() + # Rectangular approximation of a 90 degree bend. + if clockwise: + pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) + pat.rect((1, 0), xctr=radius, lx=width, ymin=-radius, ymax=0) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((radius, -radius), pi/2, ptype=ptype) + else: + pat.rect((1, 0), xmin=0, xmax=radius, yctr=0, ly=width) + pat.rect((1, 0), xctr=radius, lx=width, ymin=0, ymax=radius) + pat.ports["A"] = Port((0, 0), 0, ptype=ptype) + pat.ports["B"] = Port((radius, radius), -pi/2, ptype=ptype) + return pat + +@pytest.fixture +def multi_bend_tool() -> tuple[AutoTool, Library]: + lib = Library() + + lib["b1"] = make_bend(2, ptype="wire") + b1_abs = lib.abstract("b1") + lib["b2"] = make_bend(5, ptype="wire") + b2_abs = lib.abstract("b2") + + tool = ( + AutoTool() + .add_straight(make_straight, "wire", "A", length_range=(0, 10)) + .add_straight(lambda length: make_straight(length, width=4), "wire", "A", length_range=(10, 1e8)) + .add_bend(b1_abs, "A", "B", clockwise=True, mirror=True) + .add_bend(b2_abs, "A", "B", clockwise=True, mirror=True) + ) + return tool, lib + +def test_autotool_uturn() -> None: + from masque.builder.tools import AutoTool + lib = Library() + + def make_straight(length: float) -> Pattern: + pat = Pattern() + pat.rect(layer='M1', xmin=0, xmax=length, yctr=0, ly=1000) + pat.ports['in'] = Port((0, 0), 0) + pat.ports['out'] = Port((length, 0), pi) + return pat + + bend_pat = Pattern() + bend_pat.polygon(layer='M1', vertices=[(0, -500), (0, 500), (1000, -500)]) + bend_pat.ports['in'] = Port((0, 0), 0) + bend_pat.ports['out'] = Port((500, -500), pi/2) + lib['bend'] = bend_pat + + tool = ( + AutoTool() + .add_straight(make_straight, 'wire', 'in') + .add_bend(lib.abstract('bend'), 'in', 'out', clockwise=True) + ) + + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), 0) + + p.at('A').uturn(offset=-2000, length=1000) + + # U-turn plan output is transformed into the port extension frame. + assert numpy.allclose(p.pattern.ports['A'].offset, (-1000, 2000)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_deferred_render_autotool_double_L(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + rp = Pather(lib, tools=tool) + rp.ports["A"] = Port((0,0), 0, ptype="wire") + + rp.jog("A", 10, length=20) + + assert_allclose(rp.ports["A"].offset, [-20, -10]) + assert_allclose(rp.ports["A"].rotation, 0) + + rp.render() + assert len(rp.pattern.refs) > 0 + +def test_pather_uturn_fallback_no_heuristic(multi_bend_tool: tuple[AutoTool, Library]) -> None: + tool, lib = multi_bend_tool + + p = Pather(lib, tools=tool) + p.ports["A"] = Port((0,0), 0, ptype="wire") + + p.uturn("A", 10, length=5) + + # Fallback U-turn uses two CCW bends: (7, 2) then (8, 2) in local tool frames, + # yielding a global endpoint at (-5, -10). + assert_allclose(p.ports["A"].offset, [-5, -10]) + assert_allclose(p.ports["A"].rotation, pi) diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py new file mode 100644 index 0000000..fab1c68 --- /dev/null +++ b/masque/test/test_pather_constraints.py @@ -0,0 +1,601 @@ +from collections.abc import Sequence +from typing import Any, Literal, Never + +import pytest +import numpy +from numpy import pi + +from masque import Pather, Library, Port +from masque.builder.planner import RoutePortContext, RoutingPlanner +from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool +from masque.error import BuildError +from masque.library import ILibrary + + +class PlanningOnlyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[Any, ...]: + _ = kind, in_ptype, out_ptype, kwargs + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('planning_only_tool') + pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') + return tree + + +class FirstPortOnlyTraceTool(PlanningOnlyTool): + def __init__(self) -> None: + self.render_calls = 0 + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[StraightOffer | BendOffer, ...]: + _ = out_ptype + if in_ptype != 'wire': + return () + + if kind == 'straight': + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + def commit(length: float) -> dict[str, float | str]: + return {'kind': 'straight', 'length': length} + + return (StraightOffer( + in_ptype='wire', + out_ptype='wire', + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + if kind == 'bend': + ccw = bool(kwargs['ccw']) + + def endpoint(length: float) -> Port: + return Port( + (length, 1 if ccw else -1), + rotation=-pi / 2 if ccw else pi / 2, + ptype='wire', + ) + + def commit(length: float) -> dict[str, float | str]: + return {'kind': 'bend', 'length': length} + + return (BendOffer( + in_ptype='wire', + out_ptype='wire', + ccw=ccw, + length_domain=(1, numpy.inf), + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + return () + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> Library: + _ = batch, port_names, kwargs + self.render_calls += 1 + tree, pat = Library.mktree('trace') + pat.add_port_pair(names=port_names, ptype='wire') + return tree + + +class CountingPathTool(PathTool): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.render_calls = 0 + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> ILibrary: + self.render_calls += 1 + return super().render(batch, port_names=port_names, **kwargs) + + +def test_pather_jog_failed_two_bend_route_is_atomic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, render='immediate') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='S-bend'): + p.jog('A', 1.5, length=1.5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p._paths['A']) == 0 + +def test_pather_jog_accepts_sub_width_offset_when_length_is_sufficient() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, render='immediate') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 1.5, length=5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -1.5)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p._paths['A']) == 0 + +def test_pather_immediate_render_batches_multi_step_selected_route_once() -> None: + lib = Library() + tool = CountingPathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, render='immediate') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 4, length=10) + + assert tool.render_calls == 1 + assert len(p._paths['A']) == 0 + assert p.pattern.has_shapes() + +def test_pather_jog_length_solved_from_single_position_bound() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 2, x=-6) + assert numpy.allclose(p.pattern.ports['A'].offset, (-6, -2)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + + q = Pather(Library(), tools=tool) + q.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + q.jog('A', 2, p=-6) + assert numpy.allclose(q.pattern.ports['A'].offset, (-6, -2)) + + +def test_pather_positional_bound_requires_port_rotation() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire'), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=None, ptype='wire') + + with pytest.raises(BuildError, match='Ports must have rotation'): + p.trace_to('A', None, x=-5) + + +def test_pather_jog_omitted_length_uses_minimum_length_route() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.jog('A', 2) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -2)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert [step.opcode for step in p._paths['A']] == ['L', 'L', 'L'] + + with pytest.raises(BuildError, match='exactly one positional bound'): + p.jog('A', 2, x=-6, p=-6) + +def test_pather_trace_omitted_length_uses_minimum_offer() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.trace('A', None) + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + + p.trace('A', True) + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, -1)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) + +def test_pather_trace_to_without_bound_uses_single_port_trace_minimum() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.trace_to('A', False) + + assert numpy.allclose(p.pattern.ports['A'].offset, (-1, 1)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 3 * pi / 2) + +def test_pather_trace_to_rejects_conflicting_position_bounds() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + + for kwargs in ({'x': -5, 'y': 2}, {'y': 2, 'x': -5}, {'p': -7, 'x': -5}): + p = Pather(Library(), tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + with pytest.raises(BuildError, match='exactly one positional bound'): + p.trace_to('A', None, **kwargs) + + p = Pather(Library(), tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + with pytest.raises(BuildError, match='length cannot be combined'): + p.trace_to('A', None, x=-5, length=3) + +def test_pather_trace_rejects_length_with_bundle_bound() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='length cannot be combined'): + p.trace('A', None, length=5, xmin=-100) + +@pytest.mark.parametrize('kwargs', [{'xmin': -10, 'xmax': -20}, {'xmax': -20, 'xmin': -10}]) # noqa: PT007 +def test_pather_trace_rejects_multiple_bundle_bounds(kwargs: dict[str, int]) -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 5), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='exactly one bundle bound'): + p.trace(['A', 'B'], None, **kwargs) + + +def test_planner_constrained_bend_requires_jog() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + context = RoutePortContext('A', Port((0, 0), rotation=0, ptype='wire'), tool) + + with pytest.raises(BuildError, match='trace route requires a jog constraint'): + RoutingPlanner().plan_leg('bend', context, length=5, constrain_jog=True) + + +def test_pather_trace_each_plans_all_ports_before_mutation() -> None: + tool = FirstPortOnlyTraceTool() + p = Pather(Library(), tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-2, 5), rotation=0, ptype='blocked') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.trace(['A', 'B'], None, each=5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (-2, 5)) + assert p.pattern.ports['A'].ptype == 'wire' + assert p.pattern.ports['B'].ptype == 'blocked' + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + +def test_pather_bundle_trace_plans_all_ports_before_mutation_or_render() -> None: + tool = FirstPortOnlyTraceTool() + p = Pather(Library(), tools=tool, render='immediate') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='blocked') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.trace(['A', 'B'], True, xmin=-10, spacing=2) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) + assert p.pattern.ports['A'].rotation == 0 + assert p.pattern.ports['B'].rotation == 0 + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + assert tool.render_calls == 0 + assert not p.pattern.has_shapes() + + +def test_pather_route_commit_failure_is_atomic_for_multi_port_trace() -> None: + class CommitFailureTool(PlanningOnlyTool): + def __init__(self) -> None: + self.committed: list[str | None] = [] + self.render_calls = 0 + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[StraightOffer, ...]: + _ = out_ptype, kwargs + if kind != 'straight': + return () + + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=in_ptype) + + def commit(length: float) -> dict[str, float | str | None]: + _ = length + self.committed.append(in_ptype) + if in_ptype == 'bad': + raise BuildError('selected commit failed') + return {'ptype': in_ptype, 'length': length} + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=in_ptype, + endpoint_planner=endpoint, + commit_planner=commit, + ),) + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), + **kwargs: Any, + ) -> Library: + _ = batch, port_names, kwargs + self.render_calls += 1 + tree, pat = Library.mktree('commit_failure_tool') + pat.add_port_pair(names=port_names) + return tree + + tool = CommitFailureTool() + p = Pather(Library(), tools=tool, render='immediate') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((0, 4), rotation=0, ptype='bad') + + with pytest.raises(BuildError, match='selected commit failed'): + p.trace(['A', 'B'], None, each=5) + + assert tool.committed == ['wire', 'bad'] + assert tool.render_calls == 0 + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (0, 4)) + assert p.pattern.ports['A'].ptype == 'wire' + assert p.pattern.ports['B'].ptype == 'bad' + assert len(p._paths['A']) == 0 + assert len(p._paths['B']) == 0 + assert not p.pattern.has_shapes() + +def test_pather_jog_rejects_length_with_position_bound() -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='length cannot be combined'): + p.jog('A', 2, length=5, x=-999) + +@pytest.mark.parametrize('kwargs', [{'x': -999}, {'xmin': -10}]) # noqa: PT007 +def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: + p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'): + p.uturn('A', 4, **kwargs) + +def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.uturn('A', 4) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_pather_uturn_explicit_zero_length_preserves_old_shape() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.uturn('A', 4, length=0) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, -4)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi) + +def test_pather_uturn_does_not_use_direct_planl_fallback() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> Never: + del kind, in_ptype, out_ptype, kwargs + raise NotImplementedError + + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='No legal primitive offer for omitted-length U-turn'): + p.uturn('A', 5) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + + with pytest.raises((BuildError, NotImplementedError)): + p.uturn('A', 5, length=10) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_su_topology_rejects_out_ptype_sensitive_planl_jog() -> None: + class OutPtypeSensitiveTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + radius = 1 if out_ptype is None else 2 + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = radius + else: + rotation = pi / 2 + jog = -radius + ptype = out_ptype or in_ptype or 'wire' + return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=OutPtypeSensitiveTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.jog('A', 5, length=10, out_ptype='wide') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_two_l_planl_only_uturn_is_not_supported() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.uturn('A', 5, length=10) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_two_l_planl_only_jog_is_not_supported() -> None: + class PlanLOnlyTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + del out_ptype + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = 1 + else: + rotation = pi / 2 + jog = -1 + return Port((length, jog), rotation=rotation, ptype=in_ptype or 'wire'), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=PlanLOnlyTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.jog('A', 5, length=10, out_ptype='unk') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + +def test_pather_su_topology_rejects_out_ptype_sensitive_planl_uturn() -> None: + class OutPtypeSensitiveTool(PlanningOnlyTool): + def legacy_planL( + self, + ccw: object, + length: float, + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **_kwargs: Any, + ) -> tuple[Port, dict[str, object]]: + radius = 1 if out_ptype is None else 2 + if ccw is None: + rotation = pi + jog = 0 + elif bool(ccw): + rotation = -pi / 2 + jog = radius + else: + rotation = pi / 2 + jog = -radius + ptype = out_ptype or in_ptype or 'wire' + return Port((length, jog), rotation=rotation, ptype=ptype), {'ccw': ccw, 'length': length} + + p = Pather(Library(), tools=OutPtypeSensitiveTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises((BuildError, NotImplementedError)): + p.uturn('A', 5, length=10, out_ptype='wide') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + +def test_pather_uturn_failed_two_bend_route_is_atomic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=2, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='U-turn'): + p.uturn('A', 1.5, length=0) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].rotation == 0 + assert len(p._paths['A']) == 0 diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py new file mode 100644 index 0000000..ddf869b --- /dev/null +++ b/masque/test/test_pather_core.py @@ -0,0 +1,538 @@ +from typing import Any + +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose, assert_equal + +from masque import Pather, Library, Pattern, Port +from masque.builder import PathTool, PrimitiveOffer, StraightOffer +from masque.builder.planner import RoutingPlanner +from masque.error import BuildError, PortError + + +@pytest.fixture +def pather_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + # Port rotation points into the device, so path extension moves in the opposite direction. + p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + return p, tool, lib + + +def test_builder_tool_symbol_exports() -> None: + import masque + import masque.builder + import masque.builder.tools as builder_tools + + package_root_exports = ( + 'RenderStep', + 'PortPather', + ) + builder_tool_names = ( + 'PrimitiveOffer', + 'StraightOffer', + 'BendOffer', + 'SOffer', + 'UOffer', + ) + internal_names = ( + 'PTypeMatch', + 'canonicalize_domain_value', + 'ptype_match', + 'ptypes_compatible', + ) + + for name in (*package_root_exports, *builder_tool_names): + assert hasattr(masque.builder, name) + + for name in package_root_exports: + assert hasattr(masque, name) + + for name in (*builder_tool_names, *internal_names): + assert not hasattr(masque, name) + + for name in internal_names: + assert not hasattr(masque.builder, name) + + for name in builder_tool_names: + assert hasattr(masque.builder, name) + assert hasattr(builder_tools, name) + + +def test_pather_pending_render_steps_are_private() -> None: + p = Pather(Library(), tools=PathTool(layer=(1, 0), width=1)) + + assert not hasattr(p, 'paths') + assert hasattr(p, '_paths') + + +def test_pather_accepts_and_reuses_planner_instance() -> None: + class CountingPlanner(RoutingPlanner): + def __init__(self) -> None: + self.trace_to_calls = 0 + + def plan_trace_to_route(self, *args: Any, **kwargs: Any) -> Any: + self.trace_to_calls += 1 + return super().plan_trace_to_route(*args, **kwargs) + + planner = CountingPlanner() + p = Pather( + Library(), + tools=PathTool(layer=(1, 0), width=1), + render='deferred', + planner=planner, + ) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 1) + p.straight('A', 2) + + assert p.planner is planner + assert planner.trace_to_calls == 2 + + +def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.straight("start", 10) + + assert_allclose(p.ports["start"].offset, [0, -10], atol=1e-10) + assert p.ports["start"].rotation is not None + assert_allclose(p.ports["start"].rotation, pi / 2, atol=1e-10) + +def test_pather_bend(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.cw("start", 10) + + assert_allclose(p.ports["start"].offset, [-1, -10], atol=1e-10) + assert p.ports["start"].rotation is not None + assert_allclose(p.ports["start"].rotation, 0, atol=1e-10) + +def test_pather_path_to(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.straight("start", y=-50) + assert_equal(p.ports["start"].offset, [0, -50]) + +def test_pather_mpath(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.ports["A"] = Port((0, 0), pi / 2, ptype="wire") + p.ports["B"] = Port((10, 0), pi / 2, ptype="wire") + + p.straight(["A", "B"], ymin=-20) + assert_equal(p.ports["A"].offset, [0, -20]) + assert_equal(p.ports["B"].offset, [10, -20]) + +def test_pather_at_chaining(pather_setup: tuple[Pather, PathTool, Library]) -> None: + p, tool, lib = pather_setup + p.at("start").straight(10).ccw(10) + assert_allclose(p.ports["start"].offset, [1, -20], atol=1e-10) + assert p.ports["start"].rotation is not None + 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() + + p.straight("in", -10) + + assert_allclose(p.ports["in"].offset, [10, 0], atol=1e-10) + + p.straight("in", 20) + assert_allclose(p.ports["in"].offset, [-10, 0], atol=1e-10) + + assert not p.pattern.has_shapes() + +def test_pather_trace_basic() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + # Routing extends opposite the port's inward-facing rotation. + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace(None, 5000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) + + p.at('A').trace(True, 5000) # CCW bend + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, -500)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi/2) + +def test_pather_trace_to() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace_to(None, x=-10000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + + p.at('A').trace_to(None, p=-20000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-20000, 0)) + +def test_pather_bundle_trace() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p.at(['A', 'B']).straight(xmin=-10000) + assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) + + p.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) + # The lower port is on the inner bend, so `xmin` applies to that route. + assert numpy.isclose(p.pattern.ports['A'].offset[0], -20000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -22000) + +def test_portpather_default_spacing_matches_explicit_spacing() -> None: + lib_default = Library() + tool_default = PathTool(layer='M1', width=1000) + p_default = Pather(lib_default, tools=tool_default) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + lib_explicit = Library() + tool_explicit = PathTool(layer='M1', width=1000) + p_explicit = Pather(lib_explicit, tools=tool_explicit) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_default.at(['A', 'B'], spacing=2000).ccw(xmin=-20000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_reused_and_overridden() -> None: + p_default = Pather(Library(), tools=PathTool(layer='M1', width=1000)) + p_default.pattern.ports['A'] = Port((0, 0), rotation=0) + p_default.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000)) + p_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + pp_default = p_default.at(['A', 'B'], spacing=2000) + pp_default.ccw(xmin=-20000) + pp_default.cw(emin=1000) + p_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=2000).cw(emin=1000, spacing=2000) + + assert_allclose(p_default.pattern.ports['A'].offset, p_explicit.pattern.ports['A'].offset) + assert_allclose(p_default.pattern.ports['B'].offset, p_explicit.pattern.ports['B'].offset) + + p_override = Pather(Library(), tools=PathTool(layer='M1', width=1000)) + p_override.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override_explicit = Pather(Library(), tools=PathTool(layer='M1', width=1000)) + p_override_explicit.pattern.ports['A'] = Port((0, 0), rotation=0) + p_override_explicit.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p_override.at(['A', 'B'], spacing=2000).ccw(xmin=-20000, spacing=3000) + p_override_explicit.at(['A', 'B']).ccw(xmin=-20000, spacing=3000) + + assert_allclose(p_override.pattern.ports['A'].offset, p_override_explicit.pattern.ports['A'].offset) + assert_allclose(p_override.pattern.ports['B'].offset, p_override_explicit.pattern.ports['B'].offset) + +def test_portpather_default_spacing_not_injected_for_straight_bundle() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + + p.at(['A', 'B'], spacing=2000).straight(xmin=-10000) + + assert numpy.isclose(p.pattern.ports['A'].offset[0], -10000) + assert numpy.isclose(p.pattern.ports['B'].offset[0], -10000) + +def test_portpather_default_spacing_vector_revalidated_after_selection_change() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 2000), rotation=0) + p.pattern.ports['C'] = Port((0, 4000), rotation=0) + + pp = p.at(['A', 'B', 'C'], spacing=[2000, 3000]) + pp.deselect('C') + + with pytest.raises(BuildError, match='spacing must be scalar or have length 1'): + pp.ccw(xmin=-20000) + +def test_pather_each_bound() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((-1000, 2000), rotation=0) + + p.at(['A', 'B']).trace(None, each=5000) + assert numpy.allclose(p.pattern.ports['A'].offset, (-5000, 0)) + assert numpy.allclose(p.pattern.ports['B'].offset, (-6000, 2000)) + +def test_selection_management() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 0), rotation=0) + + pp = p.at('A') + assert pp.ports == ['A'] + + pp.select('B') + assert pp.ports == ['A', 'B'] + + pp.deselect('A') + assert pp.ports == ['B'] + + pp.select(['A']) + assert pp.ports == ['B', 'A'] + + pp.drop() + assert 'A' not in p.pattern.ports + assert 'B' not in p.pattern.ports + assert pp.ports == [] + +def test_mark_fork() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((100, 200), rotation=1) + + pp = p.at('A') + pp.mark('B') + assert 'B' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['B'].offset, (100, 200)) + assert p.pattern.ports['B'].rotation == 1 + assert pp.ports == ['A'] + + pp.fork('C') + assert 'C' in p.pattern.ports + assert pp.ports == ['C'] + +def test_mark_fork_reject_overwrite_and_duplicate_targets() -> None: + lib = Library() + + p_mark = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + with pytest.raises(PortError, match='overwrite existing ports'): + p_mark.at('A').mark('C') + assert numpy.allclose(p_mark.pattern.ports['C'].offset, (2, 0)) + + p_fork = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + })) + pp = p_fork.at(['A', 'B']) + with pytest.raises(PortError, match='targets would collide'): + pp.fork({'A': 'X', 'B': 'X'}) + assert set(p_fork.pattern.ports) == {'A', 'B'} + assert pp.ports == ['A', 'B'] + +def test_mark_fork_dead_overwrite_and_duplicate_targets() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + p.set_dead() + + p.at('A').mark('C') + assert numpy.allclose(p.pattern.ports['C'].offset, (0, 0)) + + pp = p.at(['A', 'B']) + pp.fork({'A': 'X', 'B': 'X'}) + assert numpy.allclose(p.pattern.ports['X'].offset, (1, 0)) + assert pp.ports == ['X'] + +def test_mark_fork_reject_missing_sources() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + })) + + with pytest.raises(PortError, match='selected ports'): + p.at(['A', 'B']).mark({'Z': 'C'}) + + with pytest.raises(PortError, match='selected ports'): + p.at(['A', 'B']).fork({'Z': 'C'}) + +def test_rename() -> None: + lib = Library() + p = Pather(lib) + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').rename('B') + assert 'A' not in p.pattern.ports + assert 'B' in p.pattern.ports + + p.pattern.ports['C'] = Port((0, 0), rotation=0) + pp = p.at(['B', 'C']) + pp.rename({'B': 'D', 'C': 'E'}) + assert 'B' not in p.pattern.ports + assert 'C' not in p.pattern.ports + assert 'D' in p.pattern.ports + assert 'E' in p.pattern.ports + assert set(pp.ports) == {'D', 'E'} + +def test_pather_dead_fallback_preserves_out_ptype() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.set_dead() + + p.straight('A', -1000, out_ptype='other') + + assert numpy.allclose(p.pattern.ports['A'].offset, (1000, 0)) + assert p.pattern.ports['A'].ptype == 'other' + assert len(p._paths['A']) == 0 + +def test_pather_dead_fallback_does_not_hide_fatal_route_errors() -> None: + class MismatchedEndpointTool(PathTool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001,ARG002 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + return (StraightOffer( + in_ptype=in_ptype, + out_ptype='other', + endpoint_planner=endpoint, + commit_planner=lambda length: {'length': length}, + ),) + + p = Pather(Library(), tools=MismatchedEndpointTool(layer='M1', width=1000, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.set_dead() + + with pytest.raises(BuildError, match='does not match declared offer out_ptype'): + p.straight('A', 1000) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + + +def test_pather_valid_candidate_does_not_hide_fatal_route_errors() -> None: + class PartlyMismatchedEndpointTool(PathTool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001,ARG002 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def mismatched_endpoint(length: float) -> Port: + ptype = 'wire' if numpy.isclose(length, 0) else 'other' + return Port((length, 0), rotation=pi, ptype=ptype) + + def valid_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + return ( + StraightOffer( + in_ptype=in_ptype, + out_ptype='wire', + endpoint_planner=mismatched_endpoint, + commit_planner=lambda length: {'kind': 'mismatched', 'length': length}, + ), + StraightOffer( + in_ptype=in_ptype, + out_ptype='wire', + endpoint_planner=valid_endpoint, + commit_planner=lambda length: {'kind': 'valid', 'length': length}, + ), + ) + + p = Pather(Library(), tools=PartlyMismatchedEndpointTool(layer='M1', width=1000, ptype='wire')) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='does not match declared offer out_ptype'): + p.straight('A', 1000) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert p.pattern.ports['A'].ptype == 'wire' + assert len(p._paths['A']) == 0 + + +def test_pather_dead_place_overwrites_colliding_ports_last_wins() -> None: + lib = Library() + p = Pather(lib, pattern=Pattern(ports={ + 'A': Port((5, 5), rotation=0), + 'keep': Port((9, 9), rotation=0), + })) + p.set_dead() + + other = Pattern() + other.ports['X'] = Port((1, 0), rotation=0) + other.ports['Y'] = Port((2, 0), rotation=pi / 2) + + p.place(other, port_map={'X': 'A', 'Y': 'A'}) + + assert set(p.pattern.ports) == {'A', 'keep'} + assert numpy.allclose(p.pattern.ports['A'].offset, (2, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, pi / 2) + +def test_pather_dead_plug_overwrites_colliding_outputs_last_wins() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool, pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0, ptype='wire'), + 'B': Port((99, 99), rotation=0, ptype='wire'), + })) + p.set_dead() + + other = Pattern() + other.ports['in'] = Port((0, 0), rotation=pi, ptype='wire') + other.ports['X'] = Port((10, 0), rotation=0, ptype='wire') + other.ports['Y'] = Port((20, 0), rotation=0, ptype='wire') + + p.plug(other, map_in={'A': 'in'}, map_out={'X': 'B', 'Y': 'B'}) + + assert 'A' not in p.pattern.ports + assert 'B' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['B'].offset, (20, 0)) + assert p.pattern.ports['B'].rotation is not None + assert numpy.isclose(p.pattern.ports['B'].rotation, 0) + +def test_pather_dead_rename_overwrites_colliding_ports_last_wins() -> None: + p = Pather(Library(), pattern=Pattern(ports={ + 'A': Port((0, 0), rotation=0), + 'B': Port((1, 0), rotation=0), + 'C': Port((2, 0), rotation=0), + })) + p.set_dead() + + p.rename_ports({'A': 'C', 'B': 'C'}) + + assert set(p.pattern.ports) == {'C'} + assert numpy.allclose(p.pattern.ports['C'].offset, (1, 0)) diff --git a/masque/test/test_pather_place_plug.py b/masque/test/test_pather_place_plug.py new file mode 100644 index 0000000..660d080 --- /dev/null +++ b/masque/test/test_pather_place_plug.py @@ -0,0 +1,120 @@ +import pytest +import numpy +from numpy import pi + +from masque import Pather, Library, Pattern, Port +from masque.builder.tools import PathTool +from masque.error import PortError, PatternError + + +def test_pather_place_treeview_resolves_once() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + + tree = {'child': Pattern(ports={'B': Port((1, 0), pi)})} + + p.place(tree) + + assert len(lib) == 1 + assert 'child' in lib + assert 'child' in p.pattern.refs + assert 'B' in p.pattern.ports + +def test_pather_plug_treeview_resolves_once() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + tree = {'child': Pattern(ports={'B': Port((0, 0), pi)})} + + p.plug(tree, {'A': 'B'}) + + assert len(lib) == 1 + assert 'child' in lib + assert 'child' in p.pattern.refs + assert 'A' not in p.pattern.ports + +def test_pather_failed_plug_does_not_add_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + p.pattern.annotations = {'k': [1]} + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').trace(None, 5000) + assert [step.opcode for step in p._paths['A']] == ['L'] + + other = Pattern( + annotations={'k': [2]}, + ports={'X': Port((0, 0), pi), 'Y': Port((5, 0), 0)}, + ) + + with pytest.raises(PatternError, match='Annotation keys overlap'): + p.plug(other, {'A': 'X'}, map_out={'Y': 'Z'}, append=True) + + assert [step.opcode for step in p._paths['A']] == ['L'] + assert set(p.pattern.ports) == {'A'} + +def test_pather_place_reused_deleted_name_keeps_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + p.rename_ports({'A': None}) + + other = Pattern(ports={'X': Port((-5000, 0), rotation=0)}) + p.place(other, port_map={'X': 'A'}, append=True) + p.at('A').straight(2000) + + assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] + + p.render() + assert p.pattern.has_shapes() + assert 'A' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) + +def test_pather_plug_reused_deleted_name_keeps_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + p.rename_ports({'A': None}) + + other = Pattern( + ports={ + 'X': Port((0, 0), rotation=pi), + 'Y': Port((-5000, 0), rotation=0), + }, + ) + p.plug(other, {'B': 'X'}, map_out={'Y': 'A'}, append=True) + p.at('A').straight(2000) + + assert [step.opcode for step in p._paths['A']] == ['L', 'P', 'L'] + + p.render() + assert p.pattern.has_shapes() + assert 'A' in p.pattern.ports + assert 'B' not in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-7000, 0)) + +def test_pather_failed_plugged_does_not_add_break_marker() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0) + + p.at('A').straight(5000) + assert [step.opcode for step in p._paths['A']] == ['L'] + + with pytest.raises(PortError, match='Connection destination ports were not found'): + p.plugged({'A': 'missing'}) + + assert [step.opcode for step in p._paths['A']] == ['L'] + assert set(p._paths) == {'A'} diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py new file mode 100644 index 0000000..d1945c1 --- /dev/null +++ b/masque/test/test_pather_primitive_offers.py @@ -0,0 +1,659 @@ +from collections.abc import Callable +from typing import Any, Literal + +import numpy +import pytest +from numpy import pi + +from masque import Library, Path, Port, Pather +from masque.builder.planner import RoutingPlanner +from masque.builder.tools import ( + BendOffer, + PathTool, + PrimitiveOffer, + SOffer, + StraightOffer, + Tool, + UOffer, +) +from masque.error import BuildError +from masque.utils import PTypeMatch, ptype_match + + +def offer_callbacks(planner: Callable[[float], tuple[Port, Any]]) -> dict[str, Callable[[float], Any]]: + return { + 'endpoint_planner': lambda parameter: planner(parameter)[0], + 'commit_planner': lambda parameter: planner(parameter)[1], + } + + +class PlanningOnlyTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('planning_only_tool') + pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else 'unk') + return tree + + +def test_tool_requires_primitive_offers_override() -> None: + class RenderOnlyTool(Tool): + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + return Library() + + with pytest.raises(TypeError): + RenderOnlyTool() + + +def test_tool_base_primitive_offers_is_not_no_offer_fallback() -> None: + class BaseCallingTool(Tool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + return Tool.primitive_offers(self, kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + return Library() + + with pytest.raises(NotImplementedError): + BaseCallingTool().primitive_offers('straight') + + +def canonicalize_offer_parameter(value: float, domain: tuple[float, float]) -> float: + offer = StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) + return offer.canonicalize_parameter(value) + + +def test_offer_canonicalize_parameter_half_open_and_singleton() -> None: + assert canonicalize_offer_parameter(-1e-13, (0, 10)) == 0 + assert canonicalize_offer_parameter(3, (0, 10)) == 3 + + with pytest.raises(BuildError, match='outside half-open domain'): + canonicalize_offer_parameter(10, (0, 10)) + + assert canonicalize_offer_parameter(5 + 1e-13, (5, 5)) == 5 + + with pytest.raises(BuildError, match='outside singleton domain'): + canonicalize_offer_parameter(5.1, (5, 5)) + + +@pytest.mark.parametrize('value', [numpy.nan, numpy.inf, -numpy.inf]) +def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) -> None: + with pytest.raises(BuildError, match='must be finite'): + canonicalize_offer_parameter(value, (0, 10)) + + +def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None: + with pytest.raises(BuildError, match='lower bound must not exceed upper bound'): + canonicalize_offer_parameter(3, (10, 0)) + + +def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None: + assert ptype_match('wire', 'wire') is PTypeMatch.EXACT + assert ptype_match(None, 'wire') is PTypeMatch.WILDCARD + assert ptype_match('unk', 'wire') is PTypeMatch.WILDCARD + assert ptype_match('wire', 'metal') is PTypeMatch.MISMATCH + + +def test_offer_split_endpoint_and_commit_callbacks_are_independent() -> None: + endpoint_calls: list[float] = [] + commit_calls: list[float] = [] + + def endpoint(length: float) -> Port: + endpoint_calls.append(length) + return Port((length, 2), rotation=pi, ptype='wire') + + def commit(length: float) -> dict[str, float]: + commit_calls.append(length) + return {'length': length} + + offer = StraightOffer( + in_ptype='wire', + out_ptype='wire', + length_domain=(5, 5), + endpoint_planner=endpoint, + commit_planner=commit, + ) + + assert numpy.allclose(offer.endpoint_at(5 + 1e-13).offset, (5, 2)) + assert offer.cost_at(5 + 1e-13) == 5 + pi + assert commit_calls == [] + + assert offer.commit(5 + 1e-13) == {'length': 5} + assert endpoint_calls == [5, 5] + assert commit_calls == [5] + + +def test_straight_offer_generated_factory_uses_default_endpoint_and_data_bbox() -> None: + def data_at(length: float) -> dict[str, float]: + return {'length': length} + + offer = StraightOffer.generated( + 'wire', + data_at, + length_domain=(2, 8), + priority_bias=3, + bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], 1]]), + ) + + endpoint = offer.endpoint_at(4) + + assert offer.length_domain == (2, 8) + assert offer.priority_bias == 3 + assert numpy.allclose(endpoint.offset, [4, 0]) + assert endpoint.rotation == pi + assert endpoint.ptype == 'wire' + assert offer.commit(4) == {'length': 4} + assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 1]]) + + +def test_s_offer_generated_factory_keeps_endpoint_and_commit_data_independent() -> None: + def endpoint_at(jog: float) -> Port: + return Port((10, jog), rotation=pi, ptype='wire') + + def data_at(jog: float) -> dict[str, Any]: + return {'jog': abs(jog), 'mirrored': jog < 0} + + offer = SOffer.generated( + 'wire', + endpoint_at, + data_at, + jog_domain=(-5, 5), + bbox_for_data=lambda data: numpy.array([[0, -data['jog']], [10, data['jog']]]), + ) + + endpoint = offer.endpoint_at(-3) + + assert numpy.allclose(endpoint.offset, [10, -3]) + assert offer.commit(-3) == {'jog': 3, 'mirrored': True} + assert numpy.allclose(offer.bbox_at(-3), [[0, -3], [10, 3]]) + + +def test_bend_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: + def endpoint_at(length: float) -> Port: + return Port((length, length), rotation=-pi / 2, ptype='wire') + + def data_at(length: float) -> dict[str, float]: + return {'length': length} + + offer = BendOffer.generated( + 'wire', + endpoint_at, + data_at, + ccw=True, + length_domain=(4, 4), + bbox_for_data=lambda data: numpy.array([[0, 0], [data['length'], data['length']]]), + ) + + endpoint = offer.endpoint_at(4) + + assert offer.ccw + assert offer.length_domain == (4, 4) + assert numpy.allclose(endpoint.offset, [4, 4]) + assert endpoint.rotation == 3 * pi / 2 + assert offer.commit(4) == {'length': 4} + assert numpy.allclose(offer.bbox_at(4), [[0, 0], [4, 4]]) + + +def test_u_offer_generated_factory_uses_explicit_endpoint_and_data_bbox() -> None: + def endpoint_at(jog: float) -> Port: + return Port((12, jog), rotation=0, ptype='wire') + + def data_at(jog: float) -> dict[str, float]: + return {'jog': jog} + + offer = UOffer.generated( + 'wire', + endpoint_at, + data_at, + jog_domain=(-6, 6), + bbox_for_data=lambda data: numpy.array([[0, min(0, data['jog'])], [12, max(0, data['jog'])]]), + ) + + endpoint = offer.endpoint_at(-4) + + assert offer.jog_domain == (-6, 6) + assert numpy.allclose(endpoint.offset, [12, -4]) + assert endpoint.rotation == 0 + assert offer.commit(-4) == {'jog': -4} + assert numpy.allclose(offer.bbox_at(-4), [[0, -4], [12, 0]]) + + +def test_prebuilt_offer_factories_return_fresh_endpoint_copies() -> None: + data = {'kind': 'prebuilt'} + bbox_for_data = lambda _data: numpy.array([[-1, -2], [6, 3]]) # noqa: E731 + endpoint = Port((5, 2), rotation=pi / 2, ptype='out') + cases = [ + (StraightOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 5), + (BendOffer.prebuilt('in', 'out', endpoint, data, ccw=True, bbox_for_data=bbox_for_data), 5), + (SOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), + (UOffer.prebuilt('in', 'out', endpoint, data, bbox_for_data=bbox_for_data), 2), + ] + + for offer, parameter in cases: + first = offer.endpoint_at(parameter) + first.offset[:] = 99 + second = offer.endpoint_at(parameter) + + assert numpy.allclose(second.offset, [5, 2]) + assert second.rotation == pi / 2 + assert second.ptype == 'out' + assert offer.commit(parameter) is data + assert numpy.allclose(offer.bbox_at(parameter), [[-1, -2], [6, 3]]) + + +def test_offer_rejects_negative_priority_bias() -> None: + with pytest.raises(BuildError, match='priority_bias must be nonnegative'): + StraightOffer(in_ptype='wire', out_ptype='wire', priority_bias=-1) + + +def test_offer_rejects_one_sided_split_callbacks() -> None: + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='wire') + + with pytest.raises(BuildError, match='require both'): + StraightOffer(in_ptype='wire', out_ptype='wire', endpoint_planner=endpoint) + + +def test_offer_bbox_at_validates_bounds() -> None: + offer = StraightOffer( + in_ptype='wire', + out_ptype='wire', + bbox_planner=lambda _length: numpy.array([[0, 0], [1, 2]]), + **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), + ) + + assert numpy.allclose(offer.bbox_at(3), [[0, 0], [1, 2]]) + + bad = StraightOffer( + in_ptype='wire', + out_ptype='wire', + bbox_planner=lambda _length: numpy.array([0, 1]), + **offer_callbacks(lambda length: (Port((length, 0), rotation=pi, ptype='wire'), {'length': length})), + ) + with pytest.raises(BuildError, match='shape'): + bad.bbox_at(3) + + +def test_pathtool_straight_offer_bbox_matches_path_bounds() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + offer = tool.primitive_offers('straight', in_ptype='wire')[0] + + bounds = offer.bbox_at(10) + expected = Path(vertices=[(0, 0), (10, 0)], width=2).get_bounds_single() + + assert numpy.allclose(bounds, expected) + + +def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + offer = tool.primitive_offers('bend', in_ptype='wire', ccw=True)[0] + + bounds = offer.bbox_at(1) + expected = Path(vertices=[(0, 0), (1, 0), (1, 1)], width=2).get_bounds_single() + + assert isinstance(offer, BendOffer) + assert offer.length_domain == (1, 1) + assert numpy.allclose(bounds, expected) + + +def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0] + + bounds = offer.bbox_at(3) + expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single() + + assert isinstance(offer, SOffer) + assert numpy.allclose(bounds, expected) + + +def test_pathtool_u_offers_remain_unsupported() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + + assert tool.primitive_offers('u', in_ptype='wire') == () + + +@pytest.mark.parametrize( + ('kind', 'kwargs'), + [ + ('straight', {}), + ('bend', {'ccw': True}), + ('s', {'length': 6}), + ], +) +def test_pathtool_out_ptype_unk_is_wildcard( + kind: Literal['straight', 'bend', 's'], + kwargs: dict[str, Any], + ) -> None: + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + + offers = tool.primitive_offers(kind, in_ptype='wire', out_ptype='unk', **kwargs) + + assert offers + assert offers[0].out_ptype == 'wire' + + +def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None: + class NoOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + raise NotImplementedError + + p = Pather(Library(), tools=NoOfferTool()) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(BuildError, match='No legal primitive offer for trace'): + p.straight('A', 5) + + +@pytest.mark.parametrize('err_type', [BuildError, KeyError, TypeError]) +def test_pather_propagates_offer_query_errors(err_type: type[Exception]) -> None: + class BrokenTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kind, in_ptype, out_ptype, kwargs + raise err_type('offer query failed') + + p = Pather(Library(), tools=BrokenTool()) + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + with pytest.raises(err_type): + p.straight('A', 5) + + +def test_pather_selects_lowest_cost_offer() -> None: + class MultiOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def high(length: float) -> tuple[Port, dict[str, str | float]]: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'high'} + + def low(length: float) -> tuple[Port, dict[str, str | float]]: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype), {'kind': 'low'} + + return ( + StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=10, **offer_callbacks(high)), + StraightOffer(in_ptype=in_ptype, out_ptype=out_ptype, priority_bias=0, **offer_callbacks(low)), + ) + + p = Pather(Library(), tools=MultiOfferTool(), render='deferred') + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 7) + + assert p._paths['A'][0].data == {'kind': 'low'} + assert numpy.allclose(p.ports['A'].offset, (-7, 0)) + + +class StrategyTieTool(PlanningOnlyTool): + def __init__(self) -> None: + self.seen_kwargs: list[dict[str, Any]] = [] + + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + self.seen_kwargs.append(dict(kwargs)) + endpoint_ptype = out_ptype or in_ptype + if kind == 'straight': + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda length: ( + Port((length, 0), rotation=pi, ptype=endpoint_ptype), + {'kind': 'straight', 'length': length}, + )), + ),) + if kind == 's': + return (SOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda jog: ( + Port((3, jog), rotation=pi, ptype=endpoint_ptype), + {'kind': 's', 'jog': jog}, + )), + ),) + return () + + +def pather_with_strategy_tool( + planner: RoutingPlanner | None = None, + ) -> tuple[Pather, StrategyTieTool]: + tool = StrategyTieTool() + pather = Pather(Library(), tools=tool, planner=planner, render='deferred') + pather.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + return pather, tool + + +def test_pather_route_strategy_defaults_to_straight_first() -> None: + pather, _tool = pather_with_strategy_tool() + + pather.jog('A', 4, length=10) + + assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] + assert pather._paths['A'][0].data['length'] == 7 + + +def test_pather_route_strategy_uses_planner_default() -> None: + pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) + + pather.jog('A', 4, length=10) + + assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] + assert pather._paths['A'][1].data['length'] == 7 + + +def test_pather_route_strategy_per_route_overrides_planner_default() -> None: + pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) + + pather.jog('A', 4, length=10, strategy='straight_first') + + assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] + + +def test_pather_route_strategy_per_route_can_request_turn_first() -> None: + pather, _tool = pather_with_strategy_tool() + + pather.jog('A', 4, length=10, strategy='turn_first') + + assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] + + +def test_pather_route_strategy_is_not_forwarded_to_tool() -> None: + pather, tool = pather_with_strategy_tool() + + pather.jog('A', 4, length=10, strategy='turn_first', marker='sentinel') + + assert tool.seen_kwargs + assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs) + assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs) + + +def test_pather_route_strategy_rejects_invalid_values() -> None: + with pytest.raises(BuildError, match='Invalid route strategy'): + RoutingPlanner(strategy='sideways') + + pather, _tool = pather_with_strategy_tool() + with pytest.raises(BuildError, match='Invalid route strategy'): + pather.jog('A', 4, length=10, strategy='sideways') + + +def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: + invalid_parameters: list[float] = [] + + class RotationOfferTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def invalid_endpoint(length: float) -> Port: + invalid_parameters.append(length) + return Port((length, 0), rotation=0, ptype=out_ptype or in_ptype) + + def valid_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) + + return ( + StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + endpoint_planner=invalid_endpoint, + commit_planner=lambda length: {'kind': 'invalid', 'length': length}, + ), + StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + endpoint_planner=valid_endpoint, + commit_planner=lambda length: {'kind': 'valid', 'length': length}, + ), + ) + + p = Pather(Library(), tools=RotationOfferTool(), render='deferred') + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 7) + + assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7} + assert invalid_parameters == [0.0, 0.0] + + +def test_pather_commits_only_selected_offer() -> None: + committed: list[str] = [] + + class RecordingTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + if kind != 'straight': + return () + + def make(label: str, priority: float) -> StraightOffer: + def endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype=out_ptype or in_ptype) + + def commit(length: float) -> dict[str, float | str]: + committed.append(label) + return {'kind': label, 'length': length} + + return StraightOffer( + in_ptype=in_ptype, + out_ptype=out_ptype, + priority_bias=priority, + endpoint_planner=endpoint, + commit_planner=commit, + ) + + return (make('expensive', 100), make('cheap', 0)) + + p = Pather(Library(), tools=RecordingTool(), render='deferred') + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 5) + + assert committed == ['cheap'] + assert p._paths['A'][0].data == {'kind': 'cheap', 'length': 5} + + +def test_pather_routes_with_offer_only_s_and_u_tool() -> None: + class OfferOnlyTool(PlanningOnlyTool): + def primitive_offers( + self, + kind: Literal['straight', 'bend', 's', 'u'], + *, + in_ptype: str | None = None, + out_ptype: str | None = None, + **kwargs: Any, + ) -> tuple[PrimitiveOffer, ...]: + _ = kwargs + endpoint_ptype = out_ptype or in_ptype + if kind == 'straight': + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda length: ( + Port((length, 0), rotation=pi, ptype=endpoint_ptype), + {'kind': 'straight', 'length': length}, + )), + ),) + if kind == 's': + return (SOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda jog: ( + Port((5, jog), rotation=pi, ptype=endpoint_ptype), + {'kind': 's', 'jog': jog}, + )), + ),) + if kind == 'u': + return (UOffer( + in_ptype=in_ptype, + out_ptype=endpoint_ptype, + **offer_callbacks(lambda jog: ( + Port((2, jog), rotation=0, ptype=endpoint_ptype), + {'kind': 'u', 'jog': jog}, + )), + ),) + return () + + p = Pather(Library(), tools=OfferOnlyTool(), render='deferred') + p.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.jog('A', 3) + p.uturn('A', 4) + + assert [step.data['kind'] for step in p._paths['A']] == ['s', 'u'] diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py new file mode 100644 index 0000000..186faf4 --- /dev/null +++ b/masque/test/test_pather_rendering.py @@ -0,0 +1,526 @@ +from typing import TYPE_CHECKING, cast +import logging + +import pytest +import numpy +from numpy import pi +from numpy.testing import assert_allclose + +from ..builder import Pather +from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool +from ..error import BuildError +from ..library import Library +from ..pattern import Pattern +from ..ports import Port + +if TYPE_CHECKING: + from ..shapes import Path + + +@pytest.fixture +def deferred_render_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + rp = Pather(lib, tools=tool, render='deferred') + rp.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + return rp, tool, lib + + +def add_pending_straight(pather: Pather) -> None: + pather.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + pather.straight("start", 10) + + +def route_in_context(pather: Pather) -> None: + with pather: + add_pending_straight(pather) + + +def fail_in_context(pather: Pather) -> None: + with pather: + add_pending_straight(pather) + raise RuntimeError('body failed') + + +def test_deferred_render_stores_pending_paths_until_render(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).straight(10) + + assert not rp.pattern.has_shapes() + assert len(rp._paths["start"]) == 2 + + rp.render() + assert rp.pattern.has_shapes() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + + # PathTool renders length steps in the port extension direction. + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert len(path_shape.vertices) == 3 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) + +def test_deferred_render_bend(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).cw(10) + + rp.render() + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + # The bend route is explicit straight run plus a fixed-size bend. + assert len(path_shape.vertices) == 5 + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -19], [0, -20], [-1, -20]], atol=1e-10) + +def test_deferred_render_jog_uses_lowest_cost_two_bend_route(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").jog(4, length=10) + + assert [step.opcode for step in rp._paths["start"]] == ["L", "L", "L", "L"] + + rp.render() + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -8], [0, -9], [1, -9], [3, -9], [4, -9], [4, -10]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [4, -10], atol=1e-10) + +def test_deferred_render_mirror_preserves_planned_bend_geometry(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).cw(10) + + rp.mirror(0) + rp.render() + + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, 10], [0, 19], [0, 20], [-1, 20]], atol=1e-10) + +def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool1, lib = deferred_render_setup + tool2 = PathTool(layer=(2, 0), width=4, ptype="wire") + + rp.at("start").straight(10) + rp.retool(tool2, keys=["start"]) + rp.at("start").straight(10) + + rp.render() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + assert len(rp.pattern.shapes[(2, 0)]) == 1 + +def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + pp = rp.at("start") + pp.straight(10) + pp.translate((5, 0)) + pp.straight(10) + + rp.render() + + shapes = rp.pattern.shapes[(1, 0)] + assert len(shapes) == 2 + assert_allclose(cast("Path", shapes[0]).vertices, [[0, 0], [0, -10]], atol=1e-10) + assert_allclose(cast("Path", shapes[1]).vertices, [[5, -10], [5, -20]], atol=1e-10) + assert_allclose(rp.ports["start"].offset, [5, -20], atol=1e-10) + +def test_deferred_render_dead_ports() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=1) + rp = Pather(lib, ports={"in": Port((0, 0), 0)}, tools=tool, render='deferred') + rp.set_dead() + + rp.straight("in", -10) + + assert_allclose(rp.ports["in"].offset, [10, 0], atol=1e-10) + + assert len(rp._paths["in"]) == 0 + + rp.render() + assert not rp.pattern.has_shapes() + + +def test_pather_default_auto_policy_renders_immediately_outside_context() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool) + p.ports["start"] = Port((0, 0), pi / 2, ptype="wire") + + p.straight("start", 10) + + assert p.pattern.has_shapes() + assert not any(p._paths.values()) + + +def test_pather_default_auto_policy_defers_until_clean_context_exit() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + with Pather(lib, tools=tool) as p: + add_pending_straight(p) + assert not p.pattern.has_shapes() + assert len(p._paths["start"]) == 1 + + assert p.pattern.has_shapes() + assert not any(p._paths.values()) + + +def test_pather_context_warn_policy_logs_pending_paths(caplog: pytest.LogCaptureFixture) -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='warn') as p: + add_pending_straight(p) + + records = [ + record + for record in caplog.records + if 'Pather context exited with 1 pending render step on 1 port' in record.getMessage() + ] + assert len(records) == 1 + assert records[0].stack_info is None + assert len(p._paths["start"]) == 1 + assert not p.pattern.has_shapes() + + +def test_pather_context_error_policy_rejects_pending_paths() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool, render='error') + + with pytest.raises(BuildError, match='1 pending render step on 1 port'): + route_in_context(p) + + assert len(p._paths["start"]) == 1 + assert not p.pattern.has_shapes() + + +def test_pather_context_ignore_policy_leaves_pending_paths_silent(caplog: pytest.LogCaptureFixture) -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + with caplog.at_level(logging.WARNING, logger="masque.builder.pather"), Pather(lib, tools=tool, render='ignore') as p: + add_pending_straight(p) + + assert not caplog.records + assert len(p._paths["start"]) == 1 + assert not p.pattern.has_shapes() + + +def test_pather_context_policy_does_not_mask_body_exception() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool, render='error') + + with pytest.raises(RuntimeError, match='body failed'): + fail_in_context(p) + + assert len(p._paths["start"]) == 1 + assert not p.pattern.has_shapes() + + +def test_pather_rejects_invalid_render_policy() -> None: + with pytest.raises(BuildError, match='Invalid render policy'): + Pather(Library(), tools=PathTool(layer=(1, 0), width=2), render='later') # type: ignore[arg-type] + + +def test_deferred_render_rename_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10) + rp.rename_ports({"start": "new_start"}) + rp.at("new_start").straight(10) + + assert "start" not in rp._paths + assert len(rp._paths["new_start"]) == 2 + + rp.render() + assert rp.pattern.has_shapes() + assert len(rp.pattern.shapes[(1, 0)]) == 1 + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20]], atol=1e-10) + assert "new_start" in rp.ports + assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10) + +def test_deferred_render_drop_keeps_pending_geometry_without_port(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: + rp, tool, lib = deferred_render_setup + rp.at("start").straight(10).drop() + + assert "start" not in rp.ports + assert len(rp._paths["start"]) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert "start" not in rp.ports + path_shape = cast("Path", rp.pattern.shapes[(1, 0)][0]) + assert_allclose(path_shape.vertices, [[0, 0], [0, -10]], atol=1e-10) + +def test_pathtool_bend_offer_render_geometry_matches_ports() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0] + start = Port((0, 0), rotation=pi, ptype="wire") + end = offer.endpoint_at(1) + tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),)) + pat = tree.top_pattern() + path_shape = cast("Path", pat.shapes[(1, 0)][0]) + + assert offer.length_domain == (1, 1) + assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 1]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [1, 1], atol=1e-10) + +def test_pathtool_s_offer_render_geometry_matches_ports() -> None: + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + + offer = tool.primitive_offers("s", in_ptype="wire")[0] + start = Port((0, 0), rotation=pi, ptype="wire") + end = offer.endpoint_at(4) + tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),)) + pat = tree.top_pattern() + path_shape = cast("Path", pat.shapes[(1, 0)][0]) + + assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10) + assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10) + assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10) + +def test_deferred_render_uturn_fallback() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = Pather(lib, tools=tool, render='deferred') + rp.pattern.ports['A'] = Port((0, 0), rotation=0) + + rp.at('A').uturn(offset=10000, length=5000) + + assert len(rp._paths['A']) == 4 + assert [step.opcode for step in rp._paths['A']] == ['L', 'L', 'L', 'L'] + + rp.render() + assert rp.pattern.ports['A'].rotation is not None + assert numpy.isclose(rp.pattern.ports['A'].rotation, pi) + +def test_pather_render_auto_renames_single_use_tool_children() -> None: + class FullTreeTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), + }) + child = Pattern(annotations={'batch': [len(batch)]}) + top.ref('_seg') + tree['_top'] = top + tree['_seg'] = child + return tree + + lib = Library() + p = Pather(lib, tools=FullTreeTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + p.straight('A', 10) + p.render() + + assert len(lib) == 2 + assert set(lib.keys()) == set(p.pattern.refs.keys()) + assert len(set(p.pattern.refs.keys())) == 2 + assert all(name.startswith('_seg') for name in lib) + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +def test_custom_tool_render_preserves_segment_subtrees() -> None: + class TraceTreeTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: self._trace(length), + ),) + + def _trace(self, length, *, port_names=('A', 'B')) -> Library: # noqa: ANN001 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), + }) + child = Pattern(annotations={'length': [length]}) + top.ref('_seg') + tree['_top'] = top + tree['_seg'] = child + return tree + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + assert len(batch) == 1 + assert isinstance(batch[0].data, Library) + return batch[0].data + + lib = Library() + p = Pather(lib, tools=TraceTreeTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + + assert '_seg' in lib + assert '_seg' in p.pattern.refs + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +def test_pather_render_rejects_missing_single_use_tool_refs() -> None: + class MissingSingleUseTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((1, 0), pi, ptype='wire'), + }) + top.ref('_seg') + tree['_top'] = top + return tree + + lib = Library() + lib['_seg'] = Pattern(annotations={'stale': [1]}) + p = Pather(lib, tools=MissingSingleUseTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='missing single-use refs'): + p.render() + + assert list(lib.keys()) == ['_seg'] + assert not p.pattern.refs + +def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: + class SharedRefTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + top = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), + }) + top.ref('shared') + tree['_top'] = top + return tree + + lib = Library() + lib['shared'] = Pattern(annotations={'shared': [1]}) + p = Pather(lib, tools=SharedRefTool()) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + + p.straight('A', 10) + p.render() + + assert 'shared' in p.pattern.refs + assert p.pattern.referenced_patterns() <= set(lib.keys()) + +@pytest.mark.parametrize('append', [True, False]) +def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: bool) -> None: + class WrongEndpointTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length + 1, 0), pi, ptype='wire'), + }) + return tree + + lib = Library() + p = Pather(lib, tools=WrongEndpointTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='does not match planned endpoint'): + p.render(append=append) + + assert not p.pattern.refs + assert not lib + +@pytest.mark.parametrize('append', [True, False]) +def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None: + class WrongPtypeTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + ptype = out_ptype or in_ptype or 'wire' + return (StraightOffer( + in_ptype=in_ptype, + out_ptype=ptype, + endpoint_planner=lambda length: Port((length, 0), rotation=pi, ptype=ptype), + commit_planner=lambda length: {'length': length}, + ),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data['length'] + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='metal'), + }) + return tree + + lib = Library() + p = Pather(lib, tools=WrongPtypeTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(BuildError, match='does not match planned endpoint'): + p.render(append=append) + + assert not p.pattern.refs + assert not lib + +def test_deferred_render_rename_to_none_keeps_pending_geometry_without_port() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + rp = Pather(lib, tools=tool, render='deferred') + rp.pattern.ports['A'] = Port((0, 0), rotation=0) + + rp.at('A').straight(5000) + rp.rename_ports({'A': None}) + + assert 'A' not in rp.pattern.ports + assert len(rp._paths['A']) == 1 + + rp.render() + assert rp.pattern.has_shapes() + assert 'A' not in rp.pattern.ports diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py new file mode 100644 index 0000000..ed967dd --- /dev/null +++ b/masque/test/test_pather_trace_into.py @@ -0,0 +1,351 @@ +from typing import Any + +import numpy +import pytest +from numpy import pi +from numpy.testing import assert_equal + +from masque import Library, PathTool, Port, Pather +from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner +from masque.builder.planner.planner import Candidate, RouteRequest +from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool +from masque.error import BuildError, PortError + + +@pytest.fixture +def trace_into_setup() -> tuple[Pather, PathTool, Library]: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype="wire") + p = Pather(lib, tools=tool, render='immediate', render_append=False) + return p, tool, lib + + +def test_path_into_straight(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + assert len(p.pattern.refs) == 1 + + +def test_path_into_bend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, -20), 3 * pi / 2, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + assert len(p.pattern.refs) == 1 + + +def test_path_into_sbend(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, -10), pi, ptype="wire") + + p.trace_into("src", "dst") + + assert "src" not in p.ports + assert "dst" not in p.ports + + +def test_path_into_thru(trace_into_setup: tuple[Pather, PathTool, Library]) -> None: + p, _tool, _lib = trace_into_setup + p.ports["src"] = Port((0, 0), 0, ptype="wire") + p.ports["dst"] = Port((-20, 0), pi, ptype="wire") + p.ports["other"] = Port((10, 10), 0) + + p.trace_into("src", "dst", thru="other") + + assert "src" in p.ports + assert_equal(p.ports["src"].offset, [10, 10]) + assert "other" not in p.ports + + +def test_pather_trace_into_shapes() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000) + p = Pather(lib, tools=tool) + + p.pattern.ports['A'] = Port((0, 0), rotation=0) + p.pattern.ports['B'] = Port((-10000, 0), rotation=pi) + p.at('A').trace_into('B', plug_destination=False) + assert 'B' in p.pattern.ports + assert 'A' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + + p.pattern.ports['C'] = Port((0, 0), rotation=0) + p.pattern.ports['D'] = Port((-5000, 5000), rotation=pi / 2) + p.at('C').trace_into('D', plug_destination=False) + assert 'D' in p.pattern.ports + assert 'C' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['C'].offset, (-5000, 5000)) + + p.pattern.ports['E'] = Port((0, 0), rotation=0) + p.pattern.ports['F'] = Port((-10000, 2000), rotation=pi) + p.at('E').trace_into('F', plug_destination=False) + assert 'F' in p.pattern.ports + assert 'E' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['E'].offset, (-10000, 2000)) + + p.pattern.ports['G'] = Port((0, 0), rotation=0) + p.pattern.ports['H'] = Port((-10000, 2000), rotation=0) + p.at('G').trace_into('H', plug_destination=False) + assert 'H' in p.pattern.ports + assert 'G' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['G'].offset, (-10000, 2000)) + assert p.pattern.ports['G'].rotation is not None + assert numpy.isclose(p.pattern.ports['G'].rotation, pi) + + p.pattern.ports['I'] = Port((0, 0), rotation=pi / 2) + p.pattern.ports['J'] = Port((0, -10000), rotation=3 * pi / 2) + p.at('I').trace_into('J', plug_destination=False) + assert 'J' in p.pattern.ports + assert 'I' in p.pattern.ports + assert numpy.allclose(p.pattern.ports['I'].offset, (0, -10000)) + assert p.pattern.ports['I'].rotation is not None + assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) + + +def test_pather_trace_into_refines_output_adapter_route_before_plug() -> None: + class TransitionTool(Tool): + def primitive_offers( + self, + kind, # noqa: ANN001 + *, + in_ptype=None, # noqa: ANN001 + out_ptype=None, # noqa: ANN001 + **kwargs, # noqa: ANN003 + ) -> tuple[PrimitiveOffer, ...]: + _ = in_ptype + if kind == 'straight': + def native_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='m1wire') + + native = StraightOffer( + in_ptype='m1wire', + out_ptype='m1wire', + endpoint_planner=native_endpoint, + commit_planner=lambda length: {'kind': 'straight', 'length': length}, + ) + if out_ptype in ('unk', 'm1wire'): + return (native,) + + def transition_endpoint(length: float) -> Port: + return Port((length, 0), rotation=pi, ptype='m2wire') + + transition = StraightOffer( + in_ptype='m1wire', + out_ptype='m2wire', + length_domain=(2500, numpy.inf), + endpoint_planner=transition_endpoint, + commit_planner=lambda length: {'kind': 'transition', 'length': length}, + ) + return native, transition + + if kind == 'bend': + ccw = bool(kwargs['ccw']) + + def endpoint(length: float) -> Port: + return Port( + (length, 500 if ccw else -500), + rotation=-pi / 2 if ccw else pi / 2, + ptype='m1wire', + ) + + return (BendOffer( + in_ptype='m1wire', + out_ptype='m1wire', + ccw=ccw, + length_domain=(500, numpy.inf), + endpoint_planner=endpoint, + commit_planner=lambda length: {'kind': 'bend', 'length': length}, + ),) + + return () + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + tree, pat = Library.mktree('transition_tool') + pat.add_port_pair(names=port_names, ptype=batch[-1].end_port.ptype if batch else 'm1wire') + return tree + + p = Pather(Library(), tools=TransitionTool(), render='deferred') + p.pattern.ports['src'] = Port((-65000, -11500), rotation=pi / 2, ptype='m1wire') + p.pattern.ports['dst'] = Port((-100000, -100000), rotation=pi, ptype='m2wire') + + p.trace_into('src', 'dst') + + assert 'src' not in p.pattern.ports + assert 'dst' not in p.pattern.ports + + +def test_pather_trace_into_dead_updates_ports_without_geometry() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1000, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-10000, 0), rotation=pi, ptype='wire') + p.set_dead() + + p.trace_into('A', 'B', plug_destination=False) + + assert set(p.pattern.ports) == {'A', 'B'} + assert numpy.allclose(p.pattern.ports['A'].offset, (-10000, 0)) + assert p.pattern.ports['A'].rotation is not None + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert len(p._paths['A']) == 0 + assert not p.pattern.has_shapes() + assert not p.pattern.has_refs() + + +def test_pather_trace_into_planning_failure_leaves_state_unchanged() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-5, 5), rotation=pi / 2, ptype='wire') + + with pytest.raises(BuildError): + p.trace_into('A', 'B', plug_destination=False, out_ptype='other') + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert numpy.allclose(p.pattern.ports['B'].offset, (-5, 5)) + assert numpy.isclose(p.pattern.ports['B'].rotation, pi / 2) + assert len(p._paths['A']) == 0 + + +def test_pather_trace_into_rename_failure_propagates() -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = Port((-10, 0), rotation=pi, ptype='wire') + p.pattern.ports['other'] = Port((3, 4), rotation=0, ptype='wire') + + with pytest.raises(PortError, match='overwritten'): + p.trace_into('A', 'B', plug_destination=False, thru='other') + + +@pytest.mark.parametrize( + ('dst', 'kwargs', 'match'), + [ + (Port((-5, 5), rotation=pi / 2, ptype='wire'), {'x': -99}, r'route arguments: x'), + (Port((-10, 2), rotation=pi, ptype='wire'), {'length': 1}, r'route arguments: length'), + (Port((-10, 2), rotation=0, ptype='wire'), {'length': 1}, r'route arguments: length'), + ], +) +def test_pather_trace_into_rejects_reserved_route_kwargs( + dst: Port, + kwargs: dict[str, Any], + match: str, + ) -> None: + lib = Library() + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(lib, tools=tool) + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.pattern.ports['B'] = dst + + with pytest.raises(BuildError, match=match): + p.trace_into('A', 'B', plug_destination=False, **kwargs) + + assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) + assert numpy.isclose(p.pattern.ports['A'].rotation, 0) + assert numpy.allclose(p.pattern.ports['B'].offset, dst.offset) + assert dst.rotation is not None + assert p.pattern.ports['B'].rotation is not None + assert numpy.isclose(p.pattern.ports['B'].rotation, dst.rotation) + assert len(p._paths['A']) == 0 + + +class TraceIntoBudgetSolver: + def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: + self.successes = successes + self.fatal_at = set() if fatal_at is None else fatal_at + self.attempts: list[tuple[int, int]] = [] + + def solve( + self, + *, + min_bends: int = 0, + max_bends: int | None = None, + ) -> Candidate: + assert max_bends is not None + band = (min_bends, max_bends) + self.attempts.append(band) + if band in self.fatal_at: + raise RoutePlanningError('fatal', fatal=True) + if band not in self.successes: + raise BuildError('try next budget') + return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0) + + +class TraceIntoBudgetPlanner(RoutingPlanner): + def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: + self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at) + self.solver_requests = 0 + + def solver_for_request(self, request: RouteRequest) -> Any: + _ = request + self.solver_requests += 1 + return self.solver + + def prepared_result_from_legs( + self, + legs: Any, + *, + renames: tuple[tuple[str, str], ...] = (), + ) -> PreparedRouteResult: + _ = legs, renames + return PreparedRouteResult(()) + + +@pytest.mark.parametrize( + ('dst', 'successes', 'attempts'), + [ + (Port((-10, 0), rotation=pi, ptype='wire'), {(0, 2)}, [(0, 2)]), + (Port((-10, 0), rotation=pi, ptype='wire'), {(4, 4)}, [(0, 2), (4, 4)]), + (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(1, 1)}, [(1, 1)]), + (Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), {(3, 3)}, [(1, 1), (3, 3)]), + ], +) +def test_trace_into_reuses_solver_across_staged_bend_bands( + dst: Port, + successes: set[tuple[int, int]], + attempts: list[tuple[int, int]], + ) -> None: + planner = TraceIntoBudgetPlanner(successes) + context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) + + planner.plan_trace_into(context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None) + + assert planner.solver.attempts == attempts + assert planner.solver_requests == 1 + + +def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None: + planner = TraceIntoBudgetPlanner({(4, 4)}, fatal_at={(0, 2)}) + context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) + + with pytest.raises(RoutePlanningError, match='fatal'): + planner.plan_trace_into(context, 'dst', Port((-10, 0), rotation=pi, ptype='wire'), out_ptype=None, plug_destination=True, thru=None) + + assert planner.solver.attempts == [(0, 2)] + assert planner.solver_requests == 1 + + +def test_trace_into_bend_bands_respect_max_bends() -> None: + class OneBendPlanner(RoutingPlanner): + TRACE_INTO_MAX_BENDS = 1 + + planner = OneBendPlanner() + + assert planner.trace_into_bend_bands('straight') == ((0, 0),) + assert planner.trace_into_bend_bands('s') == ((0, 0),) + assert planner.trace_into_bend_bands('bend') == ((1, 1),) diff --git a/masque/test/test_pattern.py b/masque/test/test_pattern.py new file mode 100644 index 0000000..26b1255 --- /dev/null +++ b/masque/test/test_pattern.py @@ -0,0 +1,310 @@ +import pytest +import copy +from typing import cast +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..error import PatternError +from ..abstract import Abstract +from ..pattern import Pattern +from ..shapes import Polygon +from ..ref import Ref +from ..ports import Port, PortError +from ..label import Label +from ..repetition import Grid + + +def test_pattern_init() -> None: + pat = Pattern() + assert pat.is_empty() + assert not pat.has_shapes() + assert not pat.has_refs() + assert not pat.has_labels() + assert not pat.has_ports() + + +def test_pattern_with_elements() -> None: + poly = Polygon.square(10) + label = Label("test", offset=(5, 5)) + ref = Ref(offset=(100, 100)) + port = Port((0, 0), 0) + + pat = Pattern(shapes={(1, 0): [poly]}, labels={(1, 2): [label]}, refs={"sub": [ref]}, ports={"P1": port}) + + assert pat.has_shapes() + assert pat.has_labels() + assert pat.has_refs() + assert pat.has_ports() + assert not pat.is_empty() + assert pat.shapes[(1, 0)] == [poly] + assert pat.labels[(1, 2)] == [label] + assert pat.refs["sub"] == [ref] + assert pat.ports["P1"] == port + + +def test_pattern_append() -> None: + pat1 = Pattern() + pat1.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) + + pat2 = Pattern() + pat2.polygon((2, 0), vertices=[[10, 10], [11, 10], [11, 11]]) + + pat1.append(pat2) + assert len(pat1.shapes[(1, 0)]) == 1 + assert len(pat1.shapes[(2, 0)]) == 1 + + +def test_pattern_translate() -> None: + pat = Pattern() + pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [1, 1]]) + pat.ports["P1"] = Port((5, 5), 0) + + pat.translate_elements((10, 20)) + + # Polygon.translate adds to vertices, and offset is always (0,0) + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, 20]) + assert_equal(pat.ports["P1"].offset, [15, 25]) + + +def test_pattern_scale() -> None: + pat = Pattern() + # Polygon.rect sets an offset in its constructor which is immediately translated into vertices + pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=1) + pat.scale_by(2) + + # Vertices should be scaled + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices, [[0, 0], [0, 2], [2, 2], [2, 0]]) + + +def test_pattern_rotate() -> None: + pat = Pattern() + pat.polygon((1, 0), vertices=[[10, 0], [11, 0], [10, 1]]) + # Rotate 90 degrees CCW around (0,0) + pat.rotate_around((0, 0), pi / 2) + + # [10, 0] rotated 90 deg around (0,0) is [0, 10] + assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [0, 10], atol=1e-10) + + +def test_pattern_mirror() -> None: + pat = Pattern() + pat.polygon((1, 0), vertices=[[10, 5], [11, 5], [10, 6]]) + # Mirror across X axis (y -> -y) + pat.mirror(0) + + assert_equal(cast("Polygon", pat.shapes[(1, 0)][0]).vertices[0], [10, -5]) + + +def test_pattern_get_bounds() -> None: + pat = Pattern() + pat.polygon((1, 0), vertices=[[0, 0], [10, 0], [10, 10]]) + pat.polygon((1, 0), vertices=[[-5, -5], [5, -5], [5, 5]]) + + bounds = pat.get_bounds() + assert_equal(bounds, [[-5, -5], [10, 10]]) + + +def test_pattern_flatten_preserves_ports_only_child() -> None: + child = Pattern(ports={"P1": Port((1, 2), 0)}) + + parent = Pattern() + parent.ref("child", offset=(10, 10)) + + parent.flatten({"child": child}, flatten_ports=True) + + assert set(parent.ports) == {"P1"} + assert parent.ports["P1"].rotation == 0 + assert tuple(parent.ports["P1"].offset) == (11.0, 12.0) + + +def test_pattern_flatten_repeated_ref_with_ports_raises() -> None: + child = Pattern(ports={"P1": Port((1, 2), 0)}) + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + parent = Pattern() + parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2)) + + with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'): + parent.flatten({"child": child}, flatten_ports=True) + + +def test_pattern_place_requires_abstract_for_reference() -> None: + parent = Pattern() + child = Pattern() + + with pytest.raises(PatternError, match='Must provide an `Abstract`'): + parent.place(child) + + assert not parent.ports + + +def test_pattern_place_append_requires_pattern_atomically() -> None: + parent = Pattern() + child = Abstract("child", {"A": Port((1, 2), 0)}) + + with pytest.raises(PatternError, match='Must provide a full `Pattern`'): + parent.place(child, append=True) + + assert not parent.ports + + +def test_pattern_place_append_annotation_conflict_is_atomic() -> None: + parent = Pattern(annotations={"k": [1]}) + child = Pattern(annotations={"k": [2]}, ports={"A": Port((1, 2), 0)}) + + with pytest.raises(PatternError, match="Annotation keys overlap"): + parent.place(child, append=True) + + assert not parent.ports + assert parent.annotations == {"k": [1]} + + +def test_pattern_place_skip_geometry_overwrites_colliding_ports_last_wins() -> None: + parent = Pattern(ports={ + "A": Port((5, 5), 0), + "keep": Port((9, 9), 0), + }) + child = Pattern(ports={ + "X": Port((1, 0), 0), + "Y": Port((2, 0), pi / 2), + }) + + parent.place(child, port_map={"X": "A", "Y": "A"}, skip_geometry=True, append=True) + + assert set(parent.ports) == {"A", "keep"} + assert_allclose(parent.ports["A"].offset, (2, 0)) + assert parent.ports["A"].rotation is not None + assert_allclose(parent.ports["A"].rotation, pi / 2) + + +def test_pattern_interface() -> None: + source = Pattern() + source.ports["A"] = Port((10, 20), 0, ptype="test") + + iface = Pattern.interface(source, in_prefix="in_", out_prefix="out_") + + assert "in_A" in iface.ports + assert "out_A" in iface.ports + assert iface.ports["in_A"].rotation is not None + assert_allclose(iface.ports["in_A"].rotation, pi, atol=1e-10) + assert iface.ports["out_A"].rotation is not None + assert_allclose(iface.ports["out_A"].rotation, 0, atol=1e-10) + assert iface.ports["in_A"].ptype == "test" + assert iface.ports["out_A"].ptype == "test" + + +def test_pattern_interface_duplicate_port_map_targets_raise() -> None: + source = Pattern() + source.ports["A"] = Port((10, 20), 0) + source.ports["B"] = Port((30, 40), pi) + + with pytest.raises(PortError, match='Duplicate targets in `port_map`'): + Pattern.interface(source, port_map={"A": "X", "B": "X"}) + + +def test_pattern_interface_empty_port_map_copies_no_ports() -> None: + source = Pattern() + source.ports["A"] = Port((10, 20), 0) + source.ports["B"] = Port((30, 40), pi) + + assert not Pattern.interface(source, port_map={}).ports + assert not Pattern.interface(source, port_map=[]).ports + + +def test_pattern_plug_requires_abstract_for_reference_atomically() -> None: + parent = Pattern(ports={"X": Port((0, 0), 0)}) + child = Pattern(ports={"A": Port((0, 0), pi)}) + + with pytest.raises(PatternError, match='Must provide an `Abstract`'): + parent.plug(child, {"X": "A"}) + + assert set(parent.ports) == {"X"} + + +def test_pattern_plug_append_annotation_conflict_is_atomic() -> None: + parent = Pattern( + annotations={"k": [1]}, + ports={"X": Port((0, 0), 0), "Q": Port((9, 9), 0)}, + ) + child = Pattern( + annotations={"k": [2]}, + ports={"A": Port((0, 0), pi), "B": Port((5, 0), 0)}, + ) + + with pytest.raises(PatternError, match="Annotation keys overlap"): + parent.plug(child, {"X": "A"}, map_out={"B": "Y"}, append=True) + + assert set(parent.ports) == {"X", "Q"} + assert_allclose(parent.ports["X"].offset, (0, 0)) + assert_allclose(parent.ports["Q"].offset, (9, 9)) + assert parent.annotations == {"k": [1]} + + +def test_pattern_plug_skip_geometry_overwrites_colliding_ports_last_wins() -> None: + parent = Pattern(ports={ + "A": Port((0, 0), 0, ptype="wire"), + "B": Port((99, 99), 0, ptype="wire"), + }) + child = Pattern(ports={ + "in": Port((0, 0), pi, ptype="wire"), + "X": Port((10, 0), 0, ptype="wire"), + "Y": Port((20, 0), 0, ptype="wire"), + }) + + parent.plug(child, {"A": "in"}, map_out={"X": "B", "Y": "B"}, skip_geometry=True, append=True) + + assert "A" not in parent.ports + assert "B" in parent.ports + assert_allclose(parent.ports["B"].offset, (20, 0)) + assert parent.ports["B"].rotation is not None + assert_allclose(parent.ports["B"].rotation, 0) + + +def test_pattern_append_port_conflict_is_atomic() -> None: + pat1 = Pattern() + pat1.ports["A"] = Port((0, 0), 0) + + pat2 = Pattern() + pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + pat2.ports["A"] = Port((1, 0), 0) + + with pytest.raises(PatternError, match="Port names overlap"): + pat1.append(pat2) + + assert not pat1.shapes + assert set(pat1.ports) == {"A"} + + +def test_pattern_append_annotation_conflict_is_atomic() -> None: + pat1 = Pattern(annotations={"k": [1]}) + pat2 = Pattern(annotations={"k": [2]}) + pat2.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + with pytest.raises(PatternError, match="Annotation keys overlap"): + pat1.append(pat2) + + assert not pat1.shapes + assert pat1.annotations == {"k": [1]} + + +def test_pattern_deepcopy_does_not_share_shape_repetitions() -> None: + pat = Pattern() + pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2)) + + pat2 = copy.deepcopy(pat) + pat2.scale_by(2) + + assert_allclose(cast("Polygon", pat.shapes[(1, 0)][0]).repetition.a_vector, [10, 0]) + assert_allclose(cast("Polygon", pat2.shapes[(1, 0)][0]).repetition.a_vector, [20, 0]) + + +def test_pattern_flatten_does_not_mutate_child_repetitions() -> None: + child = Pattern() + child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]], repetition=Grid(a_vector=(10, 0), a_count=2)) + + parent = Pattern() + parent.ref("child", scale=2) + + parent.flatten({"child": child}) + + assert_allclose(cast("Polygon", child.shapes[(1, 0)][0]).repetition.a_vector, [10, 0]) diff --git a/masque/test/test_poly_collection.py b/masque/test/test_poly_collection.py new file mode 100644 index 0000000..70b17d2 --- /dev/null +++ b/masque/test/test_poly_collection.py @@ -0,0 +1,89 @@ +import pytest +from numpy.testing import assert_equal + +from ..error import PatternError +from ..shapes import Circle, Ellipse, Polygon, PolyCollection + + +def test_poly_collection_init() -> None: + verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 4] + pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) + assert len(list(pc.polygon_vertices)) == 2 + assert_equal(pc.get_bounds_single(), [[0, 0], [11, 11]]) + +def test_poly_collection_to_polygons() -> None: + verts = [[0, 0], [1, 0], [1, 1], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 4] + pc = PolyCollection(vertex_lists=verts, vertex_offsets=offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + assert_equal(polys[1].vertices, [[10, 10], [11, 10], [11, 11], [10, 11]]) + +def test_poly_collection_holes() -> None: + # PolyCollection represents separate polygon boundaries, including nested boundaries. + verts = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], # Poly 1 + [2, 2], + [2, 8], + [8, 8], + [8, 2], # Poly 2 + ] + offsets = [0, 4] + pc = PolyCollection(verts, offsets) + polys = pc.to_polygons() + assert len(polys) == 2 + assert_equal(polys[0].vertices, [[0, 0], [10, 0], [10, 10], [0, 10]]) + assert_equal(polys[1].vertices, [[2, 2], [2, 8], [8, 8], [8, 2]]) + +def test_poly_collection_constituent_empty() -> None: + # Duplicate offsets create an empty constituent slice between valid polygons. + verts = [ + [0, 0], + [1, 0], + [0, 1], # Tri + [10, 10], + [11, 10], + [11, 11], + [10, 11], # Square + ] + offsets = [0, 3, 3] + pc = PolyCollection(verts, offsets) + with pytest.raises(PatternError): + pc.to_polygons() + +def test_poly_collection_valid() -> None: + verts = [[0, 0], [1, 0], [0, 1], [10, 10], [11, 10], [11, 11], [10, 11]] + offsets = [0, 3] + pc = PolyCollection(verts, offsets) + assert len(pc.to_polygons()) == 2 + shapes = [Circle(radius=20), Circle(radius=10), Polygon([[0, 0], [10, 0], [10, 10]]), Ellipse(radii=(5, 5))] + sorted_shapes = sorted(shapes) + assert len(sorted_shapes) == 4 + assert sorted(sorted_shapes) == sorted_shapes + +def test_poly_collection_normalized_form_reconstruction_is_independent() -> None: + pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) + _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) + + clone = rebuild() + clone.vertex_offsets[:] = [5] + + assert_equal(pc.vertex_offsets, [0]) + assert_equal(clone.vertex_offsets, [5]) + +def test_poly_collection_normalized_form_rebuilds_independent_clones() -> None: + pc = PolyCollection([[0, 0], [1, 0], [0, 1]], [0]) + _intrinsic, _extrinsic, rebuild = pc.normalized_form(1) + + first = rebuild() + second = rebuild() + first.vertex_offsets[:] = [7] + + assert_equal(first.vertex_offsets, [7]) + assert_equal(second.vertex_offsets, [0]) + assert_equal(pc.vertex_offsets, [0]) diff --git a/masque/test/test_polygon.py b/masque/test/test_polygon.py new file mode 100644 index 0000000..5d98ad9 --- /dev/null +++ b/masque/test/test_polygon.py @@ -0,0 +1,125 @@ +import pytest +import numpy +from numpy.testing import assert_equal + + +from ..shapes import Polygon +from ..utils import R90 +from ..error import PatternError + + +@pytest.fixture +def polygon() -> Polygon: + return Polygon([[0, 0], [1, 0], [1, 1], [0, 1]]) + + +def test_vertices(polygon: Polygon) -> None: + assert_equal(polygon.vertices, [[0, 0], [1, 0], [1, 1], [0, 1]]) + + +def test_xs(polygon: Polygon) -> None: + assert_equal(polygon.xs, [0, 1, 1, 0]) + + +def test_ys(polygon: Polygon) -> None: + assert_equal(polygon.ys, [0, 0, 1, 1]) + + +def test_offset(polygon: Polygon) -> None: + assert_equal(polygon.offset, [0, 0]) + + +def test_square() -> None: + square = Polygon.square(1) + assert_equal(square.vertices, [[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]]) + + +def test_rectangle() -> None: + rectangle = Polygon.rectangle(1, 2) + assert_equal(rectangle.vertices, [[-0.5, -1], [-0.5, 1], [0.5, 1], [0.5, -1]]) + + +def test_rect() -> None: + rect1 = Polygon.rect(xmin=0, xmax=1, ymin=-1, ymax=1) + assert_equal(rect1.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) + + rect2 = Polygon.rect(xmin=0, lx=1, ymin=-1, ly=2) + assert_equal(rect2.vertices, [[0, -1], [0, 1], [1, 1], [1, -1]]) + + rect3 = Polygon.rect(xctr=0, lx=1, yctr=-2, ly=2) + assert_equal(rect3.vertices, [[-0.5, -3], [-0.5, -1], [0.5, -1], [0.5, -3]]) + + rect4 = Polygon.rect(xctr=0, xmax=1, yctr=-2, ymax=0) + assert_equal(rect4.vertices, [[-1, -4], [-1, 0], [1, 0], [1, -4]]) + + with pytest.raises(PatternError): + Polygon.rect(xctr=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(xmin=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(xmax=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(lx=0, yctr=-2, ymax=0) + with pytest.raises(PatternError): + Polygon.rect(yctr=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ymin=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ymax=0, xctr=-2, xmax=0) + with pytest.raises(PatternError): + Polygon.rect(ly=0, xctr=-2, xmax=0) + + +def test_octagon() -> None: + octagon = Polygon.octagon(side_length=1) # regular=True + assert_equal(octagon.vertices.shape, (8, 2)) + diff = octagon.vertices - numpy.roll(octagon.vertices, -1, axis=0) + side_len = numpy.sqrt((diff * diff).sum(axis=1)) + assert numpy.allclose(side_len, 1) + + +def test_to_polygons(polygon: Polygon) -> None: + assert polygon.to_polygons() == [polygon] + + +def test_get_bounds_single(polygon: Polygon) -> None: + assert_equal(polygon.get_bounds_single(), [[0, 0], [1, 1]]) + + +def test_rotate(polygon: Polygon) -> None: + rotated_polygon = polygon.rotate(R90) + assert_equal(rotated_polygon.vertices, [[0, 0], [0, 1], [-1, 1], [-1, 0]]) + + +def test_mirror(polygon: Polygon) -> None: + mirrored_by_y = polygon.deepcopy().mirror(1) + assert_equal(mirrored_by_y.vertices, [[0, 0], [-1, 0], [-1, 1], [0, 1]]) + print(polygon.vertices) + mirrored_by_x = polygon.deepcopy().mirror(0) + assert_equal(mirrored_by_x.vertices, [[0, 0], [1, 0], [1, -1], [0, -1]]) + + +def test_scale_by(polygon: Polygon) -> None: + scaled_polygon = polygon.scale_by(2) + assert_equal(scaled_polygon.vertices, [[0, 0], [2, 0], [2, 2], [0, 2]]) + + +def test_clean_vertices(polygon: Polygon) -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, -4], [2, 0], [0, 0]]).clean_vertices() + assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) + + +def test_remove_duplicate_vertices() -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_duplicate_vertices() + assert_equal(polygon.vertices, [[0, 0], [1, 1], [2, 2], [2, 0]]) + + +def test_remove_colinear_vertices() -> None: + polygon = Polygon([[0, 0], [1, 1], [2, 2], [2, 2], [2, 0], [0, 0]]).remove_colinear_vertices() + assert_equal(polygon.vertices, [[0, 0], [2, 2], [2, 0]]) + + +def test_vertices_dtype() -> None: + polygon = Polygon(numpy.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=numpy.int32)) + polygon.scale_by(0.5) + assert_equal(polygon.vertices, [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5], [0, 0]]) diff --git a/masque/test/test_ports.py b/masque/test/test_ports.py new file mode 100644 index 0000000..f17921f --- /dev/null +++ b/masque/test/test_ports.py @@ -0,0 +1,354 @@ +import pytest +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..ports import Port, PortList +from ..error import PortError +from ..pattern import Pattern + + +def test_port_init() -> None: + p = Port(offset=(10, 20), rotation=pi / 2, ptype="test") + assert_equal(p.offset, [10, 20]) + assert p.rotation == pi / 2 + assert p.ptype == "test" + + +def test_port_transform() -> None: + p = Port(offset=(10, 0), rotation=0) + p.rotate_around((0, 0), pi / 2) + assert_allclose(p.offset, [0, 10], atol=1e-10) + assert p.rotation is not None + assert_allclose(p.rotation, pi / 2, atol=1e-10) + + p.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset + assert_allclose(p.offset, [0, 10], atol=1e-10) + # rotation was pi/2 (90 deg), mirror across x (0 deg) -> -pi/2 == 3pi/2 + assert p.rotation is not None + assert_allclose(p.rotation, 3 * pi / 2, atol=1e-10) + + +def test_port_flip_across() -> None: + p = Port(offset=(10, 0), rotation=0) + p.flip_across(axis=1) # Mirror across x=0: flips x-offset + assert_equal(p.offset, [-10, 0]) + # rotation was 0, mirrored(1) -> pi + assert p.rotation is not None + assert_allclose(p.rotation, pi, atol=1e-10) + + +def test_port_measure_travel() -> None: + p1 = Port((0, 0), 0) + p2 = Port((10, 5), pi) # Facing each other + + (travel, jog), rotation = p1.measure_travel(p2) + assert travel == 10 + assert jog == 5 + assert rotation == pi + + +def test_port_list_measure_travel() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = { + "A": Port((0, 0), 0), + "B": Port((10, 5), pi), + } + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + (travel, jog), rotation = pl.measure_travel("A", "B") + assert travel == 10 + assert jog == 5 + assert rotation == pi + + +def test_port_describe_any_rotation() -> None: + p = Port((0, 0), None) + assert p.describe() == "pos=(0, 0), rot=any" + + +def test_port_list_rename() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((0, 0), 0)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + pl.rename_ports({"A": "B"}) + assert "A" not in pl.ports + assert "B" in pl.ports + + +def test_port_list_rename_missing_port_raises() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((0, 0), 0)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Ports to rename were not found"): + pl.rename_ports({"missing": "B"}) + assert set(pl.ports) == {"A"} + + +def test_port_list_rename_colliding_targets_raises() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((0, 0), 0), "B": Port((1, 0), 0)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Renamed ports would collide"): + pl.rename_ports({"A": "C", "B": "C"}) + assert set(pl.ports) == {"A", "B"} + + +def test_port_list_add_port_pair_requires_distinct_names() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports: dict[str, Port] = {} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Port names must be distinct"): + pl.add_port_pair(names=("A", "A")) + assert not pl.ports + + +def test_port_list_plugged() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + pl.plugged({"A": "B"}) + assert not pl.ports # Both should be removed + + +def test_port_list_plugged_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level("WARNING", logger="masque.ports") + + compatible_cases = [ + ("wire", "wire"), + ("unk", "wire"), + (None, "wire"), + ] + for left, right in compatible_cases: + caplog.clear() + pl = Pattern(ports={ + "A": Port((10, 10), 0, ptype=left), # type: ignore[arg-type] + "B": Port((10, 10), pi, ptype=right), # type: ignore[arg-type] + }) + + pl.plugged({"A": "B"}) + + assert not any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + pl = Pattern(ports={ + "A": Port((10, 10), 0, ptype="wire"), + "B": Port((10, 10), pi, ptype="metal"), + }) + + pl.plugged({"A": "B"}) + + assert any("conflicting types" in record.message for record in caplog.records) + + +def test_port_list_plugged_empty_raises() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Must provide at least one port connection"): + pl.plugged({}) + assert set(pl.ports) == {"A", "B"} + + +def test_port_list_plugged_missing_port_raises() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((10, 10), 0), "B": Port((10, 10), pi)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Connection source ports were not found"): + pl.plugged({"missing": "B"}) + assert set(pl.ports) == {"A", "B"} + + +def test_port_list_plugged_reused_port_raises_atomically() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((0, 0), None), "B": Port((0, 0), None), "C": Port((0, 0), None)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + for connections in ({"A": "A"}, {"A": "B", "C": "B"}): + pl = MyPorts() + with pytest.raises(PortError, match="Each port may appear in at most one connection"): + pl.plugged(connections) + assert set(pl.ports) == {"A", "B", "C"} + + pl = MyPorts() + with pytest.raises(PortError, match="Connection destination ports were not found"): + pl.plugged({"A": "missing"}) + assert set(pl.ports) == {"A", "B", "C"} + + +def test_port_list_plugged_mismatch() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = { + "A": Port((10, 10), 0), + "B": Port((11, 10), pi), # Offset mismatch + } + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError): + pl.plugged({"A": "B"}) + + +def test_port_list_check_ports_duplicate_map_in_values_raise() -> None: + class MyPorts(PortList): + def __init__(self) -> None: + self._ports = {"A": Port((0, 0), 0), "B": Port((0, 0), 0)} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @ports.setter + def ports(self, val: dict[str, Port]) -> None: + self._ports = val + + pl = MyPorts() + with pytest.raises(PortError, match="Duplicate values in `map_in`"): + pl.check_ports({"X", "Y"}, map_in={"A": "X", "B": "X"}) + assert set(pl.ports) == {"A", "B"} + + +def test_pattern_plug_rejects_map_out_on_connected_ports_atomically() -> None: + host = Pattern(ports={"A": Port((0, 0), 0)}) + other = Pattern(ports={"X": Port((0, 0), pi), "Y": Port((5, 0), 0)}) + + with pytest.raises(PortError, match="`map_out` keys conflict with connected ports"): + host.plug(other, {"A": "X"}, map_out={"X": "renamed", "Y": "out"}, append=True) + + assert set(host.ports) == {"A"} + + +def test_find_transform_requires_connection_map() -> None: + host = Pattern(ports={"A": Port((0, 0), 0)}) + other = Pattern(ports={"X": Port((0, 0), pi)}) + + with pytest.raises(PortError, match="at least one port connection"): + host.find_transform(other, {}) + + with pytest.raises(PortError, match="at least one port connection"): + Pattern.find_port_transform({}, {}, {}) + + +def test_find_transform_ptype_compatibility_warnings(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level("WARNING", logger="masque.ports") + + compatible_cases = [ + ("wire", "wire"), + ("wire", "unk"), + ("wire", None), + ] + for left, right in compatible_cases: + caplog.clear() + host = Pattern(ports={"A": Port((0, 0), 0, ptype=left)}) # type: ignore[arg-type] + other = Pattern(ports={"X": Port((0, 0), pi, ptype=right)}) # type: ignore[arg-type] + + host.find_transform(other, {"A": "X"}) + + assert not any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + host = Pattern(ports={"A": Port((0, 0), 0, ptype="wire")}) + other = Pattern(ports={"X": Port((0, 0), pi, ptype="metal")}) + + host.find_transform(other, {"A": "X"}) + + assert any("conflicting types" in record.message for record in caplog.records) + + caplog.clear() + host.find_transform(other, {"A": "X"}, ok_connections={("wire", "metal")}) + + assert not any("conflicting types" in record.message for record in caplog.records) diff --git a/masque/test/test_ports2data.py b/masque/test/test_ports2data.py new file mode 100644 index 0000000..3f642ab --- /dev/null +++ b/masque/test/test_ports2data.py @@ -0,0 +1,132 @@ +import numpy +import pytest +from numpy.testing import assert_allclose + +from ..utils.ports2data import ports_to_data, data_to_ports +from ..pattern import Pattern +from ..ports import Port +from ..library import Library +from ..error import PortError +from ..repetition import Grid + + +def test_ports2data_roundtrip() -> None: + pat = Pattern() + pat.ports["P1"] = Port((10, 20), numpy.pi / 2, ptype="test") + + layer = (10, 0) + ports_to_data(pat, layer) + + assert len(pat.labels[layer]) == 1 + assert pat.labels[layer][0].string == "P1:test 90" + assert tuple(pat.labels[layer][0].offset) == (10.0, 20.0) + + # New pattern, read ports back + pat2 = Pattern() + pat2.labels[layer] = pat.labels[layer] + data_to_ports([layer], {}, pat2) + + assert "P1" in pat2.ports + assert_allclose(pat2.ports["P1"].offset, [10, 20], atol=1e-10) + assert pat2.ports["P1"].rotation is not None + assert_allclose(pat2.ports["P1"].rotation, numpy.pi / 2, atol=1e-10) + assert pat2.ports["P1"].ptype == "test" + + +def test_data_to_ports_hierarchical() -> None: + lib = Library() + + # Child has port data in labels + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + # Parent references child + parent = Pattern() + parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2) + + # Read ports hierarchically (max_depth > 0) + data_to_ports([layer], lib, parent, max_depth=1) + + # child port A (5,0) rot 0 + # transformed by parent ref: rot pi/2, trans (100, 100) + # (5,0) rot pi/2 -> (0, 5) + # (0, 5) + (100, 100) = (100, 105) + # rot 0 + pi/2 = pi/2 + assert "A" in parent.ports + assert_allclose(parent.ports["A"].offset, [100, 105], atol=1e-10) + assert parent.ports["A"].rotation is not None + assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) + + +def test_data_to_ports_hierarchical_scaled_ref() -> None: + lib = Library() + + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(100, 100), rotation=numpy.pi / 2, scale=2) + + data_to_ports([layer], lib, parent, max_depth=1) + + assert "A" in parent.ports + assert_allclose(parent.ports["A"].offset, [100, 110], atol=1e-10) + assert parent.ports["A"].rotation is not None + assert_allclose(parent.ports["A"].rotation, numpy.pi / 2, atol=1e-10) + + +def test_data_to_ports_hierarchical_repeated_ref_warns_and_keeps_best_effort( + caplog: pytest.LogCaptureFixture, + ) -> None: + lib = Library() + + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + parent = Pattern() + parent.ref("child", repetition=Grid(a_vector=(100, 0), a_count=3)) + + caplog.set_level("WARNING") + data_to_ports([layer], lib, parent, max_depth=1) + + assert "A" in parent.ports + assert_allclose(parent.ports["A"].offset, [5, 0], atol=1e-10) + assert any("importing only the base instance ports" in record.message for record in caplog.records) + + +def test_data_to_ports_hierarchical_collision_is_atomic() -> None: + lib = Library() + + child = Pattern() + layer = (10, 0) + child.label(layer=layer, string="A:type1 0", offset=(5, 0)) + lib["child"] = child + + parent = Pattern() + parent.ref("child", offset=(0, 0)) + parent.ref("child", offset=(10, 0)) + + with pytest.raises(PortError, match="Device ports conflict with existing ports"): + data_to_ports([layer], lib, parent, max_depth=1) + + assert not parent.ports + + +def test_data_to_ports_flat_bad_angle_warns_and_skips( + caplog: pytest.LogCaptureFixture, + ) -> None: + layer = (10, 0) + pat = Pattern() + pat.label(layer=layer, string="A:type1 nope", offset=(5, 0)) + + caplog.set_level("WARNING") + data_to_ports([layer], {}, pat) + + assert not pat.ports + assert any('bad angle' in record.message for record in caplog.records) diff --git a/masque/test/test_raw_constructors.py b/masque/test/test_raw_constructors.py new file mode 100644 index 0000000..2f86ba0 --- /dev/null +++ b/masque/test/test_raw_constructors.py @@ -0,0 +1,97 @@ +import numpy +from numpy import pi +from numpy.testing import assert_allclose + +from ..shapes import Arc, Circle, Ellipse, Path, Text + + +def test_circle_raw_constructor_matches_public() -> None: + raw = Circle._from_raw( + radius=5.0, + offset=numpy.array([1.0, 2.0]), + annotations={'1': ['circle']}, + ) + public = Circle( + radius=5.0, + offset=(1.0, 2.0), + annotations={'1': ['circle']}, + ) + assert raw == public + + +def test_ellipse_raw_constructor_matches_public() -> None: + raw = Ellipse._from_raw( + radii=numpy.array([3.0, 5.0]), + offset=numpy.array([1.0, 2.0]), + rotation=5 * pi / 2, + annotations={'2': ['ellipse']}, + ) + public = Ellipse( + radii=(3.0, 5.0), + offset=(1.0, 2.0), + rotation=5 * pi / 2, + annotations={'2': ['ellipse']}, + ) + assert raw == public + + +def test_arc_raw_constructor_matches_public() -> None: + raw = Arc._from_raw( + radii=numpy.array([10.0, 6.0]), + angles=numpy.array([0.0, pi / 2]), + width=2.0, + offset=numpy.array([1.0, 2.0]), + rotation=5 * pi / 2, + annotations={'3': ['arc']}, + ) + public = Arc( + radii=(10.0, 6.0), + angles=(0.0, pi / 2), + width=2.0, + offset=(1.0, 2.0), + rotation=5 * pi / 2, + annotations={'3': ['arc']}, + ) + assert raw == public + + +def test_path_raw_constructor_matches_public() -> None: + raw = Path._from_raw( + vertices=numpy.array([[0.0, 0.0], [10.0, 0.0], [10.0, 5.0]]), + width=2.0, + cap=Path.Cap.SquareCustom, + cap_extensions=numpy.array([1.0, 3.0]), + annotations={'4': ['path']}, + ) + public = Path( + vertices=((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), + width=2.0, + cap=Path.Cap.SquareCustom, + cap_extensions=(1.0, 3.0), + annotations={'4': ['path']}, + ) + assert raw == public + assert raw.cap_extensions is not None + assert_allclose(raw.cap_extensions, [1.0, 3.0]) + + +def test_text_raw_constructor_matches_public() -> None: + raw = Text._from_raw( + string='RAW', + height=12.0, + font_path='font.otf', + offset=numpy.array([1.0, 2.0]), + rotation=5 * pi / 2, + mirrored=True, + annotations={'5': ['text']}, + ) + public = Text( + string='RAW', + height=12.0, + font_path='font.otf', + offset=(1.0, 2.0), + rotation=5 * pi / 2, + mirrored=True, + annotations={'5': ['text']}, + ) + assert raw == public diff --git a/masque/test/test_rect_collection.py b/masque/test/test_rect_collection.py new file mode 100644 index 0000000..449f4fa --- /dev/null +++ b/masque/test/test_rect_collection.py @@ -0,0 +1,70 @@ +import copy + +import numpy +import pytest +from numpy.testing import assert_allclose, assert_equal + +from ..error import PatternError +from ..shapes import Polygon, RectCollection + + +def test_rect_collection_init_and_to_polygons() -> None: + rects = RectCollection([[10, 10, 12, 12], [0, 0, 5, 5]]) + assert_equal(rects.rects, [[0, 0, 5, 5], [10, 10, 12, 12]]) + + polys = rects.to_polygons() + assert len(polys) == 2 + assert all(isinstance(poly, Polygon) for poly in polys) + assert_equal(polys[0].vertices, [[0, 0], [0, 5], [5, 5], [5, 0]]) + + +def test_rect_collection_rejects_invalid_rects() -> None: + with pytest.raises(PatternError): + RectCollection([[0, 0, 1]]) + with pytest.raises(PatternError): + RectCollection([[5, 0, 1, 2]]) + with pytest.raises(PatternError): + RectCollection([[0, 5, 1, 2]]) + + +def test_rect_collection_raw_constructor_matches_public() -> None: + raw = RectCollection._from_raw( + rects=numpy.array([[10.0, 10.0, 12.0, 12.0], [0.0, 0.0, 5.0, 5.0]]), + annotations={'1': ['rects']}, + ) + public = RectCollection( + [[0, 0, 5, 5], [10, 10, 12, 12]], + annotations={'1': ['rects']}, + ) + assert raw == public + assert_equal(raw.get_bounds_single(), [[0, 0], [12, 12]]) + + +def test_rect_collection_manhattan_transforms() -> None: + rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) + + mirrored = copy.deepcopy(rects).mirror(1) + assert_equal(mirrored.rects, [[-2, 0, 0, 4], [-12, 20, -10, 22]]) + + scaled = copy.deepcopy(rects).scale_by(-2) + assert_equal(scaled.rects, [[-4, -8, 0, 0], [-24, -44, -20, -40]]) + + rotated = copy.deepcopy(rects).rotate(numpy.pi / 2) + assert_equal(rotated.rects, [[-4, 0, 0, 2], [-22, 10, -20, 12]]) + + +def test_rect_collection_non_manhattan_rotation_raises() -> None: + rects = RectCollection([[0, 0, 2, 4]]) + with pytest.raises(PatternError, match='Manhattan rotations'): + rects.rotate(numpy.pi / 4) + + +def test_rect_collection_normalized_form_rebuild_is_independent() -> None: + rects = RectCollection([[0, 0, 2, 4], [10, 20, 12, 22]]) + _intrinsic, extrinsic, rebuild = rects.normalized_form(2) + + clone = rebuild() + clone.rects[:] = [[1, 1, 2, 2], [3, 3, 4, 4]] + + assert_allclose(extrinsic[0], [6, 11.5]) + assert_equal(rects.rects, [[0, 0, 2, 4], [10, 20, 12, 22]]) diff --git a/masque/test/test_ref.py b/masque/test/test_ref.py new file mode 100644 index 0000000..de330fa --- /dev/null +++ b/masque/test/test_ref.py @@ -0,0 +1,111 @@ +from typing import cast, TYPE_CHECKING +import pytest +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..error import MasqueError +from ..pattern import Pattern +from ..ref import Ref +from ..repetition import Grid + +if TYPE_CHECKING: + from ..shapes import Polygon + + +def test_ref_init() -> None: + ref = Ref(offset=(10, 20), rotation=pi / 4, mirrored=True, scale=2.0) + assert_equal(ref.offset, [10, 20]) + assert ref.rotation == pi / 4 + assert ref.mirrored is True + assert ref.scale == 2.0 + + +def test_ref_as_pattern() -> None: + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + ref = Ref(offset=(10, 10), rotation=pi / 2, scale=2.0) + transformed_pat = ref.as_pattern(sub_pat) + + # Check transformed shape + shape = cast("Polygon", transformed_pat.shapes[(1, 0)][0]) + # ref.as_pattern deepcopies sub_pat then applies transformations: + # 1. pattern.scale_by(2) -> vertices [[0,0], [2,0], [0,2]] + # 2. pattern.rotate_around((0,0), pi/2) -> vertices [[0,0], [0,2], [-2,0]] + # 3. pattern.translate_elements((10,10)) -> vertices [[10,10], [10,12], [8,10]] + + assert_allclose(shape.vertices, [[10, 10], [10, 12], [8, 10]], atol=1e-10) + + +def test_ref_with_repetition() -> None: + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]]) + + rep = Grid(a_vector=(10, 0), b_vector=(0, 10), a_count=2, b_count=2) + ref = Ref(repetition=rep) + + repeated_pat = ref.as_pattern(sub_pat) + # Should have 4 shapes + assert len(repeated_pat.shapes[(1, 0)]) == 4 + + first_verts = sorted([tuple(cast("Polygon", s).vertices[0]) for s in repeated_pat.shapes[(1, 0)]]) + assert first_verts == [(0.0, 0.0), (0.0, 10.0), (10.0, 0.0), (10.0, 10.0)] + + +def test_ref_get_bounds() -> None: + sub_pat = Pattern() + sub_pat.polygon((1, 0), vertices=[[0, 0], [5, 0], [0, 5]]) + + ref = Ref(offset=(10, 10), scale=2.0) + bounds = ref.get_bounds_single(sub_pat) + # sub_pat bounds [[0,0], [5,5]] + # scaled [[0,0], [10,10]] + # translated [[10,10], [20,20]] + assert_equal(bounds, [[10, 10], [20, 20]]) + + +def test_ref_get_bounds_single_ignores_repetition_for_non_manhattan_rotation() -> None: + sub_pat = Pattern() + sub_pat.rect((1, 0), xmin=0, xmax=1, ymin=0, ymax=2) + + rep = Grid(a_vector=(5, 0), b_vector=(0, 7), a_count=3, b_count=2) + ref = Ref(offset=(10, 20), rotation=pi / 4, repetition=rep) + + bounds = ref.get_bounds_single(sub_pat) + repeated_bounds = ref.get_bounds(sub_pat) + + assert bounds is not None + assert repeated_bounds is not None + assert repeated_bounds[1, 0] > bounds[1, 0] + assert repeated_bounds[1, 1] > bounds[1, 1] + + +def test_ref_copy() -> None: + ref1 = Ref(offset=(1, 2), rotation=0.5, annotations={"a": [1]}) + ref2 = ref1.copy() + assert ref1 == ref2 + assert ref1 is not ref2 + + ref2.offset[0] = 100 + assert ref1.offset[0] == 1 + + +def test_ref_rejects_nonpositive_scale() -> None: + with pytest.raises(MasqueError, match='Scale must be positive'): + Ref(scale=0) + + with pytest.raises(MasqueError, match='Scale must be positive'): + Ref(scale=-1) + + +def test_ref_scale_by_rejects_nonpositive_scale() -> None: + ref = Ref(scale=2.0) + + with pytest.raises(MasqueError, match='Scale must be positive'): + ref.scale_by(-1) + + +def test_ref_eq_unrelated_objects_is_false() -> None: + ref = Ref() + assert not (ref == None) + assert not (ref == object()) diff --git a/masque/test/test_repetition.py b/masque/test/test_repetition.py new file mode 100644 index 0000000..00d2d7a --- /dev/null +++ b/masque/test/test_repetition.py @@ -0,0 +1,87 @@ +import pytest +from numpy.testing import assert_equal, assert_allclose +from numpy import pi + +from ..repetition import Grid, Arbitrary +from ..error import PatternError + + +def test_grid_displacements() -> None: + grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) + disps = sorted([tuple(d) for d in grid.displacements]) + assert disps == [(0.0, 0.0), (0.0, 5.0), (10.0, 0.0), (10.0, 5.0)] + + +def test_grid_1d() -> None: + grid = Grid(a_vector=(10, 0), a_count=3) + disps = sorted([tuple(d) for d in grid.displacements]) + assert disps == [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] + + +def test_grid_rotate() -> None: + grid = Grid(a_vector=(10, 0), a_count=2) + grid.rotate(pi / 2) + assert_allclose(grid.a_vector, [0, 10], atol=1e-10) + + +def test_grid_get_bounds() -> None: + grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) + bounds = grid.get_bounds() + assert_equal(bounds, [[0, 0], [10, 5]]) + + +def test_arbitrary_displacements() -> None: + pts = [[0, 0], [10, 20], [-5, 30]] + arb = Arbitrary(pts) + disps = arb.displacements + assert len(disps) == 3 + assert any((disps == [0, 0]).all(axis=1)) + assert any((disps == [10, 20]).all(axis=1)) + assert any((disps == [-5, 30]).all(axis=1)) + + +def test_arbitrary_transform() -> None: + arb = Arbitrary([[10, 0]]) + arb.rotate(pi / 2) + assert_allclose(arb.displacements, [[0, 10]], atol=1e-10) + + arb.mirror(0) + assert_allclose(arb.displacements, [[0, -10]], atol=1e-10) + + +def test_arbitrary_empty_repetition_is_allowed() -> None: + arb = Arbitrary([]) + assert arb.displacements.shape == (0, 2) + assert arb.get_bounds() is None + + +def test_arbitrary_rejects_non_nx2_displacements() -> None: + for displacements in ([[1], [2]], [[1, 2, 3]], [1, 2, 3]): + with pytest.raises(PatternError, match='displacements must be convertible to an Nx2 ndarray'): + Arbitrary(displacements) + + +def test_grid_count_setters_reject_nonpositive_values() -> None: + for attr, value, message in ( + ('a_count', 0, 'a_count'), + ('a_count', -1, 'a_count'), + ('b_count', 0, 'b_count'), + ('b_count', -1, 'b_count'), + ): + grid = Grid(a_vector=(10, 0), b_vector=(0, 5), a_count=2, b_count=2) + with pytest.raises(PatternError, match=message): + setattr(grid, attr, value) + + +def test_repetition_less_equal_includes_equality() -> None: + grid_a = Grid(a_vector=(10, 0), a_count=2) + grid_b = Grid(a_vector=(10, 0), a_count=2) + assert grid_a == grid_b + assert grid_a <= grid_b + assert grid_a >= grid_b + + arb_a = Arbitrary([[0, 0], [1, 0]]) + arb_b = Arbitrary([[0, 0], [1, 0]]) + assert arb_a == arb_b + assert arb_a <= arb_b + assert arb_a >= arb_b diff --git a/masque/test/test_rotation_consistency.py b/masque/test/test_rotation_consistency.py new file mode 100644 index 0000000..f574f52 --- /dev/null +++ b/masque/test/test_rotation_consistency.py @@ -0,0 +1,133 @@ + +from typing import cast +import numpy as np +from numpy.testing import assert_allclose +from ..pattern import Pattern +from ..ref import Ref +from ..label import Label +from ..repetition import Grid + +def test_ref_rotate_intrinsic() -> None: + # Intrinsic rotate() should NOT affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + ref.rotate(np.pi/2) + + assert_allclose(ref.rotation, np.pi/2, atol=1e-10) + # Grid vector should still be (10, 0) + assert ref.repetition is not None + assert_allclose(cast('Grid', ref.repetition).a_vector, [10, 0], atol=1e-10) + +def test_ref_rotate_around_extrinsic() -> None: + # Extrinsic rotate_around() SHOULD affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + ref.rotate_around((0, 0), np.pi/2) + + assert_allclose(ref.rotation, np.pi/2, atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref.repetition is not None + assert_allclose(cast('Grid', ref.repetition).a_vector, [0, 10], atol=1e-10) + +def test_pattern_rotate_around_extrinsic() -> None: + # Pattern.rotate_around() SHOULD affect repetition of its elements + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_around((0, 0), np.pi/2) + + # Check the ref inside the pattern + ref_in_pat = pat.refs['cell'][0] + assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) + +def test_label_rotate_around_extrinsic() -> None: + # Extrinsic rotate_around() SHOULD affect repetition of labels + rep = Grid(a_vector=(10, 0), a_count=2) + lbl = Label("test", repetition=rep, offset=(5, 0)) + + lbl.rotate_around((0, 0), np.pi/2) + + # Label offset should be (0, 5) + assert_allclose(lbl.offset, [0, 5], atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert lbl.repetition is not None + assert_allclose(cast('Grid', lbl.repetition).a_vector, [0, 10], atol=1e-10) + +def test_pattern_rotate_elements_intrinsic() -> None: + # rotate_elements() should NOT affect repetition + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_elements(np.pi/2) + + ref_in_pat = pat.refs['cell'][0] + assert_allclose(ref_in_pat.rotation, np.pi/2, atol=1e-10) + # Grid vector should still be (10, 0) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 0], atol=1e-10) + +def test_pattern_rotate_element_centers_extrinsic() -> None: + # rotate_element_centers() SHOULD affect repetition and offset + rep = Grid(a_vector=(10, 0), a_count=2) + ref = Ref(repetition=rep, offset=(5, 0)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.rotate_element_centers(np.pi/2) + + ref_in_pat = pat.refs['cell'][0] + # Offset should be (0, 5) + assert_allclose(ref_in_pat.offset, [0, 5], atol=1e-10) + # Grid vector should be rotated to (0, 10) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [0, 10], atol=1e-10) + # Ref rotation should NOT be changed + assert_allclose(ref_in_pat.rotation, 0, atol=1e-10) + +def test_pattern_mirror_elements_intrinsic() -> None: + # mirror_elements() should NOT affect repetition or offset + rep = Grid(a_vector=(10, 5), a_count=2) + ref = Ref(repetition=rep, offset=(5, 2)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.mirror_elements(axis=0) # Mirror across x (flip y) + + ref_in_pat = pat.refs['cell'][0] + assert ref_in_pat.mirrored is True + # Repetition and offset should be unchanged + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, 5], atol=1e-10) + assert_allclose(ref_in_pat.offset, [5, 2], atol=1e-10) + +def test_pattern_mirror_element_centers_extrinsic() -> None: + # mirror_element_centers() SHOULD affect repetition and offset + rep = Grid(a_vector=(10, 5), a_count=2) + ref = Ref(repetition=rep, offset=(5, 2)) + + pat = Pattern() + pat.refs['cell'].append(ref) + + pat.mirror_element_centers(axis=0) # Mirror across x (flip y) + + ref_in_pat = pat.refs['cell'][0] + # Offset should be (5, -2) + assert_allclose(ref_in_pat.offset, [5, -2], atol=1e-10) + # Grid vector should be (10, -5) + assert ref_in_pat.repetition is not None + assert_allclose(cast('Grid', ref_in_pat.repetition).a_vector, [10, -5], atol=1e-10) + # Ref mirrored state should NOT be changed + assert ref_in_pat.mirrored is False diff --git a/masque/test/test_shape_ordering.py b/masque/test/test_shape_ordering.py new file mode 100644 index 0000000..fea4efa --- /dev/null +++ b/masque/test/test_shape_ordering.py @@ -0,0 +1,15 @@ +from ..shapes import Circle, Ellipse, Polygon + + +def test_shape_comparisons() -> None: + c1 = Circle(radius=10) + c2 = Circle(radius=20) + assert c1 < c2 + assert not (c2 < c1) + + p1 = Polygon([[0, 0], [10, 0], [10, 10]]) + p2 = Polygon([[0, 0], [10, 0], [10, 11]]) + assert p1 < p2 + + assert c1 < p1 or p1 < c1 + assert (c1 < p1) != (p1 < c1) diff --git a/masque/test/test_shape_transforms.py b/masque/test/test_shape_transforms.py new file mode 100644 index 0000000..2a9092a --- /dev/null +++ b/masque/test/test_shape_transforms.py @@ -0,0 +1,44 @@ +from numpy import pi +from numpy.testing import assert_equal, assert_allclose + +from ..shapes import Arc, Ellipse + + +def test_shape_mirror() -> None: + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) + e.mirror(0) + assert_equal(e.offset, [10, 20]) + assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) + + a = Arc(radii=(10, 10), angles=(0, pi / 4), width=2, offset=(10, 20)) + a.mirror(0) + assert_equal(a.offset, [10, 20]) + assert_allclose(a.angles, [0, -pi / 4], atol=1e-10) + + a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) + a.mirror(1) + assert a.angle_ref == Arc.AngleRef.FocusNeg + + a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos) + a.mirror(0) + assert a.angle_ref == Arc.AngleRef.FocusNeg + +def test_shape_flip_across() -> None: + e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4) + e.flip_across(axis=0) + assert_equal(e.offset, [10, -20]) + assert_allclose(e.rotation, 3 * pi / 4, atol=1e-10) + + e = Ellipse(radii=(10, 5), offset=(10, 20)) + e.flip_across(y=10) + assert_equal(e.offset, [10, 0]) + +def test_shape_scale() -> None: + e = Ellipse(radii=(10, 5)) + e.scale_by(2) + assert_equal(e.radii, [20, 10]) + + a = Arc(radii=(10, 5), angles=(0, pi), width=2) + a.scale_by(0.5) + assert_equal(a.radii, [5, 2.5]) + assert a.width == 1 diff --git a/masque/test/test_svg.py b/masque/test/test_svg.py new file mode 100644 index 0000000..c0dcd97 --- /dev/null +++ b/masque/test/test_svg.py @@ -0,0 +1,100 @@ +from pathlib import Path +import xml.etree.ElementTree as ET + +import numpy +import pytest +from numpy.testing import assert_allclose + +pytest.importorskip("svgwrite") + +from ..library import Library +from ..pattern import Pattern +from ..file import svg + + +SVG_NS = "{http://www.w3.org/2000/svg}" +XLINK_HREF = "{http://www.w3.org/1999/xlink}href" + + +def _child_transform(svg_path: Path) -> tuple[float, ...]: + root = ET.fromstring(svg_path.read_text()) + for use in root.iter(f"{SVG_NS}use"): + if use.attrib.get(XLINK_HREF) == "#child": + raw = use.attrib["transform"] + assert raw.startswith("matrix(") and raw.endswith(")") + return tuple(float(value) for value in raw[7:-1].split()) + raise AssertionError("No child reference found in SVG output") + + +def test_svg_ref_rotation_uses_correct_affine_transform(tmp_path: Path) -> None: + lib = Library() + child = Pattern() + child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) + lib["child"] = child + + top = Pattern() + top.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2) + lib["top"] = top + + svg_path = tmp_path / "rotation.svg" + svg.writefile(lib, "top", str(svg_path)) + + assert_allclose(_child_transform(svg_path), (0, 2, -2, 0, 3, 4), atol=1e-10) + + +def test_svg_ref_mirroring_changes_affine_transform(tmp_path: Path) -> None: + base = Library() + child = Pattern() + child.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) + base["child"] = child + + top_plain = Pattern() + top_plain.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=False) + base["plain"] = top_plain + + plain_path = tmp_path / "plain.svg" + svg.writefile(base, "plain", str(plain_path)) + plain_transform = _child_transform(plain_path) + + mirrored = Library() + mirrored["child"] = child.deepcopy() + top_mirrored = Pattern() + top_mirrored.ref("child", offset=(3, 4), rotation=numpy.pi / 2, scale=2, mirrored=True) + mirrored["mirrored"] = top_mirrored + + mirrored_path = tmp_path / "mirrored.svg" + svg.writefile(mirrored, "mirrored", str(mirrored_path)) + mirrored_transform = _child_transform(mirrored_path) + + assert_allclose(plain_transform, (0, 2, -2, 0, 3, 4), atol=1e-10) + assert_allclose(mirrored_transform, (0, 2, 2, 0, 3, 4), atol=1e-10) + + +def test_svg_uses_unique_ids_for_colliding_mangled_names(tmp_path: Path) -> None: + lib = Library() + first = Pattern() + first.polygon("1", vertices=[[0, 0], [1, 0], [0, 1]]) + lib["a b"] = first + + second = Pattern() + second.polygon("1", vertices=[[0, 0], [2, 0], [0, 2]]) + lib["a-b"] = second + + top = Pattern() + top.ref("a b") + top.ref("a-b", offset=(5, 0)) + lib["top"] = top + + svg_path = tmp_path / "colliding_ids.svg" + svg.writefile(lib, "top", str(svg_path)) + + root = ET.fromstring(svg_path.read_text()) + ids = [group.attrib["id"] for group in root.iter(f"{SVG_NS}g")] + top_group = next(group for group in root.iter(f"{SVG_NS}g") if group.attrib["id"] == "top") + hrefs = [use.attrib[XLINK_HREF] for use in top_group.iter(f"{SVG_NS}use")] + + assert len(set(ids)) == len(ids) + assert len(hrefs) == 2 + assert len(set(hrefs)) == 2 + assert all(href.startswith("#") for href in hrefs) + assert all(href[1:] in ids for href in hrefs) diff --git a/masque/test/test_text.py b/masque/test/test_text.py new file mode 100644 index 0000000..1b6770d --- /dev/null +++ b/masque/test/test_text.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest +import numpy + +from ..shapes import Polygon, Text + + +def test_text_to_polygons() -> None: + pytest.importorskip("freetype") + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not Path(font_path).exists(): + pytest.skip("Font file not found") + + t = Text("Hi", height=10, font_path=font_path) + polys = t.to_polygons() + assert len(polys) > 0 + assert all(isinstance(p, Polygon) for p in polys) + + # Each character produces polygons with distinct horizontal placement. + char_x_means = [p.vertices[:, 0].mean() for p in polys] + assert len(set(char_x_means)) >= 2 + +def test_text_bounds_and_normalized_form() -> None: + pytest.importorskip("freetype") + font_path = "/usr/share/fonts/truetype/dejavu/DejaVuMathTeXGyre.ttf" + if not Path(font_path).exists(): + pytest.skip("Font file not found") + + text = Text("Hi", height=10, font_path=font_path) + _intrinsic, extrinsic, ctor = text.normalized_form(5) + normalized = ctor() + + assert extrinsic[1] == 2 + assert normalized.height == 5 + + bounds = text.get_bounds_single() + assert bounds is not None + assert numpy.isfinite(bounds).all() + assert numpy.all(bounds[1] > bounds[0]) + +def test_text_mirroring_affects_comparison() -> None: + text = Text("A", height=10, font_path="dummy.ttf") + mirrored = Text("A", height=10, font_path="dummy.ttf", mirrored=True) + + assert text != mirrored + assert (text < mirrored) != (mirrored < text) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py new file mode 100644 index 0000000..f5e0215 --- /dev/null +++ b/masque/test/test_utils.py @@ -0,0 +1,258 @@ +from pathlib import Path + +import numpy +from numpy.testing import assert_equal, assert_allclose +from numpy import pi +import pytest + +from ..utils import ( + DeferredDict, + apply_transforms, + normalize_mirror, + poly_contains_points, + remove_colinear_vertices, + remove_duplicate_vertices, + rotation_matrix_2d, +) +from ..file.utils import tmpfile +from ..utils.curves import bezier, circular_arc, euler_bend, euler_spiral +from ..error import PatternError + + +def test_remove_duplicate_vertices() -> None: + # Closed path (default) + v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]] + v_clean = remove_duplicate_vertices(v, closed_path=True) + # The last [0,0] is a duplicate of the first [0,0] if closed_path=True + assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]]) + + # Open path + v_clean_open = remove_duplicate_vertices(v, closed_path=False) + assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]]) + + +def test_remove_duplicate_vertices_tolerance_defaults_to_exact_match() -> None: + v = [[0, 0], [1, 1], [1 + 1e-13, 1], [2, 2], [1e-13, 0]] + + assert_allclose(remove_duplicate_vertices(v, closed_path=True), v, atol=0, rtol=0) + assert_allclose( + remove_duplicate_vertices(v, closed_path=True, tolerance=1e-12), + [[0, 0], [1 + 1e-13, 1], [2, 2]], + atol=0, + rtol=0, + ) + + +def test_remove_duplicate_vertices_rejects_negative_tolerance() -> None: + with pytest.raises(ValueError, match='non-negative'): + remove_duplicate_vertices([[0, 0]], tolerance=-1) + + +def test_remove_colinear_vertices() -> None: + v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]] + v_clean = remove_colinear_vertices(v, closed_path=True) + # [1, 0] is between [0, 0] and [2, 0] + # [2, 1] is between [2, 0] and [2, 2] + # [1, 1] is between [2, 2] and [0, 0] + assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]]) + + +def test_remove_colinear_vertices_exhaustive() -> None: + v = [[0, 0], [10, 0], [0, 0]] + v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True) + assert len(v_clean) == 3 + + v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False) + assert len(v_collapsed) == 2 + + v = [[0, 0], [10, 0], [5, 0]] + v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False) + assert len(v_clean) == 2 + + +def test_poly_contains_points() -> None: + v = [[0, 0], [10, 0], [10, 10], [0, 10]] + pts = [[5, 5], [-1, -1], [10, 10], [11, 5]] + inside = poly_contains_points(v, pts) + assert_equal(inside, [True, False, True, False]) + + +def test_rotation_matrix_2d() -> None: + m = rotation_matrix_2d(pi / 2) + assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10) + + +def test_rotation_matrix_non_manhattan() -> None: + # 45 degrees + m = rotation_matrix_2d(pi / 4) + s = numpy.sqrt(2) / 2 + assert_allclose(m, [[s, -s], [s, s]], atol=1e-10) + + +def test_apply_transforms() -> None: + # cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)] + t1 = [10, 20, 0, 0] + t2 = [[5, 0, 0, 0], [0, 5, 0, 0]] + combined = apply_transforms(t1, t2) + assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]]) + + +def test_apply_transforms_advanced() -> None: + # Ox4: (x, y, rot, mir) + # Outer: mirror x (axis 0), then rotate 90 deg CCW + # apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror) + outer = [0, 0, pi / 2, 1] + + # Inner: (10, 0, 0, 0) + inner = [10, 0, 0, 0] + + combined = apply_transforms(outer, inner) + # 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0) + # 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10) + # 3. add outer offset (0, 0) -> (0, 10) + assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) + + +def test_apply_transforms_empty_inputs() -> None: + empty_outer = apply_transforms(numpy.empty((0, 5)), [[1, 2, 0, 0, 1]]) + assert empty_outer.shape == (0, 5) + + empty_inner = apply_transforms([[1, 2, 0, 0, 1]], numpy.empty((0, 5))) + assert empty_inner.shape == (0, 5) + + both_empty_tensor = apply_transforms(numpy.empty((0, 5)), numpy.empty((0, 5)), tensor=True) + assert both_empty_tensor.shape == (0, 0, 5) + + +def test_normalize_mirror_validates_length() -> None: + with pytest.raises(ValueError, match='2-item sequence'): + normalize_mirror(()) + + with pytest.raises(ValueError, match='2-item sequence'): + normalize_mirror((True,)) + + with pytest.raises(ValueError, match='2-item sequence'): + normalize_mirror((True, False, True)) + + +def test_bezier_validates_weight_length() -> None: + with pytest.raises(PatternError, match='one entry per control point'): + bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1]) + + with pytest.raises(PatternError, match='one entry per control point'): + bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2, 3]) + + +def test_bezier_accepts_exact_weight_count() -> None: + samples = bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2]) + assert_allclose(samples, [[0, 0], [2 / 3, 2 / 3], [1, 1]], atol=1e-10) + + +def _endpoint_tangent(xy: numpy.ndarray) -> float: + dxy = xy[-1] - xy[-2] + return numpy.arctan2(dxy[1], dxy[0]) + + +@pytest.mark.parametrize( + ('switchover_angle', 'total_angle'), + [ + (pi / 8, pi / 4), + (pi / 8, pi / 2), + (pi / 4, pi), + ], + ) +def test_euler_bend_supports_total_angle(switchover_angle: float, total_angle: float) -> None: + xy = euler_bend(switchover_angle, num_points=2000, total_angle=total_angle) + + assert_allclose(xy[0], [0, 0], atol=1e-12) + assert_allclose(_endpoint_tangent(xy), -total_angle, atol=1e-3) + + +def test_euler_bend_180_degrees_with_90_degree_circular_middle() -> None: + xy = euler_bend(pi / 4, num_points=2000, total_angle=pi) + + assert_allclose(_endpoint_tangent(xy), -pi, atol=1e-3) + assert abs(xy[-1][0]) < 1e-3 + assert xy[-1][1] < 0 + + +def test_euler_bend_rejects_too_large_switchover_angle() -> None: + with pytest.raises(PatternError, match='total_angle / 2'): + euler_bend(pi / 2, total_angle=pi / 2) + + +def test_euler_spiral_and_circular_arc_helpers_match_endpoint_tangent() -> None: + xy_spiral = euler_spiral(pi / 4, num_points=1000) + assert_allclose(_endpoint_tangent(xy_spiral), -pi / 4, atol=1e-3) + + xy_arc = circular_arc( + 10, + pi / 2, + num_points=1000, + start_angle=_endpoint_tangent(xy_spiral), + start=xy_spiral[-1], + ) + assert_allclose(_endpoint_tangent(xy_arc), -3 * pi / 4, atol=2e-3) + + +def test_deferred_dict_accessors_resolve_values_once() -> None: + calls = 0 + + def make_value() -> int: + nonlocal calls + calls += 1 + return 7 + + deferred = DeferredDict[str, int]() + deferred["x"] = make_value + + assert deferred.get("missing", 9) == 9 + assert deferred.get("x") == 7 + assert list(deferred.values()) == [7] + assert list(deferred.items()) == [("x", 7)] + assert calls == 1 + + +def test_deferred_dict_mutating_accessors_preserve_value_semantics() -> None: + calls = 0 + + def make_value() -> int: + nonlocal calls + calls += 1 + return 7 + + deferred = DeferredDict[str, int]() + + assert deferred.setdefault("x", 5) == 5 + assert deferred["x"] == 5 + + assert deferred.setdefault("y", make_value) == 7 + assert deferred["y"] == 7 + assert calls == 1 + + copy_deferred = deferred.copy() + assert isinstance(copy_deferred, DeferredDict) + assert copy_deferred["x"] == 5 + assert copy_deferred["y"] == 7 + assert calls == 1 + + assert deferred.pop("x") == 5 + assert deferred.pop("missing", 9) == 9 + assert deferred.popitem() == ("y", 7) + + +def test_tmpfile_cleans_up_on_exception(tmp_path: Path) -> None: + target = tmp_path / "out.txt" + temp_path = None + + try: + with tmpfile(target) as stream: + temp_path = Path(stream.name) + stream.write(b"hello") + raise RuntimeError("boom") + except RuntimeError: + pass + + assert temp_path is not None + assert not target.exists() + assert not temp_path.exists() diff --git a/masque/test/test_visualize.py b/masque/test/test_visualize.py new file mode 100644 index 0000000..a20286c --- /dev/null +++ b/masque/test/test_visualize.py @@ -0,0 +1,53 @@ +import numpy as np +import pytest +from masque.pattern import Pattern +from masque.ports import Port +from masque.repetition import Grid + +try: + import matplotlib + HAS_MATPLOTLIB = True +except ImportError: + HAS_MATPLOTLIB = False + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") +def test_visualize_noninteractive(tmp_path) -> None: + """ + Test that visualize() runs and saves a file without error. + This covers the recursive transformation and collection logic. + """ + # Create a hierarchy + child = Pattern() + child.polygon('L1', [[0, 0], [1, 0], [1, 1], [0, 1]]) + child.ports['P1'] = Port((0.5, 0.5), 0) + + parent = Pattern() + # Add some refs with various transforms + parent.ref('child', offset=(10, 0), rotation=np.pi/4, mirrored=True, scale=2.0) + + # Add a repetition + rep = Grid(a_vector=(5, 5), a_count=2) + parent.ref('child', offset=(0, 10), repetition=rep) + + library = {'child': child} + + output_file = tmp_path / "test_plot.png" + + # Run visualize with filename to avoid showing window + parent.visualize(library=library, filename=str(output_file), ports=True) + + assert output_file.exists() + assert output_file.stat().st_size > 0 + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") +def test_visualize_empty() -> None: + """ Test visualizing an empty pattern. """ + pat = Pattern() + pat.visualize(overdraw=True) + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib not installed") +def test_visualize_no_refs() -> None: + """ Test visualizing a pattern with only local shapes (no library needed). """ + pat = Pattern() + pat.polygon('L1', [[0, 0], [1, 0], [0, 1]]) + pat.visualize(overdraw=True) diff --git a/masque/traits/__init__.py b/masque/traits/__init__.py index 7c7360c..cca38f3 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,7 +26,11 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import Mirrorable as Mirrorable +from .mirrorable import ( + Mirrorable as Mirrorable, + Flippable as Flippable, + FlippableImpl as FlippableImpl, + ) from .copyable import Copyable as Copyable from .annotatable import ( Annotatable as Annotatable, diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 6d4ec3c..2a3a9fb 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,6 +1,13 @@ from typing import Self from abc import ABCMeta, abstractmethod +import numpy +from numpy.typing import NDArray + +from ..error import MasqueError +from .positionable import Positionable +from .repeatable import Repeatable + class Mirrorable(metaclass=ABCMeta): """ @@ -11,11 +18,17 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - Mirror the entity across an axis. + Intrinsic transformation: Mirror the entity across an axis through its origin. + This does NOT affect the object's repetition grid. + + This operation is performed relative to the object's internal origin (ignoring + its offset). For objects like `Polygon` and `Path` where the offset is forced + to (0, 0), this is equivalent to mirroring in the container's coordinate system. Args: - axis: Axis to mirror across. - + axis: Axis to mirror across: + 0: X-axis (flip y coords), + 1: Y-axis (flip x coords) Returns: self """ @@ -23,10 +36,11 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes + Optionally mirror the entity across both axes through its origin. Args: - axes: (mirror_across_x, mirror_across_y) + across_x: Mirror across the horizontal X-axis (flip Y coordinates). + across_y: Mirror across the vertical Y-axis (flip X coordinates). Returns: self @@ -38,30 +52,61 @@ class Mirrorable(metaclass=ABCMeta): return self -#class MirrorableImpl(Mirrorable, metaclass=ABCMeta): -# """ -# Simple implementation of `Mirrorable` -# """ -# __slots__ = () -# -# _mirrored: NDArray[numpy.bool] -# """ Whether to mirror the instance across the x and/or y axes. """ -# -# # -# # Properties -# # -# # Mirrored property -# @property -# def mirrored(self) -> NDArray[numpy.bool]: -# """ Whether to mirror across the [x, y] axes, respectively """ -# return self._mirrored -# -# @mirrored.setter -# def mirrored(self, val: Sequence[bool]) -> None: -# if is_scalar(val): -# raise MasqueError('Mirrored must be a 2-element list of booleans') -# self._mirrored = numpy.array(val, dtype=bool) -# -# # -# # Methods -# # +class Flippable(Positionable, metaclass=ABCMeta): + """ + Trait class for entities which can be mirrored relative to an external line. + """ + __slots__ = () + + @staticmethod + def _check_flip_args(axis: int | None = None, *, x: float | None = None, y: float | None = None) -> tuple[int, NDArray[numpy.float64]]: + pivot = numpy.zeros(2) + if axis is not None: + if x is not None or y is not None: + raise MasqueError('Cannot specify both axis and x or y') + return axis, pivot + if x is not None: + if y is not None: + raise MasqueError('Cannot specify both x and y') + return 1, pivot + (x, 0) + if y is not None: + return 0, pivot + (0, y) + raise MasqueError('Must specify one of axis, x, or y') + + @abstractmethod + def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: + """ + Extrinsic transformation: Mirror the object across a line in the container's + coordinate system. This affects both the object's offset and its repetition grid. + + Unlike `mirror()`, this operation is performed relative to the container's origin + (e.g. the `Pattern` origin, in the case of shapes) and takes the object's offset + into account. + + Args: + axis: Axis to mirror across. 0: x-axis (flip y coord), 1: y-axis (flip x coord). + x: Vertical line x=val to mirror across. + y: Horizontal line y=val to mirror across. + + Returns: + self + """ + pass + + +class FlippableImpl(Flippable, Mirrorable, Repeatable, metaclass=ABCMeta): + """ + Implementation of `Flippable` for objects which are `Mirrorable`, `Positionable`, + and `Repeatable`. + """ + __slots__ = () + + def flip_across(self, axis: int | None = None, *, x: float | None = None, y: float | None = None) -> Self: + axis, pivot = self._check_flip_args(axis=axis, x=x, y=y) + self.translate(-pivot) + self.mirror(axis) + if self.repetition is not None: + self.repetition.mirror(axis) + self.offset[1 - axis] *= -1 + self.translate(+pivot) + return self diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index fbd765f..dbf4fad 100644 --- a/masque/traits/repeatable.py +++ b/masque/traits/repeatable.py @@ -76,7 +76,7 @@ class RepeatableImpl(Repeatable, Bounded, metaclass=ABCMeta): @repetition.setter def repetition(self, repetition: 'Repetition | None') -> None: - from ..repetition import Repetition + from ..repetition import Repetition #noqa: PLC0415 if repetition is not None and not isinstance(repetition, Repetition): raise MasqueError(f'{repetition} is not a valid Repetition object!') self._repetition = repetition diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 2fa86c1..436d0a2 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self, cast, Any, TYPE_CHECKING +from typing import Self from abc import ABCMeta, abstractmethod import numpy @@ -8,8 +8,7 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -if TYPE_CHECKING: - from .positionable import Positionable +from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -26,7 +25,8 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Rotate the shape around its origin (0, 0), ignoring its offset. + Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. + This does NOT affect the object's repetition grid. Args: val: Angle to rotate by (counterclockwise, radians) @@ -64,6 +64,10 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): # Methods # def rotate(self, rotation: float) -> Self: + """ + Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. + This does NOT affect the object's repetition grid. + """ self.rotation += rotation return self @@ -81,9 +85,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(metaclass=ABCMeta): +class Pivotable(Positionable, metaclass=ABCMeta): """ - Trait class for entites which can be rotated around a point. + Trait class for entities which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -91,7 +95,11 @@ class Pivotable(metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Rotate the object around a point. + Extrinsic transformation: Rotate the object around a point in the container's + coordinate system. This affects both the object's offset and its repetition grid. + + For objects that are also `Rotatable`, this also performs an intrinsic + rotation of the object. Args: pivot: Point (x, y) to rotate around @@ -103,20 +111,21 @@ class Pivotable(metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): """ Implementation of `Pivotable` for objects which are `Rotatable` + and `Positionable`. """ __slots__ = () - offset: Any # TODO see if we can get around defining `offset` in PivotableImpl - """ `[x_offset, y_offset]` """ - def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: + from .repeatable import Repeatable #noqa: PLC0415 pivot = numpy.asarray(pivot, dtype=float) - cast('Positionable', self).translate(-pivot) - cast('Rotatable', self).rotate(rotation) + self.translate(-pivot) + self.rotate(rotation) + if isinstance(self, Repeatable) and self.repetition is not None: + self.repetition.rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - cast('Positionable', self).translate(+pivot) + self.translate(+pivot) return self diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index f33142f..8e10ca1 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -10,6 +10,11 @@ from .array import is_scalar as is_scalar from .autoslots import AutoSlots as AutoSlots from .deferreddict import DeferredDict as DeferredDict from .decorators import oneshot as oneshot +from .ptypes import ( + PTypeMatch as PTypeMatch, + ptype_match as ptype_match, + ptypes_compatible as ptypes_compatible, + ) from .bitwise import ( get_bit as get_bit, diff --git a/masque/utils/autoslots.py b/masque/utils/autoslots.py index e82d3db..cef8006 100644 --- a/masque/utils/autoslots.py +++ b/masque/utils/autoslots.py @@ -17,11 +17,12 @@ class AutoSlots(ABCMeta): for base in bases: parents |= set(base.mro()) - slots = tuple(dctn.get('__slots__', ())) + slots = list(dctn.get('__slots__', ())) for parent in parents: if not hasattr(parent, '__annotations__'): continue - slots += tuple(parent.__annotations__.keys()) + slots.extend(parent.__annotations__.keys()) - dctn['__slots__'] = slots + # Deduplicate (dict to preserve order) + dctn['__slots__'] = tuple(dict.fromkeys(slots)) return super().__new__(cls, name, bases, dctn) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py new file mode 100644 index 0000000..5181fc5 --- /dev/null +++ b/masque/utils/boolean.py @@ -0,0 +1,196 @@ +from typing import Any, Literal +from collections.abc import Iterable +import logging + +import numpy +from numpy.typing import NDArray + +from ..shapes.polygon import Polygon +from ..error import PatternError + + +logger = logging.getLogger(__name__) + + +def _bridge_holes(outer_path: NDArray[numpy.float64], holes: list[NDArray[numpy.float64]]) -> NDArray[numpy.float64]: + """ + Bridge multiple holes into an outer boundary using zero-width slits. + """ + current_outer = outer_path + + # Sort holes by max X to potentially minimize bridge lengths or complexity + # (though not strictly necessary for correctness) + holes = sorted(holes, key=lambda h: numpy.max(h[:, 0]), reverse=True) + + for hole in holes: + # Find max X vertex of hole + max_idx = numpy.argmax(hole[:, 0]) + m = hole[max_idx] + + # Find intersection of ray (m.x, m.y) + (t, 0) with current_outer edges + best_t = numpy.inf + best_pt = None + best_edge_idx = -1 + + n = len(current_outer) + for i in range(n): + p1 = current_outer[i] + p2 = current_outer[(i + 1) % n] + + # Check if edge (p1, p2) spans m.y + if (p1[1] <= m[1] < p2[1]) or (p2[1] <= m[1] < p1[1]): + # Intersection x: + # x = p1.x + (m.y - p1.y) * (p2.x - p1.x) / (p2.y - p1.y) + t = (p1[0] + (m[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1])) - m[0] + if 0 <= t < best_t: + best_t = t + best_pt = numpy.array([m[0] + t, m[1]]) + best_edge_idx = i + + if best_edge_idx == -1: + # Fallback: find nearest vertex if ray fails (shouldn't happen for valid hole) + dists = numpy.linalg.norm(current_outer - m, axis=1) + best_edge_idx = int(numpy.argmin(dists)) + best_pt = current_outer[best_edge_idx] + # Adjust best_edge_idx to insert AFTER this vertex + # (treating it as a degenerate edge) + + assert best_pt is not None + + # Reorder hole vertices to start at m + hole_reordered = numpy.roll(hole, -max_idx, axis=0) + + # Construct new outer: + # 1. Start of outer up to best_edge_idx + # 2. Intersection point + # 3. Hole vertices (starting and ending at m) + # 4. Intersection point (to close slit) + # 5. Rest of outer + + new_outer: list[NDArray[numpy.float64]] = [] + new_outer.extend(current_outer[:best_edge_idx + 1]) + new_outer.append(best_pt) + new_outer.extend(hole_reordered) + new_outer.append(hole_reordered[0]) # close hole loop at m + new_outer.append(best_pt) # back to outer + new_outer.extend(current_outer[best_edge_idx + 1:]) + + current_outer = numpy.array(new_outer) + + return current_outer + +def boolean( + subjects: Iterable[Any], + clips: Iterable[Any] | None = None, + operation: Literal['union', 'intersection', 'difference', 'xor'] = 'union', + scale: float = 1e6, + ) -> list[Polygon]: + """ + Perform a boolean operation on two sets of polygons. + + Args: + subjects: List of subjects (Polygons or vertex arrays). + clips: List of clips (Polygons or vertex arrays). + operation: The boolean operation to perform. + scale: Scaling factor for integer conversion (pyclipper uses integers). + + Returns: + A list of result Polygons. + """ + try: + import pyclipper #noqa: PLC0415 + except ImportError: + raise ImportError( + "Boolean operations require 'pyclipper'. " + "Install it with 'pip install pyclipper' or 'pip install masque[boolean]'." + ) from None + + op_map = { + 'union': pyclipper.CT_UNION, + 'intersection': pyclipper.CT_INTERSECTION, + 'difference': pyclipper.CT_DIFFERENCE, + 'xor': pyclipper.CT_XOR, + } + + def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: + if objs is None: + return [] + if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon): + objs = (objs,) + elif not isinstance(objs, Iterable): + raise PatternError(f"Unsupported type for boolean operation: {type(objs)}") + verts = [] + for obj in objs: + if hasattr(obj, 'to_polygons'): + for p in obj.to_polygons(): + verts.append(p.vertices) + elif isinstance(obj, numpy.ndarray): + verts.append(obj) + elif isinstance(obj, Polygon): + verts.append(obj.vertices) + else: + # Try to iterate if it's an iterable of shapes + try: + for sub in obj: + if hasattr(sub, 'to_polygons'): + for p in sub.to_polygons(): + verts.append(p.vertices) + elif isinstance(sub, Polygon): + verts.append(sub.vertices) + except TypeError: + raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None + return verts + + subject_verts = to_vertices(subjects) + clip_verts = to_vertices(clips) + + if not subject_verts: + if operation in ('union', 'xor'): + return [Polygon(vertices) for vertices in clip_verts] + return [] + + if not clip_verts: + if operation == 'intersection': + return [] + return [Polygon(vertices) for vertices in subject_verts] + + pc = pyclipper.Pyclipper() + pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) + if clip_verts: + pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) + + # Use GetPolyTree to distinguish between outers and holes + polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) + + result_polygons = [] + + def process_node(node: Any) -> None: + if not node.IsHole: + # This is an outer boundary + outer_path = numpy.array(pyclipper.scale_from_clipper(node.Contour, scale)) + + # Find immediate holes + holes = [] + for child in node.Childs: + if child.IsHole: + holes.append(numpy.array(pyclipper.scale_from_clipper(child.Contour, scale))) + + if holes: + combined_vertices = _bridge_holes(outer_path, holes) + result_polygons.append(Polygon(combined_vertices)) + else: + result_polygons.append(Polygon(outer_path)) + + # Recursively process children of holes (which are nested outers) + for child in node.Childs: + if child.IsHole: + for grandchild in child.Childs: + process_node(grandchild) + else: + # Holes are processed as children of outers + pass + + for top_node in polytree.Childs: + process_node(top_node) + + return result_polygons diff --git a/masque/utils/comparisons.py b/masque/utils/comparisons.py index 63981c9..bb2dfee 100644 --- a/masque/utils/comparisons.py +++ b/masque/utils/comparisons.py @@ -9,7 +9,15 @@ def annotation2key(aaa: int | float | str) -> tuple[bool, Any]: return (isinstance(aaa, str), aaa) +def _normalized_annotations(annotations: annotations_t) -> annotations_t: + if not annotations: + return None + return annotations + + def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool: + aa = _normalized_annotations(aa) + bb = _normalized_annotations(bb) if aa is None: return bb is not None elif bb is None: # noqa: RET505 @@ -36,6 +44,8 @@ def annotations_lt(aa: annotations_t, bb: annotations_t) -> bool: def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: + aa = _normalized_annotations(aa) + bb = _normalized_annotations(bb) if aa is None: return bb is None elif bb is None: # noqa: RET505 @@ -47,7 +57,7 @@ def annotations_eq(aa: annotations_t, bb: annotations_t) -> bool: keys_a = tuple(sorted(aa.keys())) keys_b = tuple(sorted(bb.keys())) if keys_a != keys_b: - return keys_a < keys_b + return False for key in keys_a: va = aa[key] diff --git a/masque/utils/curves.py b/masque/utils/curves.py index 8b3fcc4..dd6450e 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -2,10 +2,8 @@ import numpy from numpy.typing import ArrayLike, NDArray from numpy import pi -try: - from numpy import trapezoid -except ImportError: - from numpy import trapz as trapezoid # type:ignore +from ..error import PatternError +from .vertices import remove_duplicate_vertices def bezier( @@ -31,6 +29,11 @@ def bezier( tt = numpy.asarray(tt) nn = nodes.shape[0] weights = numpy.ones(nn) if weights is None else numpy.asarray(weights) + if weights.ndim != 1 or weights.shape[0] != nn: + raise PatternError( + f'weights must be a 1D array with one entry per control point; ' + f'got shape {weights.shape} for {nn} control points' + ) with numpy.errstate(divide='ignore'): umul = (tt / (1 - tt)).clip(max=1) @@ -46,59 +49,196 @@ def bezier( return qq +def _integrate_tangent( + qq: NDArray[numpy.float64], + theta: NDArray[numpy.float64], + num_points: int, + ) -> NDArray[numpy.float64]: + dx = numpy.cos(theta) + dy = numpy.sin(theta) + + dq = qq[-1] / (qq.size - 1) + ix = numpy.zeros(qq.size) + iy = numpy.zeros(qq.size) + ix[1:] = numpy.cumsum((dx[:-1] + dx[1:]) / 2) * dq + iy[1:] = numpy.cumsum((dy[:-1] + dy[1:]) / 2) * dq + + qq_target = numpy.linspace(0, qq[-1], num_points) + x_target = numpy.interp(qq_target, qq, ix) + y_target = numpy.interp(qq_target, qq, iy) + + return numpy.stack((x_target, y_target), axis=1) + + +def euler_spiral( + switchover_angle: float, + num_points: int = 200, + *, + start_angle: float = 0.0, + start: ArrayLike = (0.0, 0.0), + reverse: bool = False, + ) -> NDArray[numpy.float64]: + """ + Generate one Euler bend transition segment. + + Positive angles bend clockwise, matching `euler_bend()`. When `reverse` is + `False`, curvature ramps from zero to the switchover curvature. When + `reverse` is `True`, curvature ramps from the switchover curvature to zero. + + Args: + switchover_angle: Tangent angle change across the Euler segment, in radians. + num_points: Number of points in the curve. + start_angle: Tangent angle at the first point. + start: First point of the segment. + reverse: If `True`, generate the exit segment of an Euler bend. + + Returns: + `[[x0, y0], ...]` for the curve. + """ + if num_points < 0: + raise PatternError(f'num_points must be non-negative, got {num_points}') + if switchover_angle < 0: + raise PatternError(f'switchover_angle must be non-negative, got {switchover_angle}') + if num_points == 0: + return numpy.empty((0, 2)) + + start = numpy.asarray(start, dtype=float) + if start.shape != (2,): + raise PatternError(f'start must be a 2D point; got shape {start.shape}') + + if switchover_angle == 0: + return numpy.tile(start, (num_points, 1)) + + resolution = 100000 + ll_max = numpy.sqrt(2 * switchover_angle) + qq = numpy.linspace(0, ll_max, resolution) + if reverse: + theta = start_angle - (ll_max * qq - qq * qq / 2) + else: + theta = start_angle - qq * qq / 2 + + return _integrate_tangent(qq, theta, num_points) + start + + +def circular_arc( + radius: float, + arc_angle: float, + num_points: int = 200, + *, + start_angle: float = 0.0, + start: ArrayLike = (0.0, 0.0), + ) -> NDArray[numpy.float64]: + """ + Generate a clockwise circular arc. + + Args: + radius: Arc radius. + arc_angle: Clockwise tangent angle change across the arc, in radians. + num_points: Number of points in the curve, excluding the start point. + start_angle: Tangent angle at the start point. + start: Point where the arc starts. + + Returns: + `[[x0, y0], ...]` for the arc, excluding `start`. + """ + if num_points < 0: + raise PatternError(f'num_points must be non-negative, got {num_points}') + if radius <= 0: + raise PatternError(f'radius must be positive, got {radius}') + if arc_angle < 0: + raise PatternError(f'arc_angle must be non-negative, got {arc_angle}') + if num_points == 0: + return numpy.empty((0, 2)) + + start = numpy.asarray(start, dtype=float) + if start.shape != (2,): + raise PatternError(f'start must be a 2D point; got shape {start.shape}') + + if arc_angle == 0: + return numpy.tile(start, (num_points, 1)) + + angles = numpy.linspace(0, arc_angle, num_points + 1)[1:] + right_normal = numpy.array([numpy.sin(start_angle), -numpy.cos(start_angle)]) + center = start + radius * right_normal + radial = start - center + + cos_t = numpy.cos(-angles) + sin_t = numpy.sin(-angles) + xx = center[0] + cos_t * radial[0] - sin_t * radial[1] + yy = center[1] + sin_t * radial[0] + cos_t * radial[1] + return numpy.stack((xx, yy), axis=1) + def euler_bend( switchover_angle: float, num_points: int = 200, + *, + total_angle: float = pi / 2, ) -> NDArray[numpy.float64]: """ - Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). + Generate an Euler bend (AKA Clothoid bend or Cornu spiral). + + Positive angles bend clockwise. By default, this generates the historical + 90 degree bend. Args: switchover_angle: After this angle, the bend will transition into a circular arc (and transition back to an Euler spiral on the far side). If this is set to - `>= pi / 4`, no circular arc will be added. - num_points: Number of points in the curve + `total_angle / 2`, no circular arc will be added. + num_points: Number of points in the curve. + total_angle: Total tangent angle change across the bend, in radians. Returns: `[[x0, y0], ...]` for the curve """ + if switchover_angle <= 0: + raise PatternError(f'switchover_angle must be positive, got {switchover_angle}') + if total_angle <= 0: + raise PatternError(f'total_angle must be positive, got {total_angle}') + if switchover_angle > total_angle / 2: + raise PatternError( + f'switchover_angle must be <= total_angle / 2; ' + f'got {switchover_angle} for total_angle {total_angle}' + ) + if num_points < 2: + raise PatternError(f'num_points must be at least 2, got {num_points}') + + arc_angle = total_angle - 2 * switchover_angle ll_max = numpy.sqrt(2 * switchover_angle) # total length of (one) spiral portion - ll_tot = 2 * ll_max + (pi / 2 - 2 * switchover_angle) - num_points_spiral = numpy.floor(ll_max / ll_tot * num_points).astype(int) - num_points_arc = num_points - 2 * num_points_spiral + ll_tot = 2 * ll_max + arc_angle + num_points_spiral = max(2, numpy.floor(ll_max / ll_tot * num_points).astype(int)) + num_points_arc = max(0, num_points - 2 * num_points_spiral) + if arc_angle > 0: + num_points_arc = max(1, num_points_arc) - def gen_spiral(ll_max: float) -> NDArray[numpy.float64]: - xx = [] - yy = [] - for ll in numpy.linspace(0, ll_max, num_points_spiral): - qq = numpy.linspace(0, ll, 1000) # integrate to current arclength - xx.append(trapezoid( numpy.cos(qq * qq / 2), qq)) - yy.append(trapezoid(-numpy.sin(qq * qq / 2), qq)) - xy_part = numpy.stack((xx, yy), axis=1) - return xy_part - - xy_spiral = gen_spiral(ll_max) + xy_spiral = euler_spiral(switchover_angle, num_points_spiral) xy_parts = [xy_spiral] - if switchover_angle < pi / 4: + if arc_angle > 0: # Build a circular segment to join the two euler portions rmin = 1.0 / ll_max - half_angle = pi / 4 - switchover_angle - qq = numpy.linspace(half_angle * 2, 0, num_points_arc + 1) + switchover_angle - xc = rmin * numpy.cos(qq) - yc = rmin * numpy.sin(qq) + xy_spiral[-1, 1] - xc += xy_spiral[-1, 0] - xc[0] - yc += xy_spiral[-1, 1] - yc[0] - xy_parts.append(numpy.stack((xc[1:], yc[1:]), axis=1)) + xy_arc = circular_arc( + rmin, + arc_angle, + num_points_arc, + start_angle=-switchover_angle, + start=xy_spiral[-1], + ) + xy_parts.append(xy_arc) endpoint_xy = xy_parts[-1][-1, :] - second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1] + second_spiral = euler_spiral( + switchover_angle, + num_points_spiral, + start_angle=-(total_angle - switchover_angle), + start=endpoint_xy, + reverse=True, + ) - xy_parts.append(second_spiral) + xy_parts.append(second_spiral[1:]) xy = numpy.concatenate(xy_parts) # Remove any 2x-duplicate points - xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)] + xy = remove_duplicate_vertices(xy, closed_path=False, tolerance=1e-12) return xy diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index aff3bcc..70893c0 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -1,10 +1,11 @@ from typing import TypeVar, Generic -from collections.abc import Callable +from collections.abc import Callable, Iterator from functools import lru_cache Key = TypeVar('Key') Value = TypeVar('Value') +_MISSING = object() class DeferredDict(dict, Generic[Key, Value]): @@ -25,18 +26,73 @@ class DeferredDict(dict, Generic[Key, Value]): """ def __init__(self, *args, **kwargs) -> None: dict.__init__(self) - self.update(*args, **kwargs) + if args or kwargs: + self.update(*args, **kwargs) def __setitem__(self, key: Key, value: Callable[[], Value]) -> None: + """ + Set a value, which must be a callable that returns the actual value. + The result of the callable is cached after the first access. + """ + if not callable(value): + raise TypeError(f"DeferredDict value must be callable, got {type(value)}") cached_fn = lru_cache(maxsize=1)(value) dict.__setitem__(self, key, cached_fn) def __getitem__(self, key: Key) -> Value: return dict.__getitem__(self, key)() + def get(self, key: Key, default: Value | None = None) -> Value | None: + if key not in self: + return default + return self[key] + + def setdefault(self, key: Key, default: Value | Callable[[], Value] | None = None) -> Value | None: + if key in self: + return self[key] + if callable(default): + self[key] = default + else: + self.set_const(key, default) + return self[key] + + def items(self) -> Iterator[tuple[Key, Value]]: + for key in self.keys(): + yield key, self[key] + + def values(self) -> Iterator[Value]: + for key in self.keys(): + yield self[key] + def update(self, *args, **kwargs) -> None: + """ + Update the DeferredDict. If a value is callable, it is used as a generator. + Otherwise, it is wrapped as a constant. + """ for k, v in dict(*args, **kwargs).items(): - self[k] = v + if callable(v): + self[k] = v + else: + self.set_const(k, v) + + def pop(self, key: Key, default: Value | object = _MISSING) -> Value: + if key in self: + value = self[key] + dict.pop(self, key) + return value + if default is _MISSING: + raise KeyError(key) + return default # type: ignore[return-value] + + def popitem(self) -> tuple[Key, Value]: + key, value_func = dict.popitem(self) + return key, value_func() + + def copy(self) -> 'DeferredDict[Key, Value]': + new = DeferredDict[Key, Value]() + for key in self.keys(): + dict.__setitem__(new, key, dict.__getitem__(self, key)) + return new def __repr__(self) -> str: return '' @@ -46,4 +102,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda: value + self[key] = lambda v=value: v diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index ce6b006..248f408 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,6 +60,12 @@ def maxrects_bssf( degenerate = (min_more & max_less).any(axis=0) regions = regions[~degenerate] + if regions.shape[0] == 0: + if allow_rejects: + rejected_inds.add(rect_ind) + continue + raise MasqueError(f'Failed to find a suitable location for rectangle {rect_ind}') + ''' Place the rect ''' # Best short-side fit (bssf) to pick a region region_sizes = regions[:, 2:] - regions[:, :2] @@ -102,7 +108,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds @@ -187,7 +193,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = set(unsort_order[list(rejected_inds)]) + rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} return rect_locs, rejected_inds @@ -236,7 +242,9 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for pp, oo, loc in zip(patterns, offsets, locations, strict=True): + for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): + if ii in reject_inds: + continue pat.ref(pp, offset=oo + loc) rejects = [patterns[ii] for ii in reject_inds] diff --git a/masque/utils/ports2data.py b/masque/utils/ports2data.py index b67fa0a..44a0ec3 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,11 +57,9 @@ def data_to_ports( name: str | None = None, # Note: name optional, but arg order different from read(postprocess=) max_depth: int = 0, skip_subcells: bool = True, - # TODO missing ok? + visited: set[int] | None = None, ) -> Pattern: """ - # TODO fixup documentation in ports2data - # TODO move to utils.file? Examine `pattern` for labels specifying port info, and use that info to fill out its `ports` attribute. @@ -70,18 +68,30 @@ def data_to_ports( Args: layers: Search for labels on all the given layers. + library: Mapping from pattern names to patterns. pattern: Pattern object to scan for labels. - max_depth: Maximum hierarcy depth to search. Default 999_999. + name: Name of the pattern object. + max_depth: Maximum hierarcy depth to search. Default 0. Reduce this to 0 to avoid ever searching subcells. skip_subcells: If port labels are found at a given hierarcy level, do not continue searching at deeper levels. This allows subcells to contain their own port info without interfering with supercells' port data. Default True. + visited: Set of object IDs which have already been processed. Returns: The updated `pattern`. Port labels are not removed. """ + if visited is None: + visited = set() + + # Note: visited uses id(pattern) to detect cycles and avoid redundant processing. + # This may not catch identical patterns if they are loaded as separate object instances. + if id(pattern) in visited: + return pattern + visited.add(id(pattern)) + if pattern.ports: logger.warning(f'Pattern {name if name else pattern} already had ports, skipping data_to_ports') return pattern @@ -99,18 +109,20 @@ def data_to_ports( if target is None: continue pp = data_to_ports( - layers=layers, - library=library, - pattern=library[target], - name=target, - max_depth=max_depth - 1, - skip_subcells=skip_subcells, + layers = layers, + library = library, + pattern = library[target], + name = target, + max_depth = max_depth - 1, + skip_subcells = skip_subcells, + visited = visited, ) found_ports |= bool(pp.ports) if not found_ports: return pattern + imported_ports: dict[str, Port] = {} for target, refs in pattern.refs.items(): if target is None: continue @@ -122,9 +134,14 @@ def data_to_ports( if not aa.ports: break + if ref.repetition is not None: + logger.warning(f'Pattern {name if name else pattern} has repeated ref to {target!r}; ' + 'data_to_ports() is importing only the base instance ports') aa.apply_ref_transform(ref) - pattern.check_ports(other_names=aa.ports.keys()) - pattern.ports.update(aa.ports) + Pattern(ports={**pattern.ports, **imported_ports}).check_ports(other_names=aa.ports.keys()) + imported_ports.update(aa.ports) + + pattern.ports.update(imported_ports) return pattern @@ -160,13 +177,24 @@ def data_to_ports_flat( local_ports = {} for label in labels: - name, property_string = label.string.split(':') - properties = property_string.split(' ') - ptype = properties[0] - angle_deg = float(properties[1]) if len(ptype) else 0 + if ':' not in label.string: + logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (missing ":")') + continue + + name, property_string = label.string.split(':', 1) + properties = property_string.split() + ptype = properties[0] if len(properties) > 0 else 'unk' + if len(properties) > 1: + try: + angle_deg = float(properties[1]) + except ValueError: + logger.warning(f'Invalid port label "{label.string}" in pattern "{pstr}" (bad angle)') + continue + else: + angle_deg = numpy.inf xy = label.offset - angle = numpy.deg2rad(angle_deg) + angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') @@ -175,4 +203,3 @@ def data_to_ports_flat( pattern.ports.update(local_ports) return pattern - diff --git a/masque/utils/ptypes.py b/masque/utils/ptypes.py new file mode 100644 index 0000000..e5953b2 --- /dev/null +++ b/masque/utils/ptypes.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class PTypeMatch(Enum): + """Result of comparing two port types.""" + EXACT = 'exact' + WILDCARD = 'wildcard' + MISMATCH = 'mismatch' + + +def ptype_match(left: str | None, right: str | None) -> PTypeMatch: + """Compare ptypes, treating `None` and `"unk"` as wildcards.""" + if left in (None, 'unk') or right in (None, 'unk'): + return PTypeMatch.WILDCARD + if left == right: + return PTypeMatch.EXACT + return PTypeMatch.MISMATCH + + +def ptypes_compatible(left: str | None, right: str | None) -> bool: + """Return true when two ptypes may connect under normal compatibility rules.""" + return ptype_match(left, right) is not PTypeMatch.MISMATCH diff --git a/masque/utils/transform.py b/masque/utils/transform.py index dfb6492..7b39122 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -28,8 +28,9 @@ def rotation_matrix_2d(theta: float) -> NDArray[numpy.float64]: arr = numpy.array([[numpy.cos(theta), -numpy.sin(theta)], [numpy.sin(theta), +numpy.cos(theta)]]) - # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos - if numpy.isclose(theta % (pi / 2), 0): + # If this was a manhattan rotation, round to remove some inaccuracies in sin & cos + # cos(4*theta) is 1 for any multiple of pi/2. + if numpy.isclose(numpy.cos(4 * theta), 1, atol=1e-12): arr = numpy.round(arr) arr.flags.writeable = False @@ -49,7 +50,10 @@ def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: `angle_to_rotate` in radians """ - mirrored_x, mirrored_y = mirrored + if len(mirrored) != 2: + raise ValueError(f'mirrored must be a 2-item sequence, got length {len(mirrored)}') + + mirrored_x, mirrored_y = (bool(value) for value in mirrored) mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle @@ -86,37 +90,55 @@ def apply_transforms( Apply a set of transforms (`outer`) to a second set (`inner`). This is used to find the "absolute" transform for nested `Ref`s. - The two transforms should be of shape Ox4 and Ix4. - Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x)`. - The output will be of the form (O*I)x4 (if `tensor=False`) or OxIx4 (`tensor=True`). + The two transforms should be of shape Ox5 and Ix5. + Rows should be of the form `(x_offset, y_offset, rotation_ccw_rad, mirror_across_x, scale)`. + The output will be of the form (O*I)x5 (if `tensor=False`) or OxIx5 (`tensor=True`). Args: - outer: Transforms for the container refs. Shape Ox4. - inner: Transforms for the contained refs. Shape Ix4. - tensor: If `True`, an OxIx4 array is returned, with `result[oo, ii, :]` corresponding + outer: Transforms for the container refs. Shape Ox5. + inner: Transforms for the contained refs. Shape Ix5. + tensor: If `True`, an OxIx5 array is returned, with `result[oo, ii, :]` corresponding to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x4` to allow simple + If `False` (default), this is concatenated into `(O*I)x5` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx4 or (O*I)x4 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. + OxIx5 or (O*I)x5 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. """ outer = numpy.atleast_2d(outer).astype(float, copy=False) inner = numpy.atleast_2d(inner).astype(float, copy=False) + if outer.shape[1] == 4: + outer = numpy.pad(outer, ((0, 0), (0, 1)), constant_values=1.0) + if inner.shape[1] == 4: + inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) + + if outer.shape[0] == 0 or inner.shape[0] == 0: + if tensor: + return numpy.empty((outer.shape[0], inner.shape[0], 5)) + return numpy.empty((0, 5)) + # If mirrored, flip y's xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 + # Apply outer scale to inner offset + xy_mir *= outer[:, None, 4, None] + rot_mats = [rotation_matrix_2d(angle) for angle in outer[:, 2]] xy = numpy.einsum('ort,oit->oir', rot_mats, xy_mir) - tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 5)) tot[:, :, :2] = outer[:, None, :2] + xy - tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored - tot[:, :, 2] %= 2 * pi # clamp rot - tot[:, :, 3] %= 2 # clamp mirrored + + # If mirrored, flip inner rotation + mirrored_outer = outer[:, None, 3].astype(bool) + rotations = outer[:, None, 2] + numpy.where(mirrored_outer, -inner[None, :, 2], inner[None, :, 2]) + + tot[:, :, 2] = rotations % (2 * pi) + tot[:, :, 3] = (outer[:, None, 3] + inner[None, :, 3]) % 2 # net mirrored + tot[:, :, 4] = outer[:, None, 4] * inner[None, :, 4] # net scale if tensor: return tot diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 5fddd52..0eaf13e 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -5,7 +5,11 @@ import numpy from numpy.typing import NDArray, ArrayLike -def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: +def remove_duplicate_vertices( + vertices: ArrayLike, + closed_path: bool = True, + tolerance: float = 0.0, + ) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any consecutive duplicates. @@ -13,18 +17,36 @@ def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> vertices: `[[x0, y0], [x1, y1], ...]` closed_path: If True, `vertices` is interpreted as an implicity-closed path (i.e. the last vertex will be removed if it is the same as the first) + tolerance: Maximum coordinate-wise absolute difference for two vertices to + be considered duplicates. Default `0` requires exact equality. Returns: `vertices` with no consecutive duplicates. This may be a view into the original array. """ + if tolerance < 0: + raise ValueError(f'tolerance must be non-negative, got {tolerance}') + vertices = numpy.asarray(vertices) - duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) + if vertices.shape[0] <= 1: + return vertices + if tolerance == 0: + duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) + else: + duplicates = (numpy.abs(vertices - numpy.roll(vertices, -1, axis=0)) <= tolerance).all(axis=1) if not closed_path: duplicates[-1] = False - return vertices[~duplicates] + + result = vertices[~duplicates] + if result.shape[0] == 0 and vertices.shape[0] > 0: + return vertices[:1] + return result -def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: +def remove_colinear_vertices( + vertices: ArrayLike, + closed_path: bool = True, + preserve_uturns: bool = False, + ) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -33,24 +55,40 @@ def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> N vertices: Nx2 ndarray of vertices closed_path: If `True`, the vertices are assumed to represent an implicitly closed path. If `False`, the path is assumed to be open. Default `True`. + preserve_uturns: If `True`, colinear vertices that correspond to a 180 degree + turn (a "spike") are preserved. Default `False`. Returns: `vertices` with colinear (superflous) vertices removed. May be a view into the original array. """ - vertices = remove_duplicate_vertices(vertices) + vertices = remove_duplicate_vertices(vertices, closed_path=closed_path) # Check for dx0/dy0 == dx1/dy1 + dv = numpy.roll(vertices, -1, axis=0) - vertices + if not closed_path: + dv[-1] = 0 - dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] + # dxdy[i] is based on dv[i] and dv[i-1] + # slopes_equal[i] refers to vertex i + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 slopes_equal = (dxdy_diff / err_mult) < 1e-15 + + if preserve_uturns: + # Only merge if segments are in the same direction (avoid collapsing u-turns) + dot_prod = (dv * numpy.roll(dv, 1, axis=0)).sum(axis=1) + slopes_equal &= (dot_prod > 0) + if not closed_path: slopes_equal[[0, -1]] = False + if slopes_equal.all() and vertices.shape[0] > 0: + # All colinear, keep the first and last + return vertices[[0, vertices.shape[0] - 1]] + return vertices[~slopes_equal] @@ -58,7 +96,7 @@ def poly_contains_points( vertices: ArrayLike, points: ArrayLike, include_boundary: bool = True, - ) -> NDArray[numpy.int_]: + ) -> NDArray[numpy.bool_]: """ Tests whether the provided points are inside the implicitly closed polygon described by the provided list of vertices. @@ -77,13 +115,13 @@ def poly_contains_points( vertices = numpy.asarray(vertices, dtype=float) if points.size == 0: - return numpy.zeros(0, dtype=numpy.int8) + return numpy.zeros(0, dtype=bool) min_bounds = numpy.min(vertices, axis=0)[None, :] max_bounds = numpy.max(vertices, axis=0)[None, :] trivially_outside = ((points < min_bounds).any(axis=1) - | (points > max_bounds).any(axis=1)) # noqa: E128 + | (points > max_bounds).any(axis=1)) nontrivial = ~trivially_outside if trivially_outside.all(): @@ -101,10 +139,10 @@ def poly_contains_points( dv = numpy.roll(verts, -1, axis=0) - verts is_left = (dv[:, 0] * (ntpts[..., 1] - verts[:, 1]) # >0 if left of dv, <0 if right, 0 if on the line - - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128 + - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) winding_number = ((upward & (is_left > 0)).sum(axis=0) - - (downward & (is_left < 0)).sum(axis=0)) # noqa: E128 + - (downward & (is_left < 0)).sum(axis=0)) nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number if include_boundary: diff --git a/pyproject.toml b/pyproject.toml index 9a29065..ad03fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,3 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "masque" description = "Lithography mask library" @@ -46,17 +42,40 @@ dependencies = [ "klamath~=1.4", ] +[dependency-groups] +dev = [ + "masque[arrow]", + "masque[oasis]", + "masque[dxf]", + "masque[svg]", + "masque[visualize]", + "masque[text]", + "masque[manhattanize]", + "masque[manhattanize_slow]", + "masque[boolean]", + "matplotlib>=3.10.8", + "pytest>=9.0.2", + "ruff>=0.15.5", + "mypy>=1.19.1", + ] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" [tool.hatch.version] path = "masque/__init__.py" [project.optional-dependencies] +arrow = ["pyarrow", "cffi"] oasis = ["fatamorgana~=0.11"] -dxf = ["ezdxf~=1.0.2"] +dxf = ["ezdxf~=1.4"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhatanize_slow = ["float_raster"] +manhattanize = ["scikit-image"] +manhattanize_slow = ["float_raster"] +boolean = ["pyclipper"] [tool.ruff] @@ -87,10 +106,20 @@ lint.ignore = [ "PLR09", # Too many xxx "PLR2004", # magic number "PLC0414", # import x as x +# "PLC0415", # non-top-level import + "PLW1641", # missing __hash__ with total_ordering "TRY003", # Long exception message ] [tool.pytest.ini_options] addopts = "-rsXx" testpaths = ["masque"] +filterwarnings = [ + "ignore::DeprecationWarning:ezdxf.*", +] +[tool.mypy] +mypy_path = "stubs" +python_version = "3.11" +strict = false +check_untyped_defs = true diff --git a/stubs/ezdxf/__init__.pyi b/stubs/ezdxf/__init__.pyi new file mode 100644 index 0000000..f25475f --- /dev/null +++ b/stubs/ezdxf/__init__.pyi @@ -0,0 +1,13 @@ +from typing import Any, TextIO +from collections.abc import Iterable +from .layouts import Modelspace, BlockRecords + +class Drawing: + blocks: BlockRecords + @property + def layers(self) -> Iterable[Any]: ... + def modelspace(self) -> Modelspace: ... + def write(self, stream: TextIO) -> None: ... + +def new(version: str = ..., setup: bool = ...) -> Drawing: ... +def read(stream: TextIO) -> Drawing: ... diff --git a/stubs/ezdxf/entities.pyi b/stubs/ezdxf/entities.pyi new file mode 100644 index 0000000..2c6efa9 --- /dev/null +++ b/stubs/ezdxf/entities.pyi @@ -0,0 +1,18 @@ +from typing import Any +from collections.abc import Iterable, Sequence + +class DXFEntity: + def dxfattribs(self) -> dict[str, Any]: ... + def dxftype(self) -> str: ... + +class LWPolyline(DXFEntity): + def get_points(self) -> Iterable[tuple[float, ...]]: ... + +class Polyline(DXFEntity): + def points(self) -> Iterable[Any]: ... # has .xyz + +class Text(DXFEntity): + def get_placement(self) -> tuple[int, tuple[float, float, float]]: ... + def set_placement(self, p: Sequence[float], align: int = ...) -> Text: ... + +class Insert(DXFEntity): ... diff --git a/stubs/ezdxf/enums.pyi b/stubs/ezdxf/enums.pyi new file mode 100644 index 0000000..0dcf600 --- /dev/null +++ b/stubs/ezdxf/enums.pyi @@ -0,0 +1,4 @@ +from enum import IntEnum + +class TextEntityAlignment(IntEnum): + BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi new file mode 100644 index 0000000..c9d12ad --- /dev/null +++ b/stubs/ezdxf/layouts.pyi @@ -0,0 +1,21 @@ +from typing import Any +from collections.abc import Iterator, Sequence, Iterable +from .entities import DXFEntity + +class BaseLayout: + def __iter__(self) -> Iterator[DXFEntity]: ... + def add_lwpolyline(self, points: Iterable[Sequence[float]], dxfattribs: dict[str, Any] = ...) -> Any: ... + def add_text(self, text: str, dxfattribs: dict[str, Any] = ...) -> Any: ... + def add_blockref(self, name: str, insert: Any, dxfattribs: dict[str, Any] = ...) -> Any: ... + +class Modelspace(BaseLayout): + @property + def name(self) -> str: ... + +class BlockLayout(BaseLayout): + @property + def name(self) -> str: ... + +class BlockRecords: + def new(self, name: str) -> BlockLayout: ... + def __iter__(self) -> Iterator[BlockLayout]: ... diff --git a/stubs/pyclipper/__init__.pyi b/stubs/pyclipper/__init__.pyi new file mode 100644 index 0000000..08d77c8 --- /dev/null +++ b/stubs/pyclipper/__init__.pyi @@ -0,0 +1,46 @@ +from typing import Any +from collections.abc import Iterable, Sequence +import numpy +from numpy.typing import NDArray + + +# Basic types for Clipper integer coordinates +Path = Sequence[tuple[int, int]] +Paths = Sequence[Path] + +# Types for input/output floating point coordinates +FloatPoint = tuple[float, float] | NDArray[numpy.floating] +FloatPath = Sequence[FloatPoint] | NDArray[numpy.floating] +FloatPaths = Iterable[FloatPath] + +# Constants +PT_SUBJECT: int +PT_CLIP: int + +PT_UNION: int +PT_INTERSECTION: int +PT_DIFFERENCE: int +PT_XOR: int + +PFT_EVENODD: int +PFT_NONZERO: int +PFT_POSITIVE: int +PFT_NEGATIVE: int + +# Scaling functions +def scale_to_clipper(paths: FloatPaths, scale: float = ...) -> Paths: ... +def scale_from_clipper(paths: Path | Paths, scale: float = ...) -> Any: ... + +class PolyNode: + Contour: Path + Childs: list[PolyNode] + Parent: PolyNode + IsHole: bool + +class Pyclipper: + def __init__(self) -> None: ... + def AddPath(self, path: Path, poly_type: int, closed: bool) -> None: ... + def AddPaths(self, paths: Paths, poly_type: int, closed: bool) -> None: ... + def Execute(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> Paths: ... + def Execute2(self, clip_type: int, subj_fill_type: int = ..., clip_fill_type: int = ...) -> PolyNode: ... + def Clear(self) -> None: ...