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:
commit
5b599f66bf
20 changed files with 1444 additions and 71 deletions
169
GameData/Mods/aetheria.nakovalnya/hull.scad
Normal file
169
GameData/Mods/aetheria.nakovalnya/hull.scad
Normal 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
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
BIN
GameData/Mods/aetheria.nakovalnya/ship.glb
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
144
tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs
Normal file
144
tests/Aetheria.Shared.Tests/NakovalnyaPackageTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
92
tools/blender/tests/test_kit_openscad.py
Normal file
92
tools/blender/tests/test_kit_openscad.py
Normal 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()
|
||||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue