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()