Aetheria/tools/blender/tests/smoke_frame.py
Meta Vens 429c651a2a Package: sweep only what it generated (aetheria.generated), pin bounds centre, Source-only and collider-in-Generated
Stale sweep identified generated anchors by collection membership and role, so an author's hull-collider in
Generated was deleted. Generated anchors now carry aetheria.generated; the sweep removes only those. New smoke
pass package_asymmetric_pass: an L-shaped render hull pins tractor X and shield centre to the bounds centre,
a Source-only ship pins that generation never reads the Source fallback, and a collider placed in Generated
survives Package. All commits build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:55:18 +02:00

1003 lines
56 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Headless Blender smoke for the add-on frame: New Ship, Rasterise, Flip Nose, Save, the Grid object, undo and Package.
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py
Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its
ModTools, no dotnet), and msgpack importable. The ship is created under <game folder>/GameData/Mods/smoke.frame, which
this smoke removes when it ends.
Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni: nothing
is written, ed.undo takes the ship back, and the first Save creates the file. Pass 2 makes a Save fail after create and
requires the folder gone and the retry to succeed. Pass 3 rasterises after a scale change: the .cc is unchanged until Save
and ed.undo returns the cells and the grid placement together. Pass 4 adds hardpoints and saves. Pass 5 puts meshes
outside Ship Root: Flip Nose and Rasterise refuse and change nothing; parented, Flip Nose moves the nub's cells from the
aft rows to the forward rows. Pass 6 packages: an unsaved buffer is refused, the saved one validates, and the GLB holds
neither the Source nor the Grid. Every refusal report names neither the ship ID, the collection, an object nor a path.
Pass 7 pins the guards that protect an author's files: a Save over an existing ship.cc is refused and leaves it, a failed
Save leaves the files that were in the folder before, New Ship takes none of the previous ship's hardpoints, and a buffer
that belongs to another ship is neither saved nor rasterised. Pass 8 calls the operators with undo=True, which pushes the
native undo step in background Blender only for an operator that carries UNDO: Rasterise, Flip Nose and New Ship are
undoable, Save is not.
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
import json
import os
import re
import shutil
import struct
import sys
import traceback
from pathlib import Path
import addon_utils
import bmesh
import bpy
REPO = Path(__file__).resolve().parents[3]
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
SHIP_ID = "smoke.frame"
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
MODS = GAME / "GameData" / "Mods"
COLLECTION = "Smoke Frame"
GUARD_A, GUARD_B, UNDO_SHIP = "smoke.guard.a", "smoke.guard.b", "smoke.undo"
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)
def new_object(collection, name, mesh, location=(0, 0, 0), parent=None):
obj = bpy.data.objects.new(name, mesh)
obj.location = location
obj.parent = parent
collection.objects.link(obj)
return obj
def box(name, half_x, half_y, half_z):
mesh = bpy.data.meshes.new(name)
corners = [(sx * half_x, sy * half_y, sz * half_z) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]
mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)])
return mesh
def tetrahedron(name):
mesh = bpy.data.meshes.new(name)
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)])
return mesh
def tag(obj, role, anchor_id):
obj["aetheria.role"] = role
obj["aetheria.id"] = anchor_id
return obj
def run(operator):
"""The operator's result and, for an operator that reported an error, the report (bpy.ops raises it)."""
try:
return operator(), ""
except RuntimeError as exc:
print(f"OPERATOR error: {exc}")
return {"CANCELLED"}, str(exc)
def require(condition, message):
if not condition:
raise SystemExit(message)
def refused(operator, expected, what):
"""Requires a CANCELLED operator whose report holds expected and echoes none of NAMES_NOT_ECHOED."""
result, message = run(operator)
require(result == {"CANCELLED"}, f"{what} was not refused")
require(expected in message, f"{what}: the report is not the fixed text ({message!r})")
for name in NAMES_NOT_ECHOED:
require(name not in message, f"{what}: the report echoes an input ({name})")
return message
def saved_layout(path, aetheria_ships):
shape, hardpoints, revision = aetheria_ships.ship_cc.read_layout(path, PACKAGES)
width, height, cells = shape
return width, height, {(i // height, i % height) for i, cell in enumerate(cells) if cell}, hardpoints, revision
def buffer(scene):
"""The layout buffer as (width, height, occupied cells, hardpoint count)."""
state = scene.aetheria_layout
return (state.width, state.height,
{(i // state.height, i % state.height) for i, cell in enumerate(state.cells) if cell.occupied},
len(state.hardpoints))
def row_count(cells, y):
return sum(1 for _, cy in cells if cy == y)
def rounded(matrix):
return tuple(round(value, 4) for row in matrix for value in row)
def digest(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
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 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):
return next((c for c in collection.children if c.get("aetheria.frame") == kind), None)
def grid_faces():
grid = bpy.data.objects.get("Grid")
return len(grid.data.polygons) if grid else None
def select(obj):
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
def undo_to_before(step):
"""Undoes the last operator and returns the fresh scene and ship collection: memfile undo replaces the data-blocks,
so every Python reference from before is stale."""
result = bpy.ops.ed.undo()
require(result == {"FINISHED"}, f"ed.undo after {step} did not run")
return bpy.context.scene, bpy.data.collections[COLLECTION]
def new_ship(scene, ship_id=SHIP_ID, name="Smoke Frame", reference="Djinni", length=10):
scene.aetheria_new_ship_id = ship_id
scene.aetheria_new_ship_name = name
scene.aetheria_new_ship_like = reference
scene.aetheria_new_ship_length = length
return run(bpy.ops.aetheria.new_ship)
def main():
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
sys.path.insert(0, str(REPO / "tools" / "blender"))
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first")
path = str(MODS / SHIP_ID / "ship.cc")
scene = bpy.context.scene
collection = bpy.data.collections.new(COLLECTION)
scene.collection.children.link(collection)
sphere_mesh = bpy.data.meshes.new("Sphere")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5)
bm.to_mesh(sphere_mesh)
bm.free()
sphere = new_object(collection, "Source Sphere", sphere_mesh, location=(3, 0, 0))
sphere.scale = (1, 0.6, 0.4)
select(sphere)
# Refusals come first and change nothing: the report is fixed text.
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
preferences.game_folder = ""
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = SHIP_ID, "Smoke Frame", "Djinni"
refused(bpy.ops.aetheria.new_ship, "Game folder preference", "New Ship with no game folder and an unsaved .blend")
preferences.game_folder = str(GAME)
scene.aetheria_new_ship_id = "con"
scene.aetheria_new_ship_name = "Smoke Frame"
scene.aetheria_new_ship_like = "Djinni"
message = refused(bpy.ops.aetheria.new_ship, "Windows device name", "New Ship with the ID con")
require(not re.search(r"con", message), "The bad-ID report echoes the ID")
scene.aetheria_new_ship_id = SHIP_ID
scene.aetheria_new_ship_like = ""
refused(bpy.ops.aetheria.new_ship, "reference hull", "New Ship without a reference")
require("aetheria.id" not in collection and _root_count() == 0 and sphere.parent is None,
"A refused New Ship changed the collection")
# Pass 1: New Ship writes nothing; undo takes it back; the first Save creates the file.
bpy.ops.ed.undo_push(message="before New Ship")
result, message = new_ship(scene)
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
bpy.ops.ed.undo_push(message="New Ship") # operators called from a script push no undo step, as the UI would
require(not (MODS / SHIP_ID).exists(), "New Ship wrote to the game folder")
require(list(collection.get("aetheria.pending")) == ["Smoke Frame", "Djinni"], "The collection is not pending")
root = next((obj for obj in collection.objects if obj.get("aetheria.ship_root")), None)
require(root is not None and root.name == "Ship Root", "New Ship made no Ship Root")
require(tuple(root.scale) == (1, 1, 1), "Ship Root's scale is not 1")
source = child(collection, "Source")
require(source is not None and sphere.users_collection == (source,), "The sphere is not in Source alone")
require(sphere.parent == root, "The sphere is not parented to Ship Root")
require(max(vertex.co.length for vertex in sphere.data.vertices) < 0.5 + 1e-6 and sphere.scale.x > 1,
"New Ship baked the placement into the mesh instead of the object's own matrix")
world = [sphere.matrix_world @ vertex.co for vertex in sphere.data.vertices]
dx, dy = (max(v[i] for v in world) - min(v[i] for v in world) for i in (0, 1))
print(f"DIMENSIONS {dx:.3f} x {dy:.3f}")
require(abs(dy - 20.0) < 0.4 and dx < dy, f"The sphere's long extent is not 20 m on Y: {dx} x {dy}")
width, height, cells, hardpoints = buffer(scene)
require((width, height) == (6, 10) and hardpoints == 0, f"The buffer is {width}x{height} with {hardpoints} hardpoints")
require((0, 0) not in cells and (width // 2, height // 2) in cells, "The ellipse's cells are wrong")
require(not scene.aetheria_layout.revision, "A pending ship's buffer has a revision")
origin = collection.get("aetheria.grid_origin")
require(origin is not None and len(origin) == 4 and (origin[2], origin[3]) == (width, height),
"aetheria.grid_origin does not carry the grid's size")
centre = sphere.matrix_world.translation
grid_centre = (origin[0] - (width - 1), origin[1] - (height - 1))
require(abs(grid_centre[0] - centre.x) < 1e-3 and abs(grid_centre[1] - centre.y) < 1e-3,
f"The grid is not centred on the hull: {grid_centre} against {tuple(centre)[:2]}")
generated = child(collection, "Generated")
grid = bpy.data.objects.get("Grid")
require(generated is not None and grid is not None and grid.users_collection == (generated,),
"The Grid object is not in Generated")
require(grid.parent == root and grid.display_type == "WIRE", "The Grid is not a wire child of Ship Root")
require(len(grid.data.polygons) == len(cells) and len(grid.data.edges) == 4 * width * height,
"The Grid does not draw occupied cells as faces and every cell's edges")
refused(bpy.ops.aetheria.load_ship_layout, "Not saved yet", "Load on a pending ship")
scene, collection = undo_to_before("New Ship")
require("aetheria.id" not in collection and "aetheria.pending" not in collection, "Undo left the collection bound")
require(bpy.data.objects.get("Ship Root") is None and bpy.data.objects.get("Grid") is None, "Undo left Ship Root or the Grid")
sphere = bpy.data.objects["Source Sphere"]
require(sphere.users_collection == (collection,) and sphere.parent is None, "Undo did not return the sphere")
select(sphere)
result, message = new_ship(scene)
require(result == {"FINISHED"}, f"New Ship after undo was refused: {message}")
root = bpy.data.objects["Ship Root"]
placed_scale, placed_rotation = tuple(sphere.scale), tuple(sphere.rotation_euler)
print("PASS 1 new ship ok")
# Pass 2: a Save that fails in create or after it leaves nothing behind and tells the author nothing of the input;
# the retry succeeds.
collection["aetheria.pending"] = ["Smoke Frame", "NoSuchHullXYZ"]
refused(bpy.ops.aetheria.save_ship_layout, "AetherDb refused to create the ship", "Save with an unknown reference hull")
require(not (MODS / SHIP_ID).exists(), "A refused create left the ship folder behind")
collection["aetheria.pending"] = ["Smoke Frame", "Djinni"]
real = aetheria_ships.replace_layout
def failing(*args, **kwargs):
raise ValueError("probe: replace_layout failed")
aetheria_ships.replace_layout = failing
try:
message = refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save with a failing write")
finally:
aetheria_ships.replace_layout = real
require(not (MODS / SHIP_ID).exists(), "A failed first Save left the ship folder behind")
require(collection.get("aetheria.pending") is not None, "A failed first Save cleared aetheria.pending")
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"The retried Save was refused: {message}")
require(collection.get("aetheria.pending") is None, "Save left aetheria.pending set")
width, height, cells, hardpoints, revision = saved_layout(path, aetheria_ships)
require((width, height, cells) == buffer(scene)[:3], "The saved .cc is not the buffer")
require(scene.aetheria_layout.revision == revision, "The buffer did not record the saved revision")
require(aetheria_ships.ship_cc.read(path, PACKAGES).ship.body[0] == SHIP_ID, "The saved file is not the ship")
require(grid_faces() == len(cells), "Save did not redraw the Grid")
refused_existing = _second_collection_refused()
print("PASS 2 save creates and cleans up ok", refused_existing)
# Pass 3: Rasterise edits the buffer and the placement, never the file; undo returns both.
saved_digest = digest(path)
before = buffer(scene)
before_origin = list(collection["aetheria.grid_origin"])
select(bpy.data.objects["Source Sphere"])
bpy.ops.ed.undo_push(message="before scale")
bpy.data.objects["Source Sphere"].scale = (placed_scale[0] / 2, placed_scale[1], placed_scale[2])
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"}, f"Rasterise was refused: {message}")
after = buffer(scene)
after_origin = list(collection["aetheria.grid_origin"])
bpy.ops.ed.undo_push(message="Rasterise")
require(after[:2] != before[:2] and after_origin[2:] == list(after[:2]), f"Rasterise did not resize: {after[:2]}")
require(digest(path) == saved_digest, "Rasterise wrote the .cc")
scene, collection = undo_to_before("Rasterise")
require(buffer(scene) == before and list(collection["aetheria.grid_origin"]) == before_origin,
"Undo did not return the cells and the grid placement together")
require(grid_faces() == len(before[2]), "Undo left a Grid that does not draw the restored cells")
print("PASS 3 rasterise and undo ok")
# Pass 4: hardpoints in the buffer survive Rasterise and a second Save; a toggled cell changes the Grid.
select(bpy.data.objects["Source Sphere"])
for index, (mount, x, y) in enumerate((("mount.a", 1, 2), ("mount.b", 3, 4))):
bpy.ops.aetheria.add_ship_hardpoint()
hp = bpy.context.scene.aetheria_layout.hardpoints[index]
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, "5", x, y, "#" # Reactor: internal, no anchor
hp.armor, hp.firing_arc = 5.0, 90.0
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save with hardpoints was refused: {message}")
rows = saved_layout(path, aetheria_ships)[3]
require(len(rows) == 2, "The saved .cc does not hold two hardpoints")
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"} and buffer(bpy.context.scene)[3] == 2, "Rasterise dropped the buffer's hardpoints")
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"The second Save was refused: {message}")
require(saved_layout(path, aetheria_ships)[3] == rows, "The second Save changed the hardpoint rows")
scene = bpy.context.scene
faces = grid_faces()
state = scene.aetheria_layout
cell = next(c for c in state.cells if c.occupied)
cell.occupied = False
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"} and grid_faces() == faces - 1, "Toggling a cell and Save did not redraw the Grid")
state.new_width = state.width + 1 # a layout resized after the grid was placed: the placement no longer fits it
bpy.ops.aetheria.resize_ship_layout()
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"} and bpy.data.objects.get("Grid") is None,
"A Grid placed for another size is still drawn after the layout was resized")
print("PASS 4 hardpoints and save ok")
# Pass 5: meshes outside Ship Root stop Flip Nose and Rasterise; parented, the flip moves the nub's cells.
collection = bpy.data.collections[COLLECTION]
root = bpy.data.objects["Ship Root"]
sphere = bpy.data.objects["Source Sphere"]
sphere.scale = placed_scale
centre = sphere.matrix_world.translation
render = new_object(collection, "Hull", bpy.data.meshes["Sphere"].copy(), location=tuple(sphere.location))
render.scale, render.rotation_euler = placed_scale, placed_rotation
nub = new_object(collection, "Loose Nub", box("Nub", 4.0, 3.0, 0.5), location=(centre.x, centre.y + 12.0, 0))
select(render)
snapshot = (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]),
[tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children])
for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise"),
(bpy.ops.aetheria.package_ship, "Package")):
refused(operator, "2 meshes in the ship collection are not under Ship Root", f"{what} with loose meshes")
require(nub.select_get() and render.select_get(), f"{what} did not select the loose meshes")
require(snapshot == (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]),
[tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children]),
f"{what} changed the scene while refusing")
render.parent = root # Ship Root's descendants count, however deep: the nub hangs from the hull
render.matrix_parent_inverse = root.matrix_world.inverted()
nub.parent = render
nub.matrix_parent_inverse = render.matrix_world.inverted()
select(render)
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"}, f"Rasterise Hull was refused: {message}")
width, height, before, _ = buffer(bpy.context.scene)
aft, forward = row_count(before, 0), row_count(before, height - 1)
print(f"RASTERISE {width}x{height} aft={aft} forward={forward}")
require(aft > forward, "The nub's cells are not on the aft rows before the flip")
real_rasterise = aetheria_ships._rasterise
turned = [rounded(child_obj.matrix_world) for child_obj in root.children]
def failing_rasterise(*args, **kwargs):
raise ValueError("probe: rasterise failed")
aetheria_ships._rasterise = failing_rasterise
try:
refused(bpy.ops.aetheria.flip_nose, "probe: rasterise failed", "Flip Nose whose Rasterise fails")
finally:
aetheria_ships._rasterise = real_rasterise
require(turned == [rounded(child_obj.matrix_world) for child_obj in root.children],
"A Flip Nose whose Rasterise failed left the hull turned")
result, message = run(bpy.ops.aetheria.flip_nose)
require(result == {"FINISHED"}, f"Flip Nose was refused: {message}")
_, _, after, _ = buffer(bpy.context.scene)
require(row_count(after, height - 1) == aft and row_count(after, 0) == forward,
"Flip Nose did not move the nub's cells from the aft rows to the forward rows")
require({(width - 1 - x, height - 1 - y) for x, y in before} == after, "The flipped grid is not the turned grid")
# A Source mesh that is not parented to Ship Root is loose too: Rasterise would read it and Flip Nose would not turn it.
sphere = bpy.data.objects["Source Sphere"]
sphere.parent = None
for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise")):
select(render)
refused(operator, "1 meshes in the ship collection are not under Ship Root", f"{what} with an unparented Source mesh")
require(sphere.select_get(), f"{what} did not select the unparented Source mesh")
sphere.parent = root
select(render)
print("PASS 5 loose meshes, rasterise and flip nose ok")
# Pass 6: Package refuses an unsaved buffer, then the saved one validates; the GLB leaves Source and Grid out.
scene = bpy.context.scene
collection = bpy.data.collections[COLLECTION]
root = bpy.data.objects["Ship Root"]
tag(new_object(collection, "Map Icon", box("Map Icon", 0.5, 0.5, 0.01), (0, 0, -1), root), "map-icon", "map")
tag(new_object(collection, "Hull Collider", tetrahedron("Hull Collider"), parent=root), "hull-collider", "collider")
tag(new_object(collection, "Shield", None, parent=root), "shield", "shield")
tag(new_object(collection, "Tractor", None, (0, 2, 0), root), "tractor", "tractor")
select(bpy.data.objects["Hull"])
saved_digest = digest(path)
result, message = run(bpy.ops.aetheria.package_ship)
require(result == {"CANCELLED"} and "Save Layout first" in scene.aetheria_package_report,
f"Package took an unsaved buffer: {scene.aetheria_package_report}")
require(digest(path) == saved_digest and not Path(path).with_name("ship.glb").exists(),
"A refused Package wrote the .cc or the GLB")
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save before Package was refused: {message}")
preferences.game_folder = "" # the game folder is then found from the bound .cc
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
preferences.game_folder = str(GAME)
print(f"PACKAGE {sorted(result)}: {report}")
require(result == {"FINISHED"}, f"Package with an empty Game folder preference was refused: {report}")
names = glb_node_names(Path(path).with_name("ship.glb"))
print(f"GLB nodes: {sorted(n for n in names if n)}")
require("Hull" in names and "Loose Nub" in names, "The GLB lacks the render meshes")
require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid")
require("the validator accepted the ship" in report and not any(name in report for name in NAMES_NOT_ECHOED),
f"Package's report is not the fixed text: {report}")
# Members a later schema adds to a hardpoint row are not unsaved edits.
real_read_layout = aetheria_ships.read_layout
def later_schema(*args, **kwargs):
shape, rows, revision = real_read_layout(*args, **kwargs)
return shape, [list(row) + [0] for row in rows], revision
aetheria_ships.read_layout = later_schema
try:
result, message = run(bpy.ops.aetheria.package_ship)
finally:
aetheria_ships.read_layout = real_read_layout
require(result == {"FINISHED"}, f"A .cc whose rows carry a later schema's members read as unsaved edits: {message}")
# A library error is the console's, not the panel's: a directory where ship.glb goes fails the exporter.
glb = Path(path).with_name("ship.glb")
glb.unlink()
glb.mkdir()
try:
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
finally:
glb.rmdir()
require(result == {"CANCELLED"} and "The GLB export failed" in report, f"A failed GLB export was not refused: {report}")
require("Traceback" not in report and not any(name in report for name in NAMES_NOT_ECHOED),
f"A library error reached the report: {report}")
real_visual = aetheria_ships.replace_visual
def unwritable(*args, **kwargs):
raise OSError(f"probe: cannot write {path}")
aetheria_ships.replace_visual = unwritable
try:
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
finally:
aetheria_ships.replace_visual = real_visual
require(result == {"CANCELLED"} and report == "Package failed: Stopped by OSError; see the system console",
f"A library error of another type is not reported by its type alone: {report}")
result, message = run(bpy.ops.aetheria.package_ship)
require(result == {"FINISHED"}, f"Package after the failed export was refused: {scene.aetheria_package_report}")
print("PASS 6 package ok")
mirror_pass()
print("PASS 6b mirror ok")
shear_pass()
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)
scene.collection.children.link(collection)
hull = new_object(collection, name + " Hull", box(name, 1.0, 3.0, 0.5), location)
select(hull)
result, message = new_ship(scene, ship_id, name, "Djinni", 10)
require(result == {"FINISHED"}, f"New Ship for {ship_id} was refused: {message}")
return collection, hull
def mirror_pass():
"""A half hull with a Mirror modifier, its long axis on X, gets the cells of the hull the author sees: the same
cells as the hand-mirrored solid, 10 long."""
scene = bpy.context.scene
def ship_cells(name, corners_y, modifier):
collection = bpy.data.collections.new(name)
scene.collection.children.link(collection)
mesh = bpy.data.meshes.new(name)
corners = [(x, y, z) for x in (0.0, 6.0) for y in corners_y for z in (-0.5, 0.5)]
mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)])
hull = new_object(collection, name + " Hull", mesh)
if modifier:
hull.modifiers.new("Mirror", "MIRROR").use_axis = (False, True, False)
select(hull)
result, message = new_ship(scene, "smoke.mirror", name, "Djinni", 10)
require(result == {"FINISHED"}, f"New Ship for {name} was refused: {message}")
made = buffer(bpy.context.scene)[:3]
result, message = run(bpy.ops.aetheria.rasterise_hull) # reads the objects as New Ship left them
require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made,
f"{name}: the cells New Ship drafted are not the cells of the hull it left: {message}")
return made
half = ship_cells("Mirror Half", (0.0, 1.0), True)
solid = ship_cells("Mirror Solid", (-1.0, 1.0), False)
require(half[1] == 10, f"The mirrored half hull is {half[1]} cells long, not 10")
require(half == solid, "A Mirror-modified half hull does not get the cells of the hand-mirrored solid")
def shear_pass():
"""A hull parented to a rotated, non-uniformly scaled empty has a sheared world matrix, which an object cannot hold:
New Ship drafts the cells of the hull it leaves, so Rasterise on that hull proposes the same cells."""
scene = bpy.context.scene
collection = bpy.data.collections.new("Shear")
scene.collection.children.link(collection)
holder = bpy.data.objects.new("Shear Holder", None)
holder.rotation_euler = (0, 0, 0.7)
holder.scale = (3, 1, 1)
collection.objects.link(holder)
hull = new_object(collection, "Shear Hull", box("Shear", 1.0, 0.5, 0.1), parent=holder)
hull.rotation_euler = (0, 0, 0.5)
select(hull)
result, message = new_ship(scene, "smoke.shear", "Shear", "Djinni", 10)
require(result == {"FINISHED"}, f"New Ship under a sheared parent was refused: {message}")
made = buffer(bpy.context.scene)[:3]
result, message = run(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made,
f"The cells New Ship drafted under a sheared parent are not the cells of the hull it left: {message}")
def guards_pass(aetheria_ships):
scene = bpy.context.scene
path_a, path_b = str(MODS / GUARD_A / "ship.cc"), str(MODS / GUARD_B / "ship.cc")
for ship_id in EXTRA_IDS:
require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first")
# Ship A with two hardpoints in its buffer, saved.
collection_a, hull_a = ship_from_box(scene, "Guard A", GUARD_A, (2, 1, 0))
require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the hardpoints of the ship before it in the buffer")
for index, mount in enumerate(("mount.a", "mount.b")):
select(hull_a)
bpy.ops.aetheria.add_ship_hardpoint()
hp = bpy.context.scene.aetheria_layout.hardpoints[index]
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, "5", index + 1, 2, "#"
select(hull_a)
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"} and len(saved_layout(path_a, aetheria_ships)[3]) == 2, f"Ship A was not saved: {message}")
# A pending ship over an existing ship.cc (what Ctrl+Z past a first Save restores) is refused, and the file stays.
saved = digest(path_a)
collection_a["aetheria.pending"] = ["Guard A", "Djinni"]
refused(bpy.ops.aetheria.save_ship_layout, "use Bind Ship Collection with that ship.cc, then Load Layout", "Save of a pending ship over its file")
require(Path(path_a).is_file() and digest(path_a) == saved, "A refused Save deleted or changed the author's ship.cc")
del collection_a["aetheria.pending"]
# A failed Save of a saved ship (a write that raises, then a .cc that changed since Load) leaves its real ship.cc.
real = aetheria_ships.replace_layout
def failing(*args, **kwargs):
raise ValueError("probe: replace_layout failed")
aetheria_ships.replace_layout = failing
try:
refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save of a saved ship with a failing write")
finally:
aetheria_ships.replace_layout = real
require(Path(path_a).is_file() and digest(path_a) == saved, "A failed Save deleted or changed a saved ship's ship.cc")
state = bpy.context.scene.aetheria_layout
revision, state.revision = state.revision, "changed outside Blender"
refused(bpy.ops.aetheria.save_ship_layout, "changed since Load", "Save with a stale revision")
state.revision = revision
require(Path(path_a).is_file() and digest(path_a) == saved, "A stale-revision Save deleted or changed the ship.cc")
# New Ship checks everything, the rasterising included, before it changes the collection: a vertical wall has
# horizontal extent and no top-down area.
wall_collection = bpy.data.collections.new("Guard Wall")
scene.collection.children.link(wall_collection)
wall_mesh = bpy.data.meshes.new("Guard Wall")
wall_mesh.from_pydata([(-2, 0, -1), (2, 0, -1), (2, 0, 1), (-2, 0, 1)], [], [(0, 1, 2, 3)])
wall = new_object(wall_collection, "Guard Wall Hull", wall_mesh)
select(wall)
scene.aetheria_new_ship_id = "smoke.guard.w"
scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Wall", "Djinni"
for attempt in ("first", "retry"):
refused(bpy.ops.aetheria.new_ship, "no triangle with area", f"New Ship on a wall ({attempt})")
require(not any(key.startswith("aetheria.") for key in wall_collection.keys()), "A refused New Ship bound the collection")
require(wall.parent is None and wall.users_collection == (wall_collection,) and not wall_collection.children
and not any(obj.get("aetheria.ship_root") for obj in wall_collection.objects),
"A refused New Ship moved the meshes or made a Ship Root")
select(hull_a)
# A collection with a mesh in a child collection is not made a ship.
child_host = bpy.data.collections.new("Guard Child")
scene.collection.children.link(child_host)
nested = bpy.data.collections.new("Guard Nested")
child_host.children.link(nested)
new_object(nested, "Guard Nested Mesh", box("Nested", 1.0, 1.0, 1.0))
select(new_object(child_host, "Guard Child Hull", box("Child", 1.0, 3.0, 0.5)))
scene.aetheria_new_ship_id = "smoke.guard.c"
scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Child", "Djinni"
refused(bpy.ops.aetheria.new_ship, "must sit directly in it", "New Ship with a mesh in a child collection")
require("aetheria.id" not in child_host, "A refused New Ship bound the collection")
# Ship B takes none of ship A's hardpoints.
collection_b, hull_b = ship_from_box(scene, "Guard B", GUARD_B, (0, 0, 0))
require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the previous ship's hardpoints in the buffer")
# A pending ship is not packaged, and nothing is written for it.
select(hull_b)
refused(bpy.ops.aetheria.package_ship, "Save Layout first", "Package of a pending ship")
require(not (MODS / GUARD_B).exists(), "Package wrote for a pending ship")
# A buffer that belongs to ship B is neither saved into ship A's file nor rasterised onto ship A.
select(hull_a)
refused(bpy.ops.aetheria.save_ship_layout, "Load this collection's layout before saving", "Save with another ship's buffer")
require(digest(path_a) == saved, "Save wrote another ship's buffer into ship A's file")
before = buffer(bpy.context.scene)
refused(bpy.ops.aetheria.rasterise_hull, "Load this collection's layout first", "Rasterise with another ship's buffer")
require(buffer(bpy.context.scene) == before, "Rasterise replaced another ship's buffer")
# A failed Save leaves what the author had in a Mods/<id> folder that existed before it; the retry succeeds.
(MODS / GUARD_B).mkdir(parents=True)
keep = MODS / GUARD_B / "keep.txt"
keep.write_text("an author file")
select(hull_b)
real = aetheria_ships.replace_layout
def failing(*args, **kwargs):
raise ValueError("probe: replace_layout failed")
aetheria_ships.replace_layout = failing
try:
refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save with a failing write into an existing folder")
finally:
aetheria_ships.replace_layout = real
require(keep.is_file(), "A failed Save deleted a file the author had in the ship folder")
require(not Path(path_b).exists() and collection_b.get("aetheria.pending") is not None, "A failed Save left a half-made ship")
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"} and keep.is_file(), f"The retried Save was refused or took the author's file: {message}")
require(saved_layout(path_b, aetheria_ships)[3] == [], "Ship B's file holds hardpoints from ship A")
# Another ship's unsaved buffer is no reason to refuse Package: ship A's own file is what it checks.
select(hull_a)
result, message = run(bpy.ops.aetheria.package_ship)
require("Save Layout first" not in message and "Save Layout first" not in scene.aetheria_package_report,
"Package read another ship's buffer as ship A's unsaved edits")
# A buffer whose cells do not fill its size draws no Grid.
generated_b = child(collection_b, "Generated")
state = bpy.context.scene.aetheria_layout
require(any(obj.get("aetheria.grid") for obj in generated_b.objects), "Ship B has no Grid")
state.cells.remove(len(state.cells) - 1)
aetheria_ships._redraw_grid(collection_b, state)
require(not any(obj.get("aetheria.grid") for obj in generated_b.objects), "A Grid is drawn from a buffer that lacks cells")
# Rasterise needs the Ship Root New Ship made.
select(hull_b)
bpy.data.objects.remove(next(obj for obj in collection_b.objects if obj.get("aetheria.ship_root")))
refused(bpy.ops.aetheria.rasterise_hull, "has no Ship Root", "Rasterise without a Ship Root")
def undo_pass():
scene = bpy.context.scene
collection = bpy.data.collections.new("Undo Ship")
scene.collection.children.link(collection)
hull = new_object(collection, "Undo Hull", box("Undo Hull", 1.0, 3.0, 0.5), (2, 1, 0))
select(hull)
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship"
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
def frame():
"""The ship collection, its buffer, its grid placement and the hull's placement, read fresh: undo replaces the
data-blocks."""
col, obj = bpy.data.collections["Undo Ship"], bpy.data.objects["Undo Hull"]
origin = col.get("aetheria.grid_origin")
return (col.get("aetheria.id") is not None, col.get("aetheria.pending") is not None, buffer(bpy.context.scene),
tuple(round(v, 4) for v in origin) if origin else None,
tuple(round(v, 4) for row in obj.matrix_world for v in row), tuple(round(v, 4) for v in obj.scale))
def native(operator):
"""The operator as the UI calls it: its undo step is pushed when, and only when, it carries UNDO."""
return run(lambda: operator("EXEC_DEFAULT", True))
def undoable(operator, what):
before = frame()
result, message = native(operator)
require(result == {"FINISHED"}, f"{what} was refused: {message}")
after = frame()
require(after != before, f"{what} changed nothing")
result, _ = run(bpy.ops.ed.undo)
require(result == {"FINISHED"} and frame() == before, f"{what} is not one undo step of its own (no UNDO)")
select(bpy.data.objects["Undo Hull"])
bpy.ops.ed.undo_push(message="start")
undoable(bpy.ops.aetheria.new_ship, "New Ship")
result, message = native(bpy.ops.aetheria.new_ship)
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
select(bpy.data.objects["Undo Hull"])
bpy.data.objects["Undo Hull"].scale = (0.5, 1, 1)
bpy.ops.ed.undo_push(message="Scale") # the step the UI pushes for the author's own edit
undoable(bpy.ops.aetheria.rasterise_hull, "Rasterise")
result, message = native(bpy.ops.aetheria.rasterise_hull)
require(result == {"FINISHED"}, f"Rasterise was refused: {message}")
select(bpy.data.objects["Undo Hull"])
undoable(bpy.ops.aetheria.flip_nose, "Flip Nose")
# Save pushes no step: one undo after it takes back the step before it, and the file stays.
result, message = native(bpy.ops.aetheria.flip_nose)
require(result == {"FINISHED"}, f"Flip Nose was refused: {message}")
select(bpy.data.objects["Undo Hull"])
flipped = frame()
result, message = native(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Save was refused: {message}")
path = MODS / UNDO_SHIP / "ship.cc"
require(path.is_file(), "Save did not create the file")
result, _ = run(bpy.ops.ed.undo)
require(result == {"FINISHED"} and frame()[2:] != flipped[2:], "Save is undoable: one undo took back Save and not the step before it")
require(path.is_file(), "Undo changed the .cc")
# Ctrl+Z past the first Save leaves the .cc behind an unbound collection: New Ship with the ID is refused with the
# way forward, and following it (Bind, Load, Save) works.
for _ in range(12):
if not frame()[0]:
break
require(run(bpy.ops.ed.undo)[0] == {"FINISHED"}, "ed.undo past the first Save did not run")
require(not frame()[0] and path.is_file(), "Undo past the first Save did not unbind the collection or changed the .cc")
scene = bpy.context.scene
select(bpy.data.objects["Undo Hull"])
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship"
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
refused(bpy.ops.aetheria.new_ship, "use Bind Ship Collection with its ship.cc, then Load Layout", "New Ship over the ship the first Save made")
scene.aetheria_ship_cc_path = str(path)
for operator, what in ((bpy.ops.aetheria.bind_ship_collection, "Bind"), (bpy.ops.aetheria.load_ship_layout, "Load"),
(bpy.ops.aetheria.save_ship_layout, "Save")):
result, message = run(operator)
require(result == {"FINISHED"}, f"{what} on the way forward after undoing past the first Save was refused: {message}")
def _root_count():
return sum(1 for obj in bpy.data.objects if obj.get("aetheria.ship_root"))
def _second_collection_refused():
"""New Ship on a second collection with the saved ship's ID is refused with fixed text."""
second = bpy.data.collections.new("Second Frame")
bpy.context.scene.collection.children.link(second)
mesh = new_object(second, "Other", box("Other", 1.0, 2.0, 0.5))
select(mesh)
bpy.context.scene.aetheria_new_ship_id = SHIP_ID
refused(bpy.ops.aetheria.new_ship, "already exists", "New Ship over an existing ship")
require("aetheria.id" not in second, "A refused New Ship bound the second collection")
bpy.data.objects.remove(mesh)
bpy.data.collections.remove(second)
return "existing target refused"
created_mods = not MODS.exists()
existed = (MODS / SHIP_ID).exists()
try:
main()
except SystemExit as stop:
print(f"SMOKE_FRAME_FAILED: {stop}", flush=True)
code = 1
except Exception:
traceback.print_exc()
print("SMOKE_FRAME_FAILED", flush=True)
code = 1
else:
code = 0
finally:
if not existed:
for leftover in (SHIP_ID, *EXTRA_IDS):
shutil.rmtree(MODS / leftover, ignore_errors=True)
if created_mods and not existed:
shutil.rmtree(MODS, ignore_errors=True)
sys.stdout.flush()
os._exit(code)