Merge eureka/aetheria-release-ships-addon-frame-r4: ships authoring tool and add-on frame
# Conflicts: # tests/Aetheria.Shared.Tests/ShipSchemaPinTests.cs
This commit is contained in:
commit
54ac0fcfa7
16 changed files with 1897 additions and 163 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -66,6 +66,7 @@ GameData/*.lock
|
|||
/Aetheria.Shared/obj/
|
||||
/tools/**/bin/
|
||||
/tools/**/obj/
|
||||
/ModTools/
|
||||
/tests/**/bin/
|
||||
/tests/**/obj/
|
||||
|
||||
|
|
|
|||
|
|
@ -199,14 +199,26 @@ public static class ShipAuthoringStore
|
|||
throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record.");
|
||||
}
|
||||
|
||||
// The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so
|
||||
// Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value.
|
||||
public static void RequireShipId(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Ship ID is required.");
|
||||
if (!(id[0] >= 'a' && id[0] <= 'z' || id[0] >= '0' && id[0] <= '9') || id.Any(c =>
|
||||
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
|
||||
throw new InvalidOperationException("Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
|
||||
if (id[id.Length - 1] == '.') throw new InvalidOperationException("Ship ID must not end with a dot.");
|
||||
var stem = id.Split('.')[0];
|
||||
if (stem is "con" or "prn" or "aux" or "nul"
|
||||
|| stem.Length == 4 && (stem.StartsWith("com") || stem.StartsWith("lpt")) && stem[3] >= '1' && stem[3] <= '9')
|
||||
throw new InvalidOperationException("Ship ID must not be a Windows device name.");
|
||||
}
|
||||
|
||||
// The one semantic check every path shares: the hull and its visual, judged together.
|
||||
public static void Validate(HullData hull, ShipAuthoring ship)
|
||||
{
|
||||
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
|
||||
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
|
||||
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
|
||||
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
|
||||
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
|
||||
RequireShipId(ship.Id);
|
||||
if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required.");
|
||||
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
|
||||
if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))
|
||||
|
|
|
|||
|
|
@ -61,12 +61,30 @@ weapon mount object. Thrusters and radiators are mesh objects; the others may
|
|||
be empties. Package exports the collection, Grease Pencil excluded, to
|
||||
`ship.glb` beside the `.cc`, writes the anchors, captures the lines when the
|
||||
collection holds exactly one Grease Pencil object, and shows AetherDb's
|
||||
verdict in the panel. It runs `dotnet run --project <repo>/tools/AetherDb`;
|
||||
the repo is the add-on preference **Aetheria repo**, or by default the nearest
|
||||
folder above the `.cc` holding `tools/AetherDb`.
|
||||
verdict in the panel. It runs the AetherDb in the game folder's `ModTools`
|
||||
(see Tools below).
|
||||
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
|
||||
hull, model, and anchor authoring, and judges `ship.glb` once it exists.
|
||||
|
||||
## Tools
|
||||
|
||||
Install Blender and the `aetheria_ships` add-on. In the add-on's preferences set
|
||||
**Game folder** to your Aetheria install, the folder holding `GameData` (its
|
||||
`GameData/Aetheria.cc` and `GameData/Mods`). Left empty, the add-on uses the
|
||||
nearest folder above the bound `.cc` whose `GameData` holds `Aetheria.cc`.
|
||||
AetherDb ships with the game in `ModTools`; the add-on runs it with the game
|
||||
folder as its working directory, and needs neither Unity, the Aetheria repository
|
||||
nor dotnet. New Ship creates the package under `<game folder>/GameData/Mods/<id>`.
|
||||
|
||||
From a repository checkout, publish AetherDb into the ignored `<repo>/ModTools` and
|
||||
author from the repo as the game folder:
|
||||
|
||||
dotnet publish tools/AetherDb -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o ModTools
|
||||
|
||||
with `<rid>` one of `win-x64`, `linux-x64`, `osx-arm64`. Linux needs `libssl3`
|
||||
and `libicu`; on macOS clear the quarantine flag (`xattr -d com.apple.quarantine
|
||||
ModTools/AetherDb`).
|
||||
|
||||
## Objective
|
||||
|
||||
A mod author edits one ship `.cc` file, including the hull schematic and every
|
||||
|
|
|
|||
50
tests/Aetheria.Shared.Tests/AetherDbRootTests.cs
Normal file
50
tests/Aetheria.Shared.Tests/AetherDbRootTests.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
[CollectionDefinition("process working directory", DisableParallelization = true)]
|
||||
public sealed class ProcessWorkingDirectoryCollection { }
|
||||
|
||||
// FindRoot reads the process working directory, so these run alone.
|
||||
[Collection("process working directory")]
|
||||
public sealed class AetherDbRootTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _game = new TempDirectory();
|
||||
private readonly string _previous = Environment.CurrentDirectory;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.CurrentDirectory = _previous;
|
||||
_game.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRootIsTheNearestFolderWhoseGameDataHoldsAetheriaCc()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData", "Mods", "probe.a"));
|
||||
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
|
||||
Environment.CurrentDirectory = _game.Path;
|
||||
var game = Environment.CurrentDirectory;
|
||||
Environment.CurrentDirectory = Path.Combine(game, "GameData", "Mods", "probe.a");
|
||||
Assert.Equal(game, AetherDb.FindRoot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRootIsTheWorkingDirectoryItselfWhenItsGameDataHoldsAetheriaCc()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData"));
|
||||
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
|
||||
Environment.CurrentDirectory = _game.Path;
|
||||
Assert.Equal(Environment.CurrentDirectory, AetherDb.FindRoot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_game.Path, "Aetheria.Shared"));
|
||||
File.WriteAllText(Path.Combine(_game.Path, "Aetheria.Shared", "Aetheria.Shared.csproj"), "");
|
||||
Environment.CurrentDirectory = _game.Path;
|
||||
var error = Assert.Throws<DirectoryNotFoundException>(AetherDb.FindRoot);
|
||||
Assert.Contains("GameData holds Aetheria.cc", error.Message);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -76,6 +76,39 @@ public sealed class ShipSchemaPinTests
|
|||
Assert.Equal(Strings("ANCHOR_MEMBERS").Length, KeyOf(typeof(ShipAnchor), nameof(ShipAnchor.Joint)));
|
||||
}
|
||||
|
||||
// New Ship refuses a bad ship ID before it touches anything, with ship_cc.valid_ship_id; these two constants are that
|
||||
// rule's data, so the C# rule that create and Validate share must agree with them on every character and every stem.
|
||||
[Fact]
|
||||
public void PythonShipIdConstantsAreTheRuleRequireShipIdEnforces()
|
||||
{
|
||||
var allowed = Text("SHIP_ID_CHARS");
|
||||
var reserved = Strings("RESERVED_SHIP_IDS");
|
||||
for (var code = 0; code < 256; code++)
|
||||
{
|
||||
var c = (char)code;
|
||||
Assert.Equal(allowed.Contains(c), Accepts("a" + c + "a"));
|
||||
Assert.Equal(allowed.Contains(c) && c != '.' && c != '_' && c != '-', Accepts(c + "a"));
|
||||
}
|
||||
var stems = new List<string>();
|
||||
for (var a = 'a'; a <= 'z'; a++)
|
||||
for (var b = 'a'; b <= 'z'; b++)
|
||||
for (var c = 'a'; c <= 'z'; c++)
|
||||
stems.Add($"{a}{b}{c}");
|
||||
for (var digit = 0; digit <= 9; digit++)
|
||||
stems.AddRange(new[] { $"com{digit}", $"lpt{digit}" });
|
||||
foreach (var stem in stems)
|
||||
{
|
||||
Assert.Equal(!reserved.Contains(stem), Accepts(stem));
|
||||
Assert.Equal(!reserved.Contains(stem), Accepts(stem + ".x"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Accepts(string id)
|
||||
{
|
||||
try { ShipAuthoringStore.RequireShipId(id); return true; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PythonEnumNamesAreTheEnumsInValueOrder()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,7 +59,25 @@ public sealed class ShipValidationTests
|
|||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
Assert.Contains("ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
Assert.Contains("Ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("com0"), InlineData("console"), InlineData("lpt"), InlineData("auxiliary"), InlineData("a.con"), InlineData("com10")]
|
||||
public void IdsThatOnlyResembleDeviceNamesAreAdmitted(string id)
|
||||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
ship.Validate();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("con"), InlineData("abc.")]
|
||||
public void IdRefusalsNameNoValue(string id)
|
||||
{
|
||||
var ship = ShipAuthoringTests.Fixture();
|
||||
ship.WithId(id);
|
||||
Assert.DoesNotContain(id, Assert.Throws<InvalidOperationException>(ship.Validate).Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -157,6 +175,10 @@ public sealed class ShipValidationTests
|
|||
|
||||
Add("id-null", s => s.Visual.Id = null, "Ship ID is required");
|
||||
Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required");
|
||||
foreach (var device in new[] { "con", "prn", "aux", "nul", "nul.x", "com1", "com9", "lpt1", "lpt9", "con.txt" })
|
||||
Add("id-device-" + device, s => s.Visual.Id = device, "Windows device name");
|
||||
foreach (var id in new[] { "abc.", "a.", "ab.", "a.b." })
|
||||
Add("id-trailing-dot-" + id, s => s.Visual.Id = id, "must not end with a dot");
|
||||
Add("hull-null", s => s.Hull = null, "hull data is required");
|
||||
Add("hull-name-null", s => s.Hull.Name = null, "hull name is required");
|
||||
Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required");
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
85
tools/blender/aetheria_ships/hull_grid.py
Normal file
85
tools/blender/aetheria_ships/hull_grid.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""The ship grid's arithmetic: where a cell is, and which cells a hull's top-down silhouette occupies. Imports without bpy.
|
||||
|
||||
Frame: the Ship Root frame in metres, nose toward -Y and starboard toward -X (map M7). Cell (x, y) is centred on its
|
||||
integer coordinate, so its centre is grid_origin + (-CELL_SIZE x, -CELL_SIZE y), where grid_origin is cell (0, 0)'s
|
||||
centre (the Aetheria.Entity.ToWorldPoint rule: schematic +x is starboard, +y is forward). grid_origin is a stored fact
|
||||
written only by Rasterise; every lookup below takes it as an argument.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
CELL_SIZE = 2.0 # metres per cell, Settings.SchematicCellSize
|
||||
MAX_CELLS = 32 # per axis, the layout panel's limit
|
||||
SAMPLES = 4 # per axis per cell: 4 x 4 coverage samples
|
||||
_EDGE = 1e-9 # slack for float noise on a sample bound, in sample steps
|
||||
_TOLERANCE = 1e-3 # metres: mesh coordinates are float32, so an extent a millimetre over a cell boundary is on it
|
||||
|
||||
|
||||
def cell_centre(grid_origin, x, y):
|
||||
"""The Ship Root frame position of cell (x, y)'s centre."""
|
||||
return (grid_origin[0] - CELL_SIZE * x, grid_origin[1] - CELL_SIZE * y)
|
||||
|
||||
|
||||
def cell_at(grid_origin, point):
|
||||
"""The cell whose centre is nearest to point; a point midway between two centres belongs to the lower cell."""
|
||||
return (math.ceil((grid_origin[0] - point[0]) / CELL_SIZE - 0.5),
|
||||
math.ceil((grid_origin[1] - point[1]) / CELL_SIZE - 0.5))
|
||||
|
||||
|
||||
def _cells_across(extent, cell_size):
|
||||
return max(1, math.ceil((extent - _TOLERANCE) / cell_size))
|
||||
|
||||
|
||||
def _inside(triangle, px, py):
|
||||
(ax, ay), (bx, by), (cx, cy) = triangle
|
||||
d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
|
||||
d2 = (cx - bx) * (py - by) - (cy - by) * (px - bx)
|
||||
d3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx)
|
||||
return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0))
|
||||
|
||||
|
||||
def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES):
|
||||
"""Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame.
|
||||
|
||||
The grid is ceil(extent / cell_size) cells on each axis, centred on the silhouette's bounding box. Each cell is
|
||||
tested at samples x samples points; it is occupied when at least half of them fall in some triangle. A sample
|
||||
inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box.
|
||||
Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell
|
||||
(0, 0)'s centre."""
|
||||
triangles = [tri for tri in triangles_xy if
|
||||
(tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])]
|
||||
if not triangles:
|
||||
raise ValueError("The hull has no triangle with area to rasterise")
|
||||
xs = [p[0] for tri in triangles for p in tri]
|
||||
ys = [p[1] for tri in triangles for p in tri]
|
||||
width = _cells_across(max(xs) - min(xs), cell_size)
|
||||
height = _cells_across(max(ys) - min(ys), cell_size)
|
||||
for axis, count in (("wide", width), ("long", height)):
|
||||
if count > MAX_CELLS:
|
||||
raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}")
|
||||
# Cell (0, 0)'s centre: the far (+X, +Y) corner of the grid, half a cell in.
|
||||
origin = ((min(xs) + max(xs)) / 2 + (width - 1) * cell_size / 2, (min(ys) + max(ys)) / 2 + (height - 1) * cell_size / 2)
|
||||
step = cell_size / samples
|
||||
# Sample (a, b) sits at origin + cell_size/2 - (a + 1/2) step on each axis, so it falls in cell (a // samples, b // samples).
|
||||
top_x, top_y = origin[0] + cell_size / 2, origin[1] + cell_size / 2
|
||||
|
||||
def span(low, high, top, count):
|
||||
first = math.ceil((top - high) / step - 0.5 - _EDGE)
|
||||
last = math.floor((top - low) / step - 0.5 + _EDGE)
|
||||
return range(max(first, 0), min(last, count * samples - 1) + 1)
|
||||
|
||||
hits = set()
|
||||
for tri in triangles:
|
||||
txs = [p[0] for p in tri]
|
||||
tys = [p[1] for p in tri]
|
||||
for a in span(min(txs), max(txs), top_x, width):
|
||||
px = top_x - (a + 0.5) * step
|
||||
for b in span(min(tys), max(tys), top_y, height):
|
||||
if (a, b) not in hits and _inside(tri, px, top_y - (b + 0.5) * step):
|
||||
hits.add((a, b))
|
||||
counts = {}
|
||||
for a, b in hits:
|
||||
key = (a // samples, b // samples)
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
cells = [2 * counts.get((x, y), 0) >= samples * samples for x in range(width) for y in range(height)]
|
||||
return width, height, cells, origin
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
781
tools/blender/tests/smoke_frame.py
Normal file
781
tools/blender/tests/smoke_frame.py
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
"""Headless Blender smoke for the add-on frame: New Ship, Rasterise, Flip Nose, Save, the Grid object, undo and Package.
|
||||
|
||||
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py
|
||||
|
||||
Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its
|
||||
ModTools, no dotnet), and msgpack importable. The ship is created under <game folder>/GameData/Mods/smoke.frame, which
|
||||
this smoke removes when it ends.
|
||||
|
||||
Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni: nothing
|
||||
is written, ed.undo takes the ship back, and the first Save creates the file. Pass 2 makes a Save fail after create and
|
||||
requires the folder gone and the retry to succeed. Pass 3 rasterises after a scale change: the .cc is unchanged until Save
|
||||
and ed.undo returns the cells and the grid placement together. Pass 4 adds hardpoints and saves. Pass 5 puts meshes
|
||||
outside Ship Root: Flip Nose and Rasterise refuse and change nothing; parented, Flip Nose moves the nub's cells from the
|
||||
aft rows to the forward rows. Pass 6 packages: an unsaved buffer is refused, the saved one validates, and the GLB holds
|
||||
neither the Source nor the Grid. Every refusal report names neither the ship ID, the collection, an object nor a path.
|
||||
|
||||
Pass 7 pins the guards that protect an author's files: a Save over an existing ship.cc is refused and leaves it, a failed
|
||||
Save leaves the files that were in the folder before, New Ship takes none of the previous ship's hardpoints, and a buffer
|
||||
that belongs to another ship is neither saved nor rasterised. Pass 8 calls the operators with undo=True, which pushes the
|
||||
native undo step in background Blender only for an operator that carries UNDO: Rasterise, Flip Nose and New Ship are
|
||||
undoable, Save is not.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import addon_utils
|
||||
import bmesh
|
||||
import bpy
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
|
||||
SHIP_ID = "smoke.frame"
|
||||
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
|
||||
MODS = GAME / "GameData" / "Mods"
|
||||
COLLECTION = "Smoke Frame"
|
||||
GUARD_A, GUARD_B, UNDO_SHIP = "smoke.guard.a", "smoke.guard.b", "smoke.undo"
|
||||
EXTRA_IDS = (GUARD_A, GUARD_B, UNDO_SHIP)
|
||||
NAMES_NOT_ECHOED = (SHIP_ID, COLLECTION, "Second Frame", "Loose Nub", "Hull", "NoSuchHullXYZ", str(GAME), GAME.name)
|
||||
|
||||
|
||||
def new_object(collection, name, mesh, location=(0, 0, 0), parent=None):
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
obj.location = location
|
||||
obj.parent = parent
|
||||
collection.objects.link(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def box(name, half_x, half_y, half_z):
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
corners = [(sx * half_x, sy * half_y, sz * half_z) for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]
|
||||
mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)])
|
||||
return mesh
|
||||
|
||||
|
||||
def tetrahedron(name):
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)])
|
||||
return mesh
|
||||
|
||||
|
||||
def tag(obj, role, anchor_id):
|
||||
obj["aetheria.role"] = role
|
||||
obj["aetheria.id"] = anchor_id
|
||||
return obj
|
||||
|
||||
|
||||
def run(operator):
|
||||
"""The operator's result and, for an operator that reported an error, the report (bpy.ops raises it)."""
|
||||
try:
|
||||
return operator(), ""
|
||||
except RuntimeError as exc:
|
||||
print(f"OPERATOR error: {exc}")
|
||||
return {"CANCELLED"}, str(exc)
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise SystemExit(message)
|
||||
|
||||
|
||||
def refused(operator, expected, what):
|
||||
"""Requires a CANCELLED operator whose report holds expected and echoes none of NAMES_NOT_ECHOED."""
|
||||
result, message = run(operator)
|
||||
require(result == {"CANCELLED"}, f"{what} was not refused")
|
||||
require(expected in message, f"{what}: the report is not the fixed text ({message!r})")
|
||||
for name in NAMES_NOT_ECHOED:
|
||||
require(name not in message, f"{what}: the report echoes an input ({name})")
|
||||
return message
|
||||
|
||||
|
||||
def saved_layout(path, aetheria_ships):
|
||||
shape, hardpoints, revision = aetheria_ships.ship_cc.read_layout(path, PACKAGES)
|
||||
width, height, cells = shape
|
||||
return width, height, {(i // height, i % height) for i, cell in enumerate(cells) if cell}, hardpoints, revision
|
||||
|
||||
|
||||
def buffer(scene):
|
||||
"""The layout buffer as (width, height, occupied cells, hardpoint count)."""
|
||||
state = scene.aetheria_layout
|
||||
return (state.width, state.height,
|
||||
{(i // state.height, i % state.height) for i, cell in enumerate(state.cells) if cell.occupied},
|
||||
len(state.hardpoints))
|
||||
|
||||
|
||||
def row_count(cells, y):
|
||||
return sum(1 for _, cy in cells if cy == y)
|
||||
|
||||
|
||||
def rounded(matrix):
|
||||
return tuple(round(value, 4) for row in matrix for value in row)
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def glb_node_names(path):
|
||||
data = Path(path).read_bytes()
|
||||
length, kind = struct.unpack_from("<II", data, 12)
|
||||
require(kind == 0x4E4F534A, "The GLB's first chunk is not JSON")
|
||||
return {node.get("name") for node in json.loads(data[20:20 + length])["nodes"]}
|
||||
|
||||
|
||||
def child(collection, kind):
|
||||
return next((c for c in collection.children if c.get("aetheria.frame") == kind), None)
|
||||
|
||||
|
||||
def grid_faces():
|
||||
grid = bpy.data.objects.get("Grid")
|
||||
return len(grid.data.polygons) if grid else None
|
||||
|
||||
|
||||
def select(obj):
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
|
||||
|
||||
def undo_to_before(step):
|
||||
"""Undoes the last operator and returns the fresh scene and ship collection: memfile undo replaces the data-blocks,
|
||||
so every Python reference from before is stale."""
|
||||
result = bpy.ops.ed.undo()
|
||||
require(result == {"FINISHED"}, f"ed.undo after {step} did not run")
|
||||
return bpy.context.scene, bpy.data.collections[COLLECTION]
|
||||
|
||||
|
||||
def new_ship(scene, ship_id=SHIP_ID, name="Smoke Frame", reference="Djinni", length=10):
|
||||
scene.aetheria_new_ship_id = ship_id
|
||||
scene.aetheria_new_ship_name = name
|
||||
scene.aetheria_new_ship_like = reference
|
||||
scene.aetheria_new_ship_length = length
|
||||
return run(bpy.ops.aetheria.new_ship)
|
||||
|
||||
|
||||
def main():
|
||||
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
|
||||
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
|
||||
sys.path.insert(0, str(REPO / "tools" / "blender"))
|
||||
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
|
||||
import aetheria_ships
|
||||
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first")
|
||||
path = str(MODS / SHIP_ID / "ship.cc")
|
||||
|
||||
scene = bpy.context.scene
|
||||
collection = bpy.data.collections.new(COLLECTION)
|
||||
scene.collection.children.link(collection)
|
||||
sphere_mesh = bpy.data.meshes.new("Sphere")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5)
|
||||
bm.to_mesh(sphere_mesh)
|
||||
bm.free()
|
||||
sphere = new_object(collection, "Source Sphere", sphere_mesh, location=(3, 0, 0))
|
||||
sphere.scale = (1, 0.6, 0.4)
|
||||
select(sphere)
|
||||
|
||||
# Refusals come first and change nothing: the report is fixed text.
|
||||
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
|
||||
preferences.game_folder = ""
|
||||
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = SHIP_ID, "Smoke Frame", "Djinni"
|
||||
refused(bpy.ops.aetheria.new_ship, "Game folder preference", "New Ship with no game folder and an unsaved .blend")
|
||||
preferences.game_folder = str(GAME)
|
||||
scene.aetheria_new_ship_id = "con"
|
||||
scene.aetheria_new_ship_name = "Smoke Frame"
|
||||
scene.aetheria_new_ship_like = "Djinni"
|
||||
message = refused(bpy.ops.aetheria.new_ship, "Windows device name", "New Ship with the ID con")
|
||||
require(not re.search(r"con", message), "The bad-ID report echoes the ID")
|
||||
scene.aetheria_new_ship_id = SHIP_ID
|
||||
scene.aetheria_new_ship_like = ""
|
||||
refused(bpy.ops.aetheria.new_ship, "reference hull", "New Ship without a reference")
|
||||
require("aetheria.id" not in collection and _root_count() == 0 and sphere.parent is None,
|
||||
"A refused New Ship changed the collection")
|
||||
|
||||
# Pass 1: New Ship writes nothing; undo takes it back; the first Save creates the file.
|
||||
bpy.ops.ed.undo_push(message="before New Ship")
|
||||
result, message = new_ship(scene)
|
||||
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
|
||||
bpy.ops.ed.undo_push(message="New Ship") # operators called from a script push no undo step, as the UI would
|
||||
require(not (MODS / SHIP_ID).exists(), "New Ship wrote to the game folder")
|
||||
require(list(collection.get("aetheria.pending")) == ["Smoke Frame", "Djinni"], "The collection is not pending")
|
||||
root = next((obj for obj in collection.objects if obj.get("aetheria.ship_root")), None)
|
||||
require(root is not None and root.name == "Ship Root", "New Ship made no Ship Root")
|
||||
require(tuple(root.scale) == (1, 1, 1), "Ship Root's scale is not 1")
|
||||
source = child(collection, "Source")
|
||||
require(source is not None and sphere.users_collection == (source,), "The sphere is not in Source alone")
|
||||
require(sphere.parent == root, "The sphere is not parented to Ship Root")
|
||||
require(max(vertex.co.length for vertex in sphere.data.vertices) < 0.5 + 1e-6 and sphere.scale.x > 1,
|
||||
"New Ship baked the placement into the mesh instead of the object's own matrix")
|
||||
world = [sphere.matrix_world @ vertex.co for vertex in sphere.data.vertices]
|
||||
dx, dy = (max(v[i] for v in world) - min(v[i] for v in world) for i in (0, 1))
|
||||
print(f"DIMENSIONS {dx:.3f} x {dy:.3f}")
|
||||
require(abs(dy - 20.0) < 0.4 and dx < dy, f"The sphere's long extent is not 20 m on Y: {dx} x {dy}")
|
||||
width, height, cells, hardpoints = buffer(scene)
|
||||
require((width, height) == (6, 10) and hardpoints == 0, f"The buffer is {width}x{height} with {hardpoints} hardpoints")
|
||||
require((0, 0) not in cells and (width // 2, height // 2) in cells, "The ellipse's cells are wrong")
|
||||
require(not scene.aetheria_layout.revision, "A pending ship's buffer has a revision")
|
||||
origin = collection.get("aetheria.grid_origin")
|
||||
require(origin is not None and len(origin) == 4 and (origin[2], origin[3]) == (width, height),
|
||||
"aetheria.grid_origin does not carry the grid's size")
|
||||
centre = sphere.matrix_world.translation
|
||||
grid_centre = (origin[0] - (width - 1), origin[1] - (height - 1))
|
||||
require(abs(grid_centre[0] - centre.x) < 1e-3 and abs(grid_centre[1] - centre.y) < 1e-3,
|
||||
f"The grid is not centred on the hull: {grid_centre} against {tuple(centre)[:2]}")
|
||||
generated = child(collection, "Generated")
|
||||
grid = bpy.data.objects.get("Grid")
|
||||
require(generated is not None and grid is not None and grid.users_collection == (generated,),
|
||||
"The Grid object is not in Generated")
|
||||
require(grid.parent == root and grid.display_type == "WIRE", "The Grid is not a wire child of Ship Root")
|
||||
require(len(grid.data.polygons) == len(cells) and len(grid.data.edges) == 4 * width * height,
|
||||
"The Grid does not draw occupied cells as faces and every cell's edges")
|
||||
refused(bpy.ops.aetheria.load_ship_layout, "Not saved yet", "Load on a pending ship")
|
||||
|
||||
scene, collection = undo_to_before("New Ship")
|
||||
require("aetheria.id" not in collection and "aetheria.pending" not in collection, "Undo left the collection bound")
|
||||
require(bpy.data.objects.get("Ship Root") is None and bpy.data.objects.get("Grid") is None, "Undo left Ship Root or the Grid")
|
||||
sphere = bpy.data.objects["Source Sphere"]
|
||||
require(sphere.users_collection == (collection,) and sphere.parent is None, "Undo did not return the sphere")
|
||||
select(sphere)
|
||||
result, message = new_ship(scene)
|
||||
require(result == {"FINISHED"}, f"New Ship after undo was refused: {message}")
|
||||
root = bpy.data.objects["Ship Root"]
|
||||
placed_scale, placed_rotation = tuple(sphere.scale), tuple(sphere.rotation_euler)
|
||||
print("PASS 1 new ship ok")
|
||||
|
||||
# Pass 2: a Save that fails in create or after it leaves nothing behind and tells the author nothing of the input;
|
||||
# the retry succeeds.
|
||||
collection["aetheria.pending"] = ["Smoke Frame", "NoSuchHullXYZ"]
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "AetherDb refused to create the ship", "Save with an unknown reference hull")
|
||||
require(not (MODS / SHIP_ID).exists(), "A refused create left the ship folder behind")
|
||||
collection["aetheria.pending"] = ["Smoke Frame", "Djinni"]
|
||||
real = aetheria_ships.replace_layout
|
||||
|
||||
def failing(*args, **kwargs):
|
||||
raise ValueError("probe: replace_layout failed")
|
||||
|
||||
aetheria_ships.replace_layout = failing
|
||||
try:
|
||||
message = refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save with a failing write")
|
||||
finally:
|
||||
aetheria_ships.replace_layout = real
|
||||
require(not (MODS / SHIP_ID).exists(), "A failed first Save left the ship folder behind")
|
||||
require(collection.get("aetheria.pending") is not None, "A failed first Save cleared aetheria.pending")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"The retried Save was refused: {message}")
|
||||
require(collection.get("aetheria.pending") is None, "Save left aetheria.pending set")
|
||||
width, height, cells, hardpoints, revision = saved_layout(path, aetheria_ships)
|
||||
require((width, height, cells) == buffer(scene)[:3], "The saved .cc is not the buffer")
|
||||
require(scene.aetheria_layout.revision == revision, "The buffer did not record the saved revision")
|
||||
require(aetheria_ships.ship_cc.read(path, PACKAGES).ship.body[0] == SHIP_ID, "The saved file is not the ship")
|
||||
require(grid_faces() == len(cells), "Save did not redraw the Grid")
|
||||
refused_existing = _second_collection_refused()
|
||||
print("PASS 2 save creates and cleans up ok", refused_existing)
|
||||
|
||||
# Pass 3: Rasterise edits the buffer and the placement, never the file; undo returns both.
|
||||
saved_digest = digest(path)
|
||||
before = buffer(scene)
|
||||
before_origin = list(collection["aetheria.grid_origin"])
|
||||
select(bpy.data.objects["Source Sphere"])
|
||||
bpy.ops.ed.undo_push(message="before scale")
|
||||
bpy.data.objects["Source Sphere"].scale = (placed_scale[0] / 2, placed_scale[1], placed_scale[2])
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"}, f"Rasterise was refused: {message}")
|
||||
after = buffer(scene)
|
||||
after_origin = list(collection["aetheria.grid_origin"])
|
||||
bpy.ops.ed.undo_push(message="Rasterise")
|
||||
require(after[:2] != before[:2] and after_origin[2:] == list(after[:2]), f"Rasterise did not resize: {after[:2]}")
|
||||
require(digest(path) == saved_digest, "Rasterise wrote the .cc")
|
||||
scene, collection = undo_to_before("Rasterise")
|
||||
require(buffer(scene) == before and list(collection["aetheria.grid_origin"]) == before_origin,
|
||||
"Undo did not return the cells and the grid placement together")
|
||||
require(grid_faces() == len(before[2]), "Undo left a Grid that does not draw the restored cells")
|
||||
print("PASS 3 rasterise and undo ok")
|
||||
|
||||
# Pass 4: hardpoints in the buffer survive Rasterise and a second Save; a toggled cell changes the Grid.
|
||||
select(bpy.data.objects["Source Sphere"])
|
||||
for index, (mount, x, y) in enumerate((("mount.a", 1, 2), ("mount.b", 3, 4))):
|
||||
bpy.ops.aetheria.add_ship_hardpoint()
|
||||
hp = bpy.context.scene.aetheria_layout.hardpoints[index]
|
||||
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, "5", x, y, "#" # Reactor: internal, no anchor
|
||||
hp.armor, hp.firing_arc = 5.0, 90.0
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save with hardpoints was refused: {message}")
|
||||
rows = saved_layout(path, aetheria_ships)[3]
|
||||
require(len(rows) == 2, "The saved .cc does not hold two hardpoints")
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"} and buffer(bpy.context.scene)[3] == 2, "Rasterise dropped the buffer's hardpoints")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"The second Save was refused: {message}")
|
||||
require(saved_layout(path, aetheria_ships)[3] == rows, "The second Save changed the hardpoint rows")
|
||||
scene = bpy.context.scene
|
||||
faces = grid_faces()
|
||||
state = scene.aetheria_layout
|
||||
cell = next(c for c in state.cells if c.occupied)
|
||||
cell.occupied = False
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"} and grid_faces() == faces - 1, "Toggling a cell and Save did not redraw the Grid")
|
||||
state.new_width = state.width + 1 # a layout resized after the grid was placed: the placement no longer fits it
|
||||
bpy.ops.aetheria.resize_ship_layout()
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"} and bpy.data.objects.get("Grid") is None,
|
||||
"A Grid placed for another size is still drawn after the layout was resized")
|
||||
print("PASS 4 hardpoints and save ok")
|
||||
|
||||
# Pass 5: meshes outside Ship Root stop Flip Nose and Rasterise; parented, the flip moves the nub's cells.
|
||||
collection = bpy.data.collections[COLLECTION]
|
||||
root = bpy.data.objects["Ship Root"]
|
||||
sphere = bpy.data.objects["Source Sphere"]
|
||||
sphere.scale = placed_scale
|
||||
centre = sphere.matrix_world.translation
|
||||
render = new_object(collection, "Hull", bpy.data.meshes["Sphere"].copy(), location=tuple(sphere.location))
|
||||
render.scale, render.rotation_euler = placed_scale, placed_rotation
|
||||
nub = new_object(collection, "Loose Nub", box("Nub", 4.0, 3.0, 0.5), location=(centre.x, centre.y + 12.0, 0))
|
||||
select(render)
|
||||
snapshot = (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]),
|
||||
[tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children])
|
||||
for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise"),
|
||||
(bpy.ops.aetheria.package_ship, "Package")):
|
||||
refused(operator, "2 meshes in the ship collection are not under Ship Root", f"{what} with loose meshes")
|
||||
require(nub.select_get() and render.select_get(), f"{what} did not select the loose meshes")
|
||||
require(snapshot == (buffer(bpy.context.scene), list(collection["aetheria.grid_origin"]),
|
||||
[tuple(map(tuple, child_obj.matrix_world)) for child_obj in root.children]),
|
||||
f"{what} changed the scene while refusing")
|
||||
render.parent = root # Ship Root's descendants count, however deep: the nub hangs from the hull
|
||||
render.matrix_parent_inverse = root.matrix_world.inverted()
|
||||
nub.parent = render
|
||||
nub.matrix_parent_inverse = render.matrix_world.inverted()
|
||||
select(render)
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"}, f"Rasterise Hull was refused: {message}")
|
||||
width, height, before, _ = buffer(bpy.context.scene)
|
||||
aft, forward = row_count(before, 0), row_count(before, height - 1)
|
||||
print(f"RASTERISE {width}x{height} aft={aft} forward={forward}")
|
||||
require(aft > forward, "The nub's cells are not on the aft rows before the flip")
|
||||
real_rasterise = aetheria_ships._rasterise
|
||||
turned = [rounded(child_obj.matrix_world) for child_obj in root.children]
|
||||
|
||||
def failing_rasterise(*args, **kwargs):
|
||||
raise ValueError("probe: rasterise failed")
|
||||
|
||||
aetheria_ships._rasterise = failing_rasterise
|
||||
try:
|
||||
refused(bpy.ops.aetheria.flip_nose, "probe: rasterise failed", "Flip Nose whose Rasterise fails")
|
||||
finally:
|
||||
aetheria_ships._rasterise = real_rasterise
|
||||
require(turned == [rounded(child_obj.matrix_world) for child_obj in root.children],
|
||||
"A Flip Nose whose Rasterise failed left the hull turned")
|
||||
result, message = run(bpy.ops.aetheria.flip_nose)
|
||||
require(result == {"FINISHED"}, f"Flip Nose was refused: {message}")
|
||||
_, _, after, _ = buffer(bpy.context.scene)
|
||||
require(row_count(after, height - 1) == aft and row_count(after, 0) == forward,
|
||||
"Flip Nose did not move the nub's cells from the aft rows to the forward rows")
|
||||
require({(width - 1 - x, height - 1 - y) for x, y in before} == after, "The flipped grid is not the turned grid")
|
||||
# A Source mesh that is not parented to Ship Root is loose too: Rasterise would read it and Flip Nose would not turn it.
|
||||
sphere = bpy.data.objects["Source Sphere"]
|
||||
sphere.parent = None
|
||||
for operator, what in ((bpy.ops.aetheria.flip_nose, "Flip Nose"), (bpy.ops.aetheria.rasterise_hull, "Rasterise")):
|
||||
select(render)
|
||||
refused(operator, "1 meshes in the ship collection are not under Ship Root", f"{what} with an unparented Source mesh")
|
||||
require(sphere.select_get(), f"{what} did not select the unparented Source mesh")
|
||||
sphere.parent = root
|
||||
select(render)
|
||||
print("PASS 5 loose meshes, rasterise and flip nose ok")
|
||||
|
||||
# Pass 6: Package refuses an unsaved buffer, then the saved one validates; the GLB leaves Source and Grid out.
|
||||
scene = bpy.context.scene
|
||||
collection = bpy.data.collections[COLLECTION]
|
||||
root = bpy.data.objects["Ship Root"]
|
||||
tag(new_object(collection, "Map Icon", box("Map Icon", 0.5, 0.5, 0.01), (0, 0, -1), root), "map-icon", "map")
|
||||
tag(new_object(collection, "Hull Collider", tetrahedron("Hull Collider"), parent=root), "hull-collider", "collider")
|
||||
tag(new_object(collection, "Shield", None, parent=root), "shield", "shield")
|
||||
tag(new_object(collection, "Tractor", None, (0, 2, 0), root), "tractor", "tractor")
|
||||
select(bpy.data.objects["Hull"])
|
||||
saved_digest = digest(path)
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
require(result == {"CANCELLED"} and "Save Layout first" in scene.aetheria_package_report,
|
||||
f"Package took an unsaved buffer: {scene.aetheria_package_report}")
|
||||
require(digest(path) == saved_digest and not Path(path).with_name("ship.glb").exists(),
|
||||
"A refused Package wrote the .cc or the GLB")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save before Package was refused: {message}")
|
||||
preferences.game_folder = "" # the game folder is then found from the bound .cc
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
report = scene.aetheria_package_report
|
||||
preferences.game_folder = str(GAME)
|
||||
print(f"PACKAGE {sorted(result)}: {report}")
|
||||
require(result == {"FINISHED"}, f"Package with an empty Game folder preference was refused: {report}")
|
||||
names = glb_node_names(Path(path).with_name("ship.glb"))
|
||||
print(f"GLB nodes: {sorted(n for n in names if n)}")
|
||||
require("Hull" in names and "Loose Nub" in names, "The GLB lacks the render meshes")
|
||||
require("Source Sphere" not in names and "Grid" not in names, "The GLB holds the Source or the Grid")
|
||||
require("the validator accepted the ship" in report and not any(name in report for name in NAMES_NOT_ECHOED),
|
||||
f"Package's report is not the fixed text: {report}")
|
||||
# Members a later schema adds to a hardpoint row are not unsaved edits.
|
||||
real_read_layout = aetheria_ships.read_layout
|
||||
|
||||
def later_schema(*args, **kwargs):
|
||||
shape, rows, revision = real_read_layout(*args, **kwargs)
|
||||
return shape, [list(row) + [0] for row in rows], revision
|
||||
|
||||
aetheria_ships.read_layout = later_schema
|
||||
try:
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
finally:
|
||||
aetheria_ships.read_layout = real_read_layout
|
||||
require(result == {"FINISHED"}, f"A .cc whose rows carry a later schema's members read as unsaved edits: {message}")
|
||||
# A library error is the console's, not the panel's: a directory where ship.glb goes fails the exporter.
|
||||
glb = Path(path).with_name("ship.glb")
|
||||
glb.unlink()
|
||||
glb.mkdir()
|
||||
try:
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
report = scene.aetheria_package_report
|
||||
finally:
|
||||
glb.rmdir()
|
||||
require(result == {"CANCELLED"} and "The GLB export failed" in report, f"A failed GLB export was not refused: {report}")
|
||||
require("Traceback" not in report and not any(name in report for name in NAMES_NOT_ECHOED),
|
||||
f"A library error reached the report: {report}")
|
||||
real_visual = aetheria_ships.replace_visual
|
||||
|
||||
def unwritable(*args, **kwargs):
|
||||
raise OSError(f"probe: cannot write {path}")
|
||||
|
||||
aetheria_ships.replace_visual = unwritable
|
||||
try:
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
report = scene.aetheria_package_report
|
||||
finally:
|
||||
aetheria_ships.replace_visual = real_visual
|
||||
require(result == {"CANCELLED"} and report == "Package failed: Stopped by OSError; see the system console",
|
||||
f"A library error of another type is not reported by its type alone: {report}")
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
require(result == {"FINISHED"}, f"Package after the failed export was refused: {scene.aetheria_package_report}")
|
||||
print("PASS 6 package ok")
|
||||
mirror_pass()
|
||||
print("PASS 6b mirror ok")
|
||||
shear_pass()
|
||||
print("PASS 6c shear ok")
|
||||
guards_pass(aetheria_ships)
|
||||
print("PASS 7 guards ok")
|
||||
undo_pass()
|
||||
print("PASS 8 native undo ok")
|
||||
print("SMOKE_FRAME_OK")
|
||||
|
||||
|
||||
def ship_from_box(scene, name, ship_id, location):
|
||||
"""A fresh collection holding one box, made a pending ship by New Ship; returns the collection and the box."""
|
||||
collection = bpy.data.collections.new(name)
|
||||
scene.collection.children.link(collection)
|
||||
hull = new_object(collection, name + " Hull", box(name, 1.0, 3.0, 0.5), location)
|
||||
select(hull)
|
||||
result, message = new_ship(scene, ship_id, name, "Djinni", 10)
|
||||
require(result == {"FINISHED"}, f"New Ship for {ship_id} was refused: {message}")
|
||||
return collection, hull
|
||||
|
||||
|
||||
def mirror_pass():
|
||||
"""A half hull with a Mirror modifier, its long axis on X, gets the cells of the hull the author sees: the same
|
||||
cells as the hand-mirrored solid, 10 long."""
|
||||
scene = bpy.context.scene
|
||||
|
||||
def ship_cells(name, corners_y, modifier):
|
||||
collection = bpy.data.collections.new(name)
|
||||
scene.collection.children.link(collection)
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
corners = [(x, y, z) for x in (0.0, 6.0) for y in corners_y for z in (-0.5, 0.5)]
|
||||
mesh.from_pydata(corners, [], [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)])
|
||||
hull = new_object(collection, name + " Hull", mesh)
|
||||
if modifier:
|
||||
hull.modifiers.new("Mirror", "MIRROR").use_axis = (False, True, False)
|
||||
select(hull)
|
||||
result, message = new_ship(scene, "smoke.mirror", name, "Djinni", 10)
|
||||
require(result == {"FINISHED"}, f"New Ship for {name} was refused: {message}")
|
||||
made = buffer(bpy.context.scene)[:3]
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull) # reads the objects as New Ship left them
|
||||
require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made,
|
||||
f"{name}: the cells New Ship drafted are not the cells of the hull it left: {message}")
|
||||
return made
|
||||
|
||||
half = ship_cells("Mirror Half", (0.0, 1.0), True)
|
||||
solid = ship_cells("Mirror Solid", (-1.0, 1.0), False)
|
||||
require(half[1] == 10, f"The mirrored half hull is {half[1]} cells long, not 10")
|
||||
require(half == solid, "A Mirror-modified half hull does not get the cells of the hand-mirrored solid")
|
||||
|
||||
|
||||
def shear_pass():
|
||||
"""A hull parented to a rotated, non-uniformly scaled empty has a sheared world matrix, which an object cannot hold:
|
||||
New Ship drafts the cells of the hull it leaves, so Rasterise on that hull proposes the same cells."""
|
||||
scene = bpy.context.scene
|
||||
collection = bpy.data.collections.new("Shear")
|
||||
scene.collection.children.link(collection)
|
||||
holder = bpy.data.objects.new("Shear Holder", None)
|
||||
holder.rotation_euler = (0, 0, 0.7)
|
||||
holder.scale = (3, 1, 1)
|
||||
collection.objects.link(holder)
|
||||
hull = new_object(collection, "Shear Hull", box("Shear", 1.0, 0.5, 0.1), parent=holder)
|
||||
hull.rotation_euler = (0, 0, 0.5)
|
||||
select(hull)
|
||||
result, message = new_ship(scene, "smoke.shear", "Shear", "Djinni", 10)
|
||||
require(result == {"FINISHED"}, f"New Ship under a sheared parent was refused: {message}")
|
||||
made = buffer(bpy.context.scene)[:3]
|
||||
result, message = run(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"} and buffer(bpy.context.scene)[:3] == made,
|
||||
f"The cells New Ship drafted under a sheared parent are not the cells of the hull it left: {message}")
|
||||
|
||||
|
||||
def guards_pass(aetheria_ships):
|
||||
scene = bpy.context.scene
|
||||
path_a, path_b = str(MODS / GUARD_A / "ship.cc"), str(MODS / GUARD_B / "ship.cc")
|
||||
for ship_id in EXTRA_IDS:
|
||||
require(not (MODS / ship_id).exists(), f"{MODS / ship_id} exists; remove it first")
|
||||
|
||||
# Ship A with two hardpoints in its buffer, saved.
|
||||
collection_a, hull_a = ship_from_box(scene, "Guard A", GUARD_A, (2, 1, 0))
|
||||
require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the hardpoints of the ship before it in the buffer")
|
||||
for index, mount in enumerate(("mount.a", "mount.b")):
|
||||
select(hull_a)
|
||||
bpy.ops.aetheria.add_ship_hardpoint()
|
||||
hp = bpy.context.scene.aetheria_layout.hardpoints[index]
|
||||
hp.mount_id, hp.kind, hp.x, hp.y, hp.footprint = mount, "5", index + 1, 2, "#"
|
||||
select(hull_a)
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"} and len(saved_layout(path_a, aetheria_ships)[3]) == 2, f"Ship A was not saved: {message}")
|
||||
|
||||
# A pending ship over an existing ship.cc (what Ctrl+Z past a first Save restores) is refused, and the file stays.
|
||||
saved = digest(path_a)
|
||||
collection_a["aetheria.pending"] = ["Guard A", "Djinni"]
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "use Bind Ship Collection with that ship.cc, then Load Layout", "Save of a pending ship over its file")
|
||||
require(Path(path_a).is_file() and digest(path_a) == saved, "A refused Save deleted or changed the author's ship.cc")
|
||||
del collection_a["aetheria.pending"]
|
||||
|
||||
# A failed Save of a saved ship (a write that raises, then a .cc that changed since Load) leaves its real ship.cc.
|
||||
real = aetheria_ships.replace_layout
|
||||
|
||||
def failing(*args, **kwargs):
|
||||
raise ValueError("probe: replace_layout failed")
|
||||
|
||||
aetheria_ships.replace_layout = failing
|
||||
try:
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save of a saved ship with a failing write")
|
||||
finally:
|
||||
aetheria_ships.replace_layout = real
|
||||
require(Path(path_a).is_file() and digest(path_a) == saved, "A failed Save deleted or changed a saved ship's ship.cc")
|
||||
state = bpy.context.scene.aetheria_layout
|
||||
revision, state.revision = state.revision, "changed outside Blender"
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "changed since Load", "Save with a stale revision")
|
||||
state.revision = revision
|
||||
require(Path(path_a).is_file() and digest(path_a) == saved, "A stale-revision Save deleted or changed the ship.cc")
|
||||
|
||||
# New Ship checks everything, the rasterising included, before it changes the collection: a vertical wall has
|
||||
# horizontal extent and no top-down area.
|
||||
wall_collection = bpy.data.collections.new("Guard Wall")
|
||||
scene.collection.children.link(wall_collection)
|
||||
wall_mesh = bpy.data.meshes.new("Guard Wall")
|
||||
wall_mesh.from_pydata([(-2, 0, -1), (2, 0, -1), (2, 0, 1), (-2, 0, 1)], [], [(0, 1, 2, 3)])
|
||||
wall = new_object(wall_collection, "Guard Wall Hull", wall_mesh)
|
||||
select(wall)
|
||||
scene.aetheria_new_ship_id = "smoke.guard.w"
|
||||
scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Wall", "Djinni"
|
||||
for attempt in ("first", "retry"):
|
||||
refused(bpy.ops.aetheria.new_ship, "no triangle with area", f"New Ship on a wall ({attempt})")
|
||||
require(not any(key.startswith("aetheria.") for key in wall_collection.keys()), "A refused New Ship bound the collection")
|
||||
require(wall.parent is None and wall.users_collection == (wall_collection,) and not wall_collection.children
|
||||
and not any(obj.get("aetheria.ship_root") for obj in wall_collection.objects),
|
||||
"A refused New Ship moved the meshes or made a Ship Root")
|
||||
select(hull_a)
|
||||
|
||||
# A collection with a mesh in a child collection is not made a ship.
|
||||
child_host = bpy.data.collections.new("Guard Child")
|
||||
scene.collection.children.link(child_host)
|
||||
nested = bpy.data.collections.new("Guard Nested")
|
||||
child_host.children.link(nested)
|
||||
new_object(nested, "Guard Nested Mesh", box("Nested", 1.0, 1.0, 1.0))
|
||||
select(new_object(child_host, "Guard Child Hull", box("Child", 1.0, 3.0, 0.5)))
|
||||
scene.aetheria_new_ship_id = "smoke.guard.c"
|
||||
scene.aetheria_new_ship_name, scene.aetheria_new_ship_like = "Guard Child", "Djinni"
|
||||
refused(bpy.ops.aetheria.new_ship, "must sit directly in it", "New Ship with a mesh in a child collection")
|
||||
require("aetheria.id" not in child_host, "A refused New Ship bound the collection")
|
||||
|
||||
# Ship B takes none of ship A's hardpoints.
|
||||
collection_b, hull_b = ship_from_box(scene, "Guard B", GUARD_B, (0, 0, 0))
|
||||
require(buffer(bpy.context.scene)[3] == 0, "New Ship kept the previous ship's hardpoints in the buffer")
|
||||
|
||||
# A pending ship is not packaged, and nothing is written for it.
|
||||
select(hull_b)
|
||||
refused(bpy.ops.aetheria.package_ship, "Save Layout first", "Package of a pending ship")
|
||||
require(not (MODS / GUARD_B).exists(), "Package wrote for a pending ship")
|
||||
|
||||
# A buffer that belongs to ship B is neither saved into ship A's file nor rasterised onto ship A.
|
||||
select(hull_a)
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "Load this collection's layout before saving", "Save with another ship's buffer")
|
||||
require(digest(path_a) == saved, "Save wrote another ship's buffer into ship A's file")
|
||||
before = buffer(bpy.context.scene)
|
||||
refused(bpy.ops.aetheria.rasterise_hull, "Load this collection's layout first", "Rasterise with another ship's buffer")
|
||||
require(buffer(bpy.context.scene) == before, "Rasterise replaced another ship's buffer")
|
||||
|
||||
# A failed Save leaves what the author had in a Mods/<id> folder that existed before it; the retry succeeds.
|
||||
(MODS / GUARD_B).mkdir(parents=True)
|
||||
keep = MODS / GUARD_B / "keep.txt"
|
||||
keep.write_text("an author file")
|
||||
select(hull_b)
|
||||
real = aetheria_ships.replace_layout
|
||||
|
||||
def failing(*args, **kwargs):
|
||||
raise ValueError("probe: replace_layout failed")
|
||||
|
||||
aetheria_ships.replace_layout = failing
|
||||
try:
|
||||
refused(bpy.ops.aetheria.save_ship_layout, "probe: replace_layout failed", "Save with a failing write into an existing folder")
|
||||
finally:
|
||||
aetheria_ships.replace_layout = real
|
||||
require(keep.is_file(), "A failed Save deleted a file the author had in the ship folder")
|
||||
require(not Path(path_b).exists() and collection_b.get("aetheria.pending") is not None, "A failed Save left a half-made ship")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"} and keep.is_file(), f"The retried Save was refused or took the author's file: {message}")
|
||||
require(saved_layout(path_b, aetheria_ships)[3] == [], "Ship B's file holds hardpoints from ship A")
|
||||
|
||||
# Another ship's unsaved buffer is no reason to refuse Package: ship A's own file is what it checks.
|
||||
select(hull_a)
|
||||
result, message = run(bpy.ops.aetheria.package_ship)
|
||||
require("Save Layout first" not in message and "Save Layout first" not in scene.aetheria_package_report,
|
||||
"Package read another ship's buffer as ship A's unsaved edits")
|
||||
|
||||
# A buffer whose cells do not fill its size draws no Grid.
|
||||
generated_b = child(collection_b, "Generated")
|
||||
state = bpy.context.scene.aetheria_layout
|
||||
require(any(obj.get("aetheria.grid") for obj in generated_b.objects), "Ship B has no Grid")
|
||||
state.cells.remove(len(state.cells) - 1)
|
||||
aetheria_ships._redraw_grid(collection_b, state)
|
||||
require(not any(obj.get("aetheria.grid") for obj in generated_b.objects), "A Grid is drawn from a buffer that lacks cells")
|
||||
|
||||
# Rasterise needs the Ship Root New Ship made.
|
||||
select(hull_b)
|
||||
bpy.data.objects.remove(next(obj for obj in collection_b.objects if obj.get("aetheria.ship_root")))
|
||||
refused(bpy.ops.aetheria.rasterise_hull, "has no Ship Root", "Rasterise without a Ship Root")
|
||||
|
||||
|
||||
def undo_pass():
|
||||
scene = bpy.context.scene
|
||||
collection = bpy.data.collections.new("Undo Ship")
|
||||
scene.collection.children.link(collection)
|
||||
hull = new_object(collection, "Undo Hull", box("Undo Hull", 1.0, 3.0, 0.5), (2, 1, 0))
|
||||
select(hull)
|
||||
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship"
|
||||
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
|
||||
|
||||
def frame():
|
||||
"""The ship collection, its buffer, its grid placement and the hull's placement, read fresh: undo replaces the
|
||||
data-blocks."""
|
||||
col, obj = bpy.data.collections["Undo Ship"], bpy.data.objects["Undo Hull"]
|
||||
origin = col.get("aetheria.grid_origin")
|
||||
return (col.get("aetheria.id") is not None, col.get("aetheria.pending") is not None, buffer(bpy.context.scene),
|
||||
tuple(round(v, 4) for v in origin) if origin else None,
|
||||
tuple(round(v, 4) for row in obj.matrix_world for v in row), tuple(round(v, 4) for v in obj.scale))
|
||||
|
||||
def native(operator):
|
||||
"""The operator as the UI calls it: its undo step is pushed when, and only when, it carries UNDO."""
|
||||
return run(lambda: operator("EXEC_DEFAULT", True))
|
||||
|
||||
def undoable(operator, what):
|
||||
before = frame()
|
||||
result, message = native(operator)
|
||||
require(result == {"FINISHED"}, f"{what} was refused: {message}")
|
||||
after = frame()
|
||||
require(after != before, f"{what} changed nothing")
|
||||
result, _ = run(bpy.ops.ed.undo)
|
||||
require(result == {"FINISHED"} and frame() == before, f"{what} is not one undo step of its own (no UNDO)")
|
||||
select(bpy.data.objects["Undo Hull"])
|
||||
|
||||
bpy.ops.ed.undo_push(message="start")
|
||||
undoable(bpy.ops.aetheria.new_ship, "New Ship")
|
||||
result, message = native(bpy.ops.aetheria.new_ship)
|
||||
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
|
||||
select(bpy.data.objects["Undo Hull"])
|
||||
bpy.data.objects["Undo Hull"].scale = (0.5, 1, 1)
|
||||
bpy.ops.ed.undo_push(message="Scale") # the step the UI pushes for the author's own edit
|
||||
undoable(bpy.ops.aetheria.rasterise_hull, "Rasterise")
|
||||
result, message = native(bpy.ops.aetheria.rasterise_hull)
|
||||
require(result == {"FINISHED"}, f"Rasterise was refused: {message}")
|
||||
select(bpy.data.objects["Undo Hull"])
|
||||
undoable(bpy.ops.aetheria.flip_nose, "Flip Nose")
|
||||
|
||||
# Save pushes no step: one undo after it takes back the step before it, and the file stays.
|
||||
result, message = native(bpy.ops.aetheria.flip_nose)
|
||||
require(result == {"FINISHED"}, f"Flip Nose was refused: {message}")
|
||||
select(bpy.data.objects["Undo Hull"])
|
||||
flipped = frame()
|
||||
result, message = native(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save was refused: {message}")
|
||||
path = MODS / UNDO_SHIP / "ship.cc"
|
||||
require(path.is_file(), "Save did not create the file")
|
||||
result, _ = run(bpy.ops.ed.undo)
|
||||
require(result == {"FINISHED"} and frame()[2:] != flipped[2:], "Save is undoable: one undo took back Save and not the step before it")
|
||||
require(path.is_file(), "Undo changed the .cc")
|
||||
|
||||
# Ctrl+Z past the first Save leaves the .cc behind an unbound collection: New Ship with the ID is refused with the
|
||||
# way forward, and following it (Bind, Load, Save) works.
|
||||
for _ in range(12):
|
||||
if not frame()[0]:
|
||||
break
|
||||
require(run(bpy.ops.ed.undo)[0] == {"FINISHED"}, "ed.undo past the first Save did not run")
|
||||
require(not frame()[0] and path.is_file(), "Undo past the first Save did not unbind the collection or changed the .cc")
|
||||
scene = bpy.context.scene
|
||||
select(bpy.data.objects["Undo Hull"])
|
||||
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = UNDO_SHIP, "Undo Ship"
|
||||
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
|
||||
refused(bpy.ops.aetheria.new_ship, "use Bind Ship Collection with its ship.cc, then Load Layout", "New Ship over the ship the first Save made")
|
||||
scene.aetheria_ship_cc_path = str(path)
|
||||
for operator, what in ((bpy.ops.aetheria.bind_ship_collection, "Bind"), (bpy.ops.aetheria.load_ship_layout, "Load"),
|
||||
(bpy.ops.aetheria.save_ship_layout, "Save")):
|
||||
result, message = run(operator)
|
||||
require(result == {"FINISHED"}, f"{what} on the way forward after undoing past the first Save was refused: {message}")
|
||||
|
||||
|
||||
def _root_count():
|
||||
return sum(1 for obj in bpy.data.objects if obj.get("aetheria.ship_root"))
|
||||
|
||||
|
||||
def _second_collection_refused():
|
||||
"""New Ship on a second collection with the saved ship's ID is refused with fixed text."""
|
||||
second = bpy.data.collections.new("Second Frame")
|
||||
bpy.context.scene.collection.children.link(second)
|
||||
mesh = new_object(second, "Other", box("Other", 1.0, 2.0, 0.5))
|
||||
select(mesh)
|
||||
bpy.context.scene.aetheria_new_ship_id = SHIP_ID
|
||||
refused(bpy.ops.aetheria.new_ship, "already exists", "New Ship over an existing ship")
|
||||
require("aetheria.id" not in second, "A refused New Ship bound the second collection")
|
||||
bpy.data.objects.remove(mesh)
|
||||
bpy.data.collections.remove(second)
|
||||
return "existing target refused"
|
||||
|
||||
|
||||
created_mods = not MODS.exists()
|
||||
existed = (MODS / SHIP_ID).exists()
|
||||
try:
|
||||
main()
|
||||
except SystemExit as stop:
|
||||
print(f"SMOKE_FRAME_FAILED: {stop}", flush=True)
|
||||
code = 1
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print("SMOKE_FRAME_FAILED", flush=True)
|
||||
code = 1
|
||||
else:
|
||||
code = 0
|
||||
finally:
|
||||
if not existed:
|
||||
for leftover in (SHIP_ID, *EXTRA_IDS):
|
||||
shutil.rmtree(MODS / leftover, ignore_errors=True)
|
||||
if created_mods and not existed:
|
||||
shutil.rmtree(MODS, ignore_errors=True)
|
||||
sys.stdout.flush()
|
||||
os._exit(code)
|
||||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py
|
||||
|
||||
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs
|
||||
(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'.
|
||||
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py) and SMOKE_GAME_FOLDER, a folder holding
|
||||
GameData/Aetheria.cc and the published AetherDb under ModTools (AetherDb.exe on Windows), which the smoke sets as the
|
||||
add-on's Game folder. No dotnet is needed: the add-on runs the published tool and nothing else.
|
||||
SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the
|
||||
Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=<ship.cc>.
|
||||
|
||||
Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps
|
||||
the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts
|
||||
the disc back and packages again, so the package left behind is the good one.
|
||||
the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder
|
||||
that holds Aetheria.cc but no ModTools/AetherDb, and at one that holds a ModTools tool but no Aetheria.cc, and
|
||||
requires Package to report the fixed preference text and no path.
|
||||
|
||||
Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an
|
||||
unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the
|
||||
|
|
@ -17,6 +20,8 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t
|
|||
the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -27,6 +32,7 @@ import addon_utils
|
|||
import bpy
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
|
||||
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
|
||||
SHIP_ID = "smoke.gale"
|
||||
|
||||
|
|
@ -113,14 +119,20 @@ def bind_cases(ship, path, mods):
|
|||
raise SystemExit("Bind case cross-drive: the stored path is not the absolute .cc path")
|
||||
|
||||
|
||||
CONSOLE = [] # what the last package() sent to the system console
|
||||
|
||||
|
||||
def package(addon):
|
||||
# An operator that reports an error raises it out of bpy.ops; the panel's report is the verdict either way.
|
||||
console = io.StringIO()
|
||||
try:
|
||||
result = bpy.ops.aetheria.package_ship()
|
||||
with contextlib.redirect_stdout(console):
|
||||
result = bpy.ops.aetheria.package_ship()
|
||||
except RuntimeError:
|
||||
result = {"CANCELLED"}
|
||||
CONSOLE[:] = [console.getvalue()]
|
||||
report = bpy.context.scene.aetheria_package_report
|
||||
print(f"PACKAGE {sorted(result)}: {report}")
|
||||
print(f"PACKAGE {sorted(result)}: {report}{CONSOLE[0]}")
|
||||
return result, report
|
||||
|
||||
|
||||
|
|
@ -130,13 +142,13 @@ def main():
|
|||
import aetheria_ships
|
||||
# Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender.
|
||||
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
|
||||
mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-"))
|
||||
path = str(mods / SHIP_ID / "ship.cc")
|
||||
if Path(path).exists():
|
||||
raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory")
|
||||
code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
|
||||
code, message = aetheria_ships._aetherdb(bpy.context, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
|
||||
print(f"CREATE {code}: {message}")
|
||||
if code != 0:
|
||||
raise SystemExit("create --like Djinni failed")
|
||||
|
|
@ -176,14 +188,34 @@ def main():
|
|||
ship.objects.unlink(disc)
|
||||
hollow = tagged(ship, "Thruster Empty", None, "thruster-emitter", "thruster", (0, -1, 0))
|
||||
result, report = package(aetheria_ships)
|
||||
if result != {"CANCELLED"} or "thruster-emitter anchor thruster needs a mesh" not in report:
|
||||
raise SystemExit("Pass 2: an empty thruster node was not refused by name")
|
||||
if result != {"CANCELLED"} or "the validator refused the ship" not in report or "thruster" in report or SHIP_ID in report:
|
||||
raise SystemExit("Pass 2: an empty thruster node was not refused with the fixed text")
|
||||
if "thruster-emitter anchor thruster needs a mesh" not in CONSOLE[0]:
|
||||
raise SystemExit("Pass 2: the validator's message naming the anchor did not reach the system console")
|
||||
|
||||
ship.objects.unlink(hollow)
|
||||
ship.objects.link(disc)
|
||||
result, report = package(aetheria_ships)
|
||||
if result != {"FINISHED"}:
|
||||
raise SystemExit("Pass 3: the restored ship was refused")
|
||||
|
||||
bare = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bare-"))
|
||||
(bare / "GameData").mkdir()
|
||||
(bare / "GameData" / "Aetheria.cc").write_bytes(b"")
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare)
|
||||
result, report = package(aetheria_ships)
|
||||
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL \
|
||||
or "Game folder" not in report or str(bare) in report or str(GAME) in report:
|
||||
raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text")
|
||||
# A tool under ModTools does not make a folder a game folder: GameData/Aetheria.cc is the marker.
|
||||
unmarked = Path(tempfile.mkdtemp(prefix="aetheria-smoke-unmarked-"))
|
||||
(unmarked / "ModTools").mkdir()
|
||||
(unmarked / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")).write_bytes(b"")
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(unmarked)
|
||||
result, report = package(aetheria_ships)
|
||||
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL:
|
||||
raise SystemExit("Pass 4: a folder without GameData/Aetheria.cc was not refused with the fixed text")
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
|
||||
print(f"SMOKE_PACKAGE={path}")
|
||||
print("SMOKE_PACKAGE_OK")
|
||||
|
||||
|
|
|
|||
170
tools/blender/tests/test_hull_grid.py
Normal file
170
tools/blender/tests/test_hull_grid.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Blender-free tests for aetheria_ships/hull_grid.py, the arithmetic behind New Ship, Rasterise and the Grid object.
|
||||
|
||||
python -m unittest discover -s tools/blender/tests
|
||||
|
||||
hull_grid is loaded by path because the package __init__ imports bpy. Frame: nose toward -Y, starboard toward -X, cell
|
||||
(x, y) centred on grid_origin + (-2x, -2y) metres.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# The dotted name is the module's path from the repo root, which mutmut keys its mutants by.
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"tools.blender.aetheria_ships.hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py")
|
||||
hull_grid = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(hull_grid)
|
||||
|
||||
|
||||
def rect(x0, y0, x1, y1):
|
||||
return [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))]
|
||||
|
||||
|
||||
CORNER = [((1.99, 0.98), (2.0, 0.98), (2.0, 1.0))] # holds the bounding box at x 2 without covering a sample
|
||||
|
||||
|
||||
def occupied(cells, height):
|
||||
return {(i // height, i % height) for i, cell in enumerate(cells) if cell}
|
||||
|
||||
|
||||
class CellMappingTests(unittest.TestCase):
|
||||
ORIGIN = (3.0, -5.0)
|
||||
|
||||
def test_every_cell_of_a_5x3_grid_maps_back_to_itself(self):
|
||||
for x in range(5):
|
||||
for y in range(3):
|
||||
centre = hull_grid.cell_centre(self.ORIGIN, x, y)
|
||||
self.assertEqual(hull_grid.cell_at(self.ORIGIN, centre), (x, y))
|
||||
|
||||
def test_cell_1_0_is_two_metres_toward_minus_x_and_0_1_toward_minus_y(self):
|
||||
origin = hull_grid.cell_centre(self.ORIGIN, 0, 0)
|
||||
self.assertEqual(origin, self.ORIGIN)
|
||||
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 1, 0), (1.0, -5.0))
|
||||
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 0, 1), (3.0, -7.0))
|
||||
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 4, 2), (-5.0, -9.0))
|
||||
|
||||
def test_cell_at_takes_the_nearest_centre_and_the_lower_cell_on_a_tie(self):
|
||||
near = lambda dx, dy: hull_grid.cell_at(self.ORIGIN, (self.ORIGIN[0] - 2 + dx, self.ORIGIN[1] - 4 + dy))
|
||||
self.assertEqual(near(0.9, -0.9), (1, 2))
|
||||
self.assertEqual(near(-0.9, 0.9), (1, 2))
|
||||
self.assertEqual(near(1.1, 0), (0, 2))
|
||||
self.assertEqual(near(-1.1, 0), (2, 2))
|
||||
self.assertEqual(near(0, 1.1), (1, 1))
|
||||
self.assertEqual(near(0, -1.1), (1, 3))
|
||||
self.assertEqual(near(1.0, 1.0), (0, 1))
|
||||
self.assertEqual(near(-1.0, -1.0), (1, 2))
|
||||
|
||||
|
||||
class InsideTests(unittest.TestCase):
|
||||
TRIANGLE = ((0, 0), (1, 0), (0, 1))
|
||||
|
||||
def test_inside_points_are_inside_for_both_windings(self):
|
||||
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
|
||||
for point in ((0.25, 0.25), (0.5, 0.25), (0.1, 0.8)):
|
||||
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
|
||||
|
||||
def test_points_across_each_edge_are_outside_for_both_windings(self):
|
||||
outside = ((0.5, -0.25), (0.75, 0.75), (-0.25, 0.5), (-0.5, -0.5), (1.5, 0.0), (0.0, 1.5), (2.0, 2.0))
|
||||
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
|
||||
for point in outside:
|
||||
self.assertFalse(hull_grid._inside(triangle, *point), (triangle, point))
|
||||
|
||||
def test_edges_and_corners_are_inside_for_both_windings(self):
|
||||
on_edge = ((0.5, 0.0), (0.0, 0.5), (0.5, 0.5), (0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
|
||||
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
|
||||
for point in on_edge:
|
||||
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
|
||||
|
||||
|
||||
class RasteriseTests(unittest.TestCase):
|
||||
def test_eight_of_sixteen_samples_occupy_a_cell_and_seven_do_not(self):
|
||||
wing = rect(-2, -1, 0, 1) # cell (1, 0) in full; fixes the grid at 2 x 1 cells about the origin
|
||||
eight = rect(0, -1, 2, 0)
|
||||
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
|
||||
for triangles, expected in ((eight, True), (seven, False)):
|
||||
width, height, cells, origin = hull_grid.rasterise(wing + triangles)
|
||||
self.assertEqual((width, height, origin), (2, 1, (1.0, 0.0)))
|
||||
self.assertEqual(cells, [expected, True])
|
||||
|
||||
def test_samples_sit_at_quarter_points_of_the_cell_on_each_axis(self):
|
||||
wing = rect(-2, -1, 0, 1)
|
||||
# Cell (0, 0) spans x 0..2, y -1..1; its sample rows are y = 0.75, 0.25, -0.25, -0.75 and columns x = 1.75 .. 0.25.
|
||||
rows = hull_grid.rasterise(wing + rect(0, -0.3, 2, 0.3))[2] # y = 0.25 and -0.25: 8 samples
|
||||
columns = hull_grid.rasterise(wing + rect(0.3, -1, 1.0, 1) + CORNER)[2] # x = 0.75 only: 4 samples
|
||||
self.assertEqual((rows, columns), ([True, True], [False, True]))
|
||||
|
||||
def test_a_sample_on_a_triangle_bound_counts(self):
|
||||
wing = rect(-2, -1, 0, 1)
|
||||
self.assertEqual(hull_grid.rasterise(wing + rect(0, 0.25, 2, 0.75))[2], [True, True]) # rows 0.75 and 0.25
|
||||
self.assertEqual(hull_grid.rasterise(wing + rect(0.25, -1, 0.75, 1) + CORNER)[2], [True, True]) # columns 0.75 and 0.25
|
||||
|
||||
def test_a_single_triangle_covers_only_its_own_half_of_its_bounding_box(self):
|
||||
width, height, cells, _ = hull_grid.rasterise([((-2, -2), (2, -2), (-2, 2))])
|
||||
self.assertEqual((width, height), (2, 2))
|
||||
self.assertEqual(cells, [False, True, True, True])
|
||||
|
||||
def test_a_sample_inside_two_triangles_counts_once(self):
|
||||
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
|
||||
_, _, cells, _ = hull_grid.rasterise(rect(-2, -1, 0, 1) + seven + seven)
|
||||
self.assertEqual(cells, [False, True])
|
||||
|
||||
def test_an_l_shape_gives_the_cells_of_the_l(self):
|
||||
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
|
||||
self.assertEqual((width, height, origin), (2, 2, (1.0, 1.0)))
|
||||
self.assertEqual(occupied(cells, height), {(0, 0), (0, 1), (1, 1)})
|
||||
self.assertEqual(cells, [True, True, False, True])
|
||||
|
||||
def test_winding_does_not_matter(self):
|
||||
flipped = [(a, c, b) for a, b, c in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)]
|
||||
self.assertEqual(hull_grid.rasterise(flipped)[2], [True, True, False, True])
|
||||
|
||||
def test_the_grid_is_ceil_of_the_extent_over_two_metres_on_each_axis(self):
|
||||
for extent, cells_across in ((1.0, 1), (2.0, 1), (2.01, 2), (4.0, 2), (4.0000001, 2), (4.0005, 2), (4.002, 3), (4.01, 3), (5.0, 3), (20.0, 10)):
|
||||
width, height, _, _ = hull_grid.rasterise(rect(0, 0, extent, 6.5))
|
||||
self.assertEqual((width, height), (cells_across, 4), extent)
|
||||
|
||||
def test_the_grid_is_centred_on_the_silhouette(self):
|
||||
width, height, cells, origin = hull_grid.rasterise(rect(10, 20, 13, 22))
|
||||
self.assertEqual((width, height), (2, 1))
|
||||
self.assertEqual(origin, (12.5, 21.0))
|
||||
self.assertEqual(cells, [True, True])
|
||||
|
||||
def test_cell_centres_from_the_origin_lie_in_the_cells_they_name(self):
|
||||
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
|
||||
for x in range(width):
|
||||
for y in range(height):
|
||||
cx, cy = hull_grid.cell_centre(origin, x, y)
|
||||
self.assertEqual(hull_grid.cell_at(origin, (cx, cy)), (x, y))
|
||||
inside = any(hull_grid._inside(tri, cx, cy) for tri in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
|
||||
self.assertEqual(inside, cells[x * height + y])
|
||||
|
||||
def test_samples_per_axis_and_cell_size_are_parameters(self):
|
||||
triangles = rect(-1, -1, 1, 0)
|
||||
self.assertEqual(hull_grid.rasterise(triangles, samples=1)[2], [True])
|
||||
self.assertEqual(hull_grid.rasterise(triangles, cell_size=1.0)[:2], (2, 1))
|
||||
|
||||
def test_thirty_two_cells_are_accepted_and_thirty_three_is_refused_naming_it(self):
|
||||
width, height, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 64))
|
||||
self.assertEqual((width, height, len(cells), all(cells)), (1, 32, 32, True))
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
hull_grid.rasterise(rect(0, 0, 2, 66))
|
||||
self.assertEqual(str(caught.exception), "The hull needs 33 cells long; a ship grid holds at most 32")
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
hull_grid.rasterise(rect(0, 0, 66, 2))
|
||||
self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32")
|
||||
|
||||
def test_a_silhouette_with_no_area_is_refused(self):
|
||||
for triangles in ([], [((0, 0), (1, 1), (2, 2))]):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
hull_grid.rasterise(triangles)
|
||||
self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise")
|
||||
|
||||
def test_a_degenerate_triangle_does_not_widen_the_grid(self):
|
||||
for line in (((50, 50), (60, 60), (70, 70)), ((51, 50), (53, 51), (55, 52)), ((50, 51), (51, 53), (52, 55))):
|
||||
_, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [line])
|
||||
self.assertEqual(cells, [True], line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue