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 000000000..d6f6fc58b
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/light-insert.golden differ
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 000000000..9004aebe5
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-corners-detail.golden differ
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 000000000..396dc347a
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-coarse.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-detail.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-detail.golden
new file mode 100644
index 000000000..c0570cf24
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/loop-smooth-detail.golden differ
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 000000000..73c4f0714
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/metal-wire-guide.golden differ
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 000000000..ab1d93c5c
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-corners-detail.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-coarse.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-coarse.golden
new file mode 100644
index 000000000..ec00c0958
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-coarse.golden differ
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 000000000..c1281400e
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/open-smooth-detail.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/ramp.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/ramp.golden
new file mode 100644
index 000000000..c33735488
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/ramp.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden
new file mode 100644
index 000000000..d1e419101
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/rubber.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/surface.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/surface.golden
new file mode 100644
index 000000000..ae2b1a5c3
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/surface.golden differ
diff --git a/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/trigger-outline.golden b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/trigger-outline.golden
new file mode 100644
index 000000000..58f5541c2
Binary files /dev/null and b/VisualPinball.Engine.Test/Fixtures~/DragPointGolden/trigger-outline.golden differ
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);
+ }
+ }
+}
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
new file mode 100644
index 000000000..9e6c6cf93
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointSplineInspector.cs
@@ -0,0 +1,356 @@
+// 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,
+ }
+
+ internal enum DragPointSplineAxis
+ {
+ X,
+ Y,
+ Z,
+ }
+
+ 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, DragPointSplineAxis.X);
+ }
+ if (GUILayout.Button("Flip Y")) {
+ Flip(component, DragPointSplineAxis.Y);
+ }
+ using (new EditorGUI.DisabledScope(component.Owner?.SplinePlanar ?? true)) {
+ if (GUILayout.Button("Flip Z")) {
+ Flip(component, DragPointSplineAxis.Z);
+ }
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+
+ public static void EditSpline(DragPointSplineComponent component)
+ {
+ Selection.activeGameObject = component.Container.gameObject;
+ ToolManager.SetActiveContext();
+ ToolManager.SetActiveTool();
+ SceneView.RepaintAll();
+ }
+
+ private static void Flip(DragPointSplineComponent component,
+ DragPointSplineAxis 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 DragPointSplineAxis.X:
+ dragPoint.Center.X = center.X + center.X - dragPoint.Center.X;
+ break;
+ case DragPointSplineAxis.Y:
+ dragPoint.Center.Y = center.Y + center.Y - dragPoint.Center.Y;
+ break;
+ case DragPointSplineAxis.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/DragPoint/DragPointsHandler.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs
deleted file mode 100644
index 5eb064053..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsHandler.cs
+++ /dev/null
@@ -1,423 +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();
- }
-
- 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 d7428b1fe..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/DragPointsInspectorHelper.cs
+++ /dev/null
@@ -1,309 +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()
- {
- _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)
- {
- 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.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/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.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:
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.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/DragPointSplineComponentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs
new file mode 100644
index 000000000..d703750ce
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/DragPointSplineComponentTests.cs
@@ -0,0 +1,349 @@
+// 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 System.Reflection;
+using Unity.Mathematics;
+using UnityEngine;
+using UnityEngine.Splines;
+using UnityEngine.TestTools;
+using VisualPinball.Engine.Math;
+using VisualPinball.Unity.Editor;
+
+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);
+ }
+ }
+
+ [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 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()
+ {
+ 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");
+ 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 void ClearSplineReference(RubberComponent rubber)
+ {
+ typeof(RubberComponent)
+ .GetField("_dragPointSpline", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(rubber, null);
+ }
+
+ 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.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/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.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..132b3d98f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointMetadata.cs
@@ -0,0 +1,98 @@
+// 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 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;
+ _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/DragPointSplineComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs
new file mode 100644
index 000000000..93ad54ac6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineComponent.cs
@@ -0,0 +1,378 @@
+// 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;
+
+ splineObject.AddComponent();
+ 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 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();
+ }
+ 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/DragPointSplineConverter.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/DragPointSplineConverter.cs
new file mode 100644
index 000000000..d8c46868e
--- /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.SetKnotNoNotify(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.Editor/DragPoint/FlipAxis.cs b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs
similarity index 59%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs
rename to VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs
index d2fab0e9b..5f681c6b7 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/DragPoint/FlipAxis.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/DragPoint/GeneratedDragPointSplineComponent.cs
@@ -1,25 +1,30 @@
-// 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,
- }
-}
+// 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/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..1a99886a1 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,22 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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..6a5a9888a 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,27 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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..3209ff83e 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,63 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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..4c56467f3 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,28 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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..b747c2d48 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,39 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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..37b1a1738 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,28 @@ 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()
+ {
+ _dragPointSpline = DragPointSplineComponent.GetOrCreate(this,
+ _dragPointSpline, _dragPoints);
+ _dragPoints = null;
+ 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/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"
},