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:
commit
5acbaa1b2f
10 changed files with 1849 additions and 2 deletions
|
|
@ -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():
|
||||
|
|
|
|||
14
tools/blender/aetheria_ships/factions/lowpoly.scad
Normal file
14
tools/blender/aetheria_ships/factions/lowpoly.scad
Normal 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); } }
|
||||
43
tools/blender/aetheria_ships/factions/pirate.scad
Normal file
43
tools/blender/aetheria_ships/factions/pirate.scad
Normal 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);
|
||||
30
tools/blender/aetheria_ships/factions/rnd.scad
Normal file
30
tools/blender/aetheria_ships/factions/rnd.scad
Normal 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);
|
||||
60
tools/blender/aetheria_ships/factions/zhestokost.scad
Normal file
60
tools/blender/aetheria_ships/factions/zhestokost.scad
Normal 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);
|
||||
391
tools/blender/aetheria_ships/hull_source.py
Normal file
391
tools/blender/aetheria_ships/hull_source.py
Normal 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
|
||||
50
tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad
vendored
Normal file
50
tools/blender/tests/fixtures/hulls/pi-fighter/hull.scad
vendored
Normal 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, ""]);
|
||||
}
|
||||
49
tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad
vendored
Normal file
49
tools/blender/tests/fixtures/hulls/zh-ammo-tender/hull.scad
vendored
Normal 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, ""]);
|
||||
}
|
||||
475
tools/blender/tests/smoke_hull_source.py
Normal file
475
tools/blender/tests/smoke_hull_source.py
Normal 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)
|
||||
552
tools/blender/tests/test_hull_source.py
Normal file
552
tools/blender/tests/test_hull_source.py
Normal 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()
|
||||
Loading…
Reference in a new issue