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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 21:02:22 +02:00
commit 8d9acf269d
7 changed files with 360 additions and 31 deletions

View file

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

View file

@ -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 <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>";
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

View file

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

View file

@ -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

View file

@ -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("<II", data, 12)
require(kind == 0x4E4F534A, "The GLB's first chunk is not JSON")
return {node.get("name") for node in json.loads(data[20:20 + length])["nodes"]}
return json.loads(data[20:20 + length])["nodes"]
def glb_node_names(path):
return {node.get("name") for node in glb_nodes(path)}
def child(collection, kind):
@ -466,11 +476,142 @@ def main():
print("PASS 6c shear ok")
guards_pass(aetheria_ships)
print("PASS 7 guards ok")
package_frame_pass(aetheria_ships)
print("PASS 7b package frame ok")
undo_pass()
print("PASS 8 native undo ok")
print("SMOKE_FRAME_OK")
def package_frame_pass(aetheria_ships):
"""Package on a ship with a Ship Root: recentre on the saved Shape's centre of mass, the three generated anchors,
Keep, the author's own anchor, and no collider ever."""
from mathutils import Vector
scene = bpy.context.scene
path = str(MODS / PACKAGE_FRAME_SHIP / "ship.cc")
glb = Path(path).with_name("ship.glb")
require(not (MODS / PACKAGE_FRAME_SHIP).exists(), f"{MODS / PACKAGE_FRAME_SHIP} exists; remove it first")
collection, _ = ship_from_box(scene, "Frame Pack", PACKAGE_FRAME_SHIP, (0, 0, 0))
root = next(obj for obj in collection.objects if obj.get("aetheria.ship_root"))
# The Source box, after New Ship sized it to 10 cells, is 20 m long; the render box is 1/1.2 of it on each axis, so
# anything derived from Source instead of the render mesh lands elsewhere.
half = tuple(h / 1.2 for h in (10 / 3, 10.0, 5 / 3))
centre = (0.5, 1.0, 0.3)
render = new_object(collection, "Frame Render", box("Frame Render", *half), centre, root)
select(render)
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"}, f"Rasterise of the render box was refused: {message}")
state = scene.aetheria_layout
def save_l():
for index, cell in enumerate(state.cells): # column 0 and row 0 of the grid: an L, not its bounds' middle
cell.occupied = index // state.height == 0 or index % state.height == 0
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save of the L was refused: {message}")
def world_state():
return (tuple(map(tuple, root.matrix_world)), sorted(obj.name for obj in bpy.data.objects))
def centre_of_mass_world():
occupied = saved_layout(path, aetheria_ships)[2]
mean = (sum(x for x, _ in occupied) / len(occupied), sum(y for _, y in occupied) / len(occupied))
origin = collection["aetheria.grid_origin"]
local = aetheria_ships.hull_grid.cell_centre((origin[0], origin[1]), *mean)
return root.matrix_world @ Vector((local[0], local[1], 0.0))
# A layout resized after Rasterise and saved no longer matches the grid: refused, and nothing moved or made.
state.new_width = state.width + 1
bpy.ops.aetheria.resize_ship_layout()
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save of the resized layout was refused: {message}")
unchanged = world_state()
select(render)
refused(bpy.ops.aetheria.package_ship, "Rasterise again: the grid no longer matches the saved layout",
"Package with a stale grid")
require(world_state() == unchanged, "A refused Package moved Ship Root or made objects")
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"}, f"Rasterise after the resize was refused: {message}")
save_l()
# No collider in the collection: Package is refused by the validator, it has still recentred, and it made no collider.
root.rotation_euler.z = 0.3 # the point is taken through the root's matrix, not its location
bpy.context.view_layer.update()
select(render)
before = centre_of_mass_world()
require(abs(before.x) > 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)

View file

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

View file

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