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:
parent
28ed24b28e
commit
b982fb9cd0
2 changed files with 347 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue