Merge eureka/aetheria-release-ships-addon-frame-r4: ships authoring tool and add-on frame

# Conflicts:
#	tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs
This commit is contained in:
Meta Vens 2026-10-10 19:54:03 +02:00
commit 54ac0fcfa7
16 changed files with 1897 additions and 163 deletions

1
.gitignore vendored
View file

@ -66,6 +66,7 @@ GameData/*.lock
/Aetheria.Shared/obj/
/tools/**/bin/
/tools/**/obj/
/ModTools/
/tests/**/bin/
/tests/**/obj/

View file

@ -199,14 +199,26 @@ public static class ShipAuthoringStore
throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record.");
}
// The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so
// Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value.
public static void RequireShipId(string id)
{
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Ship ID is required.");
if (!(id[0] >= 'a' && id[0] <= 'z' || id[0] >= '0' && id[0] <= '9') || id.Any(c =>
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
throw new InvalidOperationException("Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
if (id[id.Length - 1] == '.') throw new InvalidOperationException("Ship ID must not end with a dot.");
var stem = id.Split('.')[0];
if (stem is "con" or "prn" or "aux" or "nul"
|| stem.Length == 4 && (stem.StartsWith("com") || stem.StartsWith("lpt")) && stem[3] >= '1' && stem[3] <= '9')
throw new InvalidOperationException("Ship ID must not be a Windows device name.");
}
// The one semantic check every path shares: the hull and its visual, judged together.
public static void Validate(HullData hull, ShipAuthoring ship)
{
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
RequireShipId(ship.Id);
if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required.");
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))

View file

@ -61,12 +61,30 @@ weapon mount object. Thrusters and radiators are mesh objects; the others may
be empties. Package exports the collection, Grease Pencil excluded, to
`ship.glb` beside the `.cc`, writes the anchors, captures the lines when the
collection holds exactly one Grease Pencil object, and shows AetherDb's
verdict in the panel. It runs `dotnet run --project <repo>/tools/AetherDb`;
the repo is the add-on preference **Aetheria repo**, or by default the nearest
folder above the `.cc` holding `tools/AetherDb`.
verdict in the panel. It runs the AetherDb in the game folder's `ModTools`
(see Tools below).
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
hull, model, and anchor authoring, and judges `ship.glb` once it exists.
## Tools
Install Blender and the `aetheria_ships` add-on. In the add-on's preferences set
**Game folder** to your Aetheria install, the folder holding `GameData` (its
`GameData/Aetheria.cc` and `GameData/Mods`). Left empty, the add-on uses the
nearest folder above the bound `.cc` whose `GameData` holds `Aetheria.cc`.
AetherDb ships with the game in `ModTools`; the add-on runs it with the game
folder as its working directory, and needs neither Unity, the Aetheria repository
nor dotnet. New Ship creates the package under `<game folder>/GameData/Mods/<id>`.
From a repository checkout, publish AetherDb into the ignored `<repo>/ModTools` and
author from the repo as the game folder:
dotnet publish tools/AetherDb -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o ModTools
with `<rid>` one of `win-x64`, `linux-x64`, `osx-arm64`. Linux needs `libssl3`
and `libicu`; on macOS clear the quarantine flag (`xattr -d com.apple.quarantine
ModTools/AetherDb`).
## Objective
A mod author edits one ship `.cc` file, including the hull schematic and every

View file

@ -0,0 +1,50 @@
using System;
using System.IO;
using Xunit;
[CollectionDefinition("process working directory", DisableParallelization = true)]
public sealed class ProcessWorkingDirectoryCollection { }
// FindRoot reads the process working directory, so these run alone.
[Collection("process working directory")]
public sealed class AetherDbRootTests : IDisposable
{
private readonly TempDirectory _game = new TempDirectory();
private readonly string _previous = Environment.CurrentDirectory;
public void Dispose()
{
Environment.CurrentDirectory = _previous;
_game.Dispose();
}
[Fact]
public void TheRootIsTheNearestFolderWhoseGameDataHoldsAetheriaCc()
{
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData", "Mods", "probe.a"));
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
Environment.CurrentDirectory = _game.Path;
var game = Environment.CurrentDirectory;
Environment.CurrentDirectory = Path.Combine(game, "GameData", "Mods", "probe.a");
Assert.Equal(game, AetherDb.FindRoot());
}
[Fact]
public void TheRootIsTheWorkingDirectoryItselfWhenItsGameDataHoldsAetheriaCc()
{
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData"));
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
Environment.CurrentDirectory = _game.Path;
Assert.Equal(Environment.CurrentDirectory, AetherDb.FindRoot());
}
[Fact]
public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot()
{
Directory.CreateDirectory(Path.Combine(_game.Path, "Aetheria.Shared"));
File.WriteAllText(Path.Combine(_game.Path, "Aetheria.Shared", "Aetheria.Shared.csproj"), "");
Environment.CurrentDirectory = _game.Path;
var error = Assert.Throws<DirectoryNotFoundException>(AetherDb.FindRoot);
Assert.Contains("GameData holds Aetheria.cc", error.Message);
}
}

View file

@ -431,6 +431,27 @@ public sealed class ShipModCatalogTests : IDisposable
Assert.Contains("records and model", output);
}
[Theory]
[InlineData("con"), InlineData("nul.x"), InlineData("abc.")]
public void CreateRefusesABadIdBeforeTouchingTheDisk(string id)
{
var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc");
// The display name is itself a valid ship ID, so a rule applied to it instead of the ID would not refuse.
var (code, output, error) = Run("create", target, id, "probe", "--like", "Djinni");
Assert.Equal(1, code);
Assert.Contains("Ship ID", error);
Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData")));
}
[Fact]
public void CreateJudgesTheIdAndTakesAnyDisplayName()
{
var target = Path.Combine(_directory.Path, "GameData", "Mods", "probe.a", "ship.cc");
var (code, output, error) = Run("create", target, "probe.a", "Probe A");
Assert.Equal(0, code);
Assert.True(File.Exists(target), error);
}
private static (int Code, string Output, string Error) Run(params string[] args)
{
var (output, error) = (new StringWriter(), new StringWriter());

View file

@ -76,6 +76,39 @@ public sealed class ShipSchemaPinTests
Assert.Equal(Strings("ANCHOR_MEMBERS").Length, KeyOf(typeof(ShipAnchor), nameof(ShipAnchor.Joint)));
}
// New Ship refuses a bad ship ID before it touches anything, with ship_cc.valid_ship_id; these two constants are that
// rule's data, so the C# rule that create and Validate share must agree with them on every character and every stem.
[Fact]
public void PythonShipIdConstantsAreTheRuleRequireShipIdEnforces()
{
var allowed = Text("SHIP_ID_CHARS");
var reserved = Strings("RESERVED_SHIP_IDS");
for (var code = 0; code < 256; code++)
{
var c = (char)code;
Assert.Equal(allowed.Contains(c), Accepts("a" + c + "a"));
Assert.Equal(allowed.Contains(c) && c != '.' && c != '_' && c != '-', Accepts(c + "a"));
}
var stems = new List<string>();
for (var a = 'a'; a <= 'z'; a++)
for (var b = 'a'; b <= 'z'; b++)
for (var c = 'a'; c <= 'z'; c++)
stems.Add($"{a}{b}{c}");
for (var digit = 0; digit <= 9; digit++)
stems.AddRange(new[] { $"com{digit}", $"lpt{digit}" });
foreach (var stem in stems)
{
Assert.Equal(!reserved.Contains(stem), Accepts(stem));
Assert.Equal(!reserved.Contains(stem), Accepts(stem + ".x"));
}
}
private static bool Accepts(string id)
{
try { ShipAuthoringStore.RequireShipId(id); return true; }
catch (InvalidOperationException) { return false; }
}
[Fact]
public void PythonEnumNamesAreTheEnumsInValueOrder()
{

View file

@ -59,7 +59,25 @@ public sealed class ShipValidationTests
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
Assert.Contains("ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
Assert.Contains("Ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
}
[Theory]
[InlineData("com0"), InlineData("console"), InlineData("lpt"), InlineData("auxiliary"), InlineData("a.con"), InlineData("com10")]
public void IdsThatOnlyResembleDeviceNamesAreAdmitted(string id)
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
ship.Validate();
}
[Theory]
[InlineData("con"), InlineData("abc.")]
public void IdRefusalsNameNoValue(string id)
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
Assert.DoesNotContain(id, Assert.Throws<InvalidOperationException>(ship.Validate).Message);
}
[Theory]
@ -157,6 +175,10 @@ public sealed class ShipValidationTests
Add("id-null", s => s.Visual.Id = null, "Ship ID is required");
Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required");
foreach (var device in new[] { "con", "prn", "aux", "nul", "nul.x", "com1", "com9", "lpt1", "lpt9", "con.txt" })
Add("id-device-" + device, s => s.Visual.Id = device, "Windows device name");
foreach (var id in new[] { "abc.", "a.", "ab.", "a.b." })
Add("id-trailing-dot-" + id, s => s.Visual.Id = id, "must not end with a dot");
Add("hull-null", s => s.Hull = null, "hull data is required");
Add("hull-name-null", s => s.Hull.Name = null, "hull name is required");
Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required");

View file

@ -19,15 +19,15 @@ public sealed class AetherDb
catalogWritable: catalogWritable);
}
// With no root given, walks up from the working directory to the repository
// With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc
public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) =>
new AetherDb(root ?? FindRoot(), catalogWritable, withRun);
public static string FindRoot()
{
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir != null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
if (File.Exists(Path.Combine(dir.FullName, "GameData", "Aetheria.cc")))
return dir.FullName;
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
throw new DirectoryNotFoundException("Run from a folder whose GameData holds Aetheria.cc.");
}
}

