Merge eureka/aetheria-release-kit-schematic: agent hulls get schematic lines from their kit; the Nakovalnya package lands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 17:35:42 +02:00
commit 5b599f66bf
20 changed files with 1444 additions and 71 deletions

View file

@ -0,0 +1,169 @@
// Zhestokost fortress (boss) "Nakovalnya" (anvil). Built from the Zhestokost kit.
// part = lang | brief | anchors | lines | primary | secondary | accent | recess | marking | emit:<id> | rad:<id>
// Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. Anchor yaw: 0 nose, 90 port, 180 aft, 270 starboard.
include <zhestokost.scad>
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) lp_at(t=[x, 0, 0]) zh_mass(CAS);
zh_mass(SPINE);
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 -----------------------------------------
MANTLET = [4.0, 1.2, 3.4];
module casemate_gun() {
// 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]]);
}
}
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);
// ---- 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);
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) 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 ---------------------------------------------------------
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);
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.
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) 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);
}
// ---- 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, ""]);
}

BIN
GameData/Mods/aetheria.nakovalnya/ship.cc (Stored with Git LFS) Normal file

Binary file not shown.

BIN
GameData/Mods/aetheria.nakovalnya/ship.glb (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -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<VapourDumpData>(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<HullData>().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<HullData>("Djinni"), cellSize), radiusMin, 3);
Assert.Equal(33.03f, radiusMin, 1); // the Djinni, 14x17 cells at two units a cell

View file

@ -261,9 +261,7 @@ public sealed partial class RunStartTests
[Fact]
public void GeneratedLoadoutsGiveEveryReaderItsHardpoint()
{
var sold = _cache.GetAll<FactionProductData>().Select(p => p.Design.Key).ToHashSet();
var shipHulls = _cache.GetAll<HullData>()
.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()))

View file

@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
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<HullData>(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<Faction>().Single(faction => faction.Name == "Zhestokost");
var product = Assert.Single(cache.GetAll<FactionProductData>(), 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<Faction>().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<ShipAuthoring>(ShipModCatalog.AuthoringKey(Id)));
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 })
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);
// 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<EquippableItemData>().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);
}
// 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<ShipAuthoring>(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)));
}
}

View file

@ -187,9 +187,21 @@ public sealed class RestoredHullsTests
using var cache = OpenCatalog();
var missing = new List<string>();
// 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<HullData>())
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<EquippableItemData>())
{
foreach (var thruster in design.Behaviors.OfType<ThrusterData>())
@ -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<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var ships = cache.GetAll<HullData>().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)
{

View file

@ -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<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
// Every offered product (LoadoutGenerator.IsOffered); a not-offered one is available to nobody.
var products = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet() && LoadoutGenerator.IsOffered(_cache, p)).ToArray();
Assert.NotEmpty(products);
Assert.All(products, product => Assert.True(generator.IsAvailable(product), product.Name));
}

View file

@ -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<FactionProductData>().Where(p => p.Manufacturer.IsSet()).ToArray();
var products = _cache.GetAll<FactionProductData>().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));

View file

@ -325,6 +325,68 @@ public sealed class ShipProductTests : IDisposable
RefusedAndUntouched("product", path, "ACM", "--remove");
}
// The hull as data, to compare everything stats did not name.
private static byte[] HullBytes(HullData hull) => MessagePack.MessagePackSerializer.Serialize(hull, CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly));
[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");
var twin = Path.Combine(ShipFixture.WritePackage(Mods, "mod.twin", "Twinhull",
tweak: ship => ship.Hull.Behaviors.Add(ship.Hull.Behaviors.OfType<VelocityLimitData>().First())), "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");
// 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;
foreach (var (flag, value, set) in new (string, float, Action<HullData>)[]
{
("--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<VelocityLimitData>().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(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));
var top = written.Behaviors.OfType<VelocityLimitData>().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()
{

View file

@ -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 <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
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<string, float>(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<BehaviorData>()).OfType<VelocityLimitData>().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.

View file

@ -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)
@ -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,13 @@ 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,
)
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")
count = replace_lines(path, cultlib, lines)
self.report({"INFO"}, f"Captured {count} lines")
return {"FINISHED"}
@ -1492,6 +1494,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 +1580,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 +1632,39 @@ 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
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")
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 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)
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 +1692,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 +1741,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 +1771,11 @@ 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)
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)}"

