From 8d9acf269dbfcfa0b997fa725bdc67bd32f54a09 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 21:02:22 +0200 Subject: [PATCH] Package recentres on the grid's centre of mass and generates map icon, shield, tractor; no collider AetherDb ship-authoring centre prints Shape.CenterOfMass; Package asks it and moves Ship Root so that point sits at the world origin, after regenerating the unowned map icon, shield and tractor from the render meshes (Keep leaves one alone). Package never makes a hull collider (ruling package-frame-defer-collider). smoke_package's bound-collection rollback cases now rebind to a different path so a rollback that restores keys but not values fails. C# not yet built when committed; the Blender smokes and Python unit tests pass. Co-Authored-By: Claude Opus 5.5 --- .../ShipModCatalogTests.cs | 21 +++ tools/AetherDb/ShipAuthoringCommands.cs | 10 +- tools/blender/aetheria_ships/__init__.py | 167 +++++++++++++++--- tools/blender/aetheria_ships/hull_grid.py | 20 +++ tools/blender/tests/smoke_frame.py | 147 ++++++++++++++- tools/blender/tests/smoke_package.py | 11 +- tools/blender/tests/test_hull_grid.py | 15 ++ 7 files changed, 360 insertions(+), 31 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 14f2b70b2..7ba744cc9 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -452,6 +452,27 @@ public sealed class ShipModCatalogTests : IDisposable Assert.True(File.Exists(target), error); } + [Fact] + public void CentreCommandPrintsShapeCenterOfMass() + { + // An L: its centre of mass (1.2, 0.2) is not the bounds centre (1.5, 0.5). + var shape = new Shape(4, 2); + foreach (var (x, y) in new[] { (0, 0), (1, 0), (2, 0), (3, 0), (0, 1) }) shape.Cells[x, y] = true; + var target = Path.Combine(_directory.Path, "GameData", "Mods", "probe.l", "ship.cc"); + Assert.Equal(0, Run("create", target, "probe.l", "Probe L").Code); + var (hull, ship) = ShipAuthoringStore.Load(target); + hull.Shape = shape; + using (var cache = ShipAuthoringStore.Open(target, writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + var (code, output, error) = Run("centre", target); + Assert.Equal(0, code); + Assert.Equal("1.2 0.2", output.Trim()); + Assert.Equal("", error); + } + private static (int Code, string Output, string Error) Run(params string[] args) { var (output, error) = (new StringWriter(), new StringWriter()); diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index 097fe1add..60af9b39c 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -1,13 +1,14 @@ using GameCult.Caching; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | validate | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | compose "; public static int Run(string[] args) { @@ -31,6 +32,13 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: {hull.Name}, {hull.Hardpoints?.Count ?? 0} hardpoints, {ship.SchematicLines?.Count ?? 0} lines, {ship.SchematicLines?.Sum(line => line.Points?.Length / 3 ?? 0) ?? 0} points"); return 0; } + if (args[0] == "centre" && args.Length == 2) + { + // The one centre of mass Blender places a ship by: Shape.CenterOfMass in cell coordinates, as "x y". + var centre = ShipAuthoringStore.Load(args[1]).Hull.Shape.CenterOfMass; + Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{centre.x:R} {centre.y:R}")); + return 0; + } if (args[0] == "validate" && args.Length == 2) { // The package's GLB is judged too once it exists (node ids, mesh roles); a draft without one is judged on diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index ccf319022..43884e2bc 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -19,7 +19,7 @@ from pathlib import Path import bpy import numpy -from mathutils import Matrix +from mathutils import Matrix, Vector from . import hull_grid from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, @@ -28,7 +28,7 @@ from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, c 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 +GENERATED = "Generated" # child collection: derived objects, the Grid (never exported) and the anchors Package makes (exported) SHIP_ROOT = "Ship Root" AETHERDB_TIMEOUT = 120 # seconds @@ -446,8 +446,8 @@ def _anchors(collection): def _export_glb(context, collection, filepath): - """Exports the collection's objects, Grease Pencil and the Source and Generated children excluded, with custom - properties as node extras.""" + """Exports the collection's objects with custom properties as node extras: Generated's anchors are exported; Grease + Pencil, Source and the Grid are not.""" view_layer = context.view_layer layer = _layer_collection(view_layer.layer_collection, collection) if layer is None: @@ -492,9 +492,9 @@ def _child_or_new(collection, kind, name): def _derived_names(collection): - """Names of the objects in the Source and Generated children, which are never exported.""" + """Names of the objects that are never exported: Source's, and Generated's that carry no aetheria.role (the Grid).""" return {obj.name for kind in (SOURCE, GENERATED) for child in [_child(collection, kind)] if child - for obj in child.all_objects} + for obj in child.all_objects if kind == SOURCE or "aetheria.role" not in obj} def _ship_root(collection): @@ -505,14 +505,17 @@ def _meshes(objects): return [obj for obj in objects if obj.type == "MESH"] -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.""" +def _own_meshes(collection): + """The ship's own render meshes: Ship Root's descendants outside Source and Generated that carry no aetheria.role.""" derived = _derived_names(collection) - own = [obj for obj in _meshes(_ship_root(collection).children_recursive) - if obj.name not in derived and "aetheria.role" not in obj] + return [obj for obj in _meshes(_ship_root(collection).children_recursive) + if obj.name not in derived and "aetheria.role" not in obj] + + +def _frame_meshes(collection): + """The meshes Rasterise reads: the ship's own, the Source meshes while there are none.""" source = _child(collection, SOURCE) - return own or (_meshes(source.all_objects) if source else []) + return _own_meshes(collection) or (_meshes(source.all_objects) if source else []) def _loose_meshes(collection): @@ -594,6 +597,24 @@ def _grid_current(collection, state): and len(state.cells) == state.width * state.height) +def _discard_object(obj): + """Removes obj, then its mesh when nothing else uses it.""" + mesh = obj.data if obj.type == "MESH" else None + bpy.data.objects.remove(obj) + if mesh is not None and mesh.users == 0: + bpy.data.meshes.remove(mesh) + + +def _attach_to_root(obj, root): + """Parents obj to root with an identity parent inverse and a zero local transform: its local frame is Ship Root's.""" + obj.parent = root + obj.matrix_parent_inverse = Matrix.Identity(4) + obj.location = (0.0, 0.0, 0.0) + obj.rotation_mode = "XYZ" + obj.rotation_euler = (0.0, 0.0, 0.0) + obj.scale = (1.0, 1.0, 1.0) + + def _redraw_grid(collection, state): """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 @@ -606,10 +627,7 @@ def _redraw_grid(collection, state): 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) + _discard_object(grid) return origin = (float(origin[0]), float(origin[1])) half = hull_grid.CELL_SIZE / 2 @@ -635,12 +653,7 @@ def _redraw_grid(collection, state): 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) + _attach_to_root(grid, root) grid.display_type = "WIRE" @@ -663,6 +676,69 @@ def _place_grid(collection, state, width, height, cells, origin): _redraw_grid(collection, state) +GENERATED_ROLES = (("map-icon", "map", "Map Icon"), ("shield", "shield", "Shield"), ("tractor", "tractor", "Tractor")) + + +def _recentre(root, point): + """Moves Ship Root, and the ship with it, so that its local point (x, y) sits at world X = Y = 0.""" + world = root.matrix_world @ Vector((point[0], point[1], 0.0)) + moved = root.matrix_world.copy() + moved.translation.x -= world.x + moved.translation.y -= world.y + root.matrix_world = moved + + +def _regenerate_anchors(context, collection, root): + """Rebuilds Generated's map-icon, shield and tractor from the render meshes (_own_meshes) in Ship Root's frame: + every Generated anchor not marked aetheria.keep is discarded, then each of the three roles no other object in the + collection carries is made again. The operator's own object, or a kept one, owns its role. Everything that can + refuse comes before the first change.""" + generated = _child_or_new(collection, GENERATED, GENERATED) + stale = [obj for obj in generated.all_objects if "aetheria.role" in obj and not obj.get("aetheria.keep")] + stale_names = {obj.name for obj in stale} + held = {obj["aetheria.role"] for obj in collection.all_objects + if "aetheria.role" in obj and obj.name not in stale_names} + meshes = _own_meshes(collection) + missing = [entry for entry in GENERATED_ROLES if meshes and entry[0] not in held] + made = [] + if missing: + depsgraph = context.evaluated_depsgraph_get() + to_root = root.matrix_world.inverted() + points = numpy.vstack([_world_mesh(obj, depsgraph, to_root @ obj.matrix_world)[0] for obj in meshes]) + if not len(points): + raise ValueError("The render meshes have no points to derive the map icon, shield and tractor from") + low, high = points.min(axis=0), points.max(axis=0) + centre, half = (low + high) / 2, (high - low) / 2 + for role, anchor_id, name in missing: + if role == "map-icon": + outline = hull_grid.convex_outline(points[:, :2].tolist()) + mesh = bpy.data.meshes.new(name) + mesh.from_pydata([(x, y, float(centre[2])) for x, y in outline], [], [tuple(range(len(outline)))]) + mesh.update() + made.append((role, anchor_id, bpy.data.objects.new(name, mesh), None, None)) + elif role == "shield": + empty = bpy.data.objects.new(name, None) + empty.empty_display_type = "SPHERE" + made.append((role, anchor_id, empty, tuple(float(v) for v in centre), + [max(1.15 * float(h), 0.1) for h in half])) + else: + empty = bpy.data.objects.new(name, None) + empty.empty_display_type = "ARROWS" + made.append((role, anchor_id, empty, (float(centre[0]), float(low[1]), float(centre[2])), None)) + for obj in stale: + _discard_object(obj) + for role, anchor_id, obj, at, scale in made: + obj["aetheria.role"] = role + obj["aetheria.id"] = anchor_id + obj["aetheria.keep"] = False + generated.objects.link(obj) + _attach_to_root(obj, root) + if at is not None: + obj.location = at + if scale is not None: + obj.scale = scale + + class AETHERIA_OT_rasterise_hull(bpy.types.Operator): bl_idname = "aetheria.rasterise_hull" bl_label = "Rasterise Hull" @@ -778,11 +854,40 @@ class AETHERIA_OT_new_ship(bpy.types.Operator): return _refuse(self, exc) +def _ship_centre(context, path): + """The saved Shape's centre of mass in cell coordinates, from AetherDb: C# owns it.""" + code, output = _aetherdb(context, "centre", path, ship_cc=path) + try: + parts = output.split() + if code != 0 or len(parts) != 2: + raise ValueError + centre = (float(parts[0]), float(parts[1])) + if not all(map(math.isfinite, centre)): + raise ValueError + except ValueError: + print(output) + raise RuntimeError("AetherDb could not report the ship centre; its message is in the system console") from None + return centre + + +def _frame_ship(context, collection, root, path, saved_shape): + """Package's frame step for a ship with a Ship Root: the grid must match the saved layout, then the anchors are + regenerated and Ship Root is moved last, so that the saved Shape's centre of mass sits at the world origin. The + anchors are read from the children's world matrices, so they are made before the move.""" + origin = collection.get("aetheria.grid_origin") + if origin is None or len(origin) != 4 or (int(origin[2]), int(origin[3])) != (saved_shape[0], saved_shape[1]): + raise ValueError("Rasterise again: the grid no longer matches the saved layout") + centre = _ship_centre(context, path) + _regenerate_anchors(context, collection, root) + _recentre(root, hull_grid.cell_centre((float(origin[0]), float(origin[1])), centre[0], centre[1])) + + class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" - bl_description = ("Write the anchors and ship.glb from the bound collection, capture its LineArt, " - "and run AetherDb's validator") + bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and " + "tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and " + "run AetherDb's validator") bl_options = {"REGISTER"} def execute(self, context): @@ -795,14 +900,17 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): cultlib = _brokkr_cultlib(context) ship_id = collection["aetheria.id"] state = scene.aetheria_layout + saved_shape, saved_rows, _ = read_layout(path, cultlib) 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") + root = _ship_root(collection) + if root is not None: + _frame_ship(context, collection, root, path, saved_shape) anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) @@ -904,6 +1012,15 @@ class AETHERIA_PT_ship(bpy.types.Panel): def _draw_package(context, layout): layout.separator() layout.operator("aetheria.package_ship", icon="PACKAGE") + obj = context.active_object + if obj is not None and "aetheria.role" in obj: + try: + generated = _child(_ship_collection(context), GENERATED) + except ValueError: + generated = None + if generated is not None and obj.name in generated.all_objects: + layout.prop(obj, '["aetheria.keep"]', text="Keep") + layout.label(text="Generated: Package rebuilds it unless Kept") report = context.scene.aetheria_package_report if report: box = layout.box() diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index 825ccea45..fce193892 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -83,3 +83,23 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=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 + + +def convex_outline(points_xy): + """The convex hull of 2D points, counter-clockwise from the lowest-then-leftmost point (monotone chain). Duplicate + and interior points drop out. Fewer than three non-collinear points have no outline: ValueError.""" + points = sorted({(float(x), float(y)) for x, y in points_xy}) + + def half(sequence): + chain = [] + for point in sequence: + while len(chain) >= 2 and ((chain[-1][0] - chain[-2][0]) * (point[1] - chain[-2][1]) + - (chain[-1][1] - chain[-2][1]) * (point[0] - chain[-2][0])) <= 0: + chain.pop() + chain.append(point) + return chain[:-1] + + outline = half(points) + half(reversed(points)) + if len(outline) < 3: + raise ValueError("The points have no outline: they are fewer than three or collinear") + return outline diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index 4fa26b9d7..49bb0de2d 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -19,6 +19,11 @@ Save leaves the files that were in the folder before, New Ship takes none of the 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. + +Pass 7b pins what Package does to a ship with a Ship Root: it puts the saved Shape's centre of mass at the world origin +(through AetherDb 'centre'), makes the map icon, shield and tractor nobody owns from the render meshes, and never makes a +hull collider. A stale grid is refused; a kept anchor survives a second Package and an unkept one is rebuilt; an anchor +the author made takes the place of its generated twin. """ import hashlib @@ -42,7 +47,8 @@ GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLD 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) +PACKAGE_FRAME_SHIP = "smoke.pframe" +EXTRA_IDS = (GUARD_A, GUARD_B, UNDO_SHIP, PACKAGE_FRAME_SHIP) NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", "NoSuchHullXYZ", str(GAME), GAME.name) @@ -123,11 +129,15 @@ def digest(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() -def glb_node_names(path): +def glb_nodes(path): data = Path(path).read_bytes() length, kind = struct.unpack_from(" 0.5 or abs(before.y) > 0.5, "The fixture's centre of mass starts at the origin") + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"CANCELLED"} and "the validator refused the ship" in report, f"Package without a collider: {report}") + after = centre_of_mass_world() + require(abs(after.x) < 1e-3 and abs(after.y) < 1e-3, f"The centre of mass is not at the origin: {tuple(after)}") + code, output = aetheria_ships._aetherdb(bpy.context, "validate", path, ship_cc=path) + require(code != 0 and "hull-collider" in output, "The validator did not refuse a package without a hull-collider") + require(not any(obj.get("aetheria.role") == "hull-collider" for obj in collection.all_objects), + "Package never generates a collider: the collection holds one") + nodes = glb_nodes(glb) + require(not any((node.get("extras") or {}).get("aetheria.role") == "hull-collider" or "collider" in (node.get("name") or "").lower() + for node in nodes), "Package never generates a collider: the GLB holds one") + require("Grid" not in glb_node_names(glb), "The GLB holds the Grid") + + generated = child(collection, "Generated") + made = {obj["aetheria.role"]: obj for obj in generated.objects if "aetheria.role" in obj} + require(set(made) == {"map-icon", "shield", "tractor"}, f"Generated holds {sorted(made)} instead of the three anchors") + require(all(obj.parent == root and obj["aetheria.keep"] is False for obj in made.values()), + "A generated anchor is not a child of Ship Root with aetheria.keep False (as a Python bool)") + require({"map", "shield", "tractor"} <= {node.get("extras", {}).get("aetheria.id") for node in nodes}, + "The GLB lacks a generated anchor") + shield, tractor, icon = made["shield"], made["tractor"], made["map-icon"] + expected_scale = [max(1.15 * h, 0.1) for h in half] + require(all(abs(a - b) < 1e-4 for a, b in zip(shield.scale, expected_scale)) and + all(abs(a - b) < 1e-4 for a, b in zip(shield.location, centre)), + f"The shield is not 1.15 x the render half extents at its centre: {tuple(shield.scale)} {tuple(shield.location)}") + require(abs(tractor.location.x - centre[0]) < 0.01 and abs(tractor.location.y - (centre[1] - half[1])) < 1e-3 and + abs(tractor.location.z - centre[2]) < 1e-3, f"The tractor is not at the render bounds' stern: {tuple(tractor.location)}") + corners = [vertex.co for vertex in icon.data.vertices] + require(len(icon.data.polygons) == 1 and all( + abs(co.x - centre[0]) <= half[0] + 1e-4 and abs(co.y - centre[1]) <= half[1] + 1e-4 and abs(co.z - centre[2]) < 1e-4 + for co in corners), "The map icon is not one face within the render bounds") + + # A second Package changes nothing: it moves Ship Root by nothing and rebuilds the same anchors. + shape = lambda: sorted((obj["aetheria.role"], tuple(round(v, 6) for v in obj.location), tuple(round(v, 6) for v in obj.scale)) + for obj in child(collection, "Generated").objects if "aetheria.role" in obj) + anchors_before, translation = shape(), root.matrix_world.translation.copy() + select(render) + run(bpy.ops.aetheria.package_ship) + require((root.matrix_world.translation - translation).length < 1e-4 and shape() == anchors_before, + "A second Package moved Ship Root or changed the anchors") + + # Keep: a kept anchor is left where the author put it; an unkept one is rebuilt at its generated place. + generated = child(collection, "Generated") + made = {obj["aetheria.role"]: obj for obj in generated.objects if "aetheria.role" in obj} + made["tractor"]["aetheria.keep"] = True + made["tractor"].location.x += 5.0 + made["shield"].location.x += 5.0 + kept_at = tuple(made["tractor"].location) + select(render) + run(bpy.ops.aetheria.package_ship) + made = {obj["aetheria.role"]: obj for obj in child(collection, "Generated").objects if "aetheria.role" in obj} + require(tuple(made["tractor"].location) == kept_at, "Package rebuilt a kept anchor") + require(all(abs(a - b) < 1e-4 for a, b in zip(made["shield"].location, centre)), "Package did not rebuild an unkept anchor") + + # An anchor the author made owns its role: its generated twin goes and is not made again. + own = new_object(collection, "My Map Icon", box("My Map Icon", 0.5, 0.5, 0.01), (0, 0, 0), root) + tag(own, "map-icon", "hand.map") + select(render) + run(bpy.ops.aetheria.package_ship) + require(not any(obj.get("aetheria.role") == "map-icon" for obj in child(collection, "Generated").objects), + "A generated map icon stands beside the author's own") + + # With the collider the author made, the package passes; Generated still holds none. + tag(new_object(collection, "My Collider", tetrahedron("My Collider"), parent=root), "hull-collider", "collider") + select(render) + result, message = run(bpy.ops.aetheria.package_ship) + report = scene.aetheria_package_report + require(result == {"FINISHED"} and "the validator accepted the ship" in report, f"Package with a collider: {report}") + require(not any(obj.get("aetheria.role") == "hull-collider" for obj in child(collection, "Generated").objects), + "Package never generates a collider: Generated holds one") + + 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) diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index 726b34652..8ebafc843 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -115,8 +115,10 @@ def bind_cases(ship, path, mods): if result != {"FINISHED"} or not os.path.isabs(stored) or not same_file(stored, path): raise SystemExit("Bind case cross-drive-forced: the stored path is not the absolute .cc path") - # A value the ID-property store refuses leaves the collection exactly as it was, bound or not. + # A value the ID-property store refuses leaves the collection exactly as it was, bound or not. A bound collection is + # rebound to a different .cc, so a rollback that puts the keys back but not their values leaves the new path behind. import aetheria_ships + rebound = str(Path(path).with_name("rebound.cc")) for label, ship_id, pending in (("bad-id", object(), None), ("bad-pending", SHIP_ID, [object()])): for key in list(ship.keys()): if key.startswith("aetheria."): @@ -127,7 +129,7 @@ def bind_cases(ship, path, mods): before = {key: ship[key] for key in ship.keys() if key.startswith("aetheria.")} before = {key: (value.to_list() if hasattr(value, "to_list") else value) for key, value in before.items()} try: - aetheria_ships._bind_collection(ship, path, ship_id, pending) + aetheria_ships._bind_collection(ship, rebound if bound else path, ship_id, pending) except Exception: pass else: @@ -218,9 +220,14 @@ def main(): hardpoint("Thruster", 0, "thruster"), hardpoint("Radiator", 1, "radiator"), hardpoint("Energy", 2, "gun"), hardpoint("Reactor", 3, "reactor")]) + objects_before = sorted(obj.name for obj in bpy.data.objects) result, report = package(aetheria_ships) if result != {"FINISHED"}: raise SystemExit("Pass 1: the validator refused the primitive ship") + # A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing. + if sorted(obj.name for obj in bpy.data.objects) != objects_before or any( + child.get("aetheria.frame") for child in ship.children): + raise SystemExit("Pass 1: Package of a collection with no Ship Root created objects or a Generated collection") ship.objects.unlink(disc) hollow = tagged(ship, "Thruster Empty", None, "thruster-emitter", "thruster", (0, -1, 0)) diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index d9794e4ec..922c1fb49 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -166,5 +166,20 @@ class RasteriseTests(unittest.TestCase): self.assertEqual(cells, [True], line) +class ConvexOutlineTests(unittest.TestCase): + def test_a_square_with_interior_and_duplicate_points_is_its_four_corners_counter_clockwise(self): + points = [(2, 2), (0, 0), (1, 1), (2, 0), (0, 2), (1, 0), (0, 0), (1.5, 0.5), (2, 2)] + self.assertEqual(hull_grid.convex_outline(points), [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]) + + def test_a_concave_l_keeps_only_its_hull(self): + points = [(0, 0), (4, 0), (4, 1), (1, 1), (1, 4), (0, 4)] + self.assertEqual(hull_grid.convex_outline(points), [(0.0, 0.0), (4.0, 0.0), (4.0, 1.0), (1.0, 4.0), (0.0, 4.0)]) + + def test_fewer_than_three_non_collinear_points_have_no_outline(self): + for points in ([], [(1, 1)], [(0, 0), (1, 1)], [(0, 0), (1, 1), (2, 2), (3, 3)], [(1, 1), (1, 1), (1, 1)]): + with self.assertRaises(ValueError): + hull_grid.convex_outline(points) + + if __name__ == "__main__": unittest.main()