hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 15:57:44 +02:00
commit b982fb9cd0
2 changed files with 347 additions and 21 deletions

View file

@ -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/<faction>.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
<zone> the zone's geometry, OFF; a donor's zone is named "<donor>:<zone>"
emit:<id>, rad:<id> 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'<path\s+d="([^"]*)"', text):
for piece in path.split("z"):
try:
ring = [(float(x), -float(y)) for x, y in re.findall(r"(-?[\d.]+(?:e[-+]?\d+)?),(-?[\d.]+(?:e[-+]?\d+)?)", piece)]
except ValueError:
raise HullSourceError("OpenSCAD wrote a silhouette that is not readable") from None
area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:] + ring[:1]))) / 2
if len(ring) >= 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 <zhestokost.scad>."""
@ -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

View file

@ -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 = """<?xml version="1.0" standalone="no"?>
<svg width="9mm" height="9mm" viewBox="0 -4 9 9" xmlns="http://www.w3.org/2000/svg" version="1.1">
<path d="
M 0,-4 L 4,-4 L 4,-1 L 0,-1 z
M 5,1 L 5.001,1 L 5.001,1.001 z
M -1e-05,2 L 2,2 L 2,3 L 0,3 z
" fill="lightgray" stroke="black" stroke-width="0.5"/>
</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("<svg></svg>"), [])
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()