diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 2d94de9..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,426 +0,0 @@ -# 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 71c37f0..62b13bb 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 `Pather.place()` or `Pather.plug()`, which automatically creates + * Another way is use `Builder.place()` or `Builder.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 `Pather` and `place()`/`plug()` the `lib <<` portion can be implicit: -my_builder = Pather(library=lib, ...) +# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit: +my_builder = Builder(library=lib, ...) ... my_builder.place(make_tree(...)) ``` @@ -277,6 +277,12 @@ 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 57b170c..a51a27e 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() -> None: +def main(): pat = Pattern() layer = (0, 0) pat.shapes[layer].extend([ diff --git a/examples/generate_gds_perf.py b/examples/generate_gds_perf.py deleted file mode 100644 index 7eaebbb..0000000 --- a/examples/generate_gds_perf.py +++ /dev/null @@ -1,5 +0,0 @@ -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 60e0a3e..de51d6a 100644 --- a/examples/nested_poly_test.py +++ b/examples/nested_poly_test.py @@ -1,5 +1,7 @@ +import numpy from pyclipper import ( - Pyclipper, PT_SUBJECT, CT_UNION, PFT_NONZERO, + Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, + scale_to_clipper, scale_from_clipper, ) p = Pyclipper() p.AddPaths([ @@ -10,8 +12,8 @@ p.AddPaths([ ], PT_SUBJECT, closed=True) #p.Execute2? #p.Execute? -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) +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 = Pyclipper() diff --git a/examples/profile_gdsii_readers.py b/examples/profile_gdsii_readers.py deleted file mode 100644 index 0eb05a5..0000000 --- a/examples/profile_gdsii_readers.py +++ /dev/null @@ -1,131 +0,0 @@ -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 d25fb55..f82575d 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -11,7 +11,7 @@ from masque.file import gdsii, dxf, oasis -def main() -> None: +def main(): lib = Library() cell_name = 'ellip_grating' diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index aab8ad2..7210a93 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,12 +1,6 @@ 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 -------- @@ -14,30 +8,24 @@ Contents * Draw basic geometry * Export to GDS - [devices](devices.py) - * Build hierarchical photonic-crystal example devices * Reference other patterns * Add ports to a pattern - * Use `Pather` to snap ports together into a circuit + * Snap ports together to build a circuit * Check for dangling references - [library](library.py) - * 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 + * Create a `LazyLibrary`, which loads / generates patterns only when they are first used * 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 - * 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), + * 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), 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 -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. +Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. Running @@ -49,6 +37,3 @@ 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 d8f7e1e..87baaf0 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,9 +1,12 @@ +from collections.abc import Sequence import numpy from numpy import pi -from masque import layer_t, Pattern, Circle, Arc, Ref -from masque.repetition import Grid +from masque import ( + layer_t, Pattern, Label, Port, + Circle, Arc, Polygon, + ) import masque.file.gdsii @@ -36,45 +39,6 @@ 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), @@ -96,7 +60,9 @@ def triangle( ]) * radius pat = Pattern() - pat.polygon(layer, vertices=vertices) + pat.shapes[layer].extend([ + Polygon(offset=(0, 0), vertices=vertices), + ]) return pat @@ -145,13 +111,9 @@ 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 955e786..6b9cfa2 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,19 +1,11 @@ -""" -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, Pather, Port, Polygon, - Library, + layer_t, Pattern, Ref, Label, Builder, Port, Polygon, + Library, ILibraryView, ) from masque.utils import ports2data from masque.file.gdsii import writefile, check_valid_names @@ -72,9 +64,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-directed beam + perturbed_radius: radius of holes perturbed to form an upwards-driected 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. @@ -87,15 +79,14 @@ def perturbed_l3( shifts_a=shifts_a, shifts_r=shifts_r) - # Build the cavity by instancing the supplied `hole` pattern many times. - # Using references keeps the pattern compact even though it contains many holes. + # Build L3 cavity, using references to the provided hole pattern 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 based on the referenced hole extents. + # Add rectangular undercut aids min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) trench_dx = max_xy[0] - min_xy[0] @@ -104,7 +95,7 @@ def perturbed_l3( Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), ] - # Define the interface in Masque terms: two ports at the left/right extents. + # Ports are at outer extents of the device (with y=0) extent = lattice_constant * xy_size[0] pat.ports = dict( input=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -134,17 +125,17 @@ def waveguide( Returns: `Pattern` object representing the waveguide. """ - # Generate the normalized lattice locations for the line defect. + # Generate hole locations xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) - # Build the pattern by placing repeated references to the same hole cell. + # Build the pattern pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the device interface as two ports at the outer edges. + # Ports are at outer edges, with y=0 extent = lattice_constant * length / 2 pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -173,17 +164,17 @@ def bend( `Pattern` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ - # Generate the normalized lattice locations for the bend. + # Generate hole locations xy = pcgen.wgbend(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. - pat = Pattern() + # Build the pattern + pat= Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the bend interface as two ports. + # Figure out port locations. extent = lattice_constant * mirror_periods pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -212,17 +203,17 @@ def y_splitter( `Pattern` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ - # Generate the normalized lattice locations for the splitter. + # Generate hole locations xy = pcgen.y_splitter(num_mirror=mirror_periods) - # Build the pattern by instancing the shared hole cell. + # Build pattern pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Publish the splitter interface as one input and two outputs. + # Determine port locations extent = lattice_constant * mirror_periods pat.ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -236,13 +227,13 @@ def y_splitter( def main(interactive: bool = True) -> None: - # First make a couple of reusable primitive cells. + # Generate some basic hole patterns shape_lib = { 'smile': basic_shapes.smile(RADIUS), 'hole': basic_shapes.hole(RADIUS), } - # Then build a small library of higher-level devices from those primitives. + # Build some devices a = LATTICE_CONSTANT devices = {} @@ -254,23 +245,22 @@ 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 the device mapping into a `Library`. - # That gives us convenience helpers for hierarchy inspection and abstract views. + # Turn our dict of devices into a Library. + # This provides some convenience functions in the future! lib = Library(devices) # # Build a circuit # - # Create a `Pather`, and register the resulting top cell as "my_circuit". - circ = Pather(library=lib, name='my_circuit') + # Create a `Builder`, and add the circuit to our library as "my_circuit". + circ = Builder(library=lib, name='my_circuit') - # Start by placing a waveguide and renaming its ports to match the circuit-level - # names we want to use while assembling the design. + # Start by placing a waveguide. Call its ports "in" and "signal". circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': '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`. + # 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". circ.plug('wg10', {'signal': 'left'}) # We could have done the following instead: @@ -278,8 +268,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 `Pather` removes some repeated `lib.abstract(...)` boilerplate and keeps - # the assembly code focused on port-level intent. + # 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. # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the @@ -291,10 +281,13 @@ def main(interactive: bool = True) -> None: circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # 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. + # 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. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) @@ -303,26 +296,29 @@ def main(interactive: bool = True) -> None: circ.plug('l3cav', {'signal1': 'input'}) circ.plug('wg10', {'signal1': 'left'}) - # `signal2` gets a single waveguide of equivalent overall length. + # "signal2" just gets a single of equivalent length circ.plug('wg28', {'signal2': 'left'}) - # Now bend both branches back towards each other. + # Now we bend both waveguides 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 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. + # 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). circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". circ.plug('wg10', {'signal_out': 'left'}) - # 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. + # 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. ports_to_data(circ.pattern) # Check if we forgot to include any patterns... ooops! @@ -334,12 +330,12 @@ def main(interactive: bool = True) -> None: lib.add(shape_lib) assert not lib.dangling_refs() - # We can visualize the design directly, though opening the written GDS is often easier. + # We can visualize the design. Usually it's easier to just view the GDS. if interactive: print('Visualizing... this step may be slow') circ.pattern.visualize(lib) - # Write out only the subtree reachable from our top cell. + #Write out to GDS, only keeping patterns referenced by our circuit (including itself) 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 d7a2615..eab8a12 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,115 +1,135 @@ -""" -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 BuildLibrary, Pather, Pattern, cell -from masque.file.gdsii import writefile -from masque.file.gdsii.lazy import readfile +from masque import Pattern, Builder, LazyLibrary +from masque.file.gdsii import writefile, load_libraryfile +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: - builder = BuildLibrary() - cells = builder.cells + # Define a `LazyLibrary`, which provides lazy evaluation for generating + # patterns and lazy-loading of GDS contents. + lib = LazyLibrary() # # Load some devices from a GDS file # - # 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)) + # Scan circuit.gds and prepare to lazy-load its contents + gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) - print('Registered imported cells:\n' + pformat(list(gds_lib.keys()))) + # 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()))) # - # Register some new devices, this time from python code rather than GDS. + # Add some new devices to the library, this time from python code rather than GDS # - cells.triangle = basic_shapes.triangle(devices.RADIUS) + lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS) opts: dict[str, Any] = dict( - lattice_constant=devices.LATTICE_CONSTANT, - hole='triangle', - ) + lattice_constant = devices.LATTICE_CONSTANT, + hole = 'triangle', + ) - 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()))) + # 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) # - # Build the declaration set into a normal library. + # Build a mixed waveguide with an L3 cavity in the middle # - built, report = builder.build() - print('Built library contains:\n' + pformat(list(built.keys()))) - print('Build dependency graph:\n' + pformat(report.dependency_graph)) + # 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 + # - # Continue designing against the built library. + # Build a device that could plug into our mixed_wg_cav and joins the two ports # - # 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 + # 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 # - # Write all devices into a GDS file. + # Write all devices into a GDS file # print('Writing library to file...') - writefile(built, 'library.gds', **GDS_OPTS) + writefile(lib, '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 8b138a8..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -1,18 +1,11 @@ """ -Manual wire routing tutorial: Pather and primitive offers +Manual wire routing tutorial: Pather and BasicTool """ -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any, Literal - -import numpy +from collections.abc import Callable from numpy import pi -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 import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers +from masque.builder.tools import BasicTool, PathTool from masque.file.gdsii import writefile -from masque.library import ILibrary, SINGLE_USE_PREFIX from basic_shapes import GDS_OPTS @@ -114,327 +107,87 @@ def map_layer(layer: layer_t) -> layer_t: 'M2': (20, 0), 'V1': (30, 0), } - if isinstance(layer, str): - return layer_mapping.get(layer, layer) - return layer + return layer_mapping.get(layer, layer) -@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 - """ +# +# 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: # 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. + # 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) # - # 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. + # 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. # - via = library.abstract('v1_via') - via_transitions = ( - WireTransitionSpec(via, 'top', 'bottom'), - WireTransitionSpec(via, 'bottom', 'top'), + 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 ) - M1_tool = PrimitiveWireTool( - layer = 'M1', - width = M1_WIDTH, - ptype = 'm1wire', - bend = library.abstract('m1_bend'), - transitions = via_transitions, + 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 ) - 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! @@ -450,25 +203,27 @@ 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.cw('VCC', 6_000) + pather.path('VCC', ccw=False, length=6_000) - # Now path VCC to x=0. This time, don't include any bend. + # Now path VCC to x=0. This time, don't include any bend (ccw=None). # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.straight('VCC', x=0) + pather.path_to('VCC', ccw=None, x=0) # Path GND forward by 5um, turning clockwise 90 degrees. - pather.cw('GND', 5_000) + # 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) # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.straight('GND', x=pather['VCC'].offset[0]) + pather.path_to('GND', None, x=pather['VCC'].offset[0]) # Now, start using M1_tool for GND. - # Since we have defined an M2-to-M1 transition for Pather, we don't need to place one ourselves. + # Since we have defined an M2-to-M1 transition for BasicPather, 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 route GND (the `pather.ccw()` call below). - pather.retool(M1_tool, keys='GND') + # the next time we draw a path on GND (the pather.mpath() statement 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. @@ -476,7 +231,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.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) + pather.mpath(['GND', 'VCC'], ccw=True, 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. @@ -486,37 +241,38 @@ 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.ccw(['GND', 'VCC'], emax=50_000, spacing=1_200) + pather.mpath(['GND', 'VCC'], ccw=True, 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 call - # that applies to VCC, and for the second call, that applies to GND; the relative lengths of the + # 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 # segments depend on their starting positions and their ordering within the bundle. - pather.cw(['GND', 'VCC'], emin=1_000, spacing=1_200) - pather.cw(['GND', 'VCC'], emin=2_000, spacing=4_500) + pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) + pather.mpath(['GND', 'VCC'], ccw=False, 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. + # explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default. pather.retool(M2_tool) # Now path both ports to x=-28_000. - # With ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is + # 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 # equivalent. - pather.straight(['GND', 'VCC'], xmin=-28_000) + pather.mpath(['GND', 'VCC'], None, 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.straight('VCC', x=-50_000, out_ptype='m1wire') + pather.path_to('VCC', None, -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.straight('GND', x=-40_000) - pather.straight('GND', x=-50_000) + with pather.toolctx(M2_tool, keys=['GND']): + pather.path_to('GND', None, -40_000) + pather.path_to('GND', None, -50_000) # Save the pather's pattern into our library - library['Pather_and_PrimitiveOffers'] = pather.pattern + library['Pather_and_BasicTool'] = 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 5c5c31b..023079c 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 collections.abc import Sequence +from collection.abc import Sequence import numpy from numpy.typing import ArrayLike, NDArray @@ -50,7 +50,7 @@ def triangular_lattice( elif origin == 'corner': pass else: - raise ValueError(f'Invalid value for `origin`: {origin}') + raise Exception(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 ValueError('defect_length must be odd!') - pp = triangular_lattice([2 * dd + 1 for dd in mirror_dims]) + raise Exception('defect_length must be odd!') + p = triangular_lattice([2 * d + 1 for d in mirror_dims]) half_length = numpy.floor(defect_length / 2) hole_nums = numpy.arange(-half_length, half_length + 1) - holes_to_keep = numpy.isin(pp[:, 0], hole_nums, invert=True) - return pp[numpy.logical_or(holes_to_keep, pp[:, 1] != 0), :] + holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) + return p[numpy.logical_or(holes_to_keep, p[:, 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 xy in perturbed_holes: - which = (numpy.fabs(xyr[:, :2]) == xy).all(axis=1) - xyr[which, 2] = perturbed_radius + for row in xyr: + if numpy.fabs(row) in perturbed_holes: + row[2] = perturbed_radius return xyr diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py deleted file mode 100644 index 8dabf82..0000000 --- a/examples/tutorial/port_pather.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -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 d048d65..cb002f3 100644 --- a/examples/tutorial/renderpather.py +++ b/examples/tutorial/renderpather.py @@ -1,8 +1,9 @@ """ -Manual wire routing tutorial: deferred Pather and PathTool +Manual wire routing tutorial: RenderPather an PathTool """ -from masque import Pather, Library -from masque.builder import 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.file.gdsii import writefile from basic_shapes import GDS_OPTS @@ -11,9 +12,9 @@ from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via def main() -> None: # - # 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 + # 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 # a single `Path` shape. # # We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py` @@ -24,68 +25,66 @@ 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 `AutoTool`. It only generates one type of shape + # `PathTool` is more limited than `BasicTool`. 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 = Pather(tools=M2_ptool, library=library, render='deferred') + rpather = RenderPather(tools=M2_ptool, library=library) - # As in the pather tutorial, we make some pads and labels... + # As in the pather tutorial, we make soem 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.cw('VCC', 6_000) - rpather.straight('VCC', x=0) - rpather.cw('GND', 5_000) - rpather.straight('GND', x=rpather.pattern['VCC'].x) + 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]) # `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.ccw(['GND', 'VCC'], xmax=-10_000, spacing=5_000) + rpather.retool(M1_ptool, keys=['GND']) + rpather.mpath(['GND', 'VCC'], ccw=True, 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.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) + 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) # And again when VCC goes back up to M2. rpather.plug('v1_via', {'VCC': 'bottom'}) rpather.retool(M2_ptool) - rpather.straight(['GND', 'VCC'], xmin=-28_000) + rpather.mpath(['GND', 'VCC'], None, 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. - 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) + 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) rpather.plug('v1_via', {'VCC': 'top'}) - # Render the path we defined rpather.render() - library['Deferred_Pather_and_PathTool'] = rpather.pattern + library['RenderPather_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 b5dddde..4ad7e69 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -42,7 +42,6 @@ from .error import ( from .shapes import ( Shape as Shape, Polygon as Polygon, - RectCollection as RectCollection, Path as Path, Circle as Circle, Arc as Arc, @@ -56,23 +55,16 @@ 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, @@ -80,9 +72,12 @@ 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 d23d7c7..7135eba 100644 --- a/masque/abstract.py +++ b/masque/abstract.py @@ -8,13 +8,16 @@ from numpy.typing import ArrayLike from .ref import Ref from .ports import PortList, Port from .utils import rotation_matrix_2d -from .traits import Mirrorable + +#if TYPE_CHECKING: +# from .builder import Builder, Tool +# from .library import ILibrary logger = logging.getLogger(__name__) -class Abstract(PortList, Mirrorable): +class Abstract(PortList): """ An `Abstract` is a container for a name and associated ports. @@ -128,18 +131,50 @@ class Abstract(PortList, Mirrorable): port.rotate(rotation) return self - def mirror(self, axis: int = 0) -> Self: + def mirror_port_offsets(self, across_axis: int = 0) -> Self: """ - Mirror the Abstract across an axis through its origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + 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.flip_across(axis=axis) + 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) return self def apply_ref_transform(self, ref: Ref) -> Self: @@ -157,8 +192,6 @@ class Abstract(PortList, Mirrorable): 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 @@ -176,8 +209,6 @@ class Abstract(PortList, Mirrorable): # 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 5bd3914..2fd00a4 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,58 +1,12 @@ -""" -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 .builder import Builder as Builder +from .pather import Pather as Pather +from .renderpather import RenderPather as RenderPather +from .pather_mixin import 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 new file mode 100644 index 0000000..1b534b5 --- /dev/null +++ b/masque/builder/builder.py @@ -0,0 +1,448 @@ +""" +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 deleted file mode 100644 index e7cfb16..0000000 --- a/masque/builder/logging.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 cbf87f4..9af473d 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -1,174 +1,124 @@ """ -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. +Manual wire/waveguide routing (`Pather`) """ -from typing import Self, Any, Literal, overload -from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence +from typing import Self +from collections.abc import Sequence, Mapping, MutableMapping 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, TreeView, SINGLE_USE_PREFIX -from ..error import BuildError, PortError +from ..library import ILibrary +from ..error import BuildError from ..ports import PortList, Port -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 +from ..utils import SupportsBool +from .tools import Tool +from .pather_mixin import PatherMixin +from .builder import Builder logger = logging.getLogger(__name__) -RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore'] -RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore') -class Pather(PortList): +class Pather(Builder, PatherMixin): """ - 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. + 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. - The `Pather` holds context in the form of a `Library`, its underlying - pattern, and a set of `Tool`s for generating routing segments. + `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. - 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 default routing tool for all ports is set to `my_tool`. + - `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, name='mypat')` makes an empty pattern and adds it to - `library` under the name `'mypat'`. Examples: Adding to a pattern ============================= - - `pather.plug(subdevice, {'A': 'C'})` instantiates `subdevice` and - connects port 'A' of the current pattern to port 'C' of `subdevice`. + - `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.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. + 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. """ - __slots__ = ( - 'pattern', 'library', 'tools', 'planner', '_paths', - '_dead', '_logger', '_render_policy', '_render_append', '_context_depth' - ) - - pattern: Pattern - """ Layout of this device """ + __slots__ = ('tools',) library: ILibrary - """ Library from which patterns should be referenced """ + """ + Library from which existing patterns should be referenced, and to which + new ones should be added + """ tools: dict[str | None, Tool] """ - Tool objects used to dynamically generate new routing segments. - A key of `None` indicates the default `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`. """ - 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, @@ -177,52 +127,40 @@ class Pather(PortList): 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 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. + 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. 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 - self.pattern = pattern if pattern is not None else Pattern() - self.planner = RoutingPlanner() if planner is None else planner - self._paths = defaultdict(list) + 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): @@ -230,690 +168,29 @@ class Pather(PortList): else: self.tools = dict(tools) - 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, + @classmethod + def from_builder( + cls: type['Pather'], + builder: Builder, *, - out_rot: float | None = None, - out_ptype: str | None = None, - ) -> None: + tools: Tool | MutableMapping[str | None, Tool] | None = None, + ) -> 'Pather': """ - Move a dead Pather port without generating geometry. + Construct a `Pather` by adding tools to a `Builder`. - Dead fallback is only for debugging or dry layout flow. Fatal route - errors bypass it because they indicate an invalid Tool offer contract. + Args: + builder: Builder to turn into a Pather + tools: Tools for the `Pather` + + Returns: + A new Pather object, using `builder.library` and `builder.pattern`. """ - 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}) + new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) + return new - # - # 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, + cls: type['Pather'], source: PortList | Mapping[str, Port] | str, *, library: ILibrary | None = None, @@ -922,270 +199,177 @@ class Pather(PortList): out_prefix: str = '', port_map: dict[str, str] | Sequence[str] | None = None, name: str | None = None, - **kwargs: Any, - ) -> Self: + ) -> '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. + """ if library is None: if hasattr(source, 'library') and isinstance(source.library, ILibrary): library = source.library else: - raise BuildError('No library provided') + 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) - return cls(library=library, pattern=pat, name=name, tools=tools, **kwargs) + new = Pather(library=library, pattern=pat, name=name, tools=tools) + return new - 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 + 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]} else: - for k in keys: - self.tools[k] = tool + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) return self - @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} + 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 try: - 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 + 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]]) - def flatten(self) -> Self: - self.pattern.flatten(self.library) - return self + kwargs_plug = kwargs | {'plug_into': plug_into} + self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) + self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) + return self - 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.') + tname = self.library << tree + if plug_into is not None: + output = {plug_into: tool_port_names[1]} else: - self.pather.plugged({self.ports[0]: other_port}) + output = {} + self.plug(tname, {portspec: tool_port_names[0], **output}) 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 new file mode 100644 index 0000000..1655329 --- /dev/null +++ b/masque/builder/pather_mixin.py @@ -0,0 +1,677 @@ +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 deleted file mode 100644 index f2df9e6..0000000 --- a/masque/builder/planner/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -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 deleted file mode 100644 index 43224a0..0000000 --- a/masque/builder/planner/bounds.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -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 deleted file mode 100644 index 58028df..0000000 --- a/masque/builder/planner/interface.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -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 deleted file mode 100644 index 913758e..0000000 --- a/masque/builder/planner/planner.py +++ /dev/null @@ -1,1399 +0,0 @@ -""" -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 new file mode 100644 index 0000000..7f18e77 --- /dev/null +++ b/masque/builder/renderpather.py @@ -0,0 +1,646 @@ +""" +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 40097a0..6bd7547 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,585 +1,44 @@ """ -Routing Tool contracts and built-in Tool implementations. +Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) -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. +# TODO document all tools """ from typing import Literal, Any, Self -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 +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 import numpy from numpy.typing import NDArray from numpy import pi -from ..utils import ( - layer_t, - ptypes_compatible as ptypes_compatible, - rotation_matrix_2d, - ) +from ..utils import SupportsBool, rotation_matrix_2d, layer_t 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: """ - 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. + Representation of a single saved operation, used by `RenderPather` and passed + to `Tool.render()` when `RenderPather.render()` is called. """ opcode: Literal['L', 'S', 'U', 'P'] """ What operation is being performed. - L: straight or single-bend primitive - S: S-like primitive - U: U-like primitive + L: planL (straight, optionally with a single bend) + S: planS (s-bend) + U: planU (u-bend) P: plug """ tool: 'Tool | None' - """ Tool that produced this step, or `None` for `opcode='P'`. """ + """ The current tool. May be `None` if `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""" @@ -588,97 +47,371 @@ 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 - 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): +class Tool: """ Interface for path (e.g. wire or waveguide) generation. - 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. + 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). """ - @abstractmethod - def primitive_offers( + def path( self, - kind: PrimitiveKind, + 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, *, in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> tuple[PrimitiveOffer, ...]: + ) -> tuple[Port, Any]: """ - Return local primitive offers available for the requested route role. + Plan a wire or waveguide that travels exactly `length` distance along the axis + of its input port. - 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. + Used by `RenderPather`. - `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 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`. - `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. + 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. """ - raise NotImplementedError + 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)}') + + def render( + self, + batch: Sequence[RenderStep], + *, + port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) + **kwargs, # noqa: ARG002 (unused) + ) -> ILibrary: + """ + Render the provided `batch` of `RenderStep`s into geometry, returning a tree + (a Library with a single topcell). + + Args: + batch: A sequence of `RenderStep` objects containing the ports and data + provided by this tool's `planL`/`planS`/`planU` functions. + 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)}') + + +abstract_tuple_t = tuple[Abstract, str, str] + + +@dataclass +class SimpleTool(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. + """ + 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 + + def planL( + 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 + + 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 + ) + + bend_angle = angle_out - angle_in + + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 + else: + bend_dxy = numpy.zeros(2) + bend_angle = pi + + 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]:,})' + ) + + 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( + 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, + *, + 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 - @abstractmethod def render( self, batch: Sequence[RenderStep], @@ -686,692 +419,436 @@ class Tool(ABC): port_names: tuple[str, str] = ('A', 'B'), **kwargs, ) -> ILibrary: - """ - 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. + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') + pat.add_port_pair(names=(port_names[0], port_names[1])) - Args: - 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. - """ - raise NotImplementedError - - -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 + 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): +class AutoTool(Tool, metaclass=ABCMeta): """ - 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. + 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 GeneratedData: - """ Deferred render data for one generated primitive offer. """ - fn: GeneratedPrimitiveFn - port_name: str - parameter: float - mirrored: bool = False + 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 ReusableData: - """ Deferred render data for one reusable abstract primitive offer. """ + class SBend: + """ Description of an s-bend generator """ + ptype: str + + fn: Callable[[float], Pattern] | Callable[[float], Library] + """ + 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. + """ + + in_port_name: str + out_port_name: str + jog_range: tuple[float, float] = (0, numpy.inf) + + @dataclass(frozen=True, slots=True) + class Bend: + """ Description of a pre-rendered bend """ abstract: Abstract - port_name: str - mirrored: bool = False + 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? - bbox_library: Mapping[str, Pattern] | None = None - """ Optional source library used to resolve reusable refs during `bbox_at()` measurement. """ + @property + def in_port(self) -> Port: + return self.abstract.ports[self.in_port_name] - _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, - ) + @property + def out_port(self) -> Port: + return self.abstract.ports[self.out_port_name] - @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 + @dataclass(frozen=True, slots=True) + class Transition: + """ Description of a pre-rendered transition """ + abstract: Abstract + their_port_name: str + our_port_name: str - @staticmethod - def _generated_pattern(fn: GeneratedPrimitiveFn, parameter: float) -> Pattern: - generated = fn(parameter) - return generated if isinstance(generated, Pattern) else generated.top_pattern() + @property + def our_port(self) -> Port: + return self.abstract.ports[self.our_port_name] - @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 + @property + def their_port(self) -> Port: + return self.abstract.ports[self.their_port_name] - @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 + def reversed(self) -> Self: + return type(self)(self.abstract, self.our_port_name, self.their_port_name) - @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 + @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' - def _infer_straight_metadata( - self, - 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 + @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' - 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') + straights: list[Straight] + """ List of straight-generators to choose from, in order of priority """ - 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 + bends: list[Bend] + """ List of bends to choose from, in order of priority """ - 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 + sbends: list[SBend] + """ List of S-bend generators to choose from, in order of priority """ - 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') + transitions: dict[tuple[str, str], Transition] + """ `{(external_ptype, internal_ptype): Transition, ...}` """ - def add_straight( - self, - fn: GeneratedPrimitiveFn, - ptype: str | None = None, - in_port_name: str | None = None, - *, - length_range: tuple[float, float] = (0, numpy.inf), - ) -> Self: - """ - Register a generated straight primitive. + default_out_ptype: str + """ Default value for out_ptype """ - 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 - - for jog_domain in self._signed_jog_domains(jog_range): - priority_bias = len(self._s_offers) * BUILTIN_PRIORITY_STEP - - 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, - abstract: Abstract, - in_port_name: str, - out_port_name: str, - *, - mirror: bool = True, - ) -> Self: - """ - 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') - - length = float(dxy[0]) - jog = float(dxy[1]) - out_ptype = out_port.ptype - - 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) - - 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, - )) - - add_offer(jog, mirrored=False) - if mirror and not numpy.isclose(jog, 0): - add_offer(-jog, mirrored=True) - return self - - 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. - - 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 - - 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) + 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()) return self @staticmethod - 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) + 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) assert bend_angle is not None - if source_clockwise != target_clockwise: + if bool(ccw): bend_dxy[1] *= -1 bend_angle *= -1 return bend_dxy, bend_angle @staticmethod - 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): + def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: + if numpy.isclose(jog, 0): return numpy.zeros(2) - sbend_pat_or_tree = fn(jog_magnitude) + sbend_pat_or_tree = sbend.fn(abs(jog)) sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name]) + dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) 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) - - if self.bbox_library is None: - library: Mapping[str, Pattern] = tree - else: - library = ChainMap(dict(tree), self.bbox_library) - - 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, - 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 _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 @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 () + 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 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( + def planL( self, - kind: PrimitiveKind, + ccw: SupportsBool | None, + length: float, *, in_ptype: str | None = None, out_ptype: str | None = None, **kwargs, - ) -> 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, - ) + ) -> tuple[Port, LData]: - if kind == 'bend': - return tuple(self._bend_offers[int(bool(kwargs['ccw']))]) + success = False + for straight in self.straights: + for bend in self.bends: + bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - 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, - ) + 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) - if kind == 'u': - return tuple(self._u_offers) - raise BuildError(f'Unrecognized primitive offer kind {kind!r}') + 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) - def _render_generated( - self, - data: GeneratedData, - tree: ILibrary, - port_names: tuple[str, str], - gen_kwargs: dict[str, Any], - ) -> ILibrary: - if numpy.isclose(data.parameter, 0): - return tree + 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) - pat = tree.top_pattern() - 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) + 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: - top = generated.top() - generated.flatten(top, dangling_ok=True) - pat.plug(generated[top], pmap, append=True, mirrored=data.mirrored) - return tree + # 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}' + ) - def _render_reusable( + 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 + + 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 + + def _renderL( self, - data: ReusableData, + 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() - pat.plug(data.abstract, {port_names[1]: data.port_name}, mirrored=data.mirrored) + 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 _render_data( + def path( self, - data: Any, + 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, 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__}') + """ + 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, **(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}) + return tree + + 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: + _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) + return tree def render( self, @@ -1381,156 +858,114 @@ class AutoTool(Tool): **kwargs, ) -> ILibrary: - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + 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 - self._render_data(step.data, tree, port_names, kwargs) + 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) return tree @dataclass -class PathTool(Tool): +class PathTool(Tool, metaclass=ABCMeta): """ - Tool that renders routes directly as `Pattern.path()` geometry. + A tool which draws `Path` geometry elements. - `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. + If `planL` / `render` are used, the `Path` elements can cover >2 vertices; + with `path` only individual rectangles will be drawn. """ layer: layer_t - """ Layer to draw generated path geometry on. """ + """ Layer to draw on """ width: float - """ Width of generated path geometry. """ + """ `Path` width """ ptype: str = 'unk' - """ Port type for generated input and output ports. """ + """ ptype for any ports in patterns generated by this tool """ - def _bend_radius(self) -> float: - return self.width / 2 + #@dataclass(frozen=True, slots=True) + #class LData: + # dxy: NDArray[numpy.float64] - 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 __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_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( + def path( self, - kind: PrimitiveKind, + ccw: SupportsBool | None, + length: float, *, in_ptype: str | None = None, out_ptype: str | None = None, - **kwargs, - ) -> tuple[PrimitiveOffer, ...]: - if kind == 'u': - return () + 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, + ) - if not ptypes_compatible(out_ptype, self.ptype): + 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: raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') - ptype = self.ptype - if kind == 'straight': - def straight_data(length: float) -> NDArray[numpy.float64]: - return numpy.array((length, 0.0)) + if ccw is not None: + bend_dxy = numpy.array([1, -1]) * self.width / 2 + bend_angle = pi / 2 - def endpoint_straight(length: float) -> Port: - data = straight_data(length) - return Port(data, rotation=pi, ptype=ptype) + if bool(ccw): + bend_dxy[1] *= -1 + bend_angle *= -1 + else: + bend_dxy = numpy.zeros(2) + bend_angle = pi - 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]))) + straight_length = length - bend_dxy[0] + bend_run = bend_dxy[1] - 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}') + 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 def render( self, @@ -1540,43 +975,29 @@ class PathTool(Tool): **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: - # 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: + path_vertices = [batch[0].start_port.offset] + for step in 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': - 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])) + 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) else: raise BuildError(f'Unrecognized opcode "{step.opcode}"') - for vertex in local_vertices[1:]: - path_vertices.append(step.start_port.offset + transform @ vertex) + 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) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') + tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { - port_names[0]: local_batch[0].start_port.copy().rotate(pi), - port_names[1]: local_batch[-1].end_port.copy().rotate(pi), + port_names[0]: batch[0].start_port.copy().rotate(pi), + port_names[1]: batch[-1].end_port.copy().rotate(pi), } return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index ca36fff..3109f46 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_type: Method used for determining the travel distance; see diagram above. + bound_method: 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 associated with `bound_type`, see above. + bound_value: 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.allclose(spacing, 0): + if spacing is not None and not numpy.isclose(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 None: + if set_rotation is not None: raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) @@ -132,17 +132,8 @@ 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) - 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}' - ) + steps[1:] = spacing 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 237b1d8..0f6dd32 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, Solid, Trace +from ezdxf.entities import LWPolyline, Polyline, Text, Insert from .utils import is_gzipped, tmpfile from .. import Pattern, Ref, PatternError, Label @@ -55,7 +55,8 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - Shape repetitions are expanded into individual DXF entities. + DXF does not support shape repetition (only block repeptition). Please call + library.wrap_repeated_shapes() before writing to file. Other functions you may want to call: - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names @@ -192,37 +193,8 @@ 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.is_any_layout: - continue - if bb.name.startswith('_') and bb.name not in referenced: + if bb.name == '*Model_Space': continue name, pat = _read_block(bb) mlib[name] = pat @@ -241,60 +213,32 @@ 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()) - is_closed = element.closed - else: + elif isinstance(element, Polyline): points = numpy.asarray([pp.xyz for pp in element.points()]) - is_closed = element.is_closed attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) - 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: + raise PatternError('Invalid or unimplemented polygon?') - if width == 0: - width = attr.get('const_width', 0) + 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!)') - verts = points[:, :2] - if is_closed and (len(verts) < 2 or not numpy.allclose(verts[0], verts[-1])): - verts = numpy.vstack((verts, verts[0])) + width = points[0, 2] + if width == 0: + width = attr.get('const_width', 0) - 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) + shape: Path | Polygon + if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): + shape = Polygon(vertices=points[:-1, :2]) else: - shape = Path(width=width, vertices=verts) - else: - shape = Path(width=width, vertices=verts) + shape = Path(width=width, vertices=points[:, :2]) 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], @@ -329,57 +273,12 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> ) if 'column_count' in attr: - 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), + 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'], ) - 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).') @@ -404,23 +303,15 @@ 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) - # 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): + 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: 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 numpy.isclose(rotated_a[0], 0, atol=1e-8) and numpy.isclose(rotated_b[1], 0, atol=1e-8): + elif rotated_a[0] == 0 and rotated_b[1] == 0: attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] @@ -453,23 +344,16 @@ 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: - displacements = shape.repetition.displacements + raise PatternError( + 'Shape repetitions are not supported by DXF.' + ' Please call library.wrap_repeated_shapes() before writing to file.' + ) - 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) + 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) def _labels_to_texts( @@ -479,17 +363,11 @@ def _labels_to_texts( for layer, lseq in labels.items(): attribs = dict(layer=_mlayer2dxf(layer)) for label in lseq: - 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) + xy = label.offset + block.add_text( + label.string, + dxfattribs=attribs + ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) def _mlayer2dxf(layer: layer_t) -> str: diff --git a/masque/file/gdsii/klamath.py b/masque/file/gdsii.py similarity index 77% rename from masque/file/gdsii/klamath.py rename to masque/file/gdsii.py index 13d9e81..6972cfa 100644 --- a/masque/file/gdsii/klamath.py +++ b/masque/file/gdsii.py @@ -22,6 +22,8 @@ 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 @@ -33,12 +35,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, RectCollection -from ...repetition import Grid -from ...utils import layer_t, annotations_t -from ...library import Library, ILibrary +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 logger = logging.getLogger(__name__) @@ -80,7 +82,7 @@ def write( datatype is chosen to be `shape.layer[1]` if available, otherwise `0` - GDS does not support shape repetition (only cell repetition). Please call + GDS does not support shape repetition (only cell repeptition). Please call `library.wrap_repeated_shapes()` before writing to file. Other functions you may want to call: @@ -321,40 +323,26 @@ def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_ else: raise PatternError(f'Unrecognized path type: {gpath.path_type}') - vertices = gpath.xy.astype(float) - annotations = _properties_to_annotations(gpath.properties) - cap_extensions = None + 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, + ) if cap == Path.Cap.SquareCustom: - 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, - ) + mpath.cap_extensions = gpath.extension return gpath.layer, mpath def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]: - 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 + return boundary.layer, Polygon( + vertices=boundary.xy[:-1].astype(float), + offset=numpy.zeros(2), + annotations=_properties_to_annotations(boundary.properties), + raw=raw_mode, + ) def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]: @@ -465,7 +453,7 @@ def _shapes_to_elements( extension: tuple[int, int] if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(rint_cast(shape.cap_extensions)) + extension = tuple(shape.cap_extensions) # type: ignore else: extension = (0, 0) @@ -478,20 +466,6 @@ 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) @@ -540,6 +514,112 @@ 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, @@ -552,7 +632,6 @@ def check_valid_names( max_length: Max allowed length """ - names = tuple(names) allowed_chars = set(string.ascii_letters + string.digits + '_?$') bad_chars = [ @@ -569,7 +648,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_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/__init__.py b/masque/file/gdsii/__init__.py deleted file mode 100644 index 09a0b03..0000000 --- a/masque/file/gdsii/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -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 deleted file mode 100644 index c0d9c61..0000000 --- a/masque/file/gdsii/arrow.py +++ /dev/null @@ -1,878 +0,0 @@ -# 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/lazy.py b/masque/file/gdsii/lazy.py deleted file mode 100644 index 1439306..0000000 --- a/masque/file/gdsii/lazy.py +++ /dev/null @@ -1,414 +0,0 @@ -""" -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 deleted file mode 100644 index acfd454..0000000 --- a/masque/file/gdsii/lazy_arrow.py +++ /dev/null @@ -1,541 +0,0 @@ -""" -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 deleted file mode 100644 index 225787a..0000000 --- a/masque/file/gdsii/lazy_write.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -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 deleted file mode 100644 index 64bd7ab..0000000 --- a/masque/file/gdsii/perf.py +++ /dev/null @@ -1,626 +0,0 @@ -""" -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 b5d0cd8..672af25 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.build()` - **kwargs: passed to `oasis.build()` + *args: passed to `oasis.write` + **kwargs: passed to `oasis.write` """ path = pathlib.Path(filename) @@ -213,9 +213,9 @@ def readfile( Will automatically decompress gzipped files. Args: - filename: Filename to load from. - *args: passed to `oasis.read()` - **kwargs: passed to `oasis.read()` + filename: Filename to save to. + *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,35 +549,33 @@ 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), None) # reverse lookup - if path_type is None: - raise PatternError(f'OASIS writer does not support path cap {shape.cap}') + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup 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: @@ -585,13 +583,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 @@ -608,13 +606,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 @@ -624,12 +622,10 @@ 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, @@ -651,19 +647,14 @@ def repetition_masq2fata( frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) - 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 - + 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 frep = fatamorgana.GridRepetition( - a_vector = a_vector, - b_vector = b_vector, - a_count = a_count, - b_count = b_count, + 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), ) offset = (0, 0) elif isinstance(rep, Arbitrary): @@ -716,9 +707,13 @@ def properties_to_annotations( string = repr(value) logger.warning(f'Converting property value for key ({key}) to string ({string})') values.append(string) - annotations.setdefault(key, []).extend(values) + annotations[key] = 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 772aa39..859c074 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -10,59 +10,25 @@ 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 a detached - materialized copy + Write a Pattern to an SVG file, by first calling .polygonize() on it 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. @@ -74,21 +40,17 @@ def writefile( prior to calling this function. Args: - library: Mapping of pattern names to patterns. - top: Name of the top-level pattern to render. + pattern: Pattern to write to file. Modified by this function. 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)`). """ - detached = _detached_library(library) - pattern = detached[top] + pattern = library[top] # Polygonize pattern pattern.polygonize() - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds(library=library) 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) @@ -101,11 +63,10 @@ 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 detached.items(): - svg_group = svg.g(id=svg_ids[name], fill='blue', stroke='red') + for name, pat in library.items(): + svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red') for layer, shapes in pat.shapes.items(): for shape in shapes: @@ -118,37 +79,16 @@ 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 = _ref_to_svg_transform(ref) - use = svg.use(href='#' + svg_ids[target], transform=transform) + 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) svg_group.add(use) svg.defs.add(svg_group) - svg.add(svg.use(href='#' + svg_ids[top])) + svg.add(svg.use(href='#' + mangle_name(top))) svg.save() @@ -163,21 +103,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: - library: Mapping of pattern names to patterns. - top: Name of the top-level pattern to render. + pattern: Pattern to write to file. Modified by this function. filename: Filename to write to. """ - detached = _detached_library(library) - pattern = detached[top] + pattern = library[top] # Polygonize and flatten pattern - pattern.polygonize().flatten(detached) + pattern.polygonize().flatten(library) - bounds = pattern.get_bounds(library=detached) + bounds = pattern.get_bounds(library=library) 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 58c7573..33f68d4 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -33,12 +33,6 @@ 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. @@ -81,8 +75,7 @@ def preflight( raise PatternError('Non-numeric layers found:' + pformat(named_layers)) if prune_empty_patterns: - prune_dangling = 'error' if allow_dangling_refs is False else 'ignore' - pruned = lib.prune_empty(dangling=prune_dangling) + pruned = lib.prune_empty() if pruned: logger.info(f'Preflight pruned {len(pruned)} empty patterns') logger.debug('Pruned: ' + pformat(pruned)) @@ -151,11 +144,7 @@ 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: - try: - yield tmp_stream - except Exception: - pathlib.Path(tmp_stream.name).unlink(missing_ok=True) - raise + yield tmp_stream try: shutil.move(tmp_stream.name, path) diff --git a/masque/label.py b/masque/label.py index d220fee..711ef35 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, Flippable +from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded from .traits import AnnotatableImpl @functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable, Flippable): +class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): """ A text annotation with a position (but no size; it is not drawn) """ @@ -53,36 +53,17 @@ 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: @@ -95,8 +76,6 @@ 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) @@ -117,34 +96,10 @@ 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/base.py b/masque/library.py similarity index 72% rename from masque/library/base.py rename to masque/library.py index 283ca6d..0ed5271 100644 --- a/masque/library/base.py +++ b/masque/library.py @@ -1,36 +1,98 @@ -"""Core library interfaces.""" -from __future__ import annotations +""" +Library classes for managing unique name->pattern mappings and deferred loading or execution. -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 +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 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 ..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 +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 if TYPE_CHECKING: - from collections.abc import Iterator - - from numpy.typing import ArrayLike, NDArray - - from ..label import Label + from .pattern import Pattern 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. @@ -47,7 +109,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 @@ -66,15 +128,6 @@ 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, @@ -124,8 +177,6 @@ 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() @@ -135,16 +186,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). @@ -162,8 +213,6 @@ 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 @@ -215,7 +264,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. @@ -242,9 +291,8 @@ 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, refs in refs_by_target: + for target in pat.refs: if target is None: continue if dangling_ok and target not in self: @@ -255,16 +303,10 @@ 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}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # 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.' - ) + for ref in pat.refs[target]: p = ref.as_pattern(pattern=target_pat) if not flatten_ports: p.ports.clear() @@ -361,7 +403,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()] @@ -370,24 +412,9 @@ 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, *, @@ -439,11 +466,9 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): memo = {} if transform is None or transform is True: - transform = numpy.array([0, 0, 0, 0, 1], dtype=float) + transform = numpy.zeros(4) elif transform is not False: transform = numpy.asarray(transform, dtype=float) - if transform.size == 4: - transform = numpy.append(transform, 1.0) original_pattern = pattern @@ -465,12 +490,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: @@ -486,99 +511,50 @@ 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, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def child_graph(self) -> dict[str, set[str | None]]: """ 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, 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()) + graph = {name: set(pat.refs.keys()) for name, pat in self.items()} return graph - def parent_graph( - self, - dangling: dangling_mode_t = 'error', - ) -> dict[str, set[str]]: + def parent_graph(self) -> 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. """ - 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) + 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) return igraph - def child_order( - self, - dangling: dangling_mode_t = 'error', - ) -> list[str]: + def child_order(self) -> list[str]: """ - Return a topologically sorted list of graph node names. + Return a topologically sorted list of all contained pattern names. Child (referenced) patterns will appear before their parents. - Args: - dangling: Passed to `child_graph()`. - Return: Topologically sorted list of pattern names. """ - 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 + return cast('list[str]', list(TopologicalSorter(self.child_graph()).static_order())) 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`. @@ -591,8 +567,6 @@ 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 @@ -601,18 +575,8 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): """ instances = 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()): + parent_graph = self.parent_graph() + for parent in parent_graph[name]: if parent not in self: # parent_graph may be a for a superset of self continue for ref in self[parent].refs[name]: @@ -625,7 +589,6 @@ 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 @@ -642,28 +605,18 @@ 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)`. """ - 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 {} + return {} + if order is None: + order = self.child_order() + if parent_graph is None: + parent_graph = self.parent_graph() self_keys = set(self.keys()) @@ -672,16 +625,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, dangling=dangling).items(): + for parent, vals in self.find_refs_local(name, parent_graph=parent_graph).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: + if not parent_graph[next_name] & self_keys: continue - outers = self.find_refs_local(next_name, parent_graph=parent_graph, dangling=dangling) + outers = self.find_refs_local(next_name, parent_graph=parent_graph) inners = transforms.pop(next_name) for parent, outer in outers.items(): for path, inner in inners: @@ -717,7 +670,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): def __setitem__( self, key: str, - value: Pattern | Callable[[], Pattern], + value: 'Pattern | Callable[[], Pattern]', ) -> None: pass @@ -726,36 +679,9 @@ 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, @@ -774,11 +700,6 @@ 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: @@ -803,9 +724,6 @@ 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]) @@ -834,7 +752,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. @@ -845,15 +763,15 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: (name, pattern) tuple """ - from ..pattern import Pattern # noqa: PLC0415 + from .pattern import Pattern 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]: """ @@ -879,25 +797,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): (default). Returns: - A mapping of `{old_name: new_name}` for all names in `other` which were - renamed while being added. Unchanged names are omitted. + A mapping of `{old_name: new_name}` for all `old_name`s in `other`. Unchanged + names map to themselves. Raises: `LibraryError` if a duplicate name is encountered even after applying `rename_theirs()`. """ - from ..pattern import map_targets # noqa: PLC0415 - from .mapping import Library # noqa: PLC0415 - + from .pattern import map_targets duplicates = set(self.keys()) & set(other.keys()) if not duplicates: - if mutate_other: - temp = other - else: - temp = Library(copy.deepcopy(dict(other))) - - for key in temp: - self._merge(key, temp, key) + for key in other: + self._merge(key, other, key) return {} if mutate_other: @@ -936,8 +847,6 @@ 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: @@ -954,7 +863,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. @@ -1000,7 +909,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 # noqa: PLC0415 + from .pattern import Pattern if exclude_types is None: exclude_types = () @@ -1009,18 +918,6 @@ 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 = {} @@ -1037,7 +934,6 @@ 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 @@ -1046,7 +942,6 @@ 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()): @@ -1071,14 +966,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 = target_names[label] - shapes_to_remove = [] + target = label2name(label) 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) + rotation=rotation, mirrored=(mirror_x, False)) shapes_to_remove.append(ii) # Remove any shapes for which we have created refs. @@ -1086,13 +981,13 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): del pat.shapes[layer][ii] for ll, pp in shape_pats.items(): - self[target_names[ll]] = pp + self[label2name(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 @@ -1107,7 +1002,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta): Returns: self """ - from ..pattern import Pattern # noqa: PLC0415 + from .pattern import Pattern if name_func is None: def name_func(_pat: Pattern, _shape: Shape | Label) -> str: @@ -1141,25 +1036,6 @@ 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], @@ -1189,19 +1065,17 @@ 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(dangling=dangling) + parent_graph = self.parent_graph() empty = {name for name, pat in self.items() if pat.is_empty()} trimmed = set() while empty: @@ -1238,6 +1112,246 @@ 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. @@ -1257,3 +1371,20 @@ 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/__init__.py b/masque/library/__init__.py deleted file mode 100644 index b05b020..0000000 --- a/masque/library/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""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/build.py b/masque/library/build.py deleted file mode 100644 index 8582a52..0000000 --- a/masque/library/build.py +++ /dev/null @@ -1,742 +0,0 @@ -"""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 deleted file mode 100644 index 973a431..0000000 --- a/masque/library/lazy.py +++ /dev/null @@ -1,169 +0,0 @@ -"""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 deleted file mode 100644 index 95c7e73..0000000 --- a/masque/library/mapping.py +++ /dev/null @@ -1,112 +0,0 @@ -"""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 deleted file mode 100644 index a325bc6..0000000 --- a/masque/library/overlay.py +++ /dev/null @@ -1,531 +0,0 @@ -"""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 deleted file mode 100644 index 138c91a..0000000 --- a/masque/library/utils.py +++ /dev/null @@ -1,124 +0,0 @@ -"""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 daf78e8..dc7d058 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -26,7 +26,6 @@ from .traits import AnnotatableImpl, Scalable, Mirrorable, Rotatable, Positionab from .ports import Port, PortList - logger = logging.getLogger(__name__) @@ -38,8 +37,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 `builder.Pather` - and `ports.PortsList`. + See `Pattern.plug()` and `Pattern.place()`, as well as the helper classes + `builder.Builder`, `builder.Pather`, `builder.RenderPather`, and `ports.PortsList`. For convenience, ports can be read out using square brackets: - `pattern['A'] == Port((0, 0), 0)` @@ -172,8 +171,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): return s def __copy__(self) -> 'Pattern': - logger.warning('Making a shallow copy of a Pattern... old shapes/refs/labels are re-referenced! ' - 'Consider using .deepcopy() if this was not intended.') + logger.warning('Making a shallow copy of a Pattern... old shapes are re-referenced!') new = Pattern( annotations=copy.deepcopy(self.annotations), ports=copy.deepcopy(self.ports), @@ -200,7 +198,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 other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.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)) @@ -214,7 +212,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 other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.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)) @@ -223,21 +221,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.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 other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.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_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return labels_ours < labels_theirs @@ -254,7 +252,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 other.refs.items() if reflist] + other_nonempty_targets = [target for target, reflist in self.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)) @@ -268,7 +266,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 other.shapes.items() if elems] + other_nonempty_layers = [ll for ll, elems in self.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)) @@ -277,21 +275,21 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): for _, _, layer in self_layerkeys: shapes_ours = tuple(sorted(self.shapes[layer])) - shapes_theirs = tuple(sorted(other.shapes[layer])) + shapes_theirs = tuple(sorted(self.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 other.labels.items() if elems] + other_nonempty_txtlayers = [ll for ll, elems in self.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_txtlayerkeys: + for _, _, layer in self_layerkeys: labels_ours = tuple(sorted(self.labels[layer])) - labels_theirs = tuple(sorted(other.labels[layer])) + labels_theirs = tuple(sorted(self.labels[layer])) if labels_ours != labels_theirs: return False @@ -349,16 +347,6 @@ 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(): @@ -369,7 +357,14 @@ 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 @@ -504,61 +499,6 @@ 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. @@ -695,7 +635,6 @@ 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: @@ -749,9 +688,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - Extrinsic transformation: Rotate the Pattern around the a location in the - container's coordinate system. This affects all elements' offsets and - their repetition grids. + Rotate the Pattern around the a location. Args: pivot: (x, y) location to rotate around @@ -765,14 +702,11 @@ 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: """ - 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. + Rotate the offsets of all shapes, labels, refs, and ports around (0, 0) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -783,15 +717,11 @@ 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: """ - 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. + Rotate each shape, ref, and port around its origin (offset) Args: rotation: Angle to rotate by (counter-clockwise, radians) @@ -799,61 +729,54 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: self """ - for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - if isinstance(entry, Rotatable): - entry.rotate(rotation) + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Rotatable', entry).rotate(rotation) return self - def mirror_element_centers(self, axis: int = 0) -> Self: + def mirror_element_centers(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation part: Mirror the offsets and repetition grids of all - shapes, labels, refs, and ports relative to the container's origin. + Mirror the offsets of all shapes, labels, and refs across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ for entry in chain(chain_elements(self.shapes, self.refs, self.labels), self.ports.values()): - cast('Positionable', entry).offset[1 - axis] *= -1 - if isinstance(entry, Repeatable) and entry.repetition is not None: - entry.repetition.mirror(axis) + cast('Positionable', entry).offset[1 - across_axis] *= -1 return self - def mirror_elements(self, axis: int = 0) -> Self: + def mirror_elements(self, across_axis: int = 0) -> Self: """ - Intrinsic transformation part: Mirror each shape, ref, label, and port relative - to its offset. This does NOT affect their repetition grids. + Mirror each shape, ref, and pattern across an axis, relative + to its offset Args: - axis: Axis to mirror across - 0: mirror across x axis (flip y), - 1: mirror across y axis (flip x) + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - 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})") + for entry in chain(chain_elements(self.shapes, self.refs), self.ports.values()): + cast('Mirrorable', entry).mirror(across_axis) return self - def mirror(self, axis: int = 0) -> Self: + def mirror(self, across_axis: int = 0) -> Self: """ - Extrinsic transformation: Mirror the Pattern across an axis through its origin. - This affects all elements' offsets and their internal orientations. + Mirror the Pattern across an axis Args: - axis: Axis to mirror across (0: x-axis, 1: y-axis). + across_axis: Axis to mirror across + (0: mirror across x axis, 1: mirror across y axis) Returns: self """ - self.mirror_elements(axis=axis) - self.mirror_element_centers(axis=axis) - self._log_bulk_update(f"mirror({axis})") + self.mirror_elements(across_axis) + self.mirror_element_centers(across_axis) return self def copy(self) -> Self: @@ -864,7 +787,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): Returns: A deep copy of the current Pattern. """ - return self.deepcopy() + return copy.deepcopy(self) def deepcopy(self) -> Self: """ @@ -1007,28 +930,6 @@ 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`. @@ -1080,16 +981,10 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): if target_pat is None: raise PatternError(f'Circular reference in {name} to {target}') - ports_only = flatten_ports and bool(target_pat.ports) - if target_pat.is_empty() and not ports_only: # avoid some extra allocations + if target_pat.is_empty(): # 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() @@ -1108,8 +1003,6 @@ 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 @@ -1120,18 +1013,15 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): klayout or a different GDS viewer! Args: - 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. + 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 """ # TODO: add text labels to visualize() try: - from matplotlib import pyplot # type: ignore #noqa: PLC0415 - import matplotlib.collections # type: ignore #noqa: PLC0415 + from matplotlib import pyplot # type: ignore + import matplotlib.collections # type: ignore 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.') @@ -1140,155 +1030,48 @@ 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') - # 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]]] = {} + offset = numpy.asarray(offset, dtype=float) - 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() - if all_polygons: - mpl_poly_collection = matplotlib.collections.PolyCollection( - all_polygons, - facecolors = fill_color, - edgecolors = line_color, - ) - axes.add_collection(mpl_poly_collection) + polygons = [] + for shape in chain.from_iterable(self.shapes.values()): + polygons += [offset + s.offset + s.vertices for s in shape.to_polygons()] - 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)]) + mpl_poly_collection = matplotlib.collections.PolyCollection( + polygons, + facecolors=fill_color, + edgecolors=line_color, + ) + axes.add_collection(mpl_poly_collection) + pyplot.axis('equal') - axes.annotate( - port_name, - xy = tuple(p1), - xytext = tuple(p2), - arrowprops = dict(arrowstyle="->", color='g', linewidth=1), - color = 'g', - fontsize = 8, + 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.autoscale_view() - axes.set_aspect('equal') - if not overdraw: - axes.set_xlabel('x') - axes.set_ylabel('y') - if filename: - figure.savefig(filename) - else: - figure.show() + pyplot.xlabel('x') + pyplot.ylabel('y') + pyplot.show() # @overload # def place( @@ -1331,7 +1114,6 @@ 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 @@ -1363,10 +1145,6 @@ 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 @@ -1381,26 +1159,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = {} if not skip_port_check: - 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.') + self.check_ports(other.ports.keys(), map_in=None, map_out=port_map) ports = {} for name, port in other.ports.items(): @@ -1417,12 +1176,10 @@ 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: @@ -1431,6 +1188,7 @@ 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) @@ -1476,7 +1234,6 @@ 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 @@ -1526,15 +1283,11 @@ 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 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. + 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 @@ -1566,59 +1319,23 @@ 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()))} - map_out, overwrite_targets = self._resolve_insert_mapping( - other.ports.keys(), + self.check_ports(other.ports.keys(), map_in, map_out) + translation, rotation, pivot = self.find_transform( + other, map_in, - map_out, - allow_conflicts=skip_geometry, + mirrored = mirrored, + set_rotation = set_rotation, + ok_connections = ok_connections, ) - 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, @@ -1628,7 +1345,6 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): port_map = map_out, skip_port_check = True, append = append, - skip_geometry = skip_geometry, ) return self @@ -1662,7 +1378,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): current device. Args: - source: A collection of ports (e.g. Pattern, Pather, or dict) + source: A collection of ports (e.g. Pattern, Builder, 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. @@ -1690,13 +1406,9 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): else: raise PatternError(f'Unable to get ports from {type(source)}: {source}') - if port_map is not None: + if port_map: 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 bbd18b7..0211723 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -2,7 +2,6 @@ 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 @@ -11,17 +10,16 @@ import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray -from .traits import PositionableImpl, PivotableImpl, Copyable, Mirrorable, Flippable -from .utils import ptypes_compatible, rotate_offsets_around, rotation_matrix_2d +from .traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from .utils import 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(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable): """ A point at which a `Device` can be snapped to another `Device`. @@ -93,12 +91,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): 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)) @@ -107,27 +99,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): 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 @@ -143,34 +114,6 @@ class Port(PivotableImpl, PositionableImpl, Mirrorable, Flippable, Copyable): 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' @@ -236,19 +179,6 @@ 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 @@ -273,12 +203,6 @@ 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.') @@ -308,7 +232,6 @@ 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( @@ -330,147 +253,17 @@ 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}') - 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 - } + renamed = {vv: self.ports.pop(kk) for kk, vv in mapping.items()} + if None in renamed: + del renamed[None] - 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 - - for vv in winners: - self._log_port_update(vv) - return winners + return self def add_port_pair( self, @@ -492,16 +285,12 @@ 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( @@ -524,25 +313,13 @@ 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([not ptypes_compatible(at, bt) + type_conflicts = numpy.array([at != bt and 'unk' not in (at, bt) for at, bt in zip(a_types, b_types, strict=True)]) if type_conflicts.any(): @@ -583,7 +360,6 @@ 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( @@ -614,7 +390,45 @@ class PortList(metaclass=ABCMeta): `PortError` if there are any duplicate names after `map_in` and `map_out` are applied. """ - self._resolve_insert_mapping(other_names, map_in, map_out) + 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}') + return self def find_transform( @@ -641,10 +455,11 @@ 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 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)`. + 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)`. Returns: - The (x, y) translation (performed last) @@ -653,8 +468,6 @@ 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( @@ -693,10 +506,11 @@ 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 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)`. + 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)`. Returns: - The (x, y) translation (performed last) @@ -705,8 +519,6 @@ 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()] @@ -723,10 +535,8 @@ 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([ - 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) - ]) + 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)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' for nn, (kk, vv) in enumerate(map_in.items()): @@ -738,7 +548,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: - raise PortError('Must provide set_rotation if rotation is indeterminate') + PortError('Must provide set_rotation if rotation is indeterminate') rotations[:] = set_rotation else: rotations[~has_rot] = rotations[has_rot][0] @@ -763,3 +573,4 @@ 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 0cc911f..b3a684c 100644 --- a/masque/ref.py +++ b/masque/ref.py @@ -15,8 +15,7 @@ from .utils import annotations_t, rotation_matrix_2d, annotations_eq, annotation from .repetition import Repetition from .traits import ( PositionableImpl, RotatableImpl, ScalableImpl, - PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, - FlippableImpl, + Mirrorable, PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ) @@ -26,9 +25,8 @@ if TYPE_CHECKING: @functools.total_ordering class Ref( - FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - PositionableImpl, RotatableImpl, ScalableImpl, - Copyable, + PositionableImpl, RotatableImpl, ScalableImpl, Mirrorable, + PivotableImpl, Copyable, RepeatableImpl, AnnotatableImpl, ): """ `Ref` provides basic support for nesting Pattern objects within each other. @@ -44,7 +42,7 @@ class Ref( __slots__ = ( '_mirrored', # inherited - '_offset', '_rotation', '_scale', '_repetition', '_annotations', + '_offset', '_rotation', 'scale', '_repetition', '_annotations', ) _mirrored: bool @@ -86,48 +84,24 @@ 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=self.repetition, - annotations=self.annotations, + repetition=copy.deepcopy(self.repetition), + annotations=copy.deepcopy(self.annotations), ) return new def __deepcopy__(self, memo: dict | None = None) -> 'Ref': 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) + #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) @@ -142,8 +116,6 @@ 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 @@ -188,16 +160,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: @@ -215,11 +187,10 @@ class Ref( xys = self.offset[None, :] if self.repetition is not None: xys = xys + self.repetition.displacements - transforms = numpy.empty((xys.shape[0], 5)) + transforms = numpy.empty((xys.shape[0], 4)) transforms[:, :2] = xys transforms[:, 2] = self.rotation transforms[:, 3] = self.mirrored - transforms[:, 4] = self.scale return transforms def get_bounds_single( @@ -256,10 +227,7 @@ class Ref( bounds = numpy.vstack((numpy.min(corners, axis=0), numpy.max(corners, axis=0))) * self.scale + [self.offset] return bounds - - single_ref = self.deepcopy() - single_ref.repetition = None - return single_ref.as_pattern(pattern=pattern).get_bounds(library) + return self.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 9e8af26..5e7a7f0 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 __lt__(self, other: 'Repetition') -> bool: + def __le__(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] + _b_vector: NDArray[numpy.float64] | None """ 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,22 +113,6 @@ 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], @@ -200,8 +184,6 @@ 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 @@ -213,12 +195,13 @@ 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 @@ -308,7 +291,7 @@ class Grid(Repetition): return False return True - def __lt__(self, other: Repetition) -> bool: + def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Grid', other) @@ -318,8 +301,12 @@ 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.b_vector) < tuple(other.b_vector) + return tuple(self.a_vector) < tuple(other.a_vector) return False @@ -345,22 +332,7 @@ class Arbitrary(Repetition): @displacements.setter def displacements(self, val: ArrayLike) -> None: - 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') - + vala = numpy.array(val, dtype=float) order = numpy.lexsort(vala.T[::-1]) # sortrows self._displacements = vala[order] @@ -378,11 +350,11 @@ class Arbitrary(Repetition): return (f'') def __eq__(self, other: Any) -> bool: - if type(other) is not type(self): + if not type(other) is not type(self): return False return numpy.array_equal(self.displacements, other.displacements) - def __lt__(self, other: Repetition) -> bool: + def __le__(self, other: Repetition) -> bool: if type(self) is not type(other): return repr(type(self)) < repr(type(other)) other = cast('Arbitrary', other) @@ -419,9 +391,7 @@ class Arbitrary(Repetition): Returns: self """ - new_displacements = self.displacements.copy() - new_displacements[:, 1 - axis] *= -1 - self.displacements = new_displacements + self.displacements[1 - axis] *= -1 return self def get_bounds(self) -> NDArray[numpy.float64] | None: @@ -432,8 +402,6 @@ 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)) @@ -448,5 +416,6 @@ class Arbitrary(Repetition): Returns: self """ - self.displacements = self.displacements * c + self.displacements *= c return self + diff --git a/masque/shapes/__init__.py b/masque/shapes/__init__.py index ac3a14b..fd66c59 100644 --- a/masque/shapes/__init__.py +++ b/masque/shapes/__init__.py @@ -11,7 +11,6 @@ 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 53fadb8..480835e 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -1,7 +1,6 @@ from typing import Any, cast import copy import functools -from enum import Enum import numpy from numpy import pi @@ -14,37 +13,18 @@ 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. - 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. + 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. 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', '_angle_ref', + '_radii', '_angles', '_width', '_rotation', # Inherited '_offset', '_repetition', '_annotations', ) @@ -61,11 +41,6 @@ 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]: @@ -79,8 +54,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 positive') + if not val.min() >= 0: + raise PatternError('Radii must be non-negative') self._radii = val @property @@ -89,8 +64,8 @@ class Arc(PositionableImpl, Shape): @radius_x.setter def radius_x(self, val: float) -> None: - if not val > 0: - raise PatternError('Radius must be positive') + if not val >= 0: + raise PatternError('Radius must be non-negative') self._radii[0] = val @property @@ -99,8 +74,8 @@ class Arc(PositionableImpl, Shape): @radius_y.setter def radius_y(self, val: float) -> None: - if not val > 0: - raise PatternError('Radius must be positive') + if not val >= 0: + raise PatternError('Radius must be non-negative') self._radii[1] = val # arc start/stop angle properties @@ -138,18 +113,6 @@ 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: @@ -196,40 +159,27 @@ class Arc(PositionableImpl, Shape): rotation: float = 0, repetition: Repetition | None = None, annotations: annotations_t = None, - angle_ref: ArcAngleRef | str = ArcAngleRef.Center, + raw: bool = False, ) -> None: - 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 + 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 def __deepcopy__(self, memo: dict | None = None) -> 'Arc': memo = {} if memo is None else memo @@ -237,7 +187,6 @@ 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 @@ -249,7 +198,6 @@ 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) ) @@ -266,8 +214,6 @@ 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: @@ -284,8 +230,6 @@ 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 @@ -312,38 +256,29 @@ 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 - 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) + max_arclen = min(implied_arclen, max_arclen if max_arclen is not None else numpy.inf) + assert max_arclen is not None 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 = max(2, int(numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen))) + n_pts = numpy.ceil(2 * pi * max(self.radii + dr) / max_arclen).astype(int) arc_lengths, thetas = get_arclens(n_pts, *a_ranges[0 if inner else 1], dr=dr) keep = [0] - start = 0 + removable = (numpy.cumsum(arc_lengths) <= max_arclen) + start = 1 while start < arc_lengths.size: - 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 + next_to_keep = start + numpy.where(removable)[0][-1] # TODO: any chance we haven't sampled finely enough? keep.append(next_to_keep) - start = next_to_keep - + removable = (numpy.cumsum(arc_lengths[next_to_keep + 1:]) <= max_arclen) + start = next_to_keep + 1 if keep[-1] != thetas.size - 1: keep.append(thetas.size - 1) @@ -375,59 +310,81 @@ 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) - 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 = [] + 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 - if rx == 0 or ry == 0: - pts.append(numpy.zeros(2)) - continue - pts.extend(points_in_interval(rx, ry, aa[0], aa[1])) - all_pts = numpy.asarray(pts) + self.offset - return numpy.vstack((numpy.min(all_pts, axis=0), - numpy.max(all_pts, axis=0))) + if rx == 0 or ry == 0: + # Single point, at origin + mins.append([0, 0]) + maxs.append([0, 0]) + continue + + 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)) def rotate(self, theta: float) -> 'Arc': self.rotation += theta return self def mirror(self, axis: int = 0) -> 'Arc': - 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.offset[axis - 1] *= -1 self.rotation *= -1 self.rotation += axis * pi self.angles *= -1 @@ -439,7 +396,6 @@ 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 @@ -450,26 +406,23 @@ 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), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value), + return ((type(self), radii, norm_angles, width / norm_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]: @@ -480,16 +433,27 @@ class Arc(PositionableImpl, Shape): [[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse. ``` """ - a_ranges = self._angles_to_parameters() + a_ranges = cast('_array2x2_t', self._angles_to_parameters()) - 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 + 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 def _angles_to_parameters(self) -> NDArray[numpy.float64]: """ @@ -500,111 +464,22 @@ 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 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles) - sign = numpy.sign(d_angle) + 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]) 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 '' - 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 + return f'' _array2x2_t = tuple[tuple[float, float], tuple[float, float]] diff --git a/masque/shapes/circle.py b/masque/shapes/circle.py index d7591db..b20a681 100644 --- a/masque/shapes/circle.py +++ b/masque/shapes/circle.py @@ -50,33 +50,24 @@ class Circle(PositionableImpl, Shape): offset: ArrayLike = (0.0, 0.0), repetition: Repetition | None = None, annotations: annotations_t = None, + raw: bool = False, ) -> None: - 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 + 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 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 @@ -117,7 +108,7 @@ class Circle(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [2 * pi * self.radius / max_arclen] - num_vertices = max(3, int(round(max(n)))) + num_vertices = 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 @@ -133,6 +124,7 @@ 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 52a3297..6029f2f 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, dtype=float).flatten() + val = numpy.array(val).flatten() if not val.size == 2: raise PatternError('Radii must have length 2') if not val.min() >= 0: @@ -95,37 +95,28 @@ class Ellipse(PositionableImpl, Shape): rotation: float = 0, repetition: Repetition | None = None, annotations: annotations_t = None, + raw: bool = False, ) -> None: - 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 + 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 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 @@ -177,7 +168,7 @@ class Ellipse(PositionableImpl, Shape): n += [num_vertices] if max_arclen is not None: n += [perimeter / max_arclen] - num_vertices = max(3, int(round(max(n)))) + num_vertices = int(round(max(n))) thetas = numpy.linspace(2 * pi, 0, num_vertices, endpoint=False) sin_th, cos_th = (numpy.sin(thetas), numpy.cos(thetas)) @@ -189,19 +180,16 @@ class Ellipse(PositionableImpl, Shape): return [poly] def get_bounds_single(self) -> NDArray[numpy.float64]: - 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)) + rot_radii = numpy.dot(rotation_matrix_2d(self.rotation), self.radii) + return numpy.vstack((self.offset - rot_radii[0], + self.offset + rot_radii[1])) 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 @@ -219,7 +207,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), tuple(radii.tolist())), + return ((type(self), radii), (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 a1e04af..7778428 100644 --- a/masque/shapes/path.py +++ b/masque/shapes/path.py @@ -24,16 +24,7 @@ class PathCap(Enum): # # defined by path.cap_extensions def __lt__(self, other: Any) -> bool: - 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] + return self.value == other.value @functools.total_ordering @@ -88,10 +79,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 @@ -201,50 +192,37 @@ 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 - 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) + 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 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 @@ -275,14 +253,6 @@ 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) @@ -333,30 +303,9 @@ class Path(Shape): ) -> list['Polygon']: extensions = self._calculate_cap_extensions() - v = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + v = remove_colinear_vertices(self.vertices, closed_path=False) dv = numpy.diff(v, axis=0) - 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] + dvdir = dv / numpy.sqrt((dv * dv).sum(axis=1))[:, None] if self.width == 0: verts = numpy.vstack((v, v[::-1])) @@ -375,21 +324,11 @@ class Path(Shape): bs = v[1:-1] - v[:-2] + perp[1:] - perp[:-1] ds = v[1:-1] - v[:-2] - perp[1:] + perp[:-1] - 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] + rp = numpy.linalg.solve(As, bs[:, :, None])[:, 0] + rn = numpy.linalg.solve(As, ds[:, :, None])[:, 0] - intersection_p = v[:-2] + rp[:, None] * dv[:-1] + perp[:-1] - intersection_n = v[:-2] + rn[:, None] * dv[:-1] - perp[:-1] + intersection_p = v[:-2] + rp * dv[:-1] + perp[:-1] + intersection_n = v[:-2] + rn * 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 @@ -457,14 +396,12 @@ class Path(Shape): return self def mirror(self, axis: int = 0) -> 'Path': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -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: @@ -481,22 +418,21 @@ class Path(Shape): rotated_vertices = numpy.vstack([numpy.dot(rotation_matrix_2d(-rotation), v) for v in normed_vertices]) - # 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 + # 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) 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, cap_extensions0), + return ((type(self), reordered_vertices.data.tobytes(), width0, self.cap), (offset, scale / norm_value, rotation, False), lambda: Path( reordered_vertices * norm_value, - width=width0 * norm_value, + width=self.width * 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': @@ -526,7 +462,7 @@ class Path(Shape): Returns: self """ - self.vertices = remove_colinear_vertices(self.vertices, closed_path=False, preserve_uturns=True) + self.vertices = remove_colinear_vertices(self.vertices, closed_path=False) 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 f1c840a..6048f24 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.integer[Any]] + _vertex_offsets: NDArray[numpy.intp] """ 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.integer[Any]]: + def vertex_offsets(self) -> NDArray[numpy.intp]: """ Starting offset (in `vertex_lists`) for each polygon """ @@ -56,14 +56,12 @@ 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[1:], [self._vertex_lists.shape[0]]), + chain(self._vertex_offsets, (self._vertex_lists.shape[0],)), strict=True, ): - yield slice(int(ii), int(ff)) + yield slice(ii, ff) @property def polygon_vertices(self) -> Iterator[NDArray[numpy.float64]]: @@ -84,7 +82,7 @@ class PolyCollection(Shape): def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('PolyCollection offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -100,38 +98,30 @@ class PolyCollection(Shape): rotation: float = 0.0, repetition: Repetition | None = None, annotations: annotations_t = None, + raw: bool = False, ) -> None: - 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 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 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 @@ -139,7 +129,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) ) @@ -178,9 +168,7 @@ class PolyCollection(Shape): annotations = copy.deepcopy(self.annotations), ) for vv in self.polygon_vertices] - 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 + def get_bounds_single(self) -> NDArray[numpy.float64]: # TODO note shape get_bounds doesn't include repetition return numpy.vstack((numpy.min(self._vertex_lists, axis=0), numpy.max(self._vertex_lists, axis=0))) @@ -191,7 +179,7 @@ class PolyCollection(Shape): return self def mirror(self, axis: int = 0) -> Self: - self._vertex_lists[:, 1 - axis] *= -1 + self._vertex_lists[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> Self: @@ -222,11 +210,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.copy(), + vertex_offsets=self._vertex_offsets, ), ) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 06e5c2b..c8c3ddd 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -1,4 +1,4 @@ -from typing import Any, cast, TYPE_CHECKING, Self, Literal +from typing import Any, cast, TYPE_CHECKING, Self 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('Polygon offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') def set_offset(self, val: ArrayLike) -> Self: if numpy.any(val): - raise PatternError('Polygon offset is forced to (0, 0)') + raise PatternError('Path offset is forced to (0, 0)') return self def translate(self, offset: ArrayLike) -> Self: @@ -115,34 +115,26 @@ class Polygon(Shape): rotation: float = 0.0, repetition: Repetition | None = None, annotations: annotations_t = None, + raw: bool = False, ) -> None: - self.vertices = vertices - self.repetition = repetition - self.annotations = annotations + 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 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 @@ -329,7 +321,7 @@ class Polygon(Shape): else: raise PatternError('Two of ymin, yctr, ymax, ly must be None!') - poly = Polygon.rectangle(abs(lx), abs(ly), offset=(xctr, yctr), repetition=repetition) + poly = Polygon.rectangle(lx, ly, offset=(xctr, yctr), repetition=repetition) return poly @staticmethod @@ -402,7 +394,7 @@ class Polygon(Shape): return self def mirror(self, axis: int = 0) -> 'Polygon': - self.vertices[:, 1 - axis] *= -1 + self.vertices[:, axis - 1] *= -1 return self def scale_by(self, c: float) -> 'Polygon': @@ -425,15 +417,11 @@ class Polygon(Shape): for v in normed_vertices]) # Reorder the vertices so that the one with lowest x, then y, comes first. - 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) + 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) # TODO: normalize mirroring? @@ -474,23 +462,3 @@ 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 deleted file mode 100644 index eaf028f..0000000 --- a/masque/shapes/rect_collection.py +++ /dev/null @@ -1,249 +0,0 @@ -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 efc0859..90bca2b 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 ( - Copyable, Scalable, FlippableImpl, - PivotableImpl, RepeatableImpl, AnnotatableImpl, + Rotatable, Mirrorable, Copyable, Scalable, + Positionable, PivotableImpl, RepeatableImpl, AnnotatableImpl, ) if TYPE_CHECKING: @@ -26,9 +26,8 @@ normalized_shape_tuple = tuple[ DEFAULT_POLY_NUM_VERTICES = 24 -class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, - Copyable, Scalable, - metaclass=ABCMeta): +class Shape(Positionable, Rotatable, Mirrorable, Copyable, Scalable, + PivotableImpl, RepeatableImpl, AnnotatableImpl, metaclass=ABCMeta): """ Class specifying functions common to all shapes. """ @@ -74,7 +73,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, pass @abstractmethod - def normalized_form(self, norm_value: float) -> normalized_shape_tuple: + def normalized_form(self, norm_value: int) -> normalized_shape_tuple: """ Writes the shape in a standardized notation, with offset, scale, and rotation information separated out from the remaining values. @@ -121,7 +120,7 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 + from . import Polygon gx = numpy.unique(grid_x) gy = numpy.unique(grid_y) @@ -139,24 +138,22 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, 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 + # Find x-index bounds for the line # TODO: fix this and err_xmin/xmax for grids smaller than the line / shape gxi_range = numpy.digitize([v[0], v_next[0]], 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))) + gxi_min = numpy.min(gxi_range - 1).clip(0, len(gx) - 1) + gxi_max = numpy.max(gxi_range).clip(0, len(gx)) - 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 + 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_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 err_xmin >= 0.5: + gxi_min += 1 + if err_xmax >= 0.5: + gxi_max += 1 if abs(dv[0]) < 1e-20: # Vertical line, don't calculate slope - xi = [gxi_min, max(gxi_min, gxi_max - 1)] + xi = [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]) @@ -252,9 +249,9 @@ class Shape(FlippableImpl, PivotableImpl, RepeatableImpl, AnnotatableImpl, Returns: List of `Polygon` objects with grid-aligned edges. """ - from . import Polygon #noqa: PLC0415 - import skimage.measure #noqa: PLC0415 - import float_raster #noqa: PLC0415 + from . import Polygon + import skimage.measure # type: ignore + import float_raster grx = numpy.unique(grid_x) gry = numpy.unique(grid_y) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index c078879..78632f6 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -70,48 +70,31 @@ 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: - self.offset = offset - self.string = string - self.height = height - self.rotation = rotation - self.mirrored = mirrored - self.repetition = repetition - self.annotations = annotations + 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.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 @@ -122,7 +105,6 @@ 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) @@ -142,8 +124,6 @@ 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: @@ -166,7 +146,7 @@ class Text(PositionableImpl, RotatableImpl, Shape): if self.mirrored: poly.mirror() poly.scale_by(self.height) - poly.translate(self.offset + [total_advance, 0]) + poly.offset = self.offset + [total_advance, 0] poly.rotate_around(self.offset, self.rotation) all_polygons += [poly] @@ -191,25 +171,22 @@ class Text(PositionableImpl, RotatableImpl, Shape): (self.offset, self.height / norm_value, rotation, bool(self.mirrored)), lambda: Text( string=self.string, - height=norm_value, + height=self.height * norm_value, font_path=self.font_path, rotation=rotation, ).mirror2d(across_x=self.mirrored), ) - def get_bounds_single(self) -> NDArray[numpy.float64] | None: + def get_bounds_single(self) -> NDArray[numpy.float64]: # 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 @@ -225,8 +202,8 @@ def get_char_as_polygons( char: str, resolution: float = 48 * 64, ) -> tuple[list[NDArray[numpy.float64]], float]: - from freetype import Face # type: ignore #noqa: PLC0415 - from matplotlib.path import Path # type: ignore #noqa: PLC0415 + from freetype import Face # type: ignore + from matplotlib.path import Path # type: ignore """ Get a list of polygons representing a single character. diff --git a/masque/test/__init__.py b/masque/test/__init__.py deleted file mode 100644 index e02b636..0000000 --- a/masque/test/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests (run with `python3 -m pytest -rxPXs | tee results.txt`) -""" diff --git a/masque/test/conftest.py b/masque/test/conftest.py deleted file mode 100644 index 3116ee2..0000000 --- a/masque/test/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -""" - -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 deleted file mode 100644 index 2ccea1a..0000000 --- a/masque/test/helpers.py +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index d2f54ed..0000000 --- a/masque/test/test_abstract.py +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index dc23144..0000000 --- a/masque/test/test_arc.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index f29b741..0000000 --- a/masque/test/test_autotool_planning.py +++ /dev/null @@ -1,1219 +0,0 @@ -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 deleted file mode 100644 index e2fd7ea..0000000 --- a/masque/test/test_boolean.py +++ /dev/null @@ -1,242 +0,0 @@ -# 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 deleted file mode 100644 index 6a0712e..0000000 --- a/masque/test/test_build_library.py +++ /dev/null @@ -1,485 +0,0 @@ -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 deleted file mode 100644 index 53c0a8c..0000000 --- a/masque/test/test_builder.py +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index 1b4158e..0000000 --- a/masque/test/test_circle.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index aaf4070..0000000 --- a/masque/test/test_curve_polygonization.py +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 442b62f..0000000 --- a/masque/test/test_dxf.py +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 1125fb1..0000000 --- a/masque/test/test_ellipse.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 2b4f3d3..0000000 --- a/masque/test/test_fdfd.py +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index 36c4ce4..0000000 --- a/masque/test/test_file_format_roundtrip.py +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index 7a2f5b1..0000000 --- a/masque/test/test_gdsii.py +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 47f9ac8..0000000 --- a/masque/test/test_gdsii_arrow.py +++ /dev/null @@ -1,604 +0,0 @@ -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 deleted file mode 100644 index b9da929..0000000 --- a/masque/test/test_gdsii_lazy.py +++ /dev/null @@ -1,230 +0,0 @@ -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 deleted file mode 100644 index 0447921..0000000 --- a/masque/test/test_gdsii_lazy_arrow.py +++ /dev/null @@ -1,335 +0,0 @@ -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 deleted file mode 100644 index 09b3a5a..0000000 --- a/masque/test/test_gdsii_perf.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index f4f364b..0000000 --- a/masque/test/test_label.py +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index ce564aa..0000000 --- a/masque/test/test_library.py +++ /dev/null @@ -1,483 +0,0 @@ -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 deleted file mode 100644 index d0f4fa7..0000000 --- a/masque/test/test_manhattanize.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index f549db7..0000000 --- a/masque/test/test_oasis.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 914c23e..0000000 --- a/masque/test/test_pack2d.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index f8685f4..0000000 --- a/masque/test/test_path.py +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index fe8c0a6..0000000 --- a/masque/test/test_pather_autotool.py +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index fab1c68..0000000 --- a/masque/test/test_pather_constraints.py +++ /dev/null @@ -1,601 +0,0 @@ -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 deleted file mode 100644 index ddf869b..0000000 --- a/masque/test/test_pather_core.py +++ /dev/null @@ -1,538 +0,0 @@ -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 deleted file mode 100644 index 660d080..0000000 --- a/masque/test/test_pather_place_plug.py +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index d1945c1..0000000 --- a/masque/test/test_pather_primitive_offers.py +++ /dev/null @@ -1,659 +0,0 @@ -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 deleted file mode 100644 index 186faf4..0000000 --- a/masque/test/test_pather_rendering.py +++ /dev/null @@ -1,526 +0,0 @@ -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 deleted file mode 100644 index ed967dd..0000000 --- a/masque/test/test_pather_trace_into.py +++ /dev/null @@ -1,351 +0,0 @@ -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 deleted file mode 100644 index 26b1255..0000000 --- a/masque/test/test_pattern.py +++ /dev/null @@ -1,310 +0,0 @@ -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 deleted file mode 100644 index 70b17d2..0000000 --- a/masque/test/test_poly_collection.py +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 5d98ad9..0000000 --- a/masque/test/test_polygon.py +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index f17921f..0000000 --- a/masque/test/test_ports.py +++ /dev/null @@ -1,354 +0,0 @@ -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 deleted file mode 100644 index 3f642ab..0000000 --- a/masque/test/test_ports2data.py +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index 2f86ba0..0000000 --- a/masque/test/test_raw_constructors.py +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 449f4fa..0000000 --- a/masque/test/test_rect_collection.py +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index de330fa..0000000 --- a/masque/test/test_ref.py +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 00d2d7a..0000000 --- a/masque/test/test_repetition.py +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index f574f52..0000000 --- a/masque/test/test_rotation_consistency.py +++ /dev/null @@ -1,133 +0,0 @@ - -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 deleted file mode 100644 index fea4efa..0000000 --- a/masque/test/test_shape_ordering.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 2a9092a..0000000 --- a/masque/test/test_shape_transforms.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index c0dcd97..0000000 --- a/masque/test/test_svg.py +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 1b6770d..0000000 --- a/masque/test/test_text.py +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index f5e0215..0000000 --- a/masque/test/test_utils.py +++ /dev/null @@ -1,258 +0,0 @@ -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 deleted file mode 100644 index a20286c..0000000 --- a/masque/test/test_visualize.py +++ /dev/null @@ -1,53 +0,0 @@ -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 cca38f3..7c7360c 100644 --- a/masque/traits/__init__.py +++ b/masque/traits/__init__.py @@ -26,11 +26,7 @@ from .scalable import ( Scalable as Scalable, ScalableImpl as ScalableImpl, ) -from .mirrorable import ( - Mirrorable as Mirrorable, - Flippable as Flippable, - FlippableImpl as FlippableImpl, - ) +from .mirrorable import Mirrorable as Mirrorable 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 2a3a9fb..6d4ec3c 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,13 +1,6 @@ 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): """ @@ -18,17 +11,11 @@ class Mirrorable(metaclass=ABCMeta): @abstractmethod def mirror(self, axis: int = 0) -> Self: """ - 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. + Mirror the entity across an axis. Args: - axis: Axis to mirror across: - 0: X-axis (flip y coords), - 1: Y-axis (flip x coords) + axis: Axis to mirror across. + Returns: self """ @@ -36,11 +23,10 @@ class Mirrorable(metaclass=ABCMeta): def mirror2d(self, across_x: bool = False, across_y: bool = False) -> Self: """ - Optionally mirror the entity across both axes through its origin. + Optionally mirror the entity across both axes Args: - across_x: Mirror across the horizontal X-axis (flip Y coordinates). - across_y: Mirror across the vertical Y-axis (flip X coordinates). + axes: (mirror_across_x, mirror_across_y) Returns: self @@ -52,61 +38,30 @@ class Mirrorable(metaclass=ABCMeta): return self -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 +#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 +# # diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index dbf4fad..fbd765f 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 #noqa: PLC0415 + from ..repetition import Repetition 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 436d0a2..2fa86c1 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Self, cast, Any, TYPE_CHECKING from abc import ABCMeta, abstractmethod import numpy @@ -8,7 +8,8 @@ from numpy.typing import ArrayLike from ..error import MasqueError from ..utils import rotation_matrix_2d -from .positionable import Positionable +if TYPE_CHECKING: + from .positionable import Positionable _empty_slots = () # Workaround to get mypy to ignore intentionally empty slots for superclass @@ -25,8 +26,7 @@ class Rotatable(metaclass=ABCMeta): @abstractmethod def rotate(self, val: float) -> Self: """ - Intrinsic transformation: Rotate the shape around its origin (0, 0), ignoring its offset. - This does NOT affect the object's repetition grid. + Rotate the shape around its origin (0, 0), ignoring its offset. Args: val: Angle to rotate by (counterclockwise, radians) @@ -64,10 +64,6 @@ 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 @@ -85,9 +81,9 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): return self -class Pivotable(Positionable, metaclass=ABCMeta): +class Pivotable(metaclass=ABCMeta): """ - Trait class for entities which can be rotated around a point. + Trait class for entites which can be rotated around a point. This requires that they are `Positionable` but not necessarily `Rotatable` themselves. """ __slots__ = () @@ -95,11 +91,7 @@ class Pivotable(Positionable, metaclass=ABCMeta): @abstractmethod def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: """ - 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. + Rotate the object around a point. Args: pivot: Point (x, y) to rotate around @@ -111,21 +103,20 @@ class Pivotable(Positionable, metaclass=ABCMeta): pass -class PivotableImpl(Pivotable, Rotatable, metaclass=ABCMeta): +class PivotableImpl(Pivotable, 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) - self.translate(-pivot) - self.rotate(rotation) - if isinstance(self, Repeatable) and self.repetition is not None: - self.repetition.rotate(rotation) + cast('Positionable', self).translate(-pivot) + cast('Rotatable', self).rotate(rotation) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) - self.translate(+pivot) + cast('Positionable', self).translate(+pivot) return self diff --git a/masque/utils/__init__.py b/masque/utils/__init__.py index 8e10ca1..f33142f 100644 --- a/masque/utils/__init__.py +++ b/masque/utils/__init__.py @@ -10,11 +10,6 @@ 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 cef8006..e82d3db 100644 --- a/masque/utils/autoslots.py +++ b/masque/utils/autoslots.py @@ -17,12 +17,11 @@ class AutoSlots(ABCMeta): for base in bases: parents |= set(base.mro()) - slots = list(dctn.get('__slots__', ())) + slots = tuple(dctn.get('__slots__', ())) for parent in parents: if not hasattr(parent, '__annotations__'): continue - slots.extend(parent.__annotations__.keys()) + slots += tuple(parent.__annotations__.keys()) - # Deduplicate (dict to preserve order) - dctn['__slots__'] = tuple(dict.fromkeys(slots)) + dctn['__slots__'] = slots return super().__new__(cls, name, bases, dctn) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py deleted file mode 100644 index 5181fc5..0000000 --- a/masque/utils/boolean.py +++ /dev/null @@ -1,196 +0,0 @@ -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 bb2dfee..63981c9 100644 --- a/masque/utils/comparisons.py +++ b/masque/utils/comparisons.py @@ -9,15 +9,7 @@ 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 @@ -44,8 +36,6 @@ 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 @@ -57,7 +47,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 False + return keys_a < keys_b for key in keys_a: va = aa[key] diff --git a/masque/utils/curves.py b/masque/utils/curves.py index dd6450e..8b3fcc4 100644 --- a/masque/utils/curves.py +++ b/masque/utils/curves.py @@ -2,8 +2,10 @@ import numpy from numpy.typing import ArrayLike, NDArray from numpy import pi -from ..error import PatternError -from .vertices import remove_duplicate_vertices +try: + from numpy import trapezoid +except ImportError: + from numpy import trapz as trapezoid # type:ignore def bezier( @@ -29,11 +31,6 @@ 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) @@ -49,196 +46,59 @@ 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 an Euler bend (AKA Clothoid bend or Cornu spiral). - - Positive angles bend clockwise. By default, this generates the historical - 90 degree bend. + Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). 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 - `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. + `>= pi / 4`, no circular arc will be added. + num_points: Number of points in the curve 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 + 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) + 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 - xy_spiral = euler_spiral(switchover_angle, num_points_spiral) + 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_parts = [xy_spiral] - if arc_angle > 0: + if switchover_angle < pi / 4: # Build a circular segment to join the two euler portions rmin = 1.0 / ll_max - xy_arc = circular_arc( - rmin, - arc_angle, - num_points_arc, - start_angle=-switchover_angle, - start=xy_spiral[-1], - ) - xy_parts.append(xy_arc) + 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)) endpoint_xy = xy_parts[-1][-1, :] - second_spiral = euler_spiral( - switchover_angle, - num_points_spiral, - start_angle=-(total_angle - switchover_angle), - start=endpoint_xy, - reverse=True, - ) + second_spiral = xy_spiral[::-1, ::-1] + endpoint_xy - xy_spiral[-1, ::-1] - xy_parts.append(second_spiral[1:]) + xy_parts.append(second_spiral) xy = numpy.concatenate(xy_parts) # Remove any 2x-duplicate points - xy = remove_duplicate_vertices(xy, closed_path=False, tolerance=1e-12) + xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)] return xy diff --git a/masque/utils/deferreddict.py b/masque/utils/deferreddict.py index 70893c0..aff3bcc 100644 --- a/masque/utils/deferreddict.py +++ b/masque/utils/deferreddict.py @@ -1,11 +1,10 @@ from typing import TypeVar, Generic -from collections.abc import Callable, Iterator +from collections.abc import Callable from functools import lru_cache Key = TypeVar('Key') Value = TypeVar('Value') -_MISSING = object() class DeferredDict(dict, Generic[Key, Value]): @@ -26,73 +25,18 @@ class DeferredDict(dict, Generic[Key, Value]): """ def __init__(self, *args, **kwargs) -> None: dict.__init__(self) - if args or kwargs: - self.update(*args, **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(): - 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 + self[k] = v def __repr__(self) -> str: return '' @@ -102,4 +46,4 @@ class DeferredDict(dict, Generic[Key, Value]): Convenience function to avoid having to manually wrap constant values into callables. """ - self[key] = lambda v=value: v + self[key] = lambda: value diff --git a/masque/utils/pack2d.py b/masque/utils/pack2d.py index 248f408..ce6b006 100644 --- a/masque/utils/pack2d.py +++ b/masque/utils/pack2d.py @@ -60,12 +60,6 @@ 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] @@ -108,7 +102,7 @@ def maxrects_bssf( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -193,7 +187,7 @@ def guillotine_bssf_sas( if presort: unsort_order = rect_order.argsort() rect_locs = rect_locs[unsort_order] - rejected_inds = {int(rect_order[ii]) for ii in rejected_inds} + rejected_inds = set(unsort_order[list(rejected_inds)]) return rect_locs, rejected_inds @@ -242,9 +236,7 @@ def pack_patterns( locations, reject_inds = packer(sizes, containers, presort=presort, allow_rejects=allow_rejects) pat = Pattern() - for ii, (pp, oo, loc) in enumerate(zip(patterns, offsets, locations, strict=True)): - if ii in reject_inds: - continue + for pp, oo, loc in zip(patterns, offsets, locations, strict=True): 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 44a0ec3..b67fa0a 100644 --- a/masque/utils/ports2data.py +++ b/masque/utils/ports2data.py @@ -57,9 +57,11 @@ 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, - visited: set[int] | None = None, + # TODO missing ok? ) -> 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. @@ -68,30 +70,18 @@ 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. - name: Name of the pattern object. - max_depth: Maximum hierarcy depth to search. Default 0. + max_depth: Maximum hierarcy depth to search. Default 999_999. 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 @@ -109,20 +99,18 @@ 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, - visited = visited, + layers=layers, + library=library, + pattern=library[target], + name=target, + max_depth=max_depth - 1, + skip_subcells=skip_subcells, ) 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 @@ -134,14 +122,9 @@ 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(ports={**pattern.ports, **imported_ports}).check_ports(other_names=aa.ports.keys()) - imported_ports.update(aa.ports) - - pattern.ports.update(imported_ports) + pattern.check_ports(other_names=aa.ports.keys()) + pattern.ports.update(aa.ports) return pattern @@ -177,24 +160,13 @@ def data_to_ports_flat( local_ports = {} for label in labels: - 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 + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 xy = label.offset - angle = numpy.deg2rad(angle_deg) if numpy.isfinite(angle_deg) else None + angle = numpy.deg2rad(angle_deg) if name in local_ports: logger.warning(f'Duplicate port "{name}" in pattern "{pstr}"') @@ -203,3 +175,4 @@ def data_to_ports_flat( pattern.ports.update(local_ports) return pattern + diff --git a/masque/utils/ptypes.py b/masque/utils/ptypes.py deleted file mode 100644 index e5953b2..0000000 --- a/masque/utils/ptypes.py +++ /dev/null @@ -1,22 +0,0 @@ -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 7b39122..dfb6492 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -28,9 +28,8 @@ 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 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): + # If this was a manhattan rotation, round to remove some inacuraccies in sin & cos + if numpy.isclose(theta % (pi / 2), 0): arr = numpy.round(arr) arr.flags.writeable = False @@ -50,10 +49,7 @@ def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: `angle_to_rotate` in radians """ - 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) + mirrored_x, mirrored_y = mirrored mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle @@ -90,55 +86,37 @@ 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 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`). + 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`). Args: - 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 + 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 to the `oo`th `outer` transform applied to the `ii`th inner transform. - If `False` (default), this is concatenated into `(O*I)x5` to allow simple + If `False` (default), this is concatenated into `(O*I)x4` to allow simple chaining into additional `apply_transforms()` calls. Returns: - OxIx5 or (O*I)x5 array. Final dimension is - `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x, total_scale)`. + OxIx4 or (O*I)x4 array. Final dimension is + `(total_x, total_y, total_rotation_ccw_rad, net_mirrored_x)`. """ 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], 5)) + tot = numpy.empty((outer.shape[0], inner.shape[0], 4)) tot[:, :, :2] = outer[:, None, :2] + xy - - # 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 + tot[:, :, 2:] = outer[:, None, 2:] + inner[None, :, 2:] # sum rotations and mirrored + tot[:, :, 2] %= 2 * pi # clamp rot + tot[:, :, 3] %= 2 # clamp mirrored if tensor: return tot diff --git a/masque/utils/vertices.py b/masque/utils/vertices.py index 0eaf13e..5fddd52 100644 --- a/masque/utils/vertices.py +++ b/masque/utils/vertices.py @@ -5,11 +5,7 @@ import numpy from numpy.typing import NDArray, ArrayLike -def remove_duplicate_vertices( - vertices: ArrayLike, - closed_path: bool = True, - tolerance: float = 0.0, - ) -> NDArray[numpy.float64]: +def remove_duplicate_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any consecutive duplicates. @@ -17,36 +13,18 @@ def remove_duplicate_vertices( 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) - 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) + duplicates = (vertices == numpy.roll(vertices, -1, axis=0)).all(axis=1) if not closed_path: duplicates[-1] = False - - result = vertices[~duplicates] - if result.shape[0] == 0 and vertices.shape[0] > 0: - return vertices[:1] - return result + return vertices[~duplicates] -def remove_colinear_vertices( - vertices: ArrayLike, - closed_path: bool = True, - preserve_uturns: bool = False, - ) -> NDArray[numpy.float64]: +def remove_colinear_vertices(vertices: ArrayLike, closed_path: bool = True) -> NDArray[numpy.float64]: """ Given a list of vertices, remove any superflous vertices (i.e. those which lie along the line formed by their neighbors) @@ -55,40 +33,24 @@ def remove_colinear_vertices( 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, closed_path=closed_path) + vertices = remove_duplicate_vertices(vertices) # Check for dx0/dy0 == dx1/dy1 - dv = numpy.roll(vertices, -1, axis=0) - vertices - if not closed_path: - dv[-1] = 0 - # 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] + 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_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] @@ -96,7 +58,7 @@ def poly_contains_points( vertices: ArrayLike, points: ArrayLike, include_boundary: bool = True, - ) -> NDArray[numpy.bool_]: + ) -> NDArray[numpy.int_]: """ Tests whether the provided points are inside the implicitly closed polygon described by the provided list of vertices. @@ -115,13 +77,13 @@ def poly_contains_points( vertices = numpy.asarray(vertices, dtype=float) if points.size == 0: - return numpy.zeros(0, dtype=bool) + return numpy.zeros(0, dtype=numpy.int8) 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)) + | (points > max_bounds).any(axis=1)) # noqa: E128 nontrivial = ~trivially_outside if trivially_outside.all(): @@ -139,10 +101,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])) + - dv[:, 1] * (ntpts[..., 0] - verts[:, 0])) # noqa: E128 winding_number = ((upward & (is_left > 0)).sum(axis=0) - - (downward & (is_left < 0)).sum(axis=0)) + - (downward & (is_left < 0)).sum(axis=0)) # noqa: E128 nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number if include_boundary: diff --git a/pyproject.toml b/pyproject.toml index ad03fa9..9a29065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "masque" description = "Lithography mask library" @@ -42,40 +46,17 @@ 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.4"] +dxf = ["ezdxf~=1.0.2"] svg = ["svgwrite"] visualize = ["matplotlib"] text = ["matplotlib", "freetype-py"] -manhattanize = ["scikit-image"] -manhattanize_slow = ["float_raster"] -boolean = ["pyclipper"] +manhatanize_slow = ["float_raster"] [tool.ruff] @@ -106,20 +87,10 @@ 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 deleted file mode 100644 index f25475f..0000000 --- a/stubs/ezdxf/__init__.pyi +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 2c6efa9..0000000 --- a/stubs/ezdxf/entities.pyi +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 0dcf600..0000000 --- a/stubs/ezdxf/enums.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from enum import IntEnum - -class TextEntityAlignment(IntEnum): - BOTTOM_LEFT = ... diff --git a/stubs/ezdxf/layouts.pyi b/stubs/ezdxf/layouts.pyi deleted file mode 100644 index c9d12ad..0000000 --- a/stubs/ezdxf/layouts.pyi +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 08d77c8..0000000 --- a/stubs/pyclipper/__init__.pyi +++ /dev/null @@ -1,46 +0,0 @@ -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: ...