Merge eureka/aetheria-release-agent-hulls (head 3c4ea230)

Agent-built hulls, first cut: per-faction form-language files, per-hull sources, Build From Hull Source (rulings hull-art-communicates-form, zhestokost-language-lore-text). Reports cut-agent-hull-source.h1, h2; Soul s1, s2 hold on the fixes (open: question who-names-a-hulls-faction, follow-up hull-copy-refusals). The branch touches only tools/blender, which master did not change since base d28ce42d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-18 22:58:46 +02:00
commit 5acbaa1b2f
10 changed files with 1849 additions and 2 deletions

View file

@ -22,7 +22,7 @@ import bpy
import numpy
from mathutils import Matrix, Vector
from . import hull_grid
from . import hull_grid, hull_source
from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines,
replace_visual, valid_ship_id)
@ -609,6 +609,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
return _refuse(self, exc)
DEFAULT_OPENSCAD = r"C:\Tools\OpenSCAD-2026.09.23-x86-64\openscad.com"
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__
@ -636,8 +639,14 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
name="Bake max ray distance", default=0.0, min=0.0, unit="LENGTH",
description="The longest ray, in metres, Bake From Source casts from the extruded surface. 0 means no limit")
openscad: bpy.props.StringProperty(
name="OpenSCAD", subtype="FILE_PATH", default=DEFAULT_OPENSCAD,
description="The OpenSCAD executable (openscad.com on Windows, 2026.09 or later: Build From Hull Source uses its "
"Manifold backend)")
def draw(self, context):
self.layout.prop(self, "game_folder")
self.layout.prop(self, "openscad")
self.layout.prop(self, "max_texture")
self.layout.prop(self, "bake_size")
self.layout.prop(self, "bake_cage_extrusion")
@ -1515,6 +1524,172 @@ def _frame_ship(context, collection, root, path, saved_shape):
_recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1]))
HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made
HULL_COLLIDER = ("hull-collider", "collider", "Hull Collider")
def _hull_source_file(collection):
"""The bound package's hull.scad, or None. A package holds one next to its ship.cc while its hull is agent-built."""
stored = collection.get("aetheria.ship_cc")
if not stored or collection.get("aetheria.pending"):
return None
source = Path(bpy.path.abspath(stored)).with_name(hull_source.HULL_FILE)
return source if source.is_file() else None
def _fingerprint(obj):
"""What Build made of obj, as it stands: its place under its parent, the anchor properties and, for a mesh, its geometry.
A copy has another name and an edit changes this, so neither is taken for Build's own."""
values = [obj.parent.name if obj.parent else "", tuple(obj.location), tuple(obj.rotation_euler), tuple(obj.scale),
obj.get("aetheria.role", ""), obj.get("aetheria.id", ""), obj.get("aetheria.order", 0)]
if obj.type == "MESH":
values += [[tuple(v.co) for v in obj.data.vertices], [tuple(p.vertices) for p in obj.data.polygons]]
return hull_source.fingerprint(*values)
def _hull_built(collection):
"""(untouched, changed): Build's objects found in the collection as it made them, and those it made that were edited.
The record is the only owner: an object is Build's when its name and fingerprint are the ones recorded."""
record = collection.get(HULL_BUILT)
record = record.to_dict() if record is not None else {}
present = {obj.name: obj for obj in collection.all_objects if obj.name in record}
untouched = [obj for name, obj in present.items() if _fingerprint(obj) == record[name]]
return untouched, [obj for obj in present.values() if obj not in untouched]
def _hull_material(ship_id, zone, colour, emissive=False):
"""The zone's material: a flat colour from the language palette, made once per ship and zone, recoloured on a rebuild."""
name = f"{ship_id}.{zone}"
material = bpy.data.materials.get(name) or bpy.data.materials.new(name)
material.use_nodes = True
shader = _principled(material)
shader.inputs["Base Color"].default_value = (*colour, 1.0)
shader.inputs["Metallic"].default_value = 0.0
shader.inputs["Roughness"].default_value = 0.85
shader.inputs["Emission Color"].default_value = (*colour, 1.0)
shader.inputs["Emission Strength"].default_value = 1.0 if emissive else 0.0
return material
def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0):
"""A flat-shaded mesh of the hull source's, expressed in the frame of an object that stands at offset, turned yaw."""
cos, sin = math.cos(-yaw), math.sin(-yaw)
vertices = [((x - offset[0]) * cos - (y - offset[1]) * sin, (x - offset[0]) * sin + (y - offset[1]) * cos,
z - offset[2]) for x, y, z in mesh.vertices]
data = bpy.data.meshes.new(name)
data.from_pydata(vertices, [], [tuple(tri) for tri in mesh.triangles])
data.update()
data.materials.append(material)
return data
def _build_hull_source(context, collection):
"""Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root.
Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the
collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row."""
source = _hull_source_file(collection)
if source is None:
raise ValueError("The bound package holds no hull.scad")
root = _ship_root(collection)
untouched, changed = _hull_built(collection)
if root is not None:
_refuse_loose_meshes(context, collection)
theirs = [obj for obj in _own_meshes(collection) if obj not in untouched]
if theirs or changed:
raise ValueError("The collection holds meshes the hull source did not make, or objects it made that were "
"changed; Build From Hull Source replaces only what it made and nobody touched")
addon = context.preferences.addons.get(__package__)
executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD
hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable))
broken = hull.failed()
if broken:
raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken))
ship_id = collection["aetheria.id"]
if root is None:
root = bpy.data.objects.new(SHIP_ROOT, None)
root["aetheria.ship_root"] = True
collection.objects.link(root)
for obj in untouched:
_discard_object(obj)
made = []
def mark(obj):
made.append(obj)
return obj
def link(obj):
collection.objects.link(obj)
_attach_to_root(obj, root)
return mark(obj)
for zone, mesh in hull.zones.items():
name = zone.replace(":", ".")
link(bpy.data.objects.new(name, _hull_mesh(name, mesh, _hull_material(ship_id, name, hull.colours[zone]))))
placed, anchors = {}, {a.id: a for a in hull.anchors}
for anchor in hull.anchors: # a muzzle follows its mount in the echo order the source gave
yaw = math.radians(anchor.yaw)
if anchor.role in hull_source.MESH_ROLES:
colour, emissive = (hull.glow, True) if anchor.role == "thruster-emitter" else (hull.radiator, False)
material = _hull_material(ship_id, f"{anchor.role}.{anchor.id}", colour, emissive)
obj = link(bpy.data.objects.new(
anchor.id, _hull_mesh(anchor.id, hull.anchor_meshes[anchor.id], material, anchor.position, yaw)))
obj.location, obj.rotation_euler = anchor.position, (0.0, 0.0, yaw)
obj["aetheria.role"], obj["aetheria.id"] = anchor.role, anchor.id
elif anchor.role == "weapon-mount":
obj = mark(_make_empty(collection, anchor.id, anchor.role, root, anchor.position, yaw))
else:
mount = anchors[anchor.parent]
turn = -math.radians(mount.yaw)
dx, dy = anchor.position[0] - mount.position[0], anchor.position[1] - mount.position[1]
local = (dx * math.cos(turn) - dy * math.sin(turn), dx * math.sin(turn) + dy * math.cos(turn),
anchor.position[2] - mount.position[2])
order = 1 + len(_muzzles(placed[anchor.parent]))
obj = mark(_make_empty(collection, anchor.id, anchor.role, placed[anchor.parent], local, order=order))
placed[anchor.id] = obj
role, anchor_id, name = HULL_COLLIDER
collider = link(bpy.data.objects.new(name, _hull_mesh(
name, hull.zones[hull.body_zone], _hull_material(ship_id, "collider", (0.5, 0.5, 0.5)))))
collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id
collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made}
return hull
def _retire_hull_source(collection):
"""Deletes the package's hull.scad when the body is the operator's: the collection holds render meshes that Build did
not make as they stand, and no zone mesh of Build's is left. The anchors and collider she kept are hers from then on.
A hull never built, or built and packaged as it stands, keeps its source. Returns whether the file was deleted."""
source = _hull_source_file(collection)
root = _ship_root(collection)
if source is None or root is None:
return False
untouched, _ = _hull_built(collection)
zones = [obj for obj in untouched if obj.type == "MESH" and "aetheria.role" not in obj]
if zones or not [obj for obj in _own_meshes(collection) if obj not in untouched]:
return False
source.unlink()
if HULL_BUILT in collection:
del collection[HULL_BUILT]
return True
class AETHERIA_OT_build_hull_source(bpy.types.Operator):
bl_idname = "aetheria.build_hull_source"
bl_label = "Build From Hull Source"
bl_description = ("Replace the hull's generated objects with the meshes, mount anchors and collider that the "
"package's hull.scad describes, after checking it against its faction's form language. Never "
"touches the layout or objects you made")
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
collection, _ = _layout_path(context)
hull = _build_hull_source(context, collection)
self.report({"INFO"}, f"Built {len(hull.zones)} zones and {len(hull.anchors)} anchors from hull.scad")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_package_ship(bpy.types.Operator):
bl_idname = "aetheria.package_ship"
bl_label = "Package Ship"
@ -1567,6 +1742,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator):
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship"
if _retire_hull_source(collection):
scene.aetheria_package_report += "; deleted hull.scad: this package ships your art"
self.report({"INFO"}, scene.aetheria_package_report)
return {"FINISHED"}
@ -1610,6 +1787,11 @@ class AETHERIA_PT_ship(bpy.types.Panel):
row.operator("aetheria.flip_nose", icon="LOOP_BACK")
row.operator("aetheria.rasterise_hull", icon="MESH_GRID")
layout.operator("aetheria.bake_from_source", icon="RENDER_RESULT")
try:
if _hull_source_file(_ship_collection(context)) is not None:
layout.operator("aetheria.build_hull_source", icon="MESH_CUBE")
except ValueError:
pass
if not state.ship_id:
self._draw_package(context, layout)
return
@ -1685,7 +1867,8 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout,
AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection,
AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose,
AETHERIA_OT_bake_from_source,
AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship)
AETHERIA_OT_rasterise_hull, AETHERIA_OT_build_hull_source, AETHERIA_OT_package_ship,
AETHERIA_AP_preferences, AETHERIA_PT_ship)
def register():

View file

@ -0,0 +1,14 @@
// Shared low-poly primitives (any faction). Frame: Blender Ship Root, nose -Y, starboard -X, up +Z.
// cbox: a box with one chamfer segment on every edge (the operator's one-segment-bevel bar, as geometry).
module cbox(s, c=0.2, center=true) {
t = center ? [0,0,0] : s/2;
translate(t) hull() {
cube([s.x, s.y-2*c, s.z-2*c], center=true);
cube([s.x-2*c, s.y, s.z-2*c], center=true);
cube([s.x-2*c, s.y-2*c, s.z], center=true);
}
}
// tube along +Y (nose is -Y, so a forward barrel uses rotate 180 or negative length)
module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); }
// a straight strut between two points
module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } }

View file

@ -0,0 +1,43 @@
// Pirate Coalition form language: the one owner of this faction's additions kit, symmetry rule and palette. Source: Faction Flavor > Pirate Coalition > Visual Identity: "An identifiable
// donor hull with asymmetrical additions, cut-down modules, adapted hardpoints, exposed cable routes, and bolted
// salvage plates. Each major addition has a plausible mounting and supply path."
// A Pirate hull = one donor-language body (another faction's language, named in the hull's brief and judged by its
// proportions) + these additions. This language draws no body and has no proportions of its own.
include <lowpoly.scad>
// Bolted salvage plate: a slightly tilted slab with proud bolt heads.
module pi_plate(w, l, t=0.12, tilt=4) {
rotate([tilt, -tilt*0.6, 0]) { cube([w, l, t], center=true);
for (x=[-w/2+0.15, w/2-0.15], y=[-l/2+0.15, l/2-0.15]) translate([x, y, t/2]) cylinder(r=0.07, h=0.06, $fn=6); }
}
// Exposed cable run: a chain of short segments sagging between clamp points.
module pi_cable(pts, r=0.06) { for (i=[0:len(pts)-2]) strut(pts[i], pts[i+1], r, 5); }
// Weld bead / adapter collar where an addition meets the donor.
module pi_collar(r, h=0.2) { cylinder(r=r, h=h, center=true, $fn=8); }
// Adapted hardpoint: a crude saddle (two plates + braces) that carries something the donor never had.
module pi_saddle(w, l, h) {
translate([0,0,h/2]) cube([w, l, 0.15], center=true);
for (x=[-w/2+0.1, w/2-0.1]) translate([x, 0, h/4]) cube([0.12, l*0.8, h/2], center=true);
strut([-w/2, -l/2, 0], [w/2, l/2, h], 0.05); strut([w/2, -l/2, 0], [-w/2, l/2, h], 0.05);
}
// Replacement drive: an oversize bell on a braced cradle, not matching the donor's flush engines.
module pi_drive(r, l) {
rotate([-90,0,0]) { cylinder(r1=r*0.55, r2=r, h=l*0.45, $fn=8); translate([0,0,-l*0.55]) cylinder(r=r*0.6, h=l*0.55, $fn=8); }
}
// Oversized industrial gun (a mass driver pulled off a mining rig): long box receiver, single heavy barrel.
module pi_big_gun(len, cal=0.2) {
cbox([0.9, len*0.35, 0.7], 0.08);
translate([0, -len*0.17, 0.05]) rotate([0,0,180]) tube(cal, len*0.65, 8);
translate([0, -len*0.82, 0.05]) rotate([0,0,180]) tube(cal*1.6, 0.35, 8);
}
// Bolted-on sensor pod: one sleek piece from a better ship, on a strut.
module pi_sensor_pod(l=1.4, r=0.3) { scale([1, l/(2*r), 1]) sphere(r, $fn=10); }
PI_ZONES = [["primer", [0.48, 0.27, 0.22]], // primer-red salvage plates
["weld", [0.70, 0.68, 0.62]], // welded additions: guns, drives, cables
["accent", [0.10, 0.62, 0.60]], // the crew's own colour
["recess", [0.08, 0.08, 0.09]]];
PI_GLOW = [0.55, 0.85, 1.0];
PI_RADIATOR = [0.70, 0.68, 0.62];
// No body zone and no proportions: the donor's. The additions must make the top view visibly lopsided.
PI_LANG = ["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["asymmetric", 0.9]];
if (part == "lang") echo(lang = PI_LANG);

View file

@ -0,0 +1,30 @@
// Rossum & Douglas form language: "the flying wedge". Source: Faction Flavor > Rossum & Douglas > Visual Identity.
// The one owner of this faction's shape kit, proportions, symmetry rule and palette. It also serves as a donor language:
// a Pirate hull builds on a stolen R&D wedge (Bench 3 "Jumble Sale").
include <lowpoly.scad>
// Broad shallow trapezoid narrowing to a blunt nose (-Y), wide rear face (+Y), flat dorsal plane,
// bevelled perimeter, thicker centre section. One idea, no flourishes.
module rd_wedge(L, w_rear, w_nose, h_edge, h_centre) {
hull() {
translate([0, L/2-0.3, 0]) cube([w_rear, 0.6, h_edge], center=true); // rear face
translate([0, -L/2+0.3, 0]) cube([w_nose, 0.6, h_edge*0.8], center=true); // blunt nose
translate([0, L*0.05, 0]) cube([w_rear*0.45, L*0.7, h_centre], center=true); // thick centre
}
}
// Flush rear engine bank: shallow rectangular recesses across the rear face.
module rd_engine_bank(w, h, n=3) {
for (i=[0:n-1]) translate([(i-(n-1)/2)*(w/n), 0, 0]) cube([w/n*0.7, 0.5, h], center=true);
}
// Recessed window strip and identical hatches: barely interrupt the planes.
module rd_window(w=1.2) { cube([w, 0.35, 0.18], center=true); }
module rd_hatch() { cube([0.9, 1.1, 0.06], center=true); }
RD_ZONES = [["primary", [0.72, 0.74, 0.76]], // the wedge
["secondary", [0.40, 0.47, 0.56]], // hatches, panels
["accent", [0.90, 0.89, 0.84]], // stripes
["recess", [0.10, 0.12, 0.18]]]; // windows, engine bank
RD_GLOW = [1.0, 0.75, 0.3];
RD_RADIATOR = [0.40, 0.47, 0.56];
RD_LANG = ["rnd", RD_ZONES, "primary", RD_GLOW, RD_RADIATOR, [[0.12, 0.35], [0.55, 1.1]], ["bilateral", 0.97]];
if (part == "lang") echo(lang = RD_LANG);

View file

@ -0,0 +1,60 @@
// Zhestokost form language: the one owner of this faction's shape kit, proportions, symmetry rule and palette.
// Canon. Operator ruling zhestokost-language-lore-text: Zhestokost is industrial and brutalist, "the dream of the
// eternal Soviet, a society that has achieved total and unassailable permaculture via a carefully organized and
// brutally practical (self-policing) state". Form follows the lore page ("Faction Flavor and Visual Identity" >
// Zhestokost): low broad stepped masses, deep seams, charcoal, iron grey, dull oxide red. The operator's faction sheet
// (Asset Sources/Zhestokost.png) lends the handling boom with hanging magazines as vocabulary, not its palette.
// A hull composes these modules; nothing here places itself.
include <lowpoly.scad>
ZH_CH = 0.22; // chamfer: heavy, square, one segment
// A stepped mass: tiers shrink upward and pull in from both ends. "Low, broad ... stepped rectangular masses."
module zh_stepped(L, W, H, tiers=3, pull=0.12) {
for (i=[0:tiers-1]) let(k=i/tiers)
translate([0, 0, H*k + H/tiers/2]) cbox([W*(1-pull*i*1.6), L*(1-pull*i), H/tiers + 0.02], ZH_CH);
}
// Armour plate standing proud of a face, leaving a deep seam (the seam is the gap to the next plate).
module zh_plate(w, l, t=0.3) { cbox([w, l, t], 0.08); }
// Recessed machinery: a dark box set into a mass (subtract the pocket in the hull, then place this inside).
module zh_recess(w, l, d) { cube([w, l, d], center=true); }
// Ammunition magazine: square cassette with ribbed loading face and a hanger lug on top.
module zh_cassette(w=1.2, l=1.6, h=2.0) {
cbox([w, l, h], 0.12);
for (z=[-h*0.3, 0, h*0.3]) translate([0, -l/2-0.04, z]) cube([w*0.8, 0.12, 0.18], center=true);
translate([0, 0, h/2+0.2]) cube([0.3, 0.6, 0.4], center=true);
}
// Handling boom: square box beam with cross-members; magazines hang from it.
module zh_boom(len, w=0.9, h=0.7) {
cbox([w, len, h], 0.1);
for (y=[-len/2+0.6:1.6:len/2-0.4]) translate([0, y, -h/2-0.1]) cube([w+0.4, 0.25, 0.25], center=true);
}
// Towing point: a thick eye on a block.
module zh_tow_lug() { cbox([0.6, 0.6, 0.4], 0.08); translate([0,0,0.45]) rotate([0,90,0]) difference() { cylinder(r=0.38, h=0.25, center=true, $fn=8); cylinder(r=0.18, h=1, center=true, $fn=8); } }
// Ballistic mount: square base, slab mantlet, N thick barrels with a box muzzle brake. Barrels point -Y (nose).
module zh_gun(n=2, cal=0.22, len=3.0, base=1.6) {
cbox([base, base, 0.5], 0.12);
translate([0, 0, 0.55]) cbox([base*0.85, base*0.7, 0.7], 0.12);
for (i=[0:n-1]) translate([(i-(n-1)/2)*cal*3, -base*0.3, 0.6]) {
rotate([0,0,180]) tube(cal, len, 8);
translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04);
}
}
// Drive block: a flat rear face with square nozzles in a grid (recessed: emitters sit at the nozzle mouths).
module zh_drive_block(w, h, d, nx=2, nz=1, nozzle=1.0) {
difference() {
cbox([w, d, h], ZH_CH);
for (i=[0:nx-1], j=[0:nz-1]) translate([(i-(nx-1)/2)*(w/nx), d/2, (j-(nz-1)/2)*(h/nz)]) cube([nozzle, 0.8, nozzle], center=true);
}
}
// Palette zones. Value contrast carries the read at 48 px on dark space: the body is iron grey, the plates and seams
// are charcoal, the oxide red is the only hue. Zones become livery mask channels.
ZH_ZONES = [["primary", [0.42, 0.43, 0.45]], // iron grey: the stepped mass
["secondary", [0.20, 0.20, 0.22]], // charcoal: armour plates, booms, guns, command block
["accent", [0.55, 0.22, 0.14]], // dull oxide red: magazines, markings
["recess", [0.08, 0.08, 0.09]]]; // recessed machinery
ZH_GLOW = [1.0, 0.62, 0.25];
ZH_RADIATOR = [0.50, 0.51, 0.52];
// [faction, zones, body zone, glow, radiator, [height/width range, width/length range], symmetry rule]
ZH_LANG = ["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]];
if (part == "lang") echo(lang = ZH_LANG);

View file

@ -0,0 +1,391 @@
"""Agent-built hulls: reads a hull.scad through OpenSCAD and judges it against its faction's form language.
No bpy import. A hull source is a GameData/Mods/<id>/hull.scad that includes its faction's language file (and, for a
Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file. The faction's language is owned by its own file in
factions/: the checks read it from there, by the faction the brief names, and the hull's own `lang` echo is only compared
with it (a hull that redefines or shadows an included language fails a check):
brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads]
lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry]
(every included language file echoes its own, and so does factions/<faction>.scad run alone; a Pirate
language has no body zone and no proportion)
anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port
<zone> the zone's geometry, OFF; a donor's zone is named "<donor>:<zone>"
emit:<id>, rad:<id> the mesh of a thruster-emitter or radiator-mesh anchor
Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed
check. Messages and check names are fixed text: nothing a hull source says is echoed back.
"""
import hashlib
import json
import math
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
FACTIONS_DIR = Path(__file__).with_name("factions")
HULL_FILE = "hull.scad"
OPENSCAD_TIMEOUT = 180 # seconds, per part
SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares
ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh")
MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"}
ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer
LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*")
ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE)
# A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry.
Runner = Callable[[str], "tuple[str, str]"]
class HullSourceError(ValueError):
"""The hull source could not be read. The message is fixed text."""
@dataclass(frozen=True)
class Mesh:
vertices: tuple
triangles: tuple
def bounds(self):
"""((min x, y, z), (max x, y, z))."""
columns = list(zip(*self.vertices))
return tuple(min(c) for c in columns), tuple(max(c) for c in columns)
@dataclass(frozen=True)
class Language:
faction: str
zones: tuple # ((name, (r, g, b)), ...)
body: str # the zone whose proportions the language judges; "" for a language with no body of its own
glow: tuple
radiator: tuple
proportion: Optional[tuple] # ((h/w min, max), (w/l min, max)); None for a language that takes its donor's
symmetry: tuple # ("bilateral", minimum mirror overlap) or ("asymmetric", maximum mirror overlap)
def zone_names(self):
return [name for name, _ in self.zones]
def colour(self, zone):
return dict(self.zones)[zone]
@dataclass(frozen=True)
class Brief:
faction: str
donor: str
role: str
length: tuple
triangles: tuple
reads: tuple
@dataclass(frozen=True)
class Anchor:
id: str
role: str
position: tuple
yaw: float
parent: str
@dataclass(frozen=True)
class Check:
name: str
ok: bool
detail: str = ""
@dataclass
class HullBuild:
brief: Brief
languages: dict # faction -> Language
zones: dict # zone key ("primary", "rnd:primary") -> Mesh
colours: dict # zone key -> (r, g, b)
anchors: list
anchor_meshes: dict # anchor id -> Mesh
glow: tuple
radiator: tuple
body_zone: str # the zone key the collider is made from
checks: list = field(default_factory=list)
def failed(self):
return [check for check in self.checks if not check.ok]
def parse_off(text):
"""(vertices, triangles) of an OFF file, faces fanned into triangles. An empty or absent text is an empty mesh."""
lines = [line.split() for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#")]
if not lines or lines[0][0] != "OFF":
return Mesh((), ())
try:
header = lines[1] if len(lines[0]) == 1 else lines[0][1:]
vertex_count, face_count = int(header[0]), int(header[1])
first = 2 if len(lines[0]) == 1 else 1
vertices = tuple(tuple(float(v) for v in lines[first + i][:3]) for i in range(vertex_count))
triangles = []
for row in lines[first + vertex_count:first + vertex_count + face_count]:
ring = [int(v) for v in row[1:1 + int(row[0])]]
triangles += [(ring[0], ring[i], ring[i + 1]) for i in range(1, len(ring) - 1)]
if any(not 0 <= v < vertex_count for tri in triangles for v in tri):
raise ValueError
except (ValueError, IndexError):
raise HullSourceError("The hull source made geometry that is not a readable OFF file") from None
return Mesh(vertices, tuple(triangles))
def parse_echoes(text, name):
"""The values of every 'ECHO: <name> = <value>' line. OpenSCAD prints JSON-compatible values."""
values = []
for key, raw in ECHO_LINE.findall(text):
if key != name:
continue
try:
values.append(json.loads(raw))
except ValueError:
raise HullSourceError(f"The hull source echoed a {name} that is not plain numbers, strings and lists") from None
return values
def fingerprint(*values):
"""A digest of numbers, strings and the lists of them, floats to five decimals: equal exactly when the values are."""
digest = hashlib.sha256()
def add(value):
if isinstance(value, (list, tuple)):
digest.update(b"[")
for item in value:
add(item)
digest.update(b"]")
else:
digest.update((f"{round(value, 5) + 0.0:.5f};" if isinstance(value, float) else f"{value!r};").encode())
add(values)
return digest.hexdigest()
def _vector(value, size, numbers=True):
if not isinstance(value, list) or len(value) != size or (numbers and not all(_number(v) for v in value)):
raise HullSourceError("The hull source echoed a vector of the wrong shape")
return tuple(value)
def _number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _text(value):
if not isinstance(value, str):
raise HullSourceError("The hull source echoed a name that is not a string")
return value
def read_brief(text):
(raw,) = _exactly_one(parse_echoes(text, "brief"), "brief")
if not isinstance(raw, list) or len(raw) != 6 or not isinstance(raw[5], list):
raise HullSourceError("The hull source's brief has the wrong shape")
return Brief(_text(raw[0]), _text(raw[1]), _text(raw[2]), _vector(raw[3], 2), _vector(raw[4], 2),
tuple(_text(r) for r in raw[5]))
def _proportion(raw):
if raw == []:
return None
if not isinstance(raw, list) or len(raw) != 2:
raise HullSourceError("A faction language has the wrong shape")
return _vector(raw[0], 2), _vector(raw[1], 2)
def read_languages(text):
"""Every language echoed in text, in echo order."""
languages = []
for raw in parse_echoes(text, "lang"):
if not isinstance(raw, list) or len(raw) != 7 or not isinstance(raw[1], list):
raise HullSourceError("A faction language has the wrong shape")
zones = tuple((_text(z[0]), _vector(z[1], 3)) for z in raw[1] if isinstance(z, list) and len(z) == 2)
if len(zones) != len(raw[1]):
raise HullSourceError("A faction language has the wrong shape")
proportion = _proportion(raw[5])
symmetry = _vector(raw[6], 2, numbers=False)
if symmetry[0] not in ("bilateral", "asymmetric") or not _number(symmetry[1]):
raise HullSourceError("A faction language names a symmetry rule it does not have")
language = Language(_text(raw[0]), zones, _text(raw[2]), _vector(raw[3], 3), _vector(raw[4], 3), proportion,
symmetry)
if language.body and language.body not in language.zone_names():
raise HullSourceError("A faction language's body zone is not one of its zones")
languages.append(language)
return languages
def read_anchors(text):
anchors = []
for raw in parse_echoes(text, "anchor"):
if not isinstance(raw, list) or len(raw) != 5 or not _number(raw[3]):
raise HullSourceError("An anchor has the wrong shape")
anchors.append(Anchor(_text(raw[0]), _text(raw[1]), _vector(raw[2], 3), float(raw[3]), _text(raw[4])))
return anchors
def _exactly_one(values, what):
if len(values) != 1:
raise HullSourceError(f"The hull source must echo exactly one {what}")
return values
def mirror_overlap(triangles_xy, cell=SILHOUETTE_CELL):
"""Intersection over union of the top-view silhouette with its reflection across x = 0. The silhouette is every raster
cell whose centre a triangle covers (a feature thinner than a cell may not show)."""
cells = set()
for (ax, ay), (bx, by), (cx, cy) in triangles_xy:
det = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
if abs(det) < 1e-12:
continue
for i in range(math.floor(min(ax, bx, cx) / cell), math.floor(max(ax, bx, cx) / cell) + 1):
px = (i + 0.5) * cell
for j in range(math.floor(min(ay, by, cy) / cell), math.floor(max(ay, by, cy) / cell) + 1):
py = (j + 0.5) * cell
u = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / det
v = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / det
if u >= 0 and v >= 0 and u + v <= 1:
cells.add((i, j))
mirrored = {(-i - 1, j) for i, j in cells}
union = len(cells | mirrored)
return len(cells & mirrored) / union if union else 1.0
def _triangles_xy(mesh):
return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles]
def _language(library, faction):
"""The language of factions/<faction>.scad, which owns it: exactly one echo, naming that faction."""
(language,) = _exactly_one(read_languages(library(faction)), "language")
if language.faction != faction:
raise HullSourceError("A faction language file echoes the language of another faction")
return language
def build(runner, library):
"""Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with
(the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all;
otherwise returns a HullBuild whose failed() lists the checks it breaks."""
_, text = runner("brief")
brief = read_brief(text)
_, text = runner("lang")
included = read_languages(text)
own = _language(library, brief.faction)
donor = _language(library, brief.donor) if brief.donor else None
owned = [own] + ([donor] if donor else [])
zone_keys = list(own.zone_names()) + [f"{donor.faction}:{z}" for z in (donor.zone_names() if donor else ())]
colours = {z: own.colour(z) for z in own.zone_names()}
colours.update({f"{donor.faction}:{z}": donor.colour(z) for z in (donor.zone_names() if donor else ())})
zones = {key: parse_off(runner(key)[0]) for key in zone_keys}
_, text = runner("anchors")
anchors = read_anchors(text)
ids = [a.id for a in anchors]
if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors):
raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make")
anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES}
judge = donor or own
body_zone = f"{donor.faction}:{donor.body}" if donor else own.body
result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes,
own.glow, own.radiator, body_zone)
by_faction = lambda language: language.faction
result.checks = [Check("the hull includes its faction's language files and changes none of them",
sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")]
result.checks += _checks(result, own, judge)
return result
def _checks(hull, own, judge):
brief, checks = hull.brief, []
empty = [key for key, mesh in hull.zones.items() if not mesh.triangles]
checks.append(Check("every zone of the language has geometry", not empty, f"{len(empty)} empty"))
missing = [a.id for a in hull.anchors if a.role in MESH_ROLES and not hull.anchor_meshes[a.id].triangles]
checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without"))
checks.append(Check("a donor is named exactly when the language has no proportions of its own",
bool(brief.donor) == (own.proportion is None), ""))
body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ())
if body.triangles and judge.proportion:
low, high = body.bounds()
width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2])
ratios = (height / width if width else float("inf"), width / length if length else float("inf"))
(hw_lo, hw_hi), (wl_lo, wl_hi) = judge.proportion
checks.append(Check("the body's proportions lie inside the language's ranges",
hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi,
f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}"))
else:
checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge"))
every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles]
if every:
overlap = mirror_overlap([t for m in every for t in _triangles_xy(m)])
rule, bound = own.symmetry
checks.append(Check(f"the top view obeys the language's {rule} rule",
overlap >= bound if rule == "bilateral" else overlap <= bound, f"mirror overlap {overlap:.2f}"))
ys = [v[1] for m in every for v in m.vertices]
length = max(ys) - min(ys)
checks.append(Check("the length lies inside the brief's range", brief.length[0] <= length <= brief.length[1],
f"{length:.1f} m"))
else:
checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry"))
checks.append(Check("the length lies inside the brief's range", False, "no geometry"))
triangles = sum(len(m.triangles) for m in every)
checks.append(Check("the triangle count lies inside the brief's budget",
brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}"))
by_id = {a.id: a for a in hull.anchors}
unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount"
and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)]
forward = [m.id for m in hull.anchors if m.role == "weapon-muzzle" and m.parent in by_id
and by_id[m.parent].role == "weapon-mount" and m.position[1] >= by_id[m.parent].position[1]]
orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle"
and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")]
checks.append(Check("every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned",
not (unmuzzled or forward or orphans),
f"{len(unmuzzled)} without a muzzle, {len(forward)} not nose-ward, {len(orphans)} orphaned"))
return checks
def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None):
"""A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is
called with each process's id. OPENSCADPATH is the language folder, so a hull source includes <zhestokost.scad>."""
env = dict(os.environ, OPENSCADPATH=str(library))
def run(part):
with tempfile.TemporaryDirectory() as folder:
target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off")
command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"',
"-o", str(target), str(source)]
try:
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
except OSError:
raise HullSourceError("OpenSCAD could not be started") from None
if on_start:
on_start(process.pid)
try:
output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise HullSourceError("OpenSCAD did not finish a part in time") from None
if process.returncode != 0:
raise HullSourceError("OpenSCAD refused a part of the hull source")
written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else ""
return ("", written + output) if part in ECHO_PARTS else (written, output)
return run
def language_library(executable, library=FACTIONS_DIR, on_start=None):
"""The `library` build() reads languages from: faction -> the ECHO text of library/<faction>.scad run alone, so a hull
source (or a file beside it) cannot stand in for the faction's own file."""
def read(faction):
file = library / f"{faction}.scad"
if not LANGUAGE_NAME.fullmatch(faction) or not file.is_file():
raise HullSourceError("The hull source names a faction that has no language file")
return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1]
return read

