From b982fb9cd082c2abd451e964fcf12e872cb0876d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:57:44 +0200 Subject: [PATCH] hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 168 ++++++++++++++-- tools/blender/tests/test_hull_source.py | 200 +++++++++++++++++++- 2 files changed, 347 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index fb2aa246a..ca929560f 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -11,11 +11,16 @@ with it (a hull that redefines or shadows an included language fails a check): (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + lines ECHO line = [layer, cyclic, [[x, y, z], ...]] one line per kit part that draws one, points in the Ship Root + frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with +lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -49,9 +54,18 @@ OPENSCAD_TIMEOUT = 180 # seconds, per part SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} -ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_PARTS = ("brief", "lang", "anchors", "lines") # parts that make no geometry; their ECHO lines are the answer LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) +KIT_LAYERS = ("outline", "step", "seam", "fitting") # layers a kit's lines may name; "silhouette" is hull_source's own +LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)} +MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull +LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie +DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line + +# A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. +Projector = Callable[[list], "list[list[tuple]]"] # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. Runner = Callable[[str], "tuple[str, str]"] @@ -109,6 +123,16 @@ class Anchor: parent: str +@dataclass(frozen=True) +class SchematicLine: + layer: str # "silhouette" or one of KIT_LAYERS + cyclic: bool + points: tuple # ((x, y, z), ...) in the Ship Root frame + + def rgba(self): + return LINE_STYLE[self.layer] + + @dataclass(frozen=True) class Check: name: str @@ -129,6 +153,7 @@ class HullBuild: body_zone: str # the zone key the collider is made from hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) + lines: list = field(default_factory=list) # SchematicLine: the silhouette contours first, then the kit's lines def failed(self): return [check for check in self.checks if not check.ok] @@ -250,6 +275,47 @@ def read_anchors(text): return anchors +def read_lines(text): + """The kit's schematic lines, in echo order. A layer outside KIT_LAYERS, fewer than two points or a point that is not + three finite numbers is refused.""" + lines = [] + for raw in parse_echoes(text, "line"): + if not isinstance(raw, list) or len(raw) != 3 or type(raw[1]) is not bool or not isinstance(raw[2], list): + raise HullSourceError("A schematic line has the wrong shape") + layer = _text(raw[0]) + if layer not in KIT_LAYERS: + raise HullSourceError("A schematic line names a layer a kit does not draw") + if len(raw[2]) < 2: + raise HullSourceError("A schematic line has fewer than two points") + points = tuple(_vector(p, 3) for p in raw[2]) + if not all(math.isfinite(c) for p in points for c in p): + raise HullSourceError("A schematic line has a point that is not a finite number") + lines.append(SchematicLine(layer, raw[1], points)) + return lines + + +def to_off(mesh): + """The OFF text of a Mesh.""" + rows = ["OFF", f"{len(mesh.vertices)} {len(mesh.triangles)} 0"] + [" ".join(repr(float(c)) for c in v) for v in mesh.vertices] + return "\n".join(rows + [f"3 {a} {b} {c}" for a, b, c in mesh.triangles]) + "\n" + + +def parse_svg_contours(text): + """The contours of an OpenSCAD SVG (a path of 'M x,y L x,y ... z' pieces) as lists of model (x, y): the SVG's y is the + model's -y. A contour under MIN_SILHOUETTE_AREA is dropped (the zero-area slivers a projection leaves).""" + contours = [] + for path in re.findall(r'= 3 and area >= MIN_SILHOUETTE_AREA: + contours.append(ring) + return contours + + def _exactly_one(values, what): if len(values) != 1: raise HullSourceError(f"The hull source must echo exactly one {what}") @@ -289,9 +355,36 @@ def _language(library, faction): return language -def build(runner, library, hull_type): +def _point_in_ring(x, y, ring): + inside = False + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay): + inside = not inside + return inside + + +def _edge_distance(x, y, ring): + best = float("inf") + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + dx, dy = bx - ax, by - ay + along = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy) if dx or dy else 0.0 + along = max(0.0, min(1.0, along)) + best = min(best, math.hypot(x - (ax + along * dx), y - (ay + along * dy))) + return best + + +def over_silhouette(point, contours): + """Whether the point's top view lies inside the silhouette (even-odd over every contour) or within + LINE_EDGE_TOLERANCE of a contour's edge.""" + x, y = point[0], point[1] + return (sum(_point_in_ring(x, y, ring) for ring in contours) % 2 == 1 + or any(_edge_distance(x, y, ring) <= LINE_EDGE_TOLERANCE for ring in contours)) + + +def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every + zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -314,10 +407,16 @@ def build(runner, library, hull_type): if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + kit_lines = read_lines(runner("lines")[1]) judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) + meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + if meshes: + top = max(v[2] for m in meshes for v in m.vertices) + result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] + result.lines += kit_lines by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -366,6 +465,14 @@ def _checks(hull, own, judge): f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) except ValueError: checks.append(Check("the top view fits the layout grid", False, "no geometry")) + silhouette = [line for line in hull.lines if line.layer == "silhouette"] + kit = [line for line in hull.lines if line.layer != "silhouette"] + contours = [[p[:2] for p in line.points] for line in silhouette] + checks.append(Check("the top view yields a silhouette", bool(silhouette), f"{len(silhouette)} contours")) + outside = [line for line in kit if not all(over_silhouette(p, contours) for p in line.points)] if silhouette else kit + checks.append(Check("every kit line lies over the hull's top view", not outside, f"{len(outside)} outside")) + keys = [tuple(tuple(round(c / DUPLICATE_CELL) for c in p) for p in line.points) for line in kit] + checks.append(Check("no two kit lines are the same", len(set(keys)) == len(keys), f"{len(keys) - len(set(keys))} repeated")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) @@ -380,6 +487,25 @@ def _checks(hull, own, judge): return checks +def _run_openscad(command, on_start, **options): + """Runs one OpenSCAD process to completion and answers its output. The refusals are fixed text.""" + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **options) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + return output + + def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" @@ -390,20 +516,7 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', "-o", str(target), str(source)] - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - except OSError: - raise HullSourceError("OpenSCAD could not be started") from None - if on_start: - on_start(process.pid) - try: - output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) - except subprocess.TimeoutExpired: - process.kill() - process.communicate() - raise HullSourceError("OpenSCAD did not finish a part in time") from None - if process.returncode != 0: - raise HullSourceError("OpenSCAD refused a part of the hull source") + output = _run_openscad(command, on_start, env=env) written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" return ("", written + output) if part in ECHO_PARTS else (written, output) @@ -420,3 +533,24 @@ def language_library(executable, library=FACTIONS_DIR, on_start=None): return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] return read + + +def openscad_projector(executable, on_start=None): + """A Projector that has OpenSCAD (Manifold backend) project the union of the meshes onto the XY plane and export it as + SVG, one process, with the same timeout and refusal shape as openscad_runner.""" + def project(meshes): + with tempfile.TemporaryDirectory() as folder: + names = [] + for i, mesh in enumerate(meshes): + (Path(folder) / f"m{i}.off").write_text(to_off(mesh), encoding="utf-8") + names.append(f'import("m{i}.off");') + wrapper = Path(folder) / "silhouette.scad" + wrapper.write_text("projection() union() { " + " ".join(names) + " }\n", encoding="utf-8") + target = Path(folder) / "silhouette.svg" + command = [str(executable), "--backend", "Manifold", "-o", str(target), str(wrapper)] + _run_openscad(command, on_start, cwd=folder) + if not target.exists(): + raise HullSourceError("OpenSCAD refused a part of the hull source") + return parse_svg_contours(target.read_text(encoding="utf-8", errors="replace")) + + return project diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index bd534d83b..a1d8d163b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -67,8 +67,18 @@ class Fixture: + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + "lines": ("", ""), } + @staticmethod + def projector(meshes): + """A top view that is the bounding rectangle of every mesh it is given.""" + lows = [m.bounds()[0] for m in meshes] + highs = [m.bounds()[1] for m in meshes] + x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) + x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) + return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] + def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] @@ -78,7 +88,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library, self.hull_type) + return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] @@ -206,7 +216,7 @@ class HullTypeTests(unittest.TestCase): def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): - hs.build(fixture.runner(), fixture.read_library) + hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): @@ -214,7 +224,7 @@ class HullTypeTests(unittest.TestCase): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: - hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) @@ -479,7 +489,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) @@ -667,5 +677,187 @@ class RefusalTests(unittest.TestCase): self.assertEqual(source.count("subprocess.Popen"), 1) +LINES_OVER = "every kit line lies over the hull's top view" +LINES_DISTINCT = "no two kit lines are the same" +SILHOUETTE = "the top view yields a silhouette" + + +def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): + return echo("line", [layer, cyclic, [list(p) for p in points]]) + + +class LineReadingTests(unittest.TestCase): + def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): + text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") + read = hs.read_lines(text) + self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) + self.assertEqual(read[0].points[1], (1, 0, 1)) + + def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): + for layer in ("silhouette", "outlines", "", SECRET): + with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(line(layer)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): + for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', + 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): + with self.subTest(text=text), self.assertRaises(hs.HullSourceError): + hs.read_lines(text) + + def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) + + +SVG = """ + + + +""" + + +class SvgContourTests(unittest.TestCase): + def test_contours_come_back_in_model_coordinates_with_y_negated(self): + contours = hs.parse_svg_contours(SVG) + self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) + self.assertEqual(contours[1][0], (-1e-05, -2.0)) + + def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): + self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) + + def test_a_drawing_with_no_path_has_no_contours(self): + self.assertEqual(hs.parse_svg_contours(""), []) + + +class LineBuildTests(unittest.TestCase): + def built(self, kit="", **parts): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + fixture.parts.update(parts) + return fixture, fixture.build() + + def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): + _, built = self.built() + self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) + self.assertEqual(built.failed(), []) + + def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): + _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) + self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) + self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + + def test_each_layer_carries_its_fixed_style(self): + kit = "".join(line(layer) for layer in hs.KIT_LAYERS) + _, built = self.built(kit) + self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) + self.assertEqual(len({l.rgba() for l in built.lines}), 5) + self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) + + def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + fixture, given = Fixture(), [] + hs.build(fixture.runner(), fixture.read_library, "Ship", + lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) + self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + + def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): + _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): + _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) + self.assertEqual(built.failed(), []) + _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): + hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] + outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] + self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) + self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) + + def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): + _, built = self.built(line() + line("fitting")) + self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) + + def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): + _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) + self.assertEqual(built.failed(), []) + + def test_no_contour_fails_the_silhouette_check(self): + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) + self.assertEqual(built.lines, []) + + def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line()) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) + + def test_a_source_with_no_geometry_asks_for_no_silhouette(self): + fixture = Fixture() + fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) + self.assertEqual(built.lines, []) + + def test_a_refused_line_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line(SECRET)) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + +class WrapperOpenScad(FakeOpenScad): + """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" + + def __call__(self, command, **kwargs): + wrapper = Path(command[-1]) + self.wrapper = wrapper.read_text(encoding="utf-8") + self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} + return super().__call__(command, **kwargs) + + +class ProjectorTests(unittest.TestCase): + def project(self, openscad, meshes): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_projector("scad")(meshes) + + def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): + a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) + openscad = WrapperOpenScad(off=SVG) + contours = self.project(openscad, [a, b]) + self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) + self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) + self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) + command = openscad.calls[0][0] + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) + self.assertEqual(len(contours), 2) + + def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): + for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): + with self.assertRaises(hs.HullSourceError) as caught: + self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_the_process_is_reported_to_on_start(self): + started = [] + with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): + hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(started, [4242]) + + def test_the_lines_part_answers_the_echo(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): + self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) + + if __name__ == "__main__": unittest.main()