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/PackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs
index fb6680151..ceaea1429 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs
@@ -90,6 +90,10 @@ public async Task ImportIntoScene(string tableName)
var comp = item.gameObject.GetComponent(type) as IPackable;
comp?.UnpackReferences(stream.GetData(), _tableRoot, _refs, _files);
});
+ var slingshots = _tableRoot.GetComponentsInChildren(true);
+ foreach (var slingshot in slingshots) {
+ slingshot.ValidateAfterUnpack();
+ }
ReadGlobals();
ReadTableMetadata();
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/RubberColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberColliderInspector.cs
index 504919715..6cbe45533 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberColliderInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberColliderInspector.cs
@@ -33,6 +33,8 @@ public class RubberColliderInspector : ColliderInspector slot.Id == bindings[0].SlotId);
+ var prefab = Resources.Load("Prefabs/Rubber");
+ if (!prefab) {
+ EditorUtility.DisplayDialog("Cannot Create Rubber", "The Resources/Prefabs/Rubber prefab is missing.", "OK");
+ return;
+ }
+
+ var parent = bindings[0].Guide.transform.parent;
+ var gameObject = PrefabUtility.InstantiatePrefab(prefab, parent) as GameObject;
+ if (!gameObject) {
+ return;
+ }
+ Undo.RegisterCreatedObjectUndo(gameObject, "Create Guided Rubber");
+ gameObject.name = GameObjectUtility.GetUniqueNameForSibling(parent, "Rubber");
+ var normal = bindings[0].Guide.transform.localToWorldMatrix.MultiplyVector(Vector3.up).normalized;
+ var axisX = Vector3.ProjectOnPlane(
+ bindings[0].Guide.transform.localToWorldMatrix.MultiplyVector(Vector3.right), normal).normalized;
+ var axisY = Vector3.Cross(normal, axisX).normalized;
+ gameObject.transform.SetPositionAndRotation(
+ RubberGuideResolver.SlotWorldCenter(bindings[0].Guide, firstSlot),
+ Quaternion.LookRotation(-axisY, normal));
+
+ var rubber = gameObject.GetComponent();
+ if (!rubber) {
+ Undo.DestroyObjectImmediate(gameObject);
+ EditorUtility.DisplayDialog("Cannot Create Rubber", "The rubber prefab has no RubberComponent.", "OK");
+ return;
+ }
+ rubber.SetGuideBindings(bindings);
+ var collider = rubber.GetComponent();
+ if (collider) {
+ collider.Mode = RubberColliderMode.Legacy;
+ }
+ if (!RubberAutofit.TryBake(rubber, out _, out var error)) {
+ Undo.DestroyObjectImmediate(gameObject);
+ EditorUtility.DisplayDialog("Cannot Create Rubber", error, "OK");
+ return;
+ }
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ if (collider) {
+ PrefabUtility.RecordPrefabInstancePropertyModifications(collider);
+ }
+ Selection.activeGameObject = gameObject;
+ RubberGuideDependencyTracker.RebuildSoon();
+ }
+
+ [MenuItem(MenuRoot + "Create Rubber Around Selected Guides", true)]
+ private static bool CanCreateRubberAroundSelectedGuides()
+ {
+ return SelectedGuides().Count > 0;
+ }
+
+ [MenuItem(MenuRoot + "Bind Selected Guides", false, 41)]
+ private static void BindSelectedGuides()
+ {
+ BindSelectedGuidesToRubber("Bind Rubber To Guides");
+ }
+
+ [MenuItem(MenuRoot + "Bind Selected Guides", true)]
+ private static bool CanBindSelectedGuides()
+ {
+ return SelectedRubber() && SelectedGuides().Count > 0;
+ }
+
+ [MenuItem(MenuRoot + "Autofit Now", false, 42)]
+ private static void AutofitSelected()
+ {
+ Autofit(SelectedRubber(), "Autofit Rubber");
+ }
+
+ [MenuItem(MenuRoot + "Autofit Now", true)]
+ private static bool CanAutofitSelected()
+ {
+ var rubber = SelectedRubber();
+ return rubber && rubber.PathSource == RubberPathSource.Guides
+ && rubber.GuideBindings.Count > 0;
+ }
+
+ [MenuItem(MenuRoot + "Detach From Guides", false, 43)]
+ private static void DetachSelected()
+ {
+ var rubber = SelectedRubber();
+ Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, "Detach Rubber From Guides");
+ rubber.DetachFromGuides();
+ rubber.RebuildMeshes();
+ EditorUtility.SetDirty(rubber);
+ RubberGuideDependencyTracker.RebuildSoon();
+ }
+
+ [MenuItem(MenuRoot + "Detach From Guides", true)]
+ private static bool CanDetachSelected()
+ {
+ var rubber = SelectedRubber();
+ return rubber && rubber.PathSource == RubberPathSource.Guides;
+ }
+
+ [MenuItem(MenuRoot + "Convert Imported Rubber To Guided", false, 44)]
+ private static void ConvertImportedRubberToGuided()
+ {
+ var rubber = SelectedRubber();
+ if (!rubber || !RubberGuideSlotPickerWindow.TryPick(SelectedGuides(),
+ out var bindings)) {
+ return;
+ }
+ Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject,
+ "Convert Imported Rubber To Guided");
+ if (!RubberAutofit.TryConvertToGuides(rubber, bindings, out _, out var error)) {
+ EditorUtility.DisplayDialog("Rubber Conversion Failed",
+ $"The manual spline remains authoritative.\n\n{error}", "OK");
+ } else {
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ }
+ EditorUtility.SetDirty(rubber);
+ RubberGuideDependencyTracker.RebuildSoon();
+ }
+
+ [MenuItem(MenuRoot + "Convert Imported Rubber To Guided", true)]
+ private static bool CanConvertImportedRubberToGuided()
+ {
+ var rubber = SelectedRubber();
+ return rubber && rubber.PathSource == RubberPathSource.Spline && SelectedGuides().Count > 0;
+ }
+
+ internal static bool Autofit(RubberComponent rubber, string undoName)
+ {
+ if (!rubber) {
+ return false;
+ }
+ Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName);
+ if (!RubberAutofit.TryBake(rubber, out _, out var error)) {
+ EditorUtility.DisplayDialog("Rubber Autofit Failed", error, "OK");
+ return false;
+ }
+ EditorUtility.SetDirty(rubber);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ SceneView.RepaintAll();
+ return true;
+ }
+
+ private static void BindSelectedGuidesToRubber(string undoName)
+ {
+ var rubber = SelectedRubber();
+ if (!rubber || !RubberGuideSlotPickerWindow.TryPick(SelectedGuides(), out var bindings)) {
+ return;
+ }
+ Undo.IncrementCurrentGroup();
+ Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName);
+ var convertingSpline = rubber.PathSource == RubberPathSource.Spline;
+ string error;
+ var succeeded = convertingSpline
+ ? RubberAutofit.TryConvertToGuides(rubber, bindings, out _, out error)
+ : RubberAutofit.TryReplaceGuideBindings(rubber, bindings, out _, out error);
+ if (!succeeded) {
+ Undo.RevertAllInCurrentGroup();
+ var retainedState = convertingSpline
+ ? "The manual spline remains authoritative."
+ : "The bindings were preserved and the previous sampled path was retained.";
+ EditorUtility.DisplayDialog("Rubber Autofit Failed",
+ $"{retainedState}\n\n{error}", "OK");
+ } else {
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ }
+ EditorUtility.SetDirty(rubber);
+ RubberGuideDependencyTracker.RebuildSoon();
+ }
+
+ private static RubberComponent SelectedRubber()
+ {
+ if (Selection.activeGameObject) {
+ var active = Selection.activeGameObject.GetComponentInParent();
+ if (active) {
+ return active;
+ }
+ }
+ return Selection.gameObjects.Select(gameObject => gameObject.GetComponentInParent())
+ .FirstOrDefault(rubber => rubber);
+ }
+
+ private static List SelectedGuides()
+ {
+ var guides = new List();
+ foreach (var gameObject in Selection.gameObjects) {
+ foreach (var guide in gameObject.GetComponents()) {
+ if (!guides.Contains(guide)) {
+ guides.Add(guide);
+ }
+ }
+ }
+ return guides;
+ }
+ }
+
+ internal sealed class RubberGuideSlotPickerWindow : EditorWindow
+ {
+ private IReadOnlyList _guides;
+ private int[] _slotIndices;
+ private bool _confirmed;
+
+ public static bool TryPick(IReadOnlyList guides,
+ out RubberGuideBinding[] bindings)
+ {
+ bindings = Array.Empty();
+ if (guides == null || guides.Count == 0) {
+ return false;
+ }
+ if (guides.Any(guide => !guide || guide.Slots.Length == 0)) {
+ EditorUtility.DisplayDialog("Cannot Bind Rubber", "Every selected guide must expose at least one slot.", "OK");
+ return false;
+ }
+ if (guides.All(guide => guide.Slots.Length == 1)) {
+ bindings = guides.Select(guide => new RubberGuideBinding(guide, guide.Slots[0].Id)).ToArray();
+ return true;
+ }
+
+ var window = CreateInstance();
+ window.titleContent = new GUIContent("Choose Rubber Slots");
+ window._guides = guides;
+ window._slotIndices = new int[guides.Count];
+ window.minSize = new Vector2(360f, 90f + guides.Count * 22f);
+ window.maxSize = new Vector2(600f, 90f + guides.Count * 22f);
+ window.ShowModalUtility();
+ if (window._confirmed) {
+ bindings = guides.Select((guide, index) => new RubberGuideBinding(guide,
+ guide.Slots[window._slotIndices[index]].Id)).ToArray();
+ }
+ var confirmed = window._confirmed;
+ DestroyImmediate(window);
+ return confirmed;
+ }
+
+ private void OnGUI()
+ {
+ EditorGUILayout.LabelField("Choose the contact slot for each selected guide.", EditorStyles.wordWrappedLabel);
+ for (var i = 0; i < _guides.Count; i++) {
+ var labels = _guides[i].Slots.Select((slot, index) => string.IsNullOrEmpty(slot.DisplayName)
+ ? $"Slot {index + 1}" : slot.DisplayName).ToArray();
+ _slotIndices[i] = EditorGUILayout.Popup(_guides[i].name, _slotIndices[i], labels);
+ }
+ GUILayout.FlexibleSpace();
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Cancel")) {
+ Close();
+ }
+ if (GUILayout.Button("Bind")) {
+ _confirmed = true;
+ Close();
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs.meta
new file mode 100644
index 000000000..8aa79708c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 7152bf0de00946568d85b4c6af8c7736
+timeCreated: 1783936778
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs
new file mode 100644
index 000000000..94cadccbd
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs
@@ -0,0 +1,223 @@
+// 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.
+
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ [InitializeOnLoad]
+ internal static class RubberGuideDependencyTracker
+ {
+ private const double DebounceSeconds = 0.15;
+ private static readonly Dictionary> Dependents = new();
+ private static readonly Dictionary IgnoredRubberChanges = new();
+ private static readonly HashSet Pending = new();
+ private static bool _indexDirty = true;
+ private static bool _isRebaking;
+ private static double _rebakeAt;
+
+ static RubberGuideDependencyTracker()
+ {
+ ObjectChangeEvents.changesPublished += ChangesPublished;
+ EditorApplication.update += Update;
+ AssemblyReloadEvents.beforeAssemblyReload += Unregister;
+ PrefabStage.prefabStageOpened += PrefabStageChanged;
+ PrefabStage.prefabStageClosing += PrefabStageChanged;
+ }
+
+ internal static void RebuildSoon()
+ {
+ _indexDirty = true;
+ }
+
+ private static void Unregister()
+ {
+ ObjectChangeEvents.changesPublished -= ChangesPublished;
+ EditorApplication.update -= Update;
+ AssemblyReloadEvents.beforeAssemblyReload -= Unregister;
+ PrefabStage.prefabStageOpened -= PrefabStageChanged;
+ PrefabStage.prefabStageClosing -= PrefabStageChanged;
+ }
+
+ private static void PrefabStageChanged(PrefabStage _)
+ {
+ _indexDirty = true;
+ Pending.Clear();
+ }
+
+ private static void ChangesPublished(ref ObjectChangeEventStream stream)
+ {
+ if (_isRebaking) {
+ return;
+ }
+ for (var i = 0; i < stream.length; i++) {
+ var kind = stream.GetEventType(i);
+ if (kind == ObjectChangeKind.ChangeGameObjectOrComponentProperties) {
+ stream.GetChangeGameObjectOrComponentPropertiesEvent(i, out var args);
+ QueueDependents(args.entityId);
+ var changed = EditorUtility.EntityIdToObject(args.entityId);
+ if (IsIgnoredRubberChange(changed)) {
+ continue;
+ }
+ if (changed is RubberComponent rubber) {
+ QueueRubber(rubber);
+ _indexDirty = true;
+ } else if (changed is GameObject gameObject
+ && gameObject.TryGetComponent(out var gameObjectRubber)) {
+ QueueRubber(gameObjectRubber);
+ _indexDirty = true;
+ }
+ } else if (kind == ObjectChangeKind.ChangeGameObjectParent
+ || kind == ObjectChangeKind.ChangeGameObjectStructure
+ || kind == ObjectChangeKind.ChangeGameObjectStructureHierarchy
+ || kind == ObjectChangeKind.ChangeScene
+ || kind == ObjectChangeKind.CreateGameObjectHierarchy
+ || kind == ObjectChangeKind.DestroyGameObjectHierarchy
+ || kind == ObjectChangeKind.UpdatePrefabInstances) {
+ _indexDirty = true;
+ }
+ }
+ }
+
+ private static void QueueDependents(EntityId entityId)
+ {
+ if (!Dependents.TryGetValue(entityId, out var rubbers)) {
+ return;
+ }
+ foreach (var rubber in rubbers) {
+ if (rubber) {
+ Pending.Add(rubber);
+ }
+ }
+ _rebakeAt = EditorApplication.timeSinceStartup + DebounceSeconds;
+ }
+
+ private static void QueueRubber(RubberComponent rubber)
+ {
+ if (rubber && rubber.PathSource == RubberPathSource.Guides) {
+ Pending.Add(rubber);
+ _rebakeAt = EditorApplication.timeSinceStartup + DebounceSeconds;
+ }
+ }
+
+ private static void Update()
+ {
+ if (EditorApplication.isPlayingOrWillChangePlaymode) {
+ return;
+ }
+ RemoveExpiredIgnoredChanges();
+ if (_indexDirty) {
+ RebuildIndex();
+ }
+ if (Pending.Count == 0 || EditorApplication.timeSinceStartup < _rebakeAt) {
+ return;
+ }
+
+ var pending = new List(Pending);
+ Pending.Clear();
+ _isRebaking = true;
+ try {
+ foreach (var rubber in pending) {
+ if (!rubber || rubber.PathSource != RubberPathSource.Guides) {
+ continue;
+ }
+ if (RubberAutofit.GetStatus(rubber).IsValid) {
+ continue;
+ }
+ // The bake is derived data. Recording a second undo operation makes one
+ // guide edit require two undos and causes undo-triggered rebake loops.
+ IgnoreDerivedChanges(rubber);
+ if (RubberAutofit.TryBake(rubber, out _, out var error)) {
+ EditorUtility.SetDirty(rubber);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ } else {
+ Debug.LogWarning($"Could not update guided rubber '{rubber.name}': {error}", rubber);
+ }
+ }
+ } finally {
+ _isRebaking = false;
+ }
+ SceneView.RepaintAll();
+ }
+
+ private static void IgnoreDerivedChanges(RubberComponent rubber)
+ {
+ var until = EditorApplication.timeSinceStartup + DebounceSeconds * 2.0;
+ IgnoredRubberChanges[rubber.GetEntityId()] = until;
+ IgnoredRubberChanges[rubber.gameObject.GetEntityId()] = until;
+ }
+
+ private static bool IsIgnoredRubberChange(Object changed)
+ {
+ if (!changed) {
+ return false;
+ }
+ var rubber = changed as RubberComponent;
+ if (!rubber && changed is GameObject gameObject) {
+ gameObject.TryGetComponent(out rubber);
+ }
+ return rubber && IgnoredRubberChanges.TryGetValue(rubber.GetEntityId(), out var until)
+ && EditorApplication.timeSinceStartup <= until;
+ }
+
+ private static void RemoveExpiredIgnoredChanges()
+ {
+ var now = EditorApplication.timeSinceStartup;
+ var expired = new List();
+ foreach (var pair in IgnoredRubberChanges) {
+ if (pair.Value < now) {
+ expired.Add(pair.Key);
+ }
+ }
+ foreach (var entityId in expired) {
+ IgnoredRubberChanges.Remove(entityId);
+ }
+ }
+
+ private static void RebuildIndex()
+ {
+ Dependents.Clear();
+ foreach (var rubber in Object.FindObjectsByType(FindObjectsInactive.Include)) {
+ if (!rubber || EditorUtility.IsPersistent(rubber)
+ || rubber.PathSource != RubberPathSource.Guides) {
+ continue;
+ }
+ foreach (var binding in rubber.GuideBindings) {
+ if (!binding.Guide) {
+ continue;
+ }
+ AddDependency(binding.Guide.GetEntityId(), rubber);
+ AddTransformHierarchyDependencies(binding.Guide.transform, rubber);
+ }
+ AddTransformHierarchyDependencies(rubber.transform, rubber);
+ }
+ _indexDirty = false;
+ }
+
+ private static void AddTransformHierarchyDependencies(Transform transform,
+ RubberComponent rubber)
+ {
+ for (var current = transform; current; current = current.parent) {
+ AddDependency(current.GetEntityId(), rubber);
+ AddDependency(current.gameObject.GetEntityId(), rubber);
+ }
+ }
+
+ private static void AddDependency(EntityId entityId, RubberComponent rubber)
+ {
+ if (!Dependents.TryGetValue(entityId, out var rubbers)) {
+ rubbers = new HashSet();
+ Dependents.Add(entityId, rubbers);
+ }
+ rubbers.Add(rubber);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs.meta
new file mode 100644
index 000000000..a77d90256
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: f0d4ed6837e14414ae27c2c1db0bc6bd
+timeCreated: 1783936779
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs
new file mode 100644
index 000000000..7a83bf2b9
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs
@@ -0,0 +1,140 @@
+// 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.
+
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(RubberGuideComponent)), CanEditMultipleObjects]
+ public class RubberGuideInspector : UnityEditor.Editor
+ {
+ private SerializedProperty _slots;
+
+ private void OnEnable()
+ {
+ _slots = serializedObject.FindProperty("_slots");
+ }
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+ if (targets.Length > 1) {
+ EditorGUILayout.HelpBox("Edit rubber guide slots one component at a time.",
+ MessageType.Info);
+ return;
+ }
+
+ var guide = (RubberGuideComponent)target;
+ foreach (var validationError in guide.ValidateSlots()) {
+ EditorGUILayout.HelpBox(validationError, MessageType.Error);
+ }
+
+ for (var i = 0; i < _slots.arraySize; i++) {
+ var slot = _slots.GetArrayElementAtIndex(i);
+ var displayName = slot.FindPropertyRelative(nameof(RubberGuideSlot.DisplayName));
+ slot.isExpanded = EditorGUILayout.BeginFoldoutHeaderGroup(slot.isExpanded,
+ string.IsNullOrEmpty(displayName.stringValue) ? $"Slot {i + 1}" : displayName.stringValue);
+ if (slot.isExpanded) {
+ EditorGUI.indentLevel++;
+ EditorGUILayout.PropertyField(displayName);
+ EditorGUILayout.PropertyField(slot.FindPropertyRelative(nameof(RubberGuideSlot.LocalHeight)),
+ new GUIContent("Local Height", "Distance along the guide axis in Unity units."));
+ DrawProfile(slot.FindPropertyRelative(nameof(RubberGuideSlot.Profile)));
+ EditorGUILayout.PropertyField(slot.FindPropertyRelative(nameof(RubberGuideSlot.RubberSupportFriction)));
+ using (new EditorGUI.DisabledScope(true)) {
+ EditorGUILayout.TextField("ID", ReadGuid(slot.FindPropertyRelative(nameof(RubberGuideSlot.Id))));
+ }
+
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Duplicate Slot")) {
+ serializedObject.ApplyModifiedProperties();
+ Undo.RecordObject(guide, "Duplicate Rubber Guide Slot");
+ guide.DuplicateSlot(i);
+ EditorUtility.SetDirty(guide);
+ serializedObject.Update();
+ }
+ if (GUILayout.Button("Remove Slot")) {
+ _slots.DeleteArrayElementAtIndex(i);
+ EditorGUI.indentLevel--;
+ EditorGUILayout.EndHorizontal();
+ EditorGUILayout.EndFoldoutHeaderGroup();
+ break;
+ }
+ EditorGUILayout.EndHorizontal();
+ EditorGUI.indentLevel--;
+ }
+ EditorGUILayout.EndFoldoutHeaderGroup();
+ }
+
+ EditorGUILayout.Space();
+ EditorGUILayout.BeginHorizontal();
+ if (GUILayout.Button("Add Slot")) {
+ serializedObject.ApplyModifiedProperties();
+ Undo.RecordObject(guide, "Add Rubber Guide Slot");
+ guide.AddSlot(RubberGuideSlot.Create($"Slot {guide.Slots.Length + 1}", 0.01f));
+ EditorUtility.SetDirty(guide);
+ serializedObject.Update();
+ }
+ if (GUILayout.Button("Repair Duplicate IDs")) {
+ serializedObject.ApplyModifiedProperties();
+ Undo.RecordObject(guide, "Repair Rubber Guide Slot IDs");
+ var repaired = guide.RepairDuplicateIds();
+ EditorUtility.SetDirty(guide);
+ serializedObject.Update();
+ Debug.Log($"Repaired {repaired} rubber guide slot ID(s).", guide);
+ }
+ EditorGUILayout.EndHorizontal();
+ serializedObject.ApplyModifiedProperties();
+ }
+
+ private static void DrawProfile(SerializedProperty profile)
+ {
+ var type = profile.FindPropertyRelative(nameof(RubberGuideProfile.Type));
+ EditorGUILayout.PropertyField(type);
+ EditorGUILayout.PropertyField(profile.FindPropertyRelative(nameof(RubberGuideProfile.LocalCenter)),
+ new GUIContent("Local Center", "Profile-plane offset in the guide's local X/Z axes."));
+ if ((RubberGuideProfileType)type.enumValueIndex == RubberGuideProfileType.Circle) {
+ EditorGUILayout.PropertyField(profile.FindPropertyRelative(nameof(RubberGuideProfile.Radius)),
+ new GUIContent("Radius", "Contact radius in Unity units."));
+ } else {
+ EditorGUILayout.HelpBox("Convex guide profiles are reserved and not supported by autofit yet.",
+ MessageType.Warning);
+ EditorGUILayout.PropertyField(profile.FindPropertyRelative(nameof(RubberGuideProfile.ConvexHull)), true);
+ }
+ }
+
+ private static string ReadGuid(SerializedProperty guid)
+ {
+ var a = guid.FindPropertyRelative("_a").ulongValue;
+ var b = guid.FindPropertyRelative("_b").ulongValue;
+ return $"{a:x16}{b:x16}";
+ }
+
+ [DrawGizmo(GizmoType.Selected | GizmoType.NonSelected)]
+ private static void DrawGuideGizmo(RubberGuideComponent guide, GizmoType gizmoType)
+ {
+ if (!guide || guide.Slots == null) {
+ return;
+ }
+ var selected = (gizmoType & GizmoType.Selected) != 0;
+ Handles.color = selected ? new Color(0.2f, 0.9f, 1f, 1f) : new Color(0.2f, 0.7f, 0.8f, 0.45f);
+ foreach (var slot in guide.Slots.Where(slot => slot.Profile.Type == RubberGuideProfileType.Circle
+ && slot.Profile.Radius > 0f)) {
+ var center = RubberGuideResolver.SlotWorldCenter(guide, slot);
+ var radius = (guide.transform.TransformVector(Vector3.right * slot.Profile.Radius).magnitude
+ + guide.transform.TransformVector(Vector3.forward * slot.Profile.Radius).magnitude) * 0.5f;
+ Handles.DrawWireDisc(center, guide.transform.up, radius);
+ if (selected) {
+ Handles.Label(center, string.IsNullOrEmpty(slot.DisplayName) ? "Rubber slot" : slot.DisplayName);
+ }
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs.meta
new file mode 100644
index 000000000..3cfd2748d
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: bb4057e13db1484a9569d20a902416e9
+timeCreated: 1783936777
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs
index 183a6d755..7c769d48b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs
@@ -16,36 +16,28 @@
// ReSharper disable AssignmentInConditionalExpression
-using System.Collections.Generic;
-using UnityEditor;
-using UnityEngine;
-using VisualPinball.Engine.Math;
+using System;
+using System.Linq;
+using Unity.Mathematics;
+using UnityEditor;
+using UnityEngine;
using VisualPinball.Engine.VPT.Rubber;
namespace VisualPinball.Unity.Editor
{
[CustomEditor(typeof(RubberComponent)), CanEditMultipleObjects]
- public class RubberInspector : MainInspector, IDragPointsInspector
- {
-
- public Transform Transform => MainComponent.transform;
-
- private SerializedProperty _thicknessProperty;
+ public class RubberInspector : MainInspector
+ {
+ private SerializedProperty _thicknessProperty;
+ private SerializedProperty _restLengthProperty;
+ private bool _bindingChangeScheduled;
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();
+ _thicknessProperty = serializedObject.FindProperty(nameof(RubberComponent._thickness));
+ _restLengthProperty = serializedObject.FindProperty("_restLength");
}
public override void OnInspectorGUI()
@@ -56,42 +48,280 @@ public override void OnInspectorGUI()
BeginEditing();
- OnPreInspectorGUI();
-
- // height
- EditorGUI.BeginChangeCheck();
+ OnPreInspectorGUI();
+ DrawPathSection();
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Geometry", EditorStyles.boldLabel);
+ EditorGUI.BeginChangeCheck();
var newHeight = EditorGUILayout.FloatField(new GUIContent("Height", "Height of the rubber (in VPX units."), MainComponent.Height);
if (EditorGUI.EndChangeCheck()) {
Undo.RecordObject(MainComponent.transform, "Change Rubber Height");
MainComponent.Height = newHeight;
- }
- PropertyField(_thicknessProperty, rebuildMesh: true);
-
- DragPointsHelper.OnInspectorGUI(this);
+ }
+ PropertyField(_thicknessProperty, rebuildMesh: true);
+ PropertyField(_restLengthProperty);
+ var fittedLength = MainComponent.BakedPath.Sum(element => element.Length);
+ if (MainComponent.PathSource == RubberPathSource.Guides) {
+ EditorGUILayout.LabelField("Fitted Centerline Length", $"{fittedLength:0.###} VPX");
+ if (MainComponent.RestLength > 0f) {
+ EditorGUILayout.LabelField("Installed Stretch",
+ $"{fittedLength / MainComponent.RestLength:0.###}×");
+ }
+ }
+
+ if (MainComponent.PathSource == RubberPathSource.Spline) {
+ DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline);
+ } else {
+ EditorGUILayout.HelpBox("The sampled spline is generated from the exact guided path. Detach the rubber before editing knots.",
+ MessageType.Info);
+ }
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
- }
-}
+ }
+
+ private void DrawPathSection()
+ {
+ EditorGUILayout.LabelField("Path", EditorStyles.boldLabel);
+ var source = (RubberPathSource)EditorGUILayout.EnumPopup("Source", MainComponent.PathSource);
+ if (source != MainComponent.PathSource) {
+ if (source == RubberPathSource.Guides) {
+ EditorUtility.DisplayDialog("Guide Bindings Required",
+ "Select the rubber and its guides, then use GameObject > Pinball > Rubber > Bind Selected Guides.",
+ "OK");
+ } else {
+ ScheduleBindingsChange(MainComponent.GuideBindings.ToArray(),
+ Array.Empty(), "Detach Rubber From Guides");
+ }
+ }
+
+ if (MainComponent.PathSource != RubberPathSource.Guides) {
+ EditorGUILayout.HelpBox("VPX imports remain spline-authored and use Legacy collision. Bind explicit guide slots to opt into autofit.",
+ MessageType.Info);
+ return;
+ }
+
+ var expectedBindings = MainComponent.GuideBindings.ToArray();
+ var bindings = expectedBindings.ToArray();
+ for (var i = 0; i < bindings.Length; i++) {
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ var guide = (RubberGuideComponent)EditorGUILayout.ObjectField($"Guide {i + 1}",
+ bindings[i].Guide, typeof(RubberGuideComponent), true);
+ if (guide != bindings[i].Guide) {
+ if (!guide) {
+ ScheduleBindingsChange(expectedBindings,
+ bindings.Where((_, index) => index != i).ToArray(),
+ bindings.Length == 1 ? "Detach Rubber From Guides"
+ : "Remove Rubber Guide Binding");
+ } else {
+ ScheduleGuideReplacement(i, expectedBindings, guide);
+ }
+ guide = bindings[i].Guide;
+ }
+ if (guide && guide.Slots.Length > 0) {
+ var selectedSlot = Array.FindIndex(guide.Slots, slot => slot.Id == bindings[i].SlotId);
+ var slotLabels = guide.Slots.Select((slot, index) => string.IsNullOrEmpty(slot.DisplayName)
+ ? $"Slot {index + 1}" : slot.DisplayName).ToArray();
+ if (selectedSlot < 0) {
+ EditorGUILayout.HelpBox($"Missing slot {bindings[i].SlotId}", MessageType.Error);
+ var labels = new[] { $"Missing ({bindings[i].SlotId})" }
+ .Concat(slotLabels).ToArray();
+ var nextSlot = EditorGUILayout.Popup("Slot", 0, labels);
+ if (nextSlot > 0) {
+ bindings[i].SlotId = guide.Slots[nextSlot - 1].Id;
+ ScheduleBindingsChange(expectedBindings, bindings, "Repair Rubber Guide Slot");
+ }
+ } else {
+ var nextSlot = EditorGUILayout.Popup("Slot", selectedSlot, slotLabels);
+ if (nextSlot != selectedSlot && nextSlot >= 0
+ && nextSlot < guide.Slots.Length) {
+ bindings[i].SlotId = guide.Slots[nextSlot].Id;
+ ScheduleBindingsChange(expectedBindings, bindings, "Change Rubber Guide Slot");
+ }
+ }
+ } else if (guide) {
+ EditorGUILayout.HelpBox("This guide has no slots.", MessageType.Error);
+ }
+ if (GUILayout.Button(bindings.Length == 1 ? "Detach From Guides" : "Remove Binding")) {
+ ScheduleBindingsChange(expectedBindings,
+ bindings.Where((_, index) => index != i).ToArray(),
+ bindings.Length == 1 ? "Detach Rubber From Guides"
+ : "Remove Rubber Guide Binding");
+ }
+ EditorGUILayout.EndVertical();
+ }
+
+ var status = RubberAutofit.GetStatus(MainComponent);
+ EditorGUILayout.HelpBox(status.Message, status.IsValid ? MessageType.Info
+ : status.IsStale ? MessageType.Warning : MessageType.Error);
+ var supporting = MainComponent.BakedPath
+ .Where(element => element.Type == RubberPathElementType.SupportedArc)
+ .Select(element => element.StartBindingIndex).Distinct().Count();
+ EditorGUILayout.LabelField("Supporting / Enclosed",
+ $"{supporting} / {math.max(0, bindings.Length - supporting)}");
+ EditorGUILayout.BeginHorizontal();
+ using (new EditorGUI.DisabledScope(bindings.Length == 0)) {
+ if (GUILayout.Button("Autofit Now")) {
+ RubberGuideCommands.Autofit(MainComponent, "Autofit Rubber");
+ }
+ }
+ if (GUILayout.Button("Detach From Guides")) {
+ ScheduleBindingsChange(expectedBindings, Array.Empty(),
+ "Detach Rubber From Guides");
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+
+ private void ScheduleBindingsChange(RubberGuideBinding[] expectedBindings,
+ RubberGuideBinding[] bindings, string undoName)
+ {
+ if (_bindingChangeScheduled) {
+ return;
+ }
+ expectedBindings = expectedBindings.ToArray();
+ bindings = bindings.ToArray();
+ _bindingChangeScheduled = true;
+ var rubber = MainComponent;
+ EditorApplication.delayCall += () => {
+ if (!this) {
+ return;
+ }
+ _bindingChangeScheduled = false;
+ if (rubber && BindingsMatch(rubber, expectedBindings)) {
+ ApplyBindings(rubber, bindings, undoName);
+ }
+ Repaint();
+ };
+ }
+
+ private void ScheduleGuideReplacement(int bindingIndex, RubberGuideBinding[] expectedBindings,
+ RubberGuideComponent guide)
+ {
+ if (_bindingChangeScheduled) {
+ return;
+ }
+ expectedBindings = expectedBindings.ToArray();
+ _bindingChangeScheduled = true;
+ var rubber = MainComponent;
+ EditorApplication.delayCall += () => {
+ if (!this) {
+ return;
+ }
+ _bindingChangeScheduled = false;
+ if (!rubber || !guide || bindingIndex < 0
+ || bindingIndex >= rubber.GuideBindings.Count) {
+ Repaint();
+ return;
+ }
+ if (!BindingsMatch(rubber, expectedBindings)) {
+ Repaint();
+ return;
+ }
+ if (RubberGuideSlotPickerWindow.TryPick(new[] { guide }, out var selectedBindings)
+ && BindingsMatch(rubber, expectedBindings)) {
+ var bindings = rubber.GuideBindings.ToArray();
+ bindings[bindingIndex] = selectedBindings[0];
+ ApplyBindings(rubber, bindings, "Change Rubber Guide Binding");
+ }
+ Repaint();
+ };
+ }
+
+ private static bool BindingsMatch(RubberComponent rubber, RubberGuideBinding[] expectedBindings)
+ {
+ if (rubber.PathSource != RubberPathSource.Guides
+ || rubber.GuideBindings.Count != expectedBindings.Length) {
+ return false;
+ }
+ for (var i = 0; i < expectedBindings.Length; i++) {
+ var current = rubber.GuideBindings[i];
+ var expected = expectedBindings[i];
+ if (current.Guide != expected.Guide || current.SlotId != expected.SlotId) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static void ApplyBindings(RubberComponent rubber, RubberGuideBinding[] bindings, string undoName)
+ {
+ Undo.IncrementCurrentGroup();
+ Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName);
+ var detaching = bindings.Length == 0;
+ if (detaching) {
+ rubber.DetachFromGuides();
+ rubber.RebuildMeshes();
+ } else if (!RubberAutofit.TryReplaceGuideBindings(rubber, bindings,
+ out _, out var error)) {
+ Undo.RevertAllInCurrentGroup();
+ EditorUtility.DisplayDialog("Rubber Binding Change Failed",
+ $"The previous bindings and sampled path were preserved.\n\n{error}", "OK");
+ return;
+ }
+ EditorUtility.SetDirty(rubber);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(rubber);
+ if (detaching) {
+ var collider = rubber.GetComponent();
+ if (collider) {
+ EditorUtility.SetDirty(collider);
+ PrefabUtility.RecordPrefabInstancePropertyModifications(collider);
+ }
+ }
+ RubberGuideDependencyTracker.RebuildSoon();
+ SceneView.RepaintAll();
+ }
+
+ private void OnSceneGUI()
+ {
+ if (MainComponent.PathSource != RubberPathSource.Guides) {
+ return;
+ }
+ foreach (var element in MainComponent.BakedPath) {
+ Handles.color = element.Type == RubberPathElementType.FreeSpan
+ ? new Color(0.15f, 0.9f, 1f, 1f)
+ : new Color(1f, 0.55f, 0.1f, 1f);
+ DrawPathElement(MainComponent, element);
+ }
+
+ var resolution = RubberGuideResolver.Resolve(MainComponent);
+ if (!resolution.IsValid) {
+ return;
+ }
+ for (var i = 0; i < resolution.Circles.Length; i++) {
+ var circle = resolution.Circles[i];
+ var center = resolution.Plane.BakeToWorld(circle.Center);
+ Handles.color = new Color(0.2f, 0.8f, 1f, 0.8f);
+ Handles.DrawWireDisc(center, resolution.Plane.Normal, Physics.ScaleToWorld(circle.Radius));
+ Handles.color = new Color(1f, 0.75f, 0.2f, 0.8f);
+ Handles.DrawWireDisc(center, resolution.Plane.Normal,
+ Physics.ScaleToWorld(circle.Radius + MainComponent.Thickness * 0.5f));
+ }
+ }
+
+ private static void DrawPathElement(RubberComponent rubber, RubberPathElement element)
+ {
+ if (element.Type == RubberPathElementType.FreeSpan) {
+ Handles.DrawAAPolyLine(3f, BakeToWorld(rubber, element.Start),
+ BakeToWorld(rubber, element.End));
+ return;
+ }
+ var count = math.max(4, (int)math.ceil(element.SweepAngleRad / (math.PI / 24f)));
+ var points = new Vector3[count + 1];
+ for (var i = 0; i <= count; i++) {
+ var angle = element.StartAngleRad + element.SweepAngleRad * i / count;
+ var point = element.Center + new float2(math.cos(angle), math.sin(angle)) * element.Radius;
+ points[i] = BakeToWorld(rubber, point);
+ }
+ Handles.DrawAAPolyLine(3f, points);
+ }
+
+ private static Vector3 BakeToWorld(RubberComponent rubber, float2 point)
+ {
+ var localVpx = rubber.BakeFrameToLocal.MultiplyPoint3x4(new Vector3(point.x, point.y, 0f));
+ return localVpx.TranslateToWorld(rubber.transform);
+ }
+
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs
new file mode 100644
index 000000000..fcb13fb62
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs
@@ -0,0 +1,135 @@
+// 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.
+
+using UnityEditor;
+using Unity.Mathematics;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(RubberSlingshotComponent))]
+ public sealed class RubberSlingshotInspector : UnityEditor.Editor
+ {
+ private SerializedProperty _rubber;
+ private SerializedProperty _span;
+ private SerializedProperty _switchZones;
+ private SerializedProperty _armContactPosition;
+ private SerializedProperty _armRestClearance;
+ private SerializedProperty _armTipTravel;
+ private SerializedProperty _actuator;
+ private SerializedProperty _coilArmVisual;
+ private SerializedProperty _visualRotationAxis;
+ private SerializedProperty _visualRestAngle;
+ private SerializedProperty _visualFiredAngle;
+
+ private void OnEnable()
+ {
+ _rubber = serializedObject.FindProperty("_rubber");
+ _span = serializedObject.FindProperty("_span");
+ _switchZones = serializedObject.FindProperty("_switchZones");
+ _armContactPosition = serializedObject.FindProperty("_armContactPosition01");
+ _armRestClearance = serializedObject.FindProperty("_armRestClearance");
+ _armTipTravel = serializedObject.FindProperty("_armTipTravel");
+ _actuator = serializedObject.FindProperty("_actuator");
+ _coilArmVisual = serializedObject.FindProperty("_coilArmVisual");
+ _visualRotationAxis = serializedObject.FindProperty("_visualRotationAxis");
+ _visualRestAngle = serializedObject.FindProperty("_visualRestAngle");
+ _visualFiredAngle = serializedObject.FindProperty("_visualFiredAngle");
+ }
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+ EditorGUILayout.LabelField("Active Span", EditorStyles.boldLabel);
+ EditorGUILayout.PropertyField(_rubber);
+ EditorGUILayout.PropertyField(_span);
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Switches", EditorStyles.boldLabel);
+ EditorGUILayout.PropertyField(_switchZones, true);
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Actuator", EditorStyles.boldLabel);
+ EditorGUILayout.PropertyField(_actuator);
+ EditorGUILayout.PropertyField(_armContactPosition);
+ EditorGUILayout.PropertyField(_armRestClearance);
+ EditorGUILayout.PropertyField(_armTipTravel);
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Arm Visual", EditorStyles.boldLabel);
+ EditorGUILayout.PropertyField(_coilArmVisual);
+ EditorGUILayout.PropertyField(_visualRotationAxis);
+ EditorGUILayout.PropertyField(_visualRestAngle);
+ EditorGUILayout.PropertyField(_visualFiredAngle);
+ serializedObject.ApplyModifiedProperties();
+
+ if (!RubberSlingshotComponent.RuntimeAvailable) {
+ EditorGUILayout.HelpBox("Physical slingshot runtime unavailable. Authoring and package data are preserved, but this component does not register play-mode controls.",
+ MessageType.Warning);
+ }
+ var component = (RubberSlingshotComponent)target;
+ foreach (var error in component.ValidateConfiguration(includeSceneUniqueness: true)) {
+ EditorGUILayout.HelpBox(error, MessageType.Error);
+ }
+ }
+
+ private void OnSceneGUI()
+ {
+ var component = (RubberSlingshotComponent)target;
+ if (!component.TryResolveSpan(out var resolved, out _)) {
+ return;
+ }
+ var rubber = component.Rubber;
+ var pathDirection = math.normalizesafe(resolved.Element.End - resolved.Element.Start,
+ new float2(1f, 0f));
+ var inward = new float2(-pathDirection.y, pathDirection.x);
+ var start = resolved.IsReversed ? resolved.Element.End : resolved.Element.Start;
+ var end = resolved.IsReversed ? resolved.Element.Start : resolved.Element.End;
+ var worldStart = ToWorld(rubber, start);
+ var worldEnd = ToWorld(rubber, end);
+ Handles.color = new Color(1f, 0.45f, 0.05f);
+ Handles.DrawAAPolyLine(4f, worldStart, worldEnd);
+ foreach (var zone in component.SwitchZones) {
+ var authoredPosition = math.lerp(start, end, zone.Position01);
+ var position = ToWorld(rubber, authoredPosition);
+ var openPosition = ToWorld(rubber,
+ authoredPosition + inward * zone.OpenDeflection);
+ var closePosition = ToWorld(rubber,
+ authoredPosition + inward * zone.CloseDeflection);
+ var size = HandleUtility.GetHandleSize(position) * 0.045f;
+ Handles.SphereHandleCap(0, position, Quaternion.identity, size,
+ EventType.Repaint);
+ Handles.DrawDottedLine(position, openPosition, 3f);
+ Handles.DrawLine(openPosition, closePosition);
+ Handles.DrawWireDisc(openPosition, rubber.transform.up, size * 0.6f);
+ Handles.DrawWireDisc(closePosition, rubber.transform.up, size * 0.9f);
+ }
+ Handles.color = Color.cyan;
+ var armPosition = math.lerp(start, end, component.ArmContactPosition01);
+ var arm = ToWorld(rubber, armPosition);
+ var armRest = ToWorld(rubber,
+ armPosition + inward * component.ArmRestClearance);
+ var armFired = ToWorld(rubber,
+ armPosition - inward * (component.ArmTipTravel - component.ArmRestClearance));
+ var armSize = HandleUtility.GetHandleSize(arm) * 0.06f;
+ Handles.DrawWireDisc(arm, rubber.transform.up, armSize);
+ Handles.DrawDottedLine(arm, armRest, 3f);
+ Handles.DrawLine(armRest, armFired);
+ Handles.DrawWireDisc(armRest, rubber.transform.up, armSize * 0.75f);
+ Handles.DrawWireDisc(armFired, rubber.transform.up, armSize);
+ }
+
+ private static Vector3 ToWorld(RubberComponent rubber,
+ float2 point)
+ {
+ var local = rubber.BakeFrameToLocal.MultiplyPoint3x4(
+ new Vector3(point.x, point.y, 0f));
+ return local.TranslateToWorld(rubber.transform);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs.meta
new file mode 100644
index 000000000..0200422a9
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 27fca657b5a74e298a725fd50c4c36ce
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs
new file mode 100644
index 000000000..0f284d61f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs
@@ -0,0 +1,160 @@
+// 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.
+
+using System.Collections.Generic;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ internal sealed class RubberSlingshotWizard : EditorWindow
+ {
+ private RubberComponent _rubber;
+ private SlingshotActuatorAsset _actuator;
+ private int _spanIndex;
+ private readonly List _spans = new();
+ private string[] _spanLabels = System.Array.Empty();
+
+ [MenuItem("GameObject/Pinball/Create Physical Slingshot On Rubber", false, 21)]
+ private static void Open()
+ {
+ var window = CreateInstance();
+ window.titleContent = new GUIContent("Physical Slingshot");
+ window._rubber = Selection.activeGameObject
+ ? Selection.activeGameObject.GetComponent()
+ : null;
+ window.RefreshSpans();
+ window.ShowUtility();
+ }
+
+ [MenuItem("GameObject/Pinball/Create Physical Slingshot On Rubber", true)]
+ private static bool CanOpen() => Selection.activeGameObject
+ && Selection.activeGameObject.GetComponent();
+
+ private void OnGUI()
+ {
+ EditorGUI.BeginChangeCheck();
+ _rubber = (RubberComponent)EditorGUILayout.ObjectField("Rubber", _rubber,
+ typeof(RubberComponent), true);
+ if (EditorGUI.EndChangeCheck()) {
+ RefreshSpans();
+ }
+ using (new EditorGUI.DisabledScope(_spanLabels.Length == 0)) {
+ _spanIndex = EditorGUILayout.Popup("Free Span", _spanIndex, _spanLabels);
+ }
+ _actuator = (SlingshotActuatorAsset)EditorGUILayout.ObjectField("Actuator",
+ _actuator, typeof(SlingshotActuatorAsset), false);
+
+ if (!_rubber || !_rubber.HasValidGuidedPath
+ || !_rubber.TryGetComponent(out var collider)
+ || collider.Mode != RubberColliderMode.Physical) {
+ EditorGUILayout.HelpBox("Choose a current guide-driven Physical rubber.",
+ MessageType.Error);
+ } else if (_spanLabels.Length == 0) {
+ EditorGUILayout.HelpBox("No unambiguous free span is available.",
+ MessageType.Error);
+ }
+
+ using (new EditorGUI.DisabledScope(!_rubber || !_actuator
+ || _spanIndex < 0 || _spanIndex >= _spans.Count)) {
+ if (GUILayout.Button("Create")) {
+ CreateSlingshot();
+ }
+ }
+ }
+
+ private void RefreshSpans()
+ {
+ _spans.Clear();
+ _spanIndex = 0;
+ if (!_rubber || !_rubber.HasValidGuidedPath
+ || !_rubber.TryGetComponent(out var collider)
+ || collider.Mode != RubberColliderMode.Physical) {
+ _spanLabels = System.Array.Empty();
+ return;
+ }
+ var boundSpanIndices = new HashSet();
+ foreach (var component in FindObjectsByType(
+ FindObjectsInactive.Include, FindObjectsSortMode.None)) {
+ if (component.Rubber == _rubber
+ && component.TryResolveSpan(out var resolved, out _)) {
+ boundSpanIndices.Add(resolved.PathElementIndex);
+ }
+ }
+ var freeSpans = _rubber.BakedPath
+ .Select((element, index) => (element, index))
+ .Where(item => item.element.Type == RubberPathElementType.FreeSpan)
+ .ToArray();
+ foreach (var (span, pathIndex) in freeSpans) {
+ var duplicateCount = freeSpans.Count(other =>
+ (other.element.StartBindingIndex == span.StartBindingIndex
+ && other.element.EndBindingIndex == span.EndBindingIndex)
+ || (other.element.StartBindingIndex == span.EndBindingIndex
+ && other.element.EndBindingIndex == span.StartBindingIndex));
+ if (duplicateCount == 1 && !boundSpanIndices.Contains(pathIndex)) {
+ _spans.Add(new RubberSpanBinding(
+ _rubber.GuideBindings[span.StartBindingIndex],
+ _rubber.GuideBindings[span.EndBindingIndex]));
+ }
+ }
+ _spanLabels = _spans.Select(SpanLabel).ToArray();
+ }
+
+ private static string SpanLabel(RubberSpanBinding span)
+ {
+ var start = BindingLabel(span.StartSupport);
+ var end = BindingLabel(span.EndSupport);
+ return $"{start} -> {end}";
+ }
+
+ private static string BindingLabel(RubberGuideBinding binding)
+ {
+ if (!binding.Guide || !binding.Guide.TryGetSlot(binding.SlotId, out var slot)) {
+ return "Missing support";
+ }
+ return $"{binding.Guide.name}/{slot.DisplayName}";
+ }
+
+ private void CreateSlingshot()
+ {
+ var selected = _spans[_spanIndex];
+ RefreshSpans();
+ var refreshedIndex = _spans.FindIndex(candidate =>
+ SameBinding(candidate, selected));
+ if (refreshedIndex < 0) {
+ EditorUtility.DisplayDialog("Cannot create physical slingshot",
+ "The selected free span changed or is no longer available. Select it again.",
+ "OK");
+ return;
+ }
+ _spanIndex = refreshedIndex;
+ var gameObject = new GameObject($"{_rubber.name} Slingshot");
+ Undo.RegisterCreatedObjectUndo(gameObject, "Create Physical Slingshot");
+ gameObject.transform.SetParent(_rubber.transform.parent, false);
+ var component = Undo.AddComponent(gameObject);
+ component.Rubber = _rubber;
+ component.Span = selected;
+ component.Actuator = _actuator;
+ EditorUtility.SetDirty(component);
+ Selection.activeGameObject = gameObject;
+ Close();
+ }
+
+ private static bool SameBinding(RubberSpanBinding left, RubberSpanBinding right)
+ {
+ return (SameSupport(left.StartSupport, right.StartSupport)
+ && SameSupport(left.EndSupport, right.EndSupport))
+ || (SameSupport(left.StartSupport, right.EndSupport)
+ && SameSupport(left.EndSupport, right.StartSupport));
+ }
+
+ private static bool SameSupport(RubberGuideBinding left, RubberGuideBinding right)
+ => left.Guide == right.Guide && left.SlotId == right.SlotId;
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs.meta
new file mode 100644
index 000000000..9db3dde73
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 661b64b0143d4bda851693ad67a20bc5
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs
new file mode 100644
index 000000000..419c5ccca
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs
@@ -0,0 +1,239 @@
+// 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.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using UnityEditor;
+using UnityEngine;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(SlingshotActuatorAsset))]
+ public sealed class SlingshotActuatorInspector : UnityEditor.Editor
+ {
+ private static readonly string[] ScalarFields = {
+ "SupplyVoltage", "CoilResistanceOhm", "TurnOffClampVoltage",
+ "StrokeMeters", "MaximumCurrentAmps", "EffectiveMovingMassKg",
+ "ReturnSpringNewtonPerMeter", "ReturnSpringPreloadNewton",
+ "ViscousDampingNewtonSecondPerMeter", "EndStopStiffnessNewtonPerMeter",
+ "EndStopDampingNewtonSecondPerMeter",
+ };
+
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+ foreach (var field in ScalarFields) {
+ EditorGUILayout.PropertyField(serializedObject.FindProperty(field));
+ }
+ serializedObject.ApplyModifiedProperties();
+
+ var actuator = (SlingshotActuatorAsset)target;
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Calibration LUT", EditorStyles.boldLabel);
+ EditorGUILayout.LabelField("Grid",
+ $"{actuator.CurrentSampleCount} current x {actuator.StrokeSampleCount} stroke samples");
+ if (GUILayout.Button("Import current, stroke, force, flux CSV")) {
+ ImportCsv(actuator);
+ }
+ foreach (var error in actuator.ValidateData()) {
+ EditorGUILayout.HelpBox(error, MessageType.Error);
+ }
+ DrawHeatMap("Force (N)", actuator.ForceNewtonLut,
+ actuator.CurrentSampleCount, actuator.StrokeSampleCount);
+ DrawHeatMap("Flux linkage (Wb-turn)", actuator.FluxLinkageWeberTurnLut,
+ actuator.CurrentSampleCount, actuator.StrokeSampleCount);
+ }
+
+ private static void ImportCsv(SlingshotActuatorAsset actuator)
+ {
+ var path = EditorUtility.OpenFilePanel("Import slingshot actuator calibration",
+ string.Empty, "csv");
+ if (string.IsNullOrEmpty(path)) {
+ return;
+ }
+ string csv;
+ try {
+ csv = File.ReadAllText(path);
+ } catch (Exception exception) when (exception is IOException
+ or UnauthorizedAccessException) {
+ EditorUtility.DisplayDialog("Cannot read actuator CSV", exception.Message, "OK");
+ return;
+ }
+ if (!SlingshotActuatorCsv.TryParse(csv, out var data,
+ out var error)) {
+ EditorUtility.DisplayDialog("Invalid actuator CSV", error, "OK");
+ return;
+ }
+ Undo.RecordObject(actuator, "Import Slingshot Actuator Calibration");
+ actuator.MaximumCurrentAmps = data.MaximumCurrentAmps;
+ actuator.StrokeMeters = data.StrokeMeters;
+ actuator.CurrentSampleCount = data.CurrentSampleCount;
+ actuator.StrokeSampleCount = data.StrokeSampleCount;
+ actuator.ForceNewtonLut = data.ForceNewton;
+ actuator.FluxLinkageWeberTurnLut = data.FluxLinkageWeberTurn;
+ EditorUtility.SetDirty(actuator);
+ }
+
+ private static void DrawHeatMap(string label, float[] values, int rows, int columns)
+ {
+ if (values == null || rows < 1 || columns < 1 || values.Length != rows * columns
+ || values.Any(value => !float.IsFinite(value))) {
+ return;
+ }
+ EditorGUILayout.LabelField(label, EditorStyles.miniBoldLabel);
+ var rect = GUILayoutUtility.GetRect(0f, Mathf.Min(120f, rows * 8f),
+ GUILayout.ExpandWidth(true));
+ var minimum = values.Min();
+ var maximum = values.Max();
+ for (var row = 0; row < rows; row++) {
+ for (var column = 0; column < columns; column++) {
+ var value = values[row * columns + column];
+ var normalized = maximum > minimum
+ ? Mathf.InverseLerp(minimum, maximum, value)
+ : 0f;
+ var cell = new Rect(
+ rect.x + column * rect.width / columns,
+ rect.y + (rows - row - 1) * rect.height / rows,
+ rect.width / columns + 1f,
+ rect.height / rows + 1f);
+ EditorGUI.DrawRect(cell, Color.Lerp(new Color(0.05f, 0.15f, 0.45f),
+ new Color(1f, 0.35f, 0.05f), normalized));
+ }
+ }
+ EditorGUILayout.LabelField($"{minimum:g5} .. {maximum:g5}", EditorStyles.miniLabel);
+ }
+ }
+
+ public readonly struct SlingshotActuatorCsvData
+ {
+ public readonly int CurrentSampleCount;
+ public readonly int StrokeSampleCount;
+ public readonly float MaximumCurrentAmps;
+ public readonly float StrokeMeters;
+ public readonly float[] ForceNewton;
+ public readonly float[] FluxLinkageWeberTurn;
+
+ public SlingshotActuatorCsvData(int currentSampleCount, int strokeSampleCount,
+ float maximumCurrentAmps, float strokeMeters, float[] forceNewton,
+ float[] fluxLinkageWeberTurn)
+ {
+ CurrentSampleCount = currentSampleCount;
+ StrokeSampleCount = strokeSampleCount;
+ MaximumCurrentAmps = maximumCurrentAmps;
+ StrokeMeters = strokeMeters;
+ ForceNewton = forceNewton;
+ FluxLinkageWeberTurn = fluxLinkageWeberTurn;
+ }
+ }
+
+ public static class SlingshotActuatorCsv
+ {
+ private readonly struct Row
+ {
+ public readonly float Current;
+ public readonly float Stroke;
+ public readonly float Force;
+ public readonly float Flux;
+
+ public Row(float current, float stroke, float force, float flux)
+ {
+ Current = current;
+ Stroke = stroke;
+ Force = force;
+ Flux = flux;
+ }
+ }
+
+ public static bool TryParse(string csv, out SlingshotActuatorCsvData data,
+ out string error)
+ {
+ data = default;
+ var lines = (csv ?? string.Empty).Split(new[] { '\r', '\n' },
+ StringSplitOptions.RemoveEmptyEntries);
+ if (lines.Length < 5) {
+ error = "CSV needs a header and a rectangular grid with at least 2 x 2 samples.";
+ return false;
+ }
+ var header = lines[0].Split(',').Select(value => value.Trim().ToLowerInvariant()).ToArray();
+ var expectedHeader = new[] { "current_amps", "stroke_meters", "force_newton", "flux_weber_turn" };
+ if (!header.SequenceEqual(expectedHeader)) {
+ error = $"Expected header: {string.Join(",", expectedHeader)}";
+ return false;
+ }
+
+ var rows = new List(lines.Length - 1);
+ for (var line = 1; line < lines.Length; line++) {
+ var fields = lines[line].Split(',');
+ if (fields.Length != 4
+ || !fields.All(field => float.TryParse(field.Trim(), NumberStyles.Float,
+ CultureInfo.InvariantCulture, out _))) {
+ error = $"Line {line + 1} must contain four finite numbers.";
+ return false;
+ }
+ var values = fields.Select(field => float.Parse(field.Trim(),
+ NumberStyles.Float, CultureInfo.InvariantCulture)).ToArray();
+ if (values.Any(value => !float.IsFinite(value))) {
+ error = $"Line {line + 1} contains a non-finite number.";
+ return false;
+ }
+ rows.Add(new Row(values[0], values[1], values[2], values[3]));
+ }
+
+ var currents = rows.Select(row => row.Current).Distinct().OrderBy(value => value).ToArray();
+ var strokes = rows.Select(row => row.Stroke).Distinct().OrderBy(value => value).ToArray();
+ if (currents.Length < 2 || strokes.Length < 2
+ || rows.Count != currents.Length * strokes.Length) {
+ error = "CSV must contain one sample for every point of a grid at least 2 x 2.";
+ return false;
+ }
+ if (Mathf.Abs(currents[0]) > 1e-7f || Mathf.Abs(strokes[0]) > 1e-7f
+ || currents[^1] <= 0f || strokes[^1] <= 0f) {
+ error = "Current and stroke grids must start at zero and end at positive maxima.";
+ return false;
+ }
+ if (!IsUniform(currents) || !IsUniform(strokes)) {
+ error = "Current and stroke sample coordinates must be uniformly spaced.";
+ return false;
+ }
+
+ var force = new float[rows.Count];
+ var flux = new float[rows.Count];
+ var assigned = new bool[rows.Count];
+ foreach (var row in rows) {
+ var current = Array.IndexOf(currents, row.Current);
+ var stroke = Array.IndexOf(strokes, row.Stroke);
+ var index = current * strokes.Length + stroke;
+ if (assigned[index]) {
+ error = "CSV contains a duplicate current/stroke coordinate.";
+ return false;
+ }
+ assigned[index] = true;
+ force[index] = row.Force;
+ flux[index] = row.Flux;
+ }
+ data = new SlingshotActuatorCsvData(currents.Length, strokes.Length,
+ currents[^1], strokes[^1], force, flux);
+ error = null;
+ return true;
+ }
+
+ private static bool IsUniform(IReadOnlyList values)
+ {
+ var interval = values[^1] / (values.Count - 1);
+ for (var i = 1; i < values.Count; i++) {
+ if (Mathf.Abs(values[i] - interval * i) > Mathf.Max(1e-7f, interval * 1e-5f)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs.meta
new file mode 100644
index 000000000..1e0a1a0d0
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 49409e237fd44940adafd29d4f3ea3aa
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/Physics/LegacySlingshotTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs
new file mode 100644
index 000000000..8d15f2e4f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs
@@ -0,0 +1,27 @@
+// 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.
+
+using NUnit.Framework;
+
+namespace VisualPinball.Unity.Test
+{
+ public class LegacySlingshotTests
+ {
+ [TestCase(0f, 0f)]
+ [TestCase(0.25f, 0.375f)]
+ [TestCase(0.5f, 0.5f)]
+ [TestCase(0.75f, 0.375f)]
+ [TestCase(1f, 0f)]
+ public void ShouldKeepHistoricalParabolicForceDistribution(float position01,
+ float expected)
+ {
+ Assert.That(LineSlingshotCollider.LegacyForceFactor(position01 * 100f, 100f),
+ Is.EqualTo(expected).Within(1e-6f));
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs.meta
new file mode 100644
index 000000000..de0bb2ddb
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7832a040bba54c5fa74d458f9a81537c
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs
new file mode 100644
index 000000000..407dda4bb
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs
@@ -0,0 +1,397 @@
+// 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.
+
+using System;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using UnityEngine;
+using UnityEngine.TestTools;
+using VisualPinball.Engine.Math;
+
+namespace VisualPinball.Unity.Test
+{
+ public class SweptCircleColliderTests
+ {
+ [Test]
+ public void ShouldHitRoundCordSideWithoutTunneling()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(0f, 100f, 0f),
+ Velocity = new float3(0f, -1000f, 0f),
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collision, in ball, 0.2f);
+
+ Assert.That(hitTime, Is.EqualTo(0.097f).Within(1e-5f));
+ Assert.That(math.distance(collision.HitNormal, new float3(0f, 1f, 0f)), Is.LessThan(1e-5f));
+ }
+
+ [Test]
+ public void ShouldHitSphericalEndCap()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(15f, 0f, 0f),
+ Velocity = new float3(-10f, 0f, 0f),
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collision, in ball, 1f);
+
+ Assert.That(hitTime, Is.EqualTo(0.2f).Within(1e-5f));
+ Assert.That(collision.HitNormal.x, Is.EqualTo(1f).Within(1e-5f));
+ }
+
+ [Test]
+ public void ShouldHitEndCapWhileMovingParallelToAxis()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(-20f, 2f, 0f),
+ Velocity = new float3(10f, 0f, 0f),
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collision, in ball, 1f);
+
+ Assert.That(hitTime, Is.EqualTo((10f - math.sqrt(5f)) / 10f).Within(1e-5f));
+ Assert.That(collision.HitNormal.y, Is.GreaterThan(0f));
+ }
+
+ [Test]
+ public void ShouldMissOutsideCapsuleRadius()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(20f, 4f, 0f),
+ Velocity = new float3(-40f, 0f, 0f),
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ Assert.That(collider.HitTest(ref collision, in ball, 1f), Is.EqualTo(-1f));
+ }
+
+ [Test]
+ public void ShouldReportRestingContactWithoutJitterImpulse()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(0f, 3.001f, 0f),
+ Velocity = float3.zero,
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collision, in ball, 0.01f);
+
+ Assert.That(hitTime, Is.Zero);
+ Assert.That(collision.IsContact, Is.True);
+ Assert.That(collision.HitOrgNormalVelocity, Is.Zero);
+ }
+
+ [Test]
+ public void ShouldReportSlightPenetrationAsRestingContact()
+ {
+ var collider = CreateCollider();
+ var ball = new BallState {
+ Position = new float3(0f, 2.99f, 0f),
+ Velocity = float3.zero,
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ var hitTime = collider.HitTest(ref collision, in ball, 0.01f);
+
+ Assert.That(hitTime, Is.Zero);
+ Assert.That(collision.IsContact, Is.True);
+ Assert.That(collision.HitDistance, Is.EqualTo(-0.01f).Within(1e-5f));
+ }
+
+ [Test]
+ public void ShouldTreatDegenerateCordSegmentAsSphere()
+ {
+ var collider = new SweptCircleCollider(float3.zero, float3.zero, 2f,
+ new ColliderInfo { ItemId = 1 });
+ var ball = new BallState {
+ Position = new float3(0f, 10f, 0f),
+ Velocity = new float3(0f, -10f, 0f),
+ Radius = 1f,
+ };
+ var collision = new CollisionEventData();
+
+ Assert.That(collider.HitTest(ref collision, in ball, 1f),
+ Is.EqualTo(0.7f).Within(1e-5f));
+ Assert.That(collision.HitNormal.y, Is.EqualTo(1f).Within(1e-5f));
+ }
+
+ [Test]
+ public void ShouldIncludeCordRadiusInBounds()
+ {
+ var collider = CreateCollider();
+
+ Assert.That(math.distance(collider.Bounds.Aabb.Min, new float3(-12f, -2f, -2f)), Is.LessThan(1e-5f));
+ Assert.That(math.distance(collider.Bounds.Aabb.Max, new float3(12f, 2f, 2f)), Is.LessThan(1e-5f));
+ }
+
+ [Test]
+ public void ShouldTransformEndpointsAndRadiusWithUniformScale()
+ {
+ var collider = CreateCollider().Transform(float4x4.TRS(
+ new float3(3f, 4f, 5f), quaternion.RotateZ(math.PI * 0.5f), new float3(2f)));
+
+ Assert.That(math.distance(collider.V1, new float3(3f, -16f, 5f)), Is.LessThan(1e-5f));
+ Assert.That(math.distance(collider.V2, new float3(3f, 24f, 5f)), Is.LessThan(1e-5f));
+ Assert.That(collider.CordRadius, Is.EqualTo(4f).Within(1e-5f));
+ }
+
+ [Test]
+ public void ShouldRejectShearedTransform()
+ {
+ var shear = float4x4.identity;
+ shear.c1.x = 0.25f;
+
+ Assert.That(SweptCircleCollider.IsTransformable(shear), Is.False);
+ Assert.Throws(() => CreateCollider().Transform(shear));
+ }
+
+ [Test]
+ public void ShouldSurviveNativeColliderAllocation()
+ {
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ references.Add(CreateCollider(), float4x4.identity);
+ using var native = new NativeColliders(ref references, Allocator.Temp);
+
+ Assert.That(native.Length, Is.EqualTo(1));
+ Assert.That(native.GetHeader(0).Type, Is.EqualTo(ColliderType.SweptCircle));
+ Assert.That(native.SweptCircle(0).CordRadius, Is.EqualTo(2f));
+ Assert.That(native.ToArray()[0], Is.TypeOf());
+ } finally {
+ references.Dispose();
+ transforms.Dispose();
+ }
+ }
+
+ [Test]
+ public void KinematicRegistrationShouldKeepCordInLocalSpace()
+ {
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var kinematicTransforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp, true);
+ var matrix = float4x4.TRS(new float3(3f, 4f, 5f), quaternion.RotateZ(0.25f),
+ new float3(2f, 3f, 4f));
+ try {
+ references.Add(CreateCollider(), matrix);
+
+ Assert.That(references.SweptCircleColliders[0].Header.IsTransformed, Is.False);
+ Assert.That(math.distance(references.SweptCircleColliders[0].V1,
+ new float3(-10f, 0f, 0f)), Is.LessThan(1e-5f));
+ Assert.That(transforms.TryGetValue(1, out var storedMatrix), Is.True);
+ Assert.That(storedMatrix.Equals(matrix), Is.True);
+ var ball = new BallState {
+ Position = matrix.MultiplyPoint(new float3(0f, 4f, 0f)),
+ Velocity = matrix.MultiplyVector(new float3(0f, -10f, 0f)),
+ Radius = 1f,
+ };
+ ball.Transform(math.inverse(storedMatrix));
+ var collision = new CollisionEventData();
+ Assert.That(references.SweptCircleColliders[0].HitTest(
+ ref collision, in ball, 0.2f), Is.EqualTo(0.1f).Within(1e-5f));
+
+ kinematicTransforms.Add(1, matrix);
+ references.TransformToIdentity(ref kinematicTransforms);
+ Assert.That(references.SweptCircleColliders[0].Header.IsTransformed, Is.False);
+ } finally {
+ references.Dispose();
+ kinematicTransforms.Dispose();
+ transforms.Dispose();
+ }
+ }
+
+ [Test]
+ public void StaticRegistrationShouldRejectNonuniformScale()
+ {
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp);
+ var matrix = float4x4.Scale(new float3(1f, 2f, 1f));
+ try {
+ Assert.Throws(() => references.Add(
+ CreateCollider(), matrix));
+ } finally {
+ references.Dispose();
+ transforms.Dispose();
+ }
+ }
+
+ [Test]
+ public void ArcChordSubdivisionShouldRespectSagittaTolerance()
+ {
+ const float radius = 30f;
+ const float tolerance = RubberPhysicalColliderGenerator.ArcChordToleranceVpx;
+ var angle = RubberPhysicalColliderGenerator.MaximumChordAngle(radius, tolerance);
+
+ var sagitta = radius * (1f - math.cos(angle * 0.5f));
+ Assert.That(sagitta, Is.LessThanOrEqualTo(tolerance + 1e-5f));
+ }
+
+ [Test]
+ public void ThinCordShouldUseRadiusRelativeChordTolerance()
+ {
+ Assert.That(RubberPhysicalColliderGenerator.ChordTolerance(0.5f),
+ Is.EqualTo(RubberPhysicalColliderGenerator.ArcChordRadiusFraction * 0.5f)
+ .Within(1e-6f));
+ Assert.That(RubberPhysicalColliderGenerator.ChordTolerance(4f),
+ Is.EqualTo(RubberPhysicalColliderGenerator.ArcChordToleranceVpx)
+ .Within(1e-6f));
+ }
+
+ [Test]
+ public void InvalidPhysicalRubberShouldGenerateLegacyFallbackColliders()
+ {
+ var go = new GameObject("Rubber");
+ go.SetActive(false);
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ var rubber = go.AddComponent();
+ rubber.DragPoints = new[] {
+ new DragPointData(-10f, -10f),
+ new DragPointData(-10f, 10f),
+ new DragPointData(10f, 10f),
+ new DragPointData(10f, -10f),
+ };
+ var collider = go.AddComponent();
+ collider.Mode = RubberColliderMode.Physical;
+ var api = new TestRubberApi(go);
+ LogAssert.Expect(LogType.Warning,
+ "Physical rubber 'Rubber' has no current valid guided bake; falling back to Legacy collision.");
+
+ api.GenerateColliders(ref references);
+
+ Assert.That(references.Count, Is.GreaterThan(0));
+ Assert.That(references.SweptCircleColliders.Length, Is.Zero);
+ } finally {
+ references.Dispose();
+ transforms.Dispose();
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void PhysicalRubberGeneratorShouldCreateContinuousRoundCordSegments()
+ {
+ var go = new GameObject("Rubber");
+ go.SetActive(false);
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ var rubber = go.AddComponent();
+ go.AddComponent();
+ rubber._thickness = 8;
+ rubber.SetGuideBindings(Array.Empty());
+ rubber.ApplyGuidedBake(new[] {
+ new RubberPathElement {
+ Type = RubberPathElementType.FreeSpan,
+ Start = new float2(-10f, 0f),
+ End = new float2(10f, 0f),
+ },
+ new RubberPathElement {
+ Type = RubberPathElementType.SupportedArc,
+ Start = new float2(10f, 0f),
+ End = new float2(0f, 10f),
+ Center = float2.zero,
+ Radius = 10f,
+ StartAngleRad = 0f,
+ SweepAngleRad = math.PI * 0.5f,
+ },
+ }, null, Matrix4x4.identity, default, 1);
+ var api = new RubberApi(go, null, null);
+
+ new RubberPhysicalColliderGenerator(api, rubber, float4x4.identity)
+ .GenerateColliders(2f, ref references, 1f);
+
+ Assert.That(references.SweptCircleColliders.Length, Is.GreaterThan(2));
+ Assert.That(references.SweptCircleColliders[0].CordRadius, Is.EqualTo(5f));
+ Assert.That(references.SweptCircleColliders[0].V1.z, Is.EqualTo(2f));
+ for (var i = 1; i < references.SweptCircleColliders.Length; i++) {
+ Assert.That(math.distance(references.SweptCircleColliders[i - 1].V2,
+ references.SweptCircleColliders[i].V1), Is.LessThan(1e-5f));
+ }
+ } finally {
+ references.Dispose();
+ transforms.Dispose();
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void PhysicalRubberGeneratorShouldApplyOffsetAlongBakeNormal()
+ {
+ var go = new GameObject("Rubber");
+ go.SetActive(false);
+ var transforms = new NativeParallelHashMap(1, Allocator.Temp);
+ var references = new ColliderReference(ref transforms, Allocator.Temp);
+ try {
+ var rubber = go.AddComponent();
+ go.AddComponent();
+ rubber._thickness = 8;
+ rubber.SetGuideBindings(Array.Empty());
+ var bakeFrame = Matrix4x4.identity;
+ bakeFrame.SetColumn(0, new Vector4(0f, 1f, 0f, 0f));
+ bakeFrame.SetColumn(1, new Vector4(0f, 0f, 1f, 0f));
+ bakeFrame.SetColumn(2, new Vector4(1f, 0f, 0f, 0f));
+ bakeFrame.SetColumn(3, new Vector4(3f, 4f, 5f, 1f));
+ rubber.ApplyGuidedBake(new[] {
+ new RubberPathElement {
+ Type = RubberPathElementType.FreeSpan,
+ Start = new float2(-10f, 0f),
+ End = new float2(10f, 0f),
+ },
+ }, null, bakeFrame, default, 1);
+ var api = new RubberApi(go, null, null);
+
+ new RubberPhysicalColliderGenerator(api, rubber, float4x4.identity)
+ .GenerateColliders(2f, ref references, 0f);
+
+ Assert.That(references.SweptCircleColliders.Length, Is.EqualTo(1));
+ Assert.That(math.distance(references.SweptCircleColliders[0].V1,
+ new float3(5f, -6f, 5f)), Is.LessThan(1e-5f));
+ Assert.That(math.distance(references.SweptCircleColliders[0].V2,
+ new float3(5f, 14f, 5f)), Is.LessThan(1e-5f));
+ } finally {
+ references.Dispose();
+ transforms.Dispose();
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+ }
+
+ private sealed class TestRubberApi : RubberApi
+ {
+ internal TestRubberApi(GameObject go) : base(go, null, null)
+ {
+ }
+
+ internal void GenerateColliders(ref ColliderReference references)
+ => CreateColliders(ref references, float4x4.identity, 0f);
+ }
+
+ private static SweptCircleCollider CreateCollider()
+ {
+ return new SweptCircleCollider(new float3(-10f, 0f, 0f),
+ new float3(10f, 0f, 0f), 2f, new ColliderInfo { ItemId = 1 });
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs.meta
new file mode 100644
index 000000000..bb2bb6e7b
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: c2f1ec9944a444f989cfaa77f6ffbf1f
+timeCreated: 1783936782
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs
new file mode 100644
index 000000000..67676871a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs
@@ -0,0 +1,435 @@
+// 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.
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using Unity.Mathematics;
+using UnityEngine;
+using VisualPinball.Engine.Math;
+
+namespace VisualPinball.Unity.Test
+{
+ public class RubberAutofitTests
+ {
+ [Test]
+ public void ShouldRejectEmptyGuideSet()
+ {
+ var result = RubberCircleHullSolver.Solve(Array.Empty(), 4f);
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Error, Does.Contain("At least one"));
+ }
+
+ [Test]
+ public void ShouldWrapOneGuideWithFullSupportedArc()
+ {
+ var result = RubberCircleHullSolver.Solve(new[] {
+ new RubberGuideCircle(new float2(10f, 20f), 12f, 3),
+ }, 4f);
+
+ Assert.That(result.IsValid, Is.True, result.Error);
+ Assert.That(result.Elements, Has.Length.EqualTo(1));
+ Assert.That(result.Elements[0].Type, Is.EqualTo(RubberPathElementType.SupportedArc));
+ Assert.That(result.Elements[0].Radius, Is.EqualTo(16f).Within(1e-5f));
+ Assert.That(result.Elements[0].SweepAngleRad, Is.EqualTo(2f * math.PI).Within(1e-5f));
+ Assert.That(result.SupportingBindingIndices, Is.EqualTo(new[] { 3 }));
+ }
+
+ [Test]
+ public void ShouldWrapTwoUnequalGuidesWithTwoStraightTangents()
+ {
+ var result = RubberCircleHullSolver.Solve(new[] {
+ new RubberGuideCircle(new float2(-40f, 0f), 12f, 0),
+ new RubberGuideCircle(new float2(50f, 10f), 20f, 1),
+ }, 4f);
+
+ Assert.That(result.IsValid, Is.True, result.Error);
+ Assert.That(result.Elements.Count(element => element.Type == RubberPathElementType.FreeSpan),
+ Is.EqualTo(2));
+ Assert.That(result.Elements.Count(element => element.Type == RubberPathElementType.SupportedArc),
+ Is.EqualTo(2));
+ Assert.That(result.SupportingBindingIndices, Is.EqualTo(new[] { 0, 1 }));
+ AssertClosed(result.Elements);
+ }
+
+ [Test]
+ public void ShouldExcludeContainedAndDuplicateGuidesDeterministically()
+ {
+ var result = RubberCircleHullSolver.Solve(new[] {
+ new RubberGuideCircle(new float2(0f), 20f, 4),
+ new RubberGuideCircle(new float2(0f), 20f, 1),
+ new RubberGuideCircle(new float2(3f, 0f), 4f, 2),
+ }, 2f);
+
+ Assert.That(result.IsValid, Is.True, result.Error);
+ Assert.That(result.SupportingBindingIndices, Is.EqualTo(new[] { 1 }));
+ Assert.That(result.EnclosedBindingIndices, Is.EqualTo(new[] { 2, 4 }));
+ }
+
+ [Test]
+ public void ShouldTreatCollinearMiddleGuideAsNonSupporting()
+ {
+ var result = RubberCircleHullSolver.Solve(new[] {
+ new RubberGuideCircle(new float2(-50f, 0f), 10f, 0),
+ new RubberGuideCircle(new float2(0f, 0f), 10f, 1),
+ new RubberGuideCircle(new float2(50f, 0f), 10f, 2),
+ }, 4f);
+
+ Assert.That(result.IsValid, Is.True, result.Error);
+ Assert.That(result.SupportingBindingIndices, Is.EqualTo(new[] { 0, 2 }));
+ Assert.That(result.EnclosedBindingIndices, Does.Contain(1));
+ AssertClosed(result.Elements);
+ }
+
+ [Test]
+ public void ShouldProduceSamePathForShuffledInput()
+ {
+ var guides = new[] {
+ new RubberGuideCircle(new float2(-60f, -10f), 12f, 0),
+ new RubberGuideCircle(new float2(20f, 50f), 9f, 1),
+ new RubberGuideCircle(new float2(70f, -20f), 15f, 2),
+ new RubberGuideCircle(new float2(0f, 0f), 3f, 3),
+ };
+ var shuffled = new[] { guides[2], guides[0], guides[3], guides[1] };
+
+ var expected = RubberCircleHullSolver.Solve(guides, 4f);
+ var actual = RubberCircleHullSolver.Solve(shuffled, 4f);
+
+ Assert.That(expected.IsValid, Is.True, expected.Error);
+ Assert.That(actual.IsValid, Is.True, actual.Error);
+ Assert.That(actual.Elements, Has.Length.EqualTo(expected.Elements.Length));
+ for (var i = 0; i < expected.Elements.Length; i++) {
+ AssertElementEqual(expected.Elements[i], actual.Elements[i], i);
+ }
+ }
+
+ [Test]
+ public void ShouldBakeSplineWithinToleranceAndKeepFreeSpansStraight()
+ {
+ var fit = RubberCircleHullSolver.Solve(new[] {
+ new RubberGuideCircle(new float2(-45f, 0f), 13f, 0),
+ new RubberGuideCircle(new float2(55f, 8f), 18f, 1),
+ }, 4f);
+ Assert.That(fit.IsValid, Is.True, fit.Error);
+
+ var baked = RubberPathSplineBaker.TryCreateDragPoints(fit.Elements,
+ Matrix4x4.identity, 0.05f, out var dragPoints, out var deviation, out var error);
+
+ Assert.That(baked, Is.True, error);
+ Assert.That(deviation, Is.LessThanOrEqualTo(0.05f));
+ var vertices = DragPoint.GetRgVertex(dragPoints, true, 0.0001f);
+ foreach (var span in fit.Elements.Where(element => element.Type == RubberPathElementType.FreeSpan)) {
+ AssertRenderedSpanIsStraight(vertices, span);
+ }
+ }
+
+ [Test]
+ public void ShouldResolveUnityGuideSlotsAndDetectStaleBake()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideAObject = new GameObject("Guide A");
+ var guideBObject = new GameObject("Guide B");
+ try {
+ guideBObject.transform.position = new Vector3(0.1f, 0f, 0f);
+ var rubber = rubberObject.AddComponent();
+ var guideA = AddGuide(guideAObject, 0.01f);
+ var guideB = AddGuide(guideBObject, 0.012f);
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guideA, guideA.Slots[0].Id),
+ new RubberGuideBinding(guideB, guideB.Slots[0].Id),
+ });
+
+ var baked = RubberAutofit.TryBake(rubber, out var fit, out var error);
+
+ Assert.That(baked, Is.True, error);
+ Assert.That(fit.IsValid, Is.True, fit.Error);
+ Assert.That(RubberAutofit.GetStatus(rubber).IsValid, Is.True);
+ guideBObject.transform.position += new Vector3(0.01f, 0f, 0f);
+ var status = RubberAutofit.GetStatus(rubber);
+ Assert.That(status.IsValid, Is.False);
+ Assert.That(status.IsStale, Is.True);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideAObject);
+ UnityEngine.Object.DestroyImmediate(guideBObject);
+ }
+ }
+
+ [Test]
+ public void ShouldRejectNonuniformGuideProfileScale()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var guide = AddGuide(guideObject, 0.01f);
+ guideObject.transform.localScale = new Vector3(2f, 1f, 1f);
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guide, guide.Slots[0].Id),
+ });
+
+ var resolution = RubberGuideResolver.Resolve(rubber);
+
+ Assert.That(resolution.IsValid, Is.False);
+ Assert.That(resolution.Error, Does.Contain("nonuniform"));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void FailedRebindShouldRetainLastBakedGeometry()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var guide = AddGuide(guideObject, 0.01f);
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guide, guide.Slots[0].Id),
+ });
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+ var previousPath = rubber.BakedPath.ToArray();
+
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guide, SerializedGuid.New()),
+ });
+ var baked = RubberAutofit.TryBake(rubber, out _, out var error);
+
+ Assert.That(baked, Is.False);
+ Assert.That(error, Does.Contain("missing slot"));
+ Assert.That(rubber.BakedPath, Is.EqualTo(previousPath));
+ Assert.That(RubberAutofit.GetStatus(rubber).IsValid, Is.False);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void FailedGuidedReplacementShouldRestoreCurrentBake()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var guide = AddGuide(guideObject, 0.01f);
+ var binding = new RubberGuideBinding(guide, guide.Slots[0].Id);
+ rubber.SetGuideBindings(new[] { binding });
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+ var previousPath = rubber.BakedPath.ToArray();
+ var previousVersion = rubber.BakeVersion;
+ var previousHash = rubber.BakeInputHash;
+ var previousFrame = rubber.BakeFrameToLocal;
+
+ var replaced = RubberAutofit.TryReplaceGuideBindings(rubber, new[] {
+ new RubberGuideBinding(guide, SerializedGuid.New()),
+ }, out _, out var error);
+
+ Assert.That(replaced, Is.False);
+ Assert.That(error, Does.Contain("missing slot"));
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Guides));
+ Assert.That(rubber.GuideBindings.Count, Is.EqualTo(1));
+ Assert.That(rubber.GuideBindings[0].Guide, Is.SameAs(binding.Guide));
+ Assert.That(rubber.GuideBindings[0].SlotId, Is.EqualTo(binding.SlotId));
+ Assert.That(rubber.BakedPath, Is.EqualTo(previousPath));
+ Assert.That(rubber.BakeVersion, Is.EqualTo(previousVersion));
+ Assert.That(rubber.BakeInputHash, Is.EqualTo(previousHash));
+ Assert.That(rubber.BakeFrameToLocal, Is.EqualTo(previousFrame));
+ Assert.That(RubberAutofit.GetStatus(rubber).IsValid, Is.True);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void GuidedReplacementShouldRejectSplineRubberWithoutMutation()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var guide = AddGuide(guideObject, 0.01f);
+
+ var replaced = RubberAutofit.TryReplaceGuideBindings(rubber, new[] {
+ new RubberGuideBinding(guide, guide.Slots[0].Id),
+ }, out _, out var error);
+
+ Assert.That(replaced, Is.False);
+ Assert.That(error, Does.Contain("guided rubber"));
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
+ Assert.That(rubber.GuideBindings, Is.Empty);
+ Assert.That(rubber.BakedPath, Is.Empty);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void DetachingGuidedRubberShouldKeepSampledSpline()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var collider = rubberObject.AddComponent();
+ collider.Mode = RubberColliderMode.Physical;
+ var guide = AddGuide(guideObject, 0.01f);
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guide, guide.Slots[0].Id),
+ });
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+ var sampledPoints = rubber.DragPoints.Select(point => point.Center).ToArray();
+
+ rubber.DetachFromGuides();
+ rubber.RebuildMeshes();
+
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
+ Assert.That(rubber.GuideBindings, Is.Empty);
+ Assert.That(rubber.BakedPath, Is.Empty);
+ Assert.That(rubber.DragPoints.Select(point => point.Center), Is.EqualTo(sampledPoints));
+ Assert.That(collider.Mode, Is.EqualTo(RubberColliderMode.Legacy));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void FailedSplineConversionShouldRestoreManualAuthority()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var rubber = rubberObject.AddComponent();
+ rubber.DragPoints = new[] {
+ new DragPointData(-10f, -10f),
+ new DragPointData(-10f, 10f),
+ new DragPointData(10f, 10f),
+ new DragPointData(10f, -10f),
+ };
+ rubber.RestLength = 123f;
+ var originalPoints = rubber.DragPoints.Select(point => point.Center).ToArray();
+ var guide = AddGuide(guideObject, 0.01f);
+
+ var converted = RubberAutofit.TryConvertToGuides(rubber, new[] {
+ new RubberGuideBinding(guide, SerializedGuid.New()),
+ }, out _, out var error);
+
+ Assert.That(converted, Is.False);
+ Assert.That(error, Does.Contain("missing slot"));
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
+ Assert.That(rubber.GuideBindings, Is.Empty);
+ Assert.That(rubber.BakedPath, Is.Empty);
+ Assert.That(rubber.RestLength, Is.EqualTo(123f));
+ Assert.That(rubber.DragPoints.Select(point => point.Center),
+ Is.EqualTo(originalPoints));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void ShouldRejectSelfIntersectingCompiledPath()
+ {
+ var points = new[] {
+ new float2(-10f, -10f),
+ new float2(10f, 10f),
+ new float2(-10f, 10f),
+ new float2(10f, -10f),
+ };
+ var elements = new RubberPathElement[points.Length];
+ var distance = 0f;
+ for (var i = 0; i < points.Length; i++) {
+ var end = points[(i + 1) % points.Length];
+ var length = math.distance(points[i], end);
+ elements[i] = new RubberPathElement {
+ Type = RubberPathElementType.FreeSpan,
+ Start = points[i],
+ End = end,
+ StartDistance = distance,
+ Length = length,
+ };
+ distance += length;
+ }
+
+ var valid = RubberPathValidator.TryValidate(elements,
+ RubberAutofit.SolverEpsilonVpx, out var error);
+
+ Assert.That(valid, Is.False);
+ Assert.That(error, Does.Contain("intersects itself"));
+ }
+
+ private static RubberGuideComponent AddGuide(GameObject gameObject, float radius)
+ {
+ var guide = gameObject.AddComponent();
+ guide.AddSlot(RubberGuideSlot.Create("Default", radius));
+ return guide;
+ }
+
+ private static void AssertClosed(RubberPathElement[] elements)
+ {
+ for (var i = 0; i < elements.Length; i++) {
+ Assert.That(math.distance(elements[i].End, elements[(i + 1) % elements.Length].Start),
+ Is.LessThan(1e-3f), $"path junction {i}");
+ }
+ }
+
+ private static void AssertElementEqual(RubberPathElement expected,
+ RubberPathElement actual, int index)
+ {
+ Assert.That(actual.Type, Is.EqualTo(expected.Type), $"element {index} type");
+ Assert.That(math.distance(actual.Start, expected.Start), Is.LessThan(1e-4f),
+ $"element {index} start");
+ Assert.That(math.distance(actual.End, expected.End), Is.LessThan(1e-4f),
+ $"element {index} end");
+ Assert.That(actual.StartBindingIndex, Is.EqualTo(expected.StartBindingIndex),
+ $"element {index} start binding");
+ Assert.That(actual.EndBindingIndex, Is.EqualTo(expected.EndBindingIndex),
+ $"element {index} end binding");
+ }
+
+ private static void AssertRenderedSpanIsStraight(RenderVertex2D[] vertices,
+ RubberPathElement span)
+ {
+ var start = FindVertex(vertices, span.Start);
+ var end = FindVertex(vertices, span.End);
+ Assert.That(start, Is.GreaterThanOrEqualTo(0), "free-span start control point");
+ Assert.That(end, Is.GreaterThanOrEqualTo(0), "free-span end control point");
+ var delta = span.End - span.Start;
+ for (var i = start;; i = (i + 1) % vertices.Length) {
+ var point = new float2(vertices[i].X, vertices[i].Y);
+ var cross = math.abs(delta.x * (point.y - span.Start.y)
+ - delta.y * (point.x - span.Start.x));
+ Assert.That(cross, Is.LessThan(1e-3f));
+ if (i == end) {
+ break;
+ }
+ }
+ }
+
+ private static int FindVertex(RenderVertex2D[] vertices, float2 point)
+ {
+ for (var i = 0; i < vertices.Length; i++) {
+ if (math.distance(new float2(vertices[i].X, vertices[i].Y), point) < 1e-4f) {
+ return i;
+ }
+ }
+ return -1;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs.meta
new file mode 100644
index 000000000..c2a20be86
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 23f93cddcc7341d49d9cc13b3ea496b0
+timeCreated: 1783936776
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs
new file mode 100644
index 000000000..3f61eb495
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs
@@ -0,0 +1,282 @@
+// 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.
+
+using System;
+using System.Collections;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+using Unity.Mathematics;
+using UnityEngine;
+using UnityEngine.TestTools;
+using VisualPinball.Engine.Math;
+using VisualPinball.Engine.VPT.Rubber;
+using VisualPinball.Unity.Editor;
+using Object = UnityEngine.Object;
+
+namespace VisualPinball.Unity.Test
+{
+ public class RubberGuideTests
+ {
+ [Test]
+ public void ShouldValueCompareAndRepairSlotIds()
+ {
+ var go = new GameObject("Guide");
+ try {
+ var guide = go.AddComponent();
+ var id = new SerializedGuid(10, 20);
+ guide.Slots = new[] {
+ CreateSlot(id, "First", 12f),
+ CreateSlot(id, "Duplicate", 13f),
+ CreateSlot(default, "Empty", 14f),
+ };
+
+ Assert.That(guide.ValidateSlots(), Has.Count.EqualTo(2));
+ Assert.That(guide.RepairDuplicateIds(), Is.EqualTo(2));
+ Assert.That(guide.ValidateSlots(), Is.Empty);
+ Assert.That(guide.Slots[0].Id, Is.EqualTo(id));
+ Assert.That(guide.Slots.Select(slot => slot.Id).Distinct().Count(), Is.EqualTo(3));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundTripGuideSlots()
+ {
+ var sourceObject = new GameObject("Source Guide");
+ var targetObject = new GameObject("Target Guide");
+ try {
+ var source = sourceObject.AddComponent();
+ source.Slots = new[] {
+ new RubberGuideSlot {
+ Id = new SerializedGuid(1, 2),
+ DisplayName = "Upper",
+ LocalHeight = 0.12f,
+ Profile = new RubberGuideProfile {
+ Type = RubberGuideProfileType.Circle,
+ LocalCenter = new float2(0.1f, -0.2f),
+ Radius = 0.04f,
+ ConvexHull = new[] { new float2(1f, 2f) },
+ },
+ RubberSupportFriction = 0.25f,
+ },
+ };
+
+ var target = targetObject.AddComponent();
+ target.Unpack(source.Pack());
+
+ Assert.That(target.Slots, Has.Length.EqualTo(1));
+ Assert.That(target.Slots[0].Id, Is.EqualTo(source.Slots[0].Id));
+ Assert.That(target.Slots[0].DisplayName, Is.EqualTo("Upper"));
+ Assert.That(target.Slots[0].Profile.LocalCenter, Is.EqualTo(new float2(0.1f, -0.2f)));
+ Assert.That(target.Slots[0].Profile.ConvexHull, Is.EqualTo(new[] { new float2(1f, 2f) }));
+ } finally {
+ Object.DestroyImmediate(sourceObject);
+ Object.DestroyImmediate(targetObject);
+ }
+ }
+
+ [Test]
+ public void ShouldDefaultOldRubberPayloadsToSplineAndLegacy()
+ {
+ var go = new GameObject("Rubber");
+ try {
+ var rubber = go.AddComponent();
+ var oldBytes = PackageApi.Packer.Pack(new LegacyRubberPackable {
+ Thickness = 9,
+ DragPoints = new[] { DragPointPackable.From(new DragPointData(10f, 20f)) },
+ });
+
+ rubber.Unpack(oldBytes);
+
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline));
+ Assert.That(rubber.GuideBindings, Is.Empty);
+ Assert.That(rubber.BakedPath, Is.Empty);
+ Assert.That(rubber.RestLength, Is.Zero);
+ Assert.That(rubber.Thickness, Is.EqualTo(9));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundTripGuidedRubberScalarData()
+ {
+ var sourceObject = new GameObject("Source Rubber");
+ var targetObject = new GameObject("Target Rubber");
+ var guideObject = new GameObject("Guide");
+ try {
+ var slot = CreateSlot(new SerializedGuid(4, 5), "Slot", 0.05f);
+ var guide = guideObject.AddComponent();
+ guide.Slots = new[] { slot };
+
+ var source = sourceObject.AddComponent();
+ source.DragPoints = SquareDragPoints();
+ source.SetGuideBindings(new[] { new RubberGuideBinding(guide, slot.Id) });
+ source.RestLength = 250f;
+ var hash = new Hash128(1, 2, 3, 4);
+ var path = new[] {
+ new RubberPathElement {
+ Type = RubberPathElementType.SupportedArc,
+ Start = new float2(1f, 2f),
+ End = new float2(3f, 4f),
+ Center = new float2(2f, 3f),
+ Radius = 10f,
+ SweepAngleRad = math.PI,
+ StartBindingIndex = 0,
+ EndBindingIndex = 0,
+ Length = 31.4f,
+ },
+ };
+ var frame = Matrix4x4.Translate(new Vector3(1f, 2f, 3f));
+ source.ApplyGuidedBake(path, null, frame, hash, 7);
+
+ var target = targetObject.AddComponent();
+ target.Unpack(source.Pack());
+
+ Assert.That(target.PathSource, Is.EqualTo(RubberPathSource.Guides));
+ Assert.That(target.GuideBindings.Count, Is.EqualTo(1));
+ Assert.That(target.GuideBindings[0].Guide, Is.Null);
+ Assert.That(target.GuideBindings[0].SlotId, Is.EqualTo(slot.Id));
+ Assert.That(target.BakedPath.Count, Is.EqualTo(1));
+ Assert.That(target.BakedPath[0].SweepAngleRad, Is.EqualTo(math.PI));
+ Assert.That(target.BakeVersion, Is.EqualTo(7));
+ Assert.That(target.BakeInputHash, Is.EqualTo(hash));
+ Assert.That(target.BakeFrameToLocal, Is.EqualTo(frame));
+ Assert.That(target.RestLength, Is.EqualTo(250f));
+ } finally {
+ Object.DestroyImmediate(sourceObject);
+ Object.DestroyImmediate(targetObject);
+ Object.DestroyImmediate(guideObject);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundTripEveryMaterialCurveField()
+ {
+ var source = ScriptableObject.CreateInstance();
+ var target = ScriptableObject.CreateInstance();
+ try {
+ var key = new Keyframe(1.25f, 3.5f, -2f, 4f, 0.15f, 0.35f) {
+ weightedMode = WeightedMode.Both,
+ };
+ source.NominalStressMpaByStretchRatio = new AnimationCurve(key) {
+ preWrapMode = WrapMode.PingPong,
+ postWrapMode = WrapMode.Loop,
+ };
+ var packer = new RubberPhysicsMaterialPacker();
+ var meta = packer.Pack(42, source, null);
+ var bytes = MetaPackable.PackMeta(meta);
+ packer.Unpack(bytes, target, null);
+
+ var actual = target.NominalStressMpaByStretchRatio;
+ Assert.That(actual.preWrapMode, Is.EqualTo(WrapMode.PingPong));
+ Assert.That(actual.postWrapMode, Is.EqualTo(WrapMode.Loop));
+ Assert.That(actual.keys, Has.Length.EqualTo(1));
+ Assert.That(actual.keys[0].time, Is.EqualTo(key.time));
+ Assert.That(actual.keys[0].value, Is.EqualTo(key.value));
+ Assert.That(actual.keys[0].inTangent, Is.EqualTo(key.inTangent));
+ Assert.That(actual.keys[0].outTangent, Is.EqualTo(key.outTangent));
+ Assert.That(actual.keys[0].inWeight, Is.EqualTo(key.inWeight));
+ Assert.That(actual.keys[0].outWeight, Is.EqualTo(key.outWeight));
+ Assert.That(actual.keys[0].weightedMode, Is.EqualTo(key.weightedMode));
+ } finally {
+ Object.DestroyImmediate(source);
+ Object.DestroyImmediate(target);
+ }
+ }
+
+ [UnityTest]
+ public IEnumerator ShouldRoundTripGuideAndMaterialReferencesThroughPackage()
+ {
+ var package = Path.Combine(Path.GetTempPath(), $"vpe-rubber-guide-{Guid.NewGuid():N}.vpe");
+ GameObject source = null;
+ GameObject imported = null;
+ RubberPhysicsMaterialAsset material = null;
+ try {
+ source = new GameObject("Table");
+ source.AddComponent();
+
+ var guideObject = new GameObject("Guide");
+ guideObject.transform.SetParent(source.transform, false);
+ var guide = guideObject.AddComponent();
+ var slot = CreateSlot(new SerializedGuid(50, 60), "Groove", 0.05f);
+ guide.Slots = new[] { slot };
+
+ var rubberObject = new GameObject("Rubber");
+ rubberObject.transform.SetParent(source.transform, false);
+ var rubber = rubberObject.AddComponent();
+ rubber.DragPoints = SquareDragPoints();
+ rubber.SetGuideBindings(new[] { new RubberGuideBinding(guide, slot.Id) });
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+
+ var collider = rubberObject.AddComponent();
+ collider.Mode = RubberColliderMode.Physical;
+ material = ScriptableObject.CreateInstance();
+ material.name = "Natural Rubber";
+ material.DensityKgPerCubicMeter = 1075f;
+ material.NominalStressMpaByStretchRatio = new AnimationCurve(
+ new Keyframe(1f, 0f), new Keyframe(1.5f, 1.2f));
+ collider.RubberPhysicsMaterial = material;
+
+ new PackageWriter(source).WritePackageSync(package);
+ var task = new RuntimePackageReader(package).ImportIntoScene();
+ while (!task.IsCompleted) {
+ yield return null;
+ }
+ if (task.IsFaulted) {
+ throw task.Exception!.GetBaseException();
+ }
+ imported = task.Result;
+
+ var importedRubber = imported.GetComponentInChildren();
+ var importedGuide = imported.GetComponentInChildren();
+ var importedCollider = importedRubber.GetComponent();
+ Assert.That(importedRubber.GuideBindings[0].Guide, Is.SameAs(importedGuide));
+ Assert.That(importedRubber.HasValidGuidedPath, Is.True);
+ Assert.That(importedCollider.Mode, Is.EqualTo(RubberColliderMode.Physical));
+ Assert.That(importedCollider.RubberPhysicsMaterial, Is.Not.Null);
+ Assert.That(importedCollider.RubberPhysicsMaterial.DensityKgPerCubicMeter, Is.EqualTo(1075f));
+ Assert.That(importedCollider.RubberPhysicsMaterial.NominalStressMpaByStretchRatio.keys,
+ Has.Length.EqualTo(2));
+ } finally {
+ if (source) Object.DestroyImmediate(source);
+ if (imported) Object.DestroyImmediate(imported);
+ if (material) Object.DestroyImmediate(material);
+ File.Delete(package);
+ }
+ }
+
+ private static RubberGuideSlot CreateSlot(SerializedGuid id, string name, float radius)
+ {
+ return new RubberGuideSlot {
+ Id = id,
+ DisplayName = name,
+ Profile = RubberGuideProfile.Circle(radius),
+ };
+ }
+
+ private static DragPointData[] SquareDragPoints()
+ {
+ return new[] {
+ new DragPointData(-10f, -10f),
+ new DragPointData(-10f, 10f),
+ new DragPointData(10f, 10f),
+ new DragPointData(10f, -10f),
+ };
+ }
+
+ private struct LegacyRubberPackable
+ {
+ public int Thickness;
+ public DragPointPackable[] DragPoints;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs.meta
new file mode 100644
index 000000000..52e8530d0
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8b217411006643948226fd106b250b62
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs
new file mode 100644
index 000000000..aa6f52a03
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs
@@ -0,0 +1,389 @@
+// 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.
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+using VisualPinball.Unity.Editor;
+
+namespace VisualPinball.Unity.Test
+{
+ public class RubberSlingshotTests
+ {
+ [Test]
+ public void ShouldResolveStableSpanBindingInBothDirectionsAfterRebake()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideObjects = new[] {
+ new GameObject("Guide A"),
+ new GameObject("Guide B"),
+ new GameObject("Guide C"),
+ };
+ try {
+ guideObjects[0].transform.position = new Vector3(-0.1f, 0f, -0.05f);
+ guideObjects[1].transform.position = new Vector3(0.1f, 0f, -0.05f);
+ guideObjects[2].transform.position = new Vector3(0f, 0f, 0.1f);
+ var guides = guideObjects.Select(AddGuide).ToArray();
+ var rubber = rubberObject.AddComponent();
+ var collider = rubberObject.AddComponent();
+ collider.Mode = RubberColliderMode.Physical;
+ rubber.SetGuideBindings(guides.Select(guide =>
+ new RubberGuideBinding(guide, guide.Slots[0].Id)));
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+ var element = rubber.BakedPath.First(path => path.Type == RubberPathElementType.FreeSpan);
+ var forward = new RubberSpanBinding(
+ rubber.GuideBindings[element.StartBindingIndex],
+ rubber.GuideBindings[element.EndBindingIndex]);
+
+ Assert.That(RubberSpanResolver.TryResolve(rubber, forward,
+ out var resolvedForward, out var forwardError), Is.True, forwardError);
+ Assert.That(resolvedForward.IsReversed, Is.False);
+ Assert.That(resolvedForward.ToPathCoordinate(0.2f), Is.EqualTo(0.2f));
+
+ var reversed = new RubberSpanBinding(forward.EndSupport, forward.StartSupport);
+ Assert.That(RubberSpanResolver.TryResolve(rubber, reversed,
+ out var resolvedReversed, out var reversedError), Is.True, reversedError);
+ Assert.That(resolvedReversed.PathElementIndex,
+ Is.EqualTo(resolvedForward.PathElementIndex));
+ Assert.That(resolvedReversed.IsReversed, Is.True);
+ Assert.That(resolvedReversed.ToPathCoordinate(0.2f), Is.EqualTo(0.8f));
+
+ guideObjects[2].transform.position += new Vector3(0.01f, 0f, 0f);
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out bakeError),
+ Is.True, bakeError);
+ Assert.That(RubberSpanResolver.TryResolve(rubber, reversed,
+ out var afterRebake, out var rebakeError), Is.True, rebakeError);
+ Assert.That(afterRebake.IsReversed, Is.True);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ foreach (var guideObject in guideObjects) {
+ UnityEngine.Object.DestroyImmediate(guideObject);
+ }
+ }
+ }
+
+ [Test]
+ public void ShouldRejectAmbiguousTwoPostSpanBinding()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var guideAObject = new GameObject("Guide A");
+ var guideBObject = new GameObject("Guide B");
+ try {
+ guideAObject.transform.position = new Vector3(-0.1f, 0f, 0f);
+ guideBObject.transform.position = new Vector3(0.1f, 0f, 0f);
+ var guideA = AddGuide(guideAObject);
+ var guideB = AddGuide(guideBObject);
+ var rubber = rubberObject.AddComponent();
+ var collider = rubberObject.AddComponent();
+ collider.Mode = RubberColliderMode.Physical;
+ rubber.SetGuideBindings(new[] {
+ new RubberGuideBinding(guideA, guideA.Slots[0].Id),
+ new RubberGuideBinding(guideB, guideB.Slots[0].Id),
+ });
+ Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError),
+ Is.True, bakeError);
+
+ var result = RubberSpanResolver.TryResolve(rubber,
+ new RubberSpanBinding(rubber.GuideBindings[0], rubber.GuideBindings[1]),
+ out _, out var error);
+
+ Assert.That(result, Is.False);
+ Assert.That(error, Does.Contain("more than one"));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(guideAObject);
+ UnityEngine.Object.DestroyImmediate(guideBObject);
+ }
+ }
+
+ [Test]
+ public void ShouldValidateAndInvertActuatorFluxLut()
+ {
+ var actuator = ScriptableObject.CreateInstance();
+ try {
+ actuator.CurrentSampleCount = 3;
+ actuator.StrokeSampleCount = 2;
+ actuator.MaximumCurrentAmps = 10f;
+ actuator.ForceNewtonLut = new[] { 0f, 0f, 10f, 8f, 20f, 16f };
+ actuator.FluxLinkageWeberTurnLut = new[] {
+ 0f, 0f,
+ 0.01f, 0.008f,
+ 0.02f, 0.016f,
+ };
+
+ Assert.That(actuator.ValidateData(), Is.Empty);
+ Assert.That(actuator.TryGetCurrent(0.012f, 0.5f, out var current), Is.True);
+ Assert.That(current, Is.EqualTo(6.6666665f).Within(1e-5f));
+
+ actuator.FluxLinkageWeberTurnLut[4] = 0.005f;
+ Assert.That(actuator.ValidateData(), Has.Some.Contains("monotone"));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(actuator);
+ }
+ }
+
+ [Test]
+ public void ShouldInvertFluxPlateausAndReportSaturation()
+ {
+ var actuator = ScriptableObject.CreateInstance();
+ try {
+ actuator.CurrentSampleCount = 4;
+ actuator.StrokeSampleCount = 2;
+ actuator.MaximumCurrentAmps = 12f;
+ actuator.ForceNewtonLut = new float[8];
+ actuator.FluxLinkageWeberTurnLut = new[] {
+ 0f, 0f,
+ 0.01f, 0.008f,
+ 0.01f, 0.008f,
+ 0.02f, 0.016f,
+ };
+
+ Assert.That(actuator.ValidateData(), Is.Empty);
+ Assert.That(actuator.TryGetCurrent(0.01f, 0f, out var plateauCurrent,
+ out var plateauSaturated), Is.True);
+ Assert.That(plateauCurrent, Is.EqualTo(4f).Within(1e-5f));
+ Assert.That(plateauSaturated, Is.False);
+ Assert.That(actuator.TryGetCurrent(-1f, 0f, out var minimumCurrent,
+ out var minimumSaturated), Is.True);
+ Assert.That(minimumCurrent, Is.Zero);
+ Assert.That(minimumSaturated, Is.True);
+ Assert.That(actuator.TryGetCurrent(1f, 0f, out var maximumCurrent,
+ out var maximumSaturated), Is.True);
+ Assert.That(maximumCurrent, Is.EqualTo(12f).Within(1e-5f));
+ Assert.That(maximumSaturated, Is.True);
+ Assert.That(actuator.TryGetCurrent(0.01f, float.NaN, out _, out _), Is.False);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(actuator);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundTripEveryActuatorFieldAndRejectInvalidPayload()
+ {
+ var source = ScriptableObject.CreateInstance();
+ var target = ScriptableObject.CreateInstance();
+ try {
+ source.SupplyVoltage = 42f;
+ source.CoilResistanceOhm = 3.2f;
+ source.TurnOffClampVoltage = 47f;
+ source.StrokeMeters = 0.03f;
+ source.MaximumCurrentAmps = 11f;
+ source.EffectiveMovingMassKg = 0.09f;
+ source.ReturnSpringNewtonPerMeter = 275f;
+ source.ReturnSpringPreloadNewton = 6f;
+ source.ViscousDampingNewtonSecondPerMeter = 1.25f;
+ source.EndStopStiffnessNewtonPerMeter = 51000f;
+ source.EndStopDampingNewtonSecondPerMeter = 21f;
+ source.ForceNewtonLut = new[] { 0f, 0f, 25f, 20f };
+ source.FluxLinkageWeberTurnLut = new[] { 0f, 0f, 0.03f, 0.02f };
+ var packer = new SlingshotActuatorPacker();
+ var bytes = MetaPackable.PackMeta(packer.Pack(12, source, null));
+
+ packer.Unpack(bytes, target, null);
+
+ Assert.That(target.SupplyVoltage, Is.EqualTo(42f));
+ Assert.That(target.CoilResistanceOhm, Is.EqualTo(3.2f));
+ Assert.That(target.TurnOffClampVoltage, Is.EqualTo(47f));
+ Assert.That(target.StrokeMeters, Is.EqualTo(0.03f));
+ Assert.That(target.MaximumCurrentAmps, Is.EqualTo(11f));
+ Assert.That(target.EffectiveMovingMassKg, Is.EqualTo(0.09f));
+ Assert.That(target.ReturnSpringNewtonPerMeter, Is.EqualTo(275f));
+ Assert.That(target.ReturnSpringPreloadNewton, Is.EqualTo(6f));
+ Assert.That(target.ViscousDampingNewtonSecondPerMeter, Is.EqualTo(1.25f));
+ Assert.That(target.EndStopStiffnessNewtonPerMeter, Is.EqualTo(51000f));
+ Assert.That(target.EndStopDampingNewtonSecondPerMeter, Is.EqualTo(21f));
+ Assert.That(target.CurrentSampleCount, Is.EqualTo(2));
+ Assert.That(target.StrokeSampleCount, Is.EqualTo(2));
+ Assert.That(target.ForceNewtonLut, Is.EqualTo(source.ForceNewtonLut));
+ Assert.That(target.FluxLinkageWeberTurnLut,
+ Is.EqualTo(source.FluxLinkageWeberTurnLut));
+
+ var invalid = SlingshotActuatorPackable.From(13, source);
+ invalid.FluxLinkageWeberTurnLut = new[] { 0f };
+ var invalidBytes = MetaPackable.PackMeta(invalid);
+ Assert.Throws(() => packer.Unpack(invalidBytes, target, null));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(source);
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+ }
+
+ [Test]
+ public void ShouldImportRectangularActuatorCsvInCurrentMajorOrder()
+ {
+ const string csv = "current_amps,stroke_meters,force_newton,flux_weber_turn\n"
+ + "10,0.02,4,0.04\n"
+ + "0,0,1,0\n"
+ + "10,0,3,0.05\n"
+ + "0,0.02,2,0\n";
+
+ var parsed = SlingshotActuatorCsv.TryParse(csv, out var data, out var error);
+
+ Assert.That(parsed, Is.True, error);
+ Assert.That(data.CurrentSampleCount, Is.EqualTo(2));
+ Assert.That(data.StrokeSampleCount, Is.EqualTo(2));
+ Assert.That(data.ForceNewton, Is.EqualTo(new[] { 1f, 2f, 3f, 4f }));
+ Assert.That(data.FluxLinkageWeberTurn,
+ Is.EqualTo(new[] { 0f, 0f, 0.05f, 0.04f }));
+ }
+
+ [Test]
+ public void ShouldRejectMalformedActuatorCsvGrids()
+ {
+ var invalidCsv = new[] {
+ "wrong,header,columns,here\n0,0,0,0\n0,1,0,0\n1,0,0,0\n1,1,0,0\n",
+ "current_amps,stroke_meters,force_newton,flux_weber_turn\n0,0,0,0\n0,1,0,0\n1,0,0,0\n",
+ "current_amps,stroke_meters,force_newton,flux_weber_turn\n0,0,0,0\n0,1,0,0\n1,0,0,0.1\n1,0,0,0.1\n",
+ "current_amps,stroke_meters,force_newton,flux_weber_turn\n0,0,0,0\n0,1,0,0\n1,0,0,0.1\n1,1,0,0.1\n3,0,0,0.2\n3,1,0,0.2\n",
+ "current_amps,stroke_meters,force_newton,flux_weber_turn\n0,0,0,0\n0,1,0,0\n1,0,0,NaN\n1,1,0,0.1\n",
+ };
+
+ foreach (var csv in invalidCsv) {
+ Assert.That(SlingshotActuatorCsv.TryParse(csv, out _, out var error),
+ Is.False, csv);
+ Assert.That(error, Is.Not.Empty);
+ }
+ }
+
+ [Test]
+ public void InvalidUnpackedSlingshotShouldBeDisabledWithDiagnostic()
+ {
+ var gameObject = new GameObject("Invalid Slingshot");
+ try {
+ var component = gameObject.AddComponent();
+ LogAssert.Expect(LogType.Warning,
+ new Regex("Rubber slingshot 'Invalid Slingshot' was unpacked with an invalid configuration"));
+
+ component.ValidateAfterUnpack();
+
+ Assert.That(component.enabled, Is.False);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(gameObject);
+ }
+ }
+
+ [Test]
+ public void EmptySlingshotReferencesPayloadShouldPreserveExistingReferences()
+ {
+ var rubberObject = new GameObject("Rubber");
+ var slingshotObject = new GameObject("Slingshot");
+ try {
+ var rubber = rubberObject.AddComponent();
+ var component = slingshotObject.AddComponent();
+ component.Rubber = rubber;
+
+ RubberSlingshotReferencesPackable.Unpack(Array.Empty(), component,
+ null, null, null);
+
+ Assert.That(component.Rubber, Is.SameAs(rubber));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(rubberObject);
+ UnityEngine.Object.DestroyImmediate(slingshotObject);
+ }
+ }
+
+ [Test]
+ public void UnavailableCoilShouldBeIgnoredByEventTranslator()
+ {
+ var slingshotObject = new GameObject("Slingshot");
+ var visualObject = new GameObject("Visual");
+ try {
+ slingshotObject.AddComponent();
+ visualObject.transform.SetParent(slingshotObject.transform, false);
+ var translator = visualObject.AddComponent();
+ var flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ var type = typeof(EventTransformComponent);
+ var awake = type.GetMethod("Awake", flags);
+ var start = type.GetMethod("Start", flags);
+ var onDestroy = type.GetMethod("OnDestroy", flags);
+
+ Assert.That(awake, Is.Not.Null);
+ Assert.That(start, Is.Not.Null);
+ Assert.That(onDestroy, Is.Not.Null);
+ Assert.DoesNotThrow(() => awake.Invoke(translator, null));
+ Assert.DoesNotThrow(() => start.Invoke(translator, null));
+ Assert.DoesNotThrow(() => onDestroy.Invoke(translator, null));
+ } finally {
+ UnityEngine.Object.DestroyImmediate(visualObject);
+ UnityEngine.Object.DestroyImmediate(slingshotObject);
+ }
+ }
+
+ [Test]
+ public void ShouldRoundTripEverySlingshotScalarFieldWithoutRuntime()
+ {
+ var sourceObject = new GameObject("Source Slingshot");
+ var targetObject = new GameObject("Target Slingshot");
+ try {
+ var source = sourceObject.AddComponent();
+ var target = targetObject.AddComponent();
+ var startSlot = new SerializedGuid(1, 2);
+ var endSlot = new SerializedGuid(3, 4);
+ source.Span = new RubberSpanBinding(
+ new RubberGuideBinding(null, startSlot),
+ new RubberGuideBinding(null, endSlot));
+ source.SwitchZones = new[] {
+ new SlingshotSwitchZone {
+ Position01 = 0.25f,
+ CloseDeflection = 5f,
+ OpenDeflection = 2f,
+ MinimumClosedTimeMs = 9f,
+ },
+ new SlingshotSwitchZone {
+ Position01 = 0.75f,
+ CloseDeflection = 6f,
+ OpenDeflection = 3f,
+ MinimumClosedTimeMs = 10f,
+ },
+ };
+ source.ArmContactPosition01 = 0.45f;
+ source.ArmRestClearance = 1.5f;
+ source.ArmTipTravel = 19f;
+ source.VisualRotationAxis = Axis.Z;
+ source.VisualRestAngle = -3f;
+ source.VisualFiredAngle = 21f;
+
+ target.Unpack(source.Pack());
+
+ Assert.That(target.Span.StartSupport.SlotId, Is.EqualTo(startSlot));
+ Assert.That(target.Span.EndSupport.SlotId, Is.EqualTo(endSlot));
+ Assert.That(target.SwitchZones.Select(zone => zone.Position01),
+ Is.EqualTo(new[] { 0.25f, 0.75f }));
+ Assert.That(target.SwitchZones.Select(zone => zone.CloseDeflection),
+ Is.EqualTo(new[] { 5f, 6f }));
+ Assert.That(target.SwitchZones.Select(zone => zone.OpenDeflection),
+ Is.EqualTo(new[] { 2f, 3f }));
+ Assert.That(target.SwitchZones.Select(zone => zone.MinimumClosedTimeMs),
+ Is.EqualTo(new[] { 9f, 10f }));
+ Assert.That(target.ArmContactPosition01, Is.EqualTo(0.45f));
+ Assert.That(target.ArmRestClearance, Is.EqualTo(1.5f));
+ Assert.That(target.ArmTipTravel, Is.EqualTo(19f));
+ Assert.That(target.VisualRotationAxis, Is.EqualTo(Axis.Z));
+ Assert.That(target.VisualRestAngle, Is.EqualTo(-3f));
+ Assert.That(target.VisualFiredAngle, Is.EqualTo(21f));
+ Assert.That(RubberSlingshotComponent.RuntimeAvailable, Is.False);
+ } finally {
+ UnityEngine.Object.DestroyImmediate(sourceObject);
+ UnityEngine.Object.DestroyImmediate(targetObject);
+ }
+ }
+
+ private static RubberGuideComponent AddGuide(GameObject gameObject)
+ {
+ var guide = gameObject.AddComponent();
+ guide.AddSlot(RubberGuideSlot.Create("Default", 0.01f));
+ return guide;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs.meta
new file mode 100644
index 000000000..8c527da5e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 63749b8f5aec4176b37d07bd853c12f1
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs
index 379b280a5..95f38abec 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs
@@ -14,20 +14,27 @@
// 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
{
public class RubberTests
{
[Test]
- public void ShouldWriteImportedRubberData()
+ public void ShouldWriteImportedRubberData()
{
const string tmpFileName = "ShouldWriteRubberData.vpx";
var go = VpxImportEngine.ImportIntoScene(VpxPath.Rubber, options: ConvertOptions.SkipNone);
@@ -42,5 +49,111 @@ 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);
+ }
+ }
+
+ [Test]
+ public void ShouldConvertEveryVpxRubberAsManualSplineAndLegacyCollider()
+ {
+ var table = FileTableContainer.Load(VpxPath.Rubber);
+ foreach (var name in new[] { "Rubber1", "Rubber2" }) {
+ var gameObject = new GameObject(name);
+ try {
+ var rubber = gameObject.AddComponent();
+ var collider = gameObject.AddComponent();
+ rubber.SetData(table.Rubber(name).Data).ToArray();
+
+ Assert.That(rubber.PathSource, Is.EqualTo(RubberPathSource.Spline), rubber.name);
+ Assert.That(rubber.GuideBindings, Is.Empty, rubber.name);
+ Assert.That(rubber.BakedPath, Is.Empty, rubber.name);
+ Assert.That(collider.Mode, Is.EqualTo(RubberColliderMode.Legacy), rubber.name);
+ } finally {
+ Object.DestroyImmediate(gameObject);
+ }
+ }
+ }
+
+ 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/Event/EventTranslateComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
index 61b87de37..f850537cf 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Event/EventTranslateComponent.cs
@@ -58,6 +58,9 @@ private void Start()
foreach (var deviceItem in _wireComponent.AvailableWireDestinations) {
if (_wireComponent is ICoilDeviceComponent coilDevice) {
var coil = coilDevice.CoilDevice(deviceItem.Id);
+ if (coil == null) {
+ continue;
+ }
coil.CoilStatusChanged += OnCoilChanged;
}
}
@@ -71,6 +74,9 @@ private void OnDestroy()
foreach (var deviceItem in _wireComponent.AvailableWireDestinations) {
if (_wireComponent is ICoilDeviceComponent coilDevice) {
var coil = coilDevice.CoilDevice(deviceItem.Id);
+ if (coil == null) {
+ continue;
+ }
coil.CoilStatusChanged -= OnCoilChanged;
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
index 43776bb74..5fb88d2c6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
@@ -434,6 +434,10 @@ internal float HitTest(ref NativeColliders colliders, int colliderId, ref BallSt
return colliders.Spinner(colliderId).HitTest(ref newCollEvent, ref InsideOfs, in ball,
ball.CollisionEvent.HitTime);
+ case ColliderType.SweptCircle:
+ return colliders.SweptCircle(colliderId).HitTest(ref newCollEvent, in ball,
+ ball.CollisionEvent.HitTime);
+
case ColliderType.Triangle:
return colliders.Triangle(colliderId).HitTest(ref newCollEvent, in InsideOfs, in ball,
ball.CollisionEvent.HitTime);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
index 953bef7d4..e12416346 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs
@@ -100,6 +100,11 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r
line3DCollider.Collide(ref ball, ref state.EventQueue, in ball.CollisionEvent, ref state);
break;
+ case ColliderType.SweptCircle:
+ ref var sweptCircleCollider = ref colliders.SweptCircle(colliderId);
+ sweptCircleCollider.Collide(ref ball, ref state.EventQueue, in ball.CollisionEvent, ref state);
+ break;
+
case ColliderType.Point:
ref var pointCollider = ref colliders.Point(colliderId);
pointCollider.Collide(ref ball, ref state.EventQueue, in ball.CollisionEvent, ref state);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs
index cb4a26d84..5b1a59173 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs
@@ -231,6 +231,10 @@ await ReadPackablesAsync(
packableBytes,
progress,
cancellationToken);
+ var slingshots = _table.GetComponentsInChildren(true);
+ foreach (var slingshot in slingshots) {
+ slingshot.ValidateAfterUnpack();
+ }
readRefsStopwatch.Stop();
Logger.Info($"RuntimePackageReader: Restored references in {readRefsStopwatch.ElapsedMilliseconds}ms.");
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
index e10246139..bd7dac4c6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs
@@ -79,6 +79,8 @@ public unsafe ColliderBounds Bounds() {
return ((PlungerCollider*) collider)->Bounds;
case ColliderType.Spinner:
return ((SpinnerCollider*) collider)->Bounds;
+ case ColliderType.SweptCircle:
+ return ((SweptCircleCollider*) collider)->Bounds;
case ColliderType.Triangle:
return ((TriangleCollider*) collider)->Bounds;
default:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs
index cd603cafb..43be23940 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs
@@ -37,6 +37,7 @@ public struct ColliderReference : IDisposable
internal NativeList PlungerColliders;
internal NativeList PointColliders;
internal NativeList SpinnerColliders;
+ internal NativeList SweptCircleColliders;
internal NativeList TriangleColliders;
internal NativeList PlaneColliders;
@@ -62,6 +63,7 @@ public ColliderReference(ref NativeParallelHashMap nonTransformab
PlungerColliders = new NativeList(allocator);
PointColliders = new NativeList(allocator);
SpinnerColliders = new NativeList(allocator);
+ SweptCircleColliders = new NativeList(allocator);
TriangleColliders = new NativeList(allocator);
PlaneColliders = new NativeList(allocator);
@@ -85,6 +87,7 @@ public void Dispose()
PlungerColliders.Dispose();
PointColliders.Dispose();
SpinnerColliders.Dispose();
+ SweptCircleColliders.Dispose();
TriangleColliders.Dispose();
PlaneColliders.Dispose();
using (var enumerator = _itemIdToColliderIds.GetEnumerator()) {
@@ -149,6 +152,16 @@ public void TransformToIdentity(ref NativeParallelHashMap itemIdT
line3DCollider.Transform(Line3DColliders[lookup.Index], math.inverse(matrix));
break;
+ case ColliderType.SweptCircle:
+ ref var sweptCircleCollider = ref SweptCircleColliders.GetElementAsRef(lookup.Index);
+ #if UNITY_EDITOR
+ if (sweptCircleCollider.Header.IsTransformed) {
+ throw new InvalidOperationException("A transformed swept-circle collider shouldn't have been added as a kinematic collider.");
+ }
+ #endif
+ sweptCircleCollider.TransformAabb(math.inverse(matrix));
+ break;
+
case ColliderType.Triangle:
ref var triangleCollider = ref TriangleColliders.GetElementAsRef(lookup.Index);
#if UNITY_EDITOR
@@ -248,6 +261,7 @@ private ICollider LookupCollider(int i)
case ColliderType.Plunger: return PlungerColliders.GetElementAsRef(lookup.Index);
case ColliderType.Point: return PointColliders.GetElementAsRef(lookup.Index);
case ColliderType.Spinner: return SpinnerColliders.GetElementAsRef(lookup.Index);
+ case ColliderType.SweptCircle: return SweptCircleColliders.GetElementAsRef(lookup.Index);
case ColliderType.Triangle: return TriangleColliders.GetElementAsRef(lookup.Index);
case ColliderType.Plane: return PlaneColliders.GetElementAsRef(lookup.Index);
}
@@ -348,6 +362,32 @@ internal void Add(Line3DCollider collider, float4x4 matrix)
Line3DColliders.Add(collider);
}
+ internal void Add(SweptCircleCollider collider, float4x4 matrix)
+ {
+ // A static round cord is baked into playfield space, so non-uniform scale would
+ // change its circular cross-section into geometry this collider cannot represent.
+ if (!IsKinematic && !SweptCircleCollider.IsTransformable(matrix)) {
+ throw new InvalidOperationException("A swept-circle collider requires a uniformly scaled transform.");
+ }
+ if (IsKinematic) {
+ // Kinematic colliders use the engine's local-space ball transform path. As with
+ // other non-transformable colliders, non-uniform scale keeps the ball radius
+ // unchanged and is therefore an approximation, but remains safe at runtime.
+ if (!_nonTransformableColliderTransforms.ContainsKey(collider.Header.ItemId)) {
+ _nonTransformableColliderTransforms.Add(collider.Header.ItemId, matrix);
+ }
+ collider.Header.IsTransformed = false;
+ collider.TransformAabb(matrix);
+ } else {
+ collider.Header.IsTransformed = true;
+ collider.Transform(matrix);
+ }
+ collider.Id = Lookups.Length;
+ TrackReference(collider.Header.ItemId, collider.Header.Id);
+ Lookups.Add(new ColliderLookup(ColliderType.SweptCircle, SweptCircleColliders.Length));
+ SweptCircleColliders.Add(collider);
+ }
+
internal void Add(LineSlingshotCollider collider, float4x4 matrix)
{
if (!IsKinematic && LineSlingshotCollider.IsTransformable(matrix)) {
@@ -533,6 +573,9 @@ public ICollider[] ToArray()
case ColliderType.Spinner:
array[i] = SpinnerColliders[lookup.Index];
break;
+ case ColliderType.SweptCircle:
+ array[i] = SweptCircleColliders[lookup.Index];
+ break;
case ColliderType.Triangle:
array[i] = TriangleColliders[lookup.Index];
break;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineSlingshotCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineSlingshotCollider.cs
index 1738f0290..857558a96 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineSlingshotCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineSlingshotCollider.cs
@@ -113,10 +113,7 @@ public void Collide(ref BallState ball, ref NativeQueue.ParallelWrite
// distance to hit from V1
var btd = (hitPoint.x - V1.x) * hitNormal.y - (hitPoint.y - V1.y) * hitNormal.x;
- var force = math.abs(len) > 1.0e-6f ? (btd + btd) / len - 1.0f : -1.0f; // -1..+1
-
- //!! maximum value 0.5 ...I think this should have been 1.0...oh well
- force = 0.5f * (1.0f - force * force);
+ var force = LegacyForceFactor(btd, len);
// will match the previous physics
force *= _force; //-80;
@@ -141,9 +138,18 @@ public void Collide(ref BallState ball, ref NativeQueue.ParallelWrite
// m_slingshotanim.m_TimeReset = g_pplayer->m_time_msec + 100;
}
}
- }
-
- #endregion
+ }
+
+ internal static float LegacyForceFactor(float distanceFromV1, float length)
+ {
+ var normalized = math.abs(length) > 1.0e-6f
+ ? (distanceFromV1 + distanceFromV1) / length - 1.0f
+ : -1.0f; // -1..+1
+ // Historical VP behavior peaks at 0.5. Keep this exact in Legacy mode.
+ return 0.5f * (1.0f - normalized * normalized);
+ }
+
+ #endregion
#region Transformation
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs
new file mode 100644
index 000000000..89869a556
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs
@@ -0,0 +1,251 @@
+// 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.
+
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Engine.Common;
+using VisualPinball.Engine.VPT;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// A round cord segment: the Minkowski sum of a line segment and a sphere.
+ /// Ball CCD is an analytic ray/capsule test using the sum of both radii.
+ ///
+ internal struct SweptCircleCollider : ICollider
+ {
+ public int Id {
+ get => Header.Id;
+ set {
+ Header.Id = value;
+ var bounds = Bounds;
+ bounds.ColliderId = value;
+ Bounds = bounds;
+ }
+ }
+
+ public ColliderHeader Header;
+ public float3 V1;
+ public float3 V2;
+ public float CordRadius;
+ public ColliderBounds Bounds { get; private set; }
+
+ public SweptCircleCollider(float3 v1, float3 v2, float cordRadius,
+ ColliderInfo info) : this()
+ {
+ Header.Init(info, ColliderType.SweptCircle);
+ V1 = v1;
+ V2 = v2;
+ CordRadius = cordRadius;
+ CalculateBounds();
+ }
+
+ public float HitTest(ref CollisionEventData collEvent, in BallState ball, float dTime)
+ {
+ var radius = ball.Radius + CordRadius;
+ var axis = V2 - V1;
+ var axisLengthSquared = math.lengthsq(axis);
+ if (radius <= 0f) {
+ return -1f;
+ }
+
+ var isDegenerate = axisLengthSquared <= 1e-10f;
+ var closest = isDegenerate
+ ? V1
+ : ClosestPoint(ball.Position, V1, axis, axisLengthSquared);
+ var separation = ball.Position - closest;
+ var distance = math.length(separation);
+ var normal = SafeNormal(separation, ball.Velocity, axis);
+ var normalVelocity = math.dot(normal, ball.Velocity);
+ var hitDistance = distance - radius;
+ // The capsule is convex, so the closest-point normal defines a separating
+ // plane. A ball moving away from that plane cannot hit another feature.
+ if (normalVelocity > PhysicsConstants.ContactVel) {
+ return -1f;
+ }
+
+ float hitTime;
+ var isContact = false;
+ if (hitDistance < PhysicsConstants.PhysTouch) {
+ if (math.abs(normalVelocity) <= PhysicsConstants.ContactVel) {
+ isContact = true;
+ hitTime = 0f;
+ } else {
+ hitTime = math.max(0f, -hitDistance / normalVelocity);
+ }
+ } else {
+ hitTime = isDegenerate
+ ? FirstRaySphereHit(ball.Position, ball.Velocity, V1, radius, dTime)
+ : FirstRayCapsuleHit(ball.Position, ball.Velocity, V1, V2,
+ radius, dTime);
+ if (!float.IsFinite(hitTime) || hitTime < 0f) {
+ return -1f;
+ }
+ var hitPosition = ball.Position + ball.Velocity * hitTime;
+ closest = isDegenerate
+ ? V1
+ : ClosestPoint(hitPosition, V1, axis, axisLengthSquared);
+ normal = SafeNormal(hitPosition - closest, ball.Velocity, axis);
+ normalVelocity = math.dot(normal, ball.Velocity);
+ if (normalVelocity > PhysicsConstants.ContactVel) {
+ return -1f;
+ }
+ hitDistance = math.distance(hitPosition, closest) - radius;
+ }
+
+ if (!float.IsFinite(hitTime) || hitTime < 0f || hitTime > dTime) {
+ return -1f;
+ }
+ collEvent.HitNormal = normal;
+ collEvent.IsContact = isContact;
+ if (isContact) {
+ collEvent.HitOrgNormalVelocity = normalVelocity;
+ }
+ collEvent.HitDistance = hitDistance;
+ return hitTime;
+ }
+
+ public void Collide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents,
+ in CollisionEventData collEvent, ref PhysicsState state)
+ {
+ var impactSpeed = -math.dot(collEvent.HitNormal, ball.Velocity);
+ BallCollider.Collide3DWall(ref ball, in Header.Material, in collEvent,
+ in collEvent.HitNormal, ref state);
+ if (Header.FireEvents && impactSpeed >= Header.Threshold) {
+ Collider.FireHitEvent(ref ball, ref hitEvents, in Header);
+ }
+ }
+
+ public static bool IsTransformable(float4x4 matrix)
+ {
+ var scale = matrix.GetScale();
+ if (!Collider.HasEqualScale(scale.x, scale.y)
+ || !Collider.HasEqualScale(scale.x, scale.z)
+ || scale.x <= Collider.Tolerance) {
+ return false;
+ }
+
+ var x = matrix.c0.xyz / scale.x;
+ var y = matrix.c1.xyz / scale.y;
+ var z = matrix.c2.xyz / scale.z;
+ return math.abs(math.dot(x, y)) <= Collider.Tolerance
+ && math.abs(math.dot(x, z)) <= Collider.Tolerance
+ && math.abs(math.dot(y, z)) <= Collider.Tolerance;
+ }
+
+ public SweptCircleCollider Transform(float4x4 matrix)
+ {
+ Transform(this, matrix);
+ return this;
+ }
+
+ public void Transform(SweptCircleCollider collider, float4x4 matrix)
+ {
+ if (!IsTransformable(matrix)) {
+ throw new System.InvalidOperationException("A swept-circle collider requires uniform scale.");
+ }
+ V1 = matrix.MultiplyPoint(collider.V1);
+ V2 = matrix.MultiplyPoint(collider.V2);
+ CordRadius = collider.CordRadius * math.abs(matrix.GetScale().x);
+ CalculateBounds();
+ }
+
+ public Aabb GetTransformedAabb(float4x4 matrix)
+ {
+ var v1 = matrix.MultiplyPoint(V1);
+ var v2 = matrix.MultiplyPoint(V2);
+ var radius = CordRadius * math.cmax(math.abs(matrix.GetScale()));
+ return new Aabb(math.min(v1, v2) - radius, math.max(v1, v2) + radius);
+ }
+
+ public SweptCircleCollider TransformAabb(float4x4 matrix)
+ {
+ Bounds = new ColliderBounds(Header.ItemId, Header.Id, GetTransformedAabb(matrix));
+ return this;
+ }
+
+ private void CalculateBounds()
+ {
+ Bounds = new ColliderBounds(Header.ItemId, Header.Id,
+ new Aabb(math.min(V1, V2) - CordRadius, math.max(V1, V2) + CordRadius));
+ }
+
+ private static float3 ClosestPoint(float3 point, float3 start, float3 axis,
+ float axisLengthSquared)
+ {
+ return start + axis * math.saturate(math.dot(point - start, axis) / axisLengthSquared);
+ }
+
+ private static float3 SafeNormal(float3 separation, float3 velocity, float3 axis)
+ {
+ var lengthSquared = math.lengthsq(separation);
+ if (lengthSquared > 1e-12f) {
+ return separation / math.sqrt(lengthSquared);
+ }
+ if (math.lengthsq(velocity) > 1e-12f) {
+ return -math.normalize(velocity);
+ }
+ var perpendicular = math.cross(axis, new float3(0f, 0f, 1f));
+ if (math.lengthsq(perpendicular) <= 1e-12f) {
+ perpendicular = math.cross(axis, new float3(0f, 1f, 0f));
+ }
+ return math.normalizesafe(perpendicular, new float3(1f, 0f, 0f));
+ }
+
+ private static float FirstRayCapsuleHit(float3 origin, float3 velocity,
+ float3 start, float3 end, float radius, float maximumTime)
+ {
+ var axis = end - start;
+ var axisLengthSquared = math.lengthsq(axis);
+ var offset = origin - start;
+ var offsetAlongAxis = math.dot(offset, axis);
+ var velocityAlongAxis = math.dot(velocity, axis);
+ var velocitySquared = math.lengthsq(velocity);
+ var offsetVelocity = math.dot(offset, velocity);
+
+ var best = float.PositiveInfinity;
+ var a = axisLengthSquared * velocitySquared - velocityAlongAxis * velocityAlongAxis;
+ var b = axisLengthSquared * offsetVelocity - offsetAlongAxis * velocityAlongAxis;
+ var c = axisLengthSquared * (math.lengthsq(offset) - radius * radius)
+ - offsetAlongAxis * offsetAlongAxis;
+ if (math.abs(a) > 1e-10f) {
+ var discriminant = b * b - a * c;
+ if (discriminant >= 0f) {
+ var root = (-b - math.sqrt(discriminant)) / a;
+ var axial = offsetAlongAxis + root * velocityAlongAxis;
+ if (root >= 0f && root <= maximumTime && axial > 0f
+ && axial < axisLengthSquared) {
+ best = root;
+ }
+ }
+ }
+
+ best = math.min(best, FirstRaySphereHit(origin, velocity, start, radius, maximumTime));
+ best = math.min(best, FirstRaySphereHit(origin, velocity, end, radius, maximumTime));
+ return float.IsFinite(best) ? best : -1f;
+ }
+
+ private static float FirstRaySphereHit(float3 origin, float3 velocity,
+ float3 center, float radius, float maximumTime)
+ {
+ var offset = origin - center;
+ var a = math.lengthsq(velocity);
+ if (a <= 1e-12f) {
+ return float.PositiveInfinity;
+ }
+ var b = math.dot(offset, velocity);
+ var c = math.lengthsq(offset) - radius * radius;
+ var discriminant = b * b - a * c;
+ if (discriminant < 0f) {
+ return float.PositiveInfinity;
+ }
+ var root = (-b - math.sqrt(discriminant)) / a;
+ return root >= 0f && root <= maximumTime ? root : float.PositiveInfinity;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs.meta
new file mode 100644
index 000000000..59984b3e2
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 4236a15b75ec48c69ca0e6ef9c3a0334
+timeCreated: 1783936780
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs
index 6e02c768a..ef8aeb5d9 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs
@@ -34,5 +34,6 @@ public enum ColliderType
Spinner,
Triangle,
TriggerCircle,
+ SweptCircle,
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs
index 85e969682..8c356e3e1 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs
@@ -59,6 +59,7 @@ public unsafe struct NativeColliders : IDisposable
[NativeDisableUnsafePtrRestriction] private void* m_PlungerColliderBuffer;
[NativeDisableUnsafePtrRestriction] private void* m_PointColliderBuffer;
[NativeDisableUnsafePtrRestriction] private void* m_SpinnerColliderBuffer;
+ [NativeDisableUnsafePtrRestriction] private void* m_SweptCircleColliderBuffer;
[NativeDisableUnsafePtrRestriction] private void* m_TriangleColliderBuffer;
[NativeDisableUnsafePtrRestriction] private void* m_PlaneColliderBuffer;
@@ -137,6 +138,10 @@ public NativeColliders(ref ColliderReference colRef, Allocator allocator)
m_SpinnerColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator);
UnsafeUtility.MemCpy(m_SpinnerColliderBuffer, colRef.SpinnerColliders.GetUnsafePtr(), size);
+ size = UnsafeUtility.SizeOf() * colRef.SweptCircleColliders.Length;
+ m_SweptCircleColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator);
+ UnsafeUtility.MemCpy(m_SweptCircleColliderBuffer, colRef.SweptCircleColliders.GetUnsafePtr(), size);
+
size = UnsafeUtility.SizeOf() * colRef.TriangleColliders.Length;
m_TriangleColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator);
UnsafeUtility.MemCpy(m_TriangleColliderBuffer, colRef.TriangleColliders.GetUnsafePtr(), size);
@@ -282,6 +287,17 @@ internal ref SpinnerCollider Spinner(int colliderId)
return ref UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index);
}
+ internal ref SweptCircleCollider SweptCircle(int colliderId)
+ {
+ ref var lookup = ref UnsafeUtility.ArrayElementAsRef(m_LookupBuffer, colliderId);
+#if ENABLE_UNITY_COLLECTIONS_CHECKS
+ if (lookup.Type != ColliderType.SweptCircle) {
+ throw new ArgumentException($"Invalid collider type {lookup.Type} when looking up swept-circle collider {colliderId}.");
+ }
+#endif
+ return ref UnsafeUtility.ArrayElementAsRef(m_SweptCircleColliderBuffer, lookup.Index);
+ }
+
internal ref TriangleCollider Triangle(int colliderId)
{
ref var lookup = ref UnsafeUtility.ArrayElementAsRef(m_LookupBuffer, colliderId);
@@ -329,6 +345,7 @@ public ICollider this[int index]
case ColliderType.Plunger: return UnsafeUtility.ReadArrayElement