View file

@ -0,0 +1,50 @@
// Pirate Coalition fighter "Markdown" on a stolen Rossum & Douglas wedge. An agent-built hull source: the donor body
// comes from the Rossum & Douglas language, every addition from the Pirate language.
// part = lang | brief | anchors | rnd:<zone> | primer | weld | accent | recess | emit:<id>
include <rnd.scad>
include <pirate.scad>
part = "rnd:primary";
HULL_FACTION = "pirate";
HULL_DONOR = "rnd";
HULL_ROLE = "fighter";
HULL_LENGTH = [9, 16];
HULL_TRIANGLES = [300, 6000];
HULL_READS = ["the stolen R&D wedge is still recognisable (flat dorsal plane, blunt nose, flush engine face)",
"one oversized gun out of proportion to the hull, on an adapted mount",
"a replacement drive that does not match", "bolted salvage plates, an exposed cable run, one stolen sensor pod",
"asymmetric"];
if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]);
L = 11; WR = 8; WN = 2.4;
module cut() { translate([-WR/2+0.4, L/2-0.6, 0]) rotate([0,0,35]) cube([3.2, 2.6, 3], center=true); }
module wedge() { difference() { rd_wedge(L, WR, WN, 0.55, 1.5); cut(); translate([0, L/2, 0]) rd_engine_bank(5.4, 0.4, 3); translate([0, -L/2+2.2, 0.75]) rd_window(1.4); } }
module stripe() { translate([1.1, 0.5, 0.75]) cube([0.9, L*0.55, 0.04], center=true); }
module donor_hatches() { for (y=[-1.2, 1.4]) translate([-1.4, y, 0.75]) rd_hatch(); }
module gun() { translate([2.3, -0.8, 0.45]) { pi_saddle(1.2, 2.4, 0.5); translate([0, 0, 0.9]) pi_big_gun(9.0, 0.18); } }
module drive() { translate([-2.5, L/2-0.2, 0.15]) { pi_drive(1.05, 2.6);
for (a=[[0.9,-1.4,0.2],[-0.9,-1.4,0.2],[0,-1.4,0.9]]) strut([0,0,0], a, 0.07); } }
module patch() { translate([-WR/2+1.25, L/2-1.25, 0.2]) rotate([0,0,35]) rotate([0,90,0]) pi_plate(0.7, 2.8, 0.12, 0); }
module plates() { translate([1.0, -L/2+1.9, 0.62]) pi_plate(1.4, 1.2, 0.12, 6); translate([-0.6, 2.4, 0.8]) pi_plate(1.8, 1.4, 0.12, -5); }
module welded_engine() { translate([1.8, L/2+0.05, 0]) cube([1.6, 0.12, 0.4], center=true); }
module cables() { pi_cable([[2.0, 0.6, 1.0], [1.0, 1.6, 0.9], [-0.6, 2.8, 0.95], [-1.6, 3.6, 0.85], [-2.3, 4.0, 0.9]]); }
module pod() { translate([-1.4, -2.6, 1.3]) pi_sensor_pod(1.5, 0.32); strut([-1.4, -2.6, 0.7], [-1.4, -2.6, 1.1], 0.07); }
module crew_slash() { translate([-0.2, -1.6, 0.78]) rotate([0,0,-38]) cube([0.55, 4.4, 0.04], center=true); }
if (part == "rnd:primary") wedge();
if (part == "rnd:secondary") donor_hatches();
if (part == "rnd:accent") stripe();
if (part == "rnd:recess") { translate([0, -L/2+2.2, 0.72]) rd_window(1.3); translate([0, L/2-0.25, 0]) cube([1.6, 0.05, 0.36], center=true); }
if (part == "primer") { patch(); plates(); welded_engine(); }
if (part == "weld") { gun(); drive(); cables(); }
if (part == "accent") { crew_slash(); pod(); }
if (part == "recess") translate([-2.5, L/2-0.2, 0.15]) pi_collar(1.15, 0.15);
if (part == "emit:Th.Main") translate([-2.5, L/2-0.2+1.17, 0.15]) rotate([90,0,0]) cylinder(r=0.95, h=0.05, center=true, $fn=8);
if (part == "emit:Th.L") translate([0, L/2-0.22, 0]) cube([1.2, 0.05, 0.32], center=true);
// Ids are HullData mount ids: the gameplay layout addresses these by name only.
if (part == "anchors") {
echo(anchor=["Gun.P", "weapon-mount", [2.3, -0.8, 1.35], 0, ""]);
echo(anchor=["Gun.P.muzzle", "weapon-muzzle", [2.3, -0.8-0.17*9.0-0.65*9.0-0.4, 1.4], 0, "Gun.P"]);
echo(anchor=["Th.Main", "thruster-emitter", [-2.5, L/2-0.2+1.17, 0.15], 180, ""]);
echo(anchor=["Th.L", "thruster-emitter", [0, L/2-0.22, 0], 180, ""]);
}

