From 47bd3c3398ac6a0cd0eaf498a97f32951b9e728f Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 21:45:20 +0200 Subject: [PATCH 1/7] tests(splines): pin legacy drag-point output Record byte-exact goldens for every drag-point geometry consumer, including surface, ramp, rubber, wire guide, trigger-outline, and light-insert output. The fixtures include vertex positions, normals, UVs, indices, and control-point metadata so later storage and editor changes cannot alter generated geometry. Exercise open and closed curves across smooth flags and accuracy levels in both two and three dimensions. A VPX export/reload matrix also compares every BIFF field for all six Unity-backed item types, separating serialized semantics from the runtime-only IDs and calculated ramp heights. --- .../DragPointGolden/light-insert.golden | Bin 0 -> 12545 bytes .../loop-corners-detail.golden | Bin 0 -> 168 bytes .../DragPointGolden/loop-smooth-coarse.golden | Bin 0 -> 1148 bytes .../DragPointGolden/loop-smooth-detail.golden | Bin 0 -> 2268 bytes .../DragPointGolden/metal-wire-guide.golden | Bin 0 -> 25272 bytes .../open-corners-detail.golden | Bin 0 -> 168 bytes .../DragPointGolden/open-smooth-coarse.golden | Bin 0 -> 840 bytes .../DragPointGolden/open-smooth-detail.golden | Bin 0 -> 1736 bytes .../Fixtures~/DragPointGolden/ramp.golden | Bin 0 -> 134025 bytes .../Fixtures~/DragPointGolden/rubber.golden | Bin 0 -> 30280 bytes .../Fixtures~/DragPointGolden/surface.golden | Bin 0 -> 10389 bytes .../DragPointGolden/trigger-outline.golden | Bin 0 -> 360 bytes .../Math/DragPointGoldenTests.cs | 266 ++++++++++++++++++ 13 files changed, 266 insertions(+) create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/light-insert.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-corners-detail.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-coarse.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-detail.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/metal-wire-guide.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-corners-detail.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-coarse.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-detail.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/ramp.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/surface.golden create mode 100644 VisualPinball.Engine.Test/Fixtures~/DragPointGolden/trigger-outline.golden create mode 100644 VisualPinball.Engine.Test/Math/DragPointGoldenTests.cs diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/light-insert.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/light-insert.golden new file mode 100644 index 0000000000000000000000000000000000000000..d6f6fc58b4538547eac60b9a64795be9d3f9f19c GIT binary patch literal 12545 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 literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-corners-detail.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-corners-detail.golden new file mode 100644 index 0000000000000000000000000000000000000000..9004aebe5380c7d5c1b659990ed42b2fd89c017a GIT binary patch literal 168 zcmZQ&0D^`OhZq3wCICr1CKmtz literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-coarse.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-coarse.golden new file mode 100644 index 0000000000000000000000000000000000000000..396dc347a613208ffcd1e91e0f4b10b451b27e36 GIT binary patch literal 1148 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 literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/metal-wire-guide.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/metal-wire-guide.golden new file mode 100644 index 0000000000000000000000000000000000000000..73c4f0714e3cb61682c02609c478a7dddfeb4f66 GIT binary patch literal 25272 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 literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-corners-detail.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-corners-detail.golden new file mode 100644 index 0000000000000000000000000000000000000000..ab1d93c5c16f040f84b7d828ecae7a95bf447cd3 GIT binary patch literal 168 zcmZQ&0D^`OhZq^6RT27(hq`^N^p=M1X zJ9T*N*9FfD6}zNpAT3(G#;omFws}rXT0gA8)ejN8lG{d(L_R-9P(q@Zuv5OB35I zF)XmI#^Qn=+E^7-v+Q(_v39AP9K~S@gJM_t+4;w$gTK;P;~u+z`phTN;|b|(Qx81n z%L-}ONf2y+(G*u+Ygl!amHQpr)UjbzGE1|QXC_5&^qc3fbEA`IotmQhqYHz$g+u(K bY9QJlPC;$^1mADVi*DFS7QiVxmeTzXkc$05 literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-detail.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-detail.golden new file mode 100644 index 0000000000000000000000000000000000000000..c1281400e6cdfd6c85562155198336e057cbfff6 GIT binary patch literal 1736 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 MZG6o9Km5P{2VLko%m4rY literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden new file mode 100644 index 0000000000000000000000000000000000000000..d1e4191010d611f0966e9f8d4095108c00f24d4d GIT binary patch literal 30280 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 z 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 PrJjh(t)~wl;b;s1RH<(G literal 0 HcmV?d00001 diff --git a/VisualPinball.Engine.Test/Math/DragPointGoldenTests.cs b/VisualPinball.Engine.Test/Math/DragPointGoldenTests.cs new file mode 100644 index 000000000..84a7a0402 --- /dev/null +++ b/VisualPinball.Engine.Test/Math/DragPointGoldenTests.cs @@ -0,0 +1,266 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using VisualPinball.Engine.Math; +using VisualPinball.Engine.Test.Test; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Light; +using VisualPinball.Engine.VPT.MetalWireGuide; +using VisualPinball.Engine.VPT.Ramp; +using VisualPinball.Engine.VPT.Rubber; +using VisualPinball.Engine.VPT.Surface; +using VisualPinball.Engine.VPT.Table; + +namespace VisualPinball.Engine.Test.Math +{ + public class DragPointGoldenTests + { + private const string UpdateGoldenEnvironmentVariable = "UPDATE_DRAG_POINT_GOLDENS"; + + [Test] + public void ShouldKeepConsumerGeometryByteIdentical() + { + AssertGolden("surface", writer => { + var container = FileTableContainer.Load(VpxPath.Surface); + var surface = container.Surface("Wall"); + WriteMesh(writer, surface.GetMesh(SurfaceMeshGenerator.Top, container.Table)); + WriteMesh(writer, surface.GetMesh(SurfaceMeshGenerator.Side, container.Table)); + }); + + AssertGolden("ramp", writer => { + var container = FileTableContainer.Load(VpxPath.Ramp); + var flat = container.Ramp("Flat"); + WriteMesh(writer, flat.GetMesh(RampMeshGenerator.Floor, container.Table)); + WriteMesh(writer, flat.GetMesh("RightWall", container.Table)); + WriteMesh(writer, flat.GetMesh("LeftWall", container.Table)); + + var wire = container.Ramp("Wire4"); + for (var i = 1; i <= 4; i++) { + WriteMesh(writer, wire.GetMesh($"Wire{i}", container.Table)); + } + }); + + AssertGolden("rubber", writer => { + var container = FileTableContainer.Load(VpxPath.Rubber); + var data = container.Rubber("Rubber1").Data; + WriteMesh(writer, new RubberMeshGenerator(data).GetMesh(data.Height, container.Table)); + }); + + AssertGolden("metal-wire-guide", writer => { + var container = FileTableContainer.Load(VpxPath.MetalWireGuide); + var data = container.MetalWireGuide("MetalWireGuide2").Data; + WriteMesh(writer, new MetalWireGuideMeshGenerator(data).GetMesh(container.Table, data)); + }); + + AssertGolden("trigger-outline", writer => { + var container = FileTableContainer.Load(VpxPath.Trigger); + var dragPoints = container.Trigger("Data").Data.DragPoints; + AssignStableIds(dragPoints); + WriteVertices(writer, DragPoint.GetRgVertex(dragPoints)); + }); + + AssertGolden("light-insert", writer => { + var container = FileTableContainer.Load(VpxPath.Light); + var data = container.Light("Light1").Data; + var generator = new SurfaceMeshGenerator( + new LightInsertData(data.DragPoints, 20f), Vertex3D.Zero); + WriteMesh(writer, generator.GetMesh(SurfaceMeshGenerator.Top, + container.Table.Width, container.Table.Height, 0f, false)); + WriteMesh(writer, generator.GetMesh(SurfaceMeshGenerator.Side, + container.Table.Width, container.Table.Height, 0f, false)); + }); + } + + [TestCase(false, false, 4f, "open-corners-detail")] + [TestCase(false, true, 4f, "open-smooth-detail")] + [TestCase(true, false, 4f, "loop-corners-detail")] + [TestCase(true, true, 4f, "loop-smooth-detail")] + [TestCase(false, true, 400f, "open-smooth-coarse")] + [TestCase(true, true, 400f, "loop-smooth-coarse")] + public void ShouldKeepTessellationByteIdentical(bool loop, bool smooth, float accuracy, + string goldenName) + { + var dragPoints = CreateTessellationPoints(smooth); + AssertGolden(goldenName, writer => { + WriteVertices(writer, DragPoint.GetRgVertex(dragPoints, loop, accuracy)); + WriteVertices(writer, DragPoint.GetRgVertex(dragPoints, loop, accuracy)); + }); + } + + [Test] + public void ShouldRoundTripEveryDragPointFieldThroughVpx() + { + var cases = new[] { + (VpxPath.Surface, "Surface", "Wall"), + (VpxPath.Ramp, "Ramp", "FlatL"), + (VpxPath.Rubber, "Rubber", "Rubber1"), + (VpxPath.Trigger, "Trigger", "Data"), + (VpxPath.MetalWireGuide, "MetalWireGuide", "MetalWireGuide2"), + (VpxPath.Light, "Light", "Light1"), + }; + + foreach (var (path, itemType, itemName) in cases) { + var source = FileTableContainer.Load(path); + var expected = GetDragPoints(source, itemType, itemName); + var outputPath = Path.Combine(Path.GetTempPath(), $"vpe-dragpoints-{Guid.NewGuid():N}.vpx"); + try { + source.Export(outputPath); + var actual = GetDragPoints(FileTableContainer.Load(outputPath), itemType, itemName); + AssertDragPointsEqual(expected, actual, $"{itemType} {itemName}"); + } + finally { + if (File.Exists(outputPath)) { + File.Delete(outputPath); + } + } + } + } + + private static DragPointData[] GetDragPoints(FileTableContainer container, string itemType, + string itemName) + { + switch (itemType) { + case "Surface": return container.Surface(itemName).Data.DragPoints; + case "Ramp": return container.Ramp(itemName).Data.DragPoints; + case "Rubber": return container.Rubber(itemName).Data.DragPoints; + case "Trigger": return container.Trigger(itemName).Data.DragPoints; + case "MetalWireGuide": return container.MetalWireGuide(itemName).Data.DragPoints; + case "Light": return container.Light(itemName).Data.DragPoints; + default: throw new ArgumentOutOfRangeException(nameof(itemType), itemType, null); + } + } + + private static void AssertDragPointsEqual(IReadOnlyList expected, + IReadOnlyList actual, string context) + { + Assert.That(actual.Count, Is.EqualTo(expected.Count), context); + for (var i = 0; i < expected.Count; i++) { + var e = expected[i]; + var a = actual[i]; + Assert.That(a.Center, Is.EqualTo(e.Center), $"{context}, point {i}, center"); + Assert.That(a.IsSmooth, Is.EqualTo(e.IsSmooth), $"{context}, point {i}, smooth"); + Assert.That(a.IsSlingshot, Is.EqualTo(e.IsSlingshot), $"{context}, point {i}, slingshot"); + Assert.That(a.HasAutoTexture, Is.EqualTo(e.HasAutoTexture), $"{context}, point {i}, auto texture"); + Assert.That(a.TextureCoord, Is.EqualTo(e.TextureCoord), $"{context}, point {i}, texture coordinate"); + Assert.That(a.IsLocked, Is.EqualTo(e.IsLocked), $"{context}, point {i}, locked"); + Assert.That(a.EditorLayer, Is.EqualTo(e.EditorLayer), $"{context}, point {i}, editor layer"); + Assert.That(a.EditorLayerName, Is.EqualTo(e.EditorLayerName), $"{context}, point {i}, editor layer name"); + Assert.That(a.EditorLayerVisibility, Is.EqualTo(e.EditorLayerVisibility), + $"{context}, point {i}, editor layer visibility"); + } + } + + private static DragPointData[] CreateTessellationPoints(bool smooth) + { + return new[] { + CreatePoint("p0", -120.25f, 15.5f, 1.25f, smooth, true), + CreatePoint("p1", -25.75f, 140.125f, 18.5f, smooth, false), + CreatePoint("p2", 95.375f, 80.625f, -7.75f, smooth, true), + CreatePoint("p3", 155.875f, -45.25f, 32.125f, smooth, false), + CreatePoint("p4", 5.5f, -110.875f, 4.25f, smooth, false), + }; + } + + private static DragPointData CreatePoint(string id, float x, float y, float z, + bool smooth, bool slingshot) + { + return new DragPointData(new Vertex3D(x, y, z)) { + Id = id, + IsSmooth = smooth, + IsSlingshot = slingshot, + }; + } + + private static void AssignStableIds(IReadOnlyList dragPoints) + { + for (var i = 0; i < dragPoints.Count; i++) { + dragPoints[i].Id = $"p{i}"; + } + } + + private static void WriteMesh(BinaryWriter writer, Mesh mesh) + { + writer.Write(mesh.Name ?? string.Empty); + writer.Write(mesh.Vertices.Length); + foreach (var vertex in mesh.Vertices) { + vertex.Write(writer); + } + + writer.Write(mesh.Indices.Length); + foreach (var index in mesh.Indices) { + writer.Write(index); + } + } + + private static void WriteVertices(BinaryWriter writer, IReadOnlyList vertices) + { + writer.Write(vertices.Count); + foreach (var vertex in vertices) { + writer.Write(vertex.X); + writer.Write(vertex.Y); + WriteVertexMetadata(writer, vertex); + } + } + + private static void WriteVertices(BinaryWriter writer, IReadOnlyList vertices) + { + writer.Write(vertices.Count); + foreach (var vertex in vertices) { + writer.Write(vertex.X); + writer.Write(vertex.Y); + writer.Write(vertex.Z); + WriteVertexMetadata(writer, vertex); + } + } + + private static void WriteVertexMetadata(BinaryWriter writer, IRenderVertex vertex) + { + writer.Write(vertex.Smooth); + writer.Write(vertex.IsSlingshot); + writer.Write(vertex.IsControlPoint); + writer.Write(vertex.Id ?? string.Empty); + } + + private static void AssertGolden(string name, Action write) + { + byte[] actual; + using (var stream = new MemoryStream()) + using (var writer = new BinaryWriter(stream)) { + write(writer); + writer.Flush(); + actual = stream.ToArray(); + } + + var goldenDirectory = PathHelper.GetFixturePath("DragPointGolden"); + var goldenPath = Path.Combine(goldenDirectory, $"{name}.golden"); + if (Environment.GetEnvironmentVariable(UpdateGoldenEnvironmentVariable) == "1") { + Directory.CreateDirectory(goldenDirectory); + File.WriteAllBytes(goldenPath, actual); + } + + Assert.That(File.Exists(goldenPath), Is.True, + $"Missing {goldenPath}. Run with {UpdateGoldenEnvironmentVariable}=1 to create it."); + Assert.That(actual, Is.EqualTo(File.ReadAllBytes(goldenPath)), name); + } + } +} From 77b954ed89d36d4758538a25776d8e342ac4158e Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 21:53:28 +0200 Subject: [PATCH 2/7] splines: add exact drag-point conversion Depend on Unity Splines 2.8.2 and expose it to the runtime, editor, and test assemblies. Convert raw VPX knot positions to broken Bezier knots with tangents ported from VPE's centripetal Catmull-Rom coefficients, preserving the exact curve while keeping the source floats out of coordinate-space round trips. Store every semantic and runtime drag-point field in serializable per-knot metadata, and rebuild derived tangents after knot edits. Edit-mode tests cover bit-identical data round trips, sampled Catmull-Rom/Bezier equivalence for open and closed curves, and rejection of authored tangent changes. --- .../VisualPinball.Unity.Editor.asmdef | 2 + .../DragPointSplineConverterTests.cs | 144 ++++++++++++++ .../DragPointSplineConverterTests.cs.meta | 2 + .../VisualPinball.Unity.Test.asmdef | 1 + .../VisualPinball.Unity/DragPoint.meta | 8 + .../DragPoint/DragPointMetadata.cs | 83 ++++++++ .../DragPoint/DragPointMetadata.cs.meta | 2 + .../DragPoint/DragPointSplineConverter.cs | 178 ++++++++++++++++++ .../DragPointSplineConverter.cs.meta | 2 + .../VisualPinball.Unity.asmdef | 1 + package.json | 1 + 11 files changed, 424 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef index 411abede6..9f12766a8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VisualPinball.Unity.Editor.asmdef @@ -9,6 +9,8 @@ "Unity.Collections", "Unity.Formats.Fbx.Editor", "Unity.Mathematics", + "Unity.Splines", + "Unity.Splines.Editor", "Unity.InputSystem", "VisualPinball.Engine", "VisualPinball.Unity" diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs new file mode 100644 index 000000000..a6b47f243 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs @@ -0,0 +1,144 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine.Splines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity.Test +{ + public class DragPointSplineConverterTests + { + [TestCase(false)] + [TestCase(true)] + public void ShouldRoundTripDragPointsBitIdentically(bool loop) + { + var expected = CreateDragPoints(); + var spline = DragPointSplineConverter.ToSpline(expected, loop); + var actual = DragPointSplineConverter.ToDragPoints(spline, + DragPointSplineConverter.ToMetadata(expected)); + + Assert.That(spline.Closed, Is.EqualTo(loop)); + Assert.That(actual.Length, Is.EqualTo(expected.Length)); + for (var i = 0; i < expected.Length; i++) { + AssertDragPointEqual(expected[i], actual[i], i); + Assert.That(spline.GetTangentMode(i), Is.EqualTo(TangentMode.Broken)); + } + } + + [TestCase(false)] + [TestCase(true)] + public void ShouldMatchCentripetalCatmullRomSegments(bool loop) + { + var dragPoints = CreateDragPoints(); + var spline = DragPointSplineConverter.ToSpline(dragPoints, loop); + var segmentCount = loop ? dragPoints.Length : dragPoints.Length - 1; + for (var i = 0; i < segmentCount; i++) { + var nextIndex = i < dragPoints.Length - 1 ? i + 1 : 0; + var previousIndex = dragPoints[i].IsSmooth ? i - 1 : i; + if (previousIndex < 0) { + previousIndex = loop ? dragPoints.Length - 1 : 0; + } + + var followingIndex = dragPoints[nextIndex].IsSmooth ? i + 2 : i + 1; + if (followingIndex >= dragPoints.Length) { + followingIndex = loop ? followingIndex - dragPoints.Length : dragPoints.Length - 1; + } + + var catmull = CatmullCurve.GetInstance< + CatmullCurve3DCatmullCurveFactory>( + dragPoints[previousIndex].Center, + dragPoints[i].Center, + dragPoints[nextIndex].Center, + dragPoints[followingIndex].Center); + var bezier = spline.GetCurve(i); + for (var sample = 0; sample <= 20; sample++) { + var t = sample / 20f; + var expected = catmull.GetPointAt(t); + var actual = CurveUtility.EvaluatePosition(bezier, t); + Assert.That(math.distance(actual, + new float3(expected.X, expected.Y, expected.Z)), Is.LessThan(2e-5f), + $"segment {i}, t={t}"); + } + } + } + + [Test] + public void ShouldRestoreDerivedTangentsAfterSplineEdits() + { + var dragPoints = CreateDragPoints(); + var metadata = DragPointSplineConverter.ToMetadata(dragPoints); + var spline = DragPointSplineConverter.ToSpline(dragPoints, true); + var knot = spline[1]; + knot.Position += new float3(12.5f, -4.25f, 7.75f); + knot.TangentIn = new float3(900f); + knot.TangentOut = new float3(-900f); + spline.SetKnot(1, knot); + + DragPointSplineConverter.RecalculateTangents(spline, metadata); + var expected = DragPointSplineConverter.ToSpline( + DragPointSplineConverter.ToDragPoints(spline, metadata), true); + for (var i = 0; i < spline.Count; i++) { + Assert.That(spline[i], Is.EqualTo(expected[i])); + } + } + + private static DragPointData[] CreateDragPoints() + { + return new[] { + CreateDragPoint("a", -120.25f, 15.5f, 1.25f, true, false, 0.125f), + CreateDragPoint("b", -25.75f, 140.125f, 18.5f, false, true, 0.375f), + CreateDragPoint("c", 95.375f, 80.625f, -7.75f, true, false, 0.625f), + CreateDragPoint("d", 155.875f, -45.25f, 32.125f, true, true, 0.875f), + }; + } + + private static DragPointData CreateDragPoint(string id, float x, float y, float z, + bool smooth, bool slingshot, float textureCoord) + { + return new DragPointData(new Vertex3D(x, y, z)) { + Id = id, + IsSmooth = smooth, + IsSlingshot = slingshot, + HasAutoTexture = false, + TextureCoord = textureCoord, + IsLocked = id == "c", + EditorLayer = id[0], + EditorLayerName = $"layer-{id}", + EditorLayerVisibility = id != "b", + CalcHeight = textureCoord * 100f, + }; + } + + private static void AssertDragPointEqual(DragPointData expected, DragPointData actual, + int index) + { + Assert.That(actual.Center, Is.EqualTo(expected.Center), $"point {index}, center"); + Assert.That(actual.IsSmooth, Is.EqualTo(expected.IsSmooth), $"point {index}, smooth"); + Assert.That(actual.IsSlingshot, Is.EqualTo(expected.IsSlingshot), $"point {index}, slingshot"); + Assert.That(actual.HasAutoTexture, Is.EqualTo(expected.HasAutoTexture), $"point {index}, auto texture"); + Assert.That(actual.TextureCoord, Is.EqualTo(expected.TextureCoord), $"point {index}, texture coordinate"); + Assert.That(actual.IsLocked, Is.EqualTo(expected.IsLocked), $"point {index}, locked"); + Assert.That(actual.EditorLayer, Is.EqualTo(expected.EditorLayer), $"point {index}, editor layer"); + Assert.That(actual.EditorLayerName, Is.EqualTo(expected.EditorLayerName), $"point {index}, editor layer name"); + Assert.That(actual.EditorLayerVisibility, Is.EqualTo(expected.EditorLayerVisibility), + $"point {index}, editor layer visibility"); + Assert.That(actual.Id, Is.EqualTo(expected.Id), $"point {index}, id"); + Assert.That(actual.CalcHeight, Is.EqualTo(expected.CalcHeight), $"point {index}, calculated height"); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs.meta new file mode 100644 index 000000000..d7f4fd678 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineConverterTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57b1859bddfd191438cfe4543310d94e \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef index 19148d705..0f2fb156b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef @@ -6,6 +6,7 @@ "Unity.Entities", "Unity.Entities.Hybrid", "Unity.Mathematics", + "Unity.Splines", "Unity.Transforms", "com.bartofzo.nativetrees", "VisualPinball.Engine", diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint.meta new file mode 100644 index 000000000..113d51de3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 71b7101902bc0aa4e9418b18f6a82049 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs new file mode 100644 index 000000000..2b3e7b66d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs @@ -0,0 +1,83 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using UnityEngine; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity +{ + [Serializable] + public class DragPointMetadata + { + [SerializeField] private bool _isSmooth; + [SerializeField] private bool _isSlingshot; + [SerializeField] private bool _hasAutoTexture = true; + [SerializeField] private float _textureCoord; + [SerializeField] private bool _isLocked; + [SerializeField] private int _editorLayer; + [SerializeField] private string _editorLayerName = string.Empty; + [SerializeField] private bool _editorLayerVisibility = true; + [SerializeField] private string _id; + [SerializeField] private float _calcHeight; + + public bool IsSmooth { get => _isSmooth; set => _isSmooth = value; } + public bool IsSlingshot { get => _isSlingshot; set => _isSlingshot = value; } + public bool HasAutoTexture { get => _hasAutoTexture; set => _hasAutoTexture = value; } + public float TextureCoord { get => _textureCoord; set => _textureCoord = value; } + public bool IsLocked { get => _isLocked; set => _isLocked = value; } + public int EditorLayer { get => _editorLayer; set => _editorLayer = value; } + public string EditorLayerName { get => _editorLayerName; set => _editorLayerName = value; } + public bool EditorLayerVisibility { get => _editorLayerVisibility; set => _editorLayerVisibility = value; } + public string Id { get => _id; set => _id = value; } + public float CalcHeight { get => _calcHeight; set => _calcHeight = value; } + + public DragPointMetadata() { } + + public DragPointMetadata(DragPointData dragPoint) + { + CopyFrom(dragPoint); + } + + public void CopyFrom(DragPointData dragPoint) + { + _isSmooth = dragPoint.IsSmooth; + _isSlingshot = dragPoint.IsSlingshot; + _hasAutoTexture = dragPoint.HasAutoTexture; + _textureCoord = dragPoint.TextureCoord; + _isLocked = dragPoint.IsLocked; + _editorLayer = dragPoint.EditorLayer; + _editorLayerName = dragPoint.EditorLayerName; + _editorLayerVisibility = dragPoint.EditorLayerVisibility; + _id = dragPoint.Id; + _calcHeight = dragPoint.CalcHeight; + } + + public void CopyTo(DragPointData dragPoint) + { + dragPoint.IsSmooth = _isSmooth; + dragPoint.IsSlingshot = _isSlingshot; + dragPoint.HasAutoTexture = _hasAutoTexture; + dragPoint.TextureCoord = _textureCoord; + dragPoint.IsLocked = _isLocked; + dragPoint.EditorLayer = _editorLayer; + dragPoint.EditorLayerName = _editorLayerName; + dragPoint.EditorLayerVisibility = _editorLayerVisibility; + dragPoint.Id = _id; + dragPoint.CalcHeight = _calcHeight; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs.meta new file mode 100644 index 000000000..3d9c2d23a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5059e56786f5c8f4c9f77838ae9495d9 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs new file mode 100644 index 000000000..99814d349 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs @@ -0,0 +1,178 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine.Splines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity +{ + public static class DragPointSplineConverter + { + private const float MinKnotDistance = 1e-4f; + + public static Spline ToSpline(IReadOnlyList dragPoints, bool loop) + { + if (dragPoints == null) { + throw new ArgumentNullException(nameof(dragPoints)); + } + + var positions = new float3[dragPoints.Count]; + var smooth = new bool[dragPoints.Count]; + for (var i = 0; i < dragPoints.Count; i++) { + positions[i] = ToFloat3(dragPoints[i].Center); + smooth[i] = dragPoints[i].IsSmooth; + } + + var (tangentIn, tangentOut) = CalculateTangents(positions, smooth, loop); + var spline = new Spline(dragPoints.Count, loop); + for (var i = 0; i < dragPoints.Count; i++) { + spline.Add(new BezierKnot(positions[i], tangentIn[i], tangentOut[i], + quaternion.identity), TangentMode.Broken); + } + return spline; + } + + public static List ToMetadata(IReadOnlyList dragPoints) + { + if (dragPoints == null) { + throw new ArgumentNullException(nameof(dragPoints)); + } + + var metadata = new List(dragPoints.Count); + for (var i = 0; i < dragPoints.Count; i++) { + metadata.Add(new DragPointMetadata(dragPoints[i])); + } + return metadata; + } + + public static DragPointData[] ToDragPoints(Spline spline, + IReadOnlyList metadata) + { + ValidateMetadata(spline, metadata); + + var dragPoints = new DragPointData[spline.Count]; + for (var i = 0; i < spline.Count; i++) { + var position = spline[i].Position; + var dragPoint = new DragPointData(new Vertex3D(position.x, position.y, position.z)); + metadata[i].CopyTo(dragPoint); + dragPoints[i] = dragPoint; + } + return dragPoints; + } + + public static void RecalculateTangents(Spline spline, + IReadOnlyList metadata) + { + ValidateMetadata(spline, metadata); + + var positions = new float3[spline.Count]; + var smooth = new bool[spline.Count]; + for (var i = 0; i < spline.Count; i++) { + positions[i] = spline[i].Position; + smooth[i] = metadata[i].IsSmooth; + } + + var (tangentIn, tangentOut) = CalculateTangents(positions, smooth, spline.Closed); + for (var i = 0; i < spline.Count; i++) { + var knot = spline[i]; + knot.TangentIn = tangentIn[i]; + knot.TangentOut = tangentOut[i]; + knot.Rotation = quaternion.identity; + spline.SetTangentModeNoNotify(i, TangentMode.Broken); + spline.SetKnot(i, knot); + } + } + + private static (float3[] tangentIn, float3[] tangentOut) CalculateTangents( + IReadOnlyList positions, IReadOnlyList smooth, bool loop) + { + var tangentIn = new float3[positions.Count]; + var tangentOut = new float3[positions.Count]; + var segmentCount = loop ? positions.Count : math.max(0, positions.Count - 1); + for (var i = 0; i < segmentCount; i++) { + var nextIndex = i < positions.Count - 1 ? i + 1 : 0; + var p1 = positions[i]; + var p2 = positions[nextIndex]; + if (math.all(p1 == p2)) { + continue; + } + + var previousIndex = smooth[i] ? i - 1 : i; + if (previousIndex < 0) { + previousIndex = loop ? positions.Count - 1 : 0; + } + + var followingIndex = smooth[nextIndex] ? i + 2 : i + 1; + if (followingIndex >= positions.Count) { + followingIndex = loop ? followingIndex - positions.Count : positions.Count - 1; + } + + var (startTangent, endTangent) = CalculateSegmentTangents( + positions[previousIndex], p1, p2, positions[followingIndex]); + tangentOut[i] = startTangent / 3f; + tangentIn[nextIndex] = -endTangent / 3f; + } + return (tangentIn, tangentOut); + } + + private static (float3 start, float3 end) CalculateSegmentTangents(float3 p0, + float3 p1, float3 p2, float3 p3) + { + var dt0 = math.sqrt(math.distance(p0, p1)); + var dt1 = math.sqrt(math.distance(p1, p2)); + var dt2 = math.sqrt(math.distance(p2, p3)); + if (dt1 < MinKnotDistance) { + dt1 = 1f; + } + if (dt0 < MinKnotDistance) { + dt0 = dt1; + } + if (dt2 < MinKnotDistance) { + dt2 = dt1; + } + + var start = (p1 - p0) / dt0 - (p2 - p0) / (dt0 + dt1) + + (p2 - p1) / dt1; + var end = (p2 - p1) / dt1 - (p3 - p1) / (dt1 + dt2) + + (p3 - p2) / dt2; + return (start * dt1, end * dt1); + } + + private static void ValidateMetadata(Spline spline, + IReadOnlyList metadata) + { + if (spline == null) { + throw new ArgumentNullException(nameof(spline)); + } + if (metadata == null) { + throw new ArgumentNullException(nameof(metadata)); + } + if (spline.Count != metadata.Count) { + throw new ArgumentException( + $"Spline has {spline.Count} knots but metadata has {metadata.Count} entries.", + nameof(metadata)); + } + } + + private static float3 ToFloat3(Vertex3D vertex) + { + return new float3(vertex.X, vertex.Y, vertex.Z); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs.meta new file mode 100644 index 000000000..f5d59a153 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1ad9e007b11e75c44aecb12b15d3cdad \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef index f0edebb73..768b51a73 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef @@ -7,6 +7,7 @@ "Unity.Burst", "Unity.Collections", "Unity.Mathematics", + "Unity.Splines", "Unity.InputSystem", "VisualPinball.Engine", "com.bartofzo.nativetrees", diff --git a/package.json b/package.json index a6e127e5d..d93a1c8f2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "com.unity.modules.unitywebrequest": "1.0.0", "com.unity.modules.unitywebrequestaudio": "1.0.0", "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.splines": "2.8.2", "com.unity.ugui": "2.5.0", "com.unity.test-framework": "1.7.0" }, From f2a67843c2b0a3967d3d6c9fb1c12e6aa255c4de Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 22:07:15 +0200 Subject: [PATCH 3/7] splines: migrate drag-point component storage Move Rubber, Trigger, Surface, MetalWireGuide, LightInsertMesh, and Ramp onto a shared child SplineContainer with index-aligned VPE metadata. Legacy arrays now upgrade lazily, while mesh, collider, packable, and VPX export consumers keep reading computed DragPointData arrays and therefore retain their existing APIs. Place spline children under the exact VPX-to-Unity basis so knot floats remain verbatim in serialized data. Centralize insert/remove metadata alignment, derived-tangent restoration, planar constraints, Undo/prefab recording, and mesh invalidation; retain Ramp's endpoint Z-to-height behavior and cover the migration with edit-mode component tests. --- .../DragPoint/DragPointsHandler.cs | 1 + .../DragPoint/DragPointsInspectorHelper.cs | 8 +- .../DragPointSplineComponentTests.cs | 182 +++++++++++++ .../DragPointSplineComponentTests.cs.meta | 2 + .../DragPoint/DragPointMetadata.cs | 15 ++ .../DragPoint/DragPointSplineComponent.cs | 245 ++++++++++++++++++ .../DragPointSplineComponent.cs.meta | 2 + .../DragPoint/IDragPointSplineOwner.cs | 36 +++ .../DragPoint/IDragPointSplineOwner.cs.meta | 2 + .../VPT/Flipper/FlipperComponent.cs | 13 +- .../VPT/Light/LightInsertMeshComponent.cs | 49 +++- .../MetalWireGuide/MetalWireGuideComponent.cs | 45 +++- .../VPT/Ramp/RampComponent.cs | 83 +++++- .../VPT/Rubber/RubberComponent.cs | 45 +++- .../VPT/Surface/SurfaceComponent.cs | 55 +++- .../VPT/Trigger/TriggerComponent.cs | 45 +++- 16 files changed, 785 insertions(+), 43 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs index 5eb064053..767c4b72b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs @@ -237,6 +237,7 @@ public void CenterPivot() foreach (var dragPoint in DragPointInspector.DragPoints) { dragPoint.Center -= centerVpx.ToVertex3D(); } + DragPointInspector.DragPoints = DragPointInspector.DragPoints; MainComponent.RebuildMeshes(); } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs index d7428b1fe..4d25f984b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs @@ -62,6 +62,7 @@ public DragPointsInspectorHelper(IMainRenderableComponent mainComponent, IDragPo public void RebuildMeshes() { + _dragPointsInspector.DragPoints = _dragPointsInspector.DragPoints; _mainComponent.RebuildMeshes(); } @@ -195,7 +196,12 @@ public void Reverse() /// Message to appear in the UNDO menu public void PrepareUndo(string message) { - Undo.RecordObject(_mb, message); + if (_mb is IDragPointSplineOwner owner) { + var spline = owner.SplineComponent; + Undo.RecordObjects(new Object[] { _mb, spline, spline.Container }, message); + } else { + Undo.RecordObject(_mb, message); + } } public void OnInspectorGUI(ItemInspector inspector) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs new file mode 100644 index 000000000..56bd2bad6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs @@ -0,0 +1,182 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Splines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity.Test +{ + public class DragPointSplineComponentTests + { + [Test] + public void ShouldStoreRawVpxFloatsUnderTheBasisTransform() + { + var go = new GameObject("Rubber"); + try { + go.transform.SetPositionAndRotation(new Vector3(2.5f, -1.25f, 4.75f), + Quaternion.Euler(13f, 27f, -9f)); + go.transform.localScale = new Vector3(1.25f, 0.75f, 1.5f); + var rubber = go.AddComponent(); + var dragPoints = CreateDragPoints(); + rubber.DragPoints = dragPoints; + + var spline = rubber.DragPointSpline.Container.Spline; + for (var i = 0; i < spline.Count; i++) { + Assert.That(spline[i].Position, + Is.EqualTo(new float3(dragPoints[i].Center.X, dragPoints[i].Center.Y, + dragPoints[i].Center.Z))); + var expectedWorld = dragPoints[i].Center.ToUnityVector3() + .TranslateToWorld(go.transform); + var actualWorld = rubber.DragPointSpline.Container.transform + .TransformPoint(spline[i].Position); + Assert.That(Vector3.Distance(actualWorld, expectedWorld), Is.LessThan(1e-5f)); + } + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldKeepMetadataAlignedWhenKnotsAreInsertedAndRemoved() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var dragPointSpline = rubber.DragPointSpline; + var inheritedId = dragPointSpline.Metadata[1].Id; + + dragPointSpline.Container.Spline.Insert(1, + new float3(-80f, 72f, 15f), TangentMode.Broken); + + Assert.That(dragPointSpline.Metadata.Count, Is.EqualTo(5)); + Assert.That(dragPointSpline.Metadata[1].IsSmooth, + Is.EqualTo(dragPointSpline.Metadata[2].IsSmooth)); + Assert.That(dragPointSpline.Metadata[1].IsSlingshot, + Is.EqualTo(dragPointSpline.Metadata[2].IsSlingshot)); + Assert.That(dragPointSpline.Metadata[1].Id, Is.Not.EqualTo(inheritedId)); + Assert.That(dragPointSpline.DragPoints[1].Center, + Is.EqualTo(new Vertex3D(-80f, 72f, 0f))); + + dragPointSpline.Container.Spline.RemoveAt(1); + Assert.That(dragPointSpline.Metadata.Count, Is.EqualTo(4)); + Assert.That(dragPointSpline.DragPoints[1].Id, Is.EqualTo(inheritedId)); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldClampPlanarKnotEditsAndRestoreDerivedTangents() + { + var go = new GameObject("Surface"); + try { + var surface = go.AddComponent(); + surface.DragPoints = CreateDragPoints(); + var spline = surface.DragPointSpline.Container.Spline; + var knot = spline[2]; + knot.Position.z = 123f; + knot.TangentIn = new float3(900f); + knot.TangentOut = new float3(-900f); + spline.SetKnot(2, knot); + + Assert.That(spline[2].Position.z, Is.Zero); + Assert.That(math.length(spline[2].TangentIn), Is.LessThan(900f)); + Assert.That(math.length(spline[2].TangentOut), Is.LessThan(900f)); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldMapRampEndpointZEditsToHeights() + { + var go = new GameObject("Ramp"); + try { + var ramp = go.AddComponent(); + ramp._heightBottom = 10f; + ramp._heightTop = 80f; + ramp.DragPoints = CreateDragPoints(); + var spline = ramp.DragPointSpline.Container.Spline; + var bottomZ = spline[0].Position.z; + var topZ = spline[^1].Position.z; + + var bottom = spline[0]; + bottom.Position.z += 12.5f; + spline.SetKnot(0, bottom); + Assert.That(ramp._heightBottom, Is.EqualTo(22.5f)); + Assert.That(spline[0].Position.z, Is.EqualTo(bottomZ)); + + var top = spline[^1]; + top.Position.z -= 4.25f; + spline.SetKnot(spline.Count - 1, top); + Assert.That(ramp._heightTop, Is.EqualTo(75.75f)); + Assert.That(spline[^1].Position.z, Is.EqualTo(topZ)); + + var middle = spline[1]; + middle.Position.z += 6f; + spline.SetKnot(1, middle); + Assert.That(spline[1].Position.z, Is.EqualTo(middle.Position.z)); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldUpgradeTheTriggerDefaultArrayLazily() + { + var go = new GameObject("Trigger"); + try { + var trigger = go.AddComponent(); + Assert.That(trigger.DragPoints.Length, Is.EqualTo(4)); + Assert.That(trigger.DragPointSpline.Container.Spline.Closed, Is.True); + Assert.That(trigger.DragPointSpline.transform.parent, Is.EqualTo(go.transform)); + } + finally { + Object.DestroyImmediate(go); + } + } + + private static DragPointData[] CreateDragPoints() + { + return new[] { + CreateDragPoint("a", -120.25f, 15.5f, 1.25f, true, false), + CreateDragPoint("b", -25.75f, 140.125f, 18.5f, false, true), + CreateDragPoint("c", 95.375f, 80.625f, -7.75f, true, false), + CreateDragPoint("d", 155.875f, -45.25f, 32.125f, true, true), + }; + } + + private static DragPointData CreateDragPoint(string id, float x, float y, float z, + bool smooth, bool slingshot) + { + return new DragPointData(new Vertex3D(x, y, z)) { + Id = id, + IsSmooth = smooth, + IsSlingshot = slingshot, + HasAutoTexture = false, + TextureCoord = (id[0] - 'a') * 0.25f, + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs.meta new file mode 100644 index 000000000..3374bfd3c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c8b5f92d3016cab48bf4c3a28cb8df75 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs index 2b3e7b66d..132b3d98f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs @@ -52,6 +52,21 @@ public DragPointMetadata(DragPointData dragPoint) CopyFrom(dragPoint); } + public static DragPointMetadata CreateInserted(DragPointMetadata previous, + DragPointMetadata next) + { + return new DragPointMetadata { + _isSmooth = next._isSmooth, + _isSlingshot = next._isSlingshot, + _hasAutoTexture = next._hasAutoTexture, + _textureCoord = next._textureCoord, + _editorLayer = next._editorLayer, + _editorLayerName = next._editorLayerName, + _editorLayerVisibility = previous._editorLayerVisibility, + _id = Guid.NewGuid().ToString()[..8], + }; + } + public void CopyFrom(DragPointData dragPoint) { _isSmooth = dragPoint.IsSmooth; diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs new file mode 100644 index 000000000..dccadaceb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs @@ -0,0 +1,245 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Splines; +using VisualPinball.Engine.Math; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace VisualPinball.Unity +{ + [AddComponentMenu("")] + [DisallowMultipleComponent] + public class DragPointSplineComponent : MonoBehaviour + { + [SerializeField] private SplineContainer _container; + [SerializeField] private MonoBehaviour _owner; + [SerializeField] private List _metadata = new(); + + [NonSerialized] private DragPointData[] _dragPoints; + [NonSerialized] private float3[] _positions = Array.Empty(); + [NonSerialized] private bool _handlingChange; + + public SplineContainer Container => _container; + public IReadOnlyList Metadata => _metadata; + public IDragPointSplineOwner Owner => _owner as IDragPointSplineOwner; + + public DragPointData[] DragPoints + { + get { + if (_dragPoints == null) { + _dragPoints = DragPointSplineConverter.ToDragPoints(_container.Spline, _metadata); + } + return _dragPoints; + } + } + + private void OnEnable() + { + Subscribe(); + RememberPositions(); + } + + private void OnDisable() + { + Spline.Changed -= OnSplineChanged; + } + + public static DragPointSplineComponent Create(IDragPointSplineOwner owner, + IReadOnlyList dragPoints) + { + var splineObject = new GameObject("Spline"); + splineObject.transform.SetParent(owner.SplineTransform, false); + splineObject.transform.localPosition = Vector3.zero; + splineObject.transform.localRotation = ((Matrix4x4)Physics.VpxToWorld).rotation; + splineObject.transform.localScale = Physics.ScaleInvVector; + + var container = splineObject.AddComponent(); + var component = splineObject.AddComponent(); + component._container = container; + component.Bind(owner); + component.SetDragPoints(dragPoints); + +#if UNITY_EDITOR + EditorUtility.SetDirty(owner.SplineOwner); + EditorUtility.SetDirty(component); +#endif + return component; + } + + public void Bind(IDragPointSplineOwner owner) + { + _owner = owner.SplineOwner; + if (!_container) { + _container = GetComponent(); + } + Subscribe(); + RememberPositions(); + } + + private void Subscribe() + { + Spline.Changed -= OnSplineChanged; + Spline.Changed += OnSplineChanged; + } + + public void SetDragPoints(IReadOnlyList dragPoints) + { + if (dragPoints == null) { + throw new ArgumentNullException(nameof(dragPoints)); + } + + _handlingChange = true; + try { + _metadata = DragPointSplineConverter.ToMetadata(dragPoints); + _container.Spline = DragPointSplineConverter.ToSpline(dragPoints, + Owner?.SplineClosed ?? false); + _dragPoints = null; + RememberPositions(); + } + finally { + _handlingChange = false; + } + } + + public void NotifyMetadataChanged() + { + _handlingChange = true; + try { + CaptureRuntimeMetadata(); + DragPointSplineConverter.RecalculateTangents(_container.Spline, _metadata); + _dragPoints = null; + RememberPositions(); + } + finally { + _handlingChange = false; + } + Owner?.RebuildSplineMeshes(); + } + + private void OnSplineChanged(Spline spline, int knotIndex, + SplineModification modification) + { + if (_handlingChange || !_container || !ReferenceEquals(spline, _container.Spline)) { + return; + } + + var owner = Owner; + if (owner == null) { + return; + } + + _handlingChange = true; + try { +#if UNITY_EDITOR + Undo.RecordObjects(new UnityEngine.Object[] { this, owner.SplineOwner }, + "Edit Drag Points"); +#endif + CaptureRuntimeMetadata(); + AlignMetadata(modification, knotIndex, spline.Closed); + ApplyPlanarConstraint(spline, knotIndex, modification, owner.SplinePlanar); + owner.ApplySplineConstraints(spline, knotIndex, modification, _positions); + DragPointSplineConverter.RecalculateTangents(spline, _metadata); + _dragPoints = null; + RememberPositions(); + +#if UNITY_EDITOR + EditorUtility.SetDirty(this); + EditorUtility.SetDirty(owner.SplineOwner); + PrefabUtility.RecordPrefabInstancePropertyModifications(this); + PrefabUtility.RecordPrefabInstancePropertyModifications(owner.SplineOwner); +#endif + } + finally { + _handlingChange = false; + } + + owner.RebuildSplineMeshes(); + } + + private void AlignMetadata(SplineModification modification, int knotIndex, bool closed) + { + switch (modification) { + case SplineModification.KnotInserted: + if (_metadata.Count == 0) { + _metadata.Add(new DragPointMetadata(new DragPointData(0f, 0f))); + break; + } + var previousIndex = knotIndex - 1; + if (previousIndex < 0) { + previousIndex = closed ? _metadata.Count - 1 : 0; + } + var nextIndex = math.min(knotIndex, _metadata.Count - 1); + _metadata.Insert(knotIndex, DragPointMetadata.CreateInserted( + _metadata[previousIndex], _metadata[nextIndex])); + break; + + case SplineModification.KnotRemoved: + if (knotIndex >= 0 && knotIndex < _metadata.Count) { + _metadata.RemoveAt(knotIndex); + } + break; + } + + if (_metadata.Count != _container.Spline.Count) { + throw new InvalidOperationException( + $"Spline has {_container.Spline.Count} knots but metadata has {_metadata.Count} entries after {modification}."); + } + } + + private static void ApplyPlanarConstraint(Spline spline, int knotIndex, + SplineModification modification, bool planar) + { + if (!planar || modification is not (SplineModification.KnotModified + or SplineModification.KnotInserted) || knotIndex < 0 || knotIndex >= spline.Count) { + return; + } + + var knot = spline[knotIndex]; + knot.Position.z = 0f; + spline.SetKnotNoNotify(knotIndex, knot); + } + + private void CaptureRuntimeMetadata() + { + if (_dragPoints == null || _dragPoints.Length != _metadata.Count) { + return; + } + for (var i = 0; i < _metadata.Count; i++) { + _metadata[i].CalcHeight = _dragPoints[i].CalcHeight; + } + } + + private void RememberPositions() + { + if (!_container || _container.Spline == null) { + _positions = Array.Empty(); + return; + } + + _positions = new float3[_container.Spline.Count]; + for (var i = 0; i < _positions.Length; i++) { + _positions[i] = _container.Spline[i].Position; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs.meta new file mode 100644 index 000000000..7ca91f8d9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 736303edf4395b3419a3787655fac458 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs new file mode 100644 index 000000000..997f27c9d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs @@ -0,0 +1,36 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Splines; + +namespace VisualPinball.Unity +{ + public interface IDragPointSplineOwner + { + MonoBehaviour SplineOwner { get; } + Transform SplineTransform { get; } + DragPointSplineComponent SplineComponent { get; } + bool SplineClosed { get; } + bool SplinePlanar { get; } + + void ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions); + void RebuildSplineMeshes(); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs.meta new file mode 100644 index 000000000..aa5b1b260 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/IDragPointSplineOwner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a877333d51388f2468ee7a8bbf23c062 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs index 536bb0fa9..4e3743944 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs @@ -648,10 +648,10 @@ private void SetupFlipperCorrection(FlipperColliderComponent colliderComponent) triggerCollider.TimeThresholdMs = fc.TimeThresholdMs; var localPos = transform.localPosition.TranslateToVpx(); - triggerComponent.Position = new Vector2(localPos.x, localPos.y); - - var poly = GetEnclosingPolygon(23, 12); - triggerComponent.DragPoints = new DragPointData[poly.Count]; + triggerComponent.Position = new Vector2(localPos.x, localPos.y); + + var poly = GetEnclosingPolygon(23, 12); + var dragPoints = new DragPointData[poly.Count]; triggerCollider.HitHeight = 150F; // nFozzy's recommendation, but I think 50 should be ok // this was taken from the 2021 code when we had the playfield transformed to vpx world: @@ -674,8 +674,9 @@ private void SetupFlipperCorrection(FlipperColliderComponent colliderComponent) for (var i = 0; i < poly.Count; i++) { // Poly points are expressed in flipper's frame: rotate to get it to the correct position. var p = flipperToPlayfield.MultiplyPoint3x4(poly[i]); - triggerComponent.DragPoints[poly.Count - i - 1] = new DragPointData(p.x, p.y); - } + dragPoints[poly.Count - i - 1] = new DragPointData(p.x, p.y); + } + triggerComponent.DragPoints = dragPoints; // create polarities and velocities curves var polarities = new float2[fc.PolaritiesCurveSlicingCount + 1]; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs index 692d93e78..33cb33a4a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs @@ -16,7 +16,11 @@ // ReSharper disable InconsistentNaming +using System; +using System.Collections.Generic; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Light; @@ -26,7 +30,8 @@ namespace VisualPinball.Unity { - public class LightInsertMeshComponent : MeshComponent + public class LightInsertMeshComponent : MeshComponent, + IDragPointSplineOwner { #region Data @@ -34,16 +39,31 @@ public class LightInsertMeshComponent : MeshComponent public float PositionZ = 0.1f; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion protected override Mesh GetMesh(LightData data) { var playfieldComponent = GetComponentInParent(); - var meshGen = new SurfaceMeshGenerator(new LightInsertData(_dragPoints, InsertHeight), MainComponent.transform.position.TranslateToVpx().ToVertex3D()); + var meshGen = new SurfaceMeshGenerator(new LightInsertData(DragPoints, InsertHeight), MainComponent.transform.position.TranslateToVpx().ToVertex3D()); var topMesh = meshGen.GetMesh(SurfaceMeshGenerator.Top, playfieldComponent.Width, playfieldComponent.Height, 0, false); var sideMesh = meshGen.GetMesh(SurfaceMeshGenerator.Side, playfieldComponent.Width, playfieldComponent.Height, 0, false); return topMesh.Merge(sideMesh).TransformToWorld(); @@ -60,5 +80,26 @@ protected override PbrMaterial GetMaterial(LightData data, Table table) mat = new Engine.VPT.Material("Playfield Insert"); return new PbrMaterial(mat, table.GetTexture(table.Data.Image)) { DiffusionProfile = DiffusionProfileTemplate.Plastics }; } + + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => MainComponent.transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => MainComponent.RebuildMeshes(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs index 824e2fac5..42f5edcb5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs @@ -24,7 +24,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.MetalWireGuide; @@ -36,7 +38,7 @@ namespace VisualPinball.Unity [PackAs("MetalWireGuide")] [AddComponentMenu("Pinball/Game Item/Metal Wire Guide")] public class MetalWireGuideComponent : MainRenderableComponent, - IMetalWireGuideData, IPackable + IMetalWireGuideData, IPackable, IDragPointSplineOwner { #region Data @@ -57,9 +59,12 @@ public class MetalWireGuideComponent : MainRenderableComponent _dragPoints; set => _dragPoints = value; } + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); public float Thickness => _thickness; public float Standheight => _standheight; public float Height => _height; @@ -217,10 +233,31 @@ public override void CopyFromObject(GameObject go) _standheight = srcMainComp._standheight; Rotation = srcMainComp.Rotation; _bendradius = srcMainComp._bendradius; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); } } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => false; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs index e8ad6497b..41d75f572 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs @@ -22,9 +22,11 @@ #endregion using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Ramp; @@ -37,7 +39,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Ramp")] [AddComponentMenu("Pinball/Game Item/Ramp")] - public class RampComponent : MainRenderableComponent, IRampData, ISurfaceComponent, IPackable + public class RampComponent : MainRenderableComponent, IRampData, ISurfaceComponent, IPackable, + IDragPointSplineOwner { #region Data @@ -81,8 +84,11 @@ public class RampComponent : MainRenderableComponent, IRampData, ISurf [Tooltip("Vertical distance between the wires.")] public float _wireDistanceY = 88f; - [SerializeField] - private DragPointData[] _dragPoints; + [SerializeField, HideInInspector] + private DragPointData[] _dragPoints; + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; [SerializeField] public Vector3 uvOffset = Vector3.zero; @@ -102,7 +108,18 @@ public class RampComponent : MainRenderableComponent, IRampData, ISurf public float WireDiameter => _wireDiameter; public float WidthTop => _widthTop; public float WidthBottom => _widthBottom; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion @@ -408,27 +425,67 @@ public override void CopyFromObject(GameObject go) _wireDiameter = srcMainComp._wireDiameter; _wireDistanceX = srcMainComp._wireDistanceX; _wireDistanceY = srcMainComp._wireDistanceY; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); } private void CenterPivot() { - var centerVpx = DragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); - centerVpx /= DragPoints.Length; + var dragPoints = DragPoints; + var centerVpx = dragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); + centerVpx /= dragPoints.Length; if (uvOffset == Vector3.zero) { uvOffset = centerVpx; } transform.Translate(centerVpx.TranslateToWorld(transform) - transform.position); - foreach (var dragPoint in DragPoints) { - dragPoint.Center -= centerVpx.ToVertex3D(); - } + foreach (var dragPoint in dragPoints) { + dragPoint.Center -= centerVpx.ToVertex3D(); + } + DragPoints = dragPoints; RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => false; + bool IDragPointSplineOwner.SplinePlanar => false; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) + { + if (modification != SplineModification.KnotModified || knotIndex < 0 + || previousPositions.Count != spline.Count + || knotIndex != 0 && knotIndex != spline.Count - 1) { + return; + } + + var knot = spline[knotIndex]; + var delta = knot.Position.z - previousPositions[knotIndex].z; + if (knotIndex == 0) { + _heightBottom += delta; + } else { + _heightTop += delta; + } + knot.Position.z = previousPositions[knotIndex].z; + spline.SetKnotNoNotify(knotIndex, knot); + } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + internal static (float rightWallHeight, float leftWallHeight) GetCollisionWallHeights(int type, float flatRightWallHeight, float flatLeftWallHeight) { switch (type) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs index 52ded96fa..2ca0e41b8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs @@ -26,6 +26,7 @@ using System.Linq; using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Rubber; @@ -36,7 +37,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Rubber")] [AddComponentMenu("Pinball/Game Item/Rubber")] - public class RubberComponent : MainRenderableComponent, IRubberData, IPackable + public class RubberComponent : MainRenderableComponent, IRubberData, IPackable, + IDragPointSplineOwner { #region Data @@ -50,9 +52,12 @@ public float Height { [Tooltip("How thick the rubber band is rendered.")] public int _thickness = 8; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + [NonSerialized] private Vertex3D[] _scalingDragPoints; @@ -60,7 +65,18 @@ public float Height { #region IRubberData - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); public int Thickness => math.max(1, _thickness); // don't allow zero thickness #endregion @@ -215,11 +231,32 @@ public override void CopyFromObject(GameObject go) var srcMainComp = go.GetComponent(); if (srcMainComp) { _thickness = srcMainComp._thickness; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs index 505fbfeb9..77842bbfe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs @@ -24,7 +24,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Surface; @@ -35,7 +37,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Surface")] [AddComponentMenu("Pinball/Game Item/Surface")] - public class SurfaceComponent : MainRenderableComponent, ISurfaceComponent, IPackable + public class SurfaceComponent : MainRenderableComponent, ISurfaceComponent, IPackable, + IDragPointSplineOwner { #region Data @@ -45,9 +48,24 @@ public class SurfaceComponent : MainRenderableComponent, ISurfaceCo [Tooltip("Bottom height of the wall, i.e. at which height the wall starts.")] public float HeightBottom; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); [SerializeField] public Vector3 uvOffset = Vector3.zero; @@ -220,7 +238,7 @@ public override void CopyFromObject(GameObject go) if (surfaceComponent) { HeightTop = surfaceComponent.HeightTop; HeightBottom = surfaceComponent.HeightBottom; - _dragPoints = surfaceComponent._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = surfaceComponent.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); @@ -235,20 +253,43 @@ private void CenterPivot() // - changing bottom height will just change the height // - change mesh and collider creation to create top-down instead of bottom-up. - var centerVpx = DragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); - centerVpx /= DragPoints.Length; + var dragPoints = DragPoints; + var centerVpx = dragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); + centerVpx /= dragPoints.Length; if (uvOffset == Vector3.zero) { uvOffset = centerVpx; } transform.Translate(centerVpx.TranslateToWorld(transform) - transform.position); - foreach (var dragPoint in DragPoints) { + foreach (var dragPoint in dragPoints) { dragPoint.Center -= centerVpx.ToVertex3D(); } + DragPoints = dragPoints; RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion #region State diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs index 27856786b..7c5eb07aa 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs @@ -27,6 +27,7 @@ using Unity.Collections; using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Game.Engines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; @@ -39,7 +40,7 @@ namespace VisualPinball.Unity [PackAs("Trigger")] [AddComponentMenu("Pinball/Game Item/Trigger")] public class TriggerComponent : MainRenderableComponent, - ITriggerComponent, IAnimationValueEmitter, IPackable + ITriggerComponent, IAnimationValueEmitter, IPackable, IDragPointSplineOwner { #region Data @@ -69,9 +70,24 @@ public float Rotation { set => transform.SetLocalYRotation(math.radians(value)); } - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints = { new(-50f, -50f), new(-50f, 50f), new(50f, 50f), new(50f, -50f) }; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion @@ -237,11 +253,32 @@ public override void CopyFromObject(GameObject go) { var triggerComponent = go.GetComponent(); if (triggerComponent) { - _dragPoints = triggerComponent._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = triggerComponent.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + if (!_dragPointSpline) { + _dragPointSpline = DragPointSplineComponent.Create(this, + _dragPoints ?? Array.Empty()); + _dragPoints = null; + } else { + _dragPointSpline.Bind(this); + } + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion #region State From 42d3eee15497463b963b98d2b2cfb760bebaeae0 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 22:18:30 +0200 Subject: [PATCH 4/7] splines(editor): adopt native knot tooling Route all six drag-point inspectors through Unity's spline tool context and expose VPE metadata for the selected knots. Smoothness remains VPE-authored, while slingshot and texture fields stay aligned with native knot selection. Keep the established flip, reverse, and center-origin operations, including closed-loop orientation and slingshot rotation. Draw segment ownership and knot indices over the native gizmo, and cover the subtle transforms with editor tests. --- .../DragPoint/DragPointSplineInspector.cs | 348 ++++++++++++++++++ .../DragPointSplineInspector.cs.meta | 2 + .../VPT/Light/LightInsertMeshInspector.cs | 39 +- .../MetalWireGuide/MetalWireGuideInspector.cs | 38 +- .../VPT/Ramp/RampInspector.cs | 56 +-- .../VPT/Rubber/RubberInspector.cs | 37 +- .../VPT/Surface/SurfaceInspector.cs | 39 +- .../VPT/Trigger/TriggerInspector.cs | 47 +-- .../DragPointSplineComponentTests.cs | 57 +++ 9 files changed, 419 insertions(+), 244 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs new file mode 100644 index 000000000..41868eaa9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs @@ -0,0 +1,348 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; +using UnityEditor; +using UnityEditor.EditorTools; +using UnityEditor.Splines; +using UnityEngine; +using UnityEngine.Splines; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity.Editor +{ + [Flags] + internal enum DragPointSplineExposure + { + None = 0, + Smooth = 1 << 0, + Slingshot = 1 << 1, + Texture = 1 << 2, + } + + public static class DragPointSplineInspectorGUI + { + public static void OnInspectorGUI(DragPointSplineComponent component, + bool showEditButton = true) + { + EditorGUILayout.Space(10f); + EditorGUILayout.LabelField("Spline", EditorStyles.boldLabel); + if (showEditButton) { + using (new EditorGUI.DisabledScope(!IsEditable(component))) { + if (GUILayout.Button("Edit Spline")) { + EditSpline(component); + } + } + } + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Center Origin")) { + CenterOrigin(component); + } + if (GUILayout.Button("Reverse")) { + Reverse(component); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Flip X")) { + Flip(component, FlipAxis.X); + } + if (GUILayout.Button("Flip Y")) { + Flip(component, FlipAxis.Y); + } + using (new EditorGUI.DisabledScope(component.Owner?.SplinePlanar ?? true)) { + if (GUILayout.Button("Flip Z")) { + Flip(component, FlipAxis.Z); + } + } + EditorGUILayout.EndHorizontal(); + } + + public static void EditSpline(DragPointSplineComponent component) + { + Selection.activeGameObject = component.Container.gameObject; + ToolManager.SetActiveContext(); + ToolManager.SetActiveTool(); + SceneView.RepaintAll(); + } + + public static void Flip(DragPointSplineComponent component, FlipAxis axis) + { + var dragPoints = component.DragPoints; + if (dragPoints.Length == 0) { + return; + } + + RecordUndo(component, $"Flip {axis} Drag Points"); + var center = dragPoints.Aggregate(Vertex3D.Zero, + (current, dragPoint) => current + dragPoint.Center) / dragPoints.Length; + foreach (var dragPoint in dragPoints) { + switch (axis) { + case FlipAxis.X: + dragPoint.Center.X = center.X + center.X - dragPoint.Center.X; + break; + case FlipAxis.Y: + dragPoint.Center.Y = center.Y + center.Y - dragPoint.Center.Y; + break; + case FlipAxis.Z: + dragPoint.Center.Z *= -1f; + break; + default: + throw new ArgumentOutOfRangeException(nameof(axis), axis, null); + } + } + + if (component.Owner?.SplineClosed == true) { + ReverseInPlace(dragPoints); + } + Apply(component, dragPoints); + } + + public static void Reverse(DragPointSplineComponent component) + { + var dragPoints = component.DragPoints; + if (dragPoints.Length < 2) { + return; + } + + RecordUndo(component, "Reverse Drag Points"); + ReverseInPlace(dragPoints); + Apply(component, dragPoints); + } + + public static void CenterOrigin(DragPointSplineComponent component) + { + var owner = component.Owner; + var dragPoints = component.DragPoints; + if (owner == null || dragPoints.Length == 0) { + return; + } + + RecordUndo(component, $"Center pivot point of {owner.SplineOwner.name}"); + var center = dragPoints.Aggregate(Vertex3D.Zero, + (current, dragPoint) => current + dragPoint.Center) / dragPoints.Length; + var centerUnity = center.ToUnityVector3(); + var ownerTransform = owner.SplineTransform; + ownerTransform.Translate(centerUnity.TranslateToWorld(ownerTransform) + - ownerTransform.position); + foreach (var dragPoint in dragPoints) { + dragPoint.Center -= center; + } + Apply(component, dragPoints); + } + + internal static DragPointSplineExposure GetExposure(DragPointSplineComponent component) + { + switch (component.Owner) { + case RubberComponent _: return DragPointSplineExposure.Smooth; + case MetalWireGuideComponent _: return DragPointSplineExposure.Smooth; + case TriggerComponent _: return DragPointSplineExposure.Smooth + | DragPointSplineExposure.Slingshot; + case RampComponent _: return DragPointSplineExposure.Smooth + | DragPointSplineExposure.Slingshot; + case SurfaceComponent _: return DragPointSplineExposure.Smooth + | DragPointSplineExposure.Slingshot | DragPointSplineExposure.Texture; + case LightInsertMeshComponent _: return DragPointSplineExposure.Smooth + | DragPointSplineExposure.Texture; + default: return DragPointSplineExposure.None; + } + } + + private static bool IsEditable(DragPointSplineComponent component) + { + if (component.Owner is TriggerComponent trigger) { + var mesh = trigger.GetComponent(); + return !mesh || !mesh.IsCircle; + } + return component.Owner != null; + } + + private static void ReverseInPlace(DragPointData[] dragPoints) + { + Array.Reverse(dragPoints, 1, dragPoints.Length - 1); + var slingshots = dragPoints.Select(dragPoint => dragPoint.IsSlingshot).ToArray(); + for (var i = 0; i < dragPoints.Length; i++) { + dragPoints[i].IsSlingshot = slingshots[(i + 1) % slingshots.Length]; + } + } + + private static void Apply(DragPointSplineComponent component, + DragPointData[] dragPoints) + { + component.SetDragPoints(dragPoints); + component.Owner?.RebuildSplineMeshes(); + EditorUtility.SetDirty(component); + PrefabUtility.RecordPrefabInstancePropertyModifications(component); + SceneView.RepaintAll(); + } + + private static void RecordUndo(DragPointSplineComponent component, string name) + { + var owner = component.Owner; + var objects = new List { component, component.Container }; + if (owner != null) { + objects.Add(owner.SplineOwner); + objects.Add(owner.SplineTransform); + } + Undo.RecordObjects(objects.Distinct().ToArray(), name); + } + } + + [CustomEditor(typeof(DragPointSplineComponent))] + public class DragPointSplineComponentInspector : UnityEditor.Editor + { + private readonly List _selectedKnots = new(); + + public override void OnInspectorGUI() + { + var component = (DragPointSplineComponent)target; + var owner = component.Owner; + if (owner == null) { + EditorGUILayout.HelpBox("This spline has no drag-point owner.", MessageType.Warning); + return; + } + + EditorGUILayout.LabelField("Owner", owner.SplineOwner.name); + if (GUILayout.Button("Select Owner")) { + Selection.activeObject = owner.SplineOwner; + } + DragPointSplineInspectorGUI.OnInspectorGUI(component, false); + DrawSelectedMetadata(component); + } + + private void DrawSelectedMetadata(DragPointSplineComponent component) + { + _selectedKnots.Clear(); + SplineSelection.GetElements(new SplineInfo(component.Container, 0), _selectedKnots); + EditorGUILayout.Space(10f); + EditorGUILayout.LabelField("Selected Knots", EditorStyles.boldLabel); + if (_selectedKnots.Count == 0) { + EditorGUILayout.HelpBox("Select one or more knots in the Scene view.", + MessageType.Info); + return; + } + + var exposure = DragPointSplineInspectorGUI.GetExposure(component); + if ((exposure & DragPointSplineExposure.Smooth) != 0) { + DrawBoolean(component, "Smooth", metadata => metadata.IsSmooth, + (metadata, value) => metadata.IsSmooth = value); + } + if ((exposure & DragPointSplineExposure.Slingshot) != 0) { + DrawBoolean(component, "Slingshot", metadata => metadata.IsSlingshot, + (metadata, value) => metadata.IsSlingshot = value); + } + if ((exposure & DragPointSplineExposure.Texture) != 0) { + DrawBoolean(component, "Auto Texture", metadata => metadata.HasAutoTexture, + (metadata, value) => metadata.HasAutoTexture = value); + DrawFloat(component, "Texture Coordinate", metadata => metadata.TextureCoord, + (metadata, value) => metadata.TextureCoord = value); + } + } + + private void DrawBoolean(DragPointSplineComponent component, string label, + Func getter, Action setter) + { + var first = getter(component.Metadata[_selectedKnots[0].KnotIndex]); + EditorGUI.showMixedValue = _selectedKnots.Any(knot => + getter(component.Metadata[knot.KnotIndex]) != first); + EditorGUI.BeginChangeCheck(); + var value = EditorGUILayout.Toggle(label, first); + EditorGUI.showMixedValue = false; + if (EditorGUI.EndChangeCheck()) { + UpdateMetadata(component, label, metadata => setter(metadata, value)); + } + } + + private void DrawFloat(DragPointSplineComponent component, string label, + Func getter, Action setter) + { + var first = getter(component.Metadata[_selectedKnots[0].KnotIndex]); + EditorGUI.showMixedValue = _selectedKnots.Any(knot => + math.abs(getter(component.Metadata[knot.KnotIndex]) - first) > 1e-6f); + EditorGUI.BeginChangeCheck(); + var value = EditorGUILayout.FloatField(label, first); + EditorGUI.showMixedValue = false; + if (EditorGUI.EndChangeCheck()) { + UpdateMetadata(component, label, metadata => setter(metadata, value)); + } + } + + private void UpdateMetadata(DragPointSplineComponent component, string label, + Action update) + { + Undo.RecordObjects(new UnityEngine.Object[] { component, component.Container }, + $"Change Drag Point {label}"); + foreach (var knot in _selectedKnots) { + update(component.Metadata[knot.KnotIndex]); + } + component.NotifyMetadataChanged(); + EditorUtility.SetDirty(component); + PrefabUtility.RecordPrefabInstancePropertyModifications(component); + } + } + + [InitializeOnLoad] + internal static class DragPointSplineSceneView + { + private const int SegmentResolution = 24; + + static DragPointSplineSceneView() + { + SceneView.duringSceneGui += OnSceneGUI; + } + + private static void OnSceneGUI(SceneView _) + { + var component = Selection.activeGameObject + ? Selection.activeGameObject.GetComponent() + : null; + if (!component || !component.Container || component.Container.Spline.Count == 0) { + return; + } + + var spline = component.Container.Spline; + var matrix = component.Container.transform.localToWorldMatrix; + var segmentCount = spline.Closed ? spline.Count : spline.Count - 1; + for (var segment = 0; segment < segmentCount; segment++) { + var points = new Vector3[SegmentResolution + 1]; + var curve = spline.GetCurve(segment); + for (var sample = 0; sample <= SegmentResolution; sample++) { + var point = CurveUtility.EvaluatePosition(curve, + sample / (float)SegmentResolution); + points[sample] = matrix.MultiplyPoint3x4(new Vector3(point.x, point.y, point.z)); + } + Handles.color = component.Metadata[segment].IsSlingshot + ? new UnityEngine.Color(1f, 0.15f, 0.1f, 0.9f) + : new UnityEngine.Color(0.1f, 0.45f, 1f, 0.65f); + Handles.DrawAAPolyLine(4f, points); + } + + var style = new GUIStyle(EditorStyles.boldLabel) { + normal = { textColor = UnityEngine.Color.white }, + alignment = TextAnchor.MiddleCenter, + }; + for (var i = 0; i < spline.Count; i++) { + var position = spline[i].Position; + var world = matrix.MultiplyPoint3x4(new Vector3(position.x, position.y, position.z)); + Handles.Label(world, i.ToString(), style); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs.meta new file mode 100644 index 000000000..5bf40566f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 98c41ad0ab4ad1f42a99d79c4eacfe5e \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightInsertMeshInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightInsertMeshInspector.cs index 23b1857af..5f4e45461 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightInsertMeshInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Light/LightInsertMeshInspector.cs @@ -16,41 +16,25 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT.Light; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(LightInsertMeshComponent)), CanEditMultipleObjects] - public class LightInsertMeshInspector : MeshInspector, IDragPointsInspector + public class LightInsertMeshInspector : MeshInspector { - public Transform Transform => null; // not needed for lights, IDragpointInspector will probably be removed. - private SerializedProperty _insertHeightProperty; private SerializedProperty _positionZProperty; - public bool DragPointsActive => true; - protected override void OnEnable() { base.OnEnable(); - DragPointsHelper = new DragPointsInspectorHelper(MeshComponent.MainComponent, this); - DragPointsHelper.OnEnable(); - _insertHeightProperty = serializedObject.FindProperty(nameof(LightInsertMeshComponent.InsertHeight)); _positionZProperty = serializedObject.FindProperty(nameof(LightInsertMeshComponent.PositionZ)); } - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -64,31 +48,12 @@ public override void OnInspectorGUI() PropertyField(_insertHeightProperty, rebuildMesh: true); PropertyField(_positionZProperty, updateTransforms: true); - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MeshComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public DragPointData[] DragPoints { get => MeshComponent.DragPoints; set => MeshComponent.DragPoints = value; } - - public bool PointsAreLooping => true; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth, DragPointExposure.Texture }; - public DragPointTransformType HandleType => DragPointTransformType.TwoD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => MeshComponent.PositionZ; - public float[] TopBottomZ => null; - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, - float[] topBottomZ) => dragPoint.Center = value; - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideInspector.cs index e565f534a..0328de430 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/MetalWireGuide/MetalWireGuideInspector.cs @@ -16,20 +16,14 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT.MetalWireGuide; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(MetalWireGuideComponent)), CanEditMultipleObjects] - public class MetalWireGuideInspector : MainInspector, IDragPointsInspector + public class MetalWireGuideInspector : MainInspector { - - public Transform Transform => MainComponent.transform; - private SerializedProperty _heightProperty; private SerializedProperty _thicknessProperty; private SerializedProperty _rotationProperty; @@ -41,9 +35,6 @@ protected override void OnEnable() base.OnEnable(); - DragPointsHelper = new DragPointsInspectorHelper(MainComponent, this); - DragPointsHelper.OnEnable(); - _heightProperty = serializedObject.FindProperty(nameof(MetalWireGuideComponent._height)); _thicknessProperty = serializedObject.FindProperty(nameof(MetalWireGuideComponent._thickness)); _rotationProperty = serializedObject.FindProperty(nameof(MetalWireGuideComponent.Rotation)); @@ -51,12 +42,6 @@ protected override void OnEnable() _standheightProperty = serializedObject.FindProperty(nameof(MetalWireGuideComponent._standheight)); } - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -73,31 +58,12 @@ public override void OnInspectorGUI() PropertyField(_thicknessProperty, rebuildMesh: true); PropertyField(_bendradiusProperty, rebuildMesh: true); - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public bool DragPointsActive => true; - public DragPointData[] DragPoints { get => MainComponent.DragPoints; set => MainComponent.DragPoints = value; } - public bool PointsAreLooping => false; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth }; - public DragPointTransformType HandleType => DragPointTransformType.TwoD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => 0f; - public float[] TopBottomZ => null; - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, - float[] topBottomZ) => dragPoint.Center = value; - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Ramp/RampInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Ramp/RampInspector.cs index fafa4313b..c8244b203 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Ramp/RampInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Ramp/RampInspector.cs @@ -16,20 +16,15 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Ramp; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(RampComponent)), CanEditMultipleObjects] - public class RampInspector : MainInspector, IDragPointsInspector + public class RampInspector : MainInspector { - public Transform Transform => MainComponent.transform; - private bool _foldoutGeometry = true; private static readonly string[] RampTypeLabels = { @@ -74,9 +69,6 @@ protected override void OnEnable() { base.OnEnable(); - DragPointsHelper = new DragPointsInspectorHelper(MainComponent, this); - DragPointsHelper.OnEnable(); - _heightBottomProperty = serializedObject.FindProperty(nameof(RampComponent._heightBottom)); _heightTopProperty = serializedObject.FindProperty(nameof(RampComponent._heightTop)); _imageAlignmentProperty = serializedObject.FindProperty(nameof(RampComponent._imageAlignment)); @@ -90,12 +82,6 @@ protected override void OnEnable() _wireDistanceYProperty = serializedObject.FindProperty(nameof(RampComponent._wireDistanceY)); } - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -137,50 +123,12 @@ public override void OnInspectorGUI() } EditorGUILayout.EndFoldoutHeaderGroup(); - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public bool DragPointsActive => true; - public DragPointData[] DragPoints { get => MainComponent.DragPoints; set => MainComponent.DragPoints = value; } - public bool PointsAreLooping => false; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth, DragPointExposure.SlingShot }; - public DragPointTransformType HandleType => DragPointTransformType.ThreeD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => 0f; - public float[] TopBottomZ => new[] { MainComponent._heightBottom, MainComponent._heightTop }; - - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, float[] topBottomZ) - { - var isFirst = MainComponent.DragPoints[0].Id == dragPoint.Id; - var isLast = MainComponent.DragPoints[^1].Id == dragPoint.Id; - var zDiff = value.Z - dragPoint.Center.Z; - - if (isFirst && numSelectedDragPoints == 1) { - MainComponent._heightBottom = topBottomZ[0] + zDiff; - dragPoint.Center.X = value.X; - dragPoint.Center.Y = value.Y; - - } else if (isLast && numSelectedDragPoints == 1) { - MainComponent._heightTop = topBottomZ[1] + zDiff; - dragPoint.Center.X = value.X; - dragPoint.Center.Y = value.Y; - - } else { - dragPoint.Center = value; - } - } - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs index 183a6d755..b7d75946a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs @@ -16,38 +16,24 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT.Rubber; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(RubberComponent)), CanEditMultipleObjects] - public class RubberInspector : MainInspector, IDragPointsInspector + public class RubberInspector : MainInspector { - - public Transform Transform => MainComponent.transform; - private SerializedProperty _thicknessProperty; protected override void OnEnable() { base.OnEnable(); - DragPointsHelper = new DragPointsInspectorHelper(MainComponent, this); - DragPointsHelper.OnEnable(); - _thicknessProperty = serializedObject.FindProperty(nameof(RubberComponent._thickness)); } - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -67,31 +53,12 @@ public override void OnInspectorGUI() } PropertyField(_thicknessProperty, rebuildMesh: true); - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public bool DragPointsActive => true; - public DragPointData[] DragPoints { get => MainComponent.DragPoints; set => MainComponent.DragPoints = value; } - public bool PointsAreLooping => true; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth }; - public DragPointTransformType HandleType => DragPointTransformType.TwoD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => 0; - public float[] TopBottomZ => null; - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, - float[] topBottomZ) => dragPoint.Center = value; - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Surface/SurfaceInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Surface/SurfaceInspector.cs index 81d0c4ae0..ed4fda8bc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Surface/SurfaceInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Surface/SurfaceInspector.cs @@ -16,42 +16,25 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT.Surface; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(SurfaceComponent)), CanEditMultipleObjects] - public class SurfaceInspector : MainInspector, IDragPointsInspector + public class SurfaceInspector : MainInspector { - - public Transform Transform => MainComponent.transform; - private SerializedProperty _heightTopProperty; private SerializedProperty _heightBottomProperty; - public bool DragPointsActive => true; - protected override void OnEnable() { base.OnEnable(); - DragPointsHelper = new DragPointsInspectorHelper(MainComponent, this); - DragPointsHelper.OnEnable(); - _heightTopProperty = serializedObject.FindProperty(nameof(SurfaceComponent.HeightTop)); _heightBottomProperty = serializedObject.FindProperty(nameof(SurfaceComponent.HeightBottom)); } - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -65,30 +48,12 @@ public override void OnInspectorGUI() PropertyField(_heightTopProperty, "Top Height", true); PropertyField(_heightBottomProperty, "Bottom Height", true); - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public DragPointData[] DragPoints { get => MainComponent.DragPoints; set => MainComponent.DragPoints = value; } - public bool PointsAreLooping => true; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth, DragPointExposure.SlingShot, DragPointExposure.Texture }; - public DragPointTransformType HandleType => DragPointTransformType.TwoD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => MainComponent.HeightTop; - public float[] TopBottomZ => null; - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, - float[] topBottomZ) => dragPoint.Center = value; - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trigger/TriggerInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trigger/TriggerInspector.cs index 682a3545a..d032ad469 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trigger/TriggerInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Trigger/TriggerInspector.cs @@ -16,33 +16,15 @@ // ReSharper disable AssignmentInConditionalExpression -using System.Collections.Generic; using UnityEditor; using UnityEngine; -using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT.Trigger; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(TriggerComponent)), CanEditMultipleObjects] - public class TriggerInspector : MainInspector, IDragPointsInspector + public class TriggerInspector : MainInspector { - public Transform Transform => MainComponent.transform; - - protected override void OnEnable() - { - base.OnEnable(); - - DragPointsHelper = new DragPointsInspectorHelper(MainComponent, this); - DragPointsHelper.OnEnable(); - } - - protected override void OnDisable() - { - base.OnDisable(); - DragPointsHelper.OnDisable(); - } - public override void OnInspectorGUI() { if (HasErrors()) { @@ -77,37 +59,12 @@ public override void OnInspectorGUI() MainComponent.Rotation = newRotation; } - DragPointsHelper.OnInspectorGUI(this); + DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); base.OnInspectorGUI(); EndEditing(); } - private void OnSceneGUI() - { - DragPointsHelper.OnSceneGUI(this); - } - - #region Dragpoint Tooling - - public bool DragPointsActive { - get { - var meshComp = MainComponent.GetComponent(); - return !meshComp || !meshComp.IsCircle; - } - } - - public DragPointData[] DragPoints { get => MainComponent.DragPoints; set => MainComponent.DragPoints = value; } - public bool PointsAreLooping => true; - public IEnumerable DragPointExposition => new[] { DragPointExposure.Smooth, DragPointExposure.SlingShot }; - public DragPointTransformType HandleType => DragPointTransformType.TwoD; - public DragPointsInspectorHelper DragPointsHelper { get; private set; } - public float ZOffset => MainComponent.Position.z; - public float[] TopBottomZ => null; - public void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints, - float[] topBottomZ) => dragPoint.Center = value; - - #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs index 56bd2bad6..3acdffc07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs @@ -19,6 +19,7 @@ using UnityEngine; using UnityEngine.Splines; using VisualPinball.Engine.Math; +using VisualPinball.Unity.Editor; namespace VisualPinball.Unity.Test { @@ -157,6 +158,62 @@ public void ShouldUpgradeTheTriggerDefaultArrayLazily() } } + [Test] + public void ShouldReverseAroundTheFirstKnotAndRotateSlingshots() + { + var go = new GameObject("Surface"); + try { + var surface = go.AddComponent(); + surface.DragPoints = CreateDragPoints(); + + DragPointSplineInspectorGUI.Reverse(surface.DragPointSpline); + + var dragPoints = surface.DragPoints; + Assert.That(dragPoints, Has.Length.EqualTo(4)); + Assert.That(dragPoints[0].Id, Is.EqualTo("a")); + Assert.That(dragPoints[1].Id, Is.EqualTo("d")); + Assert.That(dragPoints[2].Id, Is.EqualTo("c")); + Assert.That(dragPoints[3].Id, Is.EqualTo("b")); + Assert.That(dragPoints[0].IsSlingshot, Is.True); + Assert.That(dragPoints[1].IsSlingshot, Is.False); + Assert.That(dragPoints[2].IsSlingshot, Is.True); + Assert.That(dragPoints[3].IsSlingshot, Is.False); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldCenterTheOriginWithoutMovingTheWorldCurve() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var before = GetWorldPositions(rubber.DragPointSpline.Container); + + DragPointSplineInspectorGUI.CenterOrigin(rubber.DragPointSpline); + + var after = GetWorldPositions(rubber.DragPointSpline.Container); + for (var i = 0; i < before.Length; i++) { + Assert.That(Vector3.Distance(after[i], before[i]), Is.LessThan(1e-5f)); + } + } + finally { + Object.DestroyImmediate(go); + } + } + + private static Vector3[] GetWorldPositions(SplineContainer container) + { + var positions = new Vector3[container.Spline.Count]; + for (var i = 0; i < positions.Length; i++) { + positions[i] = container.transform.TransformPoint(container.Spline[i].Position); + } + return positions; + } + private static DragPointData[] CreateDragPoints() { return new[] { From d3d3feaa756065c887b866489c65c8287d379a2a Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 22:21:37 +0200 Subject: [PATCH 5/7] splines(editor): remove legacy drag-point handles Delete the custom control-point selection, curve traveller, context menus, and scene-view renderer now that Unity Splines owns knot editing. Remove the old inspector contract and its drag-point-only handle utility as well. Keep the remaining flip-axis detail local to the spline inspector so the new editor path no longer references any part of the retired handle stack. --- .../DragPoint/ControlPoint.cs | 83 ---- .../DragPoint/ControlPoint.cs.meta | 11 - .../DragPoint/DragPointMenuItems.cs | 222 --------- .../DragPoint/DragPointMenuItems.cs.meta | 11 - .../DragPoint/DragPointSplineInspector.cs | 22 +- .../DragPoint/DragPointsHandler.cs | 424 ------------------ .../DragPoint/DragPointsHandler.cs.meta | 11 - .../DragPoint/DragPointsInspectorHelper.cs | 315 ------------- .../DragPointsInspectorHelper.cs.meta | 11 - .../DragPoint/DragPointsSceneViewHandler.cs | 259 ----------- .../DragPointsSceneViewHandler.cs.meta | 11 - .../DragPoint/FlipAxis.cs | 25 -- .../DragPoint/FlipAxis.cs.meta | 11 - .../DragPoint/IDragPointsInspector.cs | 101 ----- .../DragPoint/IDragPointsInspector.cs.meta | 11 - .../Utils/HandlesUtils.cs | 73 --- .../Utils/HandlesUtils.cs.meta | 11 - 17 files changed, 15 insertions(+), 1597 deletions(-) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs deleted file mode 100644 index da4ca312e..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs +++ /dev/null @@ -1,83 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; - -namespace VisualPinball.Unity.Editor -{ - /// - /// An editable drag point in Unity's editor.

- /// - /// The inspector manages adding/removing control points and updates the - /// data of IDragPointEditable. - /// - /// ControlPoint also manages the ControlId used by Unity's Handles system - /// and the points that make the path in the scene view. - ///

- public class ControlPoint - { - public const float ScreenRadius = 0.25f; - - /// - /// Reference to the drag point data - /// - public DragPointData DragPoint; - - /// - /// Position in VPX space - /// - public Vector3 AbsolutePosition => new(DragPoint.Center.X, DragPoint.Center.Y, DragPoint.CalcHeight); - - /// - /// Position in world space - /// - public Vector3 EditorPositionWorld => AbsolutePosition.TranslateToWorld(_dragPointsInspector.Transform); - - public float HandleSize => HandleUtility.GetHandleSize(EditorPositionWorld) * ScreenRadius; - - /// - /// Currently selected or not? - /// - public bool IsSelected = false; - - /// - /// Unity's ControlID. - /// - public readonly int ControlId; - - /// - /// The DragPoint ID that is saved within the drag point. - /// - public string DragPointId => DragPoint.Id; - - /// - /// Index of the drag point within the game item's drag point array. - /// - public readonly int Index; - - private readonly IDragPointsInspector _dragPointsInspector; - - public ControlPoint(IDragPointsInspector dragPointsInspector, int controlId, int idx) - { - DragPoint = dragPointsInspector.DragPoints[idx]; - ControlId = controlId; - Index = idx; - _dragPointsInspector = dragPointsInspector; - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs.meta deleted file mode 100644 index a4ae1c2a1..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/ControlPoint.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 340f73844a6b43c4195e63ef808326af -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs deleted file mode 100644 index d2c9b16b0..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using UnityEditor; -using VisualPinball.Engine.Math; - -namespace VisualPinball.Unity.Editor -{ - public static class DragPointMenuItems - { - public const string ControlPointsMenuPath = "CONTEXT/DragPointsItemInspector/ControlPoint"; - public const string CurveTravellerMenuPath = "CONTEXT/DragPointsItemInspector/CurveTraveller"; - - private static DragPointData RetrieveDragPoint(IDragPointsInspector inspector, int controlId) - { - return inspector?.DragPointsHelper.GetDragPoint(controlId); - } - - // Drag Points - [MenuItem(ControlPointsMenuPath + "/Is Slingshot", false, 1)] - private static void SlingShot(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - var dragPoint = RetrieveDragPoint(inspector, command.userData); - if (dragPoint != null) { - inspector.DragPointsHelper.PrepareUndo("Toggle Drag Point Slingshot"); - dragPoint.IsSlingshot = !dragPoint.IsSlingshot; - inspector.DragPointsHelper.RebuildMeshes(); - } - } - - [MenuItem(ControlPointsMenuPath + "/Is Slingshot", true)] - private static bool SlingshotValidate(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector) || inspector.DragPointsHelper.IsItemLocked()) { - return false; - } - - if (!inspector.DragPointsHelper.HasDragPointExposure(DragPointExposure.SlingShot)) { - Menu.SetChecked($"{ControlPointsMenuPath}/IsSlingshot", false); - return false; - } - - var dragPoint = RetrieveDragPoint(inspector, command.userData); - if (dragPoint != null) { - Menu.SetChecked($"{ControlPointsMenuPath}/IsSlingshot", dragPoint.IsSlingshot); - } - - return true; - } - - [MenuItem(ControlPointsMenuPath + "/Is Smooth", false, 1)] - private static void Smooth(MenuCommand command) - { - var inspector = command.context as IDragPointsInspector; - if (inspector == null) { - return; - } - - var dragPoint = RetrieveDragPoint(inspector, command.userData); - if (dragPoint != null) { - inspector.DragPointsHelper.PrepareUndo("Toggle Drag Point Smooth"); - dragPoint.IsSmooth = !dragPoint.IsSmooth; - inspector.DragPointsHelper.RebuildMeshes(); - } - } - - [MenuItem(ControlPointsMenuPath + "/Is Smooth", true)] - private static bool SmoothValidate(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector) || inspector.DragPointsHelper.IsItemLocked()) { - return false; - } - - if (!inspector.DragPointsHelper.HasDragPointExposure(DragPointExposure.Smooth)) { - Menu.SetChecked($"{ControlPointsMenuPath}/IsSmooth", false); - return false; - } - - var dragPoint = RetrieveDragPoint(inspector, command.userData); - if (dragPoint != null) { - Menu.SetChecked($"{ControlPointsMenuPath}/IsSmooth", dragPoint.IsSmooth); - } - - return true; - } - - [MenuItem(ControlPointsMenuPath + "/Remove Point", false, 101)] - private static void Remove(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - if (EditorUtility.DisplayDialog("Drag point removal", "Are you sure you want to remove this drag point?", "Yes", "No")) { - inspector.DragPointsHelper.RemoveDragPoint(command.userData); - } - } - - [MenuItem(ControlPointsMenuPath + "/Remove Point", true)] - private static bool RemoveValidate(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector) || inspector.DragPointsHelper.IsItemLocked()) { - return false; - } - - if (inspector.DragPointsHelper.DragPointsHandler?.ControlPoints.Count <= 2) { - Menu.SetChecked($"{ControlPointsMenuPath}/Remove Point", false); - return false; - } - - return true; - } - - [MenuItem(ControlPointsMenuPath + "/Copy Point", false, 301)] - private static void Copy(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.CopyDragPoint(command.userData); - } - - [MenuItem(ControlPointsMenuPath + "/Paste Point", false, 302)] - private static void Paste(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.PasteDragPoint(command.userData); - } - - [MenuItem(ControlPointsMenuPath + "/Paste Point", true)] - private static bool PasteValidate(MenuCommand command) - { - return command.context is IDragPointsInspector inspector && !inspector.DragPointsHelper.IsItemLocked(); - } - - //Curve Traveller - [MenuItem(CurveTravellerMenuPath + "/Add Point", false, 1)] - private static void Add(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.AddDragPointOnTraveller(); - } - - [MenuItem(CurveTravellerMenuPath + "/Flip Drag Points/X", false, 101)] - [MenuItem(ControlPointsMenuPath + "/Flip Drag Points/X", false, 201)] - private static void FlipX(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.FlipDragPoints(FlipAxis.X); - } - - [MenuItem(CurveTravellerMenuPath + "/Flip Drag Points/Y", false, 102)] - [MenuItem(ControlPointsMenuPath + "/Flip Drag Points/Y", false, 202)] - private static void FlipY(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.FlipDragPoints(FlipAxis.Y); - } - - [MenuItem(CurveTravellerMenuPath + "/Flip Drag Points/Z", false, 103)] - [MenuItem(ControlPointsMenuPath + "/Flip Drag Points/Z", false, 203)] - private static void FlipZ(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.FlipDragPoints(FlipAxis.Z); - } - - [MenuItem(CurveTravellerMenuPath + "/Flip Drag Points/Z", true, 103)] - [MenuItem(ControlPointsMenuPath + "/Flip Drag Points/Z", true, 203)] - private static bool FlipZValidate(MenuCommand command) - { - if (command.context is IDragPointsInspector inspector) { - return inspector.HandleType == DragPointTransformType.ThreeD; - } - return false; - } - - [MenuItem(CurveTravellerMenuPath + "/Reverse", false, 501)] - [MenuItem(ControlPointsMenuPath + "/Reverse", false, 601)] - private static void Reverse(MenuCommand command) - { - if (!(command.context is IDragPointsInspector inspector)) { - return; - } - - inspector.DragPointsHelper.Reverse(); - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs.meta deleted file mode 100644 index ed844626e..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointMenuItems.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8533dafc1a83b294aa17ef1831cbdfd1 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs index 41868eaa9..9e6c6cf93 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs @@ -36,6 +36,13 @@ internal enum DragPointSplineExposure Texture = 1 << 2, } + internal enum DragPointSplineAxis + { + X, + Y, + Z, + } + public static class DragPointSplineInspectorGUI { public static void OnInspectorGUI(DragPointSplineComponent component, @@ -62,14 +69,14 @@ public static void OnInspectorGUI(DragPointSplineComponent component, EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Flip X")) { - Flip(component, FlipAxis.X); + Flip(component, DragPointSplineAxis.X); } if (GUILayout.Button("Flip Y")) { - Flip(component, FlipAxis.Y); + Flip(component, DragPointSplineAxis.Y); } using (new EditorGUI.DisabledScope(component.Owner?.SplinePlanar ?? true)) { if (GUILayout.Button("Flip Z")) { - Flip(component, FlipAxis.Z); + Flip(component, DragPointSplineAxis.Z); } } EditorGUILayout.EndHorizontal(); @@ -83,7 +90,8 @@ public static void EditSpline(DragPointSplineComponent component) SceneView.RepaintAll(); } - public static void Flip(DragPointSplineComponent component, FlipAxis axis) + private static void Flip(DragPointSplineComponent component, + DragPointSplineAxis axis) { var dragPoints = component.DragPoints; if (dragPoints.Length == 0) { @@ -95,13 +103,13 @@ public static void Flip(DragPointSplineComponent component, FlipAxis axis) (current, dragPoint) => current + dragPoint.Center) / dragPoints.Length; foreach (var dragPoint in dragPoints) { switch (axis) { - case FlipAxis.X: + case DragPointSplineAxis.X: dragPoint.Center.X = center.X + center.X - dragPoint.Center.X; break; - case FlipAxis.Y: + case DragPointSplineAxis.Y: dragPoint.Center.Y = center.Y + center.Y - dragPoint.Center.Y; break; - case FlipAxis.Z: + case DragPointSplineAxis.Z: dragPoint.Center.Z *= -1f; break; default: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs deleted file mode 100644 index 767c4b72b..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs +++ /dev/null @@ -1,424 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System; -using System.Collections.Generic; -using System.Linq; -using UnityEditor; -using UnityEngine; -using VisualPinball.Engine.Math; -using Color = UnityEngine.Color; -using Object = UnityEngine.Object; - -namespace VisualPinball.Unity.Editor -{ - public delegate void DragPointPositionChange(); - - public class DragPointsHandler - { - /// - /// Component - /// - public IMainRenderableComponent MainComponent { get; } - - /// - /// Component item as IDragPointsEditable - /// - public IDragPointsInspector DragPointInspector { get; } - - /// - /// Transform component of the game object - /// - public Transform Transform { get; } - - /// - /// Control points storing & rendering - /// - public List ControlPoints { get; } = new(); - - /// - /// Scene view handler - /// - /// - /// - /// Will handle all the rendering part and update some handler's variables about curve traveller - /// - private readonly DragPointsSceneViewHandler _sceneViewHandler; - - /// - /// Drag points selection - /// - public List SelectedControlPoints { get; } = new(); - - /// - /// Position of the tool handle. On the drag point when only one selected, otherwise in the geometric center of the selection. - /// - /// VPX space. - /// - private Vector3 _centerSelected = Vector3.zero; - - /// - /// Curve traveller handling - /// - /// - /// - /// CurveTravellerPosition, CurveTravellerControlPointIdx & CurveTravellerVisible will be updated by the DragPointsSceneViewHandler - /// - public int CurveTravellerControlId { get; private set; } - - /// - /// Traveller position in world space. - /// - public Vector3 CurveTravellerPosition { get; set; } = Vector3.zero; - - public bool CurveTravellerVisible { get; set; } - public int CurveTravellerControlPointIdx { get; set; } = -1; - - /// - /// The center of all drag points, in VPX space. - /// - private Vector3 _center = Vector3.zero; - - private Vector3 _startPos; - private readonly Dictionary _startPosZ = new(); - private float[] _startTopBottomZ; - - /// - /// Every DragPointsInspector instantiates this to manage its curve handling. - /// - /// The renderable main component, to retrieve IsLocked. - /// - /// - public DragPointsHandler(IMainRenderableComponent mainComponent, IDragPointsInspector dragPointsInspector) - { - MainComponent = mainComponent; - DragPointInspector = dragPointsInspector; - - Transform = mainComponent.gameObject.transform; - - _sceneViewHandler = new DragPointsSceneViewHandler(this){ - CurveWidth = 10.0f, - CurveColor = Color.blue, - CurveSlingShotColor = Color.red, - CurveTravellerSizeRatio = 0.75f - }; - } - - /// - /// References drag point data to control points. - /// - /// True if control points were re-built, false otherwise. - public bool RemapControlPoints() - { - // if count differs, rebuild - if (ControlPoints.Count != DragPointInspector.DragPoints.Length) { - RebuildControlPoints(); - return true; - } - - for (var i = 0; i < DragPointInspector.DragPoints.Length; ++i) { - ControlPoints[i].DragPoint = DragPointInspector.DragPoints[i]; - } - - return false; - } - - public DragPointData GetDragPoint(int controlId) => GetControlPoint(controlId)?.DragPoint; - public ControlPoint GetControlPoint(string dragPointId) => ControlPoints.Find(cp => cp.DragPointId == dragPointId); - private ControlPoint GetControlPoint(int controlId) => ControlPoints.Find(cp => cp.ControlId == controlId); - - /// - /// Adds a new control point to the scene view and its drag point data - /// to the game object. - /// - public void AddDragPointOnTraveller() - { - if (CurveTravellerControlPointIdx < 0 || CurveTravellerControlPointIdx >= ControlPoints.Count) { - return; - } - - var dragPoint = new DragPointData(DragPointInspector.DragPoints[CurveTravellerControlPointIdx]); - var newIdx = CurveTravellerControlPointIdx + 1; - var dragPointPosition = CurveTravellerPosition.TranslateToVpx(Transform); - dragPointPosition.z = 0; - dragPoint.Center = dragPointPosition.ToVertex3D(); - var dragPoints = DragPointInspector.DragPoints.ToList(); - dragPoints.Insert(newIdx, dragPoint); - DragPointInspector.DragPoints = dragPoints.ToArray(); - - ControlPoints.Insert(newIdx, - new ControlPoint( - DragPointInspector, - GUIUtility.GetControlID(FocusType.Passive), - newIdx - )); - RebuildControlPoints(); - } - - /// - /// Removes a control point and its data. - /// - /// - public void RemoveDragPoint(int controlId) - { - var idx = ControlPoints.FindIndex(controlPoint => controlPoint.ControlId == controlId); - if (idx < 0) { - return; - } - var dragPoints = DragPointInspector.DragPoints.ToList(); - dragPoints.RemoveAt(idx); - DragPointInspector.DragPoints = dragPoints.ToArray(); - - RebuildControlPoints(); - } - - /// - /// Flips all drag points around the given axis. - /// - /// Axis to flip - public void FlipDragPoints(FlipAxis flipAxis) - { - foreach (var controlPoint in ControlPoints) { - switch (flipAxis) { - case FlipAxis.X: - controlPoint.DragPoint.Center.X = _center.x + (_center.x - controlPoint.DragPoint.Center.X); - break; - case FlipAxis.Y: - controlPoint.DragPoint.Center.Y = _center.y + (_center.y - controlPoint.DragPoint.Center.Y); - break; - case FlipAxis.Z: - controlPoint.DragPoint.Center.Z *= -1; - break; - default: - throw new ArgumentOutOfRangeException(nameof(flipAxis), flipAxis, null); - } - } - } - - public void ReverseDragPoints() - { - var dragPoints = DragPointInspector.DragPoints.ToList(); - dragPoints.Reverse(1, dragPoints.Count - 1); - - // rotate slingshot props - var isSling = dragPoints.Select(dp => dp.IsSlingshot).ToArray(); - isSling = isSling.Skip(1).Concat(isSling.Take(1)).ToArray(); - for (var i = 0; i < dragPoints.Count; i++) { - dragPoints[i].IsSlingshot = isSling[i]; - } - DragPointInspector.DragPoints = dragPoints.ToArray(); - RebuildControlPoints(); - } - - public void CenterPivot() - { - var transform = Transform; - var centerVpx = Vector3.zero; - foreach (var dragPoint in DragPointInspector.DragPoints) { - centerVpx += dragPoint.Center.ToUnityVector3(); - } - centerVpx /= DragPointInspector.DragPoints.Length; - - Undo.RecordObjects(new[] { MainComponent as Object, transform }, $"Center pivot point of {MainComponent.name}"); - transform.Translate(centerVpx.TranslateToWorld(transform) - transform.position); - foreach (var dragPoint in DragPointInspector.DragPoints) { - dragPoint.Center -= centerVpx.ToVertex3D(); - } - DragPointInspector.DragPoints = DragPointInspector.DragPoints; - - MainComponent.RebuildMeshes(); - } - - /// - /// Updates the lock status on all drag points to the given value. - /// - /// New lock status - /// True if at least one lock status changed, false otherwise. - // public bool UpdateDragPointsLock(bool itemLock) - // { - // var lockChanged = false; - // foreach (var controlPoint in ControlPoints) { - // if (controlPoint.DragPoint.IsLocked != itemLock) { - // controlPoint.DragPoint.IsLocked = itemLock; - // lockChanged = true; - // } - // } - // return lockChanged; - // } - - /// - /// Re-creates the control points of the scene view and references their - /// drag point data. - /// - private void RebuildControlPoints() - { - ControlPoints.Clear(); - var dragPoints = DragPointInspector.DragPoints; - for (var i = 0; i < dragPoints.Length; ++i) { - dragPoints[i].AssertId(); - var cp = new ControlPoint( - DragPointInspector, - GUIUtility.GetControlID(FocusType.Passive), - i - ); - ControlPoints.Add(cp); - } - CurveTravellerControlId = GUIUtility.GetControlID(FocusType.Passive); - - // persist prefab changes - EditorUtility.SetDirty(MainComponent.gameObject); - PrefabUtility.RecordPrefabInstancePropertyModifications(MainComponent as Object); - } - - /// - /// Un-selects all control points. - /// - private void ClearAllSelection() - { - foreach (var controlPoint in ControlPoints) { - controlPoint.IsSelected = false; - } - } - - /// - /// Takes care of the rendering-related stuff. - /// - /// - /// - /// This is called by the drag point inspector. - /// - /// - /// Event from the inspector - /// - public void OnSceneGUI(Event evt, DragPointPositionChange onChange = null) - { - switch (evt.type) { - case EventType.Layout: - OnSceneLayout(); - break; - - case EventType.MouseDown: - OnMouseDown(); - break; - - case EventType.Repaint: - CurveTravellerVisible = false; - break; - } - - if (SelectedControlPoints.Count > 0) { - - // set start positions since clicked - if (evt.type == EventType.MouseDown) { - _startPos = _centerSelected; - _startTopBottomZ = DragPointInspector.TopBottomZ; - _startPosZ.Clear(); - foreach (var cp in SelectedControlPoints) { - _startPosZ[cp.DragPointId] = cp.DragPoint.Center.Z; - } - } - - // get new pos since last frame - EditorGUI.BeginChangeCheck(); - var newHandlePos = HandlesUtils.HandlePosition( - _centerSelected, - Transform.localToWorldMatrix, - DragPointInspector.HandleType - ); - if (EditorGUI.EndChangeCheck()) { - var delta = newHandlePos - _centerSelected; - var deltaZ = newHandlePos.z - _startPos.z; - - Undo.RecordObject(MainComponent as MonoBehaviour, "move Drag Points"); - foreach (var controlPoint in SelectedControlPoints) { - DragPointInspector.SetDragPointPosition(controlPoint.DragPoint, new Vertex3D( - controlPoint.DragPoint.Center.X + delta.x, - controlPoint.DragPoint.Center.Y + delta.y, - _startPosZ[controlPoint.DragPointId] + deltaZ - ), SelectedControlPoints.Count, _startTopBottomZ); - } - onChange?.Invoke(); - } - } - - //Render the curve & drag points - _sceneViewHandler.OnSceneGUI(); - } - - private void OnSceneLayout() - { - SelectedControlPoints.Clear(); - _center = Vector3.zero; - - Handles.matrix = Matrix4x4.identity; - - //Setup Screen positions & controlID for control points (in case of modification of drag points coordinates outside) - foreach (var controlPoint in ControlPoints) { - _center += controlPoint.AbsolutePosition; - if (controlPoint.IsSelected) { - SelectedControlPoints.Add(controlPoint); - } - - HandleUtility.AddControl( - controlPoint.ControlId, - HandleUtility.DistanceToCircle( - controlPoint.EditorPositionWorld, - controlPoint.HandleSize - ) - ); - } - - if (ControlPoints.Count > 0) { - _center /= ControlPoints.Count; - } - - //Setup PositionHandle if some control points are selected - if (SelectedControlPoints.Count > 0) { - _centerSelected = Vector3.zero; - foreach (var controlPoint in SelectedControlPoints) { - _centerSelected += controlPoint.AbsolutePosition; - } - _centerSelected /= SelectedControlPoints.Count; - } - - if (CurveTravellerVisible) { - HandleUtility.AddControl( - CurveTravellerControlId, - HandleUtility.DistanceToCircle( - CurveTravellerPosition, - HandleUtility.GetHandleSize(CurveTravellerPosition) * ControlPoint.ScreenRadius * _sceneViewHandler.CurveTravellerSizeRatio * 0.5f)); - } - } - - private void OnMouseDown() - { - if (Event.current.button == 0) { - var nearestControlPoint = ControlPoints.Find(cp => cp.ControlId == HandleUtility.nearestControl); - if (nearestControlPoint != null) { - if (!Event.current.control) { - ClearAllSelection(); - nearestControlPoint.IsSelected = true; - } - else { - nearestControlPoint.IsSelected = !nearestControlPoint.IsSelected; - } - - Event.current.Use(); - } - } - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs.meta deleted file mode 100644 index c777e1b65..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 957cc5809e8ab9b4e9c24df01c6c68c3 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs deleted file mode 100644 index 4d25f984b..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs +++ /dev/null @@ -1,315 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System.Linq; -using UnityEditor; -using UnityEngine; -using UnityEngine.Assertions; -using VisualPinball.Engine.Math; - -namespace VisualPinball.Unity.Editor -{ - public class DragPointsInspectorHelper - { - /// - /// Catmull Curve Handler - /// - public DragPointsHandler DragPointsHandler { get; private set; } - - private bool EditingEnabled => _dragPointsInspector.DragPointsActive; - - /// - /// If true, a list of the drag points is displayed in the inspector. - /// - private bool _foldoutControlPoints; - - /// - /// stored vector during the copy/paste process - /// - /// - /// - /// It is globally stored so it can be copy/pasted on different items - /// - private static Vector3 _storedControlPoint = Vector3.zero; - - private readonly MonoBehaviour _mb; - private readonly IMainRenderableComponent _mainComponent; - private readonly IDragPointsInspector _dragPointsInspector; - private readonly PlayfieldComponent _playfieldComponent; - - public DragPointsInspectorHelper(IMainRenderableComponent mainComponent, IDragPointsInspector dragPointsInspector) - { - Assert.IsNotNull(mainComponent); - _mb = mainComponent as MonoBehaviour; - _mainComponent = mainComponent; - _dragPointsInspector = dragPointsInspector; - _playfieldComponent = mainComponent.gameObject.GetComponentInParent(); - DragPointsHandler = new DragPointsHandler(mainComponent, _dragPointsInspector); - } - - public void RebuildMeshes() - { - _dragPointsInspector.DragPoints = _dragPointsInspector.DragPoints; - _mainComponent.RebuildMeshes(); - } - - public void OnEnable() - { - Undo.undoRedoPerformed += OnUndoRedoPerformed; - } - - public void OnDisable() - { - DragPointsHandler = null; - Undo.undoRedoPerformed -= OnUndoRedoPerformed; - } - - /// - /// Returns a reference to the drag point data for a given control ID. - /// - /// Control ID of the drag point - /// Drag point data or null if no linked data. - public DragPointData GetDragPoint(int controlId) - { - return DragPointsHandler?.GetDragPoint(controlId); - } - - /// - /// Copies the position of a drag point. - /// - /// Control ID of the drag point - public void CopyDragPoint(int controlId) - { - var dp = GetDragPoint(controlId); - if (dp != null) { - _storedControlPoint = dp.Center.ToUnityVector3(); - } - } - - /// - /// Sets the position of a previously copied drag point to another drag point. - /// - /// Control ID of the drag point to which the new position is applied. - public void PasteDragPoint(int controlId) - { - var dp = GetDragPoint(controlId); - if (dp != null) { - PrepareUndo($"Paste Drag Point {controlId}"); - dp.Center = _storedControlPoint.ToVertex3D(); - RebuildMeshes(); - } - } - - /// - /// Returns true if the game item is locked. - /// - /// True if game item is locked, false otherwise. - public bool IsItemLocked() - { - // todo delete or implement properly - return false; - } - - /// - /// Returns whether this game item has a given drag point exposure. - /// - /// Exposure to check - /// True if exposed, false otherwise. - public bool HasDragPointExposure(DragPointExposure exposure) - { - return _dragPointsInspector.DragPointExposition.Contains(exposure); - } - - /// - /// Flips all drag points on a given axis. - /// - /// Axis to flip on - public void FlipDragPoints(FlipAxis flipAxis) - { - if (_dragPointsInspector.HandleType != DragPointTransformType.ThreeD && flipAxis == FlipAxis.Z) { - return; - } - - PrepareUndo($"Flip-{flipAxis} Drag Points"); - DragPointsHandler.FlipDragPoints(flipAxis); - if (_dragPointsInspector.PointsAreLooping) { - DragPointsHandler.ReverseDragPoints(); // keep counter-clockwise orientation - } - RebuildMeshes(); - } - - /// - /// Copies drag point data to the control points used in the editor. - /// - public void RemapControlPoints() - { - var rebuilt = DragPointsHandler.RemapControlPoints(); - if (rebuilt) { - RebuildMeshes(); - } - } - - /// - /// Adds a new drag point at the traveller's current position. - /// - public void AddDragPointOnTraveller() - { - PrepareUndo($"Add drag point at position {DragPointsHandler.CurveTravellerPosition}"); - DragPointsHandler.AddDragPointOnTraveller(); - RebuildMeshes(); - } - - /// - /// Removes a drag point of a given control ID. - /// - /// Control ID of the drag point to remove. - public void RemoveDragPoint(int controlId) - { - PrepareUndo("Remove Drag Point"); - DragPointsHandler.RemoveDragPoint(controlId); - RebuildMeshes(); - } - - public void Reverse() - { - PrepareUndo("Reverse Drag Points"); - DragPointsHandler.ReverseDragPoints(); - RebuildMeshes(); - } - - /// - /// Sets an UNDO point before the next operation. - /// - /// Message to appear in the UNDO menu - public void PrepareUndo(string message) - { - if (_mb is IDragPointSplineOwner owner) { - var spline = owner.SplineComponent; - Undo.RecordObjects(new Object[] { _mb, spline, spline.Container }, message); - } else { - Undo.RecordObject(_mb, message); - } - } - - public void OnInspectorGUI(ItemInspector inspector) - { - // todo remove or implement properly - // if (_mainComponent.IsLocked) { - // EditorGUILayout.LabelField("Drag Points are Locked"); - // return; - // } - - GUILayout.Space(10); - if (GUILayout.Button("Center Origin")) { - DragPointsHandler.CenterPivot(); - } - - _foldoutControlPoints = EditorGUILayout.BeginFoldoutHeaderGroup(_foldoutControlPoints, "Drag Points"); - if (_foldoutControlPoints) { - EditorGUI.indentLevel++; - for (var i = 0; i < DragPointsHandler.ControlPoints.Count; ++i) { - var controlPoint = DragPointsHandler.ControlPoints[i]; - EditorGUILayout.BeginHorizontal(); - EditorGUILayout.LabelField($"Drag Point #{i}"); - if (GUILayout.Button("Copy")) { - CopyDragPoint(controlPoint.ControlId); - } - else if (GUILayout.Button("Paste")) { - PasteDragPoint(controlPoint.ControlId); - } - EditorGUILayout.EndHorizontal(); - EditorGUI.indentLevel++; - EditorGUI.BeginChangeCheck(); - if (_dragPointsInspector.HandleType == DragPointTransformType.TwoD) { - var pos = EditorGUILayout.Vector2Field("Position", controlPoint.DragPoint.Center.ToUnityVector2()); - if (EditorGUI.EndChangeCheck()) { - controlPoint.DragPoint.Center.X = pos.x; - controlPoint.DragPoint.Center.Y = pos.y; - RebuildMeshes(); - } - } else { - var pos = EditorGUILayout.Vector3Field("Position", controlPoint.DragPoint.Center.ToUnityVector3()); - if (EditorGUI.EndChangeCheck()) { - controlPoint.DragPoint.Center = pos.ToVertex3D(); - RebuildMeshes(); - } - } - - if (HasDragPointExposure(DragPointExposure.SlingShot)) { - inspector.ItemDataField("Slingshot", ref controlPoint.DragPoint.IsSlingshot); - } - if (HasDragPointExposure(DragPointExposure.Smooth)) { - inspector.ItemDataField("Smooth", ref controlPoint.DragPoint.IsSmooth); - } - if (HasDragPointExposure(DragPointExposure.Texture)) { - inspector.ItemDataField("Has AutoTexture", ref controlPoint.DragPoint.HasAutoTexture); - inspector.ItemDataSlider("Texture Coord", ref controlPoint.DragPoint.TextureCoord, 0.0f, 1.0f); - } - EditorGUI.indentLevel--; - } - EditorGUI.indentLevel--; - } - EditorGUILayout.EndFoldoutHeaderGroup(); - } - - private void UpdateDragPointsLock() - { - // todo remove or implement properly - // if (DragPointsHandler.UpdateDragPointsLock(_mainComponent.IsLocked)) { - // HandleUtility.Repaint(); - // } - } - - private void OnDragPointPositionChange() - { - RebuildMeshes(); - PrepareUndo("Change Drag Point Position"); - } - - private void OnUndoRedoPerformed() - { - RemapControlPoints(); - } - - public void OnSceneGUI(ItemInspector inspector) - { - if (!EditingEnabled) { - return; - } - - RemapControlPoints(); - UpdateDragPointsLock(); - - DragPointsHandler.OnSceneGUI(Event.current, OnDragPointPositionChange); - - // right mouse button clicked? - Handles.matrix = Matrix4x4.identity; - if (Event.current.type == EventType.MouseDown && Event.current.button == 1) { - var nearestControlPoint = DragPointsHandler.ControlPoints.Find(cp => cp.ControlId == HandleUtility.nearestControl); - - if (nearestControlPoint != null) { - var command = new MenuCommand(inspector, nearestControlPoint.ControlId); - EditorUtility.DisplayPopupMenu(new Rect(Event.current.mousePosition.x, Event.current.mousePosition.y, 0, 0), DragPointMenuItems.ControlPointsMenuPath, command); - Event.current.Use(); - } else if (DragPointsHandler.CurveTravellerVisible && HandleUtility.nearestControl == DragPointsHandler.CurveTravellerControlId) { - var command = new MenuCommand(inspector, 0); - EditorUtility.DisplayPopupMenu(new Rect(Event.current.mousePosition.x, Event.current.mousePosition.y, 0, 0), DragPointMenuItems.CurveTravellerMenuPath, command); - Event.current.Use(); - } - } - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs.meta deleted file mode 100644 index f2b666b5f..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 8f2c609845b713246b88a786eaf768dc -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs deleted file mode 100644 index 8ba9fb8f7..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs +++ /dev/null @@ -1,259 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System.Collections.Generic; -using System.Linq; -using Unity.Mathematics; -using UnityEditor; -using UnityEngine; -using UnityEngine.Profiling; -using VisualPinball.Engine.Math; -using Color = UnityEngine.Color; - -namespace VisualPinball.Unity.Editor -{ - public class DragPointsSceneViewHandler - { - /// - /// Owning Handler - /// - private readonly DragPointsHandler _handler; - - /// - /// Curve points in world space - /// - private readonly List _pathPoints = new(); - - private bool _curveTravellerMoved = false; - - public float CurveWidth { get; set; } = 10.0f; - - public float CurveTravellerSizeRatio { get; set; } = 1.0f; - - public Color CurveColor { get; set; } = Color.blue; - - public Color CurveSlingShotColor { get; set; } = Color.red; - - private Transform Transform => _handler.MainComponent.gameObject.transform; - - public DragPointsSceneViewHandler(DragPointsHandler handler) - { - _handler = handler; - } - - public void OnSceneGUI() - { - if (_handler == null) { - return; - } - - DisplayCurve(); - DisplayControlPoints(); - } - - /// - /// Construct & display the curve from DragPointsHandler control points - /// Find the curve traveller position along the curve - /// - /// - /// - /// Will use the DragPointExposure from the handler's item to display slingshot segments accordingly - /// Will update handler's curve traveller position and control point base index for point insertion - /// - private void DisplayCurve() - { - Profiler.BeginSample("DisplayCurve"); - - // Display Curve & handle curve traveller - if (_handler.ControlPoints.Count > 1) { - - Profiler.BeginSample("Transform Points"); - var dragPointsVpx = new DragPointData[_handler.ControlPoints.Count]; - for (var i = 0; i < _handler.ControlPoints.Count; i++) { - var pos = _handler.ControlPoints[i].AbsolutePosition.ToVertex3D(); - dragPointsVpx[i] = new DragPointData(_handler.ControlPoints[i].DragPoint) { - Center = new Vertex3D(pos.X, pos.Y, pos.Z), - Id = _handler.ControlPoints[i].DragPoint.Id - }; - } - Profiler.EndSample(); - - Profiler.BeginSample("Create Curve Vertices"); - var vAccuracy = Vector3.one; - vAccuracy = _handler.Transform.localToWorldMatrix.MultiplyVector(vAccuracy); - var accuracy = Mathf.Abs(vAccuracy.x * vAccuracy.y * vAccuracy.z); - accuracy *= HandleUtility.GetHandleSize(_handler.CurveTravellerPosition) * ControlPoint.ScreenRadius; - var curveVerticesVpx = DragPoint.GetRgVertex( - dragPointsVpx, _handler.DragPointInspector.PointsAreLooping, accuracy - ); - Profiler.EndSample(); - - if (curveVerticesVpx.Length > 0) { - - var curveVerticesByDragPoint = new List[_handler.ControlPoints.Count].Select(_ => new List()).ToArray(); - - Profiler.BeginSample("Fill Control Points"); - // Fill Control points paths - ControlPoint currentControlPoint = null; - foreach (var curveVertex in curveVerticesVpx) { - if (curveVertex.IsControlPoint) { - if (currentControlPoint != null) { - curveVerticesByDragPoint[currentControlPoint.Index].Add(curveVertex.ToUnityVector3()); - } - - currentControlPoint = _handler.GetControlPoint(curveVertex.Id); - } - if (currentControlPoint != null) { - curveVerticesByDragPoint[currentControlPoint.Index].Add(curveVertex.ToUnityVector3()); - } - } - Profiler.EndSample(); - - // close loop if needed - Profiler.BeginSample("Close Loops"); - if (_handler.DragPointInspector.PointsAreLooping) { - curveVerticesByDragPoint[_handler.ControlPoints.Count - 1].Add(curveVerticesByDragPoint[0][0]); - } - Profiler.EndSample(); - - // construct full path - Profiler.BeginSample("Construct full path"); - _pathPoints.Clear(); - const float splitRatio = 0.1f; - foreach (var controlPoint in _handler.ControlPoints) { - // Split straight segments to avoid HandleUtility.ClosestPointToPolyLine issues - ref var segments = ref curveVerticesByDragPoint[controlPoint.Index]; - if (segments.Count == 2) { - var dir = segments[1] - segments[0]; - var dist = dir.magnitude; - dir = Vector3.Normalize(dir); - var newPath = new List { - segments[0] - }; - for (var splitDist = dist * splitRatio; splitDist < dist; splitDist += dist * splitRatio) { - newPath.Add(newPath[0] + dir * splitDist); - } - newPath.Add(segments[1]); - segments = newPath; - } - foreach (var segment in segments) { - _pathPoints.Add(segment.TranslateToWorld(Transform)); - } - } - Profiler.EndSample(); - - Profiler.BeginSample("Handle Traveller"); - _curveTravellerMoved = false; - if (_pathPoints.Count > 1) { - Handles.matrix = Matrix4x4.identity; - Profiler.BeginSample("Calculate closest"); - var newPos = HandleUtility.ClosestPointToPolyLine(_pathPoints.ToArray()); - Profiler.EndSample(); - Profiler.BeginSample("Calculate if moved"); - if ((newPos - _handler.CurveTravellerPosition).magnitude >= HandleUtility.GetHandleSize(_handler.CurveTravellerPosition) * ControlPoint.ScreenRadius * CurveTravellerSizeRatio * 0.1f) { - _handler.CurveTravellerPosition = newPos; - _curveTravellerMoved = true; - } - Profiler.EndSample(); - } - Profiler.EndSample(); - - Profiler.BeginSample("Draw Line"); - // Render Curve with correct color regarding drag point properties & find curve section where the curve traveller is - _handler.CurveTravellerControlPointIdx = -1; - var minDist = float.MaxValue; - Handles.matrix = Transform.localToWorldMatrix; - foreach (var controlPoint in _handler.ControlPoints) { - Profiler.BeginSample("Compute Segments"); - var segments = curveVerticesByDragPoint[controlPoint.Index].Select(cp => cp.TranslateToWorld()).ToArray(); - Profiler.EndSample(); - if (segments.Length > 1) { - Profiler.BeginSample("Determine Color"); - Handles.color = _handler.DragPointInspector.DragPointExposition.Contains(DragPointExposure.SlingShot) && controlPoint.DragPoint.IsSlingshot - ? CurveSlingShotColor - : CurveColor; - Profiler.EndSample(); - Profiler.BeginSample("Handles.DrawAAPolyLine"); - Handles.DrawAAPolyLine(CurveWidth, segments); - Profiler.EndSample(); - Profiler.BeginSample("Calculate closes point"); - var closestToPath = HandleUtility.ClosestPointToPolyLine(segments); - var dist = (closestToPath - (Vector3)math.inverse(Transform.localToWorldMatrix).MultiplyPoint(_handler.CurveTravellerPosition)).magnitude; - if (dist < minDist) { - minDist = dist; - _handler.CurveTravellerControlPointIdx = controlPoint.Index; - } - Profiler.EndSample(); - } - } - Profiler.EndSample(); - } - } - - Profiler.EndSample(); - } - - /// - /// Display all the control points - /// Evaluate if the curve traveller has to be shown and display it if it's needed. - /// - /// - /// - /// Will update handler's properties about curve traveller visibility - /// - private void DisplayControlPoints() - { - Profiler.BeginSample("DisplayControlPoints"); - // Render Control Points and check traveler distance from CP - var distToControlPoint = Mathf.Infinity; - Handles.matrix = Matrix4x4.identity; - var style = new GUIStyle { - alignment = TextAnchor.MiddleCenter, - }; - for (var i = 0; i < _handler.ControlPoints.Count; ++i) { - var controlPoint = _handler.ControlPoints[i]; - Handles.color = controlPoint.IsSelected ? Color.green : Color.gray; - - var pos = controlPoint.EditorPositionWorld; - var handleSize = controlPoint.HandleSize; - Handles.SphereHandleCap(-1, pos, Quaternion.identity, handleSize, EventType.Repaint); - Handles.Label(pos, $"{i}", style); - var dist = Vector3.Distance(_handler.CurveTravellerPosition, controlPoint.EditorPositionWorld); - distToControlPoint = Mathf.Min(distToControlPoint, dist); - } - - Handles.matrix = Matrix4x4.identity; - // if (!_handler.MainComponent.IsLocked) { - // curve traveller is not overlapping a control point, we can draw it. - if (distToControlPoint > HandleUtility.GetHandleSize(_handler.CurveTravellerPosition) * ControlPoint.ScreenRadius) { - Handles.color = Color.grey; - Handles.SphereHandleCap( - _handler.CurveTravellerControlId, - _handler.CurveTravellerPosition, - Quaternion.identity, - HandleUtility.GetHandleSize(_handler.CurveTravellerPosition) * ControlPoint.ScreenRadius * CurveTravellerSizeRatio, - EventType.Repaint - ); - _handler.CurveTravellerVisible = true; - if (EditorWindow.mouseOverWindow && _curveTravellerMoved) { - HandleUtility.Repaint(); - } - } - // } - Profiler.EndSample(); - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs.meta deleted file mode 100644 index a36113bcc..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsSceneViewHandler.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: c74729b59c98f6e4d81a0e74b7d18392 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs deleted file mode 100644 index d2fab0e9b..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -namespace VisualPinball.Unity.Editor -{ - public enum FlipAxis - { - X, - Y, - Z, - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs.meta deleted file mode 100644 index 69602b193..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 4bc482d86a5df294f8007194a6a07b62 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs deleted file mode 100644 index 6e30d55bd..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs +++ /dev/null @@ -1,101 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using System.Collections.Generic; -using UnityEngine; -using VisualPinball.Engine.Math; -using VisualPinball.Unity.Editor; - -namespace VisualPinball.Unity -{ - /// - /// Visual Pinball keeps the same data for drag points across all game - /// items. However, not all properties are used in every game item.

- /// - /// This defines the data sets that can be used in a given game item, and - /// thus how they are exposed in the editor. - ///

- public enum DragPointExposure - { - /// - /// A drag point can be set to "smooth". - /// - Smooth, - - /// - /// A drag point can be set as "slingshot". - /// - SlingShot, - - /// - /// A drag point can be set as "auto texture". - /// - Texture - } - - public enum DragPointTransformType - { - TwoD, - ThreeD, - } - - /// - /// Abstraction inspectors that support drag points. - /// - public interface IDragPointsInspector - { - Transform Transform { get; } - - /// - /// Access to the drag point data - /// - DragPointData[] DragPoints { get; set; } - - /// - /// Whether to render drag points at all. Because sometimes we don't want to, like for trigger buttons. - /// - bool DragPointsActive { get; } - - /// - /// Returns whether the drag points are looping or not. - /// - bool PointsAreLooping { get; } - - /// - /// Returns exposed drag points features - /// - IEnumerable DragPointExposition { get; } - - /// - /// Returns the applied constrains to drag points position edition. - /// - /// - DragPointTransformType HandleType { get; } - - DragPointsInspectorHelper DragPointsHelper { get; } - - float[] TopBottomZ { get; } - - /// - /// Sets the drag point position - /// - /// Drag point to which the new position is applied to - /// The new value, where Z is the z-offset for ramps (and irrelevant for other items) - /// How many other drag points are selected - /// Only used for ramps, so top and bottom height can be adapted instead of the drag point's z. - void SetDragPointPosition(DragPointData dragPoint, Vertex3D value, int numSelectedDragPoints = 1, float[] topBottomZ = null); - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs.meta deleted file mode 100644 index 618c27c7e..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/IDragPointsInspector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 6f0f2996dddc2e243b834359e370e51b -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs deleted file mode 100644 index 24f18f67e..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs +++ /dev/null @@ -1,73 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -using UnityEditor; -using UnityEngine; - -namespace VisualPinball.Unity.Editor -{ - public static class HandlesUtils - { - /// - /// Returns the position of the moved object in VPX space. - /// - /// Original position in VPX space - /// The local-to-world matrix of the item - /// Allowed position type - /// - /// - /// Moved position in VPX space. - public static Vector3 HandlePosition(Vector3 position, Matrix4x4 localToWorld, DragPointTransformType type, float handleSize = 0.2f, float snap = 0.0f) - { - - var pos = position.TranslateToWorld(); - Handles.matrix = localToWorld; - - switch (type) { - case DragPointTransformType.TwoD: { - - var forward = Vector3.forward.TranslateToWorld().normalized; - var right = Vector3.right.TranslateToWorld().normalized; - var up = Vector3.up.TranslateToWorld().normalized; - - Handles.color = Handles.xAxisColor; - pos = Handles.Slider(pos, right); - - Handles.color = Handles.yAxisColor; - pos = Handles.Slider(pos, up); - - Handles.color = Handles.zAxisColor; - pos = Handles.Slider2D( - pos, - forward, - right, - up, - HandleUtility.GetHandleSize(pos) * handleSize, - Handles.RectangleHandleCap, - snap); - break; - } - - case DragPointTransformType.ThreeD: { - pos = Handles.PositionHandle(pos, Quaternion.identity.RotateToWorld()); - break; - } - } - return pos.TranslateToVpx(); - } - - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs.meta deleted file mode 100644 index 8a7586a27..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Utils/HandlesUtils.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e81e48f5f44d97244918f6cf0d978f73 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: From 6a100aa131782acb016871fa83d8928661c246bb Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 11:06:58 +0200 Subject: [PATCH 6/7] splines: fix generated spline package roundtrips --- .../Packaging/PackageWriter.cs | 40 +++++ .../DragPointSplineComponentTests.cs | 77 ++++++++++ .../VPT/RubberTests.cs | 92 ++++++++++++ .../DragPoint/DragPointSplineComponent.cs | 141 +++++++++++++++++- .../GeneratedDragPointSplineComponent.cs | 30 ++++ .../GeneratedDragPointSplineComponent.cs.meta | 2 + .../VPT/Light/LightInsertMeshComponent.cs | 10 +- .../MetalWireGuide/MetalWireGuideComponent.cs | 10 +- .../VPT/Ramp/RampComponent.cs | 10 +- .../VPT/Rubber/RubberComponent.cs | 10 +- .../VPT/Surface/SurfaceComponent.cs | 10 +- .../VPT/Trigger/TriggerComponent.cs | 10 +- 12 files changed, 396 insertions(+), 46 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index ecf711ff4..33d1b923c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -101,6 +101,7 @@ private async Task WritePackage(string path, IProgress progress, Logger.Info($"Writing table to {path}..."); IPackageStorage storage = null; List reactivatedObjects = null; + List excludedObjects = null; try { Report(progress, 0f, "Starting export…"); storage = PackageApi.StorageManager.CreateStorage(path); @@ -113,6 +114,7 @@ private async Task WritePackage(string path, IProgress progress, // Cabinet and backbox are authored inactive; activate them (located via their // marker components) so they flow into the active-only export. Restored in finally. reactivatedObjects = ActivateMarkedObjects(); + excludedObjects = DeactivateExcludedObjects(); // Every exported node gets a stable id; items, refs, mappings, renderer states and // light profiles all reference nodes by these ids. The ids are written into the glTF @@ -232,6 +234,7 @@ private async Task WritePackage(string path, IProgress progress, throw; } finally { storage?.Close(); + RestoreExcludedObjects(excludedObjects); RestoreMarkedObjects(reactivatedObjects); } } @@ -344,6 +347,43 @@ private static void RestoreMarkedObjects(List reactivated) } } + private List DeactivateExcludedObjects() + { + var excluded = new HashSet(); + foreach (var spline in _table.GetComponentsInChildren(true)) { + excluded.Add(spline.gameObject); + } + foreach (var marker in _table.GetComponentsInChildren(true)) { + excluded.Add(marker.gameObject); + } + + var deactivated = new List(); + try { + foreach (var go in excluded) { + if (go && go.activeSelf) { + go.SetActive(false); + deactivated.Add(go); + } + } + } catch { + RestoreExcludedObjects(deactivated); + throw; + } + return deactivated; + } + + private static void RestoreExcludedObjects(List deactivated) + { + if (deactivated == null) { + return; + } + foreach (var go in deactivated) { + if (go) { + go.SetActive(true); + } + } + } + private List CollectMarkedObjects() { var objects = new List(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs index 3acdffc07..c759dcefb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs @@ -15,9 +15,11 @@ // along with this program. If not, see . using NUnit.Framework; +using System.Reflection; using Unity.Mathematics; using UnityEngine; using UnityEngine.Splines; +using UnityEngine.TestTools; using VisualPinball.Engine.Math; using VisualPinball.Unity.Editor; @@ -159,6 +161,74 @@ public void ShouldUpgradeTheTriggerDefaultArrayLazily() } [Test] + public void ShouldRebindTheGeneratedSplineWhenTheSerializedReferenceIsMissing() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var original = rubber.DragPointSpline; + + ClearSplineReference(rubber); + + Assert.That(rubber.DragPointSpline, Is.SameAs(original)); + Assert.That(go.GetComponentsInChildren(true), + Has.Length.EqualTo(1)); + Assert.That(original.GetComponent(), Is.Not.Null); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldRemoveDuplicateGeneratedSplinesDeterministically() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var original = rubber.DragPointSpline; + DragPointSplineComponent.Create(rubber, CreateDragPoints()); + ClearSplineReference(rubber); + LogAssert.Expect(LogType.Warning, + "Removing generated spline child 'Spline' from 'Rubber' because it duplicates another drag-point spline."); + + Assert.That(rubber.DragPointSpline, Is.SameAs(original)); + Assert.That(go.GetComponentsInChildren(true), + Has.Length.EqualTo(1)); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] + public void ShouldRemoveInvalidGeneratedChildrenWithoutSkippingTheSpline() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var original = rubber.DragPointSpline; + var invalid = new GameObject("Invalid Spline"); + invalid.transform.SetParent(go.transform, false); + invalid.transform.SetSiblingIndex(0); + invalid.AddComponent(); + ClearSplineReference(rubber); + LogAssert.Expect(LogType.Warning, + "Removing generated spline child 'Invalid Spline' from 'Rubber' because it has no functional drag-point spline."); + + Assert.That(rubber.DragPointSpline, Is.SameAs(original)); + Assert.That(go.GetComponentsInChildren(true), + Has.Length.EqualTo(1)); + } + finally { + Object.DestroyImmediate(go); + } + } + + [Test] public void ShouldReverseAroundTheFirstKnotAndRotateSlingshots() { var go = new GameObject("Surface"); @@ -214,6 +284,13 @@ private static Vector3[] GetWorldPositions(SplineContainer container) return positions; } + private static void ClearSplineReference(RubberComponent rubber) + { + typeof(RubberComponent) + .GetField("_dragPointSpline", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(rubber, null); + } + private static DragPointData[] CreateDragPoints() { return new[] { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs index 379b280a5..1a2d9928e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs @@ -14,13 +14,20 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +using System; +using System.Collections; using System.IO; +using System.Linq; +using System.Text; using NUnit.Framework; using UnityEngine; +using UnityEngine.TestTools; +using VisualPinball.Engine.Math; using VisualPinball.Engine.Test.Test; using VisualPinball.Engine.Test.VPT.Rubber; using VisualPinball.Engine.VPT.Table; using VisualPinball.Unity.Editor; +using Object = UnityEngine.Object; namespace VisualPinball.Unity.Test { @@ -42,5 +49,90 @@ public void ShouldWriteImportedRubberData() Object.DestroyImmediate(go); } + [UnityTest] + public IEnumerator ShouldKeepGeneratedSplinesOutOfPackageRoundTrips() + { + var firstPackage = Path.Combine(Path.GetTempPath(), + $"vpe-spline-package-{Guid.NewGuid():N}.vpe"); + var secondPackage = Path.Combine(Path.GetTempPath(), + $"vpe-spline-package-{Guid.NewGuid():N}.vpe"); + GameObject source = null; + GameObject imported = null; + try { + source = CreatePackageTable(); + Object.DestroyImmediate(source.GetComponentInChildren()); + new PackageWriter(source).WritePackageSync(firstPackage); + Assert.That(source.GetComponentInChildren(), Is.Null); + AssertPackageHasNoSplineNodes(firstPackage); + + var importTask = new RuntimePackageReader(firstPackage).ImportIntoScene(); + while (!importTask.IsCompleted) { + yield return null; + } + if (importTask.IsFaulted) { + throw importTask.Exception!.GetBaseException(); + } + imported = importTask.Result; + AssertFunctionalGeneratedSplines(imported); + + new PackageWriter(imported).WritePackageSync(secondPackage); + AssertPackageHasNoSplineNodes(secondPackage); + AssertFunctionalGeneratedSplines(imported); + } + finally { + if (source) { + Object.DestroyImmediate(source); + } + if (imported) { + Object.DestroyImmediate(imported); + } + File.Delete(firstPackage); + File.Delete(secondPackage); + } + } + + private static void AssertPackageHasNoSplineNodes(string path) + { + using var storage = PackageApi.StorageManager.OpenStorage(path); + var sceneData = storage.GetFolder(PackageApi.TableFolder) + .GetFile(PackageApi.SceneFile).GetData(); + var glbContents = Encoding.UTF8.GetString(sceneData); + Assert.That(glbContents, Does.Not.Match("\\\"name\\\"\\s*:\\s*\\\"Spline\\\"")); + } + + private static GameObject CreatePackageTable() + { + var table = new GameObject("Table"); + table.AddComponent(); + var rubberObject = new GameObject("Rubber"); + rubberObject.transform.SetParent(table.transform, false); + var rubber = rubberObject.AddComponent(); + rubber.DragPoints = new[] { + new DragPointData(-100f, -100f), + new DragPointData(-100f, 100f), + new DragPointData(100f, 100f), + new DragPointData(100f, -100f), + }; + return table; + } + + private static void AssertFunctionalGeneratedSplines(GameObject table) + { + var rubbers = table.GetComponentsInChildren(true); + Assert.That(rubbers, Is.Not.Empty); + foreach (var rubber in rubbers) { + var generated = rubber.GetComponentsInChildren(true); + Assert.That(generated, Has.Length.EqualTo(1), rubber.name); + var spline = generated[0].GetComponent(); + Assert.That(spline, Is.Not.Null, rubber.name); + Assert.That(spline!.Container, Is.Not.Null, rubber.name); + Assert.That(spline.DragPoints, Is.Not.Empty, rubber.name); + Assert.That(rubber.DragPointSpline, Is.SameAs(spline), rubber.name); + Assert.That(rubber.transform.Cast() + .Count(child => child.name == "Spline" + && !child.GetComponent()), Is.Zero, rubber.name); + } + } + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs index dccadaceb..93ad54ac6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs @@ -73,6 +73,7 @@ public static DragPointSplineComponent Create(IDragPointSplineOwner owner, splineObject.transform.localRotation = ((Matrix4x4)Physics.VpxToWorld).rotation; splineObject.transform.localScale = Physics.ScaleInvVector; + splineObject.AddComponent(); var container = splineObject.AddComponent(); var component = splineObject.AddComponent(); component._container = container; @@ -84,10 +85,142 @@ public static DragPointSplineComponent Create(IDragPointSplineOwner owner, EditorUtility.SetDirty(component); #endif return component; - } - - public void Bind(IDragPointSplineOwner owner) - { + } + + public static DragPointSplineComponent GetOrCreate(IDragPointSplineOwner owner, + DragPointSplineComponent current, IReadOnlyList dragPoints) + { + if (owner == null) { + throw new ArgumentNullException(nameof(owner)); + } + + if (IsFunctionalChild(owner, current)) { + current.Bind(owner); + if (dragPoints != null) { + current.SetDragPoints(dragPoints); + } + return current; + } + + var component = ResolveExisting(owner, current); + if (!component) { + return Create(owner, dragPoints ?? Array.Empty()); + } + + component.Bind(owner); + if (dragPoints != null) { + component.SetDragPoints(dragPoints); + } + return component; + } + + private static bool IsFunctionalChild(IDragPointSplineOwner owner, + DragPointSplineComponent component) + { + return component && component.transform.parent == owner.SplineTransform + && component.GetComponent(); + } + + private static DragPointSplineComponent ResolveExisting(IDragPointSplineOwner owner, + DragPointSplineComponent current) + { + var candidates = new List(); + var ownerTransform = owner.SplineTransform; + var generatedChildren = new List(); + for (var i = 0; i < ownerTransform.childCount; i++) { + var child = ownerTransform.GetChild(i); + var marker = child.GetComponent(); + var component = child.GetComponent(); + if (marker || component) { + generatedChildren.Add(child); + } + } + + foreach (var child in generatedChildren) { + var marker = child.GetComponent(); + var component = child.GetComponent(); + if (component && component.GetComponent()) { + EnsureExportMarker(component.gameObject); + candidates.Add(component); + } else if (marker) { + RemoveGeneratedChild(owner, child.gameObject, + "it has no functional drag-point spline"); + } + } + + DragPointSplineComponent selected = null; + if (current && current.transform.parent == ownerTransform + && current.GetComponent()) { + EnsureExportMarker(current.gameObject); + selected = current; + if (!candidates.Contains(current)) { + candidates.Insert(0, current); + } + } else if (candidates.Count > 0) { + selected = candidates[0]; + } + + foreach (var candidate in candidates) { + if (candidate != selected) { + RemoveGeneratedChild(owner, candidate.gameObject, + "it duplicates another drag-point spline"); + } + } + return selected; + } + + private static void EnsureExportMarker(GameObject splineObject) + { + if (!splineObject.TryGetComponent(out _)) { +#if UNITY_EDITOR + if (!Application.isPlaying) { + Undo.AddComponent(splineObject); + EditorUtility.SetDirty(splineObject); + return; + } +#endif + splineObject.AddComponent(); + } + } + + private static void RemoveGeneratedChild(IDragPointSplineOwner owner, + GameObject generatedChild, string reason) + { + var canDestroy = CanDestroyGeneratedChild(generatedChild); + var action = canDestroy ? "Removing" : "Disabling"; + var suffix = canDestroy + ? string.Empty + : " It belongs to a prefab instance and cannot be removed without unpacking it."; + Debug.LogWarning( + $"{action} generated spline child '{generatedChild.name}' from '{owner.SplineOwner.name}' because {reason}.{suffix}", + owner.SplineOwner); + generatedChild.SetActive(false); + if (!canDestroy) { + return; + } + if (Application.isPlaying) { + UnityEngine.Object.Destroy(generatedChild); + } else { +#if UNITY_EDITOR + Undo.DestroyObjectImmediate(generatedChild); +#else + UnityEngine.Object.DestroyImmediate(generatedChild); +#endif + } + } + + private static bool CanDestroyGeneratedChild(GameObject generatedChild) + { +#if UNITY_EDITOR + return Application.isPlaying || !PrefabUtility.IsPartOfPrefabInstance(generatedChild) + || PrefabUtility.IsAddedGameObjectOverride(generatedChild); +#else + return true; +#endif + } + + public void Bind(IDragPointSplineOwner owner) + { _owner = owner.SplineOwner; if (!_container) { _container = GetComponent(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs new file mode 100644 index 000000000..5f681c6b7 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs @@ -0,0 +1,30 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Marks a generated drag-point spline GameObject. The object is authoring-only and is + /// rebuilt from its owner's packed drag points, so the package writer excludes its subtree. + /// + [AddComponentMenu("")] + [DisallowMultipleComponent] + public sealed class GeneratedDragPointSplineComponent : MonoBehaviour + { + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs.meta new file mode 100644 index 000000000..18604a946 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: aad20eee022b486f82c422e6b36380f3 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs index 33cb33a4a..1a99886a1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs @@ -83,13 +83,9 @@ protected override PbrMaterial GetMaterial(LightData data, Table table) private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs index 42f5edcb5..6a5a9888a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs @@ -239,13 +239,9 @@ public override void CopyFromObject(GameObject go) private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs index 41d75f572..3209ff83e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs @@ -450,13 +450,9 @@ private void CenterPivot() private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs index 2ca0e41b8..4c56467f3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs @@ -238,13 +238,9 @@ public override void CopyFromObject(GameObject go) private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs index 77842bbfe..b747c2d48 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs @@ -271,13 +271,9 @@ private void CenterPivot() private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs index 7c5eb07aa..37b1a1738 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs @@ -260,13 +260,9 @@ public override void CopyFromObject(GameObject go) private DragPointSplineComponent GetOrCreateDragPointSpline() { - if (!_dragPointSpline) { - _dragPointSpline = DragPointSplineComponent.Create(this, - _dragPoints ?? Array.Empty()); - _dragPoints = null; - } else { - _dragPointSpline.Bind(this); - } + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; return _dragPointSpline; } From be7e5012321def09bcc2cb574a802123a9683ea3 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 11:15:33 +0200 Subject: [PATCH 7/7] splines: suppress derived tangent notifications --- .../DragPointSplineComponentTests.cs | 33 +++++++++++++++++++ .../DragPoint/DragPointSplineConverter.cs | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs index c759dcefb..d703750ce 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs @@ -181,6 +181,39 @@ public void ShouldRebindTheGeneratedSplineWhenTheSerializedReferenceIsMissing() } } + [Test] + public void ShouldEmitOneSplineChangePerKnotEdit() + { + var go = new GameObject("Rubber"); + try { + var rubber = go.AddComponent(); + rubber.DragPoints = CreateDragPoints(); + var spline = rubber.DragPointSpline.Container.Spline; + var changes = 0; + void CountChanges(Spline changedSpline, int _, SplineModification __) + { + if (ReferenceEquals(changedSpline, spline)) { + changes++; + } + } + + Spline.Changed += CountChanges; + try { + var knot = spline[1]; + knot.Position.x += 10f; + spline.SetKnot(1, knot); + } + finally { + Spline.Changed -= CountChanges; + } + + Assert.That(changes, Is.EqualTo(1)); + } + finally { + Object.DestroyImmediate(go); + } + } + [Test] public void ShouldRemoveDuplicateGeneratedSplinesDeterministically() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs index 99814d349..d8c46868e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs @@ -95,7 +95,7 @@ public static void RecalculateTangents(Spline spline, knot.TangentOut = tangentOut[i]; knot.Rotation = quaternion.identity; spline.SetTangentModeNoNotify(i, TangentMode.Broken); - spline.SetKnot(i, knot); + spline.SetKnotNoNotify(i, knot); } }