Compare commits

...

11 commits

20 changed files with 387 additions and 30 deletions

View file

@ -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

View file

@ -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,

View file

@ -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).')

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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))

View file

@ -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

View file

@ -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()

View file

@ -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())

View file

@ -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)

View file

@ -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]}

View file

@ -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

View file

@ -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())

View file

@ -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")

View file

@ -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

View file

@ -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")