SchematicUnderlay.Fit: the line art stretched onto the hull Shape's Width x Height

Engine-free rule in ServerShared with five behaviour tests. Builds (ServerShared and tests only; Editor wiring follows in the next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-17 18:47:35 +02:00
commit 59cbdd1f3d
3 changed files with 145 additions and 0 deletions

View file

@ -0,0 +1,36 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Collections.Generic;
using System.Linq;
using CultMath;
// A mod hull's line-art schematic laid under its cell grids. The one rule for where the lines land: the line art's
// top-down bounding box is stretched, per axis, onto the hull Shape's cell rect [0, Width] x [0, Height] as the hull
// drawer sets it, never onto the occupied cells (underlay-fits-hull-bounds). Display only: nothing is derived from the
// model into the schematic or back (schematic-and-model-are-separate-spaces).
public static class SchematicUnderlay
{
// Points are Blender Z-up XYZ in the Ship Root frame, nose toward -Y and starboard toward -X, so the schematic's
// x (starboard right) is -X and its y (forward up) is -Y. Output is in hull cell units, index-aligned with `lines`.
public static float2[][] Fit(IReadOnlyList<ShipPolyline> lines, Shape hull)
{
if (lines == null || lines.Count == 0 || hull == null) return new float2[0][];
var top = lines.Select(line => Enumerable.Range(0, (line?.Points?.Length ?? 0) / 3)
.Select(i => new float2(-line.Points[i * 3], -line.Points[i * 3 + 1])).ToArray()).ToArray();
var all = top.SelectMany(line => line).ToArray();
if (all.Length == 0) return top;
var minX = all.Min(p => p.x);
var maxX = all.Max(p => p.x);
var minY = all.Min(p => p.y);
var maxY = all.Max(p => p.y);
return top.Select(line => line.Select(p => new float2(
Stretch(p.x, minX, maxX, hull.Width), Stretch(p.y, minY, maxY, hull.Height))).ToArray()).ToArray();
}
// min lands on 0 and max on `size`; an axis with no extent sits at the midpoint.
private static float Stretch(float value, float min, float max, int size) =>
max > min ? (value - min) / (max - min) * size : size / 2f;
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 77b6dfe49272456a9f976d2578732d8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Linq;
using CultMath;
using Xunit;
// SchematicUnderlay: the one rule that registers a mod hull's line art onto the hull Shape's cell rect.
public sealed class SchematicUnderlayTests
{
private static ShipPolyline Line(params float[] xyz) => new ShipPolyline { Points = xyz };
// Lines spanning Blender X [-3,5], Y [-10,2] (nose at Y -10, starboard at X -3).
private static List<ShipPolyline> Lines() => new List<ShipPolyline>
{
Line(-3, -10, 0, 5, 2, 0),
Line(1, -4, 1, 0, 0, 1),
};
private static Shape SixByFour()
{
var shape = new Shape(6, 4);
shape[new int2(1, 1)] = true;
shape[new int2(2, 1)] = true;
return shape;
}
private static (float2 min, float2 max) Bounds(float2[][] fit)
{
var all = fit.SelectMany(l => l).ToArray();
return (new float2(all.Min(p => p.x), all.Min(p => p.y)), new float2(all.Max(p => p.x), all.Max(p => p.y)));
}
[Fact]
public void UnderlaySpansHullBoundsNotOccupiedCells()
{
var (min, max) = Bounds(SchematicUnderlay.Fit(Lines(), SixByFour()));
Assert.Equal(0f, min.x, 4);
Assert.Equal(0f, min.y, 4);
Assert.Equal(6f, max.x, 4);
Assert.Equal(4f, max.y, 4);
}
[Fact]
public void UnderlayNoseUpStarboardRight()
{
var fit = SchematicUnderlay.Fit(Lines(), SixByFour());
// Line 0 point 0 holds both the lowest Blender Y (nose) and the lowest Blender X (starboard).
Assert.Equal(4f, fit[0][0].y, 4);
Assert.Equal(6f, fit[0][0].x, 4);
// Line 0 point 1 holds the highest of both: aft and port.
Assert.Equal(0f, fit[0][1].y, 4);
Assert.Equal(0f, fit[0][1].x, 4);
}
[Fact]
public void UnderlayFollowsTheHullDrawersSize()
{
var hull = SixByFour();
hull.Resize(8, 5);
var (min, max) = Bounds(SchematicUnderlay.Fit(Lines(), hull));
Assert.Equal(new float2(0, 0), new float2(min.x, min.y));
Assert.Equal(8f, max.x, 4);
Assert.Equal(5f, max.y, 4);
}
[Fact]
public void UnderlayStretchesEachAxis()
{
var lines = new List<ShipPolyline> { Line(0, 0, 0, 4, 2, 0) };
var (min, max) = Bounds(SchematicUnderlay.Fit(lines, new Shape(3, 3)));
Assert.Equal(0f, min.x, 4);
Assert.Equal(0f, min.y, 4);
Assert.Equal(3f, max.x, 4);
Assert.Equal(3f, max.y, 4);
}
[Fact]
public void UnderlayDegenerateAndEmpty()
{
var flat = new List<ShipPolyline> { Line(2, 0, 0, 2, 5, 0) };
var fit = SchematicUnderlay.Fit(flat, new Shape(6, 4));
Assert.All(fit[0], p => Assert.Equal(3f, p.x, 4));
Assert.All(fit[0], p => Assert.True(float.IsFinite(p.y)));
var pointless = SchematicUnderlay.Fit(new List<ShipPolyline> { Line() }, new Shape(6, 4));
Assert.Single(pointless);
Assert.Empty(pointless[0]);
Assert.Empty(SchematicUnderlay.Fit(null, new Shape(6, 4)));
Assert.Empty(SchematicUnderlay.Fit(new List<ShipPolyline>(), new Shape(6, 4)));
Assert.Empty(SchematicUnderlay.Fit(Lines(), null));
}
}