From 922928ebe7f8dde67e4528fd8aeeb72fb77a666b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:35:46 +0200 Subject: [PATCH 01/18] Ship grid: hull_grid.py holds the cell arithmetic and the silhouette rasteriser, importable without Blender cell_centre and cell_at map a cell to and from the Ship Root frame through a stored grid_origin; rasterise turns top-down triangles into column-major cells (4x4 samples, half occupies, 32 cells per axis at most). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_grid.py | 85 +++++++++++++++ tools/blender/tests/test_hull_grid.py | 127 ++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tools/blender/aetheria_ships/hull_grid.py create mode 100644 tools/blender/tests/test_hull_grid.py diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py new file mode 100644 index 000000000..825ccea45 --- /dev/null +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -0,0 +1,85 @@ +"""The ship grid's arithmetic: where a cell is, and which cells a hull's top-down silhouette occupies. Imports without bpy. + +Frame: the Ship Root frame in metres, nose toward -Y and starboard toward -X (map M7). Cell (x, y) is centred on its +integer coordinate, so its centre is grid_origin + (-CELL_SIZE x, -CELL_SIZE y), where grid_origin is cell (0, 0)'s +centre (the Aetheria.Entity.ToWorldPoint rule: schematic +x is starboard, +y is forward). grid_origin is a stored fact +written only by Rasterise; every lookup below takes it as an argument. +""" + +import math + +CELL_SIZE = 2.0 # metres per cell, Settings.SchematicCellSize +MAX_CELLS = 32 # per axis, the layout panel's limit +SAMPLES = 4 # per axis per cell: 4 x 4 coverage samples +_EDGE = 1e-9 # slack for float noise on a sample bound, in sample steps +_TOLERANCE = 1e-3 # metres: mesh coordinates are float32, so an extent a millimetre over a cell boundary is on it + + +def cell_centre(grid_origin, x, y): + """The Ship Root frame position of cell (x, y)'s centre.""" + return (grid_origin[0] - CELL_SIZE * x, grid_origin[1] - CELL_SIZE * y) + + +def cell_at(grid_origin, point): + """The cell whose centre is nearest to point; a point midway between two centres belongs to the lower cell.""" + return (math.ceil((grid_origin[0] - point[0]) / CELL_SIZE - 0.5), + math.ceil((grid_origin[1] - point[1]) / CELL_SIZE - 0.5)) + + +def _cells_across(extent, cell_size): + return max(1, math.ceil((extent - _TOLERANCE) / cell_size)) + + +def _inside(triangle, px, py): + (ax, ay), (bx, by), (cx, cy) = triangle + d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + d2 = (cx - bx) * (py - by) - (cy - by) * (px - bx) + d3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx) + return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0)) + + +def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): + """Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame. + + The grid is ceil(extent / cell_size) cells on each axis, centred on the silhouette's bounding box. Each cell is + tested at samples x samples points; it is occupied when at least half of them fall in some triangle. A sample + inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box. + Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell + (0, 0)'s centre.""" + triangles = [tri for tri in triangles_xy if + (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] + if not triangles: + raise ValueError("The hull has no triangle with area to rasterise") + xs = [p[0] for tri in triangles for p in tri] + ys = [p[1] for tri in triangles for p in tri] + width = _cells_across(max(xs) - min(xs), cell_size) + height = _cells_across(max(ys) - min(ys), cell_size) + for axis, count in (("wide", width), ("long", height)): + if count > MAX_CELLS: + raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}") + # Cell (0, 0)'s centre: the far (+X, +Y) corner of the grid, half a cell in. + origin = ((min(xs) + max(xs)) / 2 + (width - 1) * cell_size / 2, (min(ys) + max(ys)) / 2 + (height - 1) * cell_size / 2) + step = cell_size / samples + # Sample (a, b) sits at origin + cell_size/2 - (a + 1/2) step on each axis, so it falls in cell (a // samples, b // samples). + top_x, top_y = origin[0] + cell_size / 2, origin[1] + cell_size / 2 + + def span(low, high, top, count): + first = math.ceil((top - high) / step - 0.5 - _EDGE) + last = math.floor((top - low) / step - 0.5 + _EDGE) + return range(max(first, 0), min(last, count * samples - 1) + 1) + + hits = set() + for tri in triangles: + txs = [p[0] for p in tri] + tys = [p[1] for p in tri] + for a in span(min(txs), max(txs), top_x, width): + px = top_x - (a + 0.5) * step + for b in span(min(tys), max(tys), top_y, height): + if (a, b) not in hits and _inside(tri, px, top_y - (b + 0.5) * step): + hits.add((a, b)) + counts = {} + for a, b in hits: + key = (a // samples, b // samples) + counts[key] = counts.get(key, 0) + 1 + cells = [2 * counts.get((x, y), 0) >= samples * samples for x in range(width) for y in range(height)] + return width, height, cells, origin diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py new file mode 100644 index 000000000..ac8ef248f --- /dev/null +++ b/tools/blender/tests/test_hull_grid.py @@ -0,0 +1,127 @@ +"""Blender-free tests for aetheria_ships/hull_grid.py, the arithmetic behind New Ship, Rasterise and the Grid object. + + python -m unittest discover -s tools/blender/tests + +hull_grid is loaded by path because the package __init__ imports bpy. Frame: nose toward -Y, starboard toward -X, cell +(x, y) centred on grid_origin + (-2x, -2y) metres. +""" + +import importlib.util +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py") +hull_grid = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(hull_grid) + + +def rect(x0, y0, x1, y1): + return [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))] + + +def occupied(cells, height): + return {(i // height, i % height) for i, cell in enumerate(cells) if cell} + + +class CellMappingTests(unittest.TestCase): + ORIGIN = (3.0, -5.0) + + def test_every_cell_of_a_5x3_grid_maps_back_to_itself(self): + for x in range(5): + for y in range(3): + centre = hull_grid.cell_centre(self.ORIGIN, x, y) + self.assertEqual(hull_grid.cell_at(self.ORIGIN, centre), (x, y)) + + def test_cell_1_0_is_two_metres_toward_minus_x_and_0_1_toward_minus_y(self): + origin = hull_grid.cell_centre(self.ORIGIN, 0, 0) + self.assertEqual(origin, self.ORIGIN) + self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 1, 0), (1.0, -5.0)) + self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 0, 1), (3.0, -7.0)) + self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 4, 2), (-5.0, -9.0)) + + def test_cell_at_takes_the_nearest_centre_and_the_lower_cell_on_a_tie(self): + near = lambda dx, dy: hull_grid.cell_at(self.ORIGIN, (self.ORIGIN[0] - 2 + dx, self.ORIGIN[1] - 4 + dy)) + self.assertEqual(near(0.9, -0.9), (1, 2)) + self.assertEqual(near(-0.9, 0.9), (1, 2)) + self.assertEqual(near(1.1, 0), (0, 2)) + self.assertEqual(near(-1.1, 0), (2, 2)) + self.assertEqual(near(0, 1.1), (1, 1)) + self.assertEqual(near(0, -1.1), (1, 3)) + self.assertEqual(near(1.0, 1.0), (0, 1)) + self.assertEqual(near(-1.0, -1.0), (1, 2)) + + +class RasteriseTests(unittest.TestCase): + def test_eight_of_sixteen_samples_occupy_a_cell_and_seven_do_not(self): + wing = rect(-2, -1, 0, 1) # cell (1, 0) in full; fixes the grid at 2 x 1 cells about the origin + eight = rect(0, -1, 2, 0) + seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0) + for triangles, expected in ((eight, True), (seven, False)): + width, height, cells, origin = hull_grid.rasterise(wing + triangles) + self.assertEqual((width, height, origin), (2, 1, (1.0, 0.0))) + self.assertEqual(cells, [expected, True]) + + def test_a_sample_inside_two_triangles_counts_once(self): + seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0) + _, _, cells, _ = hull_grid.rasterise(rect(-2, -1, 0, 1) + seven + seven) + self.assertEqual(cells, [False, True]) + + def test_an_l_shape_gives_the_cells_of_the_l(self): + width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)) + self.assertEqual((width, height, origin), (2, 2, (1.0, 1.0))) + self.assertEqual(occupied(cells, height), {(0, 0), (0, 1), (1, 1)}) + self.assertEqual(cells, [True, True, False, True]) + + def test_winding_does_not_matter(self): + flipped = [(a, c, b) for a, b, c in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)] + self.assertEqual(hull_grid.rasterise(flipped)[2], [True, True, False, True]) + + def test_the_grid_is_ceil_of_the_extent_over_two_metres_on_each_axis(self): + for extent, cells_across in ((1.0, 1), (2.0, 1), (2.01, 2), (4.0, 2), (4.0000001, 2), (4.0005, 2), (4.002, 3), (4.01, 3), (5.0, 3), (20.0, 10)): + width, height, _, _ = hull_grid.rasterise(rect(0, 0, extent, 6.5)) + self.assertEqual((width, height), (cells_across, 4), extent) + + def test_the_grid_is_centred_on_the_silhouette(self): + width, height, cells, origin = hull_grid.rasterise(rect(10, 20, 13, 22)) + self.assertEqual((width, height), (2, 1)) + self.assertEqual(origin, (12.5, 21.0)) + self.assertEqual(cells, [True, True]) + + def test_cell_centres_from_the_origin_lie_in_the_cells_they_name(self): + width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)) + for x in range(width): + for y in range(height): + cx, cy = hull_grid.cell_centre(origin, x, y) + self.assertEqual(hull_grid.cell_at(origin, (cx, cy)), (x, y)) + inside = any(hull_grid._inside(tri, cx, cy) for tri in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)) + self.assertEqual(inside, cells[x * height + y]) + + def test_samples_per_axis_and_cell_size_are_parameters(self): + triangles = rect(-1, -1, 1, 0) + self.assertEqual(hull_grid.rasterise(triangles, samples=1)[2], [True]) + self.assertEqual(hull_grid.rasterise(triangles, cell_size=1.0)[:2], (2, 1)) + + def test_thirty_two_cells_are_accepted_and_thirty_three_is_refused_naming_it(self): + width, height, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 64)) + self.assertEqual((width, height, len(cells), all(cells)), (1, 32, 32, True)) + with self.assertRaises(ValueError) as caught: + hull_grid.rasterise(rect(0, 0, 2, 66)) + self.assertIn("33", str(caught.exception)) + with self.assertRaises(ValueError) as caught: + hull_grid.rasterise(rect(0, 0, 66, 2)) + self.assertIn("33", str(caught.exception)) + + def test_a_silhouette_with_no_area_is_refused(self): + with self.assertRaises(ValueError): + hull_grid.rasterise([]) + with self.assertRaises(ValueError): + hull_grid.rasterise([((0, 0), (1, 1), (2, 2))]) + + def test_a_degenerate_triangle_does_not_widen_the_grid(self): + _, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [((50, 50), (60, 60), (70, 70))]) + self.assertEqual(cells, [True]) + + +if __name__ == "__main__": + unittest.main() From a308a7d8ea1ff0636156690ff6b00e1ef5968578 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:35:47 +0200 Subject: [PATCH 02/18] Add-on: New Ship, Flip Nose, Rasterise Hull and a Grid object; Source and Generated never exported New Ship creates the ship .cc, binds through the one shared binding, moves the meshes into Source under a Ship Root, turns them nose to -Y and sizes them. Rasterise is the only writer of aetheria.grid_origin; Load, Save and Rasterise redraw the Grid. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 390 +++++++++++++++++++---- tools/blender/tests/smoke_frame.py | 203 ++++++++++++ 2 files changed, 538 insertions(+), 55 deletions(-) create mode 100644 tools/blender/tests/smoke_frame.py diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index bcf9f6f7f..17b81a3a0 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -10,15 +10,22 @@ bl_info = { "category": "Object", } +import math import subprocess from pathlib import Path import bpy +import numpy +from mathutils import Matrix +from . import hull_grid from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, encode_hardpoint, read, read_layout, replace_layout, replace_lines, replace_visual) MODEL_ASSET = "ship.glb" +SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported +GENERATED = "Generated" # child collection: derived objects (the Grid), never exported +SHIP_ROOT = "Ship Root" AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb @@ -79,6 +86,34 @@ def _layout_path(context): return collection, bpy.path.abspath(collection["aetheria.ship_cc"]) +def _load_layout(context, collection, path): + """Fills the scene's layout state from the bound .cc and redraws the Grid; returns (width, height, hardpoints).""" + shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context)) + width, height, cells = shape + state = context.scene.aetheria_layout + state.ship_id = collection["aetheria.id"] + state.ship_cc = path + state.revision = revision + state.width = state.new_width = width + state.height = state.new_height = height + state.cells.clear() + for cell in cells: + state.cells.add().occupied = bool(cell) + state.hardpoints.clear() + for raw in hardpoints: + fields = decode_hardpoint(raw) + hp = state.hardpoints.add() + hp.kind = str(fields["Type"]) + hp.x, hp.y = fields["Position"] + hp.footprint = _footprint(fields["Shape"]) + hp.mount_id = fields["Transform"] or "" + hp.rotation = str(fields["Rotation"]) + hp.armor = fields["Armor"] + hp.firing_arc = fields["FiringArc"] + _redraw_grid(collection, state) + return width, height, len(hardpoints) + + class AETHERIA_OT_load_layout(bpy.types.Operator): bl_idname = "aetheria.load_ship_layout" bl_label = "Load Ship Layout" @@ -87,29 +122,8 @@ class AETHERIA_OT_load_layout(bpy.types.Operator): def execute(self, context): try: collection, path = _layout_path(context) - shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context)) - width, height, cells = shape - state = context.scene.aetheria_layout - state.ship_id = collection["aetheria.id"] - state.ship_cc = path - state.revision = revision - state.width = state.new_width = width - state.height = state.new_height = height - state.cells.clear() - for cell in cells: - state.cells.add().occupied = bool(cell) - state.hardpoints.clear() - for raw in hardpoints: - fields = decode_hardpoint(raw) - hp = state.hardpoints.add() - hp.kind = str(fields["Type"]) - hp.x, hp.y = fields["Position"] - hp.footprint = _footprint(fields["Shape"]) - hp.mount_id = fields["Transform"] or "" - hp.rotation = str(fields["Rotation"]) - hp.armor = fields["Armor"] - hp.firing_arc = fields["FiringArc"] - self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints") + width, height, count = _load_layout(context, collection, path) + self.report({"INFO"}, f"Loaded {width}x{height} layout and {count} hardpoints") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: self.report({"ERROR"}, str(exc)) @@ -177,6 +191,7 @@ class AETHERIA_OT_save_layout(bpy.types.Operator): path, _brokkr_cultlib(context), state.ship_id, state.revision, [state.width, state.height, cells], hardpoints ) + _redraw_grid(collection, state) self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: @@ -202,6 +217,42 @@ def _ship_collection(context): return matches[0] +def _choose_collection(context): + """The collection a bind acts on: the one marked ship that holds the active object, else the active object's only + collection.""" + obj = context.active_object + if obj is None: + raise ValueError("Select an object in the ship collection") + direct = list(obj.users_collection) + marked = [collection for collection in bpy.data.collections + if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] + if len(marked) == 1: + return marked[0] + if len(marked) == 0 and len(direct) == 1: + return direct[0] + raise ValueError("Select an object in exactly one ship collection") + + +def _bind_collection(context, path): + """Binds the chosen collection to the ship .cc at path (aetheria.asset_kind, .id and .ship_cc), or changes nothing.""" + collection = _choose_collection(context) + ship_id = read(path, _brokkr_cultlib(context)).ship.body[0] + if collection.get("aetheria.asset_kind") not in (None, "ship"): + raise ValueError(f"{collection.name} has another aetheria.asset_kind") + if collection.get("aetheria.id") not in (None, ship_id): + raise ValueError(f"{collection.name} is bound to another ship ID") + stored = path + if bpy.data.filepath: + try: + stored = bpy.path.relpath(path) + except ValueError: + pass + collection["aetheria.asset_kind"] = "ship" + collection["aetheria.id"] = ship_id + collection["aetheria.ship_cc"] = stored + return collection + + class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): bl_idname = "aetheria.bind_ship_collection" bl_label = "Bind Ship Collection" @@ -212,34 +263,8 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): try: if not context.scene.aetheria_ship_cc_path: raise ValueError("Choose an existing ship authoring .cc file") - obj = context.active_object - if obj is None: - raise ValueError("Select an object in the ship collection") - direct = list(obj.users_collection) - marked = [collection for collection in bpy.data.collections - if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] - if len(marked) == 1: - collection = marked[0] - elif len(marked) == 0 and len(direct) == 1: - collection = direct[0] - else: - raise ValueError("Select an object in exactly one ship collection") - path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) - ship_id = read(path, _brokkr_cultlib(context)).ship.body[0] - if collection.get("aetheria.asset_kind") not in (None, "ship"): - raise ValueError(f"{collection.name} has another aetheria.asset_kind") - if collection.get("aetheria.id") not in (None, ship_id): - raise ValueError(f"{collection.name} is bound to another ship ID") - stored = path - if bpy.data.filepath: - try: - stored = bpy.path.relpath(path) - except ValueError: - pass - collection["aetheria.asset_kind"] = "ship" - collection["aetheria.id"] = ship_id - collection["aetheria.ship_cc"] = stored - self.report({"INFO"}, f"Bound {collection.name} to {ship_id}") + collection = _bind_collection(context, bpy.path.abspath(context.scene.aetheria_ship_cc_path)) + self.report({"INFO"}, f"Bound {collection.name} to {collection['aetheria.id']}") return {"FINISHED"} except (OSError, ValueError, RuntimeError, ImportError) as exc: self.report({"ERROR"}, str(exc)) @@ -341,7 +366,8 @@ def _anchors(collection): def _export_glb(context, collection, filepath): - """Exports the collection's objects, Grease Pencil excluded, with custom properties as node extras.""" + """Exports the collection's objects, Grease Pencil and the Source and Generated children excluded, with custom + properties as node extras.""" view_layer = context.view_layer layer = _layer_collection(view_layer.layer_collection, collection) if layer is None: @@ -353,8 +379,9 @@ def _export_glb(context, collection, filepath): view_layer.active_layer_collection = layer for obj in previous_selection: obj.select_set(False) + derived = _derived_names(collection) for obj in collection.all_objects: - if obj.type != "GREASEPENCIL" and obj.name in view_layer.objects: + if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects: obj.select_set(True) bpy.ops.export_scene.gltf( filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True, @@ -366,6 +393,237 @@ def _export_glb(context, collection, filepath): view_layer.active_layer_collection = previous_layer +def _child(collection, kind): + """The child collection tagged aetheria.frame == kind. Blender keeps collection names unique per file, so the + name 'Source' or 'Generated' cannot identify it.""" + return next((child for child in collection.children if child.get("aetheria.frame") == kind), None) + + +def _child_or_new(collection, kind, name): + child = _child(collection, kind) + if child is None: + child = bpy.data.collections.new(name) + child["aetheria.frame"] = kind + collection.children.link(child) + return child + + +def _derived_names(collection): + """Names of the objects in the Source and Generated children, which are never exported.""" + return {obj.name for kind in (SOURCE, GENERATED) for child in [_child(collection, kind)] if child + for obj in child.all_objects} + + +def _ship_root(collection): + return next((obj for obj in collection.objects if obj.get("aetheria.ship_root")), None) + + +def _meshes(objects): + return [obj for obj in objects if obj.type == "MESH"] + + +def _render_meshes(collection): + """The ship's own meshes: bound-collection meshes outside Source and Generated that carry no aetheria.role.""" + derived = _derived_names(collection) + return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and "aetheria.role" not in obj] + + +def _world_mesh(obj, depsgraph, matrix): + """The evaluated mesh's points through matrix, as (points, triangles) numpy arrays.""" + evaluated = obj.evaluated_get(depsgraph) + mesh = evaluated.to_mesh() + try: + points = numpy.empty(len(mesh.vertices) * 3, dtype=numpy.float32) + mesh.vertices.foreach_get("co", points) + mesh.calc_loop_triangles() + triangles = numpy.empty(len(mesh.loop_triangles) * 3, dtype=numpy.int32) + mesh.loop_triangles.foreach_get("vertices", triangles) + finally: + evaluated.to_mesh_clear() + linear = numpy.array(matrix, dtype=numpy.float64) + return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3) + + +def _redraw_grid(collection, state): + """Redraws the Grid object from the layout state and the collection's aetheria.grid_origin. A collection without a + Ship Root or a grid_origin has no frame yet and gets no Grid.""" + root = _ship_root(collection) + origin = collection.get("aetheria.grid_origin") + if root is None or origin is None or len(state.cells) != state.width * state.height: + return + origin = (float(origin[0]), float(origin[1])) + half = hull_grid.CELL_SIZE / 2 + verts, edges, faces = [], [], [] + for x in range(state.width): + for y in range(state.height): + cx, cy = hull_grid.cell_centre(origin, x, y) + i = len(verts) + verts += [(cx - half, cy - half, 0.0), (cx + half, cy - half, 0.0), (cx + half, cy + half, 0.0), + (cx - half, cy + half, 0.0)] + if state.cells[x * state.height + y].occupied: + faces.append((i, i + 1, i + 2, i + 3)) + else: + edges += [(i, i + 1), (i + 1, i + 2), (i + 2, i + 3), (i + 3, i)] + mesh = bpy.data.meshes.new("Grid") + mesh.from_pydata(verts, edges, faces) + mesh.update(calc_edges=True) + generated = _child_or_new(collection, GENERATED, GENERATED) + grid = next((obj for obj in generated.objects if obj.get("aetheria.grid")), None) + if grid is None: + grid = bpy.data.objects.new("Grid", mesh) + grid["aetheria.grid"] = True + generated.objects.link(grid) + else: + previous, grid.data = grid.data, mesh + if previous.users == 0: + bpy.data.meshes.remove(previous) + grid.parent = root + grid.matrix_parent_inverse = Matrix.Identity(4) + grid.location = (0.0, 0.0, 0.0) + grid.rotation_mode = "XYZ" + grid.rotation_euler = (0.0, 0.0, 0.0) + grid.scale = (1.0, 1.0, 1.0) + grid.display_type = "WIRE" + + +def _rasterise(context, collection, path): + """Proposes the hull's cells from the render meshes (Source while there are none), top-down in Ship Root's frame, + writes them with replace_layout keeping the hardpoint rows, and stores the grid's placement. The only writer of + aetheria.grid_origin.""" + root = _ship_root(collection) + if root is None: + raise ValueError(f"{collection.name} has no Ship Root; run New Ship first") + source = _child(collection, SOURCE) + meshes = _render_meshes(collection) or (_meshes(source.all_objects) if source else []) + if not meshes: + raise ValueError(f"{collection.name} has no render or Source mesh to rasterise") + depsgraph = context.evaluated_depsgraph_get() + to_root = root.matrix_world.inverted() + triangles = [] + for obj in meshes: + points, indices = _world_mesh(obj, depsgraph, to_root @ obj.matrix_world) + triangles += points[indices][:, :, :2].tolist() + width, height, cells, origin = hull_grid.rasterise(triangles) + cultlib = _brokkr_cultlib(context) + _, hardpoints, revision = read_layout(path, cultlib) + replace_layout(path, cultlib, collection["aetheria.id"], revision, [width, height, cells], + [encode_hardpoint(**decode_hardpoint(row)) for row in hardpoints]) + collection["aetheria.grid_origin"] = [origin[0], origin[1]] + _load_layout(context, collection, path) + return width, height + + +class AETHERIA_OT_rasterise_hull(bpy.types.Operator): + bl_idname = "aetheria.rasterise_hull" + bl_label = "Rasterise Hull" + bl_description = "Propose the hull's cells from its top-down silhouette; you edit them afterwards" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + width, height = _rasterise(context, collection, path) + self.report({"INFO"}, f"Rasterised a {width}x{height} hull") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + +class AETHERIA_OT_flip_nose(bpy.types.Operator): + bl_idname = "aetheria.flip_nose" + bl_label = "Flip Nose" + bl_description = "Turn the hull 180 degrees about Ship Root's Z axis, then rasterise it again" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + try: + collection, path = _layout_path(context) + root = _ship_root(collection) + if root is None: + raise ValueError(f"{collection.name} has no Ship Root; run New Ship first") + turn = root.matrix_world @ Matrix.Rotation(math.pi, 4, "Z") @ root.matrix_world.inverted() + for child in root.children: + child.matrix_world = turn @ child.matrix_world + _rasterise(context, collection, path) + self.report({"INFO"}, "Turned the nose about Ship Root") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + +def _new_ship(context, ship_id, name, reference, length): + """Creates the ship .cc from a shipped hull, binds the active collection, moves its meshes into Source under a Ship + Root, turns them nose to -Y, sizes them to length cells and rasterises.""" + if not ship_id or ship_id != ship_id.strip() or any(c in ship_id for c in "/\\:") or not ship_id.strip("."): + raise ValueError("The ship ID must be a single name without slashes, colons or surrounding spaces") + if not name.strip() or not reference.strip(): + raise ValueError("Give the ship a display name and a reference hull") + collection = _choose_collection(context) + meshes = _meshes(collection.objects) + if not meshes: + raise ValueError(f"{collection.name} holds no mesh objects to make a ship from") + if any(obj.data.users > 1 for obj in meshes): + raise ValueError("Make the hull meshes single-user before New Ship (Object > Relations > Make Single User)") + worlds = {obj: obj.matrix_world.copy() for obj in meshes} + depsgraph = context.evaluated_depsgraph_get() + points = numpy.vstack([_world_mesh(obj, depsgraph, worlds[obj])[0] for obj in meshes]) + extent_x, extent_y = (float(v) for v in numpy.ptp(points, axis=0)[:2]) + longest = max(extent_x, extent_y) + if longest <= 0: + raise ValueError("The hull meshes have no horizontal extent") + repo = _aetheria_repo(context, bpy.data.filepath or ".") + path = str(repo / "GameData" / "Mods" / ship_id / "ship.cc") + code, message = _aetherdb(context, path, "create", path, ship_id, name, "--like", reference) + if code != 0: + raise RuntimeError(f"create failed: {message}") + collection = _bind_collection(context, path) + scale = length * hull_grid.CELL_SIZE / longest + transform = Matrix.Diagonal((scale, scale, scale, 1.0)) + if extent_x > extent_y: + transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z") + source = _child_or_new(collection, SOURCE, SOURCE) + root = bpy.data.objects.new(SHIP_ROOT, None) + root["aetheria.ship_root"] = True + collection.objects.link(root) + for obj in meshes: + placed = transform @ worlds[obj] + collection.objects.unlink(obj) + source.objects.link(obj) + obj.data.transform(placed.to_3x3().to_4x4()) + if placed.determinant() < 0: + obj.data.flip_normals() + obj.parent = root + obj.matrix_parent_inverse = Matrix.Identity(4) + obj.rotation_mode = "XYZ" + obj.rotation_euler = (0.0, 0.0, 0.0) + obj.scale = (1.0, 1.0, 1.0) + obj.location = placed.to_translation() + context.scene.aetheria_ship_cc_path = path + _rasterise(context, collection, path) + return collection + + +class AETHERIA_OT_new_ship(bpy.types.Operator): + bl_idname = "aetheria.new_ship" + bl_label = "New Ship" + bl_description = ("Create the ship .cc beside the Aetheria repo's mods, bind the active collection, and set its " + "meshes up as Source: nose toward -Y, sized to the length, with a drafted grid") + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + scene = context.scene + try: + collection = _new_ship(context, scene.aetheria_new_ship_id, scene.aetheria_new_ship_name, + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length) + self.report({"INFO"}, f"New ship {collection['aetheria.id']} bound to {collection.name}") + return {"FINISHED"} + except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + + class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" @@ -411,6 +669,12 @@ class AETHERIA_PT_ship(bpy.types.Panel): def draw(self, context): layout = self.layout + new = layout.box() + new.label(text="New Ship") + for prop in ("aetheria_new_ship_id", "aetheria_new_ship_name", "aetheria_new_ship_like", + "aetheria_new_ship_length"): + new.prop(context.scene, prop) + new.operator("aetheria.new_ship", icon="ADD") layout.prop(context.scene, "aetheria_ship_cc_path") layout.operator("aetheria.bind_ship_collection", icon="LINKED") layout.prop(context.scene, "aetheria_capture_evaluated_lines") @@ -424,7 +688,10 @@ class AETHERIA_PT_ship(bpy.types.Panel): layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") layout.separator() - layout.operator("aetheria.load_ship_layout", icon="IMPORT") + row = layout.row(align=True) + row.operator("aetheria.load_ship_layout", icon="IMPORT") + row.operator("aetheria.flip_nose", icon="LOOP_BACK") + row.operator("aetheria.rasterise_hull", icon="MESH_GRID") state = context.scene.aetheria_layout if not state.ship_id: self._draw_package(context, layout) @@ -475,7 +742,8 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout, AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, - AETHERIA_OT_capture_ship_lines, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) + AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose, + AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) def register(): @@ -485,6 +753,14 @@ def register(): bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty( name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record" ) + bpy.types.Scene.aetheria_new_ship_id = bpy.props.StringProperty( + name="ID", description="The new ship's ID; also the folder under GameData/Mods") + bpy.types.Scene.aetheria_new_ship_name = bpy.props.StringProperty(name="Name", description="Display name") + bpy.types.Scene.aetheria_new_ship_like = bpy.props.StringProperty( + name="Like", default="Djinni", description="Shipped hull whose stats the new ship starts from") + bpy.types.Scene.aetheria_new_ship_length = bpy.props.IntProperty( + name="Length (cells)", default=10, min=1, max=hull_grid.MAX_CELLS, + description="Cells from stern to nose; the hull's long axis becomes this many 2 m cells") bpy.types.Scene.aetheria_package_report = bpy.props.StringProperty( name="Package report", description="AetherDb's verdict on the last Package Ship") bpy.types.Scene.aetheria_capture_evaluated_lines = bpy.props.BoolProperty( @@ -498,5 +774,9 @@ def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) del bpy.types.Scene.aetheria_capture_evaluated_lines + del bpy.types.Scene.aetheria_new_ship_length + del bpy.types.Scene.aetheria_new_ship_like + del bpy.types.Scene.aetheria_new_ship_name + del bpy.types.Scene.aetheria_new_ship_id del bpy.types.Scene.aetheria_package_report del bpy.types.Scene.aetheria_ship_cc_path diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py new file mode 100644 index 000000000..b745124e6 --- /dev/null +++ b/tools/blender/tests/smoke_frame.py @@ -0,0 +1,203 @@ +"""Headless Blender smoke for New Ship, Rasterise, Flip Nose, the Grid object and the export exclusions. + + blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py + +Environment as smoke_package.py's header says: CULTLIB_PACKAGES, plus CULTLIB_ROOT and CULTMATH_ROOT at the pinned +revisions for the 'dotnet run --project tools/AetherDb' calls, and msgpack importable. New Ship writes the ship under +/GameData/Mods/smoke.frame, which this smoke removes when it ends. + +Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni. +Pass 2 adds a render mesh (a copy of the sphere) and a nub at the +Y end, rasterises, flips the nose, and requires the +nub's cells to move from the aft rows to the forward rows. Pass 3 tags anchors, packages, and requires the GLB to hold +neither the Source nor the Grid and the validator to accept it. +""" + +import json +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") +SHIP_ID = "smoke.frame" +MODS = REPO / "GameData" / "Mods" + + +def new_object(collection, name, mesh, location=(0, 0, 0), parent=None): + obj = bpy.data.objects.new(name, mesh) + obj.location = location + obj.parent = parent + collection.objects.link(obj) + return obj + + +def box(name, half_x, half_y, half_z): + mesh = bpy.data.meshes.new(name) + corners = [(sx * half_x, sy * half_y, sz * half_z) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)] + mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]) + return mesh + + +def tetrahedron(name): + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)]) + return mesh + + +def tag(obj, role, anchor_id): + obj["aetheria.role"] = role + obj["aetheria.id"] = anchor_id + return obj + + +def run(operator): + try: + result = operator() + except RuntimeError as exc: + result = {"CANCELLED"} + print(f"OPERATOR error: {exc}") + return result + + +def require(condition, message): + if not condition: + raise SystemExit(message) + + +def layout(path, aetheria_ships): + shape, hardpoints, _ = aetheria_ships.ship_cc.read_layout(path, PACKAGES) + width, height, cells = shape + return width, height, {(i // height, i % height) for i, cell in enumerate(cells) if cell} + + +def row_count(cells, y): + return sum(1 for _, cy in cells if cy == y) + + +def glb_node_names(path): + data = Path(path).read_bytes() + length, kind = struct.unpack_from(" forward, "The nub's cells are not on the aft rows before the flip") + require(run(bpy.ops.aetheria.flip_nose) == {"FINISHED"}, "Flip Nose was refused") + _, _, after = layout(path, aetheria_ships) + require(row_count(after, height - 1) == aft and row_count(after, 0) == forward, + "Flip Nose did not move the nub's cells from the aft rows to the forward rows") + require({(width - 1 - x, height - 1 - y) for x, y in before} == after, "The flipped grid is not the turned grid") + print("PASS 2 rasterise and flip nose ok") + + # Pass 3: Package leaves Source and Grid out of the GLB. + tag(new_object(collection, "Map Icon", box("Map Icon", 0.5, 0.5, 0.01), (0, 0, -1), root), "map-icon", "map") + tag(new_object(collection, "Hull Collider", tetrahedron("Hull Collider"), parent=root), "hull-collider", "collider") + tag(new_object(collection, "Shield", None, parent=root), "shield", "shield") + tag(new_object(collection, "Tractor", None, (0, 2, 0), root), "tractor", "tractor") + bpy.context.view_layer.objects.active = render + result = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + print(f"PACKAGE {sorted(result)}: {report}") + require(result == {"FINISHED"}, "Package was refused") + names = glb_node_names(Path(path).with_name("ship.glb")) + print(f"GLB nodes: {sorted(n for n in names if n)}") + require("Hull" in names and "Nub" in names, "The GLB lacks the render meshes") + require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid") + print("PASS 3 export ok") + print("SMOKE_FRAME_OK") + + +created_mods = not MODS.exists() +existed = (MODS / SHIP_ID).exists() +try: + main() +except SystemExit as stop: + print(f"SMOKE_FRAME_FAILED: {stop}", flush=True) + code = 1 +except Exception: + traceback.print_exc() + print("SMOKE_FRAME_FAILED", flush=True) + code = 1 +else: + code = 0 +finally: + if not existed: + shutil.rmtree(MODS / SHIP_ID, ignore_errors=True) + if created_mods and not existed: + shutil.rmtree(MODS, ignore_errors=True) +sys.stdout.flush() +os._exit(code) From a9ce0bbc424e2c06ad84ebac0eec4b12562a903c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:43:15 +0200 Subject: [PATCH 03/18] test_hull_grid: load hull_grid under its repo-rooted dotted name so mutmut can attribute kills Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_grid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index ac8ef248f..537adc031 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -10,8 +10,9 @@ import importlib.util import unittest from pathlib import Path +# The dotted name is the module's path from the repo root, which mutmut keys its mutants by. _spec = importlib.util.spec_from_file_location( - "hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py") + "tools.blender.aetheria_ships.hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py") hull_grid = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(hull_grid) From 07dcb580c36e732a39e970a8499fbee8943eb9c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:48:32 +0200 Subject: [PATCH 04/18] test_hull_grid: pin point-in-triangle sides, sample positions, bound samples, single triangles and messages Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/test_hull_grid.py | 58 +++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index 537adc031..d9794e4ec 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -21,6 +21,9 @@ def rect(x0, y0, x1, y1): return [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))] +CORNER = [((1.99, 0.98), (2.0, 0.98), (2.0, 1.0))] # holds the bounding box at x 2 without covering a sample + + def occupied(cells, height): return {(i // height, i % height) for i, cell in enumerate(cells) if cell} @@ -53,6 +56,27 @@ class CellMappingTests(unittest.TestCase): self.assertEqual(near(-1.0, -1.0), (1, 2)) +class InsideTests(unittest.TestCase): + TRIANGLE = ((0, 0), (1, 0), (0, 1)) + + def test_inside_points_are_inside_for_both_windings(self): + for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]): + for point in ((0.25, 0.25), (0.5, 0.25), (0.1, 0.8)): + self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point)) + + def test_points_across_each_edge_are_outside_for_both_windings(self): + outside = ((0.5, -0.25), (0.75, 0.75), (-0.25, 0.5), (-0.5, -0.5), (1.5, 0.0), (0.0, 1.5), (2.0, 2.0)) + for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]): + for point in outside: + self.assertFalse(hull_grid._inside(triangle, *point), (triangle, point)) + + def test_edges_and_corners_are_inside_for_both_windings(self): + on_edge = ((0.5, 0.0), (0.0, 0.5), (0.5, 0.5), (0.0, 0.0), (1.0, 0.0), (0.0, 1.0)) + for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]): + for point in on_edge: + self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point)) + + class RasteriseTests(unittest.TestCase): def test_eight_of_sixteen_samples_occupy_a_cell_and_seven_do_not(self): wing = rect(-2, -1, 0, 1) # cell (1, 0) in full; fixes the grid at 2 x 1 cells about the origin @@ -63,6 +87,23 @@ class RasteriseTests(unittest.TestCase): self.assertEqual((width, height, origin), (2, 1, (1.0, 0.0))) self.assertEqual(cells, [expected, True]) + def test_samples_sit_at_quarter_points_of_the_cell_on_each_axis(self): + wing = rect(-2, -1, 0, 1) + # Cell (0, 0) spans x 0..2, y -1..1; its sample rows are y = 0.75, 0.25, -0.25, -0.75 and columns x = 1.75 .. 0.25. + rows = hull_grid.rasterise(wing + rect(0, -0.3, 2, 0.3))[2] # y = 0.25 and -0.25: 8 samples + columns = hull_grid.rasterise(wing + rect(0.3, -1, 1.0, 1) + CORNER)[2] # x = 0.75 only: 4 samples + self.assertEqual((rows, columns), ([True, True], [False, True])) + + def test_a_sample_on_a_triangle_bound_counts(self): + wing = rect(-2, -1, 0, 1) + self.assertEqual(hull_grid.rasterise(wing + rect(0, 0.25, 2, 0.75))[2], [True, True]) # rows 0.75 and 0.25 + self.assertEqual(hull_grid.rasterise(wing + rect(0.25, -1, 0.75, 1) + CORNER)[2], [True, True]) # columns 0.75 and 0.25 + + def test_a_single_triangle_covers_only_its_own_half_of_its_bounding_box(self): + width, height, cells, _ = hull_grid.rasterise([((-2, -2), (2, -2), (-2, 2))]) + self.assertEqual((width, height), (2, 2)) + self.assertEqual(cells, [False, True, True, True]) + def test_a_sample_inside_two_triangles_counts_once(self): seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0) _, _, cells, _ = hull_grid.rasterise(rect(-2, -1, 0, 1) + seven + seven) @@ -108,20 +149,21 @@ class RasteriseTests(unittest.TestCase): self.assertEqual((width, height, len(cells), all(cells)), (1, 32, 32, True)) with self.assertRaises(ValueError) as caught: hull_grid.rasterise(rect(0, 0, 2, 66)) - self.assertIn("33", str(caught.exception)) + self.assertEqual(str(caught.exception), "The hull needs 33 cells long; a ship grid holds at most 32") with self.assertRaises(ValueError) as caught: hull_grid.rasterise(rect(0, 0, 66, 2)) - self.assertIn("33", str(caught.exception)) + self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32") def test_a_silhouette_with_no_area_is_refused(self): - with self.assertRaises(ValueError): - hull_grid.rasterise([]) - with self.assertRaises(ValueError): - hull_grid.rasterise([((0, 0), (1, 1), (2, 2))]) + for triangles in ([], [((0, 0), (1, 1), (2, 2))]): + with self.assertRaises(ValueError) as caught: + hull_grid.rasterise(triangles) + self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise") def test_a_degenerate_triangle_does_not_widen_the_grid(self): - _, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [((50, 50), (60, 60), (70, 70))]) - self.assertEqual(cells, [True]) + for line in (((50, 50), (60, 60), (70, 70)), ((51, 50), (53, 51), (55, 52)), ((50, 51), (51, 53), (52, 55))): + _, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [line]) + self.assertEqual(cells, [True], line) if __name__ == "__main__": From 63d16e81f3600d1423e5473d105a45d29c2cff34 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:53:03 +0200 Subject: [PATCH 05/18] AetherDb for ship authors: rooted at a game folder, found in ModTools; ship-ID rule in create FindRoot keys on GameData/Aetheria.cc. ShipAuthoringStore.RequireShipId is the one ID rule (Validate and create), refusing Windows device names and a trailing dot, naming no value. The add-on's one preference is Game folder; _aetheria_repo, the repo preference and the dotnet-run invocation are deleted. Smokes take SMOKE_GAME_FOLDER. Not yet built or tested at this commit. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Assets/Scripts/ServerShared/ShipAuthoring.cs | 20 ++++-- docs/moddable-ship-authoring.md | 24 ++++++- .../AetherDbRootTests.cs | 41 +++++++++++ .../ShipModCatalogTests.cs | 11 +++ .../ShipValidationTests.cs | 25 ++++++- tools/AetherDb/AetherDb.cs | 6 +- tools/AetherDb/ShipAuthoringCommands.cs | 1 + tools/blender/aetheria_ships/__init__.py | 69 +++++++++++-------- tools/blender/tests/smoke_frame.py | 11 +-- tools/blender/tests/smoke_package.py | 22 ++++-- 11 files changed, 181 insertions(+), 50 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/AetherDbRootTests.cs diff --git a/.gitignore b/.gitignore index 2cbd223f9..811814afa 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ GameData/*.lock /Aetheria.Shared/obj/ /tools/**/bin/ /tools/**/obj/ +/ModTools/ /tests/**/bin/ /tests/**/obj/ diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index ec5f2a3c2..c3cb1032e 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -182,14 +182,26 @@ public static class ShipAuthoringStore throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record."); } + // The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so + // Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value. + public static void RequireShipId(string id) + { + if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Ship ID is required."); + if (!(id[0] >= 'a' && id[0] <= 'z' || id[0] >= '0' && id[0] <= '9') || id.Any(c => + !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'))) + throw new InvalidOperationException("Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); + if (id[id.Length - 1] == '.') throw new InvalidOperationException("Ship ID must not end with a dot."); + var stem = id.Split('.')[0]; + if (stem is "con" or "prn" or "aux" or "nul" + || stem.Length == 4 && (stem.StartsWith("com") || stem.StartsWith("lpt")) && stem[3] >= '1' && stem[3] <= '9') + throw new InvalidOperationException("Ship ID must not be a Windows device name."); + } + // The one semantic check every path shares: the hull and its visual, judged together. public static void Validate(HullData hull, ShipAuthoring ship) { if (ship == null) throw new InvalidOperationException("Ship authoring record is null."); - if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required."); - if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c => - !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-'))) - throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens."); + RequireShipId(ship.Id); if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required."); if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required."); if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast().Any(occupied => occupied)) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 650286d75..02c4b64a8 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -61,12 +61,30 @@ weapon mount object. Thrusters and radiators are mesh objects; the others may be empties. Package exports the collection, Grease Pencil excluded, to `ship.glb` beside the `.cc`, writes the anchors, captures the lines when the collection holds exactly one Grease Pencil object, and shows AetherDb's -verdict in the panel. It runs `dotnet run --project /tools/AetherDb`; -the repo is the add-on preference **Aetheria repo**, or by default the nearest -folder above the `.cc` holding `tools/AetherDb`. +verdict in the panel. It runs the AetherDb in the game folder's `ModTools` +(see Tools below). `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring, and judges `ship.glb` once it exists. +## Tools + +Install Blender and the `aetheria_ships` add-on. In the add-on's preferences set +**Game folder** to your Aetheria install, the folder holding `GameData` (its +`GameData/Aetheria.cc` and `GameData/Mods`). Left empty, the add-on uses the +nearest folder above the bound `.cc` whose `GameData` holds `Aetheria.cc`. +AetherDb ships with the game in `ModTools`; the add-on runs it with the game +folder as its working directory, and needs neither Unity, the Aetheria repository +nor dotnet. New Ship creates the package under `/GameData/Mods/`. + +From a repository checkout, publish AetherDb into the ignored `/ModTools` and +author from the repo as the game folder: + + dotnet publish tools/AetherDb -c Release -r --self-contained -p:PublishSingleFile=true -o ModTools + +with `` one of `win-x64`, `linux-x64`, `osx-arm64`. Linux needs `libssl3` +and `libicu`; on macOS clear the quarantine flag (`xattr -d com.apple.quarantine +ModTools/AetherDb`). + ## Objective A mod author edits one ship `.cc` file, including the hull schematic and every diff --git a/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs new file mode 100644 index 000000000..f417d4e10 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using Xunit; + +[CollectionDefinition("process working directory", DisableParallelization = true)] +public sealed class ProcessWorkingDirectoryCollection { } + +// FindRoot reads the process working directory, so these run alone. +[Collection("process working directory")] +public sealed class AetherDbRootTests : IDisposable +{ + private readonly TempDirectory _game = new TempDirectory(); + private readonly string _previous = Environment.CurrentDirectory; + + public void Dispose() + { + Environment.CurrentDirectory = _previous; + _game.Dispose(); + } + + [Fact] + public void TheRootIsTheNearestFolderWhoseGameDataHoldsAetheriaCc() + { + Directory.CreateDirectory(Path.Combine(_game.Path, "GameData", "Mods", "probe.a")); + File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty()); + Environment.CurrentDirectory = _game.Path; + var game = Environment.CurrentDirectory; + Environment.CurrentDirectory = Path.Combine(game, "GameData", "Mods", "probe.a"); + Assert.Equal(game, AetherDb.FindRoot()); + } + + [Fact] + public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot() + { + Directory.CreateDirectory(Path.Combine(_game.Path, "Aetheria.Shared")); + File.WriteAllText(Path.Combine(_game.Path, "Aetheria.Shared", "Aetheria.Shared.csproj"), ""); + Environment.CurrentDirectory = _game.Path; + var error = Assert.Throws(AetherDb.FindRoot); + Assert.Contains("GameData holds Aetheria.cc", error.Message); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index a2426906b..83f9b9ad1 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -419,6 +419,17 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Contains("records and model", output); } + [Theory] + [InlineData("con"), InlineData("nul.x"), InlineData("abc.")] + public void CreateRefusesABadIdBeforeTouchingTheDisk(string id) + { + var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc"); + var (code, output, error) = Run("create", target, id, "Probe", "--like", "Djinni"); + Assert.Equal(1, code); + Assert.Contains("Ship ID", error); + Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData"))); + } + private static (int Code, string Output, string Error) Run(params string[] args) { var (output, error) = (new StringWriter(), new StringWriter()); diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 804ff723f..8c195e895 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -59,7 +59,25 @@ public sealed class ShipValidationTests { var ship = ShipAuthoringTests.Fixture(); ship.WithId(id); - Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).Message); + Assert.Contains("Ship ID must use", Assert.Throws(ship.Validate).Message); + } + + [Theory] + [InlineData("com0"), InlineData("console"), InlineData("lpt"), InlineData("auxiliary"), InlineData("a.con"), InlineData("com10")] + public void IdsThatOnlyResembleDeviceNamesAreAdmitted(string id) + { + var ship = ShipAuthoringTests.Fixture(); + ship.WithId(id); + ship.Validate(); + } + + [Theory] + [InlineData("con"), InlineData("abc.")] + public void IdRefusalsNameNoValue(string id) + { + var ship = ShipAuthoringTests.Fixture(); + ship.WithId(id); + Assert.DoesNotContain(id, Assert.Throws(ship.Validate).Message); } [Theory] @@ -157,6 +175,11 @@ public sealed class ShipValidationTests Add("id-null", s => s.Visual.Id = null, "Ship ID is required"); Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required"); + Add("id-device-con", s => s.Visual.Id = "con", "Windows device name"); + Add("id-device-with-extension", s => s.Visual.Id = "nul.x", "Windows device name"); + Add("id-device-com1", s => s.Visual.Id = "com1", "Windows device name"); + Add("id-device-lpt9", s => s.Visual.Id = "lpt9", "Windows device name"); + Add("id-trailing-dot", s => s.Visual.Id = "abc.", "must not end with a dot"); Add("hull-null", s => s.Hull = null, "hull data is required"); Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required"); diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 4a17e6b14..d5b4ffdb3 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -19,15 +19,15 @@ public sealed class AetherDb catalogWritable: catalogWritable); } - // With no root given, walks up from the working directory to the repository + // With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) => new AetherDb(root ?? FindRoot(), catalogWritable, withRun); public static string FindRoot() { for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir != null; dir = dir.Parent) - if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj"))) + if (File.Exists(Path.Combine(dir.FullName, "GameData", "Aetheria.cc"))) return dir.FullName; - throw new DirectoryNotFoundException("Run from inside the Aetheria repository."); + throw new DirectoryNotFoundException("Run from a folder whose GameData holds Aetheria.cc."); } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index dc5dcfb8b..097fe1add 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -46,6 +46,7 @@ public static class ShipAuthoringCommands } if (args[0] == "create" && (args.Length == 4 || args.Length == 6 && args[4] == "--like")) { + ShipAuthoringStore.RequireShipId(args[2]); var path = Path.GetFullPath(args[1]); if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}"); Directory.CreateDirectory(Path.GetDirectoryName(path)); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17b81a3a0..df02126a3 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -12,6 +12,7 @@ bl_info = { import math import subprocess +import sys from pathlib import Path import bpy @@ -26,7 +27,7 @@ MODEL_ASSET = "ship.glb" SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported GENERATED = "Generated" # child collection: derived objects (the Grid), never exported SHIP_ROOT = "Ship Root" -AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb +AETHERDB_TIMEOUT = 120 # seconds HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES)) @@ -302,38 +303,49 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): class AETHERIA_AP_preferences(bpy.types.AddonPreferences): bl_idname = __package__ - aetheria_repo: bpy.props.StringProperty( - name="Aetheria repo", subtype="DIR_PATH", - description="Aetheria checkout whose tools/AetherDb validates packages. " - "Empty: the nearest folder above the bound .cc that holds tools/AetherDb") + game_folder: bpy.props.StringProperty( + name="Game folder", subtype="DIR_PATH", + description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds " + "AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc") def draw(self, context): - self.layout.prop(self, "aetheria_repo") + self.layout.prop(self, "game_folder") -def _aetheria_repo(context, ship_cc): +GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc " + "and whose ModTools holds AetherDb") + + +def _game_folder(context, ship_cc=None): + """The folder AetherDb runs against: the Game folder preference, else the nearest folder above the bound .cc whose + GameData holds Aetheria.cc. Refusals name the preference and never a path.""" addon = context.preferences.addons.get(__package__) - configured = addon.preferences.aetheria_repo if addon and addon.preferences else "" + configured = addon.preferences.game_folder if addon and addon.preferences else "" if configured: - repo = Path(bpy.path.abspath(configured)) - if not (repo / "tools" / "AetherDb").is_dir(): - raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb") - return repo - for folder in Path(ship_cc).resolve().parents: - if (folder / "tools" / "AetherDb").is_dir(): - return folder - raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences") + folder = Path(bpy.path.abspath(configured)) + if not (folder / "GameData" / "Aetheria.cc").is_file(): + raise ValueError(GAME_FOLDER_REFUSAL) + return folder + if ship_cc: + for folder in Path(ship_cc).resolve().parents: + if (folder / "GameData" / "Aetheria.cc").is_file(): + return folder + raise ValueError(GAME_FOLDER_REFUSAL) -def _aetherdb(context, ship_cc, *args): - """Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler - warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept.""" - repo = _aetheria_repo(context, ship_cc) - result = subprocess.run( - ["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args], - cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT) - lines = (result.stdout + "\n" + result.stderr).splitlines() - return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line) +def _aetherdb(context, *args, ship_cc=None): + """Runs one AetherDb ship-authoring command from the game folder's ModTools; returns its exit code and its output, + stdout then stderr. ship_cc is the bound .cc the game folder is found from when the preference is empty.""" + folder = _game_folder(context, ship_cc) + tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb") + if not tool.is_file(): + raise ValueError(GAME_FOLDER_REFUSAL) + result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True, + timeout=AETHERDB_TIMEOUT) + lines = (result.stdout + " +" + result.stderr).splitlines() + return result.returncode, " +".join(line for line in lines if line.strip()) def _layer_collection(layer, collection): @@ -573,9 +585,8 @@ def _new_ship(context, ship_id, name, reference, length): longest = max(extent_x, extent_y) if longest <= 0: raise ValueError("The hull meshes have no horizontal extent") - repo = _aetheria_repo(context, bpy.data.filepath or ".") - path = str(repo / "GameData" / "Mods" / ship_id / "ship.cc") - code, message = _aetherdb(context, path, "create", path, ship_id, name, "--like", reference) + path = str(_game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc") + code, message = _aetherdb(context, "create", path, ship_id, name, "--like", reference, ship_cc=path) if code != 0: raise RuntimeError(f"create failed: {message}") collection = _bind_collection(context, path) @@ -647,7 +658,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): replace_lines(path, cultlib, capture_grease_pencil( pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines)) - code, message = _aetherdb(context, path, "validate", path) + code, message = _aetherdb(context, "validate", path, ship_cc=path) except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc: scene.aetheria_package_report = f"Package failed: {exc}" self.report({"ERROR"}, scene.aetheria_package_report) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index b745124e6..739001bdc 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -2,9 +2,9 @@ blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py -Environment as smoke_package.py's header says: CULTLIB_PACKAGES, plus CULTLIB_ROOT and CULTMATH_ROOT at the pinned -revisions for the 'dotnet run --project tools/AetherDb' calls, and msgpack importable. New Ship writes the ship under -/GameData/Mods/smoke.frame, which this smoke removes when it ends. +Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its +ModTools, no dotnet), and msgpack importable. New Ship writes the ship under /GameData/Mods/smoke.frame, +which this smoke removes when it ends. Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni. Pass 2 adds a render mesh (a copy of the sphere) and a nub at the +Y end, rasterises, flips the nose, and requires the @@ -27,7 +27,8 @@ 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") SHIP_ID = "smoke.frame" -MODS = REPO / "GameData" / "Mods" +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")) +MODS = GAME / "GameData" / "Mods" def new_object(collection, name, mesh, location=(0, 0, 0), parent=None): @@ -95,7 +96,7 @@ def main(): addon_utils.enable("aetheria_ships", default_set=True, handle_error=None) import aetheria_ships aetheria_ships._brokkr_cultlib = lambda context: PACKAGES - bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO) + bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME) require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first") scene = bpy.context.scene diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index de5958ad1..7fa3f90dc 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -2,14 +2,16 @@ blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py -Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs -(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'. +Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py) and SMOKE_GAME_FOLDER, a folder holding +GameData/Aetheria.cc and the published AetherDb under ModTools (AetherDb.exe on Windows), which the smoke sets as the +add-on's Game folder. No dotnet is needed: the add-on runs the published tool and nothing else. SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=. Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts -the disc back and packages again, so the package left behind is the good one. +the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder +that holds Aetheria.cc but no ModTools/AetherDb and requires Package to report the fixed preference text and no path. Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the @@ -27,6 +29,7 @@ import addon_utils import bpy REPO = Path(__file__).resolve().parents[3] +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")) PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory") SHIP_ID = "smoke.gale" @@ -130,13 +133,13 @@ def main(): import aetheria_ships # Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender. aetheria_ships._brokkr_cultlib = lambda context: PACKAGES - bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO) + bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME) mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-")) path = str(mods / SHIP_ID / "ship.cc") if Path(path).exists(): raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory") - code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni") + code, message = aetheria_ships._aetherdb(bpy.context, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni") print(f"CREATE {code}: {message}") if code != 0: raise SystemExit("create --like Djinni failed") @@ -184,6 +187,15 @@ def main(): result, report = package(aetheria_ships) if result != {"FINISHED"}: raise SystemExit("Pass 3: the restored ship was refused") + + bare = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bare-")) + (bare / "GameData").mkdir() + (bare / "GameData" / "Aetheria.cc").write_bytes(b"") + bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare) + result, report = package(aetheria_ships) + if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL or "Game folder" not in report or str(bare) in report or str(GAME) in report: + raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text") + bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME) print(f"SMOKE_PACKAGE={path}") print("SMOKE_PACKAGE_OK") From 6971ff70a779d86bdf7296752e379c0508f0979f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:11:55 +0200 Subject: [PATCH 06/18] ShipValidationTests: every Windows device name, alone and before a dot Builds; the id-refusal cases pin prn and aux, which the first set left unchecked. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipValidationTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 8c195e895..7081c58f9 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -175,10 +175,8 @@ public sealed class ShipValidationTests Add("id-null", s => s.Visual.Id = null, "Ship ID is required"); Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required"); - Add("id-device-con", s => s.Visual.Id = "con", "Windows device name"); - Add("id-device-with-extension", s => s.Visual.Id = "nul.x", "Windows device name"); - Add("id-device-com1", s => s.Visual.Id = "com1", "Windows device name"); - Add("id-device-lpt9", s => s.Visual.Id = "lpt9", "Windows device name"); + foreach (var device in new[] { "con", "prn", "aux", "nul", "nul.x", "com1", "com9", "lpt1", "lpt9", "con.txt" }) + Add("id-device-" + device, s => s.Visual.Id = device, "Windows device name"); Add("id-trailing-dot", s => s.Visual.Id = "abc.", "must not end with a dot"); Add("hull-null", s => s.Hull = null, "hull data is required"); Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); From c98ad8f56a501b64b4ba566bbb385e65b055653b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:25:05 +0200 Subject: [PATCH 07/18] Add-on: _aetherdb joins its output lines with a newline escape, not a raw newline The previous commit's _aetherdb did not parse (unterminated string literal), so 63d16e81 and 6971ff70 cannot import the add-on; this one can. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index df02126a3..6b5820724 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -342,10 +342,8 @@ def _aetherdb(context, *args, ship_cc=None): raise ValueError(GAME_FOLDER_REFUSAL) result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT) - lines = (result.stdout + " -" + result.stderr).splitlines() - return result.returncode, " -".join(line for line in lines if line.strip()) + lines = (result.stdout + "\n" + result.stderr).splitlines() + return result.returncode, "\n".join(line for line in lines if line.strip()) def _layer_collection(layer, collection): From fb030ca9c3fdc8b7c4d128bd3c56ca4af207fa91 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:27:04 +0200 Subject: [PATCH 08/18] smoke_package: a folder holding only a ModTools tool is not a game folder Pass 4 gains the case that kills the mutant dropping the Aetheria.cc check on the Game folder preference. Builds. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_package.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index 7fa3f90dc..bf0ff4884 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -11,7 +11,8 @@ Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE= Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder -that holds Aetheria.cc but no ModTools/AetherDb and requires Package to report the fixed preference text and no path. +that holds Aetheria.cc but no ModTools/AetherDb, and at one that holds a ModTools tool but no Aetheria.cc, and +requires Package to report the fixed preference text and no path. Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the @@ -193,8 +194,17 @@ def main(): (bare / "GameData" / "Aetheria.cc").write_bytes(b"") bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare) result, report = package(aetheria_ships) - if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL or "Game folder" not in report or str(bare) in report or str(GAME) in report: + if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL \ + or "Game folder" not in report or str(bare) in report or str(GAME) in report: raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text") + # A tool under ModTools does not make a folder a game folder: GameData/Aetheria.cc is the marker. + unmarked = Path(tempfile.mkdtemp(prefix="aetheria-smoke-unmarked-")) + (unmarked / "ModTools").mkdir() + (unmarked / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")).write_bytes(b"") + bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(unmarked) + result, report = package(aetheria_ships) + if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL: + raise SystemExit("Pass 4: a folder without GameData/Aetheria.cc was not refused with the fixed text") bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME) print(f"SMOKE_PACKAGE={path}") print("SMOKE_PACKAGE_OK") From 0b5cad890370958f0893b5eeb490d856295d4769 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:49:39 +0200 Subject: [PATCH 09/18] ship_cc: layout_revision and valid_ship_id as pure functions, fixed-text errors; pins and tests for the authoring-tool Low findings layout_revision(shape, hardpoints) is the revision rule replace_layout and Package share. SHIP_ID_CHARS and RESERVED_SHIP_IDS mirror ShipAuthoringStore.RequireShipId; ShipSchemaPinTests sweeps every character and every candidate stem against the C# rule. ship_cc errors no longer carry the path, mount id, anchor id or object name. C# tests: create judges the ID not the display name, FindRoot answers the working directory itself, short trailing-dot IDs. Builds: yes (C# test edits and Python only). Co-Authored-By: Claude Opus 5.5 --- .../AetherDbRootTests.cs | 9 ++++ .../ShipModCatalogTests.cs | 12 ++++- .../ShipSchemaPinTests.cs | 33 +++++++++++++ .../ShipValidationTests.cs | 3 +- tools/blender/aetheria_ships/ship_cc.py | 46 +++++++++++++------ tools/blender/tests/test_ship_cc.py | 31 ++++++++++++- 6 files changed, 115 insertions(+), 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs index f417d4e10..6aa614182 100644 --- a/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs +++ b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs @@ -29,6 +29,15 @@ public sealed class AetherDbRootTests : IDisposable Assert.Equal(game, AetherDb.FindRoot()); } + [Fact] + public void TheRootIsTheWorkingDirectoryItselfWhenItsGameDataHoldsAetheriaCc() + { + Directory.CreateDirectory(Path.Combine(_game.Path, "GameData")); + File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty()); + Environment.CurrentDirectory = _game.Path; + Assert.Equal(Environment.CurrentDirectory, AetherDb.FindRoot()); + } + [Fact] public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot() { diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 83f9b9ad1..7e025792c 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -424,12 +424,22 @@ public sealed class ShipModCatalogTests : IDisposable public void CreateRefusesABadIdBeforeTouchingTheDisk(string id) { var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc"); - var (code, output, error) = Run("create", target, id, "Probe", "--like", "Djinni"); + // The display name is itself a valid ship ID, so a rule applied to it instead of the ID would not refuse. + var (code, output, error) = Run("create", target, id, "probe", "--like", "Djinni"); Assert.Equal(1, code); Assert.Contains("Ship ID", error); Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData"))); } + [Fact] + public void CreateJudgesTheIdAndTakesAnyDisplayName() + { + var target = Path.Combine(_directory.Path, "GameData", "Mods", "probe.a", "ship.cc"); + var (code, output, error) = Run("create", target, "probe.a", "Probe A"); + Assert.Equal(0, code); + Assert.True(File.Exists(target), error); + } + private static (int Code, string Output, string Error) Run(params string[] args) { var (output, error) = (new StringWriter(), new StringWriter()); diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index e674ed23a..9e690828a 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -67,6 +67,39 @@ public sealed class ShipSchemaPinTests Assert.Equal(slot, KeyOf(typeof(ShipAnchor), members[slot])); } + // New Ship refuses a bad ship ID before it touches anything, with ship_cc.valid_ship_id; these two constants are that + // rule's data, so the C# rule that create and Validate share must agree with them on every character and every stem. + [Fact] + public void PythonShipIdConstantsAreTheRuleRequireShipIdEnforces() + { + var allowed = Text("SHIP_ID_CHARS"); + var reserved = Strings("RESERVED_SHIP_IDS"); + for (var code = 0; code < 256; code++) + { + var c = (char)code; + Assert.Equal(allowed.Contains(c), Accepts("a" + c)); + Assert.Equal(allowed.Contains(c) && c != '.' && c != '_' && c != '-', Accepts(c + "a")); + } + var stems = new List(); + for (var a = 'a'; a <= 'z'; a++) + for (var b = 'a'; b <= 'z'; b++) + for (var c = 'a'; c <= 'z'; c++) + stems.Add($"{a}{b}{c}"); + for (var digit = 0; digit <= 9; digit++) + stems.AddRange(new[] { $"com{digit}", $"lpt{digit}" }); + foreach (var stem in stems) + { + Assert.Equal(!reserved.Contains(stem), Accepts(stem)); + Assert.Equal(!reserved.Contains(stem), Accepts(stem + ".x")); + } + } + + private static bool Accepts(string id) + { + try { ShipAuthoringStore.RequireShipId(id); return true; } + catch (InvalidOperationException) { return false; } + } + [Fact] public void PythonEnumNamesAreTheEnumsInValueOrder() { diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 7081c58f9..228c60494 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -177,7 +177,8 @@ public sealed class ShipValidationTests Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required"); foreach (var device in new[] { "con", "prn", "aux", "nul", "nul.x", "com1", "com9", "lpt1", "lpt9", "con.txt" }) Add("id-device-" + device, s => s.Visual.Id = device, "Windows device name"); - Add("id-trailing-dot", s => s.Visual.Id = "abc.", "must not end with a dot"); + foreach (var id in new[] { "abc.", "a.", "ab.", "a.b." }) + Add("id-trailing-dot-" + id, s => s.Visual.Id = id, "must not end with a dot"); Add("hull-null", s => s.Hull = null, "hull data is required"); Add("hull-name-null", s => s.Hull.Name = null, "hull name is required"); Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required"); diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 6018d6a96..df76cdb6f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -37,6 +37,21 @@ HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Rea ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise") +# ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests +# pins both constants and valid_ship_id to the C# rule. +SHIP_ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789._-" +RESERVED_SHIP_IDS = ("con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", + "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9") + + +def valid_ship_id(ship_id: str) -> bool: + """True when ShipAuthoringStore.RequireShipId would accept the ID: lower-case ASCII letters, digits, dots, + underscores and hyphens, starting with a letter or digit, not ending in a dot, and whose stem before the first dot + is not a Windows device name.""" + return (bool(ship_id) and ship_id[0] not in "._-" and all(c in SHIP_ID_CHARS for c in ship_id) + and not ship_id.endswith(".") and ship_id.split(".")[0] not in RESERVED_SHIP_IDS) + + def decode_hardpoint(row: list[Any]) -> dict[str, Any]: return dict(zip(HARDPOINT_MEMBERS, row)) @@ -80,19 +95,19 @@ def read(path: str, cultlib_packages: str) -> ShipFile: body = msgpack.unpackb(envelope.payload, raw=False) if isinstance(body, list) and len(body) > RETIRED_HULL_SLOT and body[RETIRED_HULL_SLOT] is not None: raise ValueError( - f"{path}: {envelope.key}: legacy embedded hull at retired key {RETIRED_HULL_SLOT} of the {SCHEMA} record; " + f"The ship file has a legacy embedded hull at retired key {RETIRED_HULL_SLOT} of the {SCHEMA} record; " f"migrate it into its own {HULL_SCHEMA} record that names the visual, and drop key {RETIRED_HULL_SLOT}") if len(envelopes) != 2 or len(ships) != 1 or len(hulls) != 1: - raise ValueError(f"{path} must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record") + raise ValueError(f"The ship file must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record") bodies = [] for envelope in (ships[0], hulls[0]): body = msgpack.unpackb(envelope.payload, raw=False) if not isinstance(body, list): - raise ValueError(f"{path} has an incompatible {envelope.type} payload") + raise ValueError(f"The ship file has an incompatible {envelope.type} payload") bodies.append(Record(envelope, body)) ship, hull = bodies if len(ship.body) < SCHEMATIC_LINES_SLOT + 1: - raise ValueError(f"{path} has an incompatible {SCHEMA} payload") + raise ValueError(f"The ship file has an incompatible {SCHEMA} payload") return ShipFile(store, ship, hull, msgpack) @@ -129,7 +144,7 @@ def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_ass if not isinstance(anchor_id, str) or not anchor_id.strip(): raise ValueError("Every anchor needs an ID") if anchor_id in ids: - raise ValueError(f"Anchor ID {anchor_id} is used twice") + raise ValueError("An anchor ID is used twice") ids.add(anchor_id) previous = file.ship.body[ANCHORS_SLOT] if len(file.ship.body) > ANCHORS_SLOT else None later = {row[0]: row[known:] for row in previous or [] @@ -149,13 +164,14 @@ def _hull_body(file: ShipFile) -> list: def read_layout(path: str, cultlib_packages: str): file = read(path, cultlib_packages) hull = _hull_body(file) - return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, file.msgpack) + return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) -def _layout_revision(hull, msgpack): - return hashlib.sha256(msgpack.packb( - [hull[HULL_SHAPE_SLOT], hull[HULL_HARDPOINTS_SLOT]], use_bin_type=True - )).hexdigest() +def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str: + """The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores + them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's.""" + import msgpack # type: ignore + return hashlib.sha256(msgpack.packb([[shape], hardpoints], use_bin_type=True)).hexdigest() def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str, @@ -164,7 +180,7 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ if file.ship.body[0] != expected_id: raise ValueError("The bound ship ID changed; reload its layout") hull = _hull_body(file) - if _layout_revision(hull, file.msgpack) != expected_revision: + if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision: raise ValueError("The .cc layout changed since Load; reload before saving") width, height, cells = shape if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height): @@ -184,20 +200,20 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_ raise ValueError(f"Each hardpoint needs {known} typed fields and a stable mount ID") hp_width, hp_height, hp_cells = fields["Shape"][0] if not all(type(cell) is bool for cell in hp_cells): - raise ValueError(f"Hardpoint {mount} footprint cells must be booleans") + raise ValueError("Hardpoint footprint cells must be booleans") if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and len(hp_cells) == hp_width * hp_height and any(hp_cells)): - raise ValueError(f"Hardpoint {mount} has an invalid footprint") + raise ValueError("A hardpoint has an invalid footprint") updated_hardpoints.append(hardpoint + previous.get(mount, [])) hull[HULL_SHAPE_SLOT] = [shape] hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints file.store.push(_stamped(file.hull, file.msgpack)) - return _layout_revision(hull, file.msgpack) + return layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]: if obj.type != "GREASEPENCIL": - raise ValueError(f"{obj.name} is not a Grease Pencil object") + raise ValueError("The selected object is not a Grease Pencil object") source = obj.evaluated_get(depsgraph) if evaluated else obj lines: list[list[Any]] = [] for layer in source.data.layers: diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index d0f80150a..4a9c045d8 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -311,6 +311,16 @@ class LayoutTests(ShipFileCase): self.write(ship, hull) self.assertNotEqual(revision, self.layout()[2]) + def test_layout_revision_is_the_revision_replace_layout_returns_for_the_same_shape_and_rows(self): + _, _, revision = self.layout() + cells = [True, True, True, False] + rows = [hardpoint_row("thruster.starboard", tail=())] # a mount the file carried no later slots for + saved = self.edit(revision, cells=cells, hardpoints=rows) + self.assertEqual(saved, ship_cc.layout_revision(shape(2, 2, cells), rows)) + self.assertEqual(saved, self.layout()[2]) + self.assertNotEqual(saved, ship_cc.layout_revision(shape(2, 2, [True, True, False, False]), rows)) + self.assertNotEqual(saved, ship_cc.layout_revision(shape(2, 2, cells), [])) + def test_the_revision_follows_the_hardpoints_too(self): _, _, revision = self.layout() moved = hardpoint_row(tail=()) @@ -419,7 +429,7 @@ class LayoutTests(ShipFileCase): row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())), "Shape": [shape(2, 1, cells)]}) with self.subTest(name): - with self.assertRaisesRegex(ValueError, "thruster.port footprint cells must be booleans"): + with self.assertRaisesRegex(ValueError, "Hardpoint footprint cells must be booleans"): self.edit(revision, hardpoints=[row]) self.assertEqual(before, self.bytes()) @@ -456,7 +466,7 @@ class LayoutTests(ShipFileCase): ("short", shape(2, 1, [True])), ("empty", shape(1, 1, [False]))): row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())), "Shape": [footprint]}) with self.subTest(name): - with self.assertRaisesRegex(ValueError, "thruster.port has an invalid footprint"): + with self.assertRaisesRegex(ValueError, "hardpoint has an invalid footprint"): self.edit(revision, hardpoints=[row]) self.assertEqual(before, self.bytes()) @@ -509,6 +519,23 @@ class CSharpWrittenFixtureTests(unittest.TestCase): shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "skiff.glb", target / "skiff.glb") +class ShipIdTests(unittest.TestCase): + def test_admits_the_ids_the_c_sharp_rule_admits(self): + for ship_id in ("probe.a", "a", "0x", "a_b-c", "com10", "com0", "lpt0", "console", "aux2", "a.nul", "x.com1"): + with self.subTest(ship_id): + self.assertTrue(ship_cc.valid_ship_id(ship_id)) + + def test_refuses_device_names_alone_or_before_a_dot_and_every_other_malformed_id(self): + for ship_id in ("con", "nul.x", "com1", "lpt9", "prn", "aux.txt", "com5.a", "abc.", "a..", "Upper", ".x", "_x", + "-x", "", "a b", "a/b", "a:b", "abé", "a\b"): + with self.subTest(ship_id): + self.assertFalse(ship_cc.valid_ship_id(ship_id)) + + def test_the_constants_name_the_allowed_characters_and_the_reserved_stems(self): + self.assertEqual(set("abcdefghijklmnopqrstuvwxyz0123456789._-"), set(ship_cc.SHIP_ID_CHARS)) + self.assertEqual(22, len(ship_cc.RESERVED_SHIP_IDS)) + + class HardpointRowTests(unittest.TestCase): def test_a_row_is_one_value_per_member_in_slot_order(self): fields = dict(Type=1, Position=[2, 3], Shape="shape", Transform="mount", Rotation=2, Armor=4.0, FiringArc=90.0) From 5beb693c00e6b523f56c6dea0e686326336ded24 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:49:40 +0200 Subject: [PATCH 10/18] Add-on: the .cc written only by Save; New Ship, Rasterise and Flip Nose edit the undoable buffer One chooser (_ship_collection) and one predicate (_is_ship). _save_layout is the only replace_layout caller and creates a pending ship's file, removing what it made on failure. Rasterise writes the layout buffer and aetheria.grid_origin = [x, y, width, height]; Flip Nose and Package refuse meshes outside Ship Root. New Ship checks first and writes nothing to disk. Operator reports are fixed text; AetherDb output goes to the system console. smoke_frame covers undo, hardpoints, Save, cleanup, loose meshes and an empty Game folder preference. Builds: yes (needs the previous commit's ship_cc). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 421 +++++++++++++++-------- tools/blender/tests/smoke_frame.py | 290 +++++++++++++--- 2 files changed, 528 insertions(+), 183 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 6b5820724..433125ec4 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -11,8 +11,10 @@ bl_info = { } import math +import shutil import subprocess import sys +import traceback from pathlib import Path import bpy @@ -20,8 +22,9 @@ import numpy from mathutils import Matrix from . import hull_grid -from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, - encode_hardpoint, read, read_layout, replace_layout, replace_lines, replace_visual) +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) MODEL_ASSET = "ship.glb" SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported @@ -83,23 +86,27 @@ def _parse_footprint(text): def _layout_path(context): collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): - raise ValueError("Ship collection has no bound .cc path") + raise ValueError("The ship collection has no bound .cc path") return collection, bpy.path.abspath(collection["aetheria.ship_cc"]) +def _set_cells(state, width, height, cells): + state.width = state.new_width = width + state.height = state.new_height = height + state.cells.clear() + for cell in cells: + state.cells.add().occupied = bool(cell) + + def _load_layout(context, collection, path): - """Fills the scene's layout state from the bound .cc and redraws the Grid; returns (width, height, hardpoints).""" + """Fills the scene's layout buffer from the bound .cc and redraws the Grid; returns (width, height, hardpoints).""" shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context)) width, height, cells = shape state = context.scene.aetheria_layout state.ship_id = collection["aetheria.id"] state.ship_cc = path state.revision = revision - state.width = state.new_width = width - state.height = state.new_height = height - state.cells.clear() - for cell in cells: - state.cells.add().occupied = bool(cell) + _set_cells(state, width, height, cells) state.hardpoints.clear() for raw in hardpoints: fields = decode_hardpoint(raw) @@ -115,6 +122,23 @@ def _load_layout(context, collection, path): return width, height, len(hardpoints) +_EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError) + + +def _failure_text(exc): + """What an operator reports for exc. This module's own ValueError and RuntimeError messages are fixed text that + names no input; any other error is reported by its type, and its text goes to the system console.""" + if type(exc) in (ValueError, RuntimeError): + return str(exc) + traceback.print_exception(exc) + return f"Stopped by {type(exc).__name__}; see the system console" + + +def _refuse(operator, exc): + operator.report({"ERROR"}, _failure_text(exc)) + return {"CANCELLED"} + + class AETHERIA_OT_load_layout(bpy.types.Operator): bl_idname = "aetheria.load_ship_layout" bl_label = "Load Ship Layout" @@ -123,12 +147,13 @@ class AETHERIA_OT_load_layout(bpy.types.Operator): def execute(self, context): try: collection, path = _layout_path(context) + if collection.get("aetheria.pending"): + raise ValueError("Not saved yet; Save Layout creates it") width, height, count = _load_layout(context, collection, path) self.report({"INFO"}, f"Loaded {width}x{height} layout and {count} hardpoints") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) class AETHERIA_OT_resize_layout(bpy.types.Operator): @@ -172,6 +197,54 @@ class AETHERIA_OT_remove_hardpoint(bpy.types.Operator): return {"FINISHED"} +def _encode_buffer(state): + """The layout buffer as ship_cc stores it: (shape, hardpoint rows).""" + shape = [state.width, state.height, [cell.occupied for cell in state.cells]] + rows = [encode_hardpoint(Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint), + Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc) + for hp in state.hardpoints] + return shape, rows + + +def _save_layout(context, collection, state): + """The one writer of the .cc layout: writes the layout buffer, creating the file first when the ship is pending. A + failure after the file was created removes what this save made. Returns the hardpoint count.""" + if not collection.get("aetheria.ship_cc"): + raise ValueError("The ship collection has no bound .cc path") + path = bpy.path.abspath(collection["aetheria.ship_cc"]) + ship_id = collection["aetheria.id"] + if state.ship_id != ship_id or state.ship_cc != path: + raise ValueError("Load this collection's layout before saving") + cultlib = _brokkr_cultlib(context) + shape, rows = _encode_buffer(state) + pending = collection.get("aetheria.pending") + folder = Path(path).parent + if pending and Path(path).exists(): + raise ValueError("A ship file already exists at the bound path; Bind it instead") + made_folder = bool(pending) and not folder.exists() + revision = state.revision + try: + if pending: + code, output = _aetherdb(context, "create", path, ship_id, pending[0], "--like", pending[1], ship_cc=path) + if output: + print(output) + if code != 0: + raise RuntimeError("AetherDb refused to create the ship; its message is in the system console") + revision = read_layout(path, cultlib)[2] + revision = replace_layout(path, cultlib, ship_id, revision, shape, rows) + except Exception: + if pending: + Path(path).unlink(missing_ok=True) + if made_folder: + shutil.rmtree(folder, ignore_errors=True) + raise + state.revision = revision + if pending: + del collection["aetheria.pending"] + _redraw_grid(collection, state) + return len(rows) + + class AETHERIA_OT_save_layout(bpy.types.Operator): bl_idname = "aetheria.save_ship_layout" bl_label = "Save Layout to .cc" @@ -179,25 +252,12 @@ class AETHERIA_OT_save_layout(bpy.types.Operator): def execute(self, context): try: - collection, path = _layout_path(context) - state = context.scene.aetheria_layout - if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: - raise ValueError("Load this collection's layout before saving") - cells = [cell.occupied for cell in state.cells] - hardpoints = [encode_hardpoint( - Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint), - Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc) - for hp in state.hardpoints] - state.revision = replace_layout( - path, _brokkr_cultlib(context), state.ship_id, state.revision, - [state.width, state.height, cells], hardpoints - ) - _redraw_grid(collection, state) - self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}") + collection, _ = _layout_path(context) + count = _save_layout(context, collection, context.scene.aetheria_layout) + self.report({"INFO"}, f"Saved the layout and {count} hardpoints") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) def _brokkr_cultlib(context): @@ -207,41 +267,32 @@ def _brokkr_cultlib(context): return brokkr.preferences.cultlib_py_src -def _ship_collection(context): +def _is_ship(collection): + return collection.get("aetheria.asset_kind") == "ship" + + +def _ship_collection(context, unbound_ok=False): + """The one chooser: the ship collection holding the active object. unbound_ok (Bind, New Ship) also accepts the + active object's only collection when no ship collection holds it.""" obj = context.active_object if obj is None: raise ValueError("Select an object in the ship collection") - matches = [collection for collection in bpy.data.collections - if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] - if len(matches) != 1: - raise ValueError("Selected object must belong to exactly one bound ship collection") - return matches[0] + ships = [collection for collection in bpy.data.collections if _is_ship(collection) and obj.name in collection.all_objects] + if len(ships) == 1: + return ships[0] + if unbound_ok and not ships and len(obj.users_collection) == 1: + return obj.users_collection[0] + raise ValueError("Select an object in exactly one ship collection" if unbound_ok + else "Selected object must belong to exactly one bound ship collection") -def _choose_collection(context): - """The collection a bind acts on: the one marked ship that holds the active object, else the active object's only - collection.""" - obj = context.active_object - if obj is None: - raise ValueError("Select an object in the ship collection") - direct = list(obj.users_collection) - marked = [collection for collection in bpy.data.collections - if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects] - if len(marked) == 1: - return marked[0] - if len(marked) == 0 and len(direct) == 1: - return direct[0] - raise ValueError("Select an object in exactly one ship collection") - - -def _bind_collection(context, path): - """Binds the chosen collection to the ship .cc at path (aetheria.asset_kind, .id and .ship_cc), or changes nothing.""" - collection = _choose_collection(context) - ship_id = read(path, _brokkr_cultlib(context)).ship.body[0] +def _bind_collection(collection, path, ship_id, pending=None): + """Binds the collection to the ship .cc at path (aetheria.asset_kind, .id and .ship_cc, and .pending = [name, + reference] for a ship whose file the first Save creates), or changes nothing.""" if collection.get("aetheria.asset_kind") not in (None, "ship"): - raise ValueError(f"{collection.name} has another aetheria.asset_kind") + raise ValueError("The collection has another aetheria.asset_kind") if collection.get("aetheria.id") not in (None, ship_id): - raise ValueError(f"{collection.name} is bound to another ship ID") + raise ValueError("The collection is bound to another ship ID") stored = path if bpy.data.filepath: try: @@ -251,7 +302,10 @@ def _bind_collection(context, path): collection["aetheria.asset_kind"] = "ship" collection["aetheria.id"] = ship_id collection["aetheria.ship_cc"] = stored - return collection + if pending: + collection["aetheria.pending"] = pending + elif "aetheria.pending" in collection: + del collection["aetheria.pending"] class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): @@ -264,12 +318,13 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): try: if not context.scene.aetheria_ship_cc_path: raise ValueError("Choose an existing ship authoring .cc file") - collection = _bind_collection(context, bpy.path.abspath(context.scene.aetheria_ship_cc_path)) - self.report({"INFO"}, f"Bound {collection.name} to {collection['aetheria.id']}") + path = bpy.path.abspath(context.scene.aetheria_ship_cc_path) + collection = _ship_collection(context, unbound_ok=True) + _bind_collection(collection, path, read(path, _brokkr_cultlib(context)).ship.body[0]) + self.report({"INFO"}, "Bound the ship collection") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): @@ -283,7 +338,7 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): obj = context.active_object collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): - raise ValueError(f"{collection.name} has no bound ship .cc path") + raise ValueError("The ship collection has no bound .cc path") path = bpy.path.abspath(collection["aetheria.ship_cc"]) cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: @@ -293,11 +348,10 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): evaluated=context.scene.aetheria_capture_evaluated_lines, ) count = replace_lines(path, cultlib, lines) - self.report({"INFO"}, f"Captured {count} lines into {path}") + self.report({"INFO"}, f"Captured {count} lines") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) class AETHERIA_AP_preferences(bpy.types.AddonPreferences): @@ -340,8 +394,13 @@ def _aetherdb(context, *args, ship_cc=None): tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb") if not tool.is_file(): raise ValueError(GAME_FOLDER_REFUSAL) - result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True, - timeout=AETHERDB_TIMEOUT) + try: + result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True, + timeout=AETHERDB_TIMEOUT) + except subprocess.TimeoutExpired: + raise RuntimeError("AetherDb did not finish in time") from None + except OSError: + raise RuntimeError("AetherDb could not be started") from None lines = (result.stdout + "\n" + result.stderr).splitlines() return result.returncode, "\n".join(line for line in lines if line.strip()) @@ -365,12 +424,12 @@ def _anchors(collection): continue anchor_id = obj.get("aetheria.id") if not isinstance(anchor_id, str) or not anchor_id.strip(): - raise ValueError(f"{obj.name} has aetheria.role {role} but no aetheria.id") + raise ValueError("An object with aetheria.role has no aetheria.id") parent = None if role == "weapon-muzzle": parent = obj.parent.get("aetheria.id") if obj.parent else None if not parent: - raise ValueError(f"Muzzle {obj.name} must be parented to its weapon mount object") + raise ValueError("A weapon-muzzle must be parented to its weapon mount object") rows.append([anchor_id, str(role), anchor_id, parent, int(obj.get("aetheria.order", 0))]) return rows @@ -381,7 +440,7 @@ def _export_glb(context, collection, filepath): view_layer = context.view_layer layer = _layer_collection(view_layer.layer_collection, collection) if layer is None: - raise ValueError(f"{collection.name} is not in the current view layer") + raise ValueError("The ship collection is not in the current view layer") previous_layer = view_layer.active_layer_collection previous_selection = [obj for obj in view_layer.objects if obj.select_get()] previous_active = view_layer.objects.active @@ -432,10 +491,52 @@ def _meshes(objects): return [obj for obj in objects if obj.type == "MESH"] -def _render_meshes(collection): - """The ship's own meshes: bound-collection meshes outside Source and Generated that carry no aetheria.role.""" +def _frame_meshes(collection): + """The ship's own meshes: Ship Root's descendants outside Source and Generated that carry no aetheria.role; the + Source meshes while there are none.""" derived = _derived_names(collection) - return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and "aetheria.role" not in obj] + own = [obj for obj in _meshes(_ship_root(collection).children_recursive) + if obj.name not in derived and "aetheria.role" not in obj] + source = _child(collection, SOURCE) + return own or (_meshes(source.all_objects) if source else []) + + +def _loose_meshes(collection): + """Meshes of a collection that has a Ship Root which sit outside Source and Generated and are not under the root.""" + root = _ship_root(collection) + if root is None: + return [] + derived = _derived_names(collection) + framed = {obj.name for obj in root.children_recursive} + return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and obj.name not in framed] + + +def _refuse_loose_meshes(context, collection): + """Refuses while meshes sit outside Ship Root, selecting them so the author can parent them.""" + loose = _loose_meshes(collection) + if not loose: + return + for obj in context.view_layer.objects: + obj.select_set(False) + for obj in loose: + if obj.name in context.view_layer.objects: + obj.select_set(True) + raise ValueError(f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it") + + +def _frame_inputs(context, collection, state): + """Ship Root and the meshes Rasterise reads, or the refusal that stops Rasterise and Flip Nose before they change + anything.""" + root = _ship_root(collection) + if root is None: + raise ValueError("The ship collection has no Ship Root; run New Ship first") + if state.ship_id != collection["aetheria.id"]: + raise ValueError("Load this collection's layout first") + _refuse_loose_meshes(context, collection) + meshes = _frame_meshes(collection) + if not meshes: + raise ValueError("The ship collection has no render or Source mesh to rasterise") + return root, meshes def _world_mesh(obj, depsgraph, matrix): @@ -454,12 +555,29 @@ def _world_mesh(obj, depsgraph, matrix): return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3) +def _grid_current(collection, state): + """Whether the Grid can be drawn: grid_origin was computed for the buffer's own size.""" + origin = collection.get("aetheria.grid_origin") + return (origin is not None and len(origin) == 4 and (int(origin[2]), int(origin[3])) == (state.width, state.height) + and len(state.cells) == state.width * state.height) + + def _redraw_grid(collection, state): - """Redraws the Grid object from the layout state and the collection's aetheria.grid_origin. A collection without a - Ship Root or a grid_origin has no frame yet and gets no Grid.""" + """Redraws the Grid object from the layout buffer and the collection's aetheria.grid_origin while the origin was + computed for the buffer's size; otherwise removes the Grid. A collection without a Ship Root or a grid_origin has no + frame yet and gets no Grid.""" root = _ship_root(collection) origin = collection.get("aetheria.grid_origin") - if root is None or origin is None or len(state.cells) != state.width * state.height: + if root is None or origin is None: + return + generated = _child_or_new(collection, GENERATED, GENERATED) + grid = next((obj for obj in generated.objects if obj.get("aetheria.grid")), None) + if not _grid_current(collection, state): + if grid is not None: + stale = grid.data + bpy.data.objects.remove(grid) + if stale.users == 0: + bpy.data.meshes.remove(stale) return origin = (float(origin[0]), float(origin[1])) half = hull_grid.CELL_SIZE / 2 @@ -477,8 +595,6 @@ def _redraw_grid(collection, state): mesh = bpy.data.meshes.new("Grid") mesh.from_pydata(verts, edges, faces) mesh.update(calc_edges=True) - generated = _child_or_new(collection, GENERATED, GENERATED) - grid = next((obj for obj in generated.objects if obj.get("aetheria.grid")), None) if grid is None: grid = bpy.data.objects.new("Grid", mesh) grid["aetheria.grid"] = True @@ -496,17 +612,11 @@ def _redraw_grid(collection, state): grid.display_type = "WIRE" -def _rasterise(context, collection, path): - """Proposes the hull's cells from the render meshes (Source while there are none), top-down in Ship Root's frame, - writes them with replace_layout keeping the hardpoint rows, and stores the grid's placement. The only writer of - aetheria.grid_origin.""" - root = _ship_root(collection) - if root is None: - raise ValueError(f"{collection.name} has no Ship Root; run New Ship first") - source = _child(collection, SOURCE) - meshes = _render_meshes(collection) or (_meshes(source.all_objects) if source else []) - if not meshes: - raise ValueError(f"{collection.name} has no render or Source mesh to rasterise") +def _rasterise(context, collection, state): + """Proposes the hull's cells from the frame meshes, top-down in Ship Root's frame, into the layout buffer (its + hardpoints stay) and stores the grid's placement with the size it was computed for. Writes no file. The only writer + of aetheria.grid_origin.""" + root, meshes = _frame_inputs(context, collection, state) depsgraph = context.evaluated_depsgraph_get() to_root = root.matrix_world.inverted() triangles = [] @@ -514,30 +624,25 @@ def _rasterise(context, collection, path): points, indices = _world_mesh(obj, depsgraph, to_root @ obj.matrix_world) triangles += points[indices][:, :, :2].tolist() width, height, cells, origin = hull_grid.rasterise(triangles) - cultlib = _brokkr_cultlib(context) - _, hardpoints, revision = read_layout(path, cultlib) - replace_layout(path, cultlib, collection["aetheria.id"], revision, [width, height, cells], - [encode_hardpoint(**decode_hardpoint(row)) for row in hardpoints]) - collection["aetheria.grid_origin"] = [origin[0], origin[1]] - _load_layout(context, collection, path) + _set_cells(state, width, height, cells) + collection["aetheria.grid_origin"] = [float(origin[0]), float(origin[1]), width, height] + _redraw_grid(collection, state) return width, height class AETHERIA_OT_rasterise_hull(bpy.types.Operator): bl_idname = "aetheria.rasterise_hull" bl_label = "Rasterise Hull" - bl_description = "Propose the hull's cells from its top-down silhouette; you edit them afterwards" + bl_description = "Propose the hull's cells from its top-down silhouette; you edit them afterwards, then Save Layout" bl_options = {"REGISTER", "UNDO"} def execute(self, context): try: - collection, path = _layout_path(context) - width, height = _rasterise(context, collection, path) + width, height = _rasterise(context, _ship_collection(context), context.scene.aetheria_layout) self.report({"INFO"}, f"Rasterised a {width}x{height} hull") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) class AETHERIA_OT_flip_nose(bpy.types.Operator): @@ -548,32 +653,44 @@ class AETHERIA_OT_flip_nose(bpy.types.Operator): def execute(self, context): try: - collection, path = _layout_path(context) - root = _ship_root(collection) - if root is None: - raise ValueError(f"{collection.name} has no Ship Root; run New Ship first") + collection = _ship_collection(context) + state = context.scene.aetheria_layout + root, _ = _frame_inputs(context, collection, state) turn = root.matrix_world @ Matrix.Rotation(math.pi, 4, "Z") @ root.matrix_world.inverted() for child in root.children: child.matrix_world = turn @ child.matrix_world - _rasterise(context, collection, path) + try: + _rasterise(context, collection, state) + except Exception: + for child in root.children: # a half turn is its own inverse + child.matrix_world = turn @ child.matrix_world + raise self.report({"INFO"}, "Turned the nose about Ship Root") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) def _new_ship(context, ship_id, name, reference, length): - """Creates the ship .cc from a shipped hull, binds the active collection, moves its meshes into Source under a Ship - Root, turns them nose to -Y, sizes them to length cells and rasterises.""" - if not ship_id or ship_id != ship_id.strip() or any(c in ship_id for c in "/\\:") or not ship_id.strip("."): - raise ValueError("The ship ID must be a single name without slashes, colons or surrounding spaces") + """Sets the active collection up as a pending ship: binds it, moves its meshes into Source under a Ship Root, turns + them nose to -Y, sizes them to length cells, fills the layout buffer and rasterises. Every check comes first and + nothing is written to disk: the first Save creates the file.""" + if not valid_ship_id(ship_id): + raise ValueError("The ship ID must use lower-case letters, digits, dots, underscores or hyphens, must not end " + "in a dot, and must not be a Windows device name") if not name.strip() or not reference.strip(): raise ValueError("Give the ship a display name and a reference hull") - collection = _choose_collection(context) + path = _game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc" + if path.exists(): + raise ValueError("A ship with this ID already exists") + collection = _ship_collection(context, unbound_ok=True) + if collection.get("aetheria.id") is not None or _ship_root(collection) is not None: + raise ValueError("The collection is already a ship") meshes = _meshes(collection.objects) if not meshes: - raise ValueError(f"{collection.name} holds no mesh objects to make a ship from") + raise ValueError("The ship collection holds no mesh objects to make a ship from") + if len(_meshes(collection.all_objects)) != len(meshes): + raise ValueError("The ship collection's meshes must sit directly in it, not in child collections") if any(obj.data.users > 1 for obj in meshes): raise ValueError("Make the hull meshes single-user before New Ship (Object > Relations > Make Single User)") worlds = {obj: obj.matrix_world.copy() for obj in meshes} @@ -583,54 +700,58 @@ def _new_ship(context, ship_id, name, reference, length): longest = max(extent_x, extent_y) if longest <= 0: raise ValueError("The hull meshes have no horizontal extent") - path = str(_game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc") - code, message = _aetherdb(context, "create", path, ship_id, name, "--like", reference, ship_cc=path) - if code != 0: - raise RuntimeError(f"create failed: {message}") - collection = _bind_collection(context, path) scale = length * hull_grid.CELL_SIZE / longest transform = Matrix.Diagonal((scale, scale, scale, 1.0)) if extent_x > extent_y: transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z") + placed = {obj: transform @ worlds[obj] for obj in meshes} + triangles = [] + for obj in meshes: + placed_points, indices = _world_mesh(obj, depsgraph, placed[obj]) + triangles += placed_points[indices][:, :, :2].tolist() + width, height, cells, _ = hull_grid.rasterise(triangles) + _bind_collection(collection, str(path), ship_id, [name, reference]) source = _child_or_new(collection, SOURCE, SOURCE) root = bpy.data.objects.new(SHIP_ROOT, None) root["aetheria.ship_root"] = True collection.objects.link(root) for obj in meshes: - placed = transform @ worlds[obj] collection.objects.unlink(obj) source.objects.link(obj) - obj.data.transform(placed.to_3x3().to_4x4()) - if placed.determinant() < 0: + obj.data.transform(placed[obj].to_3x3().to_4x4()) + if placed[obj].determinant() < 0: obj.data.flip_normals() obj.parent = root obj.matrix_parent_inverse = Matrix.Identity(4) obj.rotation_mode = "XYZ" obj.rotation_euler = (0.0, 0.0, 0.0) obj.scale = (1.0, 1.0, 1.0) - obj.location = placed.to_translation() - context.scene.aetheria_ship_cc_path = path - _rasterise(context, collection, path) - return collection + obj.location = placed[obj].to_translation() + state = context.scene.aetheria_layout + state.ship_id = ship_id + state.ship_cc = bpy.path.abspath(collection["aetheria.ship_cc"]) + state.revision = "" + state.hardpoints.clear() + _set_cells(state, width, height, cells) + _rasterise(context, collection, state) class AETHERIA_OT_new_ship(bpy.types.Operator): bl_idname = "aetheria.new_ship" bl_label = "New Ship" - bl_description = ("Create the ship .cc beside the Aetheria repo's mods, bind the active collection, and set its " - "meshes up as Source: nose toward -Y, sized to the length, with a drafted grid") + bl_description = ("Set the active collection up as a new ship: nose toward -Y, sized to the length, with a drafted " + "grid. Nothing is written until Save Layout") bl_options = {"REGISTER", "UNDO"} def execute(self, context): scene = context.scene try: - collection = _new_ship(context, scene.aetheria_new_ship_id, scene.aetheria_new_ship_name, - scene.aetheria_new_ship_like, scene.aetheria_new_ship_length) - self.report({"INFO"}, f"New ship {collection['aetheria.id']} bound to {collection.name}") + _new_ship(context, scene.aetheria_new_ship_id, scene.aetheria_new_ship_name, + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length) + self.report({"INFO"}, "New ship set up; Save Layout creates its file") return {"FINISHED"} - except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc: - self.report({"ERROR"}, str(exc)) - return {"CANCELLED"} + except _EXPECTED as exc: + return _refuse(self, exc) class AETHERIA_OT_package_ship(bpy.types.Operator): @@ -644,8 +765,18 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): scene = context.scene try: collection, path = _layout_path(context) + if collection.get("aetheria.pending"): + raise ValueError("Save Layout first") + _refuse_loose_meshes(context, collection) cultlib = _brokkr_cultlib(context) ship_id = collection["aetheria.id"] + state = scene.aetheria_layout + if state.ship_id == ship_id and state.ship_cc == path: + shape, rows = _encode_buffer(state) + saved_shape, saved_rows, _ = read_layout(path, cultlib) + if layout_revision(shape, rows) != layout_revision( + saved_shape, [row[:len(HARDPOINT_MEMBERS)] for row in saved_rows]): + raise ValueError("Save Layout first") if read(path, cultlib).ship.body[0] != ship_id: raise ValueError("The bound collection ID does not match its .cc ship ID") anchors = _anchors(collection) @@ -657,8 +788,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines)) code, message = _aetherdb(context, "validate", path, ship_cc=path) - except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc: - scene.aetheria_package_report = f"Package failed: {exc}" + except _EXPECTED as exc: + scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" self.report({"ERROR"}, scene.aetheria_package_report) return {"CANCELLED"} scene.aetheria_package_report = message or f"validate exited {code}" @@ -689,9 +820,15 @@ class AETHERIA_PT_ship(bpy.types.Panel): layout.prop(context.scene, "aetheria_capture_evaluated_lines") obj = context.active_object layout.label(text=f"Source: {obj.name if obj else '(none)'}") + state = context.scene.aetheria_layout try: collection = _ship_collection(context) layout.label(text=f"Ship: {collection['aetheria.id']}") + if collection.get("aetheria.pending"): + layout.label(text="Not saved yet: Save Layout creates the file", icon="INFO") + elif (collection.get("aetheria.grid_origin") is not None and state.ship_id == collection["aetheria.id"] + and not _grid_current(collection, state)): + layout.label(text="Rasterise again: the grid no longer matches the layout", icon="INFO") except (ValueError, KeyError): layout.label(text="No bound ship collection", icon="ERROR") layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL") @@ -701,7 +838,6 @@ class AETHERIA_PT_ship(bpy.types.Panel): row.operator("aetheria.load_ship_layout", icon="IMPORT") row.operator("aetheria.flip_nose", icon="LOOP_BACK") row.operator("aetheria.rasterise_hull", icon="MESH_GRID") - state = context.scene.aetheria_layout if not state.ship_id: self._draw_package(context, layout) return @@ -789,3 +925,4 @@ def unregister(): del bpy.types.Scene.aetheria_new_ship_id del bpy.types.Scene.aetheria_package_report del bpy.types.Scene.aetheria_ship_cc_path + diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 739001bdc..9bcf10789 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -1,19 +1,24 @@ -"""Headless Blender smoke for New Ship, Rasterise, Flip Nose, the Grid object and the export exclusions. +"""Headless Blender smoke for the add-on frame: New Ship, Rasterise, Flip Nose, Save, the Grid object, undo and Package. blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its -ModTools, no dotnet), and msgpack importable. New Ship writes the ship under /GameData/Mods/smoke.frame, -which this smoke removes when it ends. +ModTools, no dotnet), and msgpack importable. The ship is created under /GameData/Mods/smoke.frame, which +this smoke removes when it ends. -Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni. -Pass 2 adds a render mesh (a copy of the sphere) and a nub at the +Y end, rasterises, flips the nose, and requires the -nub's cells to move from the aft rows to the forward rows. Pass 3 tags anchors, packages, and requires the GLB to hold -neither the Source nor the Grid and the validator to accept it. +Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni: nothing +is written, ed.undo takes the ship back, and the first Save creates the file. Pass 2 makes a Save fail after create and +requires the folder gone and the retry to succeed. Pass 3 rasterises after a scale change: the .cc is unchanged until Save +and ed.undo returns the cells and the grid placement together. Pass 4 adds hardpoints and saves. Pass 5 puts meshes +outside Ship Root: Flip Nose and Rasterise refuse and change nothing; parented, Flip Nose moves the nub's cells from the +aft rows to the forward rows. Pass 6 packages: an unsaved buffer is refused, the saved one validates, and the GLB holds +neither the Source nor the Grid. Every refusal report names neither the ship ID, the collection, an object nor a path. """ +import hashlib import json import os +import re import shutil import struct import sys @@ -29,6 +34,8 @@ PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES SHIP_ID = "smoke.frame" 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")) MODS = GAME / "GameData" / "Mods" +COLLECTION = "Smoke Frame" +NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", str(GAME), GAME.name) def new_object(collection, name, mesh, location=(0, 0, 0), parent=None): @@ -59,12 +66,12 @@ def tag(obj, role, anchor_id): def run(operator): + """The operator's result and, for an operator that reported an error, the report (bpy.ops raises it).""" try: - result = operator() + return operator(), "" except RuntimeError as exc: - result = {"CANCELLED"} print(f"OPERATOR error: {exc}") - return result + return {"CANCELLED"}, str(exc) def require(condition, message): @@ -72,16 +79,38 @@ def require(condition, message): raise SystemExit(message) -def layout(path, aetheria_ships): - shape, hardpoints, _ = aetheria_ships.ship_cc.read_layout(path, PACKAGES) +def refused(operator, expected, what): + """Requires a CANCELLED operator whose report holds expected and echoes none of NAMES_NOT_ECHOED.""" + result, message = run(operator) + require(result == {"CANCELLED"}, f"{what} was not refused") + require(expected in message, f"{what}: the report is not the fixed text ({message!r})") + for name in NAMES_NOT_ECHOED: + require(name not in message, f"{what}: the report echoes an input ({name})") + return message + + +def saved_layout(path, aetheria_ships): + shape, hardpoints, revision = aetheria_ships.ship_cc.read_layout(path, PACKAGES) width, height, cells = shape - return width, height, {(i // height, i % height) for i, cell in enumerate(cells) if cell} + return width, height, {(i // height, i % height) for i, cell in enumerate(cells) if cell}, hardpoints, revision + + +def buffer(scene): + """The layout buffer as (width, height, occupied cells, hardpoint count).""" + state = scene.aetheria_layout + return (state.width, state.height, + {(i // state.height, i % state.height) for i, cell in enumerate(state.cells) if cell.occupied}, + len(state.hardpoints)) def row_count(cells, y): return sum(1 for _, cy in cells if cy == y) +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + def glb_node_names(path): data = Path(path).read_bytes() length, kind = struct.unpack_from(" forward, "The nub's cells are not on the aft rows before the flip") - require(run(bpy.ops.aetheria.flip_nose) == {"FINISHED"}, "Flip Nose was refused") - _, _, after = layout(path, aetheria_ships) + result, message = run(bpy.ops.aetheria.flip_nose) + require(result == {"FINISHED"}, f"Flip Nose was refused: {message}") + _, _, after, _ = buffer(bpy.context.scene) require(row_count(after, height - 1) == aft and row_count(after, 0) == forward, "Flip Nose did not move the nub's cells from the aft rows to the forward rows") require({(width - 1 - x, height - 1 - y) for x, y in before} == after, "The flipped grid is not the turned grid") - print("PASS 2 rasterise and flip nose ok") + print("PASS 5 loose meshes, rasterise and flip nose ok") - # Pass 3: Package leaves Source and Grid out of the GLB. + # Pass 6: Package refuses an unsaved buffer, then the saved one validates; the GLB leaves Source and Grid out. + scene = bpy.context.scene + collection = bpy.data.collections[COLLECTION] + root = bpy.data.objects["Ship Root"] tag(new_object(collection, "Map Icon", box("Map Icon", 0.5, 0.5, 0.01), (0, 0, -1), root), "map-icon", "map") tag(new_object(collection, "Hull Collider", tetrahedron("Hull Collider"), parent=root), "hull-collider", "collider") tag(new_object(collection, "Shield", None, parent=root), "shield", "shield") tag(new_object(collection, "Tractor", None, (0, 2, 0), root), "tractor", "tractor") - bpy.context.view_layer.objects.active = render - result = run(bpy.ops.aetheria.package_ship) + select(bpy.data.objects["Hull"]) + saved_digest = digest(path) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"CANCELLED"} and "Save Layout first" in scene.aetheria_package_report, + f"Package took an unsaved buffer: {scene.aetheria_package_report}") + require(digest(path) == saved_digest and not Path(path).with_name("ship.glb").exists(), + "A refused Package wrote the .cc or the GLB") + result, message = run(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"}, f"Save before Package was refused: {message}") + preferences.game_folder = "" # the game folder is then found from the bound .cc + result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report + preferences.game_folder = str(GAME) print(f"PACKAGE {sorted(result)}: {report}") - require(result == {"FINISHED"}, "Package was refused") + require(result == {"FINISHED"}, f"Package with an empty Game folder preference was refused: {report}") names = glb_node_names(Path(path).with_name("ship.glb")) print(f"GLB nodes: {sorted(n for n in names if n)}") - require("Hull" in names and "Nub" in names, "The GLB lacks the render meshes") + require("Hull" in names and "Loose Nub" in names, "The GLB lacks the render meshes") require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid") - print("PASS 3 export ok") + print("PASS 6 package ok") print("SMOKE_FRAME_OK") +def _root_count(): + return sum(1 for obj in bpy.data.objects if obj.get("aetheria.ship_root")) + + +def _second_collection_refused(): + """New Ship on a second collection with the saved ship's ID is refused with fixed text.""" + second = bpy.data.collections.new("Second Frame") + bpy.context.scene.collection.children.link(second) + mesh = new_object(second, "Other", box("Other", 1.0, 2.0, 0.5)) + select(mesh) + bpy.context.scene.aetheria_new_ship_id = SHIP_ID + refused(bpy.ops.aetheria.new_ship, "already exists", "New Ship over an existing ship") + require("aetheria.id" not in second, "A refused New Ship bound the second collection") + bpy.data.objects.remove(mesh) + bpy.data.collections.remove(second) + return "existing target refused" + + created_mods = not MODS.exists() existed = (MODS / SHIP_ID).exists() try: From caa418d6cd3d05bbeaf05f6853772a7a01b37b41 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:50:52 +0200 Subject: [PATCH 11/18] smoke_frame: a refused create and a nested render mesh; test_ship_cc: refusals name no input Builds: yes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_frame.py | 16 +++++++++++----- tools/blender/tests/test_ship_cc.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 9bcf10789..d9cd6a805 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -35,7 +35,7 @@ SHIP_ID = "smoke.frame" 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")) MODS = GAME / "GameData" / "Mods" COLLECTION = "Smoke Frame" -NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", str(GAME), GAME.name) +NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", "NoSuchHullXYZ", str(GAME), GAME.name) def new_object(collection, name, mesh, location=(0, 0, 0), parent=None): @@ -237,7 +237,12 @@ def main(): root = bpy.data.objects["Ship Root"] print("PASS 1 new ship ok") - # Pass 2: a Save that fails after create removes what it made; the retry succeeds. + # Pass 2: a Save that fails in create or after it leaves nothing behind and tells the author nothing of the input; + # the retry succeeds. + collection["aetheria.pending"] = ["Smoke Frame", "NoSuchHullXYZ"] + refused(bpy.ops.aetheria.save_ship_layout, "AetherDb refused to create the ship", "Save with an unknown reference hull") + require(not (MODS / SHIP_ID).exists(), "A refused create left the ship folder behind") + collection["aetheria.pending"] = ["Smoke Frame", "Djinni"] real = aetheria_ships.replace_layout def failing(*args, **kwargs): @@ -323,9 +328,10 @@ def main(): require(snapshot == (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]), [tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children]), f"{what} changed the scene while refusing") - for obj in (render, nub): - obj.parent = root - obj.matrix_parent_inverse = root.matrix_world.inverted() + render.parent = root # Ship Root's descendants count, however deep: the nub hangs from the hull + render.matrix_parent_inverse = root.matrix_world.inverted() + nub.parent = render + nub.matrix_parent_inverse = render.matrix_world.inverted() select(render) result, message = run(bpy.ops.aetheria.rasterise_hull) require(result == {"FINISHED"}, f"Rasterise Hull was refused: {message}") diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 4a9c045d8..bd39a83e3 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -519,6 +519,27 @@ class CSharpWrittenFixtureTests(unittest.TestCase): shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "skiff.glb", target / "skiff.glb") +class RefusalsNameNoInputTests(ShipFileCase): + def test_refusals_name_no_path_mount_or_anchor_id(self): + _, _, revision = ship_cc.read_layout(self.path, PACKAGES) + mount = "mount.zq-unique" + duplicate = [[ "anchor.zq-unique", "map-icon", "n1", None, 0], ["anchor.zq-unique", "shield", "n2", None, 0]] + bad_footprint = ship_cc.encode_hardpoint( + Type=3, Position=[1, 0], Shape=[[1, 1, [0]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0) + empty_footprint = ship_cc.encode_hardpoint( + Type=3, Position=[1, 0], Shape=[[1, 1, [False]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0) + calls = (lambda: ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", duplicate), + lambda: ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", revision, [2, 2, [True] * 4], [bad_footprint]), + lambda: ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", revision, [2, 2, [True] * 4], [empty_footprint]), + lambda: ship_cc.read(os.path.join(self.path, "missing.cc"), PACKAGES)) + for call in calls: + with self.subTest(call): + with self.assertRaises(Exception) as caught: + call() + for forbidden in ("zq-unique", self.path, os.path.dirname(self.path)): + self.assertNotIn(forbidden, str(caught.exception)) + + class ShipIdTests(unittest.TestCase): def test_admits_the_ids_the_c_sharp_rule_admits(self): for ship_id in ("probe.a", "a", "0x", "a_b-c", "com10", "com0", "lpt0", "console", "aux2", "a.nul", "x.com1"): From aab6654cc127ef167e5bf6b24f0bc6f4d48fc6a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:52:51 +0200 Subject: [PATCH 12/18] smoke_frame: a Grid placed for another size is removed after a resize Builds: yes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_frame.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index d9cd6a805..f07f9ac2b 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -309,6 +309,11 @@ def main(): cell.occupied = False result, message = run(bpy.ops.aetheria.save_ship_layout) require(result == {"FINISHED"} and grid_faces() == faces - 1, "Toggling a cell and Save did not redraw the Grid") + state.new_width = state.width + 1 # a layout resized after the grid was placed: the placement no longer fits it + bpy.ops.aetheria.resize_ship_layout() + result, message = run(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"} and bpy.data.objects.get("Grid") is None, + "A Grid placed for another size is still drawn after the layout was resized") print("PASS 4 hardpoints and save ok") # Pass 5: meshes outside Ship Root stop Flip Nose and Rasterise; parented, the flip moves the nub's cells. From 2be6ed9de7da3ffb2614bdf1dd8b506264251463 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 10:21:35 +0200 Subject: [PATCH 13/18] ShipSchemaPinTests: test each allowed character mid-ID, where a trailing dot cannot refuse it Builds: yes. The previous commit's pin test failed on '.' (a dot last is refused by its own rule). Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index 9e690828a..1dc3e8fe4 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -77,7 +77,7 @@ public sealed class ShipSchemaPinTests for (var code = 0; code < 256; code++) { var c = (char)code; - Assert.Equal(allowed.Contains(c), Accepts("a" + c)); + Assert.Equal(allowed.Contains(c), Accepts("a" + c + "a")); Assert.Equal(allowed.Contains(c) && c != '.' && c != '_' && c != '-', Accepts(c + "a")); } var stems = new List(); From 0906eb53e7eb50a5172242663923f8b3f9fdeecf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:16:41 +0200 Subject: [PATCH 14/18] smoke_frame: pin the guards that protect an author's files and the UNDO flags Pass 7: a pending Save over an existing ship.cc is refused and leaves it; a failed Save keeps the files that were in the ship folder; New Ship takes no hardpoints from the ship before it; a buffer that belongs to another ship is neither saved nor rasterised. Pass 8: operators called with undo=True push a native step in background Blender only when they carry UNDO, so Rasterise, Flip Nose and New Ship undo as one step and Save does not. Nine hand mutants, all caught. Closes no finding; every commit builds (Python only). Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_frame.py | 144 ++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index f07f9ac2b..79d9e3c06 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -13,6 +13,12 @@ and ed.undo returns the cells and the grid placement together. Pass 4 adds hardp outside Ship Root: Flip Nose and Rasterise refuse and change nothing; parented, Flip Nose moves the nub's cells from the aft rows to the forward rows. Pass 6 packages: an unsaved buffer is refused, the saved one validates, and the GLB holds neither the Source nor the Grid. Every refusal report names neither the ship ID, the collection, an object nor a path. + +Pass 7 pins the guards that protect an author's files: a Save over an existing ship.cc is refused and leaves it, a failed +Save leaves the files that were in the folder before, New Ship takes none of the previous ship's hardpoints, and a buffer +that belongs to another ship is neither saved nor rasterised. Pass 8 calls the operators with undo=True, which pushes the +native undo step in background Blender only for an operator that carries UNDO: Rasterise, Flip Nose and New Ship are +undoable, Save is not. """ import hashlib @@ -35,6 +41,8 @@ SHIP_ID = "smoke.frame" 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")) MODS = GAME / "GameData" / "Mods" COLLECTION = "Smoke Frame" +GUARD_A, GUARD_B, UNDO_SHIP = "smoke.guard.a", "smoke.guard.b", "smoke.undo" +EXTRA_IDS = (GUARD_A, GUARD_B, UNDO_SHIP) NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", "NoSuchHullXYZ", str(GAME), GAME.name) @@ -380,9 +388,142 @@ def main(): require("Hull" in names and "Loose Nub" in names, "The GLB lacks the render meshes") require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid") print("PASS 6 package ok") + guards_pass(aetheria_ships) + print("PASS 7 guards ok") + undo_pass() + print("PASS 8 native undo ok") print("SMOKE_FRAME_OK") +def ship_from_box(scene, name, ship_id, location): + """A fresh collection holding one box, made a pending ship by New Ship; returns the collection and the box.""" + collection = bpy.data.collections.new(name) + scene.collection.children.link(collection) + hull = new_object(collection, name + " Hull", box(name, 1.0, 3.0, 0.5), location) + select(hull) + result, message = new_ship(scene, ship_id, name, "Djinni", 10) + require(result == {"FINISHED"}, f"New Ship for {ship_id} was refused: {message}") + return collection, hull + + +def guards_pass(aetheria_ships): + scene = bpy.context.scene + path_a, path_b = str(MODS / GUARD_A / "ship.cc"), str(MODS / GUARD_B / "ship.cc") + for ship_id in EXTRA_IDS: + require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first") + + # Ship A with two hardpoints in its buffer, saved. + collection_a, hull_a = ship_from_box(scene, "Guard A", GUARD_A, (2, 1, 0)) + require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the hardpoints of the ship before it in the buffer") + for index, mount in enumerate(("mount.a", "mount.b")): + select(hull_a) + bpy.ops.aetheria.add_ship_hardpoint() + hp = bpy.context.scene.aetheria_layout.hardpoints[index] + hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, "5", index + 1, 2, "#" + select(hull_a) + result, message = run(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"} and len(saved_layout(path_a, aetheria_ships)[3]) == 2, f"Ship A was not saved: {message}") + + # A pending ship over an existing ship.cc (what Ctrl+Z past a first Save restores) is refused, and the file stays. + saved = digest(path_a) + collection_a["aetheria.pending"] = ["Guard A", "Djinni"] + refused(bpy.ops.aetheria.save_ship_layout, "A ship file already exists at the bound path", "Save of a pending ship over its file") + require(Path(path_a).is_file() and digest(path_a) == saved, "A refused Save deleted or changed the author's ship.cc") + del collection_a["aetheria.pending"] + + # Ship B takes none of ship A's hardpoints. + collection_b, hull_b = ship_from_box(scene, "Guard B", GUARD_B, (0, 0, 0)) + require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the previous ship's hardpoints in the buffer") + + # A buffer that belongs to ship B is neither saved into ship A's file nor rasterised onto ship A. + select(hull_a) + refused(bpy.ops.aetheria.save_ship_layout, "Load this collection's layout before saving", "Save with another ship's buffer") + require(digest(path_a) == saved, "Save wrote another ship's buffer into ship A's file") + before = buffer(bpy.context.scene) + refused(bpy.ops.aetheria.rasterise_hull, "Load this collection's layout first", "Rasterise with another ship's buffer") + require(buffer(bpy.context.scene) == before, "Rasterise replaced another ship's buffer") + + # A failed Save leaves what the author had in a Mods/ folder that existed before it; the retry succeeds. + (MODS / GUARD_B).mkdir(parents=True) + keep = MODS / GUARD_B / "keep.txt" + keep.write_text("an author file") + select(hull_b) + real = aetheria_ships.replace_layout + + def failing(*args, **kwargs): + raise ValueError("probe: replace_layout failed") + + aetheria_ships.replace_layout = failing + try: + refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save with a failing write into an existing folder") + finally: + aetheria_ships.replace_layout = real + require(keep.is_file(), "A failed Save deleted a file the author had in the ship folder") + require(not Path(path_b).exists() and collection_b.get("aetheria.pending") is not None, "A failed Save left a half-made ship") + result, message = run(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"} and keep.is_file(), f"The retried Save was refused or took the author's file: {message}") + require(saved_layout(path_b, aetheria_ships)[3] == [], "Ship B's file holds hardpoints from ship A") + + +def undo_pass(): + scene = bpy.context.scene + collection = bpy.data.collections.new("Undo Ship") + scene.collection.children.link(collection) + hull = new_object(collection, "Undo Hull", box("Undo Hull", 1.0, 3.0, 0.5), (2, 1, 0)) + select(hull) + scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship" + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10 + + def frame(): + """The ship collection, its buffer, its grid placement and the hull's placement, read fresh: undo replaces the + data-blocks.""" + col, obj = bpy.data.collections["Undo Ship"], bpy.data.objects["Undo Hull"] + origin = col.get("aetheria.grid_origin") + return (col.get("aetheria.id") is not None, col.get("aetheria.pending") is not None, buffer(bpy.context.scene), + tuple(round(v, 4) for v in origin) if origin else None, + tuple(round(v, 4) for row in obj.matrix_world for v in row), tuple(round(v, 4) for v in obj.scale)) + + def native(operator): + """The operator as the UI calls it: its undo step is pushed when, and only when, it carries UNDO.""" + return run(lambda: operator("EXEC_DEFAULT", True)) + + def undoable(operator, what): + before = frame() + result, message = native(operator) + require(result == {"FINISHED"}, f"{what} was refused: {message}") + after = frame() + require(after != before, f"{what} changed nothing") + result, _ = run(bpy.ops.ed.undo) + require(result == {"FINISHED"} and frame() == before, f"{what} is not one undo step of its own (no UNDO)") + select(bpy.data.objects["Undo Hull"]) + + bpy.ops.ed.undo_push(message="start") + undoable(bpy.ops.aetheria.new_ship, "New Ship") + result, message = native(bpy.ops.aetheria.new_ship) + require(result == {"FINISHED"}, f"New Ship was refused: {message}") + select(bpy.data.objects["Undo Hull"]) + bpy.data.objects["Undo Hull"].scale = (0.5, 1, 1) + bpy.ops.ed.undo_push(message="Scale") # the step the UI pushes for the author's own edit + undoable(bpy.ops.aetheria.rasterise_hull, "Rasterise") + result, message = native(bpy.ops.aetheria.rasterise_hull) + require(result == {"FINISHED"}, f"Rasterise was refused: {message}") + select(bpy.data.objects["Undo Hull"]) + undoable(bpy.ops.aetheria.flip_nose, "Flip Nose") + + # Save pushes no step: one undo after it takes back the step before it, and the file stays. + result, message = native(bpy.ops.aetheria.flip_nose) + require(result == {"FINISHED"}, f"Flip Nose was refused: {message}") + select(bpy.data.objects["Undo Hull"]) + flipped = frame() + result, message = native(bpy.ops.aetheria.save_ship_layout) + require(result == {"FINISHED"}, f"Save was refused: {message}") + path = MODS / UNDO_SHIP / "ship.cc" + require(path.is_file(), "Save did not create the file") + result, _ = run(bpy.ops.ed.undo) + require(result == {"FINISHED"} and frame()[2:] != flipped[2:], "Save is undoable: one undo took back Save and not the step before it") + require(path.is_file(), "Undo changed the .cc") + + def _root_count(): return sum(1 for obj in bpy.data.objects if obj.get("aetheria.ship_root")) @@ -416,7 +557,8 @@ else: code = 0 finally: if not existed: - shutil.rmtree(MODS / SHIP_ID, ignore_errors=True) + for leftover in (SHIP_ID, *EXTRA_IDS): + shutil.rmtree(MODS / leftover, ignore_errors=True) if created_mods and not existed: shutil.rmtree(MODS, ignore_errors=True) sys.stdout.flush() From 47cc0c28db09a270d36e24ef7620b8054a4f5561 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:20:41 +0200 Subject: [PATCH 15/18] Add-on: an unparented Source mesh is loose; Package reports no library text One frame predicate: _loose_meshes no longer exempts Source, so a mesh Rasterise reads is a mesh Flip Nose turns (it turns Ship Root's descendants) or the operator refuses and selects it. Package: the glTF exporter's RuntimeError is printed to the console and reported as fixed text; the validator's verdict goes to the console and the panel reports accepted or refused with the anchor count only (no ship ID, no anchor ID). smoke_frame and smoke_package pin each, five hand mutants caught. Every commit builds (Python only). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 17 ++++++++--- tools/blender/tests/smoke_frame.py | 38 ++++++++++++++++++++++++ tools/blender/tests/smoke_package.py | 18 ++++++++--- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 433125ec4..7f2583fd2 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -455,6 +455,9 @@ def _export_glb(context, collection, filepath): bpy.ops.export_scene.gltf( filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True, export_extras=True, export_yup=True, export_apply=True) + except RuntimeError: # the exporter's text holds paths and a traceback + traceback.print_exc() + raise RuntimeError("The GLB export failed; its message is in the system console") from None finally: for obj in view_layer.objects: obj.select_set(obj in previous_selection) @@ -502,11 +505,13 @@ def _frame_meshes(collection): def _loose_meshes(collection): - """Meshes of a collection that has a Ship Root which sit outside Source and Generated and are not under the root.""" + """Meshes of a collection that has a Ship Root which are not under the root, Generated's apart. Source meshes count: + Rasterise reads them and Flip Nose turns only what hangs from Ship Root.""" root = _ship_root(collection) if root is None: return [] - derived = _derived_names(collection) + generated = _child(collection, GENERATED) + derived = {obj.name for obj in generated.all_objects} if generated else set() framed = {obj.name for obj in root.children_recursive} return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and obj.name not in framed] @@ -792,10 +797,14 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" self.report({"ERROR"}, scene.aetheria_package_report) return {"CANCELLED"} - scene.aetheria_package_report = message or f"validate exited {code}" + if message: + print(message) if code != 0: - self.report({"ERROR"}, f"Packaged {len(anchors)} anchors; the validator refused it: {scene.aetheria_package_report}") + scene.aetheria_package_report = (f"Packaged {len(anchors)} anchors; the validator refused the ship; " + "its message is in the system console") + self.report({"ERROR"}, scene.aetheria_package_report) return {"CANCELLED"} + scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship" self.report({"INFO"}, scene.aetheria_package_report) return {"FINISHED"} diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 79d9e3c06..b8ca7c959 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -358,6 +358,15 @@ def main(): require(row_count(after, height - 1) == aft and row_count(after, 0) == forward, "Flip Nose did not move the nub's cells from the aft rows to the forward rows") require({(width - 1 - x, height - 1 - y) for x, y in before} == after, "The flipped grid is not the turned grid") + # A Source mesh that is not parented to Ship Root is loose too: Rasterise would read it and Flip Nose would not turn it. + sphere = bpy.data.objects["Source Sphere"] + sphere.parent = None + for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise")): + select(render) + refused(operator, "1 meshes in the ship collection are not under Ship Root", f"{what} with an unparented Source mesh") + require(sphere.select_get(), f"{what} did not select the unparented Source mesh") + sphere.parent = root + select(render) print("PASS 5 loose meshes, rasterise and flip nose ok") # Pass 6: Package refuses an unsaved buffer, then the saved one validates; the GLB leaves Source and Grid out. @@ -387,6 +396,35 @@ def main(): print(f"GLB nodes: {sorted(n for n in names if n)}") require("Hull" in names and "Loose Nub" in names, "The GLB lacks the render meshes") require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid") + require("the validator accepted the ship" in report and not any(name in report for name in NAMES_NOT_ECHOED), + f"Package's report is not the fixed text: {report}") + # A library error is the console's, not the panel's: a directory where ship.glb goes fails the exporter. + glb = Path(path).with_name("ship.glb") + glb.unlink() + glb.mkdir() + try: + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + finally: + glb.rmdir() + require(result == {"CANCELLED"} and "The GLB export failed" in report, f"A failed GLB export was not refused: {report}") + require("Traceback" not in report and not any(name in report for name in NAMES_NOT_ECHOED), + f"A library error reached the report: {report}") + real_visual = aetheria_ships.replace_visual + + def unwritable(*args, **kwargs): + raise OSError(f"probe: cannot write {path}") + + aetheria_ships.replace_visual = unwritable + try: + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + finally: + aetheria_ships.replace_visual = real_visual + require(result == {"CANCELLED"} and report == "Package failed: Stopped by OSError; see the system console", + f"A library error of another type is not reported by its type alone: {report}") + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"}, f"Package after the failed export was refused: {scene.aetheria_package_report}") print("PASS 6 package ok") guards_pass(aetheria_ships) print("PASS 7 guards ok") diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index bf0ff4884..e61b5c09d 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -20,6 +20,8 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves. """ +import contextlib +import io import os import sys import tempfile @@ -117,14 +119,20 @@ def bind_cases(ship, path, mods): raise SystemExit("Bind case cross-drive: the stored path is not the absolute .cc path") +CONSOLE = [] # what the last package() sent to the system console + + def package(addon): # An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way. + console = io.StringIO() try: - result = bpy.ops.aetheria.package_ship() + with contextlib.redirect_stdout(console): + result = bpy.ops.aetheria.package_ship() except RuntimeError: result = {"CANCELLED"} + CONSOLE[:] = [console.getvalue()] report = bpy.context.scene.aetheria_package_report - print(f"PACKAGE {sorted(result)}: {report}") + print(f"PACKAGE {sorted(result)}: {report}{CONSOLE[0]}") return result, report @@ -180,8 +188,10 @@ def main(): ship.objects.unlink(disc) hollow = tagged(ship, "Thruster Empty", None, "thruster-emitter", "thruster", (0, -1, 0)) result, report = package(aetheria_ships) - if result != {"CANCELLED"} or "thruster-emitter anchor thruster needs a mesh" not in report: - raise SystemExit("Pass 2: an empty thruster node was not refused by name") + if result != {"CANCELLED"} or "the validator refused the ship" not in report or "thruster" in report or SHIP_ID in report: + raise SystemExit("Pass 2: an empty thruster node was not refused with the fixed text") + if "thruster-emitter anchor thruster needs a mesh" not in CONSOLE[0]: + raise SystemExit("Pass 2: the validator's message naming the anchor did not reach the system console") ship.objects.unlink(hollow) ship.objects.link(disc) From 9b78940766881af244cd782c4d2e6d0975e663ce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:23:51 +0200 Subject: [PATCH 16/18] smoke_frame: pin the eight refusal guards the smoke left unpinned Package refuses loose meshes and a pending ship; Package reads the .cc rows cut to the seven known members and only compares a buffer that belongs to the bound ship; Flip Nose turns the hull back when its Rasterise fails; New Ship refuses meshes in child collections; the Grid is not drawn from a buffer short of cells; Rasterise refuses a ship with no Ship Root. Eight hand mutants, all caught. Every commit builds (Python only). Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_frame.py | 69 +++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index b8ca7c959..9eb8e6a44 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -115,6 +115,10 @@ def row_count(cells, y): return sum(1 for _, cy in cells if cy == y) +def rounded(matrix): + return tuple(round(value, 4) for row in matrix for value in row) + + def digest(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() @@ -335,7 +339,8 @@ def main(): select(render) snapshot = (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]), [tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children]) - for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise")): + for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise"), + (bpy.ops.aetheria.package_ship, "Package")): refused(operator, "2 meshes in the ship collection are not under Ship Root", f"{what} with loose meshes") require(nub.select_get() and render.select_get(), f"{what} did not select the loose meshes") require(snapshot == (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]), @@ -352,6 +357,19 @@ def main(): aft, forward = row_count(before, 0), row_count(before, height - 1) print(f"RASTERISE {width}x{height} aft={aft} forward={forward}") require(aft > forward, "The nub's cells are not on the aft rows before the flip") + real_rasterise = aetheria_ships._rasterise + turned = [rounded(child_obj.matrix_world) for child_obj in root.children] + + def failing_rasterise(*args, **kwargs): + raise ValueError("probe: rasterise failed") + + aetheria_ships._rasterise = failing_rasterise + try: + refused(bpy.ops.aetheria.flip_nose, "probe: rasterise failed", "Flip Nose whose Rasterise fails") + finally: + aetheria_ships._rasterise = real_rasterise + require(turned == [rounded(child_obj.matrix_world) for child_obj in root.children], + "A Flip Nose whose Rasterise failed left the hull turned") result, message = run(bpy.ops.aetheria.flip_nose) require(result == {"FINISHED"}, f"Flip Nose was refused: {message}") _, _, after, _ = buffer(bpy.context.scene) @@ -398,6 +416,19 @@ def main(): require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid") require("the validator accepted the ship" in report and not any(name in report for name in NAMES_NOT_ECHOED), f"Package's report is not the fixed text: {report}") + # Members a later schema adds to a hardpoint row are not unsaved edits. + real_read_layout = aetheria_ships.read_layout + + def later_schema(*args, **kwargs): + shape, rows, revision = real_read_layout(*args, **kwargs) + return shape, [list(row) + [0] for row in rows], revision + + aetheria_ships.read_layout = later_schema + try: + result, message = run(bpy.ops.aetheria.package_ship) + finally: + aetheria_ships.read_layout = real_read_layout + require(result == {"FINISHED"}, f"A .cc whose rows carry a later schema's members read as unsaved edits: {message}") # A library error is the console's, not the panel's: a directory where ship.glb goes fails the exporter. glb = Path(path).with_name("ship.glb") glb.unlink() @@ -469,10 +500,27 @@ def guards_pass(aetheria_ships): require(Path(path_a).is_file() and digest(path_a) == saved, "A refused Save deleted or changed the author's ship.cc") del collection_a["aetheria.pending"] + # A collection with a mesh in a child collection is not made a ship. + child_host = bpy.data.collections.new("Guard Child") + scene.collection.children.link(child_host) + nested = bpy.data.collections.new("Guard Nested") + child_host.children.link(nested) + new_object(nested, "Guard Nested Mesh", box("Nested", 1.0, 1.0, 1.0)) + select(new_object(child_host, "Guard Child Hull", box("Child", 1.0, 3.0, 0.5))) + scene.aetheria_new_ship_id = "smoke.guard.c" + scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Child", "Djinni" + refused(bpy.ops.aetheria.new_ship, "must sit directly in it", "New Ship with a mesh in a child collection") + require("aetheria.id" not in child_host, "A refused New Ship bound the collection") + # Ship B takes none of ship A's hardpoints. collection_b, hull_b = ship_from_box(scene, "Guard B", GUARD_B, (0, 0, 0)) require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the previous ship's hardpoints in the buffer") + # A pending ship is not packaged, and nothing is written for it. + select(hull_b) + refused(bpy.ops.aetheria.package_ship, "Save Layout first", "Package of a pending ship") + require(not (MODS / GUARD_B).exists(), "Package wrote for a pending ship") + # A buffer that belongs to ship B is neither saved into ship A's file nor rasterised onto ship A. select(hull_a) refused(bpy.ops.aetheria.save_ship_layout, "Load this collection's layout before saving", "Save with another ship's buffer") @@ -502,6 +550,25 @@ def guards_pass(aetheria_ships): require(result == {"FINISHED"} and keep.is_file(), f"The retried Save was refused or took the author's file: {message}") require(saved_layout(path_b, aetheria_ships)[3] == [], "Ship B's file holds hardpoints from ship A") + # Another ship's unsaved buffer is no reason to refuse Package: ship A's own file is what it checks. + select(hull_a) + result, message = run(bpy.ops.aetheria.package_ship) + require("Save Layout first" not in message and "Save Layout first" not in scene.aetheria_package_report, + "Package read another ship's buffer as ship A's unsaved edits") + + # A buffer whose cells do not fill its size draws no Grid. + generated_b = child(collection_b, "Generated") + state = bpy.context.scene.aetheria_layout + require(any(obj.get("aetheria.grid") for obj in generated_b.objects), "Ship B has no Grid") + state.cells.remove(len(state.cells) - 1) + aetheria_ships._redraw_grid(collection_b, state) + require(not any(obj.get("aetheria.grid") for obj in generated_b.objects), "A Grid is drawn from a buffer that lacks cells") + + # Rasterise needs the Ship Root New Ship made. + select(hull_b) + bpy.data.objects.remove(next(obj for obj in collection_b.objects if obj.get("aetheria.ship_root"))) + refused(bpy.ops.aetheria.rasterise_hull, "has no Ship Root", "Rasterise without a Ship Root") + def undo_pass(): scene = bpy.context.scene From 02d9a5219e67dc277fae2e924c788009eb5b3a8e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:26:42 +0200 Subject: [PATCH 17/18] New Ship: the placement is the objects' matrix, so the cells are the hull the author sees New Ship baked rotation and scale into the mesh data after checking the cells on the evaluated mesh, so a Mirror modifier acted in a changed frame (a half hull, length 10, got 3x5 cells). The meshes now keep their data and carry the placement as their own matrix; the cells and grid_origin are the ones computed before the bind and are placed by the one _place_grid writer, which Rasterise shares; no call that can refuse follows the bind. The single-user refusal, which protected the baking, is deleted. One _triangles_xy reads the evaluated meshes for both New Ship and Rasterise. smoke_frame: a Mirror half hull gets the cells of the hand-mirrored solid, and the drafted cells equal Rasterise's of the hull left behind. Every commit builds (Python only). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 54 ++++++++++++------------ tools/blender/tests/smoke_frame.py | 44 ++++++++++++++++--- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 7f2583fd2..d5ed7465e 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -560,6 +560,16 @@ def _world_mesh(obj, depsgraph, matrix): return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3) +def _triangles_xy(depsgraph, placements): + """The top-down triangles of the evaluated meshes, each through its matrix: what the author sees from above. + placements is [(object, matrix)].""" + triangles = [] + for obj, matrix in placements: + points, indices = _world_mesh(obj, depsgraph, matrix) + triangles += points[indices][:, :, :2].tolist() + return triangles + + def _grid_current(collection, state): """Whether the Grid can be drawn: grid_origin was computed for the buffer's own size.""" origin = collection.get("aetheria.grid_origin") @@ -619,20 +629,21 @@ def _redraw_grid(collection, state): def _rasterise(context, collection, state): """Proposes the hull's cells from the frame meshes, top-down in Ship Root's frame, into the layout buffer (its - hardpoints stay) and stores the grid's placement with the size it was computed for. Writes no file. The only writer - of aetheria.grid_origin.""" + hardpoints stay) and stores the grid's placement with the size it was computed for. Writes no file.""" root, meshes = _frame_inputs(context, collection, state) - depsgraph = context.evaluated_depsgraph_get() to_root = root.matrix_world.inverted() - triangles = [] - for obj in meshes: - points, indices = _world_mesh(obj, depsgraph, to_root @ obj.matrix_world) - triangles += points[indices][:, :, :2].tolist() - width, height, cells, origin = hull_grid.rasterise(triangles) + width, height, cells, origin = hull_grid.rasterise(_triangles_xy( + context.evaluated_depsgraph_get(), [(obj, to_root @ obj.matrix_world) for obj in meshes])) + _place_grid(collection, state, width, height, cells, origin) + return width, height + + +def _place_grid(collection, state, width, height, cells, origin): + """Puts a rasterised hull into the layout buffer (its hardpoints stay), stores the grid's placement with the size it + was computed for and redraws the Grid. The only writer of aetheria.grid_origin.""" _set_cells(state, width, height, cells) collection["aetheria.grid_origin"] = [float(origin[0]), float(origin[1]), width, height] _redraw_grid(collection, state) - return width, height class AETHERIA_OT_rasterise_hull(bpy.types.Operator): @@ -678,8 +689,10 @@ class AETHERIA_OT_flip_nose(bpy.types.Operator): def _new_ship(context, ship_id, name, reference, length): """Sets the active collection up as a pending ship: binds it, moves its meshes into Source under a Ship Root, turns - them nose to -Y, sizes them to length cells, fills the layout buffer and rasterises. Every check comes first and - nothing is written to disk: the first Save creates the file.""" + them nose to -Y, sizes them to length cells, fills the layout buffer and draws the Grid. The meshes keep their data: + the placement is the objects' own matrix, so modifiers act as the author saw them and the cells are the ones checked + here. Every check and the rasterising come first; after the bind nothing can refuse. Nothing is written to disk: the + first Save creates the file.""" if not valid_ship_id(ship_id): raise ValueError("The ship ID must use lower-case letters, digits, dots, underscores or hyphens, must not end " "in a dot, and must not be a Windows device name") @@ -696,8 +709,6 @@ def _new_ship(context, ship_id, name, reference, length): raise ValueError("The ship collection holds no mesh objects to make a ship from") if len(_meshes(collection.all_objects)) != len(meshes): raise ValueError("The ship collection's meshes must sit directly in it, not in child collections") - if any(obj.data.users > 1 for obj in meshes): - raise ValueError("Make the hull meshes single-user before New Ship (Object > Relations > Make Single User)") worlds = {obj: obj.matrix_world.copy() for obj in meshes} depsgraph = context.evaluated_depsgraph_get() points = numpy.vstack([_world_mesh(obj, depsgraph, worlds[obj])[0] for obj in meshes]) @@ -710,11 +721,7 @@ def _new_ship(context, ship_id, name, reference, length): if extent_x > extent_y: transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z") placed = {obj: transform @ worlds[obj] for obj in meshes} - triangles = [] - for obj in meshes: - placed_points, indices = _world_mesh(obj, depsgraph, placed[obj]) - triangles += placed_points[indices][:, :, :2].tolist() - width, height, cells, _ = hull_grid.rasterise(triangles) + width, height, cells, origin = hull_grid.rasterise(_triangles_xy(depsgraph, [(obj, placed[obj]) for obj in meshes])) _bind_collection(collection, str(path), ship_id, [name, reference]) source = _child_or_new(collection, SOURCE, SOURCE) root = bpy.data.objects.new(SHIP_ROOT, None) @@ -723,22 +730,15 @@ def _new_ship(context, ship_id, name, reference, length): for obj in meshes: collection.objects.unlink(obj) source.objects.link(obj) - obj.data.transform(placed[obj].to_3x3().to_4x4()) - if placed[obj].determinant() < 0: - obj.data.flip_normals() obj.parent = root obj.matrix_parent_inverse = Matrix.Identity(4) - obj.rotation_mode = "XYZ" - obj.rotation_euler = (0.0, 0.0, 0.0) - obj.scale = (1.0, 1.0, 1.0) - obj.location = placed[obj].to_translation() + obj.matrix_world = placed[obj] state = context.scene.aetheria_layout state.ship_id = ship_id state.ship_cc = bpy.path.abspath(collection["aetheria.ship_cc"]) state.revision = "" state.hardpoints.clear() - _set_cells(state, width, height, cells) - _rasterise(context, collection, state) + _place_grid(collection, state, width, height, cells, origin) class AETHERIA_OT_new_ship(bpy.types.Operator): diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 9eb8e6a44..8672aa1b2 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -213,9 +213,10 @@ def main(): source = child(collection, "Source") require(source is not None and sphere.users_collection == (source,), "The sphere is not in Source alone") require(sphere.parent == root, "The sphere is not parented to Ship Root") - require(tuple(sphere.scale) == (1, 1, 1) and all(abs(a) < 1e-9 for a in sphere.rotation_euler), - "Rotation and scale were not applied to the sphere") - dx, dy, _ = sphere.dimensions + require(max(vertex.co.length for vertex in sphere.data.vertices) < 0.5 + 1e-6 and sphere.scale.x > 1, + "New Ship baked the placement into the mesh instead of the object's own matrix") + world = [sphere.matrix_world @ vertex.co for vertex in sphere.data.vertices] + dx, dy = (max(v[i] for v in world) - min(v[i] for v in world) for i in (0, 1)) print(f"DIMENSIONS {dx:.3f} x {dy:.3f}") require(abs(dy - 20.0) < 0.4 and dx < dy, f"The sphere's long extent is not 20 m on Y: {dx} x {dy}") width, height, cells, hardpoints = buffer(scene) @@ -247,6 +248,7 @@ def main(): result, message = new_ship(scene) require(result == {"FINISHED"}, f"New Ship after undo was refused: {message}") root = bpy.data.objects["Ship Root"] + placed_scale, placed_rotation = tuple(sphere.scale), tuple(sphere.rotation_euler) print("PASS 1 new ship ok") # Pass 2: a Save that fails in create or after it leaves nothing behind and tells the author nothing of the input; @@ -284,7 +286,7 @@ def main(): before_origin = list(collection["aetheria.grid_origin"]) select(bpy.data.objects["Source Sphere"]) bpy.ops.ed.undo_push(message="before scale") - bpy.data.objects["Source Sphere"].scale = (0.5, 1, 1) + bpy.data.objects["Source Sphere"].scale = (placed_scale[0] / 2, placed_scale[1], placed_scale[2]) result, message = run(bpy.ops.aetheria.rasterise_hull) require(result == {"FINISHED"}, f"Rasterise was refused: {message}") after = buffer(scene) @@ -332,9 +334,10 @@ def main(): collection = bpy.data.collections[COLLECTION] root = bpy.data.objects["Ship Root"] sphere = bpy.data.objects["Source Sphere"] - sphere.scale = (1, 1, 1) + sphere.scale = placed_scale centre = sphere.matrix_world.translation render = new_object(collection, "Hull", bpy.data.meshes["Sphere"].copy(), location=tuple(sphere.location)) + render.scale, render.rotation_euler = placed_scale, placed_rotation nub = new_object(collection, "Loose Nub", box("Nub", 4.0, 3.0, 0.5), location=(centre.x, centre.y + 12.0, 0)) select(render) snapshot = (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]), @@ -457,6 +460,8 @@ def main(): result, message = run(bpy.ops.aetheria.package_ship) require(result == {"FINISHED"}, f"Package after the failed export was refused: {scene.aetheria_package_report}") print("PASS 6 package ok") + mirror_pass() + print("PASS 6b mirror ok") guards_pass(aetheria_ships) print("PASS 7 guards ok") undo_pass() @@ -475,6 +480,35 @@ def ship_from_box(scene, name, ship_id, location): return collection, hull +def mirror_pass(): + """A half hull with a Mirror modifier, its long axis on X, gets the cells of the hull the author sees: the same + cells as the hand-mirrored solid, 10 long.""" + scene = bpy.context.scene + + def ship_cells(name, corners_y, modifier): + collection = bpy.data.collections.new(name) + scene.collection.children.link(collection) + mesh = bpy.data.meshes.new(name) + corners = [(x, y, z) for x in (0.0, 6.0) for y in corners_y for z in (-0.5, 0.5)] + mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]) + hull = new_object(collection, name + " Hull", mesh) + if modifier: + hull.modifiers.new("Mirror", "MIRROR").use_axis = (False, True, False) + select(hull) + result, message = new_ship(scene, "smoke.mirror", name, "Djinni", 10) + require(result == {"FINISHED"}, f"New Ship for {name} was refused: {message}") + made = buffer(bpy.context.scene)[:3] + result, message = run(bpy.ops.aetheria.rasterise_hull) # reads the objects as New Ship left them + require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made, + f"{name}: the cells New Ship drafted are not the cells of the hull it left: {message}") + return made + + half = ship_cells("Mirror Half", (0.0, 1.0), True) + solid = ship_cells("Mirror Solid", (-1.0, 1.0), False) + require(half[1] == 10, f"The mirrored half hull is {half[1]} cells long, not 10") + require(half == solid, "A Mirror-modified half hull does not get the cells of the hand-mirrored solid") + + def guards_pass(aetheria_ships): scene = bpy.context.scene path_a, path_b = str(MODS / GUARD_A / "ship.cc"), str(MODS / GUARD_B / "ship.cc") From e058843a34c38e76ae43811ccc80e7e909fb3cf2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 12:56:58 +0200 Subject: [PATCH 18/18] Add-on frame fix 2: New Ship drafts the hull it leaves; Save, New Ship and undo-past-Save are pinned New Ship places each mesh by the matrix an object can hold (_held: location, rotation, scale), so a sheared parent no longer makes the drafted cells differ from the cells Rasterise reads from the hull left behind. smoke_frame pins a failed Save of a saved ship (write failure and stale revision) leaving its ship.cc, New Ship refusing a vertical wall before anything changes (twice), the sheared-parent equality, and Ctrl+Z past a first Save: the refusals name the way forward (Bind Ship Collection, Load Layout) and the route works. All commits build. Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 15 ++++- tools/blender/tests/smoke_frame.py | 79 +++++++++++++++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index d5ed7465e..1cf37fbd7 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -220,7 +220,8 @@ def _save_layout(context, collection, state): pending = collection.get("aetheria.pending") folder = Path(path).parent if pending and Path(path).exists(): - raise ValueError("A ship file already exists at the bound path; Bind it instead") + raise ValueError("A ship file already exists at the bound path (Ctrl+Z past a first Save leaves it there); " + "use Bind Ship Collection with that ship.cc, then Load Layout") made_folder = bool(pending) and not folder.exists() revision = state.revision try: @@ -560,6 +561,12 @@ def _world_mesh(obj, depsgraph, matrix): return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3) +def _held(matrix): + """The world matrix an object holds after matrix_world = matrix: Blender keeps location, rotation and scale, so a + shear (a non-uniform scaled parent under rotation) is dropped. The hull the author sees afterwards is this one.""" + return Matrix.LocRotScale(*matrix.decompose()) + + def _triangles_xy(depsgraph, placements): """The top-down triangles of the evaluated meshes, each through its matrix: what the author sees from above. placements is [(object, matrix)].""" @@ -700,7 +707,9 @@ def _new_ship(context, ship_id, name, reference, length): raise ValueError("Give the ship a display name and a reference hull") path = _game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc" if path.exists(): - raise ValueError("A ship with this ID already exists") + raise ValueError("A ship with this ID already exists under GameData/Mods (Ctrl+Z past a first Save leaves it " + "there); use Bind Ship Collection with its ship.cc, then Load Layout, or give the new ship " + "another ID") collection = _ship_collection(context, unbound_ok=True) if collection.get("aetheria.id") is not None or _ship_root(collection) is not None: raise ValueError("The collection is already a ship") @@ -720,7 +729,7 @@ def _new_ship(context, ship_id, name, reference, length): transform = Matrix.Diagonal((scale, scale, scale, 1.0)) if extent_x > extent_y: transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z") - placed = {obj: transform @ worlds[obj] for obj in meshes} + placed = {obj: _held(transform @ worlds[obj]) for obj in meshes} width, height, cells, origin = hull_grid.rasterise(_triangles_xy(depsgraph, [(obj, placed[obj]) for obj in meshes])) _bind_collection(collection, str(path), ship_id, [name, reference]) source = _child_or_new(collection, SOURCE, SOURCE) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 8672aa1b2..4fa26b9d7 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -462,6 +462,8 @@ def main(): print("PASS 6 package ok") mirror_pass() print("PASS 6b mirror ok") + shear_pass() + print("PASS 6c shear ok") guards_pass(aetheria_ships) print("PASS 7 guards ok") undo_pass() @@ -509,6 +511,27 @@ def mirror_pass(): require(half == solid, "A Mirror-modified half hull does not get the cells of the hand-mirrored solid") +def shear_pass(): + """A hull parented to a rotated, non-uniformly scaled empty has a sheared world matrix, which an object cannot hold: + New Ship drafts the cells of the hull it leaves, so Rasterise on that hull proposes the same cells.""" + scene = bpy.context.scene + collection = bpy.data.collections.new("Shear") + scene.collection.children.link(collection) + holder = bpy.data.objects.new("Shear Holder", None) + holder.rotation_euler = (0, 0, 0.7) + holder.scale = (3, 1, 1) + collection.objects.link(holder) + hull = new_object(collection, "Shear Hull", box("Shear", 1.0, 0.5, 0.1), parent=holder) + hull.rotation_euler = (0, 0, 0.5) + select(hull) + result, message = new_ship(scene, "smoke.shear", "Shear", "Djinni", 10) + require(result == {"FINISHED"}, f"New Ship under a sheared parent was refused: {message}") + made = buffer(bpy.context.scene)[:3] + result, message = run(bpy.ops.aetheria.rasterise_hull) + require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made, + f"The cells New Ship drafted under a sheared parent are not the cells of the hull it left: {message}") + + def guards_pass(aetheria_ships): scene = bpy.context.scene path_a, path_b = str(MODS / GUARD_A / "ship.cc"), str(MODS / GUARD_B / "ship.cc") @@ -530,10 +553,46 @@ def guards_pass(aetheria_ships): # A pending ship over an existing ship.cc (what Ctrl+Z past a first Save restores) is refused, and the file stays. saved = digest(path_a) collection_a["aetheria.pending"] = ["Guard A", "Djinni"] - refused(bpy.ops.aetheria.save_ship_layout, "A ship file already exists at the bound path", "Save of a pending ship over its file") + refused(bpy.ops.aetheria.save_ship_layout, "use Bind Ship Collection with that ship.cc, then Load Layout", "Save of a pending ship over its file") require(Path(path_a).is_file() and digest(path_a) == saved, "A refused Save deleted or changed the author's ship.cc") del collection_a["aetheria.pending"] + # A failed Save of a saved ship (a write that raises, then a .cc that changed since Load) leaves its real ship.cc. + real = aetheria_ships.replace_layout + + def failing(*args, **kwargs): + raise ValueError("probe: replace_layout failed") + + aetheria_ships.replace_layout = failing + try: + refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save of a saved ship with a failing write") + finally: + aetheria_ships.replace_layout = real + require(Path(path_a).is_file() and digest(path_a) == saved, "A failed Save deleted or changed a saved ship's ship.cc") + state = bpy.context.scene.aetheria_layout + revision, state.revision = state.revision, "changed outside Blender" + refused(bpy.ops.aetheria.save_ship_layout, "changed since Load", "Save with a stale revision") + state.revision = revision + require(Path(path_a).is_file() and digest(path_a) == saved, "A stale-revision Save deleted or changed the ship.cc") + + # New Ship checks everything, the rasterising included, before it changes the collection: a vertical wall has + # horizontal extent and no top-down area. + wall_collection = bpy.data.collections.new("Guard Wall") + scene.collection.children.link(wall_collection) + wall_mesh = bpy.data.meshes.new("Guard Wall") + wall_mesh.from_pydata([(-2, 0, -1), (2, 0, -1), (2, 0, 1), (-2, 0, 1)], [], [(0, 1, 2, 3)]) + wall = new_object(wall_collection, "Guard Wall Hull", wall_mesh) + select(wall) + scene.aetheria_new_ship_id = "smoke.guard.w" + scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Wall", "Djinni" + for attempt in ("first", "retry"): + refused(bpy.ops.aetheria.new_ship, "no triangle with area", f"New Ship on a wall ({attempt})") + require(not any(key.startswith("aetheria.") for key in wall_collection.keys()), "A refused New Ship bound the collection") + require(wall.parent is None and wall.users_collection == (wall_collection,) and not wall_collection.children + and not any(obj.get("aetheria.ship_root") for obj in wall_collection.objects), + "A refused New Ship moved the meshes or made a Ship Root") + select(hull_a) + # A collection with a mesh in a child collection is not made a ship. child_host = bpy.data.collections.new("Guard Child") scene.collection.children.link(child_host) @@ -662,6 +721,24 @@ def undo_pass(): require(result == {"FINISHED"} and frame()[2:] != flipped[2:], "Save is undoable: one undo took back Save and not the step before it") require(path.is_file(), "Undo changed the .cc") + # Ctrl+Z past the first Save leaves the .cc behind an unbound collection: New Ship with the ID is refused with the + # way forward, and following it (Bind, Load, Save) works. + for _ in range(12): + if not frame()[0]: + break + require(run(bpy.ops.ed.undo)[0] == {"FINISHED"}, "ed.undo past the first Save did not run") + require(not frame()[0] and path.is_file(), "Undo past the first Save did not unbind the collection or changed the .cc") + scene = bpy.context.scene + select(bpy.data.objects["Undo Hull"]) + scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship" + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10 + refused(bpy.ops.aetheria.new_ship, "use Bind Ship Collection with its ship.cc, then Load Layout", "New Ship over the ship the first Save made") + scene.aetheria_ship_cc_path = str(path) + for operator, what in ((bpy.ops.aetheria.bind_ship_collection, "Bind"), (bpy.ops.aetheria.load_ship_layout, "Load"), + (bpy.ops.aetheria.save_ship_layout, "Save")): + result, message = run(operator) + require(result == {"FINISHED"}, f"{what} on the way forward after undoing past the first Save was refused: {message}") + def _root_count(): return sum(1 for obj in bpy.data.objects if obj.get("aetheria.ship_root"))