View file

@ -46,6 +46,7 @@ public static class ShipAuthoringCommands
}
if (args[0] == "create" && (args.Length == 4 || args.Length == 6 && args[4] == "--like"))
{
ShipAuthoringStore.RequireShipId(args[2]);
var path = Path.GetFullPath(args[1]);
if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}");
Directory.CreateDirectory(Path.GetDirectoryName(path));

View file

@ -10,16 +10,27 @@ bl_info = {
"category": "Object",
}
import math
import shutil
import subprocess
import sys
import traceback
from pathlib import Path
import bpy
import numpy
from mathutils import Matrix
from .ship_cc import (HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, read, read_layout, replace_layout, replace_lines, replace_visual)
from . import hull_grid
from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines,
replace_visual, valid_ship_id)
MODEL_ASSET = "ship.glb"
AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb
SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported
GENERATED = "Generated" # child collection: derived objects (the Grid), never exported
SHIP_ROOT = "Ship Root"
AETHERDB_TIMEOUT = 120 # seconds
HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES))
@ -75,10 +86,59 @@ def _parse_footprint(text):
def _layout_path(context):
collection = _ship_collection(context)
if not collection.get("aetheria.ship_cc"):
raise ValueError("Ship collection has no bound .cc path")
raise ValueError("The ship collection has no bound .cc path")
return collection, bpy.path.abspath(collection["aetheria.ship_cc"])
def _set_cells(state, width, height, cells):
state.width = state.new_width = width
state.height = state.new_height = height
state.cells.clear()
for cell in cells:
state.cells.add().occupied = bool(cell)
def _load_layout(context, collection, path):
"""Fills the scene's layout buffer from the bound .cc and redraws the Grid; returns (width, height, hardpoints)."""
shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context))
width, height, cells = shape
state = context.scene.aetheria_layout
state.ship_id = collection["aetheria.id"]
state.ship_cc = path
state.revision = revision
_set_cells(state, width, height, cells)
state.hardpoints.clear()
for raw in hardpoints:
fields = decode_hardpoint(raw)
hp = state.hardpoints.add()
hp.kind = str(fields["Type"])
hp.x, hp.y = fields["Position"]
hp.footprint = _footprint(fields["Shape"])
hp.mount_id = fields["Transform"] or ""
hp.rotation = str(fields["Rotation"])
hp.armor = fields["Armor"]
hp.firing_arc = fields["FiringArc"]
_redraw_grid(collection, state)
return width, height, len(hardpoints)
_EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError)
def _failure_text(exc):
"""What an operator reports for exc. This module's own ValueError and RuntimeError messages are fixed text that
names no input; any other error is reported by its type, and its text goes to the system console."""
if type(exc) in (ValueError, RuntimeError):
return str(exc)
traceback.print_exception(exc)
return f"Stopped by {type(exc).__name__}; see the system console"
def _refuse(operator, exc):
operator.report({"ERROR"}, _failure_text(exc))
return {"CANCELLED"}
class AETHERIA_OT_load_layout(bpy.types.Operator):
bl_idname = "aetheria.load_ship_layout"
bl_label = "Load Ship Layout"
@ -87,33 +147,13 @@ class AETHERIA_OT_load_layout(bpy.types.Operator):
def execute(self, context):
try:
collection, path = _layout_path(context)
shape, hardpoints, revision = read_layout(path, _brokkr_cultlib(context))
width, height, cells = shape
state = context.scene.aetheria_layout
state.ship_id = collection["aetheria.id"]
state.ship_cc = path
state.revision = revision
state.width = state.new_width = width
state.height = state.new_height = height
state.cells.clear()
for cell in cells:
state.cells.add().occupied = bool(cell)
state.hardpoints.clear()
for raw in hardpoints:
fields = decode_hardpoint(raw)
hp = state.hardpoints.add()
hp.kind = str(fields["Type"])
hp.x, hp.y = fields["Position"]
hp.footprint = _footprint(fields["Shape"])
hp.mount_id = fields["Transform"] or ""
hp.rotation = str(fields["Rotation"])
hp.armor = fields["Armor"]
hp.firing_arc = fields["FiringArc"]
self.report({"INFO"}, f"Loaded {width}x{height} layout and {len(hardpoints)} hardpoints")
if collection.get("aetheria.pending"):
raise ValueError("Not saved yet; Save Layout creates it")
width, height, count = _load_layout(context, collection, path)
self.report({"INFO"}, f"Loaded {width}x{height} layout and {count} hardpoints")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_resize_layout(bpy.types.Operator):
@ -157,6 +197,55 @@ class AETHERIA_OT_remove_hardpoint(bpy.types.Operator):
return {"FINISHED"}
def _encode_buffer(state):
"""The layout buffer as ship_cc stores it: (shape, hardpoint rows)."""
shape = [state.width, state.height, [cell.occupied for cell in state.cells]]
rows = [encode_hardpoint(Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint),
Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc)
for hp in state.hardpoints]
return shape, rows
def _save_layout(context, collection, state):
"""The one writer of the .cc layout: writes the layout buffer, creating the file first when the ship is pending. A
failure after the file was created removes what this save made. Returns the hardpoint count."""
if not collection.get("aetheria.ship_cc"):
raise ValueError("The ship collection has no bound .cc path")
path = bpy.path.abspath(collection["aetheria.ship_cc"])
ship_id = collection["aetheria.id"]
if state.ship_id != ship_id or state.ship_cc != path:
raise ValueError("Load this collection's layout before saving")
cultlib = _brokkr_cultlib(context)
shape, rows = _encode_buffer(state)
pending = collection.get("aetheria.pending")
folder = Path(path).parent
if pending and Path(path).exists():
raise ValueError("A ship file already exists at the bound path (Ctrl+Z past a first Save leaves it there); "
"use Bind Ship Collection with that ship.cc, then Load Layout")
made_folder = bool(pending) and not folder.exists()
revision = state.revision
try:
if pending:
code, output = _aetherdb(context, "create", path, ship_id, pending[0], "--like", pending[1], ship_cc=path)
if output:
print(output)
if code != 0:
raise RuntimeError("AetherDb refused to create the ship; its message is in the system console")
revision = read_layout(path, cultlib)[2]
revision = replace_layout(path, cultlib, ship_id, revision, shape, rows)
except Exception:
if pending:
Path(path).unlink(missing_ok=True)
if made_folder:
shutil.rmtree(folder, ignore_errors=True)
raise
state.revision = revision
if pending:
del collection["aetheria.pending"]
_redraw_grid(collection, state)
return len(rows)
class AETHERIA_OT_save_layout(bpy.types.Operator):
bl_idname = "aetheria.save_ship_layout"
bl_label = "Save Layout to .cc"
@ -164,24 +253,12 @@ class AETHERIA_OT_save_layout(bpy.types.Operator):
def execute(self, context):
try:
collection, path = _layout_path(context)
state = context.scene.aetheria_layout
if state.ship_id != collection["aetheria.id"] or state.ship_cc != path:
raise ValueError("Load this collection's layout before saving")
cells = [cell.occupied for cell in state.cells]
hardpoints = [encode_hardpoint(
Type=int(hp.kind), Position=[hp.x, hp.y], Shape=_parse_footprint(hp.footprint),
Transform=hp.mount_id, Rotation=int(hp.rotation), Armor=hp.armor, FiringArc=hp.firing_arc)
for hp in state.hardpoints]
state.revision = replace_layout(
path, _brokkr_cultlib(context), state.ship_id, state.revision,
[state.width, state.height, cells], hardpoints
)
self.report({"INFO"}, f"Saved {len(hardpoints)} hardpoints to {path}")
collection, _ = _layout_path(context)
count = _save_layout(context, collection, context.scene.aetheria_layout)
self.report({"INFO"}, f"Saved the layout and {count} hardpoints")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError, KeyError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
except _EXPECTED as exc:
return _refuse(self, exc)
def _brokkr_cultlib(context):
@ -191,15 +268,45 @@ def _brokkr_cultlib(context):
return brokkr.preferences.cultlib_py_src
def _ship_collection(context):
def _is_ship(collection):
return collection.get("aetheria.asset_kind") == "ship"
def _ship_collection(context, unbound_ok=False):
"""The one chooser: the ship collection holding the active object. unbound_ok (Bind, New Ship) also accepts the
active object's only collection when no ship collection holds it."""
obj = context.active_object
if obj is None:
raise ValueError("Select an object in the ship collection")
matches = [collection for collection in bpy.data.collections
if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects]
if len(matches) != 1:
raise ValueError("Selected object must belong to exactly one bound ship collection")
return matches[0]
ships = [collection for collection in bpy.data.collections if _is_ship(collection) and obj.name in collection.all_objects]
if len(ships) == 1:
return ships[0]
if unbound_ok and not ships and len(obj.users_collection) == 1:
return obj.users_collection[0]
raise ValueError("Select an object in exactly one ship collection" if unbound_ok
else "Selected object must belong to exactly one bound ship collection")
def _bind_collection(collection, path, ship_id, pending=None):
"""Binds the collection to the ship .cc at path (aetheria.asset_kind, .id and .ship_cc, and .pending = [name,
reference] for a ship whose file the first Save creates), or changes nothing."""
if collection.get("aetheria.asset_kind") not in (None, "ship"):
raise ValueError("The collection has another aetheria.asset_kind")
if collection.get("aetheria.id") not in (None, ship_id):
raise ValueError("The collection is bound to another ship ID")
stored = path
if bpy.data.filepath:
try:
stored = bpy.path.relpath(path)
except ValueError:
pass
collection["aetheria.asset_kind"] = "ship"
collection["aetheria.id"] = ship_id
collection["aetheria.ship_cc"] = stored
if pending:
collection["aetheria.pending"] = pending
elif "aetheria.pending" in collection:
del collection["aetheria.pending"]
class AETHERIA_OT_bind_ship_collection(bpy.types.Operator):
@ -212,38 +319,13 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator):
try:
if not context.scene.aetheria_ship_cc_path:
raise ValueError("Choose an existing ship authoring .cc file")
obj = context.active_object
if obj is None:
raise ValueError("Select an object in the ship collection")
direct = list(obj.users_collection)
marked = [collection for collection in bpy.data.collections
if collection.get("aetheria.asset_kind") == "ship" and obj.name in collection.all_objects]
if len(marked) == 1:
collection = marked[0]
elif len(marked) == 0 and len(direct) == 1:
collection = direct[0]
else:
raise ValueError("Select an object in exactly one ship collection")
path = bpy.path.abspath(context.scene.aetheria_ship_cc_path)
ship_id = read(path, _brokkr_cultlib(context)).ship.body[0]
if collection.get("aetheria.asset_kind") not in (None, "ship"):
raise ValueError(f"{collection.name} has another aetheria.asset_kind")
if collection.get("aetheria.id") not in (None, ship_id):
raise ValueError(f"{collection.name} is bound to another ship ID")
stored = path
if bpy.data.filepath:
try:
stored = bpy.path.relpath(path)
except ValueError:
pass
collection["aetheria.asset_kind"] = "ship"
collection["aetheria.id"] = ship_id
collection["aetheria.ship_cc"] = stored
self.report({"INFO"}, f"Bound {collection.name} to {ship_id}")
collection = _ship_collection(context, unbound_ok=True)
_bind_collection(collection, path, read(path, _brokkr_cultlib(context)).ship.body[0])
self.report({"INFO"}, "Bound the ship collection")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
@ -257,7 +339,7 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
obj = context.active_object
collection = _ship_collection(context)
if not collection.get("aetheria.ship_cc"):
raise ValueError(f"{collection.name} has no bound ship .cc path")
raise ValueError("The ship collection has no bound .cc path")
path = bpy.path.abspath(collection["aetheria.ship_cc"])
cultlib = _brokkr_cultlib(context)
if read(path, cultlib).ship.body[0] != collection["aetheria.id"]:
@ -267,48 +349,61 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
evaluated=context.scene.aetheria_capture_evaluated_lines,
)
count = replace_lines(path, cultlib, lines)
self.report({"INFO"}, f"Captured {count} lines into {path}")
self.report({"INFO"}, f"Captured {count} lines")
return {"FINISHED"}
except (OSError, ValueError, RuntimeError, ImportError) as exc:
self.report({"ERROR"}, str(exc))
return {"CANCELLED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__
aetheria_repo: bpy.props.StringProperty(
name="Aetheria repo", subtype="DIR_PATH",
description="Aetheria checkout whose tools/AetherDb validates packages. "
"Empty: the nearest folder above the bound .cc that holds tools/AetherDb")
game_folder: bpy.props.StringProperty(
name="Game folder", subtype="DIR_PATH",
description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds "
"AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc")
def draw(self, context):
self.layout.prop(self, "aetheria_repo")
self.layout.prop(self, "game_folder")
def _aetheria_repo(context, ship_cc):
GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc "
"and whose ModTools holds AetherDb")
def _game_folder(context, ship_cc=None):
"""The folder AetherDb runs against: the Game folder preference, else the nearest folder above the bound .cc whose
GameData holds Aetheria.cc. Refusals name the preference and never a path."""
addon = context.preferences.addons.get(__package__)
configured = addon.preferences.aetheria_repo if addon and addon.preferences else ""
configured = addon.preferences.game_folder if addon and addon.preferences else ""
if configured:
repo = Path(bpy.path.abspath(configured))
if not (repo / "tools" / "AetherDb").is_dir():
raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb")
return repo
for folder in Path(ship_cc).resolve().parents:
if (folder / "tools" / "AetherDb").is_dir():
return folder
raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences")
folder = Path(bpy.path.abspath(configured))
if not (folder / "GameData" / "Aetheria.cc").is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
return folder
if ship_cc:
for folder in Path(ship_cc).resolve().parents:
if (folder / "GameData" / "Aetheria.cc").is_file():
return folder
raise ValueError(GAME_FOLDER_REFUSAL)
def _aetherdb(context, ship_cc, *args):
"""Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler
warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept."""
repo = _aetheria_repo(context, ship_cc)
result = subprocess.run(
["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args],
cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT)
def _aetherdb(context, *args, ship_cc=None):
"""Runs one AetherDb ship-authoring command from the game folder's ModTools; returns its exit code and its output,
stdout then stderr. ship_cc is the bound .cc the game folder is found from when the preference is empty."""
folder = _game_folder(context, ship_cc)
tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")
if not tool.is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
try:
result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True,
timeout=AETHERDB_TIMEOUT)
except subprocess.TimeoutExpired:
raise RuntimeError("AetherDb did not finish in time") from None
except OSError:
raise RuntimeError("AetherDb could not be started") from None
lines = (result.stdout + "\n" + result.stderr).splitlines()
return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line)
return result.returncode, "\n".join(line for line in lines if line.strip())
def _layer_collection(layer, collection):
@ -330,22 +425,23 @@ def _anchors(collection):
continue
anchor_id = obj.get("aetheria.id")
if not isinstance(anchor_id, str) or not anchor_id.strip():
raise ValueError(f"{obj.name} has aetheria.role {role} but no aetheria.id")
raise ValueError("An object with aetheria.role has no aetheria.id")
parent = None
if role == "weapon-muzzle":
parent = obj.parent.get("aetheria.id") if obj.parent else None
if not parent:
raise ValueError(f"Muzzle {obj.name} must be parented to its weapon mount object")
raise ValueError("A weapon-muzzle must be parented to its weapon mount object")
rows.append([anchor_id, str(role), anchor_id, parent, int(obj.get("aetheria.order", 0))])
return rows
def _export_glb(context, collection, filepath):
"""Exports the collection's objects, Grease Pencil excluded, with custom properties as node extras."""
"""Exports the collection's objects, Grease Pencil and the Source and Generated children excluded, with custom
properties as node extras."""
view_layer = context.view_layer
layer = _layer_collection(view_layer.layer_collection, collection)
if layer is None:
raise ValueError(f"{collection.name} is not in the current view layer")
raise ValueError("The ship collection is not in the current view layer")
previous_layer = view_layer.active_layer_collection
previous_selection = [obj for obj in view_layer.objects if obj.select_get()]
previous_active = view_layer.objects.active
@ -353,12 +449,16 @@ def _export_glb(context, collection, filepath):
view_layer.active_layer_collection = layer
for obj in previous_selection:
obj.select_set(False)
derived = _derived_names(collection)
for obj in collection.all_objects:
if obj.type != "GREASEPENCIL" and obj.name in view_layer.objects:
if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects:
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True,
export_extras=True, export_yup=True, export_apply=True)
except RuntimeError: # the exporter's text holds paths and a traceback
traceback.print_exc()
raise RuntimeError("The GLB export failed; its message is in the system console") from None
finally:
for obj in view_layer.objects:
obj.select_set(obj in previous_selection)
@ -366,6 +466,308 @@ def _export_glb(context, collection, filepath):
view_layer.active_layer_collection = previous_layer
def _child(collection, kind):
"""The child collection tagged aetheria.frame == kind. Blender keeps collection names unique per file, so the
name 'Source' or 'Generated' cannot identify it."""
return next((child for child in collection.children if child.get("aetheria.frame") == kind), None)
def _child_or_new(collection, kind, name):
child = _child(collection, kind)
if child is None:
child = bpy.data.collections.new(name)
child["aetheria.frame"] = kind
collection.children.link(child)
return child
def _derived_names(collection):
"""Names of the objects in the Source and Generated children, which are never exported."""
return {obj.name for kind in (SOURCE, GENERATED) for child in [_child(collection, kind)] if child
for obj in child.all_objects}
def _ship_root(collection):
return next((obj for obj in collection.objects if obj.get("aetheria.ship_root")), None)
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."""
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]
source = _child(collection, SOURCE)
return own or (_meshes(source.all_objects) if source else [])
def _loose_meshes(collection):
"""Meshes of a collection that has a Ship Root which are not under the root, Generated's apart. Source meshes count:
Rasterise reads them and Flip Nose turns only what hangs from Ship Root."""
root = _ship_root(collection)
if root is None:
return []
generated = _child(collection, GENERATED)
derived = {obj.name for obj in generated.all_objects} if generated else set()
framed = {obj.name for obj in root.children_recursive}
return [obj for obj in _meshes(collection.all_objects) if obj.name not in derived and obj.name not in framed]
def _refuse_loose_meshes(context, collection):
"""Refuses while meshes sit outside Ship Root, selecting them so the author can parent them."""
loose = _loose_meshes(collection)
if not loose:
return
for obj in context.view_layer.objects:
obj.select_set(False)
for obj in loose:
if obj.name in context.view_layer.objects:
obj.select_set(True)
raise ValueError(f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it")
def _frame_inputs(context, collection, state):
"""Ship Root and the meshes Rasterise reads, or the refusal that stops Rasterise and Flip Nose before they change
anything."""
root = _ship_root(collection)
if root is None:
raise ValueError("The ship collection has no Ship Root; run New Ship first")
if state.ship_id != collection["aetheria.id"]:
raise ValueError("Load this collection's layout first")
_refuse_loose_meshes(context, collection)
meshes = _frame_meshes(collection)
if not meshes:
raise ValueError("The ship collection has no render or Source mesh to rasterise")
return root, meshes
def _world_mesh(obj, depsgraph, matrix):
"""The evaluated mesh's points through matrix, as (points, triangles) numpy arrays."""
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
points = numpy.empty(len(mesh.vertices) * 3, dtype=numpy.float32)
mesh.vertices.foreach_get("co", points)
mesh.calc_loop_triangles()
triangles = numpy.empty(len(mesh.loop_triangles) * 3, dtype=numpy.int32)
mesh.loop_triangles.foreach_get("vertices", triangles)
finally:
evaluated.to_mesh_clear()
linear = numpy.array(matrix, dtype=numpy.float64)
return points.reshape(-1, 3) @ linear[:3, :3].T + linear[:3, 3], triangles.reshape(-1, 3)
def _held(matrix):
"""The world matrix an object holds after matrix_world = matrix: Blender keeps location, rotation and scale, so a
shear (a non-uniform scaled parent under rotation) is dropped. The hull the author sees afterwards is this one."""
return Matrix.LocRotScale(*matrix.decompose())
def _triangles_xy(depsgraph, placements):
"""The top-down triangles of the evaluated meshes, each through its matrix: what the author sees from above.
placements is [(object, matrix)]."""
triangles = []
for obj, matrix in placements:
points, indices = _world_mesh(obj, depsgraph, matrix)
triangles += points[indices][:, :, :2].tolist()
return triangles
def _grid_current(collection, state):
"""Whether the Grid can be drawn: grid_origin was computed for the buffer's own size."""
origin = collection.get("aetheria.grid_origin")
return (origin is not None and len(origin) == 4 and (int(origin[2]), int(origin[3])) == (state.width, state.height)
and len(state.cells) == state.width * state.height)
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
frame yet and gets no Grid."""
root = _ship_root(collection)
origin = collection.get("aetheria.grid_origin")
if root is None or origin is None:
return
generated = _child_or_new(collection, GENERATED, GENERATED)
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)
return
origin = (float(origin[0]), float(origin[1]))
half = hull_grid.CELL_SIZE / 2
verts, edges, faces = [], [], []
for x in range(state.width):
for y in range(state.height):
cx, cy = hull_grid.cell_centre(origin, x, y)
i = len(verts)
verts += [(cx - half, cy - half, 0.0), (cx + half, cy - half, 0.0), (cx + half, cy + half, 0.0),
(cx - half, cy + half, 0.0)]
if state.cells[x * state.height + y].occupied:
faces.append((i, i + 1, i + 2, i + 3))
else:
edges += [(i, i + 1), (i + 1, i + 2), (i + 2, i + 3), (i + 3, i)]
mesh = bpy.data.meshes.new("Grid")
mesh.from_pydata(verts, edges, faces)
mesh.update(calc_edges=True)
if grid is None:
grid = bpy.data.objects.new("Grid", mesh)
grid["aetheria.grid"] = True
generated.objects.link(grid)
else:
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)
grid.display_type = "WIRE"
def _rasterise(context, collection, state):
"""Proposes the hull's cells from the frame meshes, top-down in Ship Root's frame, into the layout buffer (its
hardpoints stay) and stores the grid's placement with the size it was computed for. Writes no file."""
root, meshes = _frame_inputs(context, collection, state)
to_root = root.matrix_world.inverted()
width, height, cells, origin = hull_grid.rasterise(_triangles_xy(
context.evaluated_depsgraph_get(), [(obj, to_root @ obj.matrix_world) for obj in meshes]))
_place_grid(collection, state, width, height, cells, origin)
return width, height
def _place_grid(collection, state, width, height, cells, origin):
"""Puts a rasterised hull into the layout buffer (its hardpoints stay), stores the grid's placement with the size it
was computed for and redraws the Grid. The only writer of aetheria.grid_origin."""
_set_cells(state, width, height, cells)
collection["aetheria.grid_origin"] = [float(origin[0]), float(origin[1]), width, height]
_redraw_grid(collection, state)
class AETHERIA_OT_rasterise_hull(bpy.types.Operator):
bl_idname = "aetheria.rasterise_hull"
bl_label = "Rasterise Hull"
bl_description = "Propose the hull's cells from its top-down silhouette; you edit them afterwards, then Save Layout"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
width, height = _rasterise(context, _ship_collection(context), context.scene.aetheria_layout)
self.report({"INFO"}, f"Rasterised a {width}x{height} hull")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_flip_nose(bpy.types.Operator):
bl_idname = "aetheria.flip_nose"
bl_label = "Flip Nose"
bl_description = "Turn the hull 180 degrees about Ship Root's Z axis, then rasterise it again"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
try:
collection = _ship_collection(context)
state = context.scene.aetheria_layout
root, _ = _frame_inputs(context, collection, state)
turn = root.matrix_world @ Matrix.Rotation(math.pi, 4, "Z") @ root.matrix_world.inverted()
for child in root.children:
child.matrix_world = turn @ child.matrix_world
try:
_rasterise(context, collection, state)
except Exception:
for child in root.children: # a half turn is its own inverse
child.matrix_world = turn @ child.matrix_world
raise
self.report({"INFO"}, "Turned the nose about Ship Root")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
def _new_ship(context, ship_id, name, reference, length):
"""Sets the active collection up as a pending ship: binds it, moves its meshes into Source under a Ship Root, turns
them nose to -Y, sizes them to length cells, fills the layout buffer and draws the Grid. The meshes keep their data:
the placement is the objects' own matrix, so modifiers act as the author saw them and the cells are the ones checked
here. Every check and the rasterising come first; after the bind nothing can refuse. Nothing is written to disk: the
first Save creates the file."""
if not valid_ship_id(ship_id):
raise ValueError("The ship ID must use lower-case letters, digits, dots, underscores or hyphens, must not end "
"in a dot, and must not be a Windows device name")
if not name.strip() or not reference.strip():
raise ValueError("Give the ship a display name and a reference hull")
path = _game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc"
if path.exists():
raise ValueError("A ship with this ID already exists under GameData/Mods (Ctrl+Z past a first Save leaves it "
"there); use Bind Ship Collection with its ship.cc, then Load Layout, or give the new ship "
"another ID")
collection = _ship_collection(context, unbound_ok=True)
if collection.get("aetheria.id") is not None or _ship_root(collection) is not None:
raise ValueError("The collection is already a ship")
meshes = _meshes(collection.objects)
if not meshes:
raise ValueError("The ship collection holds no mesh objects to make a ship from")
if len(_meshes(collection.all_objects)) != len(meshes):
raise ValueError("The ship collection's meshes must sit directly in it, not in child collections")
worlds = {obj: obj.matrix_world.copy() for obj in meshes}
depsgraph = context.evaluated_depsgraph_get()
points = numpy.vstack([_world_mesh(obj, depsgraph, worlds[obj])[0] for obj in meshes])
extent_x, extent_y = (float(v) for v in numpy.ptp(points, axis=0)[:2])
longest = max(extent_x, extent_y)
if longest <= 0:
raise ValueError("The hull meshes have no horizontal extent")
scale = length * hull_grid.CELL_SIZE / longest
transform = Matrix.Diagonal((scale, scale, scale, 1.0))
if extent_x > extent_y:
transform = transform @ Matrix.Rotation(math.pi / 2, 4, "Z")
placed = {obj: _held(transform @ worlds[obj]) for obj in meshes}
width, height, cells, origin = hull_grid.rasterise(_triangles_xy(depsgraph, [(obj, placed[obj]) for obj in meshes]))
_bind_collection(collection, str(path), ship_id, [name, reference])
source = _child_or_new(collection, SOURCE, SOURCE)
root = bpy.data.objects.new(SHIP_ROOT, None)
root["aetheria.ship_root"] = True
collection.objects.link(root)
for obj in meshes:
collection.objects.unlink(obj)
source.objects.link(obj)
obj.parent = root
obj.matrix_parent_inverse = Matrix.Identity(4)
obj.matrix_world = placed[obj]
state = context.scene.aetheria_layout
state.ship_id = ship_id
state.ship_cc = bpy.path.abspath(collection["aetheria.ship_cc"])
state.revision = ""
state.hardpoints.clear()
_place_grid(collection, state, width, height, cells, origin)
class AETHERIA_OT_new_ship(bpy.types.Operator):
bl_idname = "aetheria.new_ship"
bl_label = "New Ship"
bl_description = ("Set the active collection up as a new ship: nose toward -Y, sized to the length, with a drafted "
"grid. Nothing is written until Save Layout")
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scene = context.scene
try:
_new_ship(context, scene.aetheria_new_ship_id, scene.aetheria_new_ship_name,
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length)
self.report({"INFO"}, "New ship set up; Save Layout creates its file")
return {"FINISHED"}
except _EXPECTED as exc:
return _refuse(self, exc)
class AETHERIA_OT_package_ship(bpy.types.Operator):
bl_idname = "aetheria.package_ship"
bl_label = "Package Ship"
@ -377,8 +779,18 @@ class AETHERIA_OT_package_ship(bpy.types.Operator):
scene = context.scene
try:
collection, path = _layout_path(context)
if collection.get("aetheria.pending"):
raise ValueError("Save Layout first")
_refuse_loose_meshes(context, collection)
cultlib = _brokkr_cultlib(context)
ship_id = collection["aetheria.id"]
state = scene.aetheria_layout
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")
anchors = _anchors(collection)
@ -389,15 +801,19 @@ class AETHERIA_OT_package_ship(bpy.types.Operator):
replace_lines(path, cultlib, capture_grease_pencil(
pencils[0], context.evaluated_depsgraph_get(), scene.frame_current,
evaluated=scene.aetheria_capture_evaluated_lines))
code, message = _aetherdb(context, path, "validate", path)
except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc:
scene.aetheria_package_report = f"Package failed: {exc}"
code, message = _aetherdb(context, "validate", path, ship_cc=path)
except _EXPECTED as exc:
scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}"
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
scene.aetheria_package_report = message or f"validate exited {code}"
if message:
print(message)
if code != 0:
self.report({"ERROR"}, f"Packaged {len(anchors)} anchors; the validator refused it: {scene.aetheria_package_report}")
scene.aetheria_package_report = (f"Packaged {len(anchors)} anchors; the validator refused the ship; "
"its message is in the system console")
self.report({"ERROR"}, scene.aetheria_package_report)
return {"CANCELLED"}
scene.aetheria_package_report = f"Packaged {len(anchors)} anchors; the validator accepted the ship"
self.report({"INFO"}, scene.aetheria_package_report)
return {"FINISHED"}
@ -411,21 +827,35 @@ class AETHERIA_PT_ship(bpy.types.Panel):
def draw(self, context):
layout = self.layout
new = layout.box()
new.label(text="New Ship")
for prop in ("aetheria_new_ship_id", "aetheria_new_ship_name", "aetheria_new_ship_like",
"aetheria_new_ship_length"):
new.prop(context.scene, prop)
new.operator("aetheria.new_ship", icon="ADD")
layout.prop(context.scene, "aetheria_ship_cc_path")
layout.operator("aetheria.bind_ship_collection", icon="LINKED")
layout.prop(context.scene, "aetheria_capture_evaluated_lines")
obj = context.active_object
layout.label(text=f"Source: {obj.name if obj else '(none)'}")
state = context.scene.aetheria_layout
try:
collection = _ship_collection(context)
layout.label(text=f"Ship: {collection['aetheria.id']}")
if collection.get("aetheria.pending"):
layout.label(text="Not saved yet: Save Layout creates the file", icon="INFO")
elif (collection.get("aetheria.grid_origin") is not None and state.ship_id == collection["aetheria.id"]
and not _grid_current(collection, state)):
layout.label(text="Rasterise again: the grid no longer matches the layout", icon="INFO")
except (ValueError, KeyError):
layout.label(text="No bound ship collection", icon="ERROR")
layout.operator("aetheria.capture_ship_lines", icon="GREASEPENCIL")
layout.separator()
layout.operator("aetheria.load_ship_layout", icon="IMPORT")
state = context.scene.aetheria_layout
row = layout.row(align=True)
row.operator("aetheria.load_ship_layout", icon="IMPORT")
row.operator("aetheria.flip_nose", icon="LOOP_BACK")
row.operator("aetheria.rasterise_hull", icon="MESH_GRID")
if not state.ship_id:
self._draw_package(context, layout)
return
@ -475,7 +905,8 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout,
AETHERIA_OT_load_layout, AETHERIA_OT_resize_layout,
AETHERIA_OT_add_hardpoint, AETHERIA_OT_remove_hardpoint,
AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection,
AETHERIA_OT_capture_ship_lines, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship)
AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose,
AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship)
def register():
@ -485,6 +916,14 @@ def register():
bpy.types.Scene.aetheria_ship_cc_path = bpy.props.StringProperty(
name="Ship .cc", subtype="FILE_PATH", description="Existing typed ship authoring record"
)
bpy.types.Scene.aetheria_new_ship_id = bpy.props.StringProperty(
name="ID", description="The new ship's ID; also the folder under GameData/Mods")
bpy.types.Scene.aetheria_new_ship_name = bpy.props.StringProperty(name="Name", description="Display name")
bpy.types.Scene.aetheria_new_ship_like = bpy.props.StringProperty(
name="Like", default="Djinni", description="Shipped hull whose stats the new ship starts from")
bpy.types.Scene.aetheria_new_ship_length = bpy.props.IntProperty(
name="Length (cells)", default=10, min=1, max=hull_grid.MAX_CELLS,
description="Cells from stern to nose; the hull's long axis becomes this many 2 m cells")
bpy.types.Scene.aetheria_package_report = bpy.props.StringProperty(
name="Package report", description="AetherDb's verdict on the last Package Ship")
bpy.types.Scene.aetheria_capture_evaluated_lines = bpy.props.BoolProperty(
@ -498,5 +937,10 @@ def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.aetheria_capture_evaluated_lines
del bpy.types.Scene.aetheria_new_ship_length
del bpy.types.Scene.aetheria_new_ship_like
del bpy.types.Scene.aetheria_new_ship_name
del bpy.types.Scene.aetheria_new_ship_id
del bpy.types.Scene.aetheria_package_report
del bpy.types.Scene.aetheria_ship_cc_path

View file

@ -0,0 +1,85 @@
"""The ship grid's arithmetic: where a cell is, and which cells a hull's top-down silhouette occupies. Imports without bpy.
Frame: the Ship Root frame in metres, nose toward -Y and starboard toward -X (map M7). Cell (x, y) is centred on its
integer coordinate, so its centre is grid_origin + (-CELL_SIZE x, -CELL_SIZE y), where grid_origin is cell (0, 0)'s
centre (the Aetheria.Entity.ToWorldPoint rule: schematic +x is starboard, +y is forward). grid_origin is a stored fact
written only by Rasterise; every lookup below takes it as an argument.
"""
import math
CELL_SIZE = 2.0 # metres per cell, Settings.SchematicCellSize
MAX_CELLS = 32 # per axis, the layout panel's limit
SAMPLES = 4 # per axis per cell: 4 x 4 coverage samples
_EDGE = 1e-9 # slack for float noise on a sample bound, in sample steps
_TOLERANCE = 1e-3 # metres: mesh coordinates are float32, so an extent a millimetre over a cell boundary is on it
def cell_centre(grid_origin, x, y):
"""The Ship Root frame position of cell (x, y)'s centre."""
return (grid_origin[0] - CELL_SIZE * x, grid_origin[1] - CELL_SIZE * y)
def cell_at(grid_origin, point):
"""The cell whose centre is nearest to point; a point midway between two centres belongs to the lower cell."""
return (math.ceil((grid_origin[0] - point[0]) / CELL_SIZE - 0.5),
math.ceil((grid_origin[1] - point[1]) / CELL_SIZE - 0.5))
def _cells_across(extent, cell_size):
return max(1, math.ceil((extent - _TOLERANCE) / cell_size))
def _inside(triangle, px, py):
(ax, ay), (bx, by), (cx, cy) = triangle
d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
d2 = (cx - bx) * (py - by) - (cy - by) * (px - bx)
d3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx)
return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0))
def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES):
"""Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame.
The grid is ceil(extent / cell_size) cells on each axis, centred on the silhouette's bounding box. Each cell is
tested at samples x samples points; it is occupied when at least half of them fall in some triangle. A sample
inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box.
Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell
(0, 0)'s centre."""
triangles = [tri for tri in triangles_xy if
(tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])]
if not triangles:
raise ValueError("The hull has no triangle with area to rasterise")
xs = [p[0] for tri in triangles for p in tri]
ys = [p[1] for tri in triangles for p in tri]
width = _cells_across(max(xs) - min(xs), cell_size)
height = _cells_across(max(ys) - min(ys), cell_size)
for axis, count in (("wide", width), ("long", height)):
if count > MAX_CELLS:
raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}")
# Cell (0, 0)'s centre: the far (+X, +Y) corner of the grid, half a cell in.
origin = ((min(xs) + max(xs)) / 2 + (width - 1) * cell_size / 2, (min(ys) + max(ys)) / 2 + (height - 1) * cell_size / 2)
step = cell_size / samples
# Sample (a, b) sits at origin + cell_size/2 - (a + 1/2) step on each axis, so it falls in cell (a // samples, b // samples).
top_x, top_y = origin[0] + cell_size / 2, origin[1] + cell_size / 2
def span(low, high, top, count):
first = math.ceil((top - high) / step - 0.5 - _EDGE)
last = math.floor((top - low) / step - 0.5 + _EDGE)
return range(max(first, 0), min(last, count * samples - 1) + 1)
hits = set()
for tri in triangles:
txs = [p[0] for p in tri]
tys = [p[1] for p in tri]
for a in span(min(txs), max(txs), top_x, width):
px = top_x - (a + 0.5) * step
for b in span(min(tys), max(tys), top_y, height):
if (a, b) not in hits and _inside(tri, px, top_y - (b + 0.5) * step):
hits.add((a, b))
counts = {}
for a, b in hits:
key = (a // samples, b // 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

View file

@ -37,6 +37,21 @@ HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Rea
ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise")
# ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests
# pins both constants and valid_ship_id to the C# rule.
SHIP_ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789._-"
RESERVED_SHIP_IDS = ("con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9")
def valid_ship_id(ship_id: str) -> bool:
"""True when ShipAuthoringStore.RequireShipId would accept the ID: lower-case ASCII letters, digits, dots,
underscores and hyphens, starting with a letter or digit, not ending in a dot, and whose stem before the first dot
is not a Windows device name."""
return (bool(ship_id) and ship_id[0] not in "._-" and all(c in SHIP_ID_CHARS for c in ship_id)
and not ship_id.endswith(".") and ship_id.split(".")[0] not in RESERVED_SHIP_IDS)
def decode_hardpoint(row: list[Any]) -> dict[str, Any]:
return dict(zip(HARDPOINT_MEMBERS, row))
@ -80,19 +95,19 @@ def read(path: str, cultlib_packages: str) -> ShipFile:
body = msgpack.unpackb(envelope.payload, raw=False)
if isinstance(body, list) and len(body) > RETIRED_HULL_SLOT and body[RETIRED_HULL_SLOT] is not None:
raise ValueError(
f"{path}: {envelope.key}: legacy embedded hull at retired key {RETIRED_HULL_SLOT} of the {SCHEMA} record; "
f"The ship file has a legacy embedded hull at retired key {RETIRED_HULL_SLOT} of the {SCHEMA} record; "
f"migrate it into its own {HULL_SCHEMA} record that names the visual, and drop key {RETIRED_HULL_SLOT}")
if len(envelopes) != 2 or len(ships) != 1 or len(hulls) != 1:
raise ValueError(f"{path} must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record")
raise ValueError(f"The ship file must hold exactly one {SCHEMA} record and one {HULL_SCHEMA} record")
bodies = []
for envelope in (ships[0], hulls[0]):
body = msgpack.unpackb(envelope.payload, raw=False)
if not isinstance(body, list):
raise ValueError(f"{path} has an incompatible {envelope.type} payload")
raise ValueError(f"The ship file has an incompatible {envelope.type} payload")
bodies.append(Record(envelope, body))
ship, hull = bodies
if len(ship.body) < SCHEMATIC_LINES_SLOT + 1:
raise ValueError(f"{path} has an incompatible {SCHEMA} payload")
raise ValueError(f"The ship file has an incompatible {SCHEMA} payload")
return ShipFile(store, ship, hull, msgpack)
@ -129,7 +144,7 @@ def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_ass
if not isinstance(anchor_id, str) or not anchor_id.strip():
raise ValueError("Every anchor needs an ID")
if anchor_id in ids:
raise ValueError(f"Anchor ID {anchor_id} is used twice")
raise ValueError("An anchor ID is used twice")
ids.add(anchor_id)
previous = file.ship.body[ANCHORS_SLOT] if len(file.ship.body) > ANCHORS_SLOT else None
later = {row[0]: row[known:] for row in previous or []
@ -149,13 +164,14 @@ def _hull_body(file: ShipFile) -> list:
def read_layout(path: str, cultlib_packages: str):
file = read(path, cultlib_packages)
hull = _hull_body(file)
return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], _layout_revision(hull, file.msgpack)
return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT])
def _layout_revision(hull, msgpack):
return hashlib.sha256(msgpack.packb(
[hull[HULL_SHAPE_SLOT], hull[HULL_HARDPOINTS_SLOT]], use_bin_type=True
)).hexdigest()
def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str:
"""The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores
them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's."""
import msgpack # type: ignore
return hashlib.sha256(msgpack.packb([[shape], hardpoints], use_bin_type=True)).hexdigest()
def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_revision: str,
@ -164,7 +180,7 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_
if file.ship.body[0] != expected_id:
raise ValueError("The bound ship ID changed; reload its layout")
hull = _hull_body(file)
if _layout_revision(hull, file.msgpack) != expected_revision:
if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision:
raise ValueError("The .cc layout changed since Load; reload before saving")
width, height, cells = shape
if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height):
@ -184,20 +200,20 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_
raise ValueError(f"Each hardpoint needs {known} typed fields and a stable mount ID")
hp_width, hp_height, hp_cells = fields["Shape"][0]
if not all(type(cell) is bool for cell in hp_cells):
raise ValueError(f"Hardpoint {mount} footprint cells must be booleans")
raise ValueError("Hardpoint footprint cells must be booleans")
if not (1 <= hp_width <= 32 and 1 <= hp_height <= 32 and
len(hp_cells) == hp_width * hp_height and any(hp_cells)):
raise ValueError(f"Hardpoint {mount} has an invalid footprint")
raise ValueError("A hardpoint has an invalid footprint")
updated_hardpoints.append(hardpoint + previous.get(mount, []))
hull[HULL_SHAPE_SLOT] = [shape]
hull[HULL_HARDPOINTS_SLOT] = updated_hardpoints
file.store.push(_stamped(file.hull, file.msgpack))
return _layout_revision(hull, file.msgpack)
return layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT])
def capture_grease_pencil(obj: Any, depsgraph: Any, frame_number: int, *, evaluated: bool = True) -> list[list[Any]]:
if obj.type != "GREASEPENCIL":
raise ValueError(f"{obj.name} is not a Grease Pencil object")
raise ValueError("The selected object is not a Grease Pencil object")
source = obj.evaluated_get(depsgraph) if evaluated else obj
lines: list[list[Any]] = []
for layer in source.data.layers:

View file

@ -0,0 +1,781 @@
"""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.
"""
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"
EXTRA_IDS = (GUARD_A, GUARD_B, UNDO_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_node_names(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"]}
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")
undo_pass()
print("PASS 8 native undo ok")
print("SMOKE_FRAME_OK")
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)

View file

@ -2,14 +2,17 @@
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'.
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py) and SMOKE_GAME_FOLDER, a folder holding
GameData/Aetheria.cc and the published AetherDb under ModTools (AetherDb.exe on Windows), which the smoke sets as the
add-on's Game folder. No dotnet is needed: the add-on runs the published tool and nothing else.
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.
the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder
that holds Aetheria.cc but no ModTools/AetherDb, and at one that holds a ModTools tool but no Aetheria.cc, and
requires Package to report the fixed preference text and no path.
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
@ -17,6 +20,8 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t
the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves.
"""
import contextlib
import io
import os
import sys
import tempfile
@ -27,6 +32,7 @@ import addon_utils
import bpy
REPO = Path(__file__).resolve().parents[3]
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"))
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
SHIP_ID = "smoke.gale"
@ -113,14 +119,20 @@ def bind_cases(ship, path, mods):
raise SystemExit("Bind case cross-drive: the stored path is not the absolute .cc path")
CONSOLE = [] # what the last package() sent to the system console
def package(addon):
# An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way.
console = io.StringIO()
try:
result = bpy.ops.aetheria.package_ship()
with contextlib.redirect_stdout(console):
result = bpy.ops.aetheria.package_ship()
except RuntimeError:
result = {"CANCELLED"}
CONSOLE[:] = [console.getvalue()]
report = bpy.context.scene.aetheria_package_report
print(f"PACKAGE {sorted(result)}: {report}")
print(f"PACKAGE {sorted(result)}: {report}{CONSOLE[0]}")
return result, report
@ -130,13 +142,13 @@ def main():
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)
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
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")
code, message = aetheria_ships._aetherdb(bpy.context, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
print(f"CREATE {code}: {message}")
if code != 0:
raise SystemExit("create --like Djinni failed")
@ -176,14 +188,34 @@ def main():
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")
if result != {"CANCELLED"} or "the validator refused the ship" not in report or "thruster" in report or SHIP_ID in report:
raise SystemExit("Pass 2: an empty thruster node was not refused with the fixed text")
if "thruster-emitter anchor thruster needs a mesh" not in CONSOLE[0]:
raise SystemExit("Pass 2: the validator's message naming the anchor did not reach the system console")
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")
bare = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bare-"))
(bare / "GameData").mkdir()
(bare / "GameData" / "Aetheria.cc").write_bytes(b"")
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare)
result, report = package(aetheria_ships)
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL \
or "Game folder" not in report or str(bare) in report or str(GAME) in report:
raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text")
# A tool under ModTools does not make a folder a game folder: GameData/Aetheria.cc is the marker.
unmarked = Path(tempfile.mkdtemp(prefix="aetheria-smoke-unmarked-"))
(unmarked / "ModTools").mkdir()
(unmarked / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")).write_bytes(b"")
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(unmarked)
result, report = package(aetheria_ships)
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL:
raise SystemExit("Pass 4: a folder without GameData/Aetheria.cc was not refused with the fixed text")
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
print(f"SMOKE_PACKAGE={path}")
print("SMOKE_PACKAGE_OK")

View file

@ -0,0 +1,170 @@
"""Blender-free tests for aetheria_ships/hull_grid.py, the arithmetic behind New Ship, Rasterise and the Grid object.
python -m unittest discover -s tools/blender/tests
hull_grid is loaded by path because the package __init__ imports bpy. Frame: nose toward -Y, starboard toward -X, cell
(x, y) centred on grid_origin + (-2x, -2y) metres.
"""
import importlib.util
import unittest
from pathlib import Path
# The dotted name is the module's path from the repo root, which mutmut keys its mutants by.
_spec = importlib.util.spec_from_file_location(
"tools.blender.aetheria_ships.hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py")
hull_grid = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(hull_grid)
def rect(x0, y0, x1, y1):
return [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))]
CORNER = [((1.99, 0.98), (2.0, 0.98), (2.0, 1.0))] # holds the bounding box at x 2 without covering a sample
def occupied(cells, height):
return {(i // height, i % height) for i, cell in enumerate(cells) if cell}
class CellMappingTests(unittest.TestCase):
ORIGIN = (3.0, -5.0)
def test_every_cell_of_a_5x3_grid_maps_back_to_itself(self):
for x in range(5):
for y in range(3):
centre = hull_grid.cell_centre(self.ORIGIN, x, y)
self.assertEqual(hull_grid.cell_at(self.ORIGIN, centre), (x, y))
def test_cell_1_0_is_two_metres_toward_minus_x_and_0_1_toward_minus_y(self):
origin = hull_grid.cell_centre(self.ORIGIN, 0, 0)
self.assertEqual(origin, self.ORIGIN)
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 1, 0), (1.0, -5.0))
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 0, 1), (3.0, -7.0))
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 4, 2), (-5.0, -9.0))
def test_cell_at_takes_the_nearest_centre_and_the_lower_cell_on_a_tie(self):
near = lambda dx, dy: hull_grid.cell_at(self.ORIGIN, (self.ORIGIN[0] - 2 + dx, self.ORIGIN[1] - 4 + dy))
self.assertEqual(near(0.9, -0.9), (1, 2))
self.assertEqual(near(-0.9, 0.9), (1, 2))
self.assertEqual(near(1.1, 0), (0, 2))
self.assertEqual(near(-1.1, 0), (2, 2))
self.assertEqual(near(0, 1.1), (1, 1))
self.assertEqual(near(0, -1.1), (1, 3))
self.assertEqual(near(1.0, 1.0), (0, 1))
self.assertEqual(near(-1.0, -1.0), (1, 2))
class InsideTests(unittest.TestCase):
TRIANGLE = ((0, 0), (1, 0), (0, 1))
def test_inside_points_are_inside_for_both_windings(self):
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in ((0.25, 0.25), (0.5, 0.25), (0.1, 0.8)):
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
def test_points_across_each_edge_are_outside_for_both_windings(self):
outside = ((0.5, -0.25), (0.75, 0.75), (-0.25, 0.5), (-0.5, -0.5), (1.5, 0.0), (0.0, 1.5), (2.0, 2.0))
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in outside:
self.assertFalse(hull_grid._inside(triangle, *point), (triangle, point))
def test_edges_and_corners_are_inside_for_both_windings(self):
on_edge = ((0.5, 0.0), (0.0, 0.5), (0.5, 0.5), (0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in on_edge:
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
class RasteriseTests(unittest.TestCase):
def test_eight_of_sixteen_samples_occupy_a_cell_and_seven_do_not(self):
wing = rect(-2, -1, 0, 1) # cell (1, 0) in full; fixes the grid at 2 x 1 cells about the origin
eight = rect(0, -1, 2, 0)
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
for triangles, expected in ((eight, True), (seven, False)):
width, height, cells, origin = hull_grid.rasterise(wing + triangles)
self.assertEqual((width, height, origin), (2, 1, (1.0, 0.0)))
self.assertEqual(cells, [expected, True])
def test_samples_sit_at_quarter_points_of_the_cell_on_each_axis(self):
wing = rect(-2, -1, 0, 1)
# Cell (0, 0) spans x 0..2, y -1..1; its sample rows are y = 0.75, 0.25, -0.25, -0.75 and columns x = 1.75 .. 0.25.
rows = hull_grid.rasterise(wing + rect(0, -0.3, 2, 0.3))[2] # y = 0.25 and -0.25: 8 samples
columns = hull_grid.rasterise(wing + rect(0.3, -1, 1.0, 1) + CORNER)[2] # x = 0.75 only: 4 samples
self.assertEqual((rows, columns), ([True, True], [False, True]))
def test_a_sample_on_a_triangle_bound_counts(self):
wing = rect(-2, -1, 0, 1)
self.assertEqual(hull_grid.rasterise(wing + rect(0, 0.25, 2, 0.75))[2], [True, True]) # rows 0.75 and 0.25
self.assertEqual(hull_grid.rasterise(wing + rect(0.25, -1, 0.75, 1) + CORNER)[2], [True, True]) # columns 0.75 and 0.25
def test_a_single_triangle_covers_only_its_own_half_of_its_bounding_box(self):
width, height, cells, _ = hull_grid.rasterise([((-2, -2), (2, -2), (-2, 2))])
self.assertEqual((width, height), (2, 2))
self.assertEqual(cells, [False, True, True, True])
def test_a_sample_inside_two_triangles_counts_once(self):
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
_, _, cells, _ = hull_grid.rasterise(rect(-2, -1, 0, 1) + seven + seven)
self.assertEqual(cells, [False, True])
def test_an_l_shape_gives_the_cells_of_the_l(self):
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
self.assertEqual((width, height, origin), (2, 2, (1.0, 1.0)))
self.assertEqual(occupied(cells, height), {(0, 0), (0, 1), (1, 1)})
self.assertEqual(cells, [True, True, False, True])
def test_winding_does_not_matter(self):
flipped = [(a, c, b) for a, b, c in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)]
self.assertEqual(hull_grid.rasterise(flipped)[2], [True, True, False, True])
def test_the_grid_is_ceil_of_the_extent_over_two_metres_on_each_axis(self):
for extent, cells_across in ((1.0, 1), (2.0, 1), (2.01, 2), (4.0, 2), (4.0000001, 2), (4.0005, 2), (4.002, 3), (4.01, 3), (5.0, 3), (20.0, 10)):
width, height, _, _ = hull_grid.rasterise(rect(0, 0, extent, 6.5))
self.assertEqual((width, height), (cells_across, 4), extent)
def test_the_grid_is_centred_on_the_silhouette(self):
width, height, cells, origin = hull_grid.rasterise(rect(10, 20, 13, 22))
self.assertEqual((width, height), (2, 1))
self.assertEqual(origin, (12.5, 21.0))
self.assertEqual(cells, [True, True])
def test_cell_centres_from_the_origin_lie_in_the_cells_they_name(self):
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
for x in range(width):
for y in range(height):
cx, cy = hull_grid.cell_centre(origin, x, y)
self.assertEqual(hull_grid.cell_at(origin, (cx, cy)), (x, y))
inside = any(hull_grid._inside(tri, cx, cy) for tri in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
self.assertEqual(inside, cells[x * height + y])
def test_samples_per_axis_and_cell_size_are_parameters(self):
triangles = rect(-1, -1, 1, 0)
self.assertEqual(hull_grid.rasterise(triangles, samples=1)[2], [True])
self.assertEqual(hull_grid.rasterise(triangles, cell_size=1.0)[:2], (2, 1))
def test_thirty_two_cells_are_accepted_and_thirty_three_is_refused_naming_it(self):
width, height, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 64))
self.assertEqual((width, height, len(cells), all(cells)), (1, 32, 32, True))
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(rect(0, 0, 2, 66))
self.assertEqual(str(caught.exception), "The hull needs 33 cells long; a ship grid holds at most 32")
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(rect(0, 0, 66, 2))
self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32")
def test_a_silhouette_with_no_area_is_refused(self):
for triangles in ([], [((0, 0), (1, 1), (2, 2))]):
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(triangles)
self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise")
def test_a_degenerate_triangle_does_not_widen_the_grid(self):
for line in (((50, 50), (60, 60), (70, 70)), ((51, 50), (53, 51), (55, 52)), ((50, 51), (51, 53), (52, 55))):
_, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [line])
self.assertEqual(cells, [True], line)
if __name__ == "__main__":
unittest.main()

View file

@ -339,6 +339,16 @@ class LayoutTests(ShipFileCase):
self.write(ship, hull)
self.assertNotEqual(revision, self.layout()[2])
def test_layout_revision_is_the_revision_replace_layout_returns_for_the_same_shape_and_rows(self):
_, _, revision = self.layout()
cells = [True, True, True, False]
rows = [hardpoint_row("thruster.starboard", tail=())] # a mount the file carried no later slots for
saved = self.edit(revision, cells=cells, hardpoints=rows)
self.assertEqual(saved, ship_cc.layout_revision(shape(2, 2, cells), rows))
self.assertEqual(saved, self.layout()[2])
self.assertNotEqual(saved, ship_cc.layout_revision(shape(2, 2, [True, True, False, False]), rows))
self.assertNotEqual(saved, ship_cc.layout_revision(shape(2, 2, cells), []))
def test_the_revision_follows_the_hardpoints_too(self):
_, _, revision = self.layout()
moved = hardpoint_row(tail=())
@ -447,7 +457,7 @@ class LayoutTests(ShipFileCase):
row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())),
"Shape": [shape(2, 1, cells)]})
with self.subTest(name):
with self.assertRaisesRegex(ValueError, "thruster.port footprint cells must be booleans"):
with self.assertRaisesRegex(ValueError, "Hardpoint footprint cells must be booleans"):
self.edit(revision, hardpoints=[row])
self.assertEqual(before, self.bytes())
@ -484,7 +494,7 @@ class LayoutTests(ShipFileCase):
("short", shape(2, 1, [True])), ("empty", shape(1, 1, [False]))):
row = ship_cc.encode_hardpoint(**{**ship_cc.decode_hardpoint(hardpoint_row(tail=())), "Shape": [footprint]})
with self.subTest(name):
with self.assertRaisesRegex(ValueError, "thruster.port has an invalid footprint"):
with self.assertRaisesRegex(ValueError, "hardpoint has an invalid footprint"):
self.edit(revision, hardpoints=[row])
self.assertEqual(before, self.bytes())
@ -537,6 +547,44 @@ class CSharpWrittenFixtureTests(unittest.TestCase):
shutil.copy(Path(os.environ["SHIP_FIXTURE_DIR"]) / "mod.skiff" / "skiff.glb", target / "skiff.glb")
class RefusalsNameNoInputTests(ShipFileCase):
def test_refusals_name_no_path_mount_or_anchor_id(self):
_, _, revision = ship_cc.read_layout(self.path, PACKAGES)
mount = "mount.zq-unique"
duplicate = [[ "anchor.zq-unique", "map-icon", "n1", None, 0], ["anchor.zq-unique", "shield", "n2", None, 0]]
bad_footprint = ship_cc.encode_hardpoint(
Type=3, Position=[1, 0], Shape=[[1, 1, [0]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0)
empty_footprint = ship_cc.encode_hardpoint(
Type=3, Position=[1, 0], Shape=[[1, 1, [False]]], Transform=mount, Rotation=0, Armor=0.0, FiringArc=0.0)
calls = (lambda: ship_cc.replace_visual(self.path, PACKAGES, "mod.skiff", "ship.glb", duplicate),
lambda: ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", revision, [2, 2, [True] * 4], [bad_footprint]),
lambda: ship_cc.replace_layout(self.path, PACKAGES, "mod.skiff", revision, [2, 2, [True] * 4], [empty_footprint]),
lambda: ship_cc.read(os.path.join(self.path, "missing.cc"), PACKAGES))
for call in calls:
with self.subTest(call):
with self.assertRaises(Exception) as caught:
call()
for forbidden in ("zq-unique", self.path, os.path.dirname(self.path)):
self.assertNotIn(forbidden, str(caught.exception))
class ShipIdTests(unittest.TestCase):
def test_admits_the_ids_the_c_sharp_rule_admits(self):
for ship_id in ("probe.a", "a", "0x", "a_b-c", "com10", "com0", "lpt0", "console", "aux2", "a.nul", "x.com1"):
with self.subTest(ship_id):
self.assertTrue(ship_cc.valid_ship_id(ship_id))
def test_refuses_device_names_alone_or_before_a_dot_and_every_other_malformed_id(self):
for ship_id in ("con", "nul.x", "com1", "lpt9", "prn", "aux.txt", "com5.a", "abc.", "a..", "Upper", ".x", "_x",
"-x", "", "a b", "a/b", "a:b", "abé", "a\b"):
with self.subTest(ship_id):
self.assertFalse(ship_cc.valid_ship_id(ship_id))
def test_the_constants_name_the_allowed_characters_and_the_reserved_stems(self):
self.assertEqual(set("abcdefghijklmnopqrstuvwxyz0123456789._-"), set(ship_cc.SHIP_ID_CHARS))
self.assertEqual(22, len(ship_cc.RESERVED_SHIP_IDS))
class HardpointRowTests(unittest.TestCase):
def test_a_row_is_one_value_per_member_in_slot_order(self):
fields = dict(Type=1, Position=[2, 3], Shape="shape", Transform="mount", Rotation=2, Armor=4.0, FiringArc=90.0)