View file

@ -0,0 +1,49 @@
// Zhestokost ammunition tender "Podvoz". An agent-built hull source: composes the Zhestokost language only.
// part = lang | brief | anchors | primary | secondary | accent | recess | emit:<id> | rad:<id>
// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard.
include <zhestokost.scad>
part = "primary";
HULL_FACTION = "zhestokost";
HULL_DONOR = "";
HULL_ROLE = "tender";
HULL_LENGTH = [18, 28];
HULL_TRIANGLES = [300, 6000];
HULL_READS = ["ammunition magazines carried where a rearming ship can see and reach them",
"a handling boom that brings magazines forward", "towing points",
"one defensive ballistic mount, not a warship battery", "low, broad, heavy, slow"];
if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]);
L = 15; W = 10; H = 4.2; Y0 = 3; // main mass centred at y=+3 (nose is -Y)
module pockets() { for (s=[-1,1], y=[-1, 4]) translate([s*W/2, Y0+y, 1.3]) cube([1.2, 2.6, 1.4], center=true); }
module body() { difference() { translate([0, Y0, 0]) zh_stepped(L, W, H, 3, 0.10); pockets(); } }
module plates() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.05), Y0-5.4+i*3.6, 0.6]) rotate([0,0,90]) rotate([90,0,0]) zh_plate(3.2, 1.0, 0.3); }
module band() { for (s=[-1,1], i=[0:3]) translate([s*(W/2+0.22), Y0-5.4+i*3.6, 0.6]) cube([0.04, 3.2, 0.3], center=true); }
module booms() { for (s=[-1,1]) translate([s*2.6, -5.0, H-0.3]) { zh_boom(13); translate([0, 5.6, -0.9]) cbox([1.4, 1.4, 1.6], 0.12); } }
module magazines() { for (s=[-1,1], i=[0:3]) translate([s*3.75, -10.4+i*1.9, H-1.7]) zh_cassette(1.2, 1.5, 2.0); }
module arms() { for (s=[-1,1], i=[0:3]) translate([s*3.2, -10.4+i*1.9, H-0.45]) cube([1.6, 0.3, 0.3], center=true); }
module bridge() { translate([0, Y0+5.6, H]) { cbox([4.4, 2.6, 1.4], ZH_CH); translate([0, 0.3, 1.2]) cbox([2.8, 1.8, 1.0], ZH_CH);
translate([0, -1.32, 0.35]) cube([3.2, 0.06, 0.3], center=true); } }
module lugs() { for (s=[-1,1], y=[Y0-L/2+0.8, Y0+L/2-1.0]) translate([s*(W/2-1.6), y, H/3+0.2]) zh_tow_lug(); }
module drive() { translate([0, Y0+L/2+0.6, 1.3]) zh_drive_block(6.0, 2.2, 1.6, 2, 1, 1.3); }
module gun() { translate([0, Y0+3.2, H]) zh_gun(2, 0.2, 2.6, 1.6); }
module rad(s) { translate([s*(W/2+1.35), Y0+5.2, 2.0]) cube([2.5, 2.2, 0.12], center=true); }
module recess_fill() { for (s=[-1,1], y=[-1, 4]) translate([s*(W/2-0.55), Y0+y, 1.3]) cube([0.1, 2.5, 1.3], center=true); }
if (part == "primary") { body(); drive(); }
if (part == "secondary") { plates(); booms(); arms(); lugs(); gun(); bridge(); }
if (part == "accent") { magazines(); band(); }
if (part == "recess") recess_fill();
if (part == "rad:Ra.L") rad(1);
if (part == "rad:Ra.R") rad(-1);
if (part == "emit:Th.L") translate([ 1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true);
if (part == "emit:Th.R") translate([-1.5, Y0+L/2+1.05, 1.3]) cube([1.2, 0.05, 1.2], center=true);
// Ids are HullData mount ids: the gameplay layout addresses these by name only.
if (part == "anchors") {
echo(anchor=["Gun.D", "weapon-mount", [0, Y0+3.2, H+0.6], 0, ""]);
echo(anchor=["Gun.D.muzzle", "weapon-muzzle", [0, Y0+3.2-0.48-2.6-0.3, H+1.15], 0, "Gun.D"]);
echo(anchor=["Th.L", "thruster-emitter", [ 1.5, Y0+L/2+1.05, 1.3], 180, ""]);
echo(anchor=["Th.R", "thruster-emitter", [-1.5, Y0+L/2+1.05, 1.3], 180, ""]);
echo(anchor=["Ra.L", "radiator-mesh", [ W/2+1.35, Y0+5.2, 2.0], 0, ""]);
echo(anchor=["Ra.R", "radiator-mesh", [-W/2-1.35, Y0+5.2, 2.0], 0, ""]);
}

View file

@ -0,0 +1,475 @@
"""Headless Blender smoke for Build From Hull Source, with the real OpenSCAD and the real faction language files.
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_hull_source.py
Environment: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER as smoke_package.py's header says (the published AetherDb under
ModTools, GameData/Aetheria.cc), msgpack importable, and OpenSCAD 2026.09 or later at SMOKE_OPENSCAD (default: the add-on
preference's default). SMOKE_PID_LOG, when set, receives one line per OpenSCAD process. Each ship is created under
<game folder>/GameData/Mods/smoke.hull.*, which this smoke removes when it ends.
For each of the two fixture hulls (tests/fixtures/hulls: the Zhestokost ammunition tender and the Pirate fighter on a
Rossum & Douglas wedge) the smoke makes a ship from a stand-in box (New Ship), authors its gameplay layout separately
(rows named for the hull source's anchors, at cells that have nothing to do with where the model puts its gear), copies
the hull.scad into the package and runs the panel's operators:
Build: zone meshes with one palette material each, the mount anchors, the emitter and radiator meshes and a collider,
all recorded on the collection by name and fingerprint under Ship Root, each at the pose the hull source gives it
(a mount yawed 90 degrees included), and no layout row changed by Build itself.
A second Build replaces only its own objects: an object the author made, a mesh and an empty, is still there.
Build refuses, naming a rule and no input, a hull source that breaks its language's or brief's rule, and changes nothing.
Build refuses a hull source that redefines or shadows its faction's language (reassigned variable, second echo, a
language file beside the hull), and changes nothing.
Build refuses, and changes nothing, a collection that holds a render mesh it did not make, a copy of a generated zone,
or a generated zone or mount that was edited in place.
Package accepts the built ship (the C# validator), writes ship.glb with a node per anchor, and leaves hull.scad; a
Build after Package still works.
The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package deletes hull.scad in
that one act and says so, and Build then refuses.
A hull that was never built keeps its hull.scad through the same deletion rule.
"""
import json
import math
import os
import shutil
import struct
import sys
import traceback
from pathlib import Path
import addon_utils
import bmesh
import bpy
REPO = Path(__file__).resolve().parents[3]
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
OPENSCAD = os.environ.get("SMOKE_OPENSCAD")
PID_LOG = os.environ.get("SMOKE_PID_LOG")
MODS = GAME / "GameData" / "Mods"
FIXTURES = REPO / "tools" / "blender" / "tests" / "fixtures" / "hulls"
THRUSTER, RADIATOR, BALLISTIC = "3", "6", "10"
# fixture folder, ship id, stand-in box (x, y) metres and its length in cells, the layout rows (mount id, kind, cell),
# the body zone the collider copies, and the palette each zone must carry
HULLS = (
("zh-ammo-tender", "smoke.hull.tender", (10.0, 23.4), 12,
(("Gun.D", BALLISTIC, (2, 3)), ("Th.L", THRUSTER, (0, 0)), ("Th.R", THRUSTER, (1, 0)),
("Ra.L", RADIATOR, (2, 0)), ("Ra.R", RADIATOR, (3, 0))),
"primary", {"primary": (0.42, 0.43, 0.45), "secondary": (0.20, 0.20, 0.22), "accent": (0.55, 0.22, 0.14), "recess": (0.08, 0.08, 0.09)}),
("pi-fighter", "smoke.hull.fighter", (8.0, 15.0), 8,
(("Gun.P", BALLISTIC, (3, 3)), ("Th.Main", THRUSTER, (0, 0)), ("Th.L", THRUSTER, (1, 0))),
"rnd.primary", {"rnd.primary": (0.72, 0.74, 0.76), "primer": (0.48, 0.27, 0.22), "weld": (0.70, 0.68, 0.62),
"accent": (0.10, 0.62, 0.60), "rnd.accent": (0.90, 0.89, 0.84)}),
)
# per fixture: the variable the hull's language is in, a value that redefines it (every rule loosened), the language file a
# copy of which beside hull.scad would shadow the include, and a colour in it
ATTACKS = {
"zh-ammo-tender": ("ZH_LANG", '["zhestokost", ZH_ZONES, "primary", ZH_GLOW, ZH_RADIATOR, [[0, 9], [0, 9]], ["asymmetric", 0.0]]',
"zhestokost.scad", "[0.42, 0.43, 0.45]"),
"pi-fighter": ("PI_LANG", '["pirate", PI_ZONES, "", PI_GLOW, PI_RADIATOR, [], ["bilateral", 0.0]]',
"pirate.scad", "[0.48, 0.27, 0.22]"),
}
EXECUTABLE = None # the OpenSCAD the add-on preference names, set by main
hull_source = None # aetheria_ships.hull_source, set by main
def require(condition, message):
if not condition:
raise SystemExit(message)
def select(obj):
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
def run(operator):
try:
return operator(), ""
except RuntimeError as exc:
return {"CANCELLED"}, str(exc)
def approx(a, b, tolerance=1e-3):
return all(abs(x - y) <= tolerance for x, y in zip(a, b))
def generated(collection):
"""Build's objects, by the record Build keeps on the collection."""
record = collection.get("aetheria.hull_built")
return {obj.name: obj for obj in collection.all_objects if record is not None and obj.name in record}
def layout_rows(scene):
return [(hp.mount_id, hp.kind, hp.armor, hp.firing_arc, hp.footprint) for hp in scene.aetheria_layout.hardpoints]
def glb_nodes(path):
data = Path(path).read_bytes()
length, kind = struct.unpack_from("<II", data, 12)
require(kind == 0x4E4F534A, "The GLB's first chunk is not JSON")
return json.loads(data[20:20 + length])["nodes"]
def make_ship(scene, fixture, ship_id, extent, length, rows):
"""A ship from a stand-in box, its layout authored by hand, hull.scad copied into its package; returns the collection."""
collection = bpy.data.collections.new(f"Smoke {ship_id}")
scene.collection.children.link(collection)
mesh = bpy.data.meshes.new(f"{ship_id} stand-in")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bm.to_mesh(mesh)
bm.free()
box = bpy.data.objects.new(f"{ship_id} stand-in", mesh)
box.scale = (extent[0], extent[1], 2.0)
collection.objects.link(box)
select(box)
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ship_id, ship_id
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", length
result, message = run(bpy.ops.aetheria.new_ship)
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
state = scene.aetheria_layout
for mount_id, kind, (x, y) in rows:
bpy.ops.aetheria.add_ship_hardpoint()
hp = state.hardpoints[len(state.hardpoints) - 1]
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount_id, kind, x, y, "#"
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save was refused: {message}")
package = MODS / ship_id
shutil.copy(FIXTURES / fixture / "hull.scad", package / "hull.scad")
select(box)
return collection, box, package
def check_built(collection, root, body, palette, anchors_by_role):
objects = generated(collection)
for zone, colour in palette.items():
obj = objects.get(zone)
require(obj is not None and obj.type == "MESH" and obj.parent == root and len(obj.data.polygons) > 0,
f"Zone {zone} is not a mesh under Ship Root")
material = obj.data.materials[0]
base = next(n for n in material.node_tree.nodes if n.type == "BSDF_PRINCIPLED").inputs["Base Color"].default_value
require(approx(tuple(base)[:3], colour), f"Zone {zone} is not its palette colour: {tuple(base)}")
require(not any(p.use_smooth for p in obj.data.polygons), f"Zone {zone} is not flat shaded")
for role, ids in anchors_by_role.items():
for anchor_id in ids:
obj = objects.get(anchor_id)
require(obj is not None and obj.get("aetheria.role") == role and obj.get("aetheria.id") == anchor_id and obj.parent is not None,
f"Anchor {anchor_id} ({role}) was not built")
colliders = [o for o in objects.values() if o.get("aetheria.role") == "hull-collider"]
require(len(colliders) == 1 and colliders[0].type == "MESH", "Build did not make exactly one collider mesh")
shape = lambda obj: sorted(tuple(round(c, 4) for c in v.co) for v in obj.data.vertices)
require(shape(colliders[0]) == shape(objects[body]), "The collider is not the body zone's mesh")
return objects
def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette):
collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows)
state = scene.aetheria_layout
root = bpy.data.objects[next(o.name for o in collection.all_objects if o.get("aetheria.ship_root"))]
rows_before = layout_rows(scene)
source = package / "hull.scad"
lang_var, lang_value, shadowed, shadow_colour = ATTACKS[fixture]
runner = hull_source.openscad_runner(EXECUTABLE, source)
require(source.is_file(), "The fixture hull.scad was not copied")
require("Build From Hull Source" == bpy.ops.aetheria.build_hull_source.get_rna_type().name, "Build is not registered")
# Build, and a rule that fails before anything changes.
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"FINISHED"}, f"Build was refused: {message}")
roles = {}
for obj in generated(collection).values():
role = obj.get("aetheria.role")
if role in ("weapon-mount", "thruster-emitter", "radiator-mesh", "weapon-muzzle"):
roles.setdefault(role, []).append(obj.name)
require(roles.get("weapon-mount") and roles.get("weapon-muzzle") and roles.get("thruster-emitter"), f"Anchors missing: {roles}")
first = check_built(collection, root, body, palette, roles)
for muzzle in roles["weapon-muzzle"]:
mount = first[muzzle].parent
require(mount.get("aetheria.role") == "weapon-mount" and first[muzzle].get("aetheria.order") == 1, "A muzzle is not ordered under its mount")
require(first[muzzle].matrix_world.translation.y < mount.matrix_world.translation.y, "A muzzle is not nose-ward of its mount")
require(layout_rows(scene) == rows_before, "Build changed the layout buffer")
check_poses(collection, root, runner)
if fixture == "zh-ammo-tender":
original = source.read_text(encoding="utf-8")
yawed_mount(scene, box, collection, root, package, source, original, runner)
first = generated(collection)
print(f"PASS {number}.1 {fixture} builds: {len(first)} objects, {sum(len(v) for v in roles.values())} anchors")
# A second Build replaces only its own objects; the author's own empty and mesh (outside render meshes' reach: a mesh
# is tested separately) stay.
keep = bpy.data.objects.new("author note", None)
collection.objects.link(keep)
keep.parent = root
for obj in first.values():
obj["smoke.stale"] = True
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"FINISHED"}, f"The second Build was refused: {message}")
second = generated(collection)
require(sorted(second) == sorted(first), "The second Build changed the set of generated objects")
require(not any(obj.get("smoke.stale") for obj in second.values()), "The second Build kept an object of the first")
require(bpy.data.objects.get("author note") is keep and keep.parent == root, "Build removed an object the author made")
require(len([o for o in collection.all_objects if o.name.startswith("Hull Collider")]) == 1, "A second Build left a second collider")
print(f"PASS {number}.2 a second Build replaces only its own objects")
# Operator art in the collection: Build refuses and changes nothing.
art_mesh = bpy.data.meshes.new("author hull")
art_mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)])
art = bpy.data.objects.new("author hull", art_mesh)
collection.objects.link(art)
art.parent = root
names = sorted(o.name for o in collection.all_objects)
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "did not make" in message, f"Build overwrote or ignored operator art: {message!r}")
require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects")
for echoed in (ship_id, str(GAME), "author hull"):
require(echoed not in message, f"The refusal echoes an input ({echoed})")
bpy.data.objects.remove(art)
print(f"PASS {number}.3 Build refuses operator art and changes nothing")
# A hull source that breaks the brief: the refusal names the rule and nothing changes.
original = source.read_text(encoding="utf-8")
source.write_text(original.replace("HULL_LENGTH = [", "HULL_LENGTH = [200, 300]; UNUSED = ["), encoding="utf-8")
names = sorted(o.name for o in collection.all_objects)
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "the length lies inside the brief's range" in message, f"A broken brief was built: {message!r}")
require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects")
for echoed in (ship_id, str(GAME), fixture):
require(echoed not in message, f"The refusal echoes an input ({echoed})")
source.write_text(original, encoding="utf-8")
print(f"PASS {number}.4 Build refuses a hull that breaks its brief")
# Package: the validator accepts the built ship and the GLB holds a node per anchor.
select(box)
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"} and "validator accepted" in report, f"Package refused the built ship: {report!r} {message!r}")
require(source.is_file(), "Package deleted a hull source whose hull is still built from it")
nodes = {n.get("name"): n for n in glb_nodes(package / "ship.glb")}
for role, ids in roles.items():
for anchor_id in ids:
require(anchor_id in nodes, f"The GLB has no node for {anchor_id}")
for zone in palette:
require(zone in nodes, f"The GLB has no mesh node for zone {zone}")
moved = [r for r in (hp for hp in state.hardpoints) if r.mount_id in roles["weapon-mount"]]
print(f"INFO {number}.5 weapon rows after Package (fork package-derives-schematic-from-model): "
f"{[(hp.mount_id, (hp.x, hp.y)) for hp in moved]} (authored at {[r[2] for r in rows if r[1] == BALLISTIC]})")
print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad")
# What Package did to the built objects is not an edit: Build still replaces them.
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"FINISHED"}, f"Build after Package was refused: {message!r}")
print(f"PASS {number}.6 Build after Package replaces its own objects")
# A hull source that redefines or shadows its faction's language: refused, nothing changes.
attacks = []
for label, change in (
("reassigns the language after the include", lambda text: text + f"\n{lang_var} = {lang_value};\n"),
("echoes a second language", lambda text: text + f'\nif (part == "lang") echo(lang = {lang_value});\n')):
attacks.append((label, lambda change=change: source.write_text(change(original), encoding="utf-8"), lambda: None))
shadow = package / shadowed
shadow_text = (REPO / "tools" / "blender" / "aetheria_ships" / "factions" / shadowed).read_text(encoding="utf-8")
require(shadow_text.count(shadow_colour) >= 1, "The shadow's colour is not in the language file")
attacks.append(("shadows the language file beside itself",
lambda: shadow.write_text(shadow_text.replace(shadow_colour, "[1, 0, 1]"), encoding="utf-8"),
lambda: shadow.unlink()))
for label, apply, undo in attacks:
apply()
names = sorted(o.name for o in collection.all_objects)
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
undo()
source.write_text(original, encoding="utf-8")
require(result == {"CANCELLED"} and "includes its faction's language files" in message,
f"A hull that {label} was built: {message!r}")
require(sorted(o.name for o in collection.all_objects) == names, "A refused Build changed objects")
for echoed in (ship_id, str(GAME), fixture):
require(echoed not in message, f"The refusal echoes an input ({echoed})")
print(f"PASS {number}.7 Build refuses a hull that redefines or shadows its language")
# An author's copy of a generated zone, and an edit of a generated zone or mount, are the author's: Build refuses
# and leaves them as they are.
zone = bpy.data.objects[body]
piece = zone.copy()
piece.data = zone.data.copy()
piece.name = "my piece"
collection.objects.link(piece)
names = sorted(o.name for o in collection.all_objects)
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored a copy of a generated zone: {message!r}")
require(sorted(o.name for o in collection.all_objects) == names and bpy.data.objects.get("my piece") is piece,
"Build removed a copy of a generated zone")
mesh = piece.data
bpy.data.objects.remove(piece)
bpy.data.meshes.remove(mesh)
original_vertices = [tuple(v.co) for v in zone.data.vertices]
zone.data.vertices[0].co.z += 5.0
zone.data.update()
names = sorted(o.name for o in collection.all_objects)
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and "did not make" in message, f"Build ignored an edited zone: {message!r}")
require(sorted(o.name for o in collection.all_objects) == names and abs(zone.data.vertices[0].co.z - original_vertices[0][2] - 5.0) < 1e-3,
"Build discarded an edited zone")
api = sys.modules["aetheria_ships"]
require(api._retire_hull_source(collection) is False and source.is_file(),
"One edited zone, with the others still Build's, retired hull.scad")
for vertex, co in zip(zone.data.vertices, original_vertices):
vertex.co = co
zone.data.update()
mount = bpy.data.objects[roles["weapon-mount"][0]]
at = tuple(mount.location)
mount.location.x += 1.0
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and abs(mount.location.x - at[0] - 1.0) < 1e-6, f"Build discarded a moved mount: {message!r}")
mount.location = at
print(f"PASS {number}.8 Build refuses and keeps a copy and an edit of what it made")
# The author replaces the zone meshes with her art and keeps Build's anchors and collider: Package ships the ship
# and retires hull.scad in that one act; Build is gone with it.
for name in list(generated(collection)):
obj = generated(collection)[name]
if obj.type == "MESH" and "aetheria.role" not in obj:
bpy.data.objects.remove(obj)
art_mesh = bpy.data.meshes.new("author hull")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bm.to_mesh(art_mesh)
bm.free()
art = bpy.data.objects.new("author hull", art_mesh)
collection.objects.link(art)
art.parent = root
require(any(o.get("aetheria.role") for o in generated(collection).values()), "The scene kept no anchor of Build's")
select(box)
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"} and "deleted hull.scad" in report, f"Package did not retire the hull source: {report!r} {message!r}")
require(not source.exists(), "hull.scad is still there after the author's art was packaged")
require("aetheria.hull_built" not in collection, "The record of Build's objects outlived hull.scad")
require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input")
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}")
print(f"PASS {number}.9 her art with Build's anchors kept retires hull.scad, with a report; Build then refuses")
return collection
def check_poses(collection, root, runner):
"""Every built anchor stands where the hull source says in Ship Root's frame, turned as it says, and the emitter and
radiator meshes are the source's meshes in that frame."""
inverse = root.matrix_world.inverted()
for anchor in hull_source.read_anchors(runner("anchors")[1]):
obj = bpy.data.objects[anchor.id]
local = inverse @ obj.matrix_world
require(approx(tuple(local.translation), anchor.position), f"{anchor.id} stands at {tuple(local.translation)}, not {anchor.position}")
if anchor.role != "weapon-muzzle":
turn = local.to_euler().z
want = math.radians(anchor.yaw)
require(approx((math.cos(turn), math.sin(turn)), (math.cos(want), math.sin(want))),
f"{anchor.id} is turned {math.degrees(turn):.1f} degrees, not {anchor.yaw}")
if anchor.role in hull_source.MESH_ROLES:
made = sorted(tuple(round(c, 3) for c in (inverse @ obj.matrix_world @ v.co)) for v in obj.data.vertices)
wanted = sorted(tuple(round(c, 3) for c in v) for v in hull_source.parse_off(runner(hull_source.MESH_ROLES[anchor.role] + anchor.id)[0]).vertices)
require(len(made) == len(wanted) and all(approx(a, b, 2e-3) for a, b in zip(made, wanted)),
f"The mesh of {anchor.id} is not the source's mesh in Ship Root's frame")
def yawed_mount(scene, box, collection, root, package, source, original, runner):
"""A mount the source turns 90 degrees stands, turned, where it says, and its muzzle follows it."""
changed = original.replace('[0, Y0+3.2, H+0.6], 0, ""]', '[0, Y0+3.2, H+0.6], 90, ""]')
require(changed != original, "The tender's mount is not where the pose test turns it")
source.write_text(changed, encoding="utf-8")
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"FINISHED"}, f"Build refused a turned mount: {message!r}")
check_poses(collection, root, runner)
require(abs(bpy.data.objects["Gun.D"].rotation_euler.z - math.pi / 2) < 1e-4, "The mount is not turned 90 degrees")
source.write_text(original, encoding="utf-8")
select(box)
result, message = run(bpy.ops.aetheria.build_hull_source)
require(result == {"FINISHED"}, f"Build refused the restored source: {message!r}")
print("PASS 1.10 a turned mount and its muzzle stand where the source puts them")
def clear(collection):
"""Removes a finished ship's objects: Blender names objects once per file, and anchors are found by name."""
for obj in list(collection.all_objects if collection else ()):
bpy.data.objects.remove(obj)
def never_built(scene):
"""A hull.scad whose hull was never built has no art in the collection: the deletion rule keeps it."""
ship_id = "smoke.hull.unbuilt"
collection, box, package = make_ship(scene, "pi-fighter", ship_id, (8.0, 15.0), 8, ())
api = sys.modules["aetheria_ships"]
require(api._retire_hull_source(collection) is False and (package / "hull.scad").is_file(),
"A hull that was never built lost its hull.scad")
print("PASS 3 a hull that was never built keeps its hull.scad")
def main():
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
sys.path.insert(0, str(REPO / "tools" / "blender"))
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
global hull_source, EXECUTABLE
hull_source = aetheria_ships.hull_source
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
preferences.game_folder = str(GAME)
if OPENSCAD:
preferences.openscad = OPENSCAD
EXECUTABLE = preferences.openscad
real = aetheria_ships.hull_source.openscad_runner
def logged(executable, source, **kwargs):
kwargs.pop("on_start", None)
def on_start(pid):
if PID_LOG:
with open(PID_LOG, "a") as log:
log.write(f"{pid}\n")
return real(executable, source, on_start=on_start, **kwargs)
aetheria_ships.hull_source.openscad_runner = logged
for _, ship_id, *_ in HULLS:
require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first")
scene = bpy.context.scene
previous = None
for number, (fixture, ship_id, extent, length, rows, body, palette) in enumerate(HULLS, start=1):
clear(previous)
previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette)
clear(previous)
never_built(scene)
created = not MODS.exists()
try:
main()
except SystemExit as stop:
print(f"SMOKE_HULL_SOURCE_FAILED: {stop}", flush=True)
code = 1
except Exception:
traceback.print_exc()
print("SMOKE_HULL_SOURCE_FAILED", flush=True)
code = 1
else:
print("SMOKE_HULL_SOURCE_OK", flush=True)
code = 0
finally:
for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"),):
shutil.rmtree(MODS / ship_id, ignore_errors=True)
if created:
shutil.rmtree(MODS, ignore_errors=True)
sys.stdout.flush()
os._exit(code)

