hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
"""Blender-free tests for aetheria_ships/hull_source.py, which reads a hull.scad through OpenSCAD and judges it against its
|
|
|
|
|
faction's form language. A fake runner answers each part with fixture OFF and ECHO text, so no OpenSCAD runs here; the
|
|
|
|
|
real OpenSCAD and the real language files are exercised by smoke_hull_source.py.
|
|
|
|
|
|
|
|
|
|
python -m unittest discover -s tools/blender/tests -p "test_hull_source.py"
|
|
|
|
|
|
|
|
|
|
hull_source is loaded by path because the package __init__ imports bpy.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-10-22 16:11:53 +02:00
|
|
|
import dataclasses
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
import importlib.util
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
import tempfile
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
from unittest import mock
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
|
|
|
"hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py")
|
|
|
|
|
hs = importlib.util.module_from_spec(_spec)
|
|
|
|
|
sys.modules["hull_source"] = hs
|
|
|
|
|
_spec.loader.exec_module(hs)
|
|
|
|
|
|
|
|
|
|
SECRET = "SECRET-NAME-7" # a value a hull source supplies and no refusal may echo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def box(x0, x1, y0, y1, z0, z1):
|
|
|
|
|
"""An OFF text for an axis-aligned box: 8 vertices, 6 quads (12 triangles once fanned)."""
|
|
|
|
|
v = [(x, y, z) for z in (z0, z1) for y in (y0, y1) for x in (x0, x1)]
|
|
|
|
|
quads = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
|
|
|
|
|
rows = ["OFF", "8 6 0"] + [f"{x} {y} {z}" for x, y, z in v] + [f"4 {a} {b} {c} {d}" for a, b, c, d in quads]
|
|
|
|
|
return "\n".join(rows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def echo(name, value):
|
|
|
|
|
return f"ECHO: {name} = {json.dumps(value)}\n"
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 00:20:00 +02:00
|
|
|
MAKER = "faction-record-key"
|
|
|
|
|
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)),
|
2026-10-19 00:20:00 +02:00
|
|
|
symmetry=("bilateral", 0.97), maker=MAKER):
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
palette = [[z, [0.1 * (i + 1), 0.2, 0.3]] for i, z in enumerate(zones)]
|
|
|
|
|
return echo("lang", [faction, palette, body, [1, 0.6, 0.2], [0.5, 0.5, 0.5], [list(p) for p in proportion] if proportion
|
2026-10-19 00:20:00 +02:00
|
|
|
else [], list(symmetry), maker])
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)):
|
|
|
|
|
return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]])
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 17:09:04 +02:00
|
|
|
def marks_off(points, size=0.002):
|
|
|
|
|
"""An OFF text of one tiny cube per point, as the kit's marks part is."""
|
|
|
|
|
rows, faces = [], []
|
|
|
|
|
for i, (x, y, z) in enumerate(points):
|
|
|
|
|
h = size / 2
|
|
|
|
|
rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)]
|
|
|
|
|
b = 8 * i
|
|
|
|
|
faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4),
|
|
|
|
|
(b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)]
|
|
|
|
|
return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows]
|
|
|
|
|
+ ["4 " + " ".join(map(str, f)) for f in faces]) + "\n"
|
|
|
|
|
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
class Fixture:
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
"""A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own
|
|
|
|
|
language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise."""
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
def __init__(self):
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
self.library = {"zhestokost": language()}
|
|
|
|
|
self.hull_lang = None
|
2026-10-22 01:03:27 +02:00
|
|
|
self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package
|
2026-10-21 20:26:02 +02:00
|
|
|
# Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle.
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
self.parts = {
|
|
|
|
|
"brief": ("", brief()),
|
|
|
|
|
"primary": (box(-3, 3, -5, 5, 0, 2), ""),
|
|
|
|
|
"secondary": (box(-1, 1, -1, 1, 2, 3), ""),
|
|
|
|
|
"anchors": ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])
|
|
|
|
|
+ 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), ""),
|
2026-10-22 15:57:44 +02:00
|
|
|
"lines": ("", ""),
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
}
|
2026-10-22 17:09:04 +02:00
|
|
|
self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
2026-10-22 15:57:44 +02:00
|
|
|
@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)]]
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def runner(self):
|
2026-10-22 17:09:04 +02:00
|
|
|
return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part]
|
|
|
|
|
|
|
|
|
|
def mark_part(self):
|
|
|
|
|
"""What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point."""
|
|
|
|
|
if self.marks is not None:
|
|
|
|
|
return self.marks
|
|
|
|
|
return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "")
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
|
|
|
|
|
def read_library(self, faction):
|
|
|
|
|
if faction not in self.library:
|
|
|
|
|
raise hs.HullSourceError("no such language file")
|
|
|
|
|
return self.library[faction]
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
def build(self):
|
2026-10-22 15:57:44 +02:00
|
|
|
return hs.build(self.runner(), self.read_library, self.hull_type, self.projector)
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
def failed(self):
|
|
|
|
|
return [c.name for c in self.build().failed()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReadingTests(unittest.TestCase):
|
|
|
|
|
def test_off_fans_polygons_into_triangles(self):
|
|
|
|
|
mesh = hs.parse_off(box(0, 1, 0, 1, 0, 1))
|
|
|
|
|
self.assertEqual((len(mesh.vertices), len(mesh.triangles)), (8, 12))
|
|
|
|
|
self.assertEqual(mesh.bounds(), ((0, 0, 0), (1, 1, 1)))
|
|
|
|
|
|
|
|
|
|
def test_off_accepts_a_header_with_counts_on_the_first_line_and_skips_comments(self):
|
|
|
|
|
mesh = hs.parse_off("OFF 3 1 0\n# c\n0 0 0\n1 0 0\n0 1 0\n3 0 1 2\n")
|
|
|
|
|
self.assertEqual(mesh.triangles, ((0, 1, 2),))
|
|
|
|
|
|
|
|
|
|
def test_nothing_is_an_empty_mesh_and_a_broken_file_is_refused_without_echoing_it(self):
|
|
|
|
|
self.assertEqual(hs.parse_off("").triangles, ())
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.parse_off(f"OFF\n3 1 0\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9 {SECRET}\n")
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
2026-10-18 22:04:42 +02:00
|
|
|
def test_a_face_naming_the_vertex_one_past_the_last_is_refused(self):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
hs.parse_off("\n".join(["OFF", "3 1 0", "0 0 0", "1 0 0", "0 1 0", "3 0 1 3"]))
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_echoes_are_read_by_name_and_only_plain_values_are_accepted(self):
|
|
|
|
|
text = echo("anchor", ["a", 1]) + echo("other", [2]) + echo("anchor", ["b", 2.5e-05])
|
|
|
|
|
self.assertEqual(hs.parse_echoes(text, "anchor"), [["a", 1], ["b", 2.5e-05]])
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.parse_echoes(f"ECHO: anchor = [{SECRET}, undef]\n", "anchor")
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_a_brief_is_exactly_one_echo_of_six_fields(self):
|
|
|
|
|
self.assertEqual(hs.read_brief(brief()).faction, "zhestokost")
|
|
|
|
|
for text in ("", brief() + brief(), echo("brief", ["x"])):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
hs.read_brief(text)
|
|
|
|
|
|
|
|
|
|
def test_a_pirate_language_reads_with_no_body_and_no_proportion(self):
|
|
|
|
|
pirate = hs.read_languages(language("pirate", ("primer", "weld"), body="", proportion=None,
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
symmetry=("asymmetric", 0.9)))[0]
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
self.assertEqual((pirate.body, pirate.proportion, pirate.symmetry), ("", None, ("asymmetric", 0.9)))
|
|
|
|
|
|
|
|
|
|
def test_a_language_naming_an_unknown_rule_or_a_body_outside_its_zones_is_refused(self):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
hs.read_languages(language(symmetry=("mostly", 0.5)))
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
hs.read_languages(language(body="keel"))
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 01:03:27 +02:00
|
|
|
PROPORTION = "the body's proportions lie inside the language's ranges"
|
|
|
|
|
GRID = "the top view fits the layout grid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def names(built):
|
|
|
|
|
return [c.name for c in built.checks]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HullTypeTests(unittest.TestCase):
|
|
|
|
|
"""A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type."""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def fixture(hull_type, **parts):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_type = hull_type
|
|
|
|
|
fixture.parts.update({k: (v, "") for k, v in parts.items()})
|
|
|
|
|
return fixture
|
|
|
|
|
|
|
|
|
|
def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self):
|
|
|
|
|
wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges
|
|
|
|
|
for hull_type in ("Station", "Turret"):
|
|
|
|
|
with self.subTest(hull_type):
|
|
|
|
|
built = self.fixture(hull_type, primary=wide).build()
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [])
|
|
|
|
|
self.assertNotIn(PROPORTION, names(built))
|
|
|
|
|
self.assertEqual(built.hull_type, hull_type)
|
|
|
|
|
self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION])
|
|
|
|
|
|
|
|
|
|
def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self):
|
|
|
|
|
self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build()))
|
|
|
|
|
ship = self.fixture("Ship", primary="").build()
|
|
|
|
|
self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"])
|
|
|
|
|
|
|
|
|
|
def test_a_ship_lists_the_proportion_check_when_it_passes(self):
|
|
|
|
|
built = self.fixture("Ship").build()
|
|
|
|
|
self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True])
|
|
|
|
|
|
|
|
|
|
def test_a_station_is_still_judged_on_symmetry_length_and_budget(self):
|
|
|
|
|
self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(),
|
|
|
|
|
["the top view obeys the language's bilateral rule"])
|
|
|
|
|
self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(),
|
|
|
|
|
["the length lies inside the brief's range"])
|
|
|
|
|
station = self.fixture("Station")
|
|
|
|
|
station.parts["brief"] = ("", brief(triangles=(10, 20)))
|
|
|
|
|
self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"])
|
|
|
|
|
|
|
|
|
|
def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self):
|
|
|
|
|
for hull_type in ("Ship", "Station", "Turret"):
|
|
|
|
|
with self.subTest(hull_type):
|
|
|
|
|
fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build()
|
|
|
|
|
self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID],
|
|
|
|
|
[(True, "32 x 5 cells, at most 32 a side")])
|
|
|
|
|
wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3))
|
|
|
|
|
self.assertEqual(wide.failed(), [GRID])
|
|
|
|
|
self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID],
|
|
|
|
|
["33 x 5 cells, at most 32 a side"])
|
2026-10-22 01:15:11 +02:00
|
|
|
long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3))
|
|
|
|
|
long.parts["brief"] = ("", brief(length=(8, 70)))
|
|
|
|
|
self.assertEqual(long.failed(), [])
|
|
|
|
|
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"])
|
2026-10-22 01:03:27 +02:00
|
|
|
long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3))
|
|
|
|
|
long.parts["brief"] = ("", brief(length=(8, 70)))
|
|
|
|
|
self.assertEqual(long.failed(), [GRID])
|
|
|
|
|
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"])
|
|
|
|
|
|
|
|
|
|
def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self):
|
|
|
|
|
built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build()
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [])
|
|
|
|
|
self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"])
|
|
|
|
|
|
|
|
|
|
def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self):
|
|
|
|
|
built = self.fixture("Station", primary="", secondary="").build()
|
|
|
|
|
self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")])
|
|
|
|
|
|
|
|
|
|
def test_build_has_no_default_hull_type(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
with self.assertRaises(TypeError):
|
2026-10-22 15:57:44 +02:00
|
|
|
hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector)
|
2026-10-22 01:03:27 +02:00
|
|
|
|
2026-10-22 01:27:38 +02:00
|
|
|
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):
|
|
|
|
|
with self.subTest(hull_type=hull_type):
|
|
|
|
|
fixture, read = Fixture(), []
|
|
|
|
|
runner = fixture.runner()
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
2026-10-22 15:57:44 +02:00
|
|
|
hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector)
|
2026-10-22 01:27:38 +02:00
|
|
|
self.assertEqual(read, [])
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self):
|
|
|
|
|
for hull_type in ("Ship", "Station", "Turret"):
|
|
|
|
|
with self.subTest(hull_type):
|
|
|
|
|
built = self.fixture(hull_type).build()
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [])
|
|
|
|
|
self.assertEqual(PROPORTION in names(built), hull_type == "Ship")
|
|
|
|
|
|
2026-10-22 01:03:27 +02:00
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
class MirrorOverlapTests(unittest.TestCase):
|
|
|
|
|
@staticmethod
|
|
|
|
|
def top(text):
|
|
|
|
|
mesh = hs.parse_off(text)
|
|
|
|
|
return hs._triangles_xy(mesh)
|
|
|
|
|
|
|
|
|
|
def test_a_centred_box_overlaps_its_reflection_completely(self):
|
|
|
|
|
self.assertEqual(hs.mirror_overlap(self.top(box(-2, 2, 0, 4, 0, 1))), 1.0)
|
|
|
|
|
|
2026-10-18 22:04:42 +02:00
|
|
|
def test_nothing_overlaps_itself_completely(self):
|
|
|
|
|
self.assertEqual(hs.mirror_overlap([]), 1.0)
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_a_box_on_one_side_does_not_overlap_it_at_all(self):
|
|
|
|
|
self.assertEqual(hs.mirror_overlap(self.top(box(1, 3, 0, 4, 0, 1))), 0.0)
|
|
|
|
|
|
|
|
|
|
def test_a_box_straddling_the_axis_unevenly_overlaps_by_the_shared_part(self):
|
|
|
|
|
# x in [-1, 3]: the reflection covers [-3, 1]; shared [-1, 1] = 2 of a union 6 wide
|
|
|
|
|
self.assertAlmostEqual(hs.mirror_overlap(self.top(box(-1, 3, 0, 4, 0, 1))), 2 / 6, places=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class JudgementTests(unittest.TestCase):
|
|
|
|
|
def test_a_hull_that_keeps_every_rule_passes_every_check(self):
|
|
|
|
|
self.assertEqual(Fixture().failed(), [])
|
|
|
|
|
|
|
|
|
|
def test_the_language_decides_proportions_not_the_hull(self):
|
|
|
|
|
# The same 6 x 10 x 2 body: inside Zhestokost's ranges, outside a language that wants tall narrow bodies.
|
|
|
|
|
fixture = Fixture()
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library["zhestokost"] = language(proportion=((0.8, 1.2), (0.1, 0.3)))
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"])
|
|
|
|
|
|
|
|
|
|
def test_a_body_too_tall_for_the_language_fails_proportion_and_nothing_else(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "")
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"])
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_body_too_wide_for_its_length_fails_proportion_and_nothing_else(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["primary"] = (box(-6, 6, -5, 5, 0, 4), "") # h/w 0.33 but w/l 1.2
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the body's proportions lie inside the language's ranges"])
|
|
|
|
|
|
|
|
|
|
def test_every_zone_of_the_language_is_coloured_with_its_own_palette_entry(self):
|
|
|
|
|
built = Fixture().build()
|
|
|
|
|
self.assertEqual(built.colours, {"primary": (0.1, 0.2, 0.3), "secondary": (0.2, 0.2, 0.3)})
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_a_body_too_narrow_for_the_language_fails_proportion(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["primary"] = (box(-1, 1, -5, 5, 0, 1), "")
|
|
|
|
|
self.assertIn("the body's proportions lie inside the language's ranges", fixture.failed())
|
|
|
|
|
|
|
|
|
|
def test_a_bilateral_language_refuses_a_lopsided_hull(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "")
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the top view obeys the language's bilateral rule"])
|
|
|
|
|
|
2026-10-18 22:04:42 +02:00
|
|
|
def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self):
|
|
|
|
|
fixture = Fixture()
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library["zhestokost"] = language(symmetry=("bilateral", 1.0))
|
2026-10-18 22:04:42 +02:00
|
|
|
self.assertEqual(fixture.failed(), [])
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_an_asymmetric_language_refuses_a_symmetric_hull(self):
|
|
|
|
|
fixture = Fixture()
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9))
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
self.assertEqual(fixture.failed(), ["the top view obeys the language's asymmetric rule"])
|
|
|
|
|
|
|
|
|
|
def test_an_asymmetric_language_accepts_a_lopsided_hull(self):
|
|
|
|
|
fixture = Fixture()
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9))
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
fixture.parts["secondary"] = (box(2, 8, -4, 4, 0, 3), "")
|
|
|
|
|
self.assertEqual(fixture.failed(), [])
|
|
|
|
|
|
|
|
|
|
def test_a_zone_the_language_names_must_have_geometry(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["secondary"] = ("", "")
|
|
|
|
|
self.assertEqual(fixture.failed(), ["every zone of the language has geometry"])
|
|
|
|
|
|
|
|
|
|
def test_the_triangle_budget_is_a_range_with_two_ends(self):
|
|
|
|
|
for budget in ((10, 20), (1000, 2000)):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief(triangles=budget))
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"], budget)
|
|
|
|
|
|
2026-10-18 22:04:42 +02:00
|
|
|
def test_the_budget_counts_the_triangles_of_the_emitter_meshes_too(self):
|
|
|
|
|
# 12 + 12 triangles of zones, 12 of the emitter: a budget of 30 holds without the emitter and not with it
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief(triangles=(10, 30)))
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the triangle count lies inside the brief's budget"])
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_the_length_is_a_range_with_two_ends(self):
|
|
|
|
|
for length in ((1, 5), (30, 40)):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief(length=length))
|
|
|
|
|
self.assertEqual(fixture.failed(), ["the length lies inside the brief's range"], length)
|
|
|
|
|
|
2026-10-21 20:26:02 +02:00
|
|
|
MUZZLE = "every weapon-mount has a muzzle and no muzzle is orphaned"
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
2026-10-21 20:26:17 +02:00
|
|
|
def test_a_muzzle_facing_any_way_is_accepted(self):
|
|
|
|
|
# Which way a gun points is HullData's hardpoint rotation; the hull source never judges it.
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])
|
|
|
|
|
+ echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, 1, 3], 0, "Gun.A"])
|
|
|
|
|
+ echo("anchor", ["Gun.B", "weapon-mount", [0, 0, 3], 0, ""])
|
|
|
|
|
+ echo("anchor", ["Gun.B.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.B"])
|
|
|
|
|
+ echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""]))
|
|
|
|
|
self.assertEqual(fixture.failed(), [])
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_a_mount_without_a_muzzle_and_a_muzzle_without_a_mount_are_refused(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""]))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.MUZZLE])
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.MUZZLE])
|
2026-10-21 20:31:47 +02:00
|
|
|
# Another gun's muzzle does not pair this one.
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])
|
|
|
|
|
+ echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"])
|
|
|
|
|
+ echo("anchor", ["Gun.B", "weapon-mount", [2, 0, 3], 0, ""]))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.MUZZLE])
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
2026-10-18 22:04:42 +02:00
|
|
|
def test_a_muzzle_whose_parent_is_not_a_weapon_mount_is_refused(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])
|
|
|
|
|
+ echo("anchor", ["Th.A.muzzle", "weapon-muzzle", [1, 2, 1], 0, "Th.A"]))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.MUZZLE])
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_an_emitter_with_no_mesh_is_refused(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["emit:Th.A"] = ("", "")
|
|
|
|
|
self.assertEqual(fixture.failed(), ["every emitter and radiator anchor has a mesh"])
|
|
|
|
|
|
|
|
|
|
def test_the_collider_zone_is_the_languages_body(self):
|
|
|
|
|
self.assertEqual(Fixture().build().body_zone, "primary")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DonorTests(unittest.TestCase):
|
|
|
|
|
"""A Pirate hull: the Pirate language has no body and no proportions, so the donor's decide."""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def pirate(donor_proportion=((0.12, 0.35), (0.55, 1.1)), donor="rnd"):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief("pirate", donor, length=(8, 14)))
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library = {"pirate": language("pirate", ("primer", "weld"), body="", proportion=None,
|
|
|
|
|
symmetry=("asymmetric", 0.9)),
|
|
|
|
|
"rnd": language("rnd", ("primary", "secondary"), proportion=donor_proportion)}
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
for key in ("primary", "secondary"):
|
|
|
|
|
del fixture.parts[key]
|
|
|
|
|
fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "") # h/w 0.17, w/l 0.6
|
|
|
|
|
fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "")
|
|
|
|
|
fixture.parts["primer"] = (box(2, 4, -4, -2, 0, 1), "")
|
|
|
|
|
fixture.parts["weld"] = (box(2, 6, 0, 4, 0, 1), "")
|
|
|
|
|
return fixture
|
|
|
|
|
|
|
|
|
|
def test_the_donors_proportions_judge_the_body(self):
|
|
|
|
|
fixture = self.pirate()
|
|
|
|
|
self.assertEqual(fixture.build().body_zone, "rnd:primary")
|
|
|
|
|
self.assertEqual(fixture.failed(), [])
|
|
|
|
|
self.assertEqual(self.pirate(donor_proportion=((0.5, 0.9), (0.55, 1.1))).failed(),
|
|
|
|
|
["the body's proportions lie inside the language's ranges"])
|
|
|
|
|
|
|
|
|
|
def test_the_donors_zones_are_required_and_coloured_from_the_donor(self):
|
|
|
|
|
fixture = self.pirate()
|
|
|
|
|
fixture.parts["rnd:secondary"] = ("", "")
|
|
|
|
|
self.assertEqual(fixture.failed(), ["every zone of the language has geometry"])
|
2026-10-18 22:04:42 +02:00
|
|
|
built = fixture.build()
|
|
|
|
|
for zone in ("primary", "secondary"):
|
|
|
|
|
self.assertEqual(built.colours[f"rnd:{zone}"], built.languages["rnd"].colour(zone))
|
|
|
|
|
self.assertNotEqual(built.colours["rnd:primary"], built.colours["rnd:secondary"])
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
|
|
|
|
|
DONOR = "a donor is named exactly when the language has no proportions of its own"
|
|
|
|
|
|
|
|
|
|
def test_a_pirate_hull_that_names_no_donor_is_refused(self):
|
|
|
|
|
fixture = self.pirate(donor="")
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
del fixture.library["rnd"]
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
for key in ("rnd:primary", "rnd:secondary"):
|
|
|
|
|
del fixture.parts[key]
|
|
|
|
|
self.assertIn(self.DONOR, fixture.failed())
|
|
|
|
|
|
|
|
|
|
def test_a_hull_whose_language_has_proportions_of_its_own_may_not_name_a_donor(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief("zhestokost", "rnd"))
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
fixture.library["rnd"] = language("rnd", ("primary", "secondary"))
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
fixture.parts["rnd:primary"] = (box(-3, 3, -5, 5, 0, 1), "")
|
|
|
|
|
fixture.parts["rnd:secondary"] = (box(-1, 1, -1, 1, 1, 1.2), "")
|
|
|
|
|
self.assertIn(self.DONOR, fixture.failed())
|
|
|
|
|
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
class LanguageOwnershipTests(unittest.TestCase):
|
|
|
|
|
"""The faction's language file owns its palette, proportions and symmetry rule: the hull's own `lang` echo is compared
|
|
|
|
|
with it and never judged against."""
|
|
|
|
|
|
|
|
|
|
OWN = "the hull includes its faction's language files and changes none of them"
|
|
|
|
|
PROPORTIONS = "the body's proportions lie inside the language's ranges"
|
|
|
|
|
|
|
|
|
|
def redefined(self, **language_arguments):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_lang = language(**language_arguments)
|
|
|
|
|
return fixture
|
|
|
|
|
|
|
|
|
|
def test_a_hull_that_includes_the_languages_unchanged_passes(self):
|
|
|
|
|
self.assertEqual(Fixture().failed(), [])
|
|
|
|
|
|
|
|
|
|
def test_a_hull_that_redefines_its_palette_fails_and_is_still_coloured_from_the_file(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_lang = echo("lang", ["zhestokost", [["primary", [1, 0, 1]], ["secondary", [1, 0, 1]]], "primary",
|
2026-10-19 00:20:00 +02:00
|
|
|
[1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97], MAKER])
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
built = fixture.build()
|
|
|
|
|
self.assertEqual(built.colours["primary"], built.languages["zhestokost"].colour("primary"))
|
|
|
|
|
self.assertNotEqual(built.colours["primary"], (1, 0, 1))
|
|
|
|
|
|
|
|
|
|
def test_a_hull_that_adds_a_zone_to_its_palette_fails(self):
|
|
|
|
|
self.assertEqual(self.redefined(zones=("primary", "secondary", "extra")).failed(), [self.OWN])
|
|
|
|
|
|
|
|
|
|
def test_a_hull_that_redefines_the_proportions_fails_and_is_judged_by_the_file(self):
|
|
|
|
|
fixture = self.redefined(proportion=((0.0, 9.0), (0.0, 9.0)))
|
|
|
|
|
fixture.parts["primary"] = (box(-3, 3, -5, 5, 0, 5), "") # too tall for the file's ranges
|
|
|
|
|
self.assertEqual(sorted(fixture.failed()), sorted([self.OWN, self.PROPORTIONS]))
|
|
|
|
|
|
|
|
|
|
def test_a_hull_that_redefines_the_symmetry_rule_fails_and_is_judged_by_the_file(self):
|
|
|
|
|
fixture = self.redefined(symmetry=("asymmetric", 0.99))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
fixture.parts["secondary"] = (box(2, 6, -1, 1, 2, 3), "") # lopsided: the file's bilateral rule refuses it
|
|
|
|
|
self.assertIn("the top view obeys the language's bilateral rule", fixture.failed())
|
|
|
|
|
|
2026-10-19 00:20:00 +02:00
|
|
|
def test_a_hull_cannot_restate_its_languages_maker(self):
|
|
|
|
|
"""The language file owns its maker (the Faction record key Package compares with the product's): an included
|
|
|
|
|
language echoed with another maker fails the includes-unchanged check, and the hull is read as the file's."""
|
|
|
|
|
fixture = self.redefined(maker="another-faction-record-key")
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
self.assertEqual(fixture.build().languages["zhestokost"].maker, MAKER)
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_second_echo_of_the_language_after_the_first_fails(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_lang = language() + language(symmetry=("asymmetric", 0.99))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
|
2026-10-18 22:35:14 +02:00
|
|
|
def test_the_genuine_language_echoed_after_an_altered_one_does_not_save_the_hull(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_lang = language(symmetry=("asymmetric", 0.99)) + language()
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_hull_that_echoes_no_language_or_another_factions_fails(self):
|
|
|
|
|
for text in ("", language("rnd")):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.hull_lang = text
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
|
|
|
|
|
def test_a_pirate_hull_must_include_its_donors_language_too(self):
|
|
|
|
|
fixture = DonorTests.pirate()
|
|
|
|
|
fixture.hull_lang = language("pirate", ("primer", "weld"), body="", proportion=None, symmetry=("asymmetric", 0.9))
|
|
|
|
|
self.assertEqual(fixture.failed(), [self.OWN])
|
|
|
|
|
|
|
|
|
|
def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self):
|
|
|
|
|
asked = []
|
|
|
|
|
fixture = DonorTests.pirate()
|
2026-10-22 15:57:44 +02:00
|
|
|
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector)
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
self.assertEqual(sorted(asked), ["pirate", "rnd"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeOpenScad:
|
|
|
|
|
"""Stands in for subprocess.Popen: records each command and environment, writes `off` where -o points, and answers
|
|
|
|
|
with `output` and `returncode`."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, returncode=0, off="", output=""):
|
|
|
|
|
self.returncode, self.off, self.output, self.calls = returncode, off, output, []
|
|
|
|
|
self.pid = 4242
|
|
|
|
|
|
|
|
|
|
def __call__(self, command, **kwargs):
|
|
|
|
|
self.calls.append((command, kwargs.get("env")))
|
|
|
|
|
if self.off:
|
|
|
|
|
Path(command[command.index("-o") + 1]).write_text(self.off, encoding="utf-8")
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def communicate(self, timeout=None):
|
|
|
|
|
return self.output, None
|
|
|
|
|
|
|
|
|
|
def kill(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LanguageLibraryTests(unittest.TestCase):
|
|
|
|
|
def test_a_faction_name_that_is_not_a_plain_word_or_has_no_file_is_refused_without_a_process(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as folder, mock.patch.object(hs.subprocess, "Popen") as popen:
|
2026-10-18 22:35:14 +02:00
|
|
|
library = Path(folder) / "languages"
|
|
|
|
|
library.mkdir()
|
|
|
|
|
(library / "zhestokost.scad").write_text("", encoding="utf-8")
|
|
|
|
|
(Path(folder) / "escape.scad").write_text("", encoding="utf-8") # a file outside the library
|
|
|
|
|
read = hs.language_library("openscad", library)
|
|
|
|
|
for faction in ("../escape", "Zhestokost", "", "nobody", "zhestokost.scad"):
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
read(faction)
|
|
|
|
|
self.assertNotIn("nobody", str(caught.exception))
|
|
|
|
|
popen.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_the_language_is_read_from_the_factions_own_file_run_alone(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
|
|
|
library = Path(folder)
|
|
|
|
|
(library / "zhestokost.scad").write_text("", encoding="utf-8")
|
|
|
|
|
openscad = FakeOpenScad(output=language())
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", openscad):
|
|
|
|
|
text = hs.language_library("openscad", library)("zhestokost")
|
|
|
|
|
self.assertEqual(hs.read_languages(text)[0].faction, "zhestokost")
|
|
|
|
|
command = openscad.calls[0][0]
|
|
|
|
|
self.assertEqual(command[-1], str(library / "zhestokost.scad"))
|
|
|
|
|
self.assertEqual(command[command.index("-D") + 1], 'part="lang"')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RunnerTests(unittest.TestCase):
|
|
|
|
|
def run_part(self, part, openscad, **arguments):
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", openscad):
|
|
|
|
|
return hs.openscad_runner("scad", "hull.scad", **arguments)(part)
|
|
|
|
|
|
|
|
|
|
def test_openscad_is_asked_for_a_full_render_on_the_manifold_backend_of_one_part(self):
|
|
|
|
|
openscad = FakeOpenScad()
|
|
|
|
|
self.run_part("primary", openscad)
|
|
|
|
|
command = openscad.calls[0][0]
|
|
|
|
|
self.assertEqual(command[0], "scad")
|
|
|
|
|
self.assertEqual(command[command.index("--backend") + 1], "Manifold")
|
|
|
|
|
self.assertEqual(command[command.index("--render") + 1], "all")
|
|
|
|
|
self.assertEqual(command[command.index("-D") + 1], 'part="primary"')
|
|
|
|
|
self.assertEqual(command[-1], "hull.scad")
|
|
|
|
|
|
|
|
|
|
def test_the_language_folder_is_what_openscad_searches_for_includes(self):
|
|
|
|
|
openscad = FakeOpenScad()
|
|
|
|
|
self.run_part("primary", openscad, library=Path("some") / "languages")
|
|
|
|
|
self.assertEqual(openscad.calls[0][1]["OPENSCADPATH"], str(Path("some") / "languages"))
|
|
|
|
|
|
|
|
|
|
def test_a_part_openscad_fails_to_make_is_refused_without_echoing_its_output(self):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
self.run_part("primary", FakeOpenScad(returncode=1, output=SECRET))
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_echo_parts_answer_the_echo_and_geometry_parts_the_off(self):
|
|
|
|
|
self.assertEqual(self.run_part("brief", FakeOpenScad(output=brief())), ("", brief()))
|
|
|
|
|
self.assertEqual(self.run_part("primary", FakeOpenScad(off=box(0, 1, 0, 1, 0, 1), output="log")),
|
|
|
|
|
(box(0, 1, 0, 1, 0, 1), "log"))
|
|
|
|
|
|
|
|
|
|
def test_each_process_is_reported_to_on_start(self):
|
|
|
|
|
started = []
|
|
|
|
|
self.run_part("primary", FakeOpenScad(), on_start=started.append)
|
|
|
|
|
self.assertEqual(started, [4242])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FingerprintTests(unittest.TestCase):
|
|
|
|
|
def test_equal_values_give_equal_digests_and_a_change_gives_another(self):
|
|
|
|
|
base = hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3)
|
|
|
|
|
self.assertEqual(base, hs.fingerprint("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3))
|
|
|
|
|
for other in (("b", (1.0, 2.5), [[0.0, 1.0, 2.0]], 3), ("a", (1.0, 2.501), [[0.0, 1.0, 2.0]], 3),
|
|
|
|
|
("a", (1.0, 2.5), [[0.0, 1.0, 2.0]], 4), ("a", (1.0, 2.5), [[0.0, 1.0], [2.0]], 3)):
|
|
|
|
|
self.assertNotEqual(base, hs.fingerprint(*other))
|
|
|
|
|
|
2026-10-18 22:36:54 +02:00
|
|
|
def test_a_list_of_two_empty_lists_is_not_one_list_holding_one(self):
|
|
|
|
|
self.assertNotEqual(hs.fingerprint([], []), hs.fingerprint([[]]))
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_float_noise_below_five_decimals_and_a_negative_zero_do_not_change_it(self):
|
|
|
|
|
self.assertEqual(hs.fingerprint((1.0, 0.0)), hs.fingerprint((1.0 + 1e-8, -0.0)))
|
|
|
|
|
self.assertEqual(hs.fingerprint((-1e-9,)), hs.fingerprint((0.0,)))
|
|
|
|
|
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
class RefusalTests(unittest.TestCase):
|
|
|
|
|
def test_a_source_that_includes_no_language_for_its_faction_is_refused(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief("nobody"))
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
fixture.build()
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_named_donor_that_has_no_language_file_is_refused(self):
|
2026-10-18 22:04:42 +02:00
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", brief("zhestokost", "rnd"))
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
fixture.build()
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_language_file_that_echoes_another_faction_or_not_exactly_one_language_is_refused(self):
|
|
|
|
|
for text in (language("rnd"), language() + language(), ""):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.library["zhestokost"] = text
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
fixture.build()
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_anchors_that_repeat_an_id_or_name_an_unknown_role_are_refused_without_echoing_them(self):
|
|
|
|
|
for anchors in (echo("anchor", [SECRET, "weapon-mount", [0, 0, 0], 0, ""]) * 2,
|
|
|
|
|
echo("anchor", [SECRET, "map-icon", [0, 0, 0], 0, ""])):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", anchors)
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
fixture.build()
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
def test_a_name_that_is_not_a_string_is_refused_wherever_the_source_gives_one(self):
|
|
|
|
|
def refused(fixture):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
fixture.build()
|
|
|
|
|
|
|
|
|
|
for index in range(3): # faction, donor, role
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
raw = ["zhestokost", "", "tender", [8, 14], [10, 100], ["a read"]]
|
|
|
|
|
raw[index] = 7
|
|
|
|
|
fixture.parts["brief"] = ("", echo("brief", raw))
|
|
|
|
|
refused(fixture)
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["brief"] = ("", echo("brief", ["zhestokost", "", "tender", [8, 14], [10, 100], [7]]))
|
|
|
|
|
refused(fixture)
|
|
|
|
|
for anchor in ([7, "weapon-mount", [0, 0, 3], 0, ""], ["Gun.A", 7, [0, 0, 3], 0, ""],
|
|
|
|
|
["Gun.A", "weapon-mount", [0, 0, 3], 0, 7]):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", anchor))
|
|
|
|
|
refused(fixture)
|
2026-10-19 00:20:00 +02:00
|
|
|
for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER],
|
|
|
|
|
["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER],
|
|
|
|
|
["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1], MAKER]):
|
Agent hulls: the faction file owns its language; Build owns what it recorded, not what is marked
hull_source.build reads the faction's and donor's language from factions/<faction>.scad run alone and only compares the
hull's own lang echo with it; a hull that reassigns, re-echoes or shadows a language fails a check.
Build records what it made on the collection by name and fingerprint instead of marking objects. A copy (another name) or an
edit (another fingerprint) is the author's: Build refuses and deletes nothing. Package retires hull.scad when render meshes
that are not Build's remain and no zone mesh of Build's does, so keeping Build's anchors and collider no longer blocks
takeover.
Unit tests cover the runner (arguments, exit status, OPENSCADPATH), non-string names, the width/length bound, own-zone
colours and the language ownership; the smoke pins world poses of mounts, emitters and emitter meshes (a mount turned 90
degrees included), the language attacks with the real OpenSCAD, copy and edit, Build after Package and the takeover.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:34:41 +02:00
|
|
|
with self.assertRaises(hs.HullSourceError):
|
|
|
|
|
hs.read_languages(echo("lang", raw))
|
|
|
|
|
|
2026-10-19 00:20:00 +02:00
|
|
|
def test_the_maker_is_the_eighth_element_of_a_language_echo(self):
|
|
|
|
|
self.assertEqual(hs.read_languages(language(maker="K1"))[0].maker, "K1")
|
|
|
|
|
raw = ["zhestokost", [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]]
|
|
|
|
|
for bad in (raw, raw + [""], raw + [7], raw + ["K1", "extra"]): # seven elements, empty maker, not text, nine
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.read_languages(echo("lang", bad))
|
|
|
|
|
self.assertNotIn("K1", str(caught.exception))
|
|
|
|
|
self.assertEqual(hs.read_languages(echo("lang", raw + ["K1"]))[0].maker, "K1")
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
def test_a_malformed_anchor_is_refused_without_echoing_it(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0], 0, ""]))
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
fixture.build()
|
|
|
|
|
self.assertNotIn(SECRET, str(caught.exception))
|
|
|
|
|
|
|
|
|
|
def test_check_names_and_details_carry_no_hull_supplied_text(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["anchors"] = ("", echo("anchor", [SECRET, "weapon-mount", [0, 0, 3], 0, ""]))
|
|
|
|
|
fixture.parts["emit:Th.A"] = ("", "")
|
|
|
|
|
for check in fixture.build().checks:
|
|
|
|
|
self.assertNotIn(SECRET, check.name + check.detail)
|
|
|
|
|
|
|
|
|
|
def test_the_module_writes_no_json_and_starts_no_second_process(self):
|
|
|
|
|
source = (Path(hs.__file__)).read_text(encoding="utf-8")
|
|
|
|
|
for forbidden in ("json.dump", "ThreadPool", "concurrent.futures", "multiprocessing"):
|
|
|
|
|
self.assertNotIn(forbidden, source)
|
|
|
|
|
self.assertEqual(source.count("subprocess.Popen"), 1)
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 15:57:44 +02:00
|
|
|
LINES_OVER = "every kit line lies over the hull's top view"
|
|
|
|
|
LINES_DISTINCT = "no two kit lines are the same"
|
2026-10-22 17:09:04 +02:00
|
|
|
LINES_FRAME = "every kit line is drawn in the frame of its geometry"
|
2026-10-22 15:57:44 +02:00
|
|
|
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]])
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 17:09:04 +02:00
|
|
|
class FrameCheckTests(unittest.TestCase):
|
|
|
|
|
"""The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame
|
|
|
|
|
lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places."""
|
|
|
|
|
|
|
|
|
|
def built(self, kit, marks=None):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["lines"] = ("", kit)
|
|
|
|
|
if marks is not None:
|
|
|
|
|
fixture.marks = (marks_off(marks), "")
|
|
|
|
|
return fixture.build()
|
|
|
|
|
|
|
|
|
|
def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self):
|
|
|
|
|
built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))))
|
|
|
|
|
self.assertEqual(built.failed(), [])
|
|
|
|
|
self.assertIn(LINES_FRAME, names(built))
|
|
|
|
|
|
|
|
|
|
def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self):
|
|
|
|
|
built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)])
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [LINES_FRAME])
|
|
|
|
|
|
|
|
|
|
def test_a_line_with_no_mark_fails(self):
|
|
|
|
|
built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)])
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [LINES_FRAME])
|
|
|
|
|
|
|
|
|
|
def test_a_mark_with_no_line_fails(self):
|
|
|
|
|
built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)])
|
|
|
|
|
self.assertEqual([c.name for c in built.failed()], [LINES_FRAME])
|
|
|
|
|
|
|
|
|
|
def test_a_mark_a_millimetre_off_is_the_same_place(self):
|
|
|
|
|
self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), [])
|
|
|
|
|
|
2026-10-22 17:17:40 +02:00
|
|
|
def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
runner = fixture.runner()
|
|
|
|
|
asked = []
|
|
|
|
|
built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector)
|
|
|
|
|
self.assertNotIn("marks", asked)
|
|
|
|
|
self.assertEqual(built.failed(), [])
|
2026-10-22 17:09:04 +02:00
|
|
|
|
|
|
|
|
def test_the_detail_counts_the_lines_and_marks_that_disagree(self):
|
|
|
|
|
built = self.built(line(), marks=[(5, 5, 2)])
|
|
|
|
|
self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree")
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 15:57:44 +02:00
|
|
|
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})
|
2026-10-22 16:06:59 +02:00
|
|
|
self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)})
|
2026-10-22 15:57:44 +02:00
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-10-22 16:06:59 +02:00
|
|
|
def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self):
|
2026-10-22 15:57:44 +02:00
|
|
|
fixture, given = Fixture(), []
|
|
|
|
|
hs.build(fixture.runner(), fixture.read_library, "Ship",
|
|
|
|
|
lambda meshes: given.append(len(meshes)) or fixture.projector(meshes))
|
2026-10-22 16:06:59 +02:00
|
|
|
self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view
|
2026-10-22 15:57:44 +02:00
|
|
|
|
|
|
|
|
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()))
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 16:11:53 +02:00
|
|
|
class LineStyleTests(unittest.TestCase):
|
|
|
|
|
def test_the_style_table_is_the_one_the_underlay_reads(self):
|
|
|
|
|
self.assertEqual(hs.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)})
|
|
|
|
|
self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting"))
|
|
|
|
|
|
|
|
|
|
def test_a_schematic_line_cannot_be_changed_after_it_is_read(self):
|
|
|
|
|
read = hs.read_lines(line())[0]
|
|
|
|
|
with self.assertRaises(dataclasses.FrozenInstanceError):
|
|
|
|
|
read.layer = "step"
|
|
|
|
|
|
|
|
|
|
def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self):
|
|
|
|
|
for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")):
|
|
|
|
|
with self.subTest(part=part):
|
|
|
|
|
openscad = FakeOpenScad()
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", openscad):
|
|
|
|
|
hs.openscad_runner("scad", "hull.scad")(part)
|
|
|
|
|
command = openscad.calls[0][0]
|
|
|
|
|
self.assertTrue(command[command.index("-o") + 1].endswith(suffix))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LineRefusalTextTests(unittest.TestCase):
|
|
|
|
|
def refusal(self, text):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.read_lines(text)
|
|
|
|
|
return str(caught.exception)
|
|
|
|
|
|
|
|
|
|
def test_each_refusal_says_one_fixed_thing(self):
|
|
|
|
|
self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape")
|
|
|
|
|
self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape")
|
|
|
|
|
self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw")
|
|
|
|
|
self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points")
|
|
|
|
|
self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'),
|
|
|
|
|
"A schematic line has a point that is not a finite number")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ContourAreaTests(unittest.TestCase):
|
|
|
|
|
@staticmethod
|
|
|
|
|
def svg(*rings):
|
|
|
|
|
paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings)
|
|
|
|
|
return f'<svg><path d="\n{paths}\n" fill="lightgray"/></svg>'
|
|
|
|
|
|
|
|
|
|
def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self):
|
|
|
|
|
square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)]
|
|
|
|
|
triangle = [(10, 10), (11, 10), (10, 11)]
|
|
|
|
|
for ring in (square, square[::-1], triangle, triangle[::-1]):
|
|
|
|
|
with self.subTest(ring=ring):
|
|
|
|
|
self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1)
|
|
|
|
|
|
|
|
|
|
def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self):
|
|
|
|
|
for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]):
|
|
|
|
|
with self.subTest(ring=ring):
|
|
|
|
|
self.assertEqual(hs.parse_svg_contours(self.svg(ring)), [])
|
|
|
|
|
|
|
|
|
|
def test_a_contour_of_fewer_than_three_points_is_dropped(self):
|
|
|
|
|
self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OverSilhouetteTests(unittest.TestCase):
|
|
|
|
|
TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)]
|
|
|
|
|
|
|
|
|
|
def over(self, x, y, contours=None, z=99.0):
|
|
|
|
|
return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours)
|
|
|
|
|
|
|
|
|
|
def test_inside_and_far_outside(self):
|
|
|
|
|
self.assertTrue(self.over(2, 2))
|
|
|
|
|
self.assertFalse(self.over(8, 8))
|
|
|
|
|
self.assertFalse(self.over(-5, 3))
|
|
|
|
|
|
|
|
|
|
def test_the_height_of_a_point_does_not_matter(self):
|
|
|
|
|
self.assertTrue(self.over(2, 2, z=-50.0))
|
|
|
|
|
self.assertFalse(self.over(8, 8, z=2.0))
|
|
|
|
|
|
|
|
|
|
def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self):
|
|
|
|
|
# the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside
|
|
|
|
|
self.assertTrue(self.over(5.1, 5.1))
|
|
|
|
|
self.assertFalse(self.over(5.3, 5.3))
|
|
|
|
|
|
|
|
|
|
def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self):
|
|
|
|
|
self.assertTrue(self.over(10.25, 0))
|
|
|
|
|
self.assertFalse(self.over(10.35, 0))
|
|
|
|
|
self.assertTrue(self.over(-0.25, -0.0))
|
|
|
|
|
self.assertFalse(self.over(0, -0.35))
|
|
|
|
|
|
|
|
|
|
def test_a_ray_through_a_vertex_counts_the_edge_once(self):
|
|
|
|
|
diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)]
|
|
|
|
|
self.assertTrue(self.over(0.5, 0, [diamond]))
|
|
|
|
|
self.assertTrue(self.over(-0.5, 0, [diamond]))
|
|
|
|
|
self.assertFalse(self.over(3.0, 0, [diamond]))
|
|
|
|
|
self.assertFalse(self.over(0.0, 3.0, [diamond]))
|
|
|
|
|
|
|
|
|
|
def test_the_even_odd_rule_alternates_through_nested_contours(self):
|
|
|
|
|
rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)]
|
|
|
|
|
self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only
|
|
|
|
|
self.assertFalse(self.over(5.5, 0, rings)) # a hole
|
|
|
|
|
self.assertTrue(self.over(2.5, 0, rings)) # island in the hole
|
|
|
|
|
|
|
|
|
|
def test_three_nested_contours_put_the_centre_back_on_the_hull(self):
|
|
|
|
|
rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)]
|
|
|
|
|
self.assertTrue(self.over(0.0, 0.0, rings))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProcessRefusalTests(unittest.TestCase):
|
|
|
|
|
def test_openscad_that_cannot_start_is_refused_in_fixed_words(self):
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)):
|
|
|
|
|
for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"),
|
|
|
|
|
lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
make()
|
|
|
|
|
self.assertEqual(str(caught.exception), "OpenSCAD could not be started")
|
|
|
|
|
|
|
|
|
|
def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self):
|
|
|
|
|
class Slow(FakeOpenScad):
|
|
|
|
|
killed = False
|
|
|
|
|
|
|
|
|
|
def communicate(self, timeout=None):
|
|
|
|
|
if not self.killed:
|
|
|
|
|
raise hs.subprocess.TimeoutExpired("scad", timeout)
|
|
|
|
|
return "", None
|
|
|
|
|
|
|
|
|
|
def kill(self):
|
|
|
|
|
self.killed = True
|
|
|
|
|
|
|
|
|
|
slow = Slow()
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", slow):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.openscad_runner("scad", "hull.scad")("primary")
|
|
|
|
|
self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True))
|
|
|
|
|
|
|
|
|
|
def test_a_projection_openscad_never_wrote_is_refused(self):
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])
|
|
|
|
|
self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source")
|
|
|
|
|
|
|
|
|
|
def test_a_refusing_openscad_says_so_in_fixed_words(self):
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.openscad_runner("scad", "hull.scad")("primary")
|
|
|
|
|
self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LineCheckDetailTests(unittest.TestCase):
|
|
|
|
|
def details(self, kit, projector=Fixture.projector):
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
fixture.parts["lines"] = ("", kit)
|
|
|
|
|
built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector)
|
|
|
|
|
return {c.name: c.detail for c in built.checks}
|
|
|
|
|
|
|
|
|
|
def test_the_details_count_what_the_checks_found(self):
|
|
|
|
|
got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step"))
|
|
|
|
|
self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated"))
|
|
|
|
|
|
|
|
|
|
def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self):
|
|
|
|
|
twice = lambda meshes: Fixture.projector(meshes) * 2
|
|
|
|
|
got = self.details("", twice)
|
|
|
|
|
self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated"))
|
|
|
|
|
fixture = Fixture()
|
|
|
|
|
built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice)
|
|
|
|
|
self.assertEqual(built.failed(), [])
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 16:17:00 +02:00
|
|
|
class BytesOpenScad(FakeOpenScad):
|
|
|
|
|
"""Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title."""
|
|
|
|
|
|
|
|
|
|
def __call__(self, command, **kwargs):
|
|
|
|
|
self.calls.append((command, kwargs.get("env")))
|
|
|
|
|
Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8"))
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SilhouetteReadTests(unittest.TestCase):
|
|
|
|
|
def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self):
|
|
|
|
|
with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()):
|
|
|
|
|
self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2)
|
|
|
|
|
|
|
|
|
|
def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self):
|
|
|
|
|
with self.assertRaises(hs.HullSourceError) as caught:
|
|
|
|
|
hs.parse_svg_contours('<svg><path d="M .,. L 1,1 L 2,2 z"/></svg>')
|
|
|
|
|
self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable")
|
|
|
|
|
|
|
|
|
|
|
hull_source: reads a hull.scad through OpenSCAD and judges it against its faction's form language
One language file per faction (Zhestokost, Rossum & Douglas, Pirate) owns the shape kit, proportions, symmetry rule and
palette; the Zhestokost palette follows the lore page (ruling zhestokost-language-lore-text). The two probe hulls are
ported as fixtures. Checks: zones, emitter and radiator meshes, donor, body proportions, top-view mirror overlap,
length, triangle budget, muzzles nose-ward. Fixture-text tests need no OpenSCAD.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:57:47 +02:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|