diff --git a/Assets/Scripts/ServerShared/SchematicUnderlay.cs b/Assets/Scripts/ServerShared/SchematicUnderlay.cs new file mode 100644 index 000000000..09f3d4118 --- /dev/null +++ b/Assets/Scripts/ServerShared/SchematicUnderlay.cs @@ -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 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; +} diff --git a/Assets/Scripts/ServerShared/SchematicUnderlay.cs.meta b/Assets/Scripts/ServerShared/SchematicUnderlay.cs.meta new file mode 100644 index 000000000..d6120a641 --- /dev/null +++ b/Assets/Scripts/ServerShared/SchematicUnderlay.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77b6dfe49272456a9f976d2578732d8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/SchematicUnderlayTests.cs b/tests/Aetheria.Shared.Tests/SchematicUnderlayTests.cs new file mode 100644 index 000000000..6001b6b51 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SchematicUnderlayTests.cs @@ -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 Lines() => new List + { + 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 { 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 { 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 { 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(), new Shape(6, 4))); + Assert.Empty(SchematicUnderlay.Fit(Lines(), null)); + } +}