View file

@ -0,0 +1,552 @@
"""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.
"""
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
_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"
def language(faction="zhestokost", zones=("primary", "secondary"), body="primary", proportion=((0.25, 0.6), (0.35, 0.75)),
symmetry=("bilateral", 0.97)):
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
else [], list(symmetry)])
def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)):
return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]])
class Fixture:
"""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."""
def __init__(self):
self.library = {"zhestokost": language()}
self.hull_lang = None
# 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 nose-ward.
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), ""),
}
def runner(self):
return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part]
def read_library(self, faction):
if faction not in self.library:
raise hs.HullSourceError("no such language file")
return self.library[faction]
def build(self):
return hs.build(self.runner(), self.read_library)
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))
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"]))
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,
symmetry=("asymmetric", 0.9)))[0]
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"))
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)
def test_nothing_overlaps_itself_completely(self):
self.assertEqual(hs.mirror_overlap([]), 1.0)
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()
fixture.library["zhestokost"] = language(proportion=((0.8, 1.2), (0.1, 0.3)))
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"])
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)})
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"])
def test_a_bilateral_bound_is_met_by_exactly_that_overlap(self):
fixture = Fixture()
fixture.library["zhestokost"] = language(symmetry=("bilateral", 1.0))
self.assertEqual(fixture.failed(), [])
def test_an_asymmetric_language_refuses_a_symmetric_hull(self):
fixture = Fixture()
fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9))
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()
fixture.library["zhestokost"] = language("zhestokost", symmetry=("asymmetric", 0.9))
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)
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"])
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)
MUZZLE = "every weapon-mount has a muzzle nose-ward of it and no muzzle is orphaned"
def test_a_muzzle_aft_of_its_mount_is_refused(self):
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", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""]))
self.assertEqual(fixture.failed(), [self.MUZZLE])
def test_a_muzzle_level_with_its_mount_is_not_nose_ward(self):
fixture = Fixture()
fixture.parts["anchors"] = ("", echo("anchor", ["Gun.A", "weapon-mount", [0, 0, 3], 0, ""])
+ echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [1, 0, 3], 0, "Gun.A"]))
self.assertEqual(fixture.failed(), [self.MUZZLE])
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])
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])
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)))
fixture.library = {"pirate": language("pirate", ("primer", "weld"), body="", proportion=None,
symmetry=("asymmetric", 0.9)),
"rnd": language("rnd", ("primary", "secondary"), proportion=donor_proportion)}
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"])
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"])
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="")
del fixture.library["rnd"]
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"))
fixture.library["rnd"] = language("rnd", ("primary", "secondary"))
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())
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",
[1, 0.6, 0.2], [0.5, 0.5, 0.5], [[0.25, 0.6], [0.35, 0.75]], ["bilateral", 0.97]])
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())
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])
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])
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()
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction))
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:
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"):
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))
def test_a_list_of_two_empty_lists_is_not_one_list_holding_one(self):
self.assertNotEqual(hs.fingerprint([], []), hs.fingerprint([[]]))
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,)))
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()
def test_a_named_donor_that_has_no_language_file_is_refused(self):
fixture = Fixture()
fixture.parts["brief"] = ("", brief("zhestokost", "rnd"))
with self.assertRaises(hs.HullSourceError):
fixture.build()
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()
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))
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)
for raw in ([7, [["primary", [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]],
["zhestokost", [[7, [1, 1, 1]]], "primary", [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]],
["zhestokost", [["primary", [1, 1, 1]]], 7, [1, 1, 1], [1, 1, 1], [], ["bilateral", 1]]):
with self.assertRaises(hs.HullSourceError):
hs.read_languages(echo("lang", raw))
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)
if __name__ == "__main__":
unittest.main()