View file

@ -12,3 +12,34 @@ 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.
// 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) && ($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);
}
}

View file

@ -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:<zone>'.
// 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]];
@ -83,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.
@ -127,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);
}
@ -139,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).
@ -154,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) {
@ -172,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);

View file

@ -11,11 +11,18 @@ 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/<faction>.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
<zone> the zone's geometry, OFF; a donor's zone is named "<donor>:<zone>"
emit:<id>, rad:<id> 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, 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.
@ -49,9 +56,19 @@ 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
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]]"]
# 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 +126,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 +156,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 +278,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'<path\s+d="([^"]*)"', text):
for piece in path.split("z"):
try:
ring = [(float(x), -float(y)) for x, y in re.findall(r"(-?[\d.]+(?:e[-+]?\d+)?),(-?[\d.]+(?:e[-+]?\d+)?)", piece)]
except ValueError:
raise HullSourceError("OpenSCAD wrote a silhouette that is not readable") from None
area = abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(ring, ring[1:] + ring[:1]))) / 2
if len(ring) >= 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}")
@ -281,6 +350,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/<faction>.scad, which owns it: exactly one echo, naming that faction."""
(language,) = _exactly_one(read_languages(library(faction)), "language")
@ -289,9 +387,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 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,14 +439,25 @@ 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 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)]
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), "")]
result.checks += _checks(result, own, judge)
# 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
@ -366,6 +502,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 +524,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 <zhestokost.scad>."""
@ -390,20 +553,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 +570,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

View file

@ -173,12 +173,22 @@ 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)
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

View file

@ -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,54 @@ 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 = [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")
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")
# 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)
@ -456,7 +508,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")
@ -473,9 +525,18 @@ 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")
# 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}")
@ -566,6 +627,64 @@ 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 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)
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")
# 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")
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 +916,7 @@ def main():
clear(agent_guards(scene))
clear(hull_type_gate(scene))
never_built(scene)
nakovalnya_frames(EXECUTABLE)
created = not MODS.exists()

View file

@ -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
@ -50,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."""
@ -67,10 +81,27 @@ 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": ("", ""),
}
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):
"""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]
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:
@ -78,7 +109,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 +237,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 +245,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 +510,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 +698,415 @@ 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"
LINES_FRAME = "every kit line is drawn in the frame of its geometry"
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 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_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)])
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")
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 = """<?xml version="1.0" standalone="no"?>
<svg width="9mm" height="9mm" viewBox="0 -4 9 9" xmlns="http://www.w3.org/2000/svg" version="1.1">
<path d="
M 0,-4 L 4,-4 L 4,-1 L 0,-1 z
M 5,1 L 5.001,1 L 5.001,1.001 z
M -1e-05,2 L 2,2 L 2,3 L 0,3 z
" fill="lightgray" stroke="black" stroke-width="0.5"/>
</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("<svg></svg>"), [])
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.0), (-3.0, 5.0)})
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_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, [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))))
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()))
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'<svg><path d="\n{paths}\n" fill="lightgray"/></svg>'
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(), [])
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('<svg><path d="M .,. L 1,1 L 2,2 z"/></svg>')
self.assertEqual(str(caught.exception), "OpenSCAD wrote a silhouette that is not readable")
if __name__ == "__main__":
unittest.main()

View file

@ -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=<path>\\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 <zhestokost.scad>
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()

View file

@ -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()
@ -368,7 +384,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())