Merge ships-addon-package-frame: Package recentres on the centre of mass and generates map icon, shield, tractor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6a8ecb9b8d
7 changed files with 443 additions and 31 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,71 @@ 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 anchor Package itself generated (marked aetheria.generated) and not marked aetheria.keep is discarded; anything
|
||||
else in Generated, a collider included, is the operator's and stays. 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 obj.get("aetheria.generated") 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
|
||||
obj["aetheria.generated"] = True
|
||||
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 +856,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 +902,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 +1014,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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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"
|
||||
PACKAGE_L_SHIP = "smoke.pellhull"
|
||||
PACKAGE_SOURCE_SHIP = "smoke.psource"
|
||||
EXTRA_IDS = (GUARD_A, GUARD_B, UNDO_SHIP, PACKAGE_FRAME_SHIP, PACKAGE_L_SHIP, PACKAGE_SOURCE_SHIP)
|
||||
NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", "NoSuchHullXYZ", str(GAME), GAME.name)
|
||||
|
||||
|
||||
|
|
@ -123,11 +131,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 +478,221 @@ def main():
|
|||
print("PASS 6c shear ok")
|
||||
guards_pass(aetheria_ships)
|
||||
print("PASS 7 guards ok")
|
||||
package_frame_pass(aetheria_ships)
|
||||
package_asymmetric_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(not any(obj.get("aetheria.role") == "hull-collider" for obj in collection.all_objects),
|
||||
"Package never generates a collider: the collection holds one")
|
||||
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")
|
||||
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")
|
||||
|
||||
# A flat hull still gets a shield: its scale is floored at 0.1 on the thin axis.
|
||||
render.scale.z = 0.01
|
||||
select(render)
|
||||
run(bpy.ops.aetheria.package_ship)
|
||||
shield = next(obj for obj in child(collection, "Generated").objects if obj.get("aetheria.role") == "shield")
|
||||
require(all(abs(a - b) < 1e-4 for a, b in zip(shield.scale, (1.15 * half[0], 1.15 * half[1], 0.1))),
|
||||
f"A flat hull's shield is not floored at 0.1: {tuple(shield.scale)}")
|
||||
render.scale.z = 1.0
|
||||
|
||||
# A render mesh with no outline from above refuses Package before it changes anything: Ship Root, displaced here so
|
||||
# that a recentre would move it, stays where it is and the generated anchors stay.
|
||||
root.location.x += 3.0
|
||||
bpy.context.view_layer.update()
|
||||
render.scale.x = 0.0
|
||||
select(render)
|
||||
unchanged = world_state()
|
||||
refused(bpy.ops.aetheria.package_ship, "no outline", "Package of a hull with no outline from above")
|
||||
require(world_state() == unchanged, "A refused Package moved Ship Root or changed the objects")
|
||||
render.scale.x = 1.0
|
||||
|
||||
# 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 package_asymmetric_pass(aetheria_ships):
|
||||
"""Where generation takes its centre and its meshes from, and what the stale sweep may delete."""
|
||||
scene = bpy.context.scene
|
||||
for ship_id in (PACKAGE_L_SHIP, PACKAGE_SOURCE_SHIP):
|
||||
require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first")
|
||||
|
||||
# An L-shaped render hull: its bounds centre (0, 4, 0.15) differs from the mean of its vertices (1, 2.5, 0.4) and
|
||||
# from Ship Root's origin, and Ship Root sits away from the world origin, so a centre taken from any of those lands
|
||||
# elsewhere. Generated map-icon, shield and tractor are in Ship Root's frame.
|
||||
collection, _ = ship_from_box(scene, "Frame L", PACKAGE_L_SHIP, (0, 0, 0))
|
||||
root = next(obj for obj in collection.objects if obj.get("aetheria.ship_root"))
|
||||
root.location = (7.0, -3.0, 0.0)
|
||||
bpy.context.view_layer.update()
|
||||
new_object(collection, "L Long", box("L Long", 3.0, 1.0, 1.0), (0, 0, 0), root)
|
||||
arm = new_object(collection, "L Arm", box("L Arm", 0.5, 4.0, 0.5), (2.0, 5.0, 0.8), root)
|
||||
select(arm)
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"}, f"Rasterise of the L hull was refused: {message}")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save of the L hull was refused: {message}")
|
||||
select(arm)
|
||||
run(bpy.ops.aetheria.package_ship)
|
||||
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)} for the L hull")
|
||||
tractor, shield = made["tractor"].location, made["shield"]
|
||||
require(all(abs(a - b) < 1e-3 for a, b in zip(tractor, (0.0, -1.0, 0.15))),
|
||||
f"The tractor is not at the L hull's bounds centre X and minimum Y: {tuple(tractor)}")
|
||||
require(all(abs(a - b) < 1e-3 for a, b in zip(shield.location, (0.0, 4.0, 0.15))) and
|
||||
all(abs(a - b) < 1e-3 for a, b in zip(shield.scale, (1.15 * 3.0, 1.15 * 5.0, 1.15 * 1.15))),
|
||||
f"The shield is not at the L hull's bounds centre: {tuple(shield.location)} {tuple(shield.scale)}")
|
||||
|
||||
# The author's hull collider is hers wherever she keeps it: Package's stale sweep removes only what Package made.
|
||||
own = new_object(collection, "L Collider", tetrahedron("L Collider"), parent=root)
|
||||
tag(own, "hull-collider", "collider")
|
||||
for holder in own.users_collection:
|
||||
holder.objects.unlink(own)
|
||||
generated.objects.link(own)
|
||||
select(arm)
|
||||
run(bpy.ops.aetheria.package_ship)
|
||||
require(bpy.data.objects.get("L Collider") is not None and own.name in generated.objects,
|
||||
"Package deleted the author's hull collider from Generated")
|
||||
require(not any(obj.get("aetheria.generated") for obj in generated.objects if obj.get("aetheria.role") == "hull-collider"),
|
||||
"Package marked the author's collider as its own")
|
||||
|
||||
# A ship with Source meshes and no render mesh: Package generates nothing; the Source fallback is Rasterise's.
|
||||
collection, source_hull = ship_from_box(scene, "Frame Source", PACKAGE_SOURCE_SHIP, (0, 0, 0))
|
||||
select(source_hull)
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"}, f"Rasterise of the Source-only ship was refused: {message}")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save of the Source-only ship was refused: {message}")
|
||||
select(source_hull)
|
||||
run(bpy.ops.aetheria.package_ship)
|
||||
require(not any("aetheria.role" in obj for obj in child(collection, "Generated").objects),
|
||||
"Package generated anchors from Source meshes")
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue