From 2fd8cbe8e9f94bdffc2e41c8e9a9b5795f27f979 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:17:59 +0200 Subject: [PATCH 01/29] AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 58 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 38 +++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 27e84629f..e06143f38 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -325,6 +325,64 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + // The hull as data, to compare everything stats did not name. + private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + + [Fact] + public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 0); + var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + void RefusedAndUntouched(string file, params string[] flags) + { + var before = File.ReadAllBytes(file); + Assert.Equal(1, Run(new[] { "stats", file }.Concat(flags).ToArray()).Code); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + RefusedAndUntouched(path); + RefusedAndUntouched(path, "--speed", "5"); + RefusedAndUntouched(path, "--mass"); + RefusedAndUntouched(path, "--mass", "NaN"); + RefusedAndUntouched(path, "--mass", "Infinity"); + RefusedAndUntouched(path, "--armor", "-1"); + RefusedAndUntouched(path, "--drag", "slow"); + RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); + // A valid flag beside a bad one writes nothing. + RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); + RefusedAndUntouched(bare, "--top-speed", "50"); + RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + + // Each flag sets its own field and nothing else. + var expected = ShipAuthoringStore.Load(path).Hull; + foreach (var (flag, value, set) in new (string, float, Action)[] + { + ("--mass", 60000f, hull => hull.Mass = 60000f), + ("--durability", 8000f, hull => hull.Durability = 8000f), + ("--armor", 40f, hull => hull.Armor = 40f), + ("--drag", .35f, hull => hull.Drag = .35f), + ("--top-speed", 37.5f, hull => { var speed = hull.Behaviors.OfType().Single().TopSpeed; speed.Min = speed.Max = 37.5f; }), + }) + { + var (code, output, error) = Run("stats", path, flag, value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(code == 0, error); + Assert.Contains(flag.Substring(2), output); + set(expected); + Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + } + var written = ShipAuthoringStore.Load(path).Hull; + Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); + var top = written.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((37.5f, 37.5f), (top.Min, top.Max)); + + // Several at once, and a hull with no top speed takes the others. + Assert.Equal(0, Run("stats", path, "--mass", "1", "--drag", "0").Code); + Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); + Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); + Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index b745e3dbf..bce7eda08 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,9 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + + private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; public static int Run(string[] args) { @@ -91,6 +93,40 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "stats" && args.Length >= 2) + { + // Every flag is judged before the file is touched: a refusal leaves it byte-identical. + var stats = new Dictionary(StringComparer.Ordinal); + for (var i = 2; i < args.Length; i += 2) + { + if (Array.IndexOf(StatFlags, args[i]) < 0) throw new InvalidOperationException($"stats: unknown flag {args[i]}."); + if (stats.ContainsKey(args[i])) throw new InvalidOperationException($"stats: {args[i]} is given twice."); + if (i + 1 == args.Length) throw new InvalidOperationException($"stats: {args[i]} needs a value."); + if (!float.TryParse(args[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !float.IsFinite(value) || value < 0) + throw new InvalidOperationException($"stats: {args[i]} must be a finite, non-negative number."); + stats[args[i]] = value; + } + if (stats.Count == 0) throw new InvalidOperationException("stats: name at least one stat."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + if (stats.TryGetValue("--top-speed", out var topSpeed)) + { + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. + limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; + } + if (stats.TryGetValue("--mass", out var mass)) hull.Mass = mass; + if (stats.TryGetValue("--durability", out var durability)) hull.Durability = durability; + if (stats.TryGetValue("--armor", out var armor)) hull.Armor = armor; + if (stats.TryGetValue("--drag", out var drag)) hull.Drag = drag; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: " + string.Join(", ", stats.Select(stat => string.Create(CultureInfo.InvariantCulture, $"{stat.Key.Substring(2)} {stat.Value:R}"))) + "."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none. From 0bccb395a45fa491a8fc44edad335f236a720abd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:22:44 +0200 Subject: [PATCH 02/29] ShipProductTests: compare the stats hull by its MessagePack bytes (Newtonsoft loops on Shape) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index e06143f38..8fac0c5b0 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -326,7 +326,7 @@ public sealed class ShipProductTests : IDisposable } // The hull as data, to compare everything stats did not name. - private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + private static byte[] HullBytes(HullData hull) => MessagePack.MessagePackSerializer.Serialize(hull, CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly)); [Fact] public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() @@ -369,7 +369,7 @@ public sealed class ShipProductTests : IDisposable Assert.True(code == 0, error); Assert.Contains(flag.Substring(2), output); set(expected); - Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + Assert.Equal(HullBytes(expected), HullBytes(ShipAuthoringStore.Load(path).Hull)); } var written = ShipAuthoringStore.Load(path).Hull; Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); From 7cb6fb0c9d1bc444838de1806e5481166e0318d7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:31 +0200 Subject: [PATCH 03/29] Mods: aetheria.nakovalnya, the Zhestokost boss hull package (hull.scad, ship.cc, ship.glb) Built from hull-bench Nakovalnya B by the Starfire job: New Ship --like Djinni, ship-authoring stats (mass 60000, durability 8000, armor 40, drag 0.35, top speed 25), price 60000000, a not-offered Zhestokost product, Build, Rasterise (19x30), 27 layout rows, Package. The validator accepted it and compose excludes nothing. Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 157 ++++++++++++++++++++ GameData/Mods/aetheria.nakovalnya/ship.cc | 3 + GameData/Mods/aetheria.nakovalnya/ship.glb | 3 + 3 files changed, 163 insertions(+) create mode 100644 GameData/Mods/aetheria.nakovalnya/hull.scad create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.cc create mode 100644 GameData/Mods/aetheria.nakovalnya/ship.glb diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad new file mode 100644 index 000000000..976078073 --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -0,0 +1,157 @@ +// Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. +// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. +include +part = "primary"; + +HULL_FACTION = "zhestokost"; +HULL_DONOR = ""; +HULL_ROLE = "fortress"; +HULL_LENGTH = [45, 60]; +HULL_TRIANGLES = [6000, 12000]; +HULL_READS = ["three long thick heavy barrels from fixed armoured casemates built into the stepped prow, all pointing straight forward", + "a ring of ten small squat flak turrets on raised rings evenly spaced around the upper edge", + "huge low broad stepped mass, wider at the bow, tapering to a squared stern; narrow cockpit slit high on the prow", + "round feed collar with guide lugs and an oxide ammunition door on each flank, magazine hatches in rows on the dorsal plating; six turning blocks and two broad stern drives"]; +if (part == "brief") echo(brief = [HULL_FACTION, HULL_DONOR, HULL_ROLE, HULL_LENGTH, HULL_TRIANGLES, HULL_READS]); + +// ---- masses: three steps (hull, mid-deck, spine) with the casemates set into the prow's lowest step ----------------- +// M: the hull, broadest at the bow, tapering aft to a squared stern with a cast lip. Its bow face is set back so the +// casemates sit inside the bow line. +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5), + zh_s(19.6, 23, 5.5, -2.5), zh_s(20, 22.4, 5.2, -2.35)]; +DECK = 3.0; +// T1: the mid-deck, ~60% of the hull's width, 3 m up; the outer band is the flak ring's gallery. +T1 = zh_tier([zh_sec(M, -15), zh_sec(M, 5), zh_sec(M, 11)], 5.4, 3.0, 4.0); +// T2: the spine, 2 m above that, carrying the cab and its slit at the prow's top step. +T2 = zh_tier(T1, 4.0, 2.0, 2.5); +// the spine runs on aft at the same height to the stern shoulders +SPINE = zh_block(12.0, 12, 8, 5.05, DECK - 0.05, 0.8, 0, 0.5); +// the belly steps down too: a keel block under the centre +KEEL = zh_block(0.5, 30, 18, 1.6, -4.0, 0, 0.6, 0.8); +// The prow's lowest step: a glacis apron, and three casemates whose tops are flush with the hull deck. +APRON = zh_block(-18.5, 8, 26, 2.6, -2.7, 0.6, 0.5, 0.4); +CX = [-9.6, 0, 9.6]; +CAS = zh_block(-18.0, 10, 7.2, 5.7, -2.7, 1.3, 0.9, 0.6); +GY = -23.0; // casemate face +GZ = 0.1; // barrel axis height +BL = 11.0; BR = 0.9; // barrel length, radius +// stern shoulders: radiator troughs either side of the spine +RAD_Y = 12.4; RAD_U = 0.55; RAD_W = 3.0; RAD_L = 2.4; +// stern drives +DX = 6.0; DRV = zh_block(20.6, 4, 8, 4.6, -2.3, 0.9, 0.7, 0.4); +DRIVE_Y = 22.6; DZ = -2.3 + 2.3; + +zh_mass(M); +zh_mass(T1); +zh_mass(T2); +zh_mass(APRON); +for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +zh_mass(SPINE); +zh_mass(KEEL); +for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); + +// ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +module casemate_gun() { + zh_z("secondary") { + translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet + translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar + translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + } + zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); + zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); +} +for (x = CX) translate([x, 0, 0]) casemate_gun(); +for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +// black reveals between the casemates, on the apron +for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); + +// ---- the flak ring: ten turrets on raised drums, equal arc-length around the hull's deck rim ---------------------- +// [x, y, outward yaw]; F1 at the bow-port quarter, numbered clockwise seen from above; equal-arc-length stations, baked. +FLAK = [[5.43, -13.40, 21], [-5.43, -13.40, 339], [-13.77, -10.93, 311], [-13.50, -0.06, 274], [-11.53, 10.51, 230], + [-5.43, 17.20, 199], [5.43, 17.20, 161], [11.53, 10.51, 130], [13.50, -0.06, 86], [13.77, -10.93, 49]]; +FR = 2.2; FH = 1.7; // raised drum +FG = [2, 0.27, 3.0, 3.2]; // zh_gun n, cal, len, base +module flak(yaw) { + zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); + zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum + zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); + translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); +} +for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; + +// ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- +zh_on_top(T2, -6.4, 0) zh_cab(6.0, 3.4, 3.0, "primary"); +zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); + +// ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- +for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams + +// Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. +module feed_collar() { + zh_z("primary") difference() { + cylinder(r=3.3, h=0.9 + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=2.1, h=2, $fn=8); } + zh_collar(2.2, 1.5, 4); +} +// ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- +for (s = [-1, 1]) { + zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); + zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); +} + +// ---- six square turning blocks on the belt, a small exhaust slot in each ------------------------------------------- +TB = [2.8, 2.6, 1.8]; // along Y, down the wall, outward +TY = [-11.5, 13.6]; +module turn_block() { + zh_mass(zh_block(0, TB[0], TB[1], TB[2], 0, 0.45, 0.45, 0.35), "secondary"); + zh_z("recess") translate([0, 0, TB[2] + 0.02]) cube([0.6, 1.4, 0.14], center=true); +} +for (y = TY, s = [-1, 1]) zh_on_belt(M, y, s, 0.5) turn_block(); +// the forward pair face ahead from the bow corners, outboard of the casemates +NX = 14.6; +for (s = [-1, 1]) zh_on_nose(M, s*NX, 0.55) turn_block(); +function nb_at(s) = zh_nose_pt(M, s*NX, 0.55) + [0, ZH_SINK - TB[2] - 0.1, 0]; +function tb_at(y, s) = zh_belt(M, y, s, 0.5) + [s*(TB[2] + 0.1 - ZH_SINK), 0, 0]; + +// ---- stern: drives, radiator troughs on the shoulders, stern plate ------------------------------------------------ +module drive_mouth() { + zh_z("secondary") translate([0, DRIVE_Y + 0.12, 0]) difference() { + cbox([7.0, 0.4, 3.6], 0.1); cube([5.4, 1, 2.4], center=true); } + zh_z("recess") translate([0, DRIVE_Y - 0.02, 0]) cube([5.5, 0.3, 2.5], center=true); +} +for (s = [-1, 1]) translate([s*DX, 0, DZ]) drive_mouth(); +for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45); +zh_on_stern(M, 0, 0.62) zh_plate(4.0, 1.4, 0.3); +zh_on_stern(M, 0, 0.62) translate([0, 0, 0.3]) zh_stripes(3.4, 1.0, 4); + +// ---- anchor meshes ------------------------------------------------------------------------------------------------ +for (s = [-1, 1]) if (part == (s > 0 ? "rad:Ra.L" : "rad:Ra.R")) + zh_on_top(M, RAD_Y, s*RAD_U) for (h = [-1, 1]) zh_rad_vanes(RAD_W, RAD_L, h, 4, 1.0); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.L" : "emit:Th.R")) + translate([s*DX, DRIVE_Y + 0.15, DZ]) cube([5.2, 0.05, 2.3], center=true); +TID = [["Th.T3", TY[0], 1], ["Th.T4", TY[0], -1], ["Th.T5", TY[1], 1], ["Th.T6", TY[1], -1]]; +for (t = TID) if (part == str("emit:", t[0])) translate(tb_at(t[1], t[2])) cube([0.05, 1.2, 0.4], center=true); +for (s = [-1, 1]) if (part == (s > 0 ? "emit:Th.T1" : "emit:Th.T2")) translate(nb_at(s)) cube([1.2, 0.05, 0.4], center=true); + +if (part == "anchors") { + for (i = [0:2]) { + id = str("Gun.H", i + 1); + echo(anchor=[id, "weapon-mount", [CX[i], GY, GZ], 0, ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", [CX[i], GY - 0.7 - BL - 0.85, GZ], 0, id]); + } + for (i = [0:9]) { + f = FLAK[i]; id = str("Gun.F", i + 1); base = [f[0], f[1], DECK - ZH_SINK + FH - 0.02]; + echo(anchor=[id, "weapon-mount", base + [0, 0, 0.8], f[2], ""]); + echo(anchor=[str(id, ".muzzle"), "weapon-muzzle", base + rz(f[2], zh_gun_muzzle(FG[0], FG[1], FG[2], FG[3])), f[2], id]); + } + echo(anchor=["Th.T1", "thruster-emitter", nb_at(1), 0, ""]); + echo(anchor=["Th.T2", "thruster-emitter", nb_at(-1), 0, ""]); + for (t = TID) echo(anchor=[t[0], "thruster-emitter", tb_at(t[1], t[2]), t[2] > 0 ? 90 : 270, ""]); + echo(anchor=["Th.L", "thruster-emitter", [DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Th.R", "thruster-emitter", [-DX, DRIVE_Y + 0.15, DZ], 180, ""]); + echo(anchor=["Ra.L", "radiator-mesh", zh_top_at(M, RAD_Y, RAD_U, [0, 0, 0.5]), 0, ""]); + echo(anchor=["Ra.R", "radiator-mesh", zh_top_at(M, RAD_Y, -RAD_U, [0, 0, 0.5]), 0, ""]); +} diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc new file mode 100644 index 000000000..ce591805b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb new file mode 100644 index 000000000..ff556fc6b --- /dev/null +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 +size 843464 From 95f89a38a6bc3509ca0c5d6cda0ca1de2f5b4f97 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:28:59 +0200 Subject: [PATCH 04/29] NakovalnyaPackageTests: the package composes, its Zhestokost product is never offered or drawn, and its layout is the brief's Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs new file mode 100644 index 000000000..153892804 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The first first-party ship package, GameData/Mods/aetheria.nakovalnya: the Zhestokost boss hull. It enters the game as +// data through the one compose path (ShipModCatalog.ResolveCatalog) and is made by Zhestokost but never offered +// (rulings off-market-is-unoffered-product, boss-hulls-exclusive). +public sealed class NakovalnyaPackageTests +{ + private const string Id = "aetheria.nakovalnya"; + + private static CultCache Catalog() + { + var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + return cache; + } + + private static HullData Hull(CultCache cache) => cache.Get(new CultRecordRef(ShipModCatalog.HullKey(Id))); + + [Fact] + public void ComposeExcludesNothing() + { + var root = RunStartTests.FindRepoRoot(); + using var work = new TempDirectory(); + var shipped = Path.Combine(root, "GameData", "Aetheria.cc"); + var (catalog, excluded) = ShipModCatalog.ResolveCatalog(shipped, Path.Combine(work.Path, "derived.cc"), + Path.Combine(root, "GameData", "Mods"), TestCatalog.Registry()); + Assert.Empty(excluded); + Assert.NotEqual(shipped, catalog); + + using var cache = Catalog(); + var hull = Hull(cache); + Assert.NotNull(hull); + Assert.Equal(("Nakovalnya", HullType.Ship), (hull.Name, hull.HullType)); + Assert.Equal(ShipModCatalog.AuthoringKey(Id), hull.Visual.Key); + } + + [Fact] + public void BossProductIsNotOffered() + { + using var cache = Catalog(); + var hull = Hull(cache); + var zhestokost = cache.GetAll().Single(faction => faction.Name == "Zhestokost"); + var product = Assert.Single(cache.GetAll(), p => p.Design.Key.Equals(ShipModCatalog.HullKey(Id))); + Assert.Equal(cache.RefOf(zhestokost).Key, product.Manufacturer.Key); + Assert.Equal(ShipModCatalog.ProductKey(Id, cache.RefOf(zhestokost).Key), cache.RefOf(product).Key); + Assert.Equal("Nakovalnya", product.Name); + Assert.True(product.NotOffered); + Assert.True(hull.Price > 0); + + Assert.DoesNotContain(hull, LoadoutGenerator.SoldHulls(cache)); + var items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }); + foreach (var faction in cache.GetAll().Append(null)) + { + var random = new CultMath.Random(7u); + var generator = new LoadoutGenerator(ref random, items, null, null, faction, .5f); + Assert.False(generator.IsAvailable(product), faction?.Name); + // Even a required draw that falls back to any manufacturer never reaches it. + Assert.Null(generator.RandomHull(HullType.Ship, design => design == hull).design); + } + } + + [Fact] + public void LayoutMatchesTheBrief() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); + Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); + Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); + Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); + foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) + Assert.Equal(1, hull.Hardpoints.Count(h => h.Type == kind)); + Assert.Equal(27, hull.Hardpoints.Count); + + // Every mount row names a model anchor by id (ruling schematic-and-model-are-separate-spaces). Every row has an id + // (ShipAuthoringStore.Validate); an internal row's id names no anchor. + var anchors = ship.Anchors.Select(anchor => anchor.Id).ToHashSet(StringComparer.Ordinal); + var internals = new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }; + var mounts = hull.Hardpoints.Where(h => !internals.Contains(h.Type)).ToArray(); + Assert.Equal(23, mounts.Length); + Assert.All(mounts, h => Assert.Contains(h.Transform, anchors)); + Assert.All(hull.Hardpoints.Where(h => internals.Contains(h.Type)), h => Assert.DoesNotContain(h.Transform, anchors)); + Assert.Equal(Enumerable.Range(1, 3).Select(i => $"Gun.H{i}").Concat(Enumerable.Range(1, 10).Select(i => $"Gun.F{i}")).OrderBy(s => s, StringComparer.Ordinal), + mounts.Where(h => h.Type == HardpointType.Ballistic).Select(h => h.Transform).OrderBy(s => s, StringComparer.Ordinal)); + + Assert.InRange(hull.Shape.Width, 1, 32); + Assert.InRange(hull.Shape.Height, 1, 32); + } +} From 85e539af2b6e49c50cb97641e3eaf723d465a2f2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:29:47 +0200 Subject: [PATCH 05/29] NakovalnyaPackageTests: import System.Collections.Generic Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 153892804..6f55823f0 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; From 9ac8f23307793665802db0f9181e51746fb6eacf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:09 +0200 Subject: [PATCH 06/29] RunStartTests: the own-maker and factionless reach claims hold for every product LoadoutGenerator.IsOffered offers Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/RunStartGearTests.cs | 3 ++- tests/Aetheria.Shared.Tests/RunStartTests.cs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs index 3b5766dcd..2d0e1fb67 100644 --- a/tests/Aetheria.Shared.Tests/RunStartGearTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartGearTests.cs @@ -55,7 +55,8 @@ public sealed partial class RunStartTests var galaxy = MainSectorGalaxy(); AbsentMaker("AbsentToAll"); var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, null, .5f); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + // Every offered product (LoadoutGenerator.IsOffered); a not-offered one is available to nobody. + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); Assert.NotEmpty(products); Assert.All(products, product => Assert.True(generator.IsAvailable(product), product.Name)); } diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 9d499b3e1..b3c502a6b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -178,13 +178,14 @@ public sealed partial class RunStartTests : IDisposable } } - // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every product a faction makes is - // on offer to that faction, allegiance or not. And an allegiance map lists only other factions. + // A faction always reaches its own manufacturer's gear: in a main-sector galaxy every offered product a faction makes + // (LoadoutGenerator.IsOffered, the market's one owner) is on offer to that faction, allegiance or not. And an + // allegiance map lists only other factions. [Fact] public void AFactionReachesItsOwnGearAndItsAllegianceNamesOnlyOthers() { var galaxy = MainSectorGalaxy(); - var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray(); foreach (var faction in galaxy.Factions) { Assert.DoesNotContain(faction.Allegiance.Keys, key => key.Key.Equals(_cache.RefOf(faction).Key)); From bc53361ff95d81d055f3f6aaa852b4907181a18b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:10 +0200 Subject: [PATCH 07/29] Census tests: sold hulls are LoadoutGenerator.SoldHulls; a hull's asset is its Prefab or its mod-ship Visual record Ruling census-tests-read-the-owners. Co-Authored-By: Claude Opus 5.5 --- .../HardpointOwnershipTests.cs | 4 +--- .../RestoredHullsTests.cs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 12daaee32..2650b8009 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -261,9 +261,7 @@ public sealed partial class RunStartTests [Fact] public void GeneratedLoadoutsGiveEveryReaderItsHardpoint() { - var sold = _cache.GetAll().Select(p => p.Design.Key).ToHashSet(); - var shipHulls = _cache.GetAll() - .Where(h => h.HullType == HullType.Ship && sold.Contains(_cache.RefOf(h).Key)).Select(h => h.Name).ToList(); + var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 8b0065f93..6dd235551 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -187,9 +187,21 @@ public sealed class RestoredHullsTests using var cache = OpenCatalog(); var missing = new List(); + // A hull names one body (ShipAuthoringStore.RequireOneBody): a Unity prefab, or a mod ship's visual record, which + // must be in the catalog under its own mod-ship key. foreach (var hull in cache.GetAll()) - if (!assetGuids.Contains(hull.Prefab ?? "")) + { + if (hull.Visual.IsSet()) + { + var visual = cache.Get(hull.Visual); + if (visual == null || !hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id))) + missing.Add($"{hull.Name}.Visual = {hull.Visual.Key.Value}"); + if (!string.IsNullOrWhiteSpace(hull.Prefab)) + missing.Add($"{hull.Name} names both Prefab {hull.Prefab} and Visual {hull.Visual.Key.Value}"); + } + else if (!assetGuids.Contains(hull.Prefab ?? "")) missing.Add($"{hull.Name}.Prefab = {hull.Prefab ?? "null"}"); + } foreach (var design in cache.GetAll()) { foreach (var thruster in design.Behaviors.OfType()) @@ -245,14 +257,13 @@ public sealed class RestoredHullsTests } // The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog - // sells carries exactly one, with a top speed that is a finite positive number. After the retirement + // sells (LoadoutGenerator.SoldHulls) carries exactly one, with a top speed that is a finite positive number. After the retirement // (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni. [Fact] public void EveryShippedShipHullHasATopSpeed() { using var cache = OpenCatalog(); - var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); - var ships = cache.GetAll().Where(h => h.HullType == HullType.Ship && sold.Contains(cache.RefOf(h).Key)).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); + var ships = LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name)); foreach (var hull in ships) { From 07bd57d9e1fd80cf986fd79106694046ecdd6e0d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 08/29] ConsumablesFirstSetTests: the vent's smallest cloud holds every sold ship hull Ruling vapour-cloud-sold-hulls; the radius is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs index 300c2f9b9..a3858a407 100644 --- a/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumablesFirstSetTests.cs @@ -74,9 +74,10 @@ public sealed class ConsumablesFirstSetTests Assert.Equal(new[] { "nozzle", "coolant" }, vent.Roles.Select(r => r.Name).ToArray()); Assert.Equal(2, vent.Behaviors.Count); var dump = Assert.IsType(vent.Behaviors[0]); - // The cloud at its smallest holds every ship hull; its largest is half as big again. + // The cloud at its smallest holds every sold ship hull (ruling vapour-cloud-sold-hulls: a boss hull is never sold and + // its Loadout is agent-authored); its largest is half as big again. var radiusMin = dump.Radius.Min; - foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Ship)) + foreach (var hull in LoadoutGenerator.SoldHulls(cache).Where(h => h.HullType == HullType.Ship)) Assert.True(radiusMin >= Circumradius(hull, cellSize), $"{hull.Name} pokes out of the smallest cloud"); Assert.Equal(1.5f * Circumradius(cache.GetByName("Djinni"), cellSize), radiusMin, 3); Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell From f7314b26614141f5cd270cf2ea5ce0b8af79c83b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:41:12 +0200 Subject: [PATCH 09/29] ShipProductTests: stats --top-speed refuses a hull with two VelocityLimits and leaves it untouched Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ShipProductTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 8fac0c5b0..bb358c4df 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -334,6 +334,8 @@ public sealed class ShipProductTests : IDisposable Environment.CurrentDirectory = _root.Path; var path = Package("mod.skiff", "Skiffhull", 0); var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull", + tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType().First())), "ship.cc"); void RefusedAndUntouched(string file, params string[] flags) { var before = File.ReadAllBytes(file); @@ -353,6 +355,8 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); RefusedAndUntouched(bare, "--top-speed", "50"); RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + // Two VelocityLimits leave no one top speed to set. + RefusedAndUntouched(twin, "--top-speed", "50"); // Each flag sets its own field and nothing else. var expected = ShipAuthoringStore.Load(path).Hull; From d76563dd5a188ba5cd38ea6a5c9b0f4b2ee5cfe3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:25:22 +0200 Subject: [PATCH 10/29] NakovalnyaPackageTests: every mount takes its gear through Takes; rows inside the shape and disjoint; no footprint-count pin (red) Co-Authored-By: Claude Opus 5.5 --- .../NakovalnyaPackageTests.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index 6f55823f0..d2b08f8ba 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -71,9 +71,7 @@ public sealed class NakovalnyaPackageTests using var cache = Catalog(); var hull = Hull(cache); var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); - var rows = hull.Hardpoints.GroupBy(h => (h.Type, h.Shape.Width, h.Shape.Height)).ToDictionary(g => g.Key, g => g.Count()); - Assert.Equal(3, rows.GetValueOrDefault((HardpointType.Ballistic, 2, 2))); - Assert.Equal(10, rows.GetValueOrDefault((HardpointType.Ballistic, 1, 2))); + Assert.Equal(13, hull.Hardpoints.Count(h => h.Type == HardpointType.Ballistic)); Assert.Equal(8, hull.Hardpoints.Count(h => h.Type == HardpointType.Thruster)); Assert.Equal(2, hull.Hardpoints.Count(h => h.Type == HardpointType.Radiator)); foreach (var kind in new[] { HardpointType.Reactor, HardpointType.Shield, HardpointType.ControlModule, HardpointType.Sensors }) @@ -93,5 +91,33 @@ public sealed class NakovalnyaPackageTests Assert.InRange(hull.Shape.Width, 1, 32); Assert.InRange(hull.Shape.Height, 1, 32); + + // Every row's cells lie inside the hull's shape, and no two rows share a cell. + var owners = new Dictionary<(int, int), string>(); + foreach (var h in hull.Hardpoints) + foreach (var c in h.Shape.Coordinates) + { + var cell = h.Position + c; + Assert.True(hull.Shape[cell], $"{h.Transform} covers ({cell.x},{cell.y}), outside the hull's shape"); + Assert.True(owners.TryAdd((cell.x, cell.y), h.Transform), $"{h.Transform} and {owners.GetValueOrDefault((cell.x, cell.y))} share ({cell.x},{cell.y})"); + } + } + + // Each mount takes the gear it was placed for, under the one fit rule (HardpointData.Takes): ruling nakovalnya-flak-1x2 + // means the Flak Gun fits every flak mount at its facing, and a row's footprint is its gear's shape turned by Rotation. + [Fact] + public void EveryMountTakesItsGear() + { + using var cache = Catalog(); + var hull = Hull(cache); + EquippableItemData Gear(string name) => cache.GetAll().Single(design => design.Name == name); + var gear = Enumerable.Range(1, 10).Select(i => ($"Gun.F{i}", "Flak Gun")) + .Concat(Enumerable.Range(1, 3).Select(i => ($"Gun.H{i}", "Autocannon"))) + .Concat(new[] { ("Th.L", "Large Drive"), ("Th.R", "Large Drive") }) + .Concat(Enumerable.Range(1, 6).Select(i => ($"Th.T{i}", "Medium Drive"))) + .Concat(new[] { ("Ra.L", "Radiator"), ("Ra.R", "Radiator") }); + var refused = gear.Where(row => !hull.Hardpoints.Single(h => h.Transform == row.Item1).Takes(Gear(row.Item2))) + .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); + Assert.Empty(refused); } } From a67711b3d32a3feaaffb9fc66560c91e5e9c62c3 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 01:31:16 +0200 Subject: [PATCH 11/29] aetheria.nakovalnya: side flak rows and radiators are 2x1 in the grid frame (drive.py rerun through the add-on) Co-Authored-By: Claude Opus 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 2 +- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index ce591805b..2de9f3458 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62f66a2f76c82af1caac2977c22f39e66b331d5fffe2de82beaab68f0a7bc28b +oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 size 12701 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index ff556fc6b..27ed4683b 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:01508b4903138bf8fd13caf3d01fcd3f6b6c410c8007a5266fe95c86d9b773d7 -size 843464 +oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba +size 843456 From bd343b2a4e0ead4929389fed748fa5a7c01ca847 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:53:20 +0200 Subject: [PATCH 12/29] Kit frames: mates are lp_at placements that track the Ship Root frame; zone OFFs unchanged Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 25 +++++++++++++++++++ .../aetheria_ships/factions/zhestokost.scad | 15 +++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index 9979483a0..ef3063c6c 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -12,3 +12,28 @@ module cbox(s, c=0.2, center=true) { module tube(r, l, fn=8) { rotate([-90,0,0]) cylinder(r=r, h=l, $fn=fn); } // a straight strut between two points module strut(a, b, r=0.08, fn=6) { hull() { translate(a) sphere(r, $fn=fn); translate(b) sphere(r, $fn=fn); } } + +// ==== Frames and schematic lines ===================================================================================== +// A kit part that draws schematic lines is placed with lp_at, never with raw translate/rotate/mirror: lp_at moves the +// geometry (multmatrix) and tracks the same frame in $lp_m, so lp_line, called anywhere beneath it, emits its points in +// the Ship Root frame. Placement has this one owner. A line is echoed (part "lines") only when the frame's local +Z +// points up, so fittings on the belly, the walls and the belts, which a top view cannot see, emit nothing. +$lp_m = [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; +function lp_T(t) = [[1,0,0,t[0]], [0,1,0,t[1]], [0,0,1,t[2]], [0,0,0,1]]; +function lp_R(r) = // Rz * Ry * Rx, as rotate() composes + [[cos(r[2]), -sin(r[2]), 0, 0], [sin(r[2]), cos(r[2]), 0, 0], [0,0,1,0], [0,0,0,1]] + * [[cos(r[1]), 0, sin(r[1]), 0], [0,1,0,0], [-sin(r[1]), 0, cos(r[1]), 0], [0,0,0,1]] + * [[1,0,0,0], [0, cos(r[0]), -sin(r[0]), 0], [0, sin(r[0]), cos(r[0]), 0], [0,0,0,1]]; +lp_MX = [[-1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]; // mirror across x = 0 +function lp_ap(m, p) = let(v = m * [p[0], p[1], p[2], 1]) [v[0], v[1], v[2]]; +// Mirror (when mx), then translate by t, then rotate by r (as nested mirror, translate, rotate would). +module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { + M = (mx ? lp_MX : [[1,0,0,0], [0,1,0,0], [0,0,1,0], [0,0,0,1]]) * lp_T(t) * lp_R(r); + $lp_m = $lp_m * M; + multmatrix(M) children(); +} +// One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +module lp_line(layer, pts, cyclic=false) { + if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) + echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); +} diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index e077351ee..ae38b0186 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -22,6 +22,7 @@ // zh_on_stern, zh_on_nose. In the mate frame local +Z is the outward normal and the part is sunk ZH_SINK into // the skin, so nothing floats. Top/belly frames keep local +Y aft; wall/belt frames keep +Y aft with local +X // pointing down the wall on either side (starboard mates are mirrors of port); stern/nose frames have local +Y down. +// Mates are lp_at frames: a part under a mate that draws lines (lp_line) emits them in the Ship Root frame. // 3. Zone routing: every zh_ module draws only the zone the global `part` names, so a hull writes its assembly once. // A hull on a Zhestokost donor (HULL_DONOR == "zhestokost") names a donor zone 'zhestokost:'. // Anchor meshes (zh_rad_vanes, zh_emitter) are not routed: call them inside the hull's rad:/emit: parts under @@ -65,17 +66,17 @@ function zh_wall_tilt(secs, y) = let(s = zh_sec(secs, y)) atan2(s[4], s[2]*(1-ZH function zh_stern_pt(secs, x=0, v=0.5) = let(s = secs[len(secs)-1]) [x, s[0], s[3] + s[2]*v]; function zh_nose_pt(secs, x=0, v=0.5) = let(s = secs[0]) [x, s[0], s[3] + s[2]*v]; -module zh_on_top(secs, y, u=0, sink=ZH_SINK) translate(zh_top(secs, y, u) - [0, 0, sink]) children(); -module zh_on_belly(secs, y, u=0, sink=ZH_SINK) translate(zh_belly(secs, y, u) + [0, 0, sink]) rotate([0, 180, 0]) children(); -module _zh_port(side) { if (side < 0) mirror([1, 0, 0]) children(); else children(); } +module zh_on_top(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_top(secs, y, u) - [0, 0, sink]) children(); +module zh_on_belly(secs, y, u=0, sink=ZH_SINK) lp_at(t=zh_belly(secs, y, u) + [0, 0, sink], r=[0, 180, 0]) children(); module zh_on_belt(secs, y, side=1, v=0.5, sink=ZH_SINK) - _zh_port(side) translate(zh_belt(secs, y, 1, v) - [sink, 0, 0]) rotate([0, 90, 0]) children(); + lp_at(t=zh_belt(secs, y, 1, v) - [sink, 0, 0], r=[0, 90, 0], mx=side < 0) children(); module zh_on_wall(secs, y, side=1, v=0.5, sink=ZH_SINK) { t = zh_wall_tilt(secs, y); - _zh_port(side) translate(zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink) rotate([0, 90 - t, 0]) children(); + lp_at(t=zh_wall(secs, y, 1, v) - [cos(t), 0, sin(t)]*sink, r=[0, 90 - t, 0], mx=side < 0) children(); } -module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_stern_pt(secs, x, v) - [0, sink, 0]) rotate([-90, 0, 0]) children(); -module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) translate(zh_nose_pt(secs, x, v) + [0, sink, 0]) rotate([90, 0, 0]) rotate([0, 0, 180]) children(); +module zh_on_stern(secs, x=0, v=0.5, sink=ZH_SINK) lp_at(t=zh_stern_pt(secs, x, v) - [0, sink, 0], r=[-90, 0, 0]) children(); +module zh_on_nose(secs, x=0, v=0.5, sink=ZH_SINK) + lp_at(t=zh_nose_pt(secs, x, v) + [0, sink, 0], r=[90, 0, 0]) lp_at(r=[0, 0, 180]) children(); // world point of a local offset under a mate (for anchors): top, belly, stern frames function zh_top_at(secs, y, u, o, sink=ZH_SINK) = zh_top(secs, y, u) - [0, 0, sink] + o; function zh_belly_at(secs, y, u, o, sink=ZH_SINK) = zh_belly(secs, y, u) + [0, 0, sink] + [-o[0], o[1], -o[2]]; From 28ed24b28ed0648e79bb8802934da80e65eba729 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:54:27 +0200 Subject: [PATCH 13/29] Zhestokost kit and Nakovalnya emit schematic lines from their own frames (outline, step, seam, fitting) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 26 ++++++---- .../aetheria_ships/factions/zhestokost.scad | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 976078073..062285157 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -1,5 +1,5 @@ // Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit. -// part = lang | brief | anchors | primary | secondary | accent | recess | marking | emit: | rad: +// part = lang | brief | anchors | lines | primary | secondary | accent | recess | marking | emit: | rad: // Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard. include part = "primary"; @@ -46,10 +46,10 @@ zh_mass(M); zh_mass(T1); zh_mass(T2); zh_mass(APRON); -for (x = CX) translate([x, 0, 0]) zh_mass(CAS); +for (x = CX) lp_at(t=[x, 0, 0]) zh_mass(CAS); zh_mass(SPINE); -zh_mass(KEEL); -for (s = [-1, 1]) translate([s*DX, 0, 0]) zh_mass(DRV, "secondary"); +zh_mass(KEEL, lines=false); // under the hull: no top view sees it +for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- module casemate_gun() { @@ -60,9 +60,11 @@ module casemate_gun() { } zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); + lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } -for (x = CX) translate([x, 0, 0]) casemate_gun(); -for (x = CX) translate([x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); +for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); +for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); // black reveals between the casemates, on the apron for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0); @@ -76,9 +78,10 @@ module flak(yaw) { zh_z("secondary") cylinder(r=FR, h=FH + ZH_SINK, $fn=8); zh_z("accent") translate([0, 0, FH*0.35]) cylinder(r=FR + 0.08, h=0.35, $fn=8); // oxide band round the drum zh_z("recess") translate([0, 0, FH - 0.06]) cylinder(r=FR*0.84, h=0.1, $fn=8); - translate([0, 0, FH - 0.02]) rotate([0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); + lp_line("fitting", zh_oct(FR, FH), true); + lp_at(t=[0, 0, FH - 0.02], r=[0, 0, yaw]) zh_gun(FG[0], FG[1], FG[2], FG[3], "primary"); } -for (f = FLAK) translate([f[0], f[1], DECK - ZH_SINK]) flak(f[2]); +for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]); function rz(a, p) = [p[0]*cos(a) - p[1]*sin(a), p[0]*sin(a) + p[1]*cos(a), p[2]]; // ---- bow: cab with the slit high on the spine's front step --------------------------------------------------------- @@ -87,7 +90,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); // plate seams +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams + zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); + lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +} // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. module feed_collar() { @@ -97,7 +103,7 @@ module feed_collar() { } // ---- flanks: feed collar, ammunition door, unit serial ------------------------------------------------------------- for (s = [-1, 1]) { - zh_on_belt(M, 1.5, s, 0.5) rotate([0, 0, 22.5]) feed_collar(); + zh_on_belt(M, 1.5, s, 0.5) lp_at(r=[0, 0, 22.5]) feed_collar(); zh_on_wall(M, 7.0, s, 0.45) zh_hatch(2.6, 3.0); zh_on_wall(M, -9.0, s, 0.5) zh_serial(0.8, 5); } diff --git a/tools/blender/aetheria_ships/factions/zhestokost.scad b/tools/blender/aetheria_ships/factions/zhestokost.scad index ae38b0186..01829d368 100644 --- a/tools/blender/aetheria_ships/factions/zhestokost.scad +++ b/tools/blender/aetheria_ships/factions/zhestokost.scad @@ -84,7 +84,22 @@ function zh_stern_at(secs, x, v, o, sink=ZH_SINK) = zh_stern_pt(secs, x, v) - [0 // ==== PRIMARY: masses that make the silhouette ======================================================================== // Any section list as one faceted poured mass. -module zh_mass(secs, zone="primary") zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } +// Lines: the plan at the cast belt (outline), the deck edge (step) and, on a primary mass, a seam across the deck at each +// interior station. lines=false for a mass no top view can see (a keel under the hull). +module zh_mass(secs, zone="primary", lines=true) { + zh_z(zone) for (i = [0:len(secs)-2]) hull() { _zh_slice(secs[i]); _zh_slice(secs[i+1]); } + if (lines) { + n = len(secs); + lp_line("outline", concat([for (s = secs) [-s[1]/2, s[0], s[3] + s[2]*ZH_WU]], + [for (i = [n-1:-1:0]) [secs[i][1]/2, secs[i][0], secs[i][3] + secs[i][2]*ZH_WU]]), true); + lp_line("step", concat([for (s = secs) [-(s[1]/2 - s[4]), s[0], s[3] + s[2]]], + [for (i = [n-1:-1:0]) [secs[i][1]/2 - secs[i][4], secs[i][0], secs[i][3] + secs[i][2]]]), true); + if (zone == "primary") for (i = [1:n-2]) let(s = secs[i]) + lp_line("seam", [[-(s[1]/2 - s[4]), s[0], s[3] + s[2]], [s[1]/2 - s[4], s[0], s[3] + s[2]]]); + } +} +function zh_rect(w, l, z) = [[-w/2, -l/2, z], [w/2, -l/2, z], [w/2, l/2, z], [-w/2, l/2, z]]; +function zh_oct(r, z) = [for (k = [0:7]) [r*cos(45*k), r*sin(45*k), z]]; // A core or whole hull: bow chamfered in plan and dropped (bow, as a fraction of L), stern a flat face with a cast lip. // bowW / bowH: nose width and height as fractions of W and H. @@ -128,6 +143,7 @@ module zh_collar(r=ZH_COLLAR_R, proud=0.55, lugs=4) { zh_z("secondary") difference() { cylinder(r=r, h=proud + ZH_SINK, $fn=8); translate([0, 0, -0.1]) cylinder(r=r*0.6, h=proud + 1, $fn=8); } zh_z("recess") cylinder(r=r*0.62, h=0.08 + ZH_SINK, $fn=8); + lp_line("fitting", zh_oct(r, proud), true); zh_z("accent") for (i = [0:lugs-1]) rotate(i*360/lugs) translate([r + 0.16, 0, (proud + 0.25)/2]) cube([0.5, 0.6, proud + 0.25 + ZH_SINK], center=true); } @@ -140,13 +156,17 @@ module zh_hatch(w=ZH_HATCH, l=ZH_HATCH, t=0.22, dogs=true) { for (x = [-1, 1]) translate([x*w*0.27, 0, t + 0.12]) cbox([0.5, l + 0.6, 0.34], 0.07); if (dogs) for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 + 0.12), sy*l*0.3, t*0.6]) cube([0.36, 0.44, 0.4], center=true); } + lp_line("fitting", zh_rect(w, l, t), true); translate([0, l/2 - 0.42, t]) zh_stripes(w*0.36, 0.5, dogs ? 3 : 2); } // Armour plate (any frame): replaceable slab with four bolt bosses; leaves a seam to its neighbour. -module zh_plate(w, l, t=0.3, zone="secondary") zh_z(zone) { - translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); - for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); +module zh_plate(w, l, t=0.3, zone="secondary") { + zh_z(zone) { + translate([0, 0, t/2]) cbox([w, l, t + ZH_SINK], 0.07); + for (sx = [-1, 1], sy = [-1, 1]) translate([sx*(w/2 - 0.25), sy*(l/2 - 0.25), t + 0.05]) cube([0.22, 0.22, 0.12], center=true); + } + lp_line("fitting", zh_rect(w, l, t), true); } // Radiator trough (top frame): cast frame round a black floor, split by a spine wall; vanes stand in it (zh_rad_vanes). @@ -155,6 +175,8 @@ module zh_rad_trough(w=3.2, l=2.4, d=0.45, zone="secondary") { cbox([w + 0.7, l + 0.7, d + ZH_SINK], 0.1); cube([w, l, d*3], center=true); } zh_z(zone) translate([0, 0, d*0.6]) cube([0.3, l, d*1.2 + ZH_SINK], center=true); zh_z("recess") translate([0, 0, 0.04]) cube([w, l, 0.08 + ZH_SINK], center=true); + lp_line("fitting", zh_rect(w + 0.7, l + 0.7, d), true); + lp_line("fitting", zh_rect(w, l, d), true); } // radiator-mesh for one half of a trough (side 1 port, -1 starboard): transverse vanes rising out of it module zh_rad_vanes(w=3.2, l=2.4, side=1, n=5, h=1.0) { @@ -173,17 +195,24 @@ module zh_tow_lug(s=ZH_LUG, zone="secondary") { // Ballistic mount (top frame): octagonal ring, battered mantlet block, n thick barrels with box brakes, pointing -Y. module zh_gun(n=2, cal=0.22, len=3.0, base=1.8, zone="secondary") { zh_z(zone) cylinder(r=base*0.56, h=0.35 + ZH_SINK, $fn=8); - zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone); - zh_z(zone) for (i = [0:n-1]) translate([(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { - rotate([0, 0, 180]) tube(cal, len, 8); - translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + lp_line("fitting", zh_oct(base*0.56, 0.35), true); + zh_mass([for (s = zh_block(0, base*0.95, base, base*0.5, 0.33, base*0.14, 0, 0.22)) s], zone, lines=false); + for (i = [0:n-1]) lp_at(t=[(i - (n-1)/2)*cal*3.2, -base*0.4, 0.33 + base*0.25]) { + zh_z(zone) { + rotate([0, 0, 180]) tube(cal, len, 8); + translate([0, -len, 0]) cbox([cal*3, cal*3.5, cal*3], 0.04); + } + lp_line("fitting", [[0, 0, 0], [0, -len, 0]]); } } function zh_gun_muzzle(n=2, cal=0.22, len=3.0, base=1.8) = [0, -base*0.4 - len - cal*1.75, 0.33 + base*0.25]; // ==== TERTIARY: detail =============================================================================================== // Shadow-gap floor (top frame): the black bottom of a reveal between modules. -module zh_reveal(w, l) zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); +module zh_reveal(w, l) { + zh_z("recess") translate([0, 0, 0.03]) cube([w, l, 0.06 + ZH_SINK], center=true); + lp_line("seam", zh_rect(w, l, 0.06), true); +} // Cream safety chevrons (any frame, +Z out): n diagonal bars clipped to w x l. module zh_stripes(w, l, n=3) zh_z("marking") intersection() { translate([0, 0, 0.04]) cube([w, l, 0.1], center=true); From b982fb9cd082c2abd451e964fcf12e872cb0876d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:57:44 +0200 Subject: [PATCH 14/29] hull_source: kit lines echo, SVG silhouette projector, SchematicLine, three line checks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 168 ++++++++++++++-- tools/blender/tests/test_hull_source.py | 200 +++++++++++++++++++- 2 files changed, 347 insertions(+), 21 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index fb2aa246a..ca929560f 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -11,11 +11,16 @@ with it (a hull that redefines or shadows an included language fails a check): (every included language file echoes its own, and so does factions/.scad run alone; a Pirate language has no body zone and no proportion) anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port + lines ECHO line = [layer, cyclic, [[x, y, z], ...]] one line per kit part that draws one, points in the Ship Root + frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with +lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -49,9 +54,18 @@ OPENSCAD_TIMEOUT = 180 # seconds, per part SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh") MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"} -ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer +ECHO_PARTS = ("brief", "lang", "anchors", "lines") # parts that make no geometry; their ECHO lines are the answer LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*") ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE) +KIT_LAYERS = ("outline", "step", "seam", "fitting") # layers a kit's lines may name; "silhouette" is hull_source's own +LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)} +MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull +LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie +DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line + +# A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. +Projector = Callable[[list], "list[list[tuple]]"] # A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry. Runner = Callable[[str], "tuple[str, str]"] @@ -109,6 +123,16 @@ class Anchor: parent: str +@dataclass(frozen=True) +class SchematicLine: + layer: str # "silhouette" or one of KIT_LAYERS + cyclic: bool + points: tuple # ((x, y, z), ...) in the Ship Root frame + + def rgba(self): + return LINE_STYLE[self.layer] + + @dataclass(frozen=True) class Check: name: str @@ -129,6 +153,7 @@ class HullBuild: body_zone: str # the zone key the collider is made from hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it checks: list = field(default_factory=list) + lines: list = field(default_factory=list) # SchematicLine: the silhouette contours first, then the kit's lines def failed(self): return [check for check in self.checks if not check.ok] @@ -250,6 +275,47 @@ def read_anchors(text): return anchors +def read_lines(text): + """The kit's schematic lines, in echo order. A layer outside KIT_LAYERS, fewer than two points or a point that is not + three finite numbers is refused.""" + lines = [] + for raw in parse_echoes(text, "line"): + if not isinstance(raw, list) or len(raw) != 3 or type(raw[1]) is not bool or not isinstance(raw[2], list): + raise HullSourceError("A schematic line has the wrong shape") + layer = _text(raw[0]) + if layer not in KIT_LAYERS: + raise HullSourceError("A schematic line names a layer a kit does not draw") + if len(raw[2]) < 2: + raise HullSourceError("A schematic line has fewer than two points") + points = tuple(_vector(p, 3) for p in raw[2]) + if not all(math.isfinite(c) for p in points for c in p): + raise HullSourceError("A schematic line has a point that is not a finite number") + lines.append(SchematicLine(layer, raw[1], points)) + return lines + + +def to_off(mesh): + """The OFF text of a Mesh.""" + rows = ["OFF", f"{len(mesh.vertices)} {len(mesh.triangles)} 0"] + [" ".join(repr(float(c)) for c in v) for v in mesh.vertices] + return "\n".join(rows + [f"3 {a} {b} {c}" for a, b, c in mesh.triangles]) + "\n" + + +def parse_svg_contours(text): + """The contours of an OpenSCAD SVG (a path of 'M x,y L x,y ... z' pieces) as lists of model (x, y): the SVG's y is the + model's -y. A contour under MIN_SILHOUETTE_AREA is dropped (the zero-area slivers a projection leaves).""" + contours = [] + for path in re.findall(r'= 3 and area >= MIN_SILHOUETTE_AREA: + contours.append(ring) + return contours + + def _exactly_one(values, what): if len(values) != 1: raise HullSourceError(f"The hull source must echo exactly one {what}") @@ -289,9 +355,36 @@ def _language(library, faction): return language -def build(runner, library, hull_type): +def _point_in_ring(x, y, ring): + inside = False + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay): + inside = not inside + return inside + + +def _edge_distance(x, y, ring): + best = float("inf") + for (ax, ay), (bx, by) in zip(ring, ring[1:] + ring[:1]): + dx, dy = bx - ax, by - ay + along = ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy) if dx or dy else 0.0 + along = max(0.0, min(1.0, along)) + best = min(best, math.hypot(x - (ax + along * dx), y - (ay + along * dy))) + return best + + +def over_silhouette(point, contours): + """Whether the point's top view lies inside the silhouette (even-odd over every contour) or within + LINE_EDGE_TOLERANCE of a contour's edge.""" + x, y = point[0], point[1] + return (sum(_point_in_ring(x, y, ring) for ring in contours) % 2 == 1 + or any(_edge_distance(x, y, ring) <= LINE_EDGE_TOLERANCE for ring in contours)) + + +def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with - (the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller + (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every + zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -314,10 +407,16 @@ def build(runner, library, hull_type): if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors): raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make") anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES} + kit_lines = read_lines(runner("lines")[1]) judge = donor or own body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) + meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + if meshes: + top = max(v[2] for m in meshes for v in m.vertices) + result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] + result.lines += kit_lines by_faction = lambda language: language.faction result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] @@ -366,6 +465,14 @@ def _checks(hull, own, judge): f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side")) except ValueError: checks.append(Check("the top view fits the layout grid", False, "no geometry")) + silhouette = [line for line in hull.lines if line.layer == "silhouette"] + kit = [line for line in hull.lines if line.layer != "silhouette"] + contours = [[p[:2] for p in line.points] for line in silhouette] + checks.append(Check("the top view yields a silhouette", bool(silhouette), f"{len(silhouette)} contours")) + outside = [line for line in kit if not all(over_silhouette(p, contours) for p in line.points)] if silhouette else kit + checks.append(Check("every kit line lies over the hull's top view", not outside, f"{len(outside)} outside")) + keys = [tuple(tuple(round(c / DUPLICATE_CELL) for c in p) for p in line.points) for line in kit] + checks.append(Check("no two kit lines are the same", len(set(keys)) == len(keys), f"{len(keys) - len(set(keys))} repeated")) triangles = sum(len(m.triangles) for m in every) checks.append(Check("the triangle count lies inside the brief's budget", brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}")) @@ -380,6 +487,25 @@ def _checks(hull, own, judge): return checks +def _run_openscad(command, on_start, **options): + """Runs one OpenSCAD process to completion and answers its output. The refusals are fixed text.""" + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **options) + except OSError: + raise HullSourceError("OpenSCAD could not be started") from None + if on_start: + on_start(process.pid) + try: + output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise HullSourceError("OpenSCAD did not finish a part in time") from None + if process.returncode != 0: + raise HullSourceError("OpenSCAD refused a part of the hull source") + return output + + def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): """A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is called with each process's id. OPENSCADPATH is the language folder, so a hull source includes .""" @@ -390,20 +516,7 @@ def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None): target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off") command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"', "-o", str(target), str(source)] - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - except OSError: - raise HullSourceError("OpenSCAD could not be started") from None - if on_start: - on_start(process.pid) - try: - output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT) - except subprocess.TimeoutExpired: - process.kill() - process.communicate() - raise HullSourceError("OpenSCAD did not finish a part in time") from None - if process.returncode != 0: - raise HullSourceError("OpenSCAD refused a part of the hull source") + output = _run_openscad(command, on_start, env=env) written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else "" return ("", written + output) if part in ECHO_PARTS else (written, output) @@ -420,3 +533,24 @@ def language_library(executable, library=FACTIONS_DIR, on_start=None): return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1] return read + + +def openscad_projector(executable, on_start=None): + """A Projector that has OpenSCAD (Manifold backend) project the union of the meshes onto the XY plane and export it as + SVG, one process, with the same timeout and refusal shape as openscad_runner.""" + def project(meshes): + with tempfile.TemporaryDirectory() as folder: + names = [] + for i, mesh in enumerate(meshes): + (Path(folder) / f"m{i}.off").write_text(to_off(mesh), encoding="utf-8") + names.append(f'import("m{i}.off");') + wrapper = Path(folder) / "silhouette.scad" + wrapper.write_text("projection() union() { " + " ".join(names) + " }\n", encoding="utf-8") + target = Path(folder) / "silhouette.svg" + command = [str(executable), "--backend", "Manifold", "-o", str(target), str(wrapper)] + _run_openscad(command, on_start, cwd=folder) + if not target.exists(): + raise HullSourceError("OpenSCAD refused a part of the hull source") + return parse_svg_contours(target.read_text(encoding="utf-8", errors="replace")) + + return project diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index bd534d83b..a1d8d163b 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -67,8 +67,18 @@ class Fixture: + echo("anchor", ["Gun.A.muzzle", "weapon-muzzle", [0, -3, 3], 0, "Gun.A"]) + echo("anchor", ["Th.A", "thruster-emitter", [1, 5, 1], 180, ""])), "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), + "lines": ("", ""), } + @staticmethod + def projector(meshes): + """A top view that is the bounding rectangle of every mesh it is given.""" + lows = [m.bounds()[0] for m in meshes] + highs = [m.bounds()[1] for m in meshes] + x0, y0 = min(v[0] for v in lows), min(v[1] for v in lows) + x1, y1 = max(v[0] for v in highs), max(v[1] for v in highs) + return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] + def runner(self): return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] @@ -78,7 +88,7 @@ class Fixture: return self.library[faction] def build(self): - return hs.build(self.runner(), self.read_library, self.hull_type) + return hs.build(self.runner(), self.read_library, self.hull_type, self.projector) def failed(self): return [c.name for c in self.build().failed()] @@ -206,7 +216,7 @@ class HullTypeTests(unittest.TestCase): def test_build_has_no_default_hull_type(self): fixture = Fixture() with self.assertRaises(TypeError): - hs.build(fixture.runner(), fixture.read_library) + hs.build(fixture.runner(), fixture.read_library, projector=fixture.projector) def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self): for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET): @@ -214,7 +224,7 @@ class HullTypeTests(unittest.TestCase): fixture, read = Fixture(), [] runner = fixture.runner() with self.assertRaises(hs.HullSourceError) as caught: - hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type) + hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type, fixture.projector) self.assertEqual(read, []) self.assertNotIn(SECRET, str(caught.exception)) @@ -479,7 +489,7 @@ class LanguageOwnershipTests(unittest.TestCase): def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self): asked = [] fixture = DonorTests.pirate() - hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship") + hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship", fixture.projector) self.assertEqual(sorted(asked), ["pirate", "rnd"]) @@ -667,5 +677,187 @@ class RefusalTests(unittest.TestCase): self.assertEqual(source.count("subprocess.Popen"), 1) +LINES_OVER = "every kit line lies over the hull's top view" +LINES_DISTINCT = "no two kit lines are the same" +SILHOUETTE = "the top view yields a silhouette" + + +def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): + return echo("line", [layer, cyclic, [list(p) for p in points]]) + + +class LineReadingTests(unittest.TestCase): + def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): + text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") + read = hs.read_lines(text) + self.assertEqual([(l.layer, l.cyclic, len(l.points)) for l in read], [("step", True, 3), ("fitting", False, 2)]) + self.assertEqual(read[0].points[1], (1, 0, 1)) + + def test_a_layer_outside_the_kit_layers_is_refused_without_echoing_it(self): + for layer in ("silhouette", "outlines", "", SECRET): + with self.subTest(layer=layer), self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(line(layer)) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_a_line_of_one_point_or_a_point_that_is_not_finite_is_refused(self): + for text in (line(points=((0, 0, 0),)), line(points=()), 'ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n', + 'ECHO: line = ["seam", false, [[0, 0, Infinity], [1, 1, 1]]]\n', line(points=((0, 0), (1, 1)))): + with self.subTest(text=text), self.assertRaises(hs.HullSourceError): + hs.read_lines(text) + + def test_a_cyclic_flag_that_is_not_a_boolean_is_refused(self): + with self.assertRaises(hs.HullSourceError): + hs.read_lines(echo("line", ["seam", 1, [[0, 0, 0], [1, 1, 1]]])) + + +SVG = """ + + + +""" + + +class SvgContourTests(unittest.TestCase): + def test_contours_come_back_in_model_coordinates_with_y_negated(self): + contours = hs.parse_svg_contours(SVG) + self.assertEqual(contours[0], [(0.0, 4.0), (4.0, 4.0), (4.0, 1.0), (0.0, 1.0)]) + self.assertEqual(contours[1][0], (-1e-05, -2.0)) + + def test_a_contour_under_a_quarter_square_metre_is_a_sliver_and_dropped(self): + self.assertEqual(len(hs.parse_svg_contours(SVG)), 2) + + def test_a_drawing_with_no_path_has_no_contours(self): + self.assertEqual(hs.parse_svg_contours(""), []) + + +class LineBuildTests(unittest.TestCase): + def built(self, kit="", **parts): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + fixture.parts.update(parts) + return fixture, fixture.build() + + def test_a_source_with_no_lines_has_the_silhouette_alone_and_passes_every_check(self): + _, built = self.built() + self.assertEqual([(l.layer, l.cyclic) for l in built.lines], [("silhouette", True)]) + self.assertEqual(built.failed(), []) + + def test_the_silhouette_is_first_cyclic_and_at_the_top_of_the_geometry(self): + _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) + self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) + self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + + def test_each_layer_carries_its_fixed_style(self): + kit = "".join(line(layer) for layer in hs.KIT_LAYERS) + _, built = self.built(kit) + self.assertEqual([l.rgba() for l in built.lines], [hs.LINE_STYLE[l.layer] for l in built.lines]) + self.assertEqual(len({l.rgba() for l in built.lines}), 5) + self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) + + def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + fixture, given = Fixture(), [] + hs.build(fixture.runner(), fixture.read_library, "Ship", + lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) + self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + + def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): + _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_a_kit_line_a_little_off_the_edge_still_lies_over_the_hull(self): + _, built = self.built(line(points=((3.2, 0, 2), (3.2, 1, 2)))) + self.assertEqual(built.failed(), []) + _, built = self.built(line(points=((3.4, 0, 2), (3.4, 1, 2)))) + self.assertEqual([c.name for c in built.failed()], [LINES_OVER]) + + def test_the_inside_of_a_hole_in_the_silhouette_is_not_over_the_hull(self): + hole = [(-1, -1), (1, -1), (1, 1), (-1, 1)] + outer = [(-3, -5), (3, -5), (3, 5), (-3, 5)] + self.assertTrue(hs.over_silhouette((2, 0, 0), [outer, hole])) + self.assertFalse(hs.over_silhouette((0, 0, 0), [outer, hole])) + + def test_two_equal_kit_lines_fail_the_distinct_check_and_nothing_else(self): + _, built = self.built(line() + line("fitting")) + self.assertEqual([c.name for c in built.failed()], [LINES_DISTINCT]) + + def test_lines_that_differ_by_a_millimetre_or_more_are_distinct(self): + _, built = self.built(line(points=((0, 0, 2), (1, 0, 2))) + line(points=((0, 0.002, 2), (1, 0, 2)))) + self.assertEqual(built.failed(), []) + + def test_no_contour_fails_the_silhouette_check(self): + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual([c.name for c in built.failed()], [SILHOUETTE]) + self.assertEqual(built.lines, []) + + def test_a_kit_line_with_no_silhouette_to_lie_over_fails_both_checks(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line()) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: []) + self.assertEqual(sorted(c.name for c in built.failed()), sorted([SILHOUETTE, LINES_OVER])) + + def test_a_source_with_no_geometry_asks_for_no_silhouette(self): + fixture = Fixture() + fixture.parts.update({"primary": ("", ""), "secondary": ("", ""), "emit:Th.A": ("", "")}) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: self.fail("projected nothing")) + self.assertEqual(built.lines, []) + + def test_a_refused_line_is_refused_without_echoing_it(self): + fixture = Fixture() + fixture.parts["lines"] = ("", line(SECRET)) + with self.assertRaises(hs.HullSourceError) as caught: + fixture.build() + self.assertNotIn(SECRET, str(caught.exception)) + + +class WrapperOpenScad(FakeOpenScad): + """Keeps the wrapper scad and the OFF files OpenSCAD is given, which vanish with the temp folder.""" + + def __call__(self, command, **kwargs): + wrapper = Path(command[-1]) + self.wrapper = wrapper.read_text(encoding="utf-8") + self.imports = {f.name: f.read_text(encoding="utf-8") for f in wrapper.parent.glob("*.off")} + return super().__call__(command, **kwargs) + + +class ProjectorTests(unittest.TestCase): + def project(self, openscad, meshes): + with mock.patch.object(hs.subprocess, "Popen", openscad): + return hs.openscad_projector("scad")(meshes) + + def test_the_meshes_are_unioned_projected_and_the_svg_read_back(self): + a, b = hs.parse_off(box(0, 1, 0, 1, 0, 1)), hs.parse_off(box(2, 3, 0, 1, 0, 1)) + openscad = WrapperOpenScad(off=SVG) + contours = self.project(openscad, [a, b]) + self.assertEqual(openscad.wrapper.split(), ["projection()", "union()", "{", 'import("m0.off");', 'import("m1.off");', "}"]) + self.assertEqual(sorted(openscad.imports), ["m0.off", "m1.off"]) + self.assertEqual(hs.parse_off(openscad.imports["m1.off"]).bounds(), b.bounds()) + command = openscad.calls[0][0] + self.assertEqual(command[command.index("--backend") + 1], "Manifold") + self.assertTrue(command[command.index("-o") + 1].endswith(".svg")) + self.assertEqual(len(contours), 2) + + def test_a_failed_or_silent_openscad_is_refused_without_echoing_its_output(self): + for openscad in (FakeOpenScad(returncode=1, output=SECRET), FakeOpenScad(output=SECRET)): + with self.assertRaises(hs.HullSourceError) as caught: + self.project(openscad, [hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertNotIn(SECRET, str(caught.exception)) + + def test_the_process_is_reported_to_on_start(self): + started = [] + with mock.patch.object(hs.subprocess, "Popen", WrapperOpenScad(off=SVG)): + hs.openscad_projector("scad", on_start=started.append)([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(started, [4242]) + + def test_the_lines_part_answers_the_echo(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(output=line())): + self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) + + if __name__ == "__main__": unittest.main() From 905074defb43412a9f430b3833e498d56a110201 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 15:59:56 +0200 Subject: [PATCH 15/29] Add-on: schematic lines are hers (one Grease Pencil) else Build's from the kit; smoke and C# fact Co-Authored-By: Claude Sonnet 5.5 --- .../NakovalnyaPackageTests.cs | 21 +++++ tools/blender/aetheria_ships/__init__.py | 54 ++++++++---- tools/blender/aetheria_ships/ship_cc.py | 2 +- tools/blender/tests/smoke_hull_source.py | 84 +++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 2 +- 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs index d2b08f8ba..29d15db31 100644 --- a/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs +++ b/tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs @@ -120,4 +120,25 @@ public sealed class NakovalnyaPackageTests .Select(row => $"{row.Item1} refuses the {row.Item2}").ToArray(); Assert.Empty(refused); } + + // The package's schematic is the kit's (ruling agent-hull-schematic-from-kit): hull_source's silhouette first, then + // the kit's lines; Studio fits their top view onto the hull Shape's whole cell rect. + [Fact] + public void SchematicLinesComeFromTheKit() + { + using var cache = Catalog(); + var hull = Hull(cache); + var ship = cache.Get(new CultRecordRef(ShipModCatalog.AuthoringKey(Id))); + var kitLayers = new[] { "outline", "step", "seam", "fitting" }; + + Assert.Equal("silhouette", ship.SchematicLines[0].Layer); + Assert.True(ship.SchematicLines[0].Cyclic); + Assert.True(ship.SchematicLines.Count(line => kitLayers.Contains(line.Layer)) > 20); + Assert.All(ship.SchematicLines, line => Assert.Contains(line.Layer, kitLayers.Append("silhouette"))); + ShipAuthoringStore.Validate(hull, ship); + + var fit = SchematicUnderlay.Fit(ship.SchematicLines, hull.Shape).SelectMany(line => line).ToArray(); + Assert.Equal((19, 30), (hull.Shape.Width, hull.Shape.Height)); + Assert.Equal((0f, 0f, 19f, 30f), (fit.Min(p => p.x), fit.Min(p => p.y), fit.Max(p => p.x), fit.Max(p => p.y))); + } } diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17ad00afc..08daa095c 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -533,12 +533,11 @@ class AETHERIA_OT_bind_ship_collection(bpy.types.Operator): class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): bl_idname = "aetheria.capture_ship_lines" bl_label = "Capture Ship Lines" - bl_description = "Replace only the line slot in the selected typed ship .cc record" + bl_description = "Replace only the line slot in the bound ship .cc record: from the Grease Pencil object, else from the hull source" bl_options = {"REGISTER"} def execute(self, context): try: - obj = context.active_object collection = _ship_collection(context) if not collection.get("aetheria.ship_cc"): raise ValueError("The ship collection has no bound .cc path") @@ -546,10 +545,9 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") - lines = capture_grease_pencil( - obj, context.evaluated_depsgraph_get(), context.scene.frame_current, - evaluated=context.scene.aetheria_capture_evaluated_lines, - ) + lines = _schematic_lines(context, collection) + if lines is None: + raise ValueError("No schematic lines were captured; the ship record was not changed") count = replace_lines(path, cultlib, lines) self.report({"INFO"}, f"Captured {count} lines") return {"FINISHED"} @@ -1492,6 +1490,7 @@ def _frame_ship(context, collection, root, path, saved_shape): HULL_MAKER = "aetheria.hull_maker" # on the collection: the Faction key of the language Build judged the hull against +HULL_LINES = "aetheria.hull_lines" # on the collection: [{layer, cyclic, points (flat), rgba}] the hull source's kit drew, in Ship Root's frame HULL_BUILT = "aetheria.hull_built" # on the collection: {object name: fingerprint} of what Build From Hull Source made HULL_AGENT = "agent" # HS rows H5, H7-H10, H12: Build's judged body or record governs; Package checks the maker HULL_ART = "art" # HS rows H4, H6, H11: her meshes replace Build's; Package ships them and retires the hull source @@ -1577,7 +1576,7 @@ def _build_hull_source(context, collection): executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context)) hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), - hull_type) + hull_type, hull_source.openscad_projector(executable)) broken = hull.failed() if broken: raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken)) @@ -1629,9 +1628,38 @@ def _build_hull_source(context, collection): collider["aetheria.role"], collider["aetheria.id"] = role, anchor_id collection[HULL_BUILT] = {obj.name: _fingerprint(obj) for obj in made} collection[HULL_MAKER] = hull.languages[hull.brief.faction].maker + collection[HULL_LINES] = [{"layer": line.layer, "cyclic": line.cyclic, "points": [c for p in line.points for c in p], + "rgba": list(line.rgba())} for line in hull.lines] return hull +def _schematic_lines(context, collection): + """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, + mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and + the slot is left as it is.""" + pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] + if len(pencils) > 1: + raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") + scene = context.scene + if pencils: + return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, + evaluated=scene.aetheria_capture_evaluated_lines) + record = collection.get(HULL_LINES) + if record is None: + return None + root = _ship_root(collection) + world = root.matrix_world if root is not None else Matrix.Identity(4) + rows = [] + for line in record: + flat = list(line["points"]) + points = [] + for x, y, z in zip(flat[0::3], flat[1::3], flat[2::3]): + points.extend(float(c) for c in world @ Vector((x, y, z))) + rows.append([line["layer"], "", points, None, None, bool(line["cyclic"]), [float(c) for c in line["rgba"]]]) + return rows + + def _hull_state(collection): """The one owner of a ship collection's hull state (map HS), decided in this order: an untouched Build zone mesh keeps the hull agent's; her meshes beside Build's record or hull.scad make it art; then Build's record is agent, @@ -1659,7 +1687,7 @@ def _retire_hull_source(collection): source = _hull_source_file(collection) if source is not None: source.unlink() - for record in (HULL_BUILT, HULL_MAKER): + for record in (HULL_BUILT, HULL_MAKER, HULL_LINES): if record in collection: del collection[record] return True @@ -1708,7 +1736,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): bl_idname = "aetheria.package_ship" bl_label = "Package Ship" bl_description = ("Put the grid's centre of mass at the world origin, derive the missing map icon, shield and " - "tractor, write the anchors and ship.glb from the bound collection, capture its LineArt, and " + "tractor, write the anchors and ship.glb from the bound collection, capture its schematic lines, and " "run AetherDb's validator") bl_options = {"REGISTER"} @@ -1738,11 +1766,9 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) - pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] - if len(pencils) == 1: - replace_lines(path, cultlib, capture_grease_pencil( - pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, - evaluated=scene.aetheria_capture_evaluated_lines)) + lines = _schematic_lines(context, collection) + if lines is not None: + replace_lines(path, cultlib, lines) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 079296be4..f3742dd4f 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -173,7 +173,7 @@ def _stamped(record: Record, msgpack: Any): def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> int: file = read(path, cultlib_packages) if not lines: - raise ValueError("No Grease Pencil strokes were captured; the ship record was not changed") + raise ValueError("No schematic lines were captured; the ship record was not changed") file.ship.body[SCHEMATIC_LINES_SLOT] = lines file.store.push(_stamped(file.ship, file.msgpack)) return len(lines) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 06f2db715..b57dfe5af 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -36,6 +36,10 @@ the hull.scad into the package and runs the panel's operators: or a product by another maker than the language Build judged it against (hull-names-its-own-faction); with a product by that maker it accepts, with hull.scad in the package or not (the guard keys on the hull's state), and a refused Package changes nothing in the scene. Her art needs no product. + Schematic lines: after Build and Package the ship.cc's first line is the silhouette and the kit's lines follow (the Pirate + hull's kit draws none); a Grease Pencil object in the collection replaces them with its own; a second one is refused by + Package and Capture Ship Lines, and the slot is as it was. The Nakovalnya's hull.scad, read with the real kit, has a + silhouette whose box is the zones' top-view box and a fitting octagon at every flak mount anchor. Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing. Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw. The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the @@ -366,6 +370,37 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(saved == [(mount_id, cell) for mount_id, _, cell in rows], f"A row is not as authored after Package: {saved}") print(f"PASS {number}.5 Package accepts the built ship and keeps hull.scad") + # Schematic lines: Build's, else hers, and never two of hers. + packaged = ship_lines(ship_cc) + require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") + kit = packaged[1:] + require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + "A kit line row is not shaped as Package writes it") + require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") + require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") + shipped_z = {round(c, 3) for row in packaged for c in row[2][2::3]} + require(len(shipped_z) >= 1, "The lines have no height") + pencil = grease_pencil(collection, "her lines", [(0, 0, 0), (1, 0, 0), (1, 1, 0)]) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"}, f"Package refused a ship with one Grease Pencil: {message!r}") + hers = ship_lines(ship_cc) + require(len(hers) == 1 and hers[0][0] == "hers" and len(hers[0][2]) == 9, f"Her Grease Pencil did not replace the kit's lines: {hers}") + second = grease_pencil(collection, "second lines", [(0, 0, 0), (2, 0, 0)]) + before = ship_lines(ship_cc) + for operator in (bpy.ops.aetheria.package_ship, bpy.ops.aetheria.capture_ship_lines): + select(box) + result, message = run(operator) + require(result == {"CANCELLED"} and "Keep one Grease Pencil object" in (message + scene.aetheria_package_report), + f"Two Grease Pencil objects were not refused: {message!r}") + require(ship_lines(ship_cc) == before, "A refused schematic changed the line slot") + for obj in (pencil, second): + bpy.data.objects.remove(obj) + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") + print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -566,6 +601,54 @@ def clear(collection): bpy.data.objects.remove(obj) +def ship_lines(ship_path): + """The ShipAuthoring.SchematicLines rows of a ship.cc, as Package wrote them.""" + return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] + + +def grease_pencil(collection, name, points): + """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" + data = bpy.data.grease_pencils.new(name) + material = bpy.data.materials.new(name) + bpy.data.materials.create_gpencil_data(material) + data.materials.append(material) + layer = data.layers.new("hers") + drawing = layer.frames.new(1).drawing + drawing.add_strokes([len(points)]) + for point, position in zip(drawing.strokes[0].points, points): + point.position = position + obj = bpy.data.objects.new(name, data) + collection.objects.link(obj) + return obj + + +def nakovalnya_frames(executable): + """The Nakovalnya's hull.scad with the real kit: the silhouette is the zones' top view and a fitting octagon sits at + every flak mount (the frame the kit placed the barrel in is the frame the anchor is in).""" + source = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" + built = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable), + "Ship", hull_source.openscad_projector(executable)) + require(not built.failed(), f"The Nakovalnya breaks a check: {[c.name for c in built.failed()]}") + silhouette = [row for row in built.lines if row.layer == "silhouette"] + require(built.lines[0].layer == "silhouette" and len(silhouette) == 1, "The Nakovalnya silhouette is not one first contour") + xs = [v[0] for mesh in built.zones.values() for v in mesh.vertices] + ys = [v[1] for mesh in built.zones.values() for v in mesh.vertices] + box = (min(xs), max(xs), min(ys), max(ys)) + flat = silhouette[0].points + got = (min(p[0] for p in flat), max(p[0] for p in flat), min(p[1] for p in flat), max(p[1] for p in flat)) + require(approx(box, got, 1e-3), f"The silhouette box {got} is not the zones' top-view box {box}") + octagons = [row.points for row in built.lines if row.layer == "fitting" and row.cyclic and len(row.points) == 8] + centres = [(sum(p[0] for p in o) / 8, sum(p[1] for p in o) / 8) for o in octagons] + mounts = [a for a in built.anchors if a.id.startswith("Gun.F") and a.role == "weapon-mount"] + require(len(mounts) == 10, f"The Nakovalnya has {len(mounts)} flak mounts") + for mount in mounts: + near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) + require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + kinds = {row.layer for row in built.lines} + require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") + print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") + + def aetherdb(ship_path, *args): """Runs one AetherDb ship-authoring command through the add-on's own runner; returns its exit code.""" return sys.modules["aetheria_ships"]._aetherdb(bpy.context, *args, ship_cc=ship_path)[0] @@ -797,6 +880,7 @@ def main(): clear(agent_guards(scene)) clear(hull_type_gate(scene)) never_built(scene) + nakovalnya_frames(EXECUTABLE) created = not MODS.exists() diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 828352438..ac01b2faa 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -368,7 +368,7 @@ class ReplaceLinesTests(ShipFileCase): def test_refuses_an_empty_capture_and_changes_nothing(self): before = self.bytes() - with self.assertRaisesRegex(ValueError, "No Grease Pencil strokes"): + with self.assertRaisesRegex(ValueError, "No schematic lines"): ship_cc.replace_lines(self.path, PACKAGES, []) self.assertEqual(before, self.bytes()) From 5bb4ad5f0f2579f9484638dfb443363b1d082ece Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:02:53 +0200 Subject: [PATCH 16/29] Smoke: the silhouette may be several contours, all before the kit's lines Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index b57dfe5af..ba5a12ea6 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -373,8 +373,10 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett # Schematic lines: Build's, else hers, and never two of hers. packaged = ship_lines(ship_cc) require(packaged and packaged[0][0] == "silhouette" and packaged[0][5] is True, f"The first line is not the silhouette: {packaged[:1]}") - kit = packaged[1:] - require(all(row[0] in hull_source.KIT_LAYERS and row[3] is None and row[4] is None and row[1] == "" for row in kit), + kit = [row for row in packaged if row[0] != "silhouette"] + drawn = [row[0] for row in packaged] + require(drawn[:len(packaged) - len(kit)] == ["silhouette"] * (len(packaged) - len(kit)), "The silhouette contours are not first") + require(all(row[3] is None and row[4] is None and row[1] == "" for row in packaged), "A kit line row is not shaped as Package writes it") require(bool(kit) == (fixture == "zh-ammo-tender"), f"The kit drew {len(kit)} lines for {fixture}") require(all(approx(row[6], hull_source.LINE_STYLE[row[0]]) for row in packaged), "A line lost its style") From 66c69cdc90c7fd6bed79d32da028d75803da9a9c Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:19 +0200 Subject: [PATCH 17/29] Smoke: sideways-facing kit parts draw no lines (octagon and line counts of the Nakovalnya) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index ba5a12ea6..c60967253 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -646,6 +646,9 @@ def nakovalnya_frames(executable): for mount in mounts: near = min(math.hypot(mount.position[0] - c[0], mount.position[1] - c[1]) for c in centres) require(near <= 0.3, f"{mount.id} is {near:.2f} m from the nearest fitting octagon") + # Parts that face sideways (the belt collars and turning blocks, the wall hatches) draw nothing: no top view sees them. + require(len(octagons) == 20, f"{len(octagons)} octagons: ten flak drums and ten flak rings, and no belt collar's") + require(sum(row.layer != "silhouette" for row in built.lines) == 102, "The Nakovalnya kit's line count changed") kinds = {row.layer for row in built.lines} require({"silhouette", "outline", "step", "seam", "fitting"} <= kinds, f"The Nakovalnya lacks a layer: {kinds}") print(f"PASS frames the Nakovalnya's {len(built.lines)} lines are in the frame of its geometry") From fd1188a797dbef1fd61703decb1aac0b434404ec Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:06:59 +0200 Subject: [PATCH 18/29] hull_source: the silhouette is the zones' top view, what Rasterise reads Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 6 +++--- tools/blender/tests/test_hull_source.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index ca929560f..2689a7230 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -18,7 +18,7 @@ with it (a hull that redefines or shadows an included language fails a check): Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. -Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone and anchor mesh, +Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS @@ -384,7 +384,7 @@ def over_silhouette(point, contours): def build(runner, library, hull_type, projector): """Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with (the ECHO text of that faction's own language file). projector (see Projector) answers the top-view contours of every - zone and anchor mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller + zone mesh, which become the silhouette line. hull_type is the hull's HullData.HullType name, which the caller reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a HullBuild whose failed() lists the checks it breaks.""" @@ -412,7 +412,7 @@ def build(runner, library, hull_type, projector): body_zone = f"{donor.faction}:{donor.body}" if donor else own.body result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes, own.glow, own.radiator, body_zone, hull_type) - meshes = [m for m in list(zones.values()) + list(anchor_meshes.values()) if m.triangles] + meshes = [m for m in zones.values() if m.triangles] # the meshes Rasterise reads, so the underlay registers on the grid if meshes: top = max(v[2] for m in meshes for v in m.vertices) result.lines = [SchematicLine("silhouette", True, tuple((x, y, top) for x, y in contour)) for contour in projector(meshes)] diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index a1d8d163b..173b7f551 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -750,7 +750,7 @@ class LineBuildTests(unittest.TestCase): _, built = self.built(line("outline", ((-1, 0, 1), (1, 0, 1)))) self.assertEqual([l.layer for l in built.lines], ["silhouette", "outline"]) self.assertEqual({p[2] for p in built.lines[0].points}, {3.0}) - self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.1), (-3.0, 5.1)}) + self.assertEqual({p[:2] for p in built.lines[0].points}, {(-3.0, -5.0), (3.0, -5.0), (3.0, 5.0), (-3.0, 5.0)}) def test_each_layer_carries_its_fixed_style(self): kit = "".join(line(layer) for layer in hs.KIT_LAYERS) @@ -759,11 +759,11 @@ class LineBuildTests(unittest.TestCase): self.assertEqual(len({l.rgba() for l in built.lines}), 5) self.assertEqual(hs.LINE_STYLE["silhouette"], (1.0, 1.0, 1.0, 1.0)) - def test_the_projector_is_given_every_zone_and_anchor_mesh_that_has_geometry(self): + def test_the_projector_is_given_every_zone_mesh_that_has_geometry_and_no_anchor_mesh(self): fixture, given = Fixture(), [] hs.build(fixture.runner(), fixture.read_library, "Ship", lambda meshes: given.append(len(meshes)) or fixture.projector(meshes)) - self.assertEqual(given, [3]) # primary, secondary, emit:Th.A + self.assertEqual(given, [2]) # primary and secondary; the emitter mesh is not part of the layout's top view def test_a_kit_line_off_the_hull_fails_the_over_the_top_view_check_and_nothing_else(self): _, built = self.built(line(points=((0, 0, 2), (20, 0, 2)))) From 0cb78d26108ccbbe4681999abb60f5fe3ed7ddc5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:11:53 +0200 Subject: [PATCH 19/29] hull_source tests: pin style, echo parts, contour area, even-odd and edge tolerance, process refusals, check details Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 164 ++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 173b7f551..928bee976 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -7,6 +7,7 @@ real OpenSCAD and the real language files are exercised by smoke_hull_source.py. hull_source is loaded by path because the package __init__ imports bpy. """ +import dataclasses import importlib.util import json import sys @@ -859,5 +860,168 @@ class ProjectorTests(unittest.TestCase): self.assertEqual(hs.openscad_runner("scad", "hull.scad")("lines"), ("", line())) +class LineStyleTests(unittest.TestCase): + def test_the_style_table_is_the_one_the_underlay_reads(self): + self.assertEqual(hs.LINE_STYLE, { + "silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.75), "fitting": (1.0, 1.0, 1.0, 0.6), + "step": (1.0, 1.0, 1.0, 0.5), "seam": (1.0, 1.0, 1.0, 0.35)}) + self.assertEqual(hs.KIT_LAYERS, ("outline", "step", "seam", "fitting")) + + def test_a_schematic_line_cannot_be_changed_after_it_is_read(self): + read = hs.read_lines(line())[0] + with self.assertRaises(dataclasses.FrozenInstanceError): + read.layer = "step" + + def test_the_four_echo_parts_are_written_as_echo_files_and_geometry_parts_as_off(self): + for part, suffix in (("brief", ".echo"), ("lang", ".echo"), ("anchors", ".echo"), ("lines", ".echo"), ("primary", ".off")): + with self.subTest(part=part): + openscad = FakeOpenScad() + with mock.patch.object(hs.subprocess, "Popen", openscad): + hs.openscad_runner("scad", "hull.scad")(part) + command = openscad.calls[0][0] + self.assertTrue(command[command.index("-o") + 1].endswith(suffix)) + + +class LineRefusalTextTests(unittest.TestCase): + def refusal(self, text): + with self.assertRaises(hs.HullSourceError) as caught: + hs.read_lines(text) + return str(caught.exception) + + def test_each_refusal_says_one_fixed_thing(self): + self.assertEqual(self.refusal(echo("line", ["seam", False])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(echo("line", ["seam", 1, []])), "A schematic line has the wrong shape") + self.assertEqual(self.refusal(line("nope")), "A schematic line names a layer a kit does not draw") + self.assertEqual(self.refusal(line(points=((0, 0, 0),))), "A schematic line has fewer than two points") + self.assertEqual(self.refusal('ECHO: line = ["seam", false, [[0, 0, NaN], [1, 1, 1]]]\n'), + "A schematic line has a point that is not a finite number") + + +class ContourAreaTests(unittest.TestCase): + @staticmethod + def svg(*rings): + paths = "\n".join("M " + " L ".join(f"{x},{-y}" for x, y in ring) + " z" for ring in rings) + return f'' + + def test_a_contour_is_kept_from_a_quarter_square_metre_whichever_way_it_winds_or_where_it_lies(self): + square = [(3, 7), (3.5, 7), (3.5, 7.5), (3, 7.5)] + triangle = [(10, 10), (11, 10), (10, 11)] + for ring in (square, square[::-1], triangle, triangle[::-1]): + with self.subTest(ring=ring): + self.assertEqual(len(hs.parse_svg_contours(self.svg(ring))), 1) + + def test_a_contour_just_under_a_quarter_square_metre_is_dropped(self): + for ring in ([(3, 7), (3.6, 7), (3.6, 7.4), (3, 7.4)], [(0, 0), (1, 0), (0, 0.49)]): + with self.subTest(ring=ring): + self.assertEqual(hs.parse_svg_contours(self.svg(ring)), []) + + def test_a_contour_of_fewer_than_three_points_is_dropped(self): + self.assertEqual(hs.parse_svg_contours(self.svg([(0, 0), (9, 9)])), []) + + +class OverSilhouetteTests(unittest.TestCase): + TRIANGLE = [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)] + + def over(self, x, y, contours=None, z=99.0): + return hs.over_silhouette((x, y, z), [self.TRIANGLE] if contours is None else contours) + + def test_inside_and_far_outside(self): + self.assertTrue(self.over(2, 2)) + self.assertFalse(self.over(8, 8)) + self.assertFalse(self.over(-5, 3)) + + def test_the_height_of_a_point_does_not_matter(self): + self.assertTrue(self.over(2, 2, z=-50.0)) + self.assertFalse(self.over(8, 8, z=2.0)) + + def test_a_point_outside_but_within_the_tolerance_of_a_slanted_edge_is_over_the_hull(self): + # the hypotenuse x + y = 10; (5.1, 5.1) is 0.14 m outside it, (5.3, 5.3) is 0.42 m outside + self.assertTrue(self.over(5.1, 5.1)) + self.assertFalse(self.over(5.3, 5.3)) + + def test_a_point_beyond_the_end_of_an_edge_is_measured_to_its_end(self): + self.assertTrue(self.over(10.25, 0)) + self.assertFalse(self.over(10.35, 0)) + self.assertTrue(self.over(-0.25, -0.0)) + self.assertFalse(self.over(0, -0.35)) + + def test_a_ray_through_a_vertex_counts_the_edge_once(self): + diamond = [(0.0, -2.0), (2.0, 0.0), (0.0, 2.0), (-2.0, 0.0)] + self.assertTrue(self.over(0.5, 0, [diamond])) + self.assertTrue(self.over(-0.5, 0, [diamond])) + self.assertFalse(self.over(3.0, 0, [diamond])) + self.assertFalse(self.over(0.0, 3.0, [diamond])) + + def test_the_even_odd_rule_alternates_through_nested_contours(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 7.0, 4.0, 1.0)] + self.assertTrue(self.over(8.5, 0, rings)) # in the outer ring only + self.assertFalse(self.over(5.5, 0, rings)) # a hole + self.assertTrue(self.over(2.5, 0, rings)) # island in the hole + + def test_three_nested_contours_put_the_centre_back_on_the_hull(self): + rings = [[(-s, -s), (s, -s), (s, s), (-s, s)] for s in (10.0, 6.0, 2.0)] + self.assertTrue(self.over(0.0, 0.0, rings)) + + +class ProcessRefusalTests(unittest.TestCase): + def test_openscad_that_cannot_start_is_refused_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", side_effect=OSError(SECRET)): + for make in (lambda: hs.openscad_runner("scad", "hull.scad")("primary"), + lambda: hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])): + with self.assertRaises(hs.HullSourceError) as caught: + make() + self.assertEqual(str(caught.exception), "OpenSCAD could not be started") + + def test_openscad_that_does_not_finish_is_killed_and_refused_in_fixed_words(self): + class Slow(FakeOpenScad): + killed = False + + def communicate(self, timeout=None): + if not self.killed: + raise hs.subprocess.TimeoutExpired("scad", timeout) + return "", None + + def kill(self): + self.killed = True + + slow = Slow() + with mock.patch.object(hs.subprocess, "Popen", slow): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual((str(caught.exception), slow.killed), ("OpenSCAD did not finish a part in time", True)) + + def test_a_projection_openscad_never_wrote_is_refused(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad()): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))]) + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + def test_a_refusing_openscad_says_so_in_fixed_words(self): + with mock.patch.object(hs.subprocess, "Popen", FakeOpenScad(returncode=2)): + with self.assertRaises(hs.HullSourceError) as caught: + hs.openscad_runner("scad", "hull.scad")("primary") + self.assertEqual(str(caught.exception), "OpenSCAD refused a part of the hull source") + + +class LineCheckDetailTests(unittest.TestCase): + def details(self, kit, projector=Fixture.projector): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + built = hs.build(fixture.runner(), fixture.read_library, "Ship", projector) + return {c.name: c.detail for c in built.checks} + + def test_the_details_count_what_the_checks_found(self): + got = self.details(line(points=((0, 0, 2), (20, 0, 2))) + line("step") + line("step")) + self.assertEqual((got[SILHOUETTE], got[LINES_OVER], got[LINES_DISTINCT]), ("1 contours", "1 outside", "1 repeated")) + + def test_a_silhouette_is_not_a_kit_line_so_two_equal_contours_are_not_a_repeat(self): + twice = lambda meshes: Fixture.projector(meshes) * 2 + got = self.details("", twice) + self.assertEqual((got[SILHOUETTE], got[LINES_DISTINCT]), ("2 contours", "0 repeated")) + fixture = Fixture() + built = hs.build(fixture.runner(), fixture.read_library, "Ship", twice) + self.assertEqual(built.failed(), []) + + if __name__ == "__main__": unittest.main() From 970155a8aca6da8b5ad53ba0550a24c56d5078ce Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:12:17 +0200 Subject: [PATCH 20/29] Nakovalnya package rebuilt through the add-on: schematic lines from the kit (silhouette plus 102 kit lines) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/ship.cc | 4 ++-- GameData/Mods/aetheria.nakovalnya/ship.glb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/ship.cc b/GameData/Mods/aetheria.nakovalnya/ship.cc index 2de9f3458..ebe05cd33 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.cc +++ b/GameData/Mods/aetheria.nakovalnya/ship.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2390998c0f36f647fbd7b12098a726445a747fc2dfd57c0e68b9fcc7e9116338 -size 12701 +oid sha256:87d8cbcca252460d1ec2bfeb895c1447ab185172a4a4449b237a297b83ea5057 +size 40059 diff --git a/GameData/Mods/aetheria.nakovalnya/ship.glb b/GameData/Mods/aetheria.nakovalnya/ship.glb index 27ed4683b..3dac76cc9 100644 --- a/GameData/Mods/aetheria.nakovalnya/ship.glb +++ b/GameData/Mods/aetheria.nakovalnya/ship.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0f2e8bdb820330a1ba2b49c9615484b971f96634e7108ed675e788c38c3c8ba -size 843456 +oid sha256:90a503473e3b12251c225b7fe283b2fc5b45a25e6e47839d1c3d78e3ac61b00c +size 843452 From 4a6ddaa413b5a3ae803baa5f814d53f389033bdf Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:17:00 +0200 Subject: [PATCH 21/29] hull_source tests: a non-UTF-8 byte in the drawing, an unreadable number Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 928bee976..48a401404 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -1023,5 +1023,25 @@ class LineCheckDetailTests(unittest.TestCase): self.assertEqual(built.failed(), []) +class BytesOpenScad(FakeOpenScad): + """Writes raw bytes where -o points: an SVG with a byte that is not UTF-8 in its title.""" + + def __call__(self, command, **kwargs): + self.calls.append((command, kwargs.get("env"))) + Path(command[command.index("-o") + 1]).write_bytes(bytes([255]) + SVG.encode("utf-8")) + return self + + +class SilhouetteReadTests(unittest.TestCase): + def test_a_drawing_with_a_byte_that_is_not_utf8_still_reads(self): + with mock.patch.object(hs.subprocess, "Popen", BytesOpenScad()): + self.assertEqual(len(hs.openscad_projector("scad")([hs.parse_off(box(0, 1, 0, 1, 0, 1))])), 2) + + def test_a_number_that_is_not_a_number_is_refused_in_fixed_words(self): + with self.assertRaises(hs.HullSourceError) as caught: + hs.parse_svg_contours('') + self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable") + + if __name__ == "__main__": unittest.main() From c1d55952eb0730228fe0578fc4b732ba3dbb7398 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:11 +0200 Subject: [PATCH 22/29] Smoke: Build records the kit lines and retiring the hull source deletes them Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index c60967253..7084274bb 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -493,7 +493,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"CANCELLED"} and "The package has no product" in report, f"Package took a hull with no zones and no art: {report!r} {message!r}") require((package / "ship.glb").read_bytes() == glb_before, "A refused Package changed ship.glb") - require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and source.is_file() == (number % 2 == 1), + require("aetheria.hull_maker" in collection and "aetheria.hull_built" in collection and "aetheria.hull_lines" in collection and source.is_file() == (number % 2 == 1), "A refused Package changed Build's records or hull.scad") print(f"PASS {number}.9a Build's zones deleted with nothing in their place stay under the guard") art_mesh = bpy.data.meshes.new("author hull") @@ -510,7 +510,7 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett report = scene.aetheria_package_report require(result == {"FINISHED"} and "retired the agent hull source" in report, f"Package did not retire the hull source: {report!r} {message!r}") require(not source.exists(), "hull.scad is still there after the author's art was packaged") - require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection, + require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") select(box) From 2b053b34c2ea7500f6cac534002824c2b282bd4b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:52:17 +0200 Subject: [PATCH 23/29] Smoke: a retired hull's lines are not hers, and Capture writes the kit's lines in Package's frame (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/smoke_hull_source.py | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/blender/tests/smoke_hull_source.py b/tools/blender/tests/smoke_hull_source.py index 7084274bb..a3676b0c7 100644 --- a/tools/blender/tests/smoke_hull_source.py +++ b/tools/blender/tests/smoke_hull_source.py @@ -403,6 +403,21 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require(result == {"FINISHED"} and len(ship_lines(ship_cc)) == len(packaged), f"Without Grease Pencil the kit's lines are back: {message!r}") print(f"PASS {number}.5b the kit's lines ship, her Grease Pencil wins, two are refused") + # Capture's kit branch writes what Package wrote, and also when Save Layout has moved the centre of mass since the last + # Package (here: Ship Root is off the origin), because Capture frames the ship as Package does. + shipped = ship_lines(ship_cc) + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), f"Capture did not write the kit's lines as Package did: {message!r}") + root.location.x += 1.7 + root.location.y -= 0.9 + bpy.context.view_layer.update() + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"FINISHED"} and same_lines(ship_lines(ship_cc), shipped), + f"Capture, with Ship Root off its Package frame, wrote lines out of the packaged model's frame: {message!r}") + print(f"PASS {number}.5c Capture writes the kit's lines in Package's frame") + # What Package did to the built objects is not an edit: Build still replaces them. select(box) result, message = run(bpy.ops.aetheria.build_hull_source) @@ -513,6 +528,15 @@ def one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palett require("aetheria.hull_built" not in collection and "aetheria.hull_maker" not in collection and "aetheria.hull_lines" not in collection, "The record of Build's objects or its maker outlived hull.scad") require(all(echoed not in report for echoed in (ship_id, str(GAME))), "The report echoes an input") + # The retired hull's kit lines are not hers: the slot is empty (Studio draws no underlay, the preview no line mesh), + # Capture has nothing to capture, and a second Package leaves it empty. + require(ship_lines(ship_cc) == [], f"The retired hull's {len(ship_lines(ship_cc))} lines ship over her art") + select(box) + result, message = run(bpy.ops.aetheria.capture_ship_lines) + require(result == {"CANCELLED"} and ship_lines(ship_cc) == [], f"Capture wrote lines for a hull with no source: {message!r}") + select(box) + result, message = run(bpy.ops.aetheria.package_ship) + require(result == {"FINISHED"} and ship_lines(ship_cc) == [], f"A second Package brought lines back: {message!r} {ship_lines(ship_cc)[:1]}") select(box) result, message = run(bpy.ops.aetheria.build_hull_source) require(result == {"CANCELLED"} and bpy.data.objects.get("author hull") is art, f"Build ran after takeover: {message!r}") @@ -608,6 +632,13 @@ def ship_lines(ship_path): return sys.modules["aetheria_ships"].read(ship_path, PACKAGES).ship.body[sys.modules["aetheria_ships"].ship_cc.SCHEMATIC_LINES_SLOT] +def same_lines(a, b, tolerance=1e-4): + """Whether two SchematicLines slots hold the same rows: layer, cyclic, colour and every point within tolerance.""" + return len(a) == len(b) and all( + x[0] == y[0] and x[5] == y[5] and len(x[2]) == len(y[2]) and approx(x[6], y[6], tolerance) + and all(abs(p - q) <= tolerance for p, q in zip(x[2], y[2])) for x, y in zip(a, b)) + + def grease_pencil(collection, name, points): """A Grease Pencil object in the collection holding one stroke, layer named 'hers'.""" data = bpy.data.grease_pencils.new(name) From 87dc0f013f4efb083be86e6341399e1df1161be9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:13 +0200 Subject: [PATCH 24/29] Capture Ship Lines frames the ship as Package does, so kit and Grease Pencil lines land in ship.glb's frame Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 08daa095c..d12c19fc8 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -545,6 +545,10 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator): cultlib = _brokkr_cultlib(context) if read(path, cultlib).ship.body[0] != collection["aetheria.id"]: raise ValueError("The bound collection ID does not match its .cc ship ID") + saved_shape, _, _ = read_layout(path, cultlib) + root = _ship_root(collection) + if root is not None: # the frame Package would write the model in, so the lines match ship.glb + _frame_ship(context, collection, root, path, saved_shape) lines = _schematic_lines(context, collection) if lines is None: raise ValueError("No schematic lines were captured; the ship record was not changed") From dab415ad872a3b956adebdf24e2ebf859bba72dd Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:55:50 +0200 Subject: [PATCH 25/29] Package: her art's takeover leaves no retired hull lines in the .cc (state decides the source; the slot is cleared) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/__init__.py | 13 ++++++++----- tools/blender/aetheria_ships/ship_cc.py | 10 ++++++++++ tools/blender/tests/test_ship_cc.py | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index d12c19fc8..821529244 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -23,7 +23,7 @@ import numpy from mathutils import Matrix, Vector from . import hull_grid, hull_source -from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint, +from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, clear_lines, decode_hardpoint, encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines, product_makers, replace_visual, valid_ship_id) @@ -1639,9 +1639,10 @@ def _build_hull_source(context, collection): def _schematic_lines(context, collection): """The ShipAuthoring.SchematicLines rows Package and Capture write, and the only source of them: her Grease Pencil - object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit, - mapped through Ship Root's matrix_world (so after _frame_ship, the frame her strokes are captured in), else None and - the slot is left as it is.""" + object if the collection has one (two or more are refused), else the lines Build recorded from the hull source's kit + while the hull is still Build's (not _hull_state art), mapped through Ship Root's matrix_world (so after _frame_ship, + the frame her strokes are captured in), else None and the slot is left as it is. Package alone clears the slot, at + the moment her art takes over.""" pencils = [obj for obj in collection.all_objects if obj.type == "GREASEPENCIL"] if len(pencils) > 1: raise ValueError("Keep one Grease Pencil object: it is the ship's schematic") @@ -1650,7 +1651,7 @@ def _schematic_lines(context, collection): return capture_grease_pencil(pencils[0], context.evaluated_depsgraph_get(), scene.frame_current, evaluated=scene.aetheria_capture_evaluated_lines) record = collection.get(HULL_LINES) - if record is None: + if record is None or _hull_state(collection) == HULL_ART: # her art took over: Build's lines are a retired hull's return None root = _ship_root(collection) world = root.matrix_world if root is not None else Matrix.Identity(4) @@ -1773,6 +1774,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): lines = _schematic_lines(context, collection) if lines is not None: replace_lines(path, cultlib, lines) + elif _hull_state(collection) == HULL_ART: # the lines in the slot are the retired hull's, not hers + clear_lines(path, cultlib) code, message = _aetherdb(context, "validate", path, ship_cc=path) except _EXPECTED as exc: scene.aetheria_package_report = f"Package failed: {_failure_text(exc)}" diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index f3742dd4f..dd3ca78be 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -179,6 +179,16 @@ def replace_lines(path: str, cultlib_packages: str, lines: list[list[Any]]) -> i return len(lines) +def clear_lines(path: str, cultlib_packages: str) -> int: + """Empties the line slot and nothing else; returns how many lines it held. replace_lines refuses an empty capture so a + failed capture never wipes a ship; this is the one deliberate way to leave a ship without lines.""" + file = read(path, cultlib_packages) + held = len(file.ship.body[SCHEMATIC_LINES_SLOT] or []) + file.ship.body[SCHEMATIC_LINES_SLOT] = [] + file.store.push(_stamped(file.ship, file.msgpack)) + return held + + def replace_visual(path: str, cultlib_packages: str, expected_id: str, model_asset: str, anchors: list[list[Any]]) -> int: """Writes the visual's model asset and anchor list (ShipAuthoring slots 2 and 3) and nothing else. Each anchor row is diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index ac01b2faa..0b4da5677 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -344,6 +344,22 @@ class ReplaceLinesTests(ShipFileCase): if slot != ship_cc.SCHEMATIC_LINES_SLOT: self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + def test_clear_lines_empties_only_the_line_slot(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + before = self.ship() + self.assertEqual(len(LINES), ship_cc.clear_lines(self.path, PACKAGES)) + after = self.ship() + self.assertEqual([], after[ship_cc.SCHEMATIC_LINES_SLOT]) + for slot in range(len(before)): + if slot != ship_cc.SCHEMATIC_LINES_SLOT: + self.assertEqual(before[slot], after[slot], f"slot {slot} changed") + + def test_replace_lines_still_refuses_an_empty_capture(self): + ship_cc.replace_lines(self.path, PACKAGES, LINES) + with self.assertRaises(ValueError): + ship_cc.replace_lines(self.path, PACKAGES, []) + self.assertEqual(LINES, self.ship()[ship_cc.SCHEMATIC_LINES_SLOT]) + def test_a_save_refreshes_the_stored_at_of_the_record_it_writes_and_only_that_one(self): stale = "2000-01-01T00:00:00+00:00" envelopes = self.envelopes() From 71b12d594b7d7498c7c23090b85ae9eb7dd38c77 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:09:04 +0200 Subject: [PATCH 26/29] Kit lines tests: a line drawn outside its geometry's frame fails Build; side-facing fittings emit no line (red) Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/tests/test_hull_source.py | 62 +++++++++++++++- tools/blender/tests/test_kit_openscad.py | 92 ++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tools/blender/tests/test_kit_openscad.py diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 48a401404..46e153a3f 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -51,6 +51,19 @@ def brief(faction="zhestokost", donor="", length=(8, 14), triangles=(10, 100)): return echo("brief", [faction, donor, "tender", list(length), list(triangles), ["a read"]]) +def marks_off(points, size=0.002): + """An OFF text of one tiny cube per point, as the kit's marks part is.""" + rows, faces = [], [] + for i, (x, y, z) in enumerate(points): + h = size / 2 + rows += [(x + dx * h, y + dy * h, z + dz * h) for dz in (-1, 1) for dy in (-1, 1) for dx in (-1, 1)] + b = 8 * i + faces += [(b, b + 1, b + 3, b + 2), (b + 4, b + 6, b + 7, b + 5), (b, b + 2, b + 6, b + 4), + (b + 1, b + 5, b + 7, b + 3), (b, b + 4, b + 5, b + 1), (b + 2, b + 3, b + 7, b + 6)] + return "\n".join(["OFF", f"{len(rows)} {len(faces)} 0"] + [" ".join(map(repr, r)) for r in rows] + + ["4 " + " ".join(map(str, f)) for f in faces]) + "\n" + + class Fixture: """A hull source as the fake runner sees it: part -> (OFF, ECHO). library holds the ECHO text of each faction's own language file; the hull's own `lang` echo is the files it includes, unless hull_lang says otherwise.""" @@ -70,6 +83,7 @@ class Fixture: "emit:Th.A": (box(0.5, 1.5, 5, 5.1, 0.5, 1.5), ""), "lines": ("", ""), } + self.marks = None # the "marks" part when a test sets one; else a mark at each kit line's first point @staticmethod def projector(meshes): @@ -81,7 +95,13 @@ class Fixture: return [[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]] def runner(self): - return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.parts[part] + return lambda part: ("", self.hull_lang if self.hull_lang is not None else "".join(self.library.values())) if part == "lang" else self.mark_part() if part == "marks" else self.parts[part] + + def mark_part(self): + """What the kit's lp_line draws in the geometry's own frame: a tiny cube at each line's first point.""" + if self.marks is not None: + return self.marks + return (marks_off([l.points[0] for l in hs.read_lines(self.parts["lines"][1])]), "") def read_library(self, faction): if faction not in self.library: @@ -680,6 +700,7 @@ class RefusalTests(unittest.TestCase): LINES_OVER = "every kit line lies over the hull's top view" LINES_DISTINCT = "no two kit lines are the same" +LINES_FRAME = "every kit line is drawn in the frame of its geometry" SILHOUETTE = "the top view yields a silhouette" @@ -687,6 +708,45 @@ def line(layer="seam", points=((-1, -1, 2), (1, 1, 2)), cyclic=False): return echo("line", [layer, cyclic, [list(p) for p in points]]) +class FrameCheckTests(unittest.TestCase): + """The kit draws a mark where each line starts, in the frame the geometry is drawn in; the line is echoed in the frame + lp_at tracks. A part placed by anything but lp_at has its mark and its line in different places.""" + + def built(self, kit, marks=None): + fixture = Fixture() + fixture.parts["lines"] = ("", kit) + if marks is not None: + fixture.marks = (marks_off(marks), "") + return fixture.build() + + def test_marks_where_the_lines_start_pass_and_the_check_is_listed(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2)))) + self.assertEqual(built.failed(), []) + self.assertIn(LINES_FRAME, names(built)) + + def test_a_mark_three_metres_from_its_line_fails_the_frame_check_and_nothing_else(self): + built = self.built(line(points=((-1, -1, 2), (1, 1, 2))), marks=[(-1, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_line_with_no_mark_fails(self): + built = self.built(line() + line("step", ((0, 0, 2), (1, 1, 2))), marks=[(-1, -1, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_with_no_line_fails(self): + built = self.built(line(), marks=[(-1, -1, 2), (2, 2, 2)]) + self.assertEqual([c.name for c in built.failed()], [LINES_FRAME]) + + def test_a_mark_a_millimetre_off_is_the_same_place(self): + self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) + + def test_a_source_with_no_lines_and_no_marks_passes(self): + self.assertEqual(self.built("", marks=[]).failed(), []) + + def test_the_detail_counts_the_lines_and_marks_that_disagree(self): + built = self.built(line(), marks=[(5, 5, 2)]) + self.assertEqual({c.name: c.detail for c in built.checks}[LINES_FRAME], "1 lines and 1 marks disagree") + + class LineReadingTests(unittest.TestCase): def test_lines_are_read_in_echo_order_with_their_layer_flag_and_points(self): text = line("step", ((0, 0, 1), (1, 0, 1), (1, 1, 1)), True) + line("fitting") diff --git a/tools/blender/tests/test_kit_openscad.py b/tools/blender/tests/test_kit_openscad.py new file mode 100644 index 000000000..6867b429f --- /dev/null +++ b/tools/blender/tests/test_kit_openscad.py @@ -0,0 +1,92 @@ +"""The Zhestokost kit through the real OpenSCAD: where its schematic lines are drawn relative to its geometry, and which +frames draw none. Skipped unless SMOKE_OPENSCAD names the OpenSCAD executable (nightly with the Manifold backend). + + SMOKE_OPENSCAD=\\openscad.com python -m unittest discover -s tools/blender/tests -p "test_kit_openscad.py" + +hull_source is loaded by path because the package __init__ imports bpy. +""" + +import importlib.util +import os +import sys +import tempfile +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "hull_source", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_source.py") +hs = importlib.util.module_from_spec(_spec) +sys.modules["hull_source"] = hs +_spec.loader.exec_module(hs) + +OPENSCAD = os.environ.get("SMOKE_OPENSCAD") +REPO = Path(__file__).resolve().parents[3] +NAKOVALNYA = REPO / "GameData" / "Mods" / "aetheria.nakovalnya" / "hull.scad" +FRAME = "every kit line is drawn in the frame of its geometry" + +# Placement drifts (finding line-drift-inside-footprint): a raw translate or rotate around a part that draws lines, not an +# lp_at. Each moves the geometry and leaves the line where lp_at's frame says. +DRIFTS = { + "spine mass 3 m aft": ("zh_mass(T2);", "translate([0, 3, 0]) zh_mass(T2);"), + "radiator troughs 4 m forward": ("for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);", + "translate([0, -4, 0]) for (s = [-1, 1]) zh_on_top(M, RAD_Y, s*RAD_U) zh_rad_trough(RAD_W, RAD_L, 0.45);"), + "apron reveals 3 m aft": ("for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);", + "translate([0, 3, 0]) for (u = [-0.33, 0.33]) zh_on_top(APRON, -19.5, u) zh_reveal(2.3, 7.0);"), + "flak ring rotated 18 degrees": ("for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);", + "rotate([0, 0, 18]) for (f = FLAK) lp_at(t=[f[0], f[1], DECK - ZH_SINK]) flak(f[2]);"), +} + +# A fitting on every frame the kit mates to. Only the dorsal one faces up, so only it is seen from above. +HEAD = """include +part = "primary"; +M = [zh_s(-16, 32, 5.0, -2.25), zh_s(-15, 34, 5.5, -2.5), zh_s(5, 33, 5.5, -2.5), zh_s(14, 27, 5.5, -2.5)]; +""" +SIDEWAYS = { + "wall": "zh_on_wall(M, 0, 1, 0.45) zh_hatch(2.6, 3.0);", + "belt": "zh_on_belt(M, 0, 1, 0.5) zh_hatch(2.6, 3.0);", + "belly": "zh_on_belly(M, 0, 0) zh_hatch(2.6, 3.0);", + "stern": "zh_on_stern(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", + "nose": "zh_on_nose(M, 0, 0.5) zh_plate(4.0, 1.4, 0.3);", +} +DORSAL = "zh_on_top(M, 0, 0) zh_hatch(2.6, 3.0);" + + +@unittest.skipUnless(OPENSCAD, "SMOKE_OPENSCAD names no OpenSCAD") +class KitThroughOpenScad(unittest.TestCase): + def build(self, source_text): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(source_text, encoding="utf-8") + return hs.build(hs.openscad_runner(OPENSCAD, source), hs.language_library(OPENSCAD), "Ship", + hs.openscad_projector(OPENSCAD)) + + def lines(self, body): + with tempfile.TemporaryDirectory() as folder: + source = Path(folder) / "hull.scad" + source.write_text(HEAD + body, encoding="utf-8") + return hs.read_lines(hs.openscad_runner(OPENSCAD, source)("lines")[1]) + + def test_the_nakovalnya_draws_every_line_in_the_frame_of_its_geometry(self): + built = self.build(NAKOVALNYA.read_text(encoding="utf-8")) + self.assertEqual([c.name for c in built.failed()], []) + self.assertIn(FRAME, [c.name for c in built.checks]) + + def test_a_placement_that_drifts_from_its_lines_fails_the_frame_check(self): + text = NAKOVALNYA.read_text(encoding="utf-8") + for label, (old, new) in DRIFTS.items(): + with self.subTest(label): + self.assertEqual(text.count(old), 1) + self.assertIn(FRAME, [c.name for c in self.build(text.replace(old, new)).failed()]) + + def test_a_fitting_on_a_frame_a_top_view_cannot_see_emits_no_line(self): + for label, body in SIDEWAYS.items(): + with self.subTest(label): + self.assertEqual(self.lines(body), []) + + def test_the_gate_is_the_frame_and_not_the_fitting(self): + self.assertEqual(len(self.lines(DORSAL)), 1) + self.assertEqual(len(self.lines(DORSAL + " ".join(SIDEWAYS.values()))), 1) + + +if __name__ == "__main__": + unittest.main() From 98b4f4f9469ffd9ba5b8fda230af4700e01b066e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:35 +0200 Subject: [PATCH 27/29] Build checks that every kit line is drawn in the frame of its geometry: lp_line's marks part meets its lines part Co-Authored-By: Claude Sonnet 5.5 --- .../aetheria_ships/factions/lowpoly.scad | 10 ++++-- tools/blender/aetheria_ships/hull_source.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/blender/aetheria_ships/factions/lowpoly.scad b/tools/blender/aetheria_ships/factions/lowpoly.scad index ef3063c6c..23e82918b 100644 --- a/tools/blender/aetheria_ships/factions/lowpoly.scad +++ b/tools/blender/aetheria_ships/factions/lowpoly.scad @@ -33,7 +33,13 @@ module lp_at(t=[0,0,0], r=[0,0,0], mx=false) { multmatrix(M) children(); } // One schematic line: layer (outline, step, seam or fitting), points in the local frame, cyclic when it closes. +// Part "marks" draws a LP_MARK cube at the line's first point through the CTM, the frame the geometry is drawn in, where +// "lines" echoes the same point through $lp_m; hull_source compares the two, so a part placed by anything but lp_at +// (a raw translate, rotate or mirror) shows as a line away from its geometry. +LP_MARK = 0.002; module lp_line(layer, pts, cyclic=false) { - if (!is_undef(part) && part == "lines" && ($lp_m * [0, 0, 1, 0])[2] > 0.5) - echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (!is_undef(part) && ($lp_m * [0, 0, 1, 0])[2] > 0.5) { + if (part == "lines") echo(line = [layer, cyclic, [for (p = pts) lp_ap($lp_m, p)]]); + if (part == "marks") translate(pts[0]) cube(LP_MARK, center=true); + } } diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index 2689a7230..f004486e9 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -63,6 +63,7 @@ LINE_STYLE = {"silhouette": (1.0, 1.0, 1.0, 1.0), "outline": (1.0, 1.0, 1.0, 0.7 MIN_SILHOUETTE_AREA = 0.25 # m2, below which a projected contour is a sliver, not the hull LINE_EDGE_TOLERANCE = 0.3 # m, how far outside the silhouette's edge a kit line's point may lie DUPLICATE_CELL = 0.001 # m, kit lines whose points all agree to this are the same line +MARK_TOLERANCE = 0.01 # m, how far a mark of the kit may lie from the start of its line # A projector answers a list of Meshes with the contours (lists of (x, y)) of their top-view union. Projector = Callable[[list], "list[list[tuple]]"] @@ -347,6 +348,35 @@ def _triangles_xy(mesh): return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles] +def mark_centres(mesh): + """The centre of each connected piece of mesh: the kit's "marks" part draws one tiny cube per line start.""" + parent = list(range(len(mesh.vertices))) + + def root(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for triangle in mesh.triangles: + for corner in triangle[1:]: + parent[root(corner)] = root(triangle[0]) + pieces = {} + for i in {corner for triangle in mesh.triangles for corner in triangle}: + pieces.setdefault(root(i), []).append(mesh.vertices[i]) + return [tuple((min(v[a] for v in vs) + max(v[a] for v in vs)) / 2 for a in range(3)) for vs in pieces.values()] + + +def _frame_disagreements(kit, marks): + """(lines with no mark at their first point, marks that start no line). The kit echoes a line in the frame lp_at tracks + and draws its mark in the frame the geometry is drawn in; they agree unless a part was placed by anything but lp_at.""" + starts = [line.points[0] for line in kit] + centres = mark_centres(marks) + near = lambda a, b: all(abs(x - y) <= MARK_TOLERANCE for x, y in zip(a, b)) + return (sum(not any(near(start, c) for c in centres) for start in starts), + sum(not any(near(c, start) for start in starts) for c in centres)) + + def _language(library, faction): """The language of factions/.scad, which owns it: exactly one echo, naming that faction.""" (language,) = _exactly_one(read_languages(library(faction)), "language") @@ -421,6 +451,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) + unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), + f"{unmarked} lines and {stray} marks disagree")) return result From 8d58bca27a77a1a7086923e6b58cc73621027491 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:11:38 +0200 Subject: [PATCH 28/29] Nakovalnya: casemate guns and plate seams emit their lines from the frame that draws them; the copied offsets are gone (geometry and lines unchanged) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Mods/aetheria.nakovalnya/hull.scad | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/GameData/Mods/aetheria.nakovalnya/hull.scad b/GameData/Mods/aetheria.nakovalnya/hull.scad index 062285157..66353ca69 100644 --- a/GameData/Mods/aetheria.nakovalnya/hull.scad +++ b/GameData/Mods/aetheria.nakovalnya/hull.scad @@ -52,16 +52,21 @@ zh_mass(KEEL, lines=false); // under the hull: no top view for (s = [-1, 1]) lp_at(t=[s*DX, 0, 0]) zh_mass(DRV, "secondary"); // ---- the casemate guns: lighter cast-steel barrels out of charcoal mantlets ----------------------------------------- +MANTLET = [4.0, 1.2, 3.4]; module casemate_gun() { - zh_z("secondary") { - translate([0, GY - 0.3, GZ]) cbox([4.0, 1.2, 3.4], 0.2); // mantlet - translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR*1.55, 1.8, 8); // barrel collar - translate([0, GY - 0.7 - BL, GZ]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + // mantlet frame: its fitting outline is drawn in the frame the mantlet is + lp_at(t=[0, GY - 0.3, GZ]) { + zh_z("secondary") cbox(MANTLET, 0.2); + lp_line("fitting", zh_rect(MANTLET.x, MANTLET.y, MANTLET.z/2), true); + } + // barrel frame: local +Y runs forward from the collar; the barrel's top line is drawn in the frame the barrel is + lp_at(t=[0, GY - 0.7, GZ], r=[0, 0, 180]) { + zh_z("secondary") tube(BR*1.55, 1.8, 8); // barrel collar + zh_z("primary") tube(BR, BL, 8); + zh_z("secondary") translate([0, BL, 0]) cbox([BR*2.7, 1.6, BR*2.7], 0.12); // muzzle block + zh_z("recess") translate([0, BL + 0.81, 0]) cube([BR*1.2, 0.05, BR*1.2], center=true); + lp_line("fitting", [[0, 0, BR], [0, BL, BR]]); } - zh_z("primary") translate([0, GY - 0.7, GZ]) rotate([0, 0, 180]) tube(BR, BL, 8); - zh_z("recess") translate([0, GY - 0.7 - BL - 0.81, GZ]) cube([BR*1.2, 0.05, BR*1.2], center=true); - lp_line("fitting", [for (p = zh_rect(4.0, 1.2, GZ + 1.7)) p + [0, GY - 0.3, 0]], true); - lp_line("fitting", [[0, GY - 0.7, GZ + BR], [0, GY - 0.7 - BL, GZ + BR]]); } for (x = CX) lp_at(t=[x, 0, 0]) casemate_gun(); for (x = CX) lp_at(t=[x, 0, 0]) zh_on_top(CAS, -21.9, 0) zh_stripes(3.6, 0.6, 4); @@ -90,9 +95,10 @@ zh_on_top(T2, -3.6, 0) zh_serial(0.7, 4); // ---- dorsal magazine hatches: two rows of four on the mid-deck, clear of the rim ----------------------------------- for (y = [-8.0, -3.4, 1.2, 5.6], u = [-0.78, 0.78]) zh_on_top(T1, y, u) zh_hatch(2.8, 3.0, dogs=false); -for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) { // plate seams - zh_z("secondary") translate([0, 0, 0.06]) cube([8.4, 0.5, 0.2], center=true); - lp_line("seam", [[-4.2, 0, 0.16], [4.2, 0, 0.16]]); +PLATE_SEAM = [8.4, 0.5, 0.2]; +for (y = [0.5, 4.5]) zh_on_top(T2, y, 0) lp_at(t=[0, 0, 0.06]) { // plate seams + zh_z("secondary") cube(PLATE_SEAM, center=true); + lp_line("seam", [[-PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2], [PLATE_SEAM.x/2, 0, PLATE_SEAM.z/2]]); } // Feed collar for an Oboz: a lighter cast-steel flange ring standing proud of the flank round the kit collar. From 66c38d9506caf6b45b3d98bc6751f3d3d8ee0f16 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 17:17:40 +0200 Subject: [PATCH 29/29] Build asks for the marks part only when the kit drew lines (OpenSCAD refuses a part that draws nothing); hull_source documents marks Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/hull_source.py | 8 ++++++-- tools/blender/tests/test_hull_source.py | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tools/blender/aetheria_ships/hull_source.py b/tools/blender/aetheria_ships/hull_source.py index f004486e9..2d6d0ca16 100644 --- a/tools/blender/aetheria_ships/hull_source.py +++ b/tools/blender/aetheria_ships/hull_source.py @@ -15,12 +15,14 @@ with it (a hull that redefines or shadows an included language fails a check): frame (lowpoly.scad lp_line under lp_at frames); layer is one of KIT_LAYERS the zone's geometry, OFF; a donor's zone is named ":" emit:, rad: the mesh of a thruster-emitter or radiator-mesh anchor + marks OFF, a tiny cube at the first point of each kit line, drawn through the frame the geometry is drawn in (lp_line) Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship", "Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag. Schematic lines (HullBuild.lines): the top-view silhouette first (OpenSCAD projection() of every zone mesh, the geometry Rasterise reads, read from the SVG it exports), then the kit's lines, each with a fixed RGBA from LINE_STYLE. A kit part is placed with -lp_at so its line is emitted in the frame the geometry is; a source that echoes no lines has the silhouette alone. +lp_at so its line is emitted in the frame the geometry is, and Build checks it: a line whose mark (the marks part) lies elsewhere +was placed by something else. A source that echoes no lines has the silhouette alone. A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back. @@ -451,7 +453,9 @@ def build(runner, library, hull_type, projector): result.checks = [Check("the hull includes its faction's language files and changes none of them", sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")] result.checks += _checks(result, own, judge) - unmarked, stray = _frame_disagreements(kit_lines, parse_off(runner("marks")[0])) + # A source with no kit lines has no marks, and OpenSCAD refuses to export a part that drew nothing. + marks = parse_off(runner("marks")[0]) if kit_lines else Mesh([], []) + unmarked, stray = _frame_disagreements(kit_lines, marks) result.checks.append(Check("every kit line is drawn in the frame of its geometry", not (unmarked or stray), f"{unmarked} lines and {stray} marks disagree")) return result diff --git a/tools/blender/tests/test_hull_source.py b/tools/blender/tests/test_hull_source.py index 46e153a3f..e77ec11b2 100644 --- a/tools/blender/tests/test_hull_source.py +++ b/tools/blender/tests/test_hull_source.py @@ -739,8 +739,13 @@ class FrameCheckTests(unittest.TestCase): def test_a_mark_a_millimetre_off_is_the_same_place(self): self.assertEqual(self.built(line(), marks=[(-1, -1, 2.001)]).failed(), []) - def test_a_source_with_no_lines_and_no_marks_passes(self): - self.assertEqual(self.built("", marks=[]).failed(), []) + def test_a_source_with_no_lines_asks_for_no_marks_because_openscad_refuses_a_part_that_draws_nothing(self): + fixture = Fixture() + runner = fixture.runner() + asked = [] + built = hs.build(lambda part: asked.append(part) or runner(part), fixture.read_library, "Ship", fixture.projector) + self.assertNotIn("marks", asked) + self.assertEqual(built.failed(), []) def test_the_detail_counts_the_lines_and_marks_that_disagree(self): built = self.built(line(), marks=[(5, 5, 2)])