Aetheria/tools/blender/tests/smoke_package.py
Meta Vens 4895c75236 Bind Ship Collection: compute the stored .cc path before binding, absolute when relative cannot hold
The bind called bpy.path.relpath after setting aetheria.asset_kind and aetheria.id, so a .cc on another drive than the
.blend raised and left the collection half-bound, and an unsaved .blend stored a path relative to the working
directory. The stored path is now computed first: absolute for an unsaved .blend, '//'-relative when relpath succeeds,
absolute when it raises ValueError. Every reader already resolves both through bpy.path.abspath.

smoke_package.py gains the bind cases: unsaved stores absolute and survives two Save As moves, a failing bind leaves
no aetheria.* property, same-drive stores '//'-relative, and with SMOKE_OTHER_DRIVE the final bind crosses drives
before the package passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:37:21 +02:00

201 lines
8.7 KiB
Python

"""Headless Blender smoke for the Package Ship action, end to end.
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs
(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'.
SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the
Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=<ship.cc>.
Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps
the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts
the disc back and packages again, so the package left behind is the good one.
Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an
unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the
.cc's drive stores a '//'-relative path. SMOKE_OTHER_DRIVE names a directory on another drive than SMOKE_MODS; when set,
the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves.
"""
import os
import sys
import tempfile
import traceback
from pathlib import Path
import addon_utils
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.gale"
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 quad(name, size=1.0):
mesh = bpy.data.meshes.new(name)
half = size / 2
mesh.from_pydata([(-half, -half, 0), (half, -half, 0), (half, half, 0), (-half, half, 0)], [], [(0, 1, 2, 3)])
return mesh
def tagged(collection, name, data, role=None, anchor_id=None, location=(0, 0, 0), parent=None):
obj = bpy.data.objects.new(name, data)
obj.location = location
if role:
obj["aetheria.role"] = role
obj["aetheria.id"] = anchor_id
obj.parent = parent
collection.objects.link(obj)
return obj
BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc")
def same_file(a, b):
return os.path.normcase(os.path.normpath(a)) == os.path.normcase(os.path.normpath(b))
def bind(ship, label):
for key in BIND_KEYS:
if key in ship:
del ship[key]
try:
result = bpy.ops.aetheria.bind_ship_collection()
except RuntimeError:
result = {"CANCELLED"}
stored = ship.get("aetheria.ship_cc")
print(f"BIND {label} {sorted(result)}: blend={bpy.data.filepath!r} stored={stored!r}")
return result, stored
def bind_cases(ship, path, mods):
if bpy.data.filepath:
raise SystemExit("Bind cases need an unsaved .blend to start from")
result, stored = bind(ship, "unsaved")
if result != {"FINISHED"} or not os.path.isabs(stored) or not same_file(stored, path):
raise SystemExit("Bind case unsaved: the stored path is not the absolute .cc path")
for folder in ("blend-a", "blend-b"):
(mods / folder).mkdir()
bpy.ops.wm.save_as_mainfile(filepath=str(mods / folder / "ship.blend"))
if not same_file(bpy.path.abspath(ship["aetheria.ship_cc"]), path):
raise SystemExit(f"Bind case unsaved: the stored path stopped resolving after Save As to {folder}")
relpath = bpy.path.relpath
bpy.path.relpath = lambda *args, **kwargs: (_ for _ in ()).throw(OSError("probe: relpath failed"))
try:
result, _ = bind(ship, "failing")
finally:
bpy.path.relpath = relpath
if result != {"CANCELLED"} or any(key in ship for key in BIND_KEYS):
raise SystemExit("Bind case failing: a failed bind left aetheria.* properties behind")
result, stored = bind(ship, "same-drive")
if result != {"FINISHED"} or not stored.startswith("//") or not same_file(bpy.path.abspath(stored), path):
raise SystemExit("Bind case same-drive: the stored path is not a resolving '//'-relative path")
other = os.environ.get("SMOKE_OTHER_DRIVE")
if not other:
print("BIND cross-drive skipped: SMOKE_OTHER_DRIVE is not set")
return
if os.path.splitdrive(other)[0].lower() == os.path.splitdrive(path)[0].lower():
raise SystemExit("SMOKE_OTHER_DRIVE is on the same drive as SMOKE_MODS")
Path(other).mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(Path(other) / "ship.blend"))
result, stored = bind(ship, "cross-drive")
if result != {"FINISHED"} or not os.path.isabs(stored) or not same_file(stored, path):
raise SystemExit("Bind case cross-drive: the stored path is not the absolute .cc path")
def package(addon):
# An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way.
try:
result = bpy.ops.aetheria.package_ship()
except RuntimeError:
result = {"CANCELLED"}
report = bpy.context.scene.aetheria_package_report
print(f"PACKAGE {sorted(result)}: {report}")
return result, report
def main():
sys.path.insert(0, str(REPO / "tools" / "blender"))
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
# Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender.
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-"))
path = str(mods / SHIP_ID / "ship.cc")
if Path(path).exists():
raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory")
code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
print(f"CREATE {code}: {message}")
if code != 0:
raise SystemExit("create --like Djinni failed")
scene = bpy.context.scene
ship = bpy.data.collections.new("Smoke Gale")
scene.collection.children.link(ship)
hull = tagged(ship, "Hull", tetrahedron("Hull"))
tagged(ship, "Map Icon", quad("Map Icon"), "map-icon", "map", (0, 0, -1))
tagged(ship, "Hull Collider", tetrahedron("Hull Collider"), "hull-collider", "collider")
tagged(ship, "Shield", None, "shield", "shield")
tagged(ship, "Tractor", None, "tractor", "tractor", (0, 2, 0))
disc = tagged(ship, "Thruster", quad("Thruster", 0.4), "thruster-emitter", "thruster", (0, -1, 0))
gun = tagged(ship, "Gun", None, "weapon-mount", "gun", (1, 0, 0))
tagged(ship, "Gun Muzzle", None, "weapon-muzzle", "gun.muzzle", (0, 0.5, 0), parent=gun)
tagged(ship, "Radiator", quad("Radiator", 0.5), "radiator-mesh", "radiator", (-1, 0, 0))
scene.aetheria_ship_cc_path = path
bpy.context.view_layer.objects.active = hull
bind_cases(ship, path, mods)
ship_cc = aetheria_ships.ship_cc
_, _, revision = ship_cc.read_layout(path, PACKAGES)
def hardpoint(kind, x, mount):
return ship_cc.encode_hardpoint(Type=ship_cc.HARDPOINT_TYPE_NAMES.index(kind), Position=[x, 0],
Shape=[[1, 1, [True]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0)
ship_cc.replace_layout(path, PACKAGES, SHIP_ID, revision, [4, 1, [True] * 4], [
hardpoint("Thruster", 0, "thruster"), hardpoint("Radiator", 1, "radiator"),
hardpoint("Energy", 2, "gun"), hardpoint("Reactor", 3, "reactor")])
result, report = package(aetheria_ships)
if result != {"FINISHED"}:
raise SystemExit("Pass 1: the validator refused the primitive ship")
ship.objects.unlink(disc)
hollow = tagged(ship, "Thruster Empty", None, "thruster-emitter", "thruster", (0, -1, 0))
result, report = package(aetheria_ships)
if result != {"CANCELLED"} or "thruster-emitter anchor thruster needs a mesh" not in report:
raise SystemExit("Pass 2: an empty thruster node was not refused by name")
ship.objects.unlink(hollow)
ship.objects.link(disc)
result, report = package(aetheria_ships)
if result != {"FINISHED"}:
raise SystemExit("Pass 3: the restored ship was refused")
print(f"SMOKE_PACKAGE={path}")
print("SMOKE_PACKAGE_OK")
try:
main()
except SystemExit as stop:
print(f"SMOKE_PACKAGE_FAILED: {stop}", flush=True)
os._exit(1)
except Exception:
traceback.print_exc()
print("SMOKE_PACKAGE_FAILED", flush=True)
os._exit(1)
sys.stdout.flush()
os._exit(0)