Compare commits
11 commits
e7f847d4c7
...
f34b9b2f5c
| Author | SHA1 | Date | |
|---|---|---|---|
| f34b9b2f5c | |||
| 89cdd23f00 | |||
| 620b001af5 | |||
| 46a3559391 | |||
| 08421d6a54 | |||
| 462a05a665 | |||
| 2b29e46b93 | |||
| 2e0b64bdab | |||
| 20c845a881 | |||
| 707a16fe64 | |||
| 932565d531 |
20 changed files with 387 additions and 30 deletions
|
|
@ -228,7 +228,17 @@ class Pather(PortList):
|
|||
@logged_op(lambda args: list(args['mapping'].keys()))
|
||||
def rename_ports(self, mapping: dict[str, str | None], overwrite: bool = False) -> Self:
|
||||
self.pattern.rename_ports(mapping, overwrite)
|
||||
renamed: dict[str, list[RenderStep]] = {vv: self.paths.pop(kk) for kk, vv in mapping.items() if kk in self.paths and vv is not None}
|
||||
renamed: dict[str, list[RenderStep]] = {}
|
||||
for kk, vv in mapping.items():
|
||||
if kk not in self.paths:
|
||||
continue
|
||||
steps = self.paths.pop(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.
|
||||
renamed[kk if vv is None else vv] = steps
|
||||
self.paths.update(renamed)
|
||||
return self
|
||||
|
||||
|
|
@ -789,6 +799,9 @@ class PortPather:
|
|||
#
|
||||
# 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)
|
||||
|
|
@ -865,8 +878,7 @@ class PortPather:
|
|||
|
||||
def drop(self) -> Self:
|
||||
""" Remove selected ports from the pattern and the PortPather. """
|
||||
for pp in self.ports:
|
||||
del self.pather.pattern.ports[pp]
|
||||
self.pather.rename_ports({pp: None for pp in self.ports})
|
||||
self.ports = []
|
||||
return self
|
||||
|
||||
|
|
@ -880,7 +892,7 @@ class PortPather:
|
|||
if name is None:
|
||||
self.drop()
|
||||
return None
|
||||
del self.pather.pattern.ports[name]
|
||||
self.pather.rename_ports({name: None})
|
||||
self.ports = [pp for pp in self.ports if pp != name]
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ class RenderStep:
|
|||
new_start = self.start_port.copy()
|
||||
new_end = self.end_port.copy()
|
||||
|
||||
new_start.mirror(axis)
|
||||
new_end.mirror(axis)
|
||||
new_start.flip_across(axis=axis)
|
||||
new_end.flip_across(axis=axis)
|
||||
|
||||
return RenderStep(
|
||||
opcode = self.opcode,
|
||||
|
|
@ -240,7 +240,7 @@ class Tool:
|
|||
BuildError if an impossible or unsupported geometry is requested.
|
||||
"""
|
||||
# Fallback implementation using traceL
|
||||
port_names = kwargs.get('port_names', ('A', 'B'))
|
||||
port_names = kwargs.pop('port_names', ('A', 'B'))
|
||||
tree = self.traceL(
|
||||
ccw,
|
||||
length,
|
||||
|
|
@ -288,7 +288,7 @@ class Tool:
|
|||
BuildError if an impossible or unsupported geometry is requested.
|
||||
"""
|
||||
# Fallback implementation using traceS
|
||||
port_names = kwargs.get('port_names', ('A', 'B'))
|
||||
port_names = kwargs.pop('port_names', ('A', 'B'))
|
||||
tree = self.traceS(
|
||||
length,
|
||||
jog,
|
||||
|
|
|
|||
|
|
@ -300,12 +300,57 @@ def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) ->
|
|||
)
|
||||
|
||||
if 'column_count' in attr:
|
||||
args['repetition'] = Grid(
|
||||
a_vector=(attr['column_spacing'], 0),
|
||||
b_vector=(0, attr['row_spacing']),
|
||||
a_count=attr['column_count'],
|
||||
b_count=attr['row_count'],
|
||||
col_spacing = attr['column_spacing']
|
||||
row_spacing = attr['row_spacing']
|
||||
col_count = attr['column_count']
|
||||
row_count = attr['row_count']
|
||||
local_x = numpy.array((col_spacing, 0.0))
|
||||
local_y = numpy.array((0.0, row_spacing))
|
||||
inv_rot = rotation_matrix_2d(-rotation)
|
||||
|
||||
candidates = (
|
||||
(inv_rot @ local_x, inv_rot @ local_y, col_count, row_count),
|
||||
(inv_rot @ local_y, inv_rot @ local_x, row_count, col_count),
|
||||
)
|
||||
repetition = None
|
||||
for a_vector, b_vector, a_count, b_count in candidates:
|
||||
rotated_a = rotation_matrix_2d(rotation) @ a_vector
|
||||
rotated_b = rotation_matrix_2d(rotation) @ b_vector
|
||||
if (numpy.isclose(rotated_a[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], row_spacing, atol=1e-8)
|
||||
and a_count == col_count
|
||||
and b_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
if (numpy.isclose(rotated_a[0], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[1], 0, atol=1e-8)
|
||||
and numpy.isclose(rotated_b[0], col_spacing, atol=1e-8)
|
||||
and numpy.isclose(rotated_a[1], row_spacing, atol=1e-8)
|
||||
and b_count == col_count
|
||||
and a_count == row_count):
|
||||
repetition = Grid(
|
||||
a_vector=a_vector,
|
||||
b_vector=b_vector,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
)
|
||||
break
|
||||
|
||||
if repetition is None:
|
||||
repetition = Grid(
|
||||
a_vector=inv_rot @ local_x,
|
||||
b_vector=inv_rot @ local_y,
|
||||
a_count=col_count,
|
||||
b_count=row_count,
|
||||
)
|
||||
args['repetition'] = repetition
|
||||
pat.ref(**args)
|
||||
else:
|
||||
logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).')
|
||||
|
|
|
|||
|
|
@ -714,7 +714,7 @@ def properties_to_annotations(
|
|||
string = repr(value)
|
||||
logger.warning(f'Converting property value for key ({key}) to string ({string})')
|
||||
values.append(string)
|
||||
annotations[key] = values
|
||||
annotations.setdefault(key, []).extend(values)
|
||||
return annotations
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl
|
|||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
self.string == other.string
|
||||
and numpy.array_equal(self.offset, other.offset)
|
||||
|
|
|
|||
|
|
@ -1037,6 +1037,18 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
def label2name(label: tuple) -> str: # noqa: ARG001
|
||||
return self.get_name(SINGLE_USE_PREFIX + 'shape')
|
||||
|
||||
used_names = set(self.keys())
|
||||
|
||||
def reserve_target_name(label: tuple) -> str:
|
||||
base_name = label2name(label)
|
||||
name = base_name
|
||||
ii = sum(1 for nn in used_names if nn.startswith(base_name)) if base_name in used_names else 0
|
||||
while name in used_names or name == '':
|
||||
name = base_name + b64suffix(ii)
|
||||
ii += 1
|
||||
used_names.add(name)
|
||||
return name
|
||||
|
||||
shape_counts: MutableMapping[tuple, int] = defaultdict(int)
|
||||
shape_funcs = {}
|
||||
|
||||
|
|
@ -1053,6 +1065,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
shape_counts[label] += 1
|
||||
|
||||
shape_pats = {}
|
||||
target_names = {}
|
||||
for label, count in shape_counts.items():
|
||||
if count < threshold:
|
||||
continue
|
||||
|
|
@ -1061,6 +1074,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
shape_pat = Pattern()
|
||||
shape_pat.shapes[label[-1]] += [shape_func()]
|
||||
shape_pats[label] = shape_pat
|
||||
target_names[label] = reserve_target_name(label)
|
||||
|
||||
# ## Second pass ##
|
||||
for pat in tuple(self.values()):
|
||||
|
|
@ -1085,14 +1099,14 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
# For repeated shapes, create a `Pattern` holding a normalized shape object,
|
||||
# and add `pat.refs` entries for each occurrence in pat. Also, note down that
|
||||
# we should delete the `pat.shapes` entries for which we made `Ref`s.
|
||||
shapes_to_remove = []
|
||||
for label, shape_entries in shape_table.items():
|
||||
layer = label[-1]
|
||||
target = label2name(label)
|
||||
target = target_names[label]
|
||||
shapes_to_remove = []
|
||||
for ii, values in shape_entries:
|
||||
offset, scale, rotation, mirror_x = values
|
||||
pat.ref(target=target, offset=offset, scale=scale,
|
||||
rotation=rotation, mirrored=(mirror_x, False))
|
||||
rotation=rotation, mirrored=mirror_x)
|
||||
shapes_to_remove.append(ii)
|
||||
|
||||
# Remove any shapes for which we have created refs.
|
||||
|
|
@ -1100,7 +1114,7 @@ class ILibrary(ILibraryView, MutableMapping[str, 'Pattern'], metaclass=ABCMeta):
|
|||
del pat.shapes[layer][ii]
|
||||
|
||||
for ll, pp in shape_pats.items():
|
||||
self[label2name(ll)] = pp
|
||||
self[target_names[ll]] = pp
|
||||
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ class Ref(
|
|||
return annotations_lt(self.annotations, other.annotations)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return False
|
||||
return (
|
||||
numpy.array_equal(self.offset, other.offset)
|
||||
and self.mirrored == other.mirrored
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class Repetition(Copyable, Rotatable, Mirrorable, Scalable, Bounded, metaclass=A
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __le__(self, other: 'Repetition') -> bool:
|
||||
def __lt__(self, other: 'Repetition') -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -288,7 +288,7 @@ class Grid(Repetition):
|
|||
return False
|
||||
return True
|
||||
|
||||
def __le__(self, other: Repetition) -> bool:
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Grid', other)
|
||||
|
|
@ -347,7 +347,7 @@ class Arbitrary(Repetition):
|
|||
return False
|
||||
return numpy.array_equal(self.displacements, other.displacements)
|
||||
|
||||
def __le__(self, other: Repetition) -> bool:
|
||||
def __lt__(self, other: Repetition) -> bool:
|
||||
if type(self) is not type(other):
|
||||
return repr(type(self)) < repr(type(other))
|
||||
other = cast('Arbitrary', other)
|
||||
|
|
@ -415,4 +415,3 @@ class Arbitrary(Repetition):
|
|||
"""
|
||||
self.displacements = self.displacements * c
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ class Arc(PositionableImpl, Shape):
|
|||
rotation %= 2 * pi
|
||||
width = self.width
|
||||
|
||||
return ((type(self), radii, norm_angles, width / norm_value),
|
||||
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value),
|
||||
(self.offset, scale / norm_value, rotation, False),
|
||||
lambda: Arc(
|
||||
radii=radii * norm_value,
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ class Ellipse(PositionableImpl, Shape):
|
|||
radii = self.radii[::-1] / self.radius_y
|
||||
scale = self.radius_y
|
||||
angle = (self.rotation + pi / 2) % pi
|
||||
return ((type(self), radii),
|
||||
return ((type(self), tuple(radii.tolist())),
|
||||
(self.offset, scale / norm_value, angle, False),
|
||||
lambda: Ellipse(radii=radii * norm_value))
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
and self.string == other.string
|
||||
and self.height == other.height
|
||||
and self.font_path == other.font_path
|
||||
and self.mirrored == other.mirrored
|
||||
and self.rotation == other.rotation
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
|
|
@ -127,6 +128,8 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
return self.font_path < other.font_path
|
||||
if not numpy.array_equal(self.offset, other.offset):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.mirrored != other.mirrored:
|
||||
return self.mirrored < other.mirrored
|
||||
if self.rotation != other.rotation:
|
||||
return self.rotation < other.rotation
|
||||
if self.repetition != other.repetition:
|
||||
|
|
@ -174,22 +177,25 @@ class Text(PositionableImpl, RotatableImpl, Shape):
|
|||
(self.offset, self.height / norm_value, rotation, bool(self.mirrored)),
|
||||
lambda: Text(
|
||||
string=self.string,
|
||||
height=self.height * norm_value,
|
||||
height=norm_value,
|
||||
font_path=self.font_path,
|
||||
rotation=rotation,
|
||||
).mirror2d(across_x=self.mirrored),
|
||||
)
|
||||
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64]:
|
||||
def get_bounds_single(self) -> NDArray[numpy.float64] | None:
|
||||
# rotation makes this a huge pain when using slot.advance and glyph.bbox(), so
|
||||
# just convert to polygons instead
|
||||
polys = self.to_polygons()
|
||||
if not polys:
|
||||
return None
|
||||
|
||||
pbounds = numpy.full((len(polys), 2, 2), nan)
|
||||
for pp, poly in enumerate(polys):
|
||||
pbounds[pp] = poly.get_bounds_nonempty()
|
||||
bounds = numpy.vstack((
|
||||
numpy.min(pbounds[: 0, :], axis=0),
|
||||
numpy.max(pbounds[: 1, :], axis=0),
|
||||
numpy.min(pbounds[:, 0, :], axis=0),
|
||||
numpy.max(pbounds[:, 1, :], axis=0),
|
||||
))
|
||||
|
||||
return bounds
|
||||
|
|
|
|||
|
|
@ -112,6 +112,38 @@ def test_dxf_manhattan_precision(tmp_path: Path):
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -46,3 +46,9 @@ def test_label_copy() -> None:
|
|||
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())
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from ..pattern import Pattern
|
|||
from ..error import LibraryError, PatternError
|
||||
from ..ports import Port
|
||||
from ..repetition import Grid
|
||||
from ..shapes import Path
|
||||
from ..shapes import Arc, Ellipse, Path, Text
|
||||
from ..file.utils import preflight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -259,3 +259,88 @@ def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None:
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from numpy.testing import assert_equal
|
|||
|
||||
from ..pattern import Pattern
|
||||
from ..library import Library
|
||||
|
||||
|
||||
def test_oasis_roundtrip(tmp_path: Path) -> None:
|
||||
# Skip if fatamorgana is not installed
|
||||
pytest.importorskip("fatamorgana")
|
||||
|
|
@ -23,3 +25,20 @@ def test_oasis_roundtrip(tmp_path: Path) -> None:
|
|||
|
||||
# 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]}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import pytest
|
|||
import numpy
|
||||
from numpy import pi
|
||||
from masque import Pather, RenderPather, Library, Pattern, Port
|
||||
from masque.builder.tools import PathTool
|
||||
from masque.builder.tools import PathTool, Tool
|
||||
from masque.error import BuildError
|
||||
|
||||
def test_pather_trace_basic() -> None:
|
||||
|
|
@ -258,6 +258,36 @@ def test_pather_jog_failed_fallback_is_atomic() -> None:
|
|||
assert len(p.paths['A']) == 0
|
||||
|
||||
|
||||
def test_tool_planL_fallback_accepts_custom_port_names() -> None:
|
||||
class DummyTool(Tool):
|
||||
def traceL(self, ccw, length, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
||||
pat.ports[port_names[1]] = Port((length, 0), pi, ptype='wire')
|
||||
lib['top'] = pat
|
||||
return lib
|
||||
|
||||
out_port, _ = DummyTool().planL(None, 5, port_names=('X', 'Y'))
|
||||
assert numpy.allclose(out_port.offset, (5, 0))
|
||||
assert numpy.isclose(out_port.rotation, pi)
|
||||
|
||||
|
||||
def test_tool_planS_fallback_accepts_custom_port_names() -> None:
|
||||
class DummyTool(Tool):
|
||||
def traceS(self, length, jog, *, in_ptype=None, out_ptype=None, port_names=('A', 'B'), **kwargs) -> Library:
|
||||
lib = Library()
|
||||
pat = Pattern()
|
||||
pat.ports[port_names[0]] = Port((0, 0), 0, ptype='wire')
|
||||
pat.ports[port_names[1]] = Port((length, jog), pi, ptype='wire')
|
||||
lib['top'] = pat
|
||||
return lib
|
||||
|
||||
out_port, _ = DummyTool().planS(5, 2, port_names=('X', 'Y'))
|
||||
assert numpy.allclose(out_port.offset, (5, 2))
|
||||
assert numpy.isclose(out_port.rotation, pi)
|
||||
|
||||
|
||||
def test_pather_uturn_failed_fallback_is_atomic() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=2, ptype='wire')
|
||||
|
|
@ -270,3 +300,20 @@ def test_pather_uturn_failed_fallback_is_atomic() -> None:
|
|||
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_renderpather_rename_to_none_keeps_pending_geometry_without_port() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer='M1', width=1000)
|
||||
rp = RenderPather(lib, tools=tool)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -87,3 +87,9 @@ def test_ref_scale_by_rejects_nonpositive_scale() -> None:
|
|||
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -65,6 +65,17 @@ def test_renderpather_bend(rpather_setup: tuple[RenderPather, PathTool, Library]
|
|||
assert_allclose(path_shape.vertices, [[0, 0], [0, -10], [0, -20], [-1, -20]], atol=1e-10)
|
||||
|
||||
|
||||
def test_renderpather_mirror_preserves_planned_bend_geometry(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = rpather_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, 20], [-1, 20]], atol=1e-10)
|
||||
|
||||
|
||||
def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool1, lib = rpather_setup
|
||||
tool2 = PathTool(layer=(2, 0), width=4, ptype="wire")
|
||||
|
|
@ -79,6 +90,22 @@ def test_renderpather_retool(rpather_setup: tuple[RenderPather, PathTool, Librar
|
|||
assert len(rp.pattern.shapes[(2, 0)]) == 1
|
||||
|
||||
|
||||
def test_portpather_translate_only_affects_future_steps(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = rpather_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_renderpather_dead_ports() -> None:
|
||||
lib = Library()
|
||||
tool = PathTool(layer=(1, 0), width=1)
|
||||
|
|
@ -121,6 +148,20 @@ def test_renderpather_rename_port(rpather_setup: tuple[RenderPather, PathTool, L
|
|||
assert_allclose(rp.ports["new_start"].offset, [0, -20], atol=1e-10)
|
||||
|
||||
|
||||
def test_renderpather_drop_keeps_pending_geometry_without_port(rpather_setup: tuple[RenderPather, PathTool, Library]) -> None:
|
||||
rp, tool, lib = rpather_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_traceL_bend_geometry_matches_ports() -> None:
|
||||
tool = PathTool(layer=(1, 0), width=2, ptype="wire")
|
||||
|
||||
|
|
|
|||
|
|
@ -49,3 +49,17 @@ def test_arbitrary_transform() -> None:
|
|||
# self.displacements[:, 1 - axis] *= -1
|
||||
# if axis=0, 1-axis=1, so y *= -1
|
||||
assert_allclose(arb.displacements, [[0, -10]], atol=1e-10)
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,33 @@ def test_text_to_polygons() -> None:
|
|||
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)
|
||||
|
||||
|
||||
# 2. Manhattanization tests
|
||||
def test_manhattanize() -> None:
|
||||
pytest.importorskip("float_raster")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue