diff --git a/.gitignore b/.gitignore index 2cbd223f9..811814afa 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ GameData/*.lock /Aetheria.Shared/obj/ /tools/**/bin/ /tools/**/obj/ +/ModTools/ /tests/**/bin/ /tests/**/obj/ diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 5093eb706..7120d7429 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -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().Any(occupied => occupied)) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 650286d75..02c4b64a8 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -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 /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 ` 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 `/GameData/Mods/`. + +From a repository checkout, publish AetherDb into the ignored `/ModTools` and +author from the repo as the game folder: + + dotnet publish tools/AetherDb -c Release -r --self-contained -p:PublishSingleFile=true -o ModTools + +with `` 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 diff --git a/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs new file mode 100644 index 000000000..6aa614182 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs @@ -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()); + 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()); + 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(AetherDb.FindRoot); + Assert.Contains("GameData holds Aetheria.cc", error.Message); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 14a9b1663..14f2b70b2 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -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()); diff --git a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs index f0a2283aa..452a70464 100644 --- a/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs @@ -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(); + 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() { diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 5b772bdd0..460918608 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -59,7 +59,25 @@ public sealed class ShipValidationTests { var ship = ShipAuthoringTests.Fixture(); ship.WithId(id); - Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).Message); + Assert.Contains("Ship ID must use", Assert.Throws(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(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"); diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 4a17e6b14..d5b4ffdb3 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -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."); } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index dc5dcfb8b..097fe1add 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -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)); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index bcf9f6f7f..1cf37fbd7 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -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 + diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py new file mode 100644 index 000000000..825ccea45 --- /dev/null +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -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 diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 6018d6a96..df76cdb6f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -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: diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py new file mode 100644 index 000000000..4fa26b9d7 --- /dev/null +++ b/tools/blender/tests/smoke_frame.py @@ -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 /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(" 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/ 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) diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index de5958ad1..e61b5c09d 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -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=. 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") diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py new file mode 100644 index 000000000..d9794e4ec --- /dev/null +++ b/tools/blender/tests/test_hull_grid.py @@ -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() diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 20d9cf6c4..fb9b9994e 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -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)