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(m_PlungerColliderBuffer, lookup.Index); case ColliderType.Point: return UnsafeUtility.ReadArrayElement(m_PointColliderBuffer, lookup.Index); case ColliderType.Spinner: return UnsafeUtility.ReadArrayElement(m_SpinnerColliderBuffer, lookup.Index); + case ColliderType.SweptCircle: return UnsafeUtility.ReadArrayElement(m_SweptCircleColliderBuffer, lookup.Index); case ColliderType.Triangle: return UnsafeUtility.ReadArrayElement(m_TriangleColliderBuffer, lookup.Index); case ColliderType.Plane: return UnsafeUtility.ReadArrayElement(m_PlaneColliderBuffer, lookup.Index); } @@ -389,6 +406,9 @@ public ICollider this[int index] case ColliderType.Spinner: UnsafeUtility.WriteArrayElement(m_SpinnerColliderBuffer, lookup.Index, (SpinnerCollider)value); break; + case ColliderType.SweptCircle: + UnsafeUtility.WriteArrayElement(m_SweptCircleColliderBuffer, lookup.Index, (SweptCircleCollider)value); + break; case ColliderType.Triangle: UnsafeUtility.WriteArrayElement(m_TriangleColliderBuffer, lookup.Index, (TriangleCollider)value); break; @@ -419,6 +439,7 @@ public void Dispose() UnsafeUtility.Free(m_PlungerColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_PointColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_SpinnerColliderBuffer, m_AllocatorLabel); + UnsafeUtility.Free(m_SweptCircleColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_TriangleColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_PlaneColliderBuffer, m_AllocatorLabel); @@ -433,6 +454,7 @@ public void Dispose() m_PlungerColliderBuffer = null; m_PointColliderBuffer = null; m_SpinnerColliderBuffer = null; + m_SweptCircleColliderBuffer = null; m_TriangleColliderBuffer = null; m_PlaneColliderBuffer = null; m_Length = 0; @@ -474,6 +496,7 @@ public Aabb GetAabb(int index) case ColliderType.Plunger: return UnsafeUtility.ArrayElementAsRef(m_PlungerColliderBuffer, lookup.Index).Bounds.Aabb; case ColliderType.Point: return UnsafeUtility.ArrayElementAsRef(m_PointColliderBuffer, lookup.Index).Bounds.Aabb; case ColliderType.Spinner: return UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index).Bounds.Aabb; + case ColliderType.SweptCircle: return UnsafeUtility.ArrayElementAsRef(m_SweptCircleColliderBuffer, lookup.Index).Bounds.Aabb; case ColliderType.Triangle: return UnsafeUtility.ArrayElementAsRef(m_TriangleColliderBuffer, lookup.Index).Bounds.Aabb; case ColliderType.Plane: return UnsafeUtility.ArrayElementAsRef(m_PlaneColliderBuffer, lookup.Index).Bounds.Aabb; default: @@ -529,6 +552,10 @@ public Aabb GetTransformedAabb(int index, ref NativeParallelHashMap(m_SpinnerColliderBuffer, lookup.Index); return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); } + case ColliderType.SweptCircle: { + var collider = UnsafeUtility.ArrayElementAsRef(m_SweptCircleColliderBuffer, lookup.Index); + return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); + } case ColliderType.Triangle: { var collider = UnsafeUtility.ArrayElementAsRef(m_TriangleColliderBuffer, lookup.Index); return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); @@ -565,6 +592,7 @@ public ref ColliderHeader GetHeader(int index) case ColliderType.Plunger: return ref UnsafeUtility.ArrayElementAsRef(m_PlungerColliderBuffer, lookup.Index).Header; case ColliderType.Point: return ref UnsafeUtility.ArrayElementAsRef(m_PointColliderBuffer, lookup.Index).Header; case ColliderType.Spinner: return ref UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index).Header; + case ColliderType.SweptCircle: return ref UnsafeUtility.ArrayElementAsRef(m_SweptCircleColliderBuffer, lookup.Index).Header; case ColliderType.Triangle: return ref UnsafeUtility.ArrayElementAsRef(m_TriangleColliderBuffer, lookup.Index).Header; case ColliderType.Plane: return ref UnsafeUtility.ArrayElementAsRef(m_PlaneColliderBuffer, lookup.Index).Header; } @@ -614,6 +642,9 @@ public ICollider[] ToArray() case ColliderType.Spinner: array[i] = UnsafeUtility.ReadArrayElement(m_SpinnerColliderBuffer, lookup.Index); break; + case ColliderType.SweptCircle: + array[i] = UnsafeUtility.ReadArrayElement(m_SweptCircleColliderBuffer, lookup.Index); + break; case ColliderType.Triangle: array[i] = UnsafeUtility.ReadArrayElement(m_TriangleColliderBuffer, lookup.Index); break; @@ -651,4 +682,4 @@ public NativeCollidersDebugView(NativeColliders nativeColliders) } public ICollider[] Colliders => _nativeColliders.ToArray(); } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs index 7b25f6ed8..2c29c3ceb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs @@ -482,6 +482,9 @@ private void GenerateColliderMesh(ref ColliderReference colliders, out Mesh tran AddCollider(col.LineSeg1, verticesNonTransformable, normalsNonTransformable, indicesNonTransformable); } } + foreach (var col in colliders.SweptCircleColliders) { + _nonMeshColliders.Add(col); + } foreach (var col in colliders.TriangleColliders) { if (col.Header.IsTransformed) { AddCollider(col, vertices, normals, indices); @@ -602,6 +605,10 @@ private void GenerateColliderMesh(IEnumerable colliders, out Mesh tra AddCollider(spinnerCollider.LineSeg1, verticesNonTransformable, normalsNonTransformable, indicesNonTransformable); break; + case SweptCircleCollider sweptCircleCollider: + _nonMeshColliders.Add(sweptCircleCollider); + break; + // triangle collider case TriangleCollider { Header: { IsTransformed: true } } triangleCollider: AddCollider(triangleCollider, vertices, normals, indices); @@ -652,10 +659,32 @@ private void DrawNonMeshColliders() DrawLine(lineZCol.XY.ToFloat3(lineZCol.ZLow), lineZCol.XY.ToFloat3(lineZCol.ZHigh)); break; } + case SweptCircleCollider sweptCircle: { + DrawSweptCircle(sweptCircle); + break; + } } } } + private static void DrawSweptCircle(SweptCircleCollider collider) + { + var axis = math.normalizesafe(collider.V2 - collider.V1, new float3(0f, 0f, 1f)); + var side = math.normalizesafe(math.cross(axis, new float3(0f, 0f, 1f)), + new float3(1f, 0f, 0f)); + var up = math.normalizesafe(math.cross(axis, side), new float3(0f, 1f, 0f)); + Handles.DrawWireDisc(collider.V1, axis, collider.CordRadius); + Handles.DrawWireDisc(collider.V2, axis, collider.CordRadius); + Handles.DrawLine(collider.V1 + side * collider.CordRadius, + collider.V2 + side * collider.CordRadius); + Handles.DrawLine(collider.V1 - side * collider.CordRadius, + collider.V2 - side * collider.CordRadius); + Handles.DrawLine(collider.V1 + up * collider.CordRadius, + collider.V2 + up * collider.CordRadius); + Handles.DrawLine(collider.V1 - up * collider.CordRadius, + collider.V2 - up * collider.CordRadius); + } + private static void AddCollider(CircleCollider circleCol, IList vertices, IList normals, ICollection indices) { var startIdx = vertices.Count; @@ -828,11 +857,11 @@ private static void DrawAabb(Aabb aabb, bool isSelected) #endregion - void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) - => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); - - bool ICollidableComponent.IsCollidable => isActiveAndEnabled; - } + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + => InstantiateColliderApi(player, physicsEngine).CreateColliders(ref colliders, translateWithinPlayfieldMatrix, margin); + + bool ICollidableComponent.IsCollidable => isActiveAndEnabled; + } internal static class ColliderColor { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs index 536bb0fa9..4e3743944 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperComponent.cs @@ -648,10 +648,10 @@ private void SetupFlipperCorrection(FlipperColliderComponent colliderComponent) triggerCollider.TimeThresholdMs = fc.TimeThresholdMs; var localPos = transform.localPosition.TranslateToVpx(); - triggerComponent.Position = new Vector2(localPos.x, localPos.y); - - var poly = GetEnclosingPolygon(23, 12); - triggerComponent.DragPoints = new DragPointData[poly.Count]; + triggerComponent.Position = new Vector2(localPos.x, localPos.y); + + var poly = GetEnclosingPolygon(23, 12); + var dragPoints = new DragPointData[poly.Count]; triggerCollider.HitHeight = 150F; // nFozzy's recommendation, but I think 50 should be ok // this was taken from the 2021 code when we had the playfield transformed to vpx world: @@ -674,8 +674,9 @@ private void SetupFlipperCorrection(FlipperColliderComponent colliderComponent) for (var i = 0; i < poly.Count; i++) { // Poly points are expressed in flipper's frame: rotate to get it to the correct position. var p = flipperToPlayfield.MultiplyPoint3x4(poly[i]); - triggerComponent.DragPoints[poly.Count - i - 1] = new DragPointData(p.x, p.y); - } + dragPoints[poly.Count - i - 1] = new DragPointData(p.x, p.y); + } + triggerComponent.DragPoints = dragPoints; // create polarities and velocities curves var polarities = new float2[fc.PolaritiesCurveSlicingCount + 1]; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs index 692d93e78..1a99886a1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Light/LightInsertMeshComponent.cs @@ -16,7 +16,11 @@ // ReSharper disable InconsistentNaming +using System; +using System.Collections.Generic; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Light; @@ -26,7 +30,8 @@ namespace VisualPinball.Unity { - public class LightInsertMeshComponent : MeshComponent + public class LightInsertMeshComponent : MeshComponent, + IDragPointSplineOwner { #region Data @@ -34,16 +39,31 @@ public class LightInsertMeshComponent : MeshComponent public float PositionZ = 0.1f; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion protected override Mesh GetMesh(LightData data) { var playfieldComponent = GetComponentInParent(); - var meshGen = new SurfaceMeshGenerator(new LightInsertData(_dragPoints, InsertHeight), MainComponent.transform.position.TranslateToVpx().ToVertex3D()); + var meshGen = new SurfaceMeshGenerator(new LightInsertData(DragPoints, InsertHeight), MainComponent.transform.position.TranslateToVpx().ToVertex3D()); var topMesh = meshGen.GetMesh(SurfaceMeshGenerator.Top, playfieldComponent.Width, playfieldComponent.Height, 0, false); var sideMesh = meshGen.GetMesh(SurfaceMeshGenerator.Side, playfieldComponent.Width, playfieldComponent.Height, 0, false); return topMesh.Merge(sideMesh).TransformToWorld(); @@ -60,5 +80,22 @@ protected override PbrMaterial GetMaterial(LightData data, Table table) mat = new Engine.VPT.Material("Playfield Insert"); return new PbrMaterial(mat, table.GetTexture(table.Data.Image)) { DiffusionProfile = DiffusionProfileTemplate.Plastics }; } + + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => MainComponent.transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => MainComponent.RebuildMeshes(); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs index 824e2fac5..6a5a9888a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/MetalWireGuide/MetalWireGuideComponent.cs @@ -24,7 +24,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.MetalWireGuide; @@ -36,7 +38,7 @@ namespace VisualPinball.Unity [PackAs("MetalWireGuide")] [AddComponentMenu("Pinball/Game Item/Metal Wire Guide")] public class MetalWireGuideComponent : MainRenderableComponent, - IMetalWireGuideData, IPackable + IMetalWireGuideData, IPackable, IDragPointSplineOwner { #region Data @@ -57,9 +59,12 @@ public class MetalWireGuideComponent : MainRenderableComponent _dragPoints; set => _dragPoints = value; } + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); public float Thickness => _thickness; public float Standheight => _standheight; public float Height => _height; @@ -217,10 +233,27 @@ public override void CopyFromObject(GameObject go) _standheight = srcMainComp._standheight; Rotation = srcMainComp.Rotation; _bendradius = srcMainComp._bendradius; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); } } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => false; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs index e8ad6497b..3209ff83e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ramp/RampComponent.cs @@ -22,9 +22,11 @@ #endregion using System; -using System.Collections.Generic; -using System.Linq; -using UnityEngine; +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Ramp; @@ -37,7 +39,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Ramp")] [AddComponentMenu("Pinball/Game Item/Ramp")] - public class RampComponent : MainRenderableComponent, IRampData, ISurfaceComponent, IPackable + public class RampComponent : MainRenderableComponent, IRampData, ISurfaceComponent, IPackable, + IDragPointSplineOwner { #region Data @@ -81,8 +84,11 @@ public class RampComponent : MainRenderableComponent, IRampData, ISurf [Tooltip("Vertical distance between the wires.")] public float _wireDistanceY = 88f; - [SerializeField] - private DragPointData[] _dragPoints; + [SerializeField, HideInInspector] + private DragPointData[] _dragPoints; + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; [SerializeField] public Vector3 uvOffset = Vector3.zero; @@ -102,7 +108,18 @@ public class RampComponent : MainRenderableComponent, IRampData, ISurf public float WireDiameter => _wireDiameter; public float WidthTop => _widthTop; public float WidthBottom => _widthBottom; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion @@ -408,27 +425,63 @@ public override void CopyFromObject(GameObject go) _wireDiameter = srcMainComp._wireDiameter; _wireDistanceX = srcMainComp._wireDistanceX; _wireDistanceY = srcMainComp._wireDistanceY; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); } private void CenterPivot() { - var centerVpx = DragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); - centerVpx /= DragPoints.Length; + var dragPoints = DragPoints; + var centerVpx = dragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); + centerVpx /= dragPoints.Length; if (uvOffset == Vector3.zero) { uvOffset = centerVpx; } transform.Translate(centerVpx.TranslateToWorld(transform) - transform.position); - foreach (var dragPoint in DragPoints) { - dragPoint.Center -= centerVpx.ToVertex3D(); - } + foreach (var dragPoint in dragPoints) { + dragPoint.Center -= centerVpx.ToVertex3D(); + } + DragPoints = dragPoints; RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => false; + bool IDragPointSplineOwner.SplinePlanar => false; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) + { + if (modification != SplineModification.KnotModified || knotIndex < 0 + || previousPositions.Count != spline.Count + || knotIndex != 0 && knotIndex != spline.Count - 1) { + return; + } + + var knot = spline[knotIndex]; + var delta = knot.Position.z - previousPositions[knotIndex].z; + if (knotIndex == 0) { + _heightBottom += delta; + } else { + _heightTop += delta; + } + knot.Position.z = previousPositions[knotIndex].z; + spline.SetKnotNoNotify(knotIndex, knot); + } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + internal static (float rightWallHeight, float leftWallHeight) GetCollisionWallHeights(int type, float flatRightWallHeight, float flatLeftWallHeight) { switch (type) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide.meta new file mode 100644 index 000000000..4e0fdb309 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fd87df6e9dc64fd2ae0ed044fc3c1756 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs new file mode 100644 index 000000000..9246042f8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.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. + +using System.Collections.Generic; +using System.Linq; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public readonly struct RubberBakeStatus + { + public readonly bool IsValid; + public readonly bool IsStale; + public readonly string Message; + + public RubberBakeStatus(bool isValid, bool isStale, string message) + { + IsValid = isValid; + IsStale = isStale; + Message = message; + } + } + + public static class RubberAutofit + { + public const uint CurrentBakeVersion = 1; + public const float SolverEpsilonVpx = 1e-4f; + public const float RenderToleranceVpx = 0.05f; + + public static bool TryBake(RubberComponent rubber, out RubberAutofitResult fit, + out string error) + { + fit = null; + error = null; + var resolution = RubberGuideResolver.Resolve(rubber); + if (!resolution.IsValid) { + error = resolution.Error; + return false; + } + + fit = RubberCircleHullSolver.Solve(resolution.Circles, + rubber.Thickness * 0.5f, SolverEpsilonVpx); + if (!fit.IsValid) { + error = fit.Error; + return false; + } + if (!RubberPathSplineBaker.TryCreateDragPoints(fit.Elements, + resolution.Plane.BakeFrameToLocal, RenderToleranceVpx, out var dragPoints, + out _, out error)) { + return false; + } + + rubber.ApplyGuidedBake(fit.Elements, dragPoints, + resolution.Plane.BakeFrameToLocal, resolution.InputHash, CurrentBakeVersion); + rubber.RebuildMeshes(); + return true; + } + + public static bool TryConvertToGuides(RubberComponent rubber, + IEnumerable bindings, out RubberAutofitResult fit, + out string error) + { + fit = null; + if (!rubber) { + error = "A rubber component is required."; + return false; + } + if (rubber.PathSource != RubberPathSource.Spline) { + error = "Only a manual spline rubber can use the transactional conversion workflow."; + return false; + } + return TryApplyGuideBindings(rubber, bindings, out fit, out error); + } + + public static bool TryReplaceGuideBindings(RubberComponent rubber, + IEnumerable bindings, out RubberAutofitResult fit, + out string error) + { + fit = null; + if (!rubber) { + error = "A rubber component is required."; + return false; + } + if (rubber.PathSource != RubberPathSource.Guides) { + error = "Only a guided rubber can replace its guide bindings."; + return false; + } + return TryApplyGuideBindings(rubber, bindings, out fit, out error); + } + + private static bool TryApplyGuideBindings(RubberComponent rubber, + IEnumerable bindings, out RubberAutofitResult fit, + out string error) + { + var previousSource = rubber.PathSource; + var previousBindings = rubber.GuideBindings.ToArray(); + var previousPath = rubber.BakedPath.ToArray(); + var previousVersion = rubber.BakeVersion; + var previousHash = rubber.BakeInputHash; + var previousFrame = rubber.BakeFrameToLocal; + var previousRestLength = rubber.RestLength; + rubber.SetGuideBindings(bindings); + if (TryBake(rubber, out fit, out error)) { + return true; + } + + rubber.RestorePackedState(previousSource, previousBindings, + previousPath, previousVersion, previousHash, previousFrame, + previousRestLength); + return false; + } + + public static RubberBakeStatus GetStatus(RubberComponent rubber) + { + if (!rubber || rubber.PathSource != RubberPathSource.Guides) { + return new RubberBakeStatus(false, false, "Spline path is authoritative."); + } + if (rubber.BakedPath.Count == 0) { + return new RubberBakeStatus(false, false, "No guided path has been baked."); + } + if (rubber.BakeVersion != CurrentBakeVersion) { + return new RubberBakeStatus(false, true, + $"Bake version {rubber.BakeVersion} is stale; expected {CurrentBakeVersion}."); + } + var resolution = RubberGuideResolver.Resolve(rubber); + if (!resolution.IsValid) { + return new RubberBakeStatus(false, false, resolution.Error); + } + if (resolution.InputHash != rubber.BakeInputHash) { + return new RubberBakeStatus(false, true, "Guide geometry changed after the last bake."); + } + if (!RubberPathValidator.TryValidate(rubber.BakedPath, + SolverEpsilonVpx, out var validationError)) { + return new RubberBakeStatus(false, false, validationError); + } + return new RubberBakeStatus(true, false, "Guided bake is current."); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs.meta new file mode 100644 index 000000000..3116c3c55 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f61b281719ef4d3e904d1b6911afcb44 +timeCreated: 1783936775 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs new file mode 100644 index 000000000..fbd222550 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs @@ -0,0 +1,561 @@ +// 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.Linq; +using Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public readonly struct RubberGuideCircle + { + public readonly float2 Center; + public readonly float Radius; + public readonly int BindingIndex; + + public RubberGuideCircle(float2 center, float radius, int bindingIndex) + { + Center = center; + Radius = radius; + BindingIndex = bindingIndex; + } + } + + public sealed class RubberAutofitResult + { + public bool IsValid { get; internal set; } + public RubberPathElement[] Elements { get; internal set; } = Array.Empty(); + public int[] SupportingBindingIndices { get; internal set; } = Array.Empty(); + public int[] EnclosedBindingIndices { get; internal set; } = Array.Empty(); + public string Error { get; internal set; } + public float Length => Elements.Sum(element => element.Length); + } + + /// + /// Computes the counter-clockwise boundary of the convex hull of expanded circular guides. + /// The exact result alternates between straight common tangents and outward circular arcs. + /// + public static class RubberCircleHullSolver + { + private const float TwoPi = 2f * math.PI; + + private readonly struct Circle + { + public readonly float2 Center; + public readonly float Radius; + public readonly int BindingIndex; + public readonly int InputIndex; + + public Circle(RubberGuideCircle source, float cordRadius, int inputIndex) + { + Center = source.Center; + Radius = source.Radius + cordRadius; + BindingIndex = source.BindingIndex; + InputIndex = inputIndex; + } + } + + private readonly struct Tangent + { + public readonly int StartCircle; + public readonly int EndCircle; + public readonly float2 Start; + public readonly float2 End; + public readonly float2 InwardNormal; + + public Tangent(int startCircle, int endCircle, float2 start, float2 end, + float2 inwardNormal) + { + StartCircle = startCircle; + EndCircle = endCircle; + Start = start; + End = end; + InwardNormal = inwardNormal; + } + + public float Length => math.distance(Start, End); + } + + public static RubberAutofitResult Solve(IReadOnlyList guides, + float cordRadius, float epsilon = 1e-4f) + { + var result = new RubberAutofitResult(); + if (guides == null) { + result.Error = "Guide circles are required."; + return result; + } + if (!float.IsFinite(cordRadius) || cordRadius < 0f) { + result.Error = "Cord radius must be finite and non-negative."; + return result; + } + if (!float.IsFinite(epsilon) || epsilon <= 0f) { + result.Error = "Solver epsilon must be finite and positive."; + return result; + } + + var circles = new List(guides.Count); + for (var i = 0; i < guides.Count; i++) { + var guide = guides[i]; + if (!math.all(math.isfinite(guide.Center)) || !float.IsFinite(guide.Radius) + || guide.Radius <= 0f) { + result.Error = $"Guide binding {guide.BindingIndex} has invalid circle geometry."; + return result; + } + circles.Add(new Circle(guide, cordRadius, i)); + } + + if (circles.Count == 0) { + result.Error = "At least one guide binding is required."; + return result; + } + + var enclosed = new HashSet(); + circles = RemoveDuplicatesAndContained(circles, epsilon, enclosed); + if (circles.Count == 1) { + var circle = circles[0]; + var start = circle.Center + new float2(-circle.Radius, 0f); + result.Elements = new[] { + new RubberPathElement { + Type = RubberPathElementType.SupportedArc, + Start = start, + End = start, + Center = circle.Center, + Radius = circle.Radius, + StartAngleRad = math.PI, + SweepAngleRad = TwoPi, + StartBindingIndex = circle.BindingIndex, + EndBindingIndex = circle.BindingIndex, + Length = TwoPi * circle.Radius, + }, + }; + result.SupportingBindingIndices = new[] { circle.BindingIndex }; + result.EnclosedBindingIndices = enclosed.OrderBy(index => index).ToArray(); + result.IsValid = true; + return result; + } + + var tangents = FindHullTangents(circles, epsilon); + tangents = RemoveSubsegmentTangents(tangents, epsilon); + if (!TryOrderCycle(circles, tangents, epsilon, out var cycle, out var cycleError)) { + result.Error = cycleError; + return result; + } + + var elements = EmitElements(circles, cycle, epsilon, enclosed); + if (elements.Count == 0) { + result.Error = "The guide hull produced no path elements."; + return result; + } + Normalize(elements, epsilon); + if (!RubberPathValidator.TryValidate(elements, epsilon, out var validationError)) { + result.Error = validationError; + return result; + } + + var supporting = new HashSet(); + foreach (var element in elements) { + if (element.Type == RubberPathElementType.SupportedArc) { + supporting.Add(element.StartBindingIndex); + } + } + foreach (var circle in circles) { + if (!supporting.Contains(circle.BindingIndex)) { + enclosed.Add(circle.BindingIndex); + } + } + + result.Elements = elements.ToArray(); + result.SupportingBindingIndices = supporting.OrderBy(index => index).ToArray(); + result.EnclosedBindingIndices = enclosed.OrderBy(index => index).ToArray(); + result.IsValid = true; + return result; + } + + private static List RemoveDuplicatesAndContained(IReadOnlyList source, + float epsilon, ISet enclosed) + { + var ordered = source + .OrderBy(circle => circle.BindingIndex) + .ThenBy(circle => circle.InputIndex) + .ToList(); + var duplicatesRemoved = new List(ordered.Count); + foreach (var circle in ordered) { + var duplicate = duplicatesRemoved.FindIndex(candidate => + math.distance(candidate.Center, circle.Center) <= epsilon + && math.abs(candidate.Radius - circle.Radius) <= epsilon); + if (duplicate >= 0) { + enclosed.Add(circle.BindingIndex); + } else { + duplicatesRemoved.Add(circle); + } + } + + var retained = new List(duplicatesRemoved.Count); + for (var i = 0; i < duplicatesRemoved.Count; i++) { + var circle = duplicatesRemoved[i]; + var container = -1; + for (var j = 0; j < duplicatesRemoved.Count; j++) { + if (i == j) { + continue; + } + var candidate = duplicatesRemoved[j]; + if (math.distance(circle.Center, candidate.Center) + circle.Radius + <= candidate.Radius + epsilon) { + if (container < 0 || candidate.Radius > duplicatesRemoved[container].Radius + || candidate.Radius == duplicatesRemoved[container].Radius + && candidate.BindingIndex < duplicatesRemoved[container].BindingIndex) { + container = j; + } + } + } + if (container >= 0) { + enclosed.Add(circle.BindingIndex); + } else { + retained.Add(circle); + } + } + return retained; + } + + private static List FindHullTangents(IReadOnlyList circles, + float epsilon) + { + var tangents = new List(); + for (var i = 0; i < circles.Count; i++) { + for (var j = 0; j < circles.Count; j++) { + if (i == j) { + continue; + } + var delta = circles[j].Center - circles[i].Center; + var distance = math.length(delta); + if (distance <= epsilon) { + continue; + } + var radiusDelta = circles[j].Radius - circles[i].Radius; + var ratio = radiusDelta / distance; + if (math.abs(ratio) > 1f + epsilon) { + continue; + } + ratio = math.clamp(ratio, -1f, 1f); + var direction = delta / distance; + var inward = direction * ratio + + new float2(-direction.y, direction.x) * math.sqrt(math.max(0f, 1f - ratio * ratio)); + var start = circles[i].Center - inward * circles[i].Radius; + var end = circles[j].Center - inward * circles[j].Radius; + if (math.distance(start, end) <= epsilon) { + continue; + } + + var valid = true; + for (var k = 0; k < circles.Count; k++) { + if (math.dot(inward, circles[k].Center - start) < circles[k].Radius - epsilon) { + valid = false; + break; + } + } + if (valid) { + tangents.Add(new Tangent(i, j, start, end, inward)); + } + } + } + return tangents; + } + + private static List RemoveSubsegmentTangents(IReadOnlyList source, + float epsilon) + { + var retained = new List(); + for (var i = 0; i < source.Count; i++) { + var tangent = source[i]; + var direction = math.normalize(tangent.End - tangent.Start); + var startDistance = math.dot(direction, tangent.Start); + var endDistance = math.dot(direction, tangent.End); + var contained = false; + for (var j = 0; j < source.Count; j++) { + if (i == j) { + continue; + } + var candidate = source[j]; + if (math.dot(tangent.InwardNormal, candidate.InwardNormal) < 1f - epsilon + || math.abs(math.dot(tangent.InwardNormal, candidate.Start - tangent.Start)) > epsilon) { + continue; + } + var candidateStart = math.dot(direction, candidate.Start); + var candidateEnd = math.dot(direction, candidate.End); + if (candidateStart <= startDistance + epsilon && candidateEnd >= endDistance - epsilon + && candidate.Length > tangent.Length + epsilon) { + contained = true; + break; + } + } + if (!contained) { + retained.Add(tangent); + } + } + return retained; + } + + private static bool TryOrderCycle(IReadOnlyList circles, + IReadOnlyList tangents, float epsilon, out List cycle, + out string error) + { + cycle = new List(); + error = null; + if (tangents.Count < 2) { + error = "The guide circles do not produce a closed outer tangent cycle."; + return false; + } + + var outgoing = tangents.GroupBy(tangent => tangent.StartCircle) + .ToDictionary(group => group.Key, group => group + .OrderByDescending(tangent => tangent.Length) + .ThenBy(tangent => circles[tangent.EndCircle].BindingIndex) + .ToList()); + foreach (var pair in outgoing) { + if (pair.Value.Count > 1 + && math.abs(pair.Value[0].Length - pair.Value[1].Length) > epsilon) { + error = $"Guide binding {circles[pair.Key].BindingIndex} has an ambiguous hull tangent."; + return false; + } + } + + var first = tangents + .OrderBy(tangent => tangent.Start.x) + .ThenBy(tangent => tangent.Start.y) + .ThenBy(tangent => circles[tangent.StartCircle].BindingIndex) + .ThenBy(tangent => circles[tangent.EndCircle].BindingIndex) + .First(); + var current = first; + var visited = new HashSet<(int, int)>(); + while (visited.Add((current.StartCircle, current.EndCircle))) { + cycle.Add(current); + if (!outgoing.TryGetValue(current.EndCircle, out var nextCandidates)) { + error = $"Guide binding {circles[current.EndCircle].BindingIndex} breaks the tangent cycle."; + return false; + } + current = nextCandidates[0]; + if (current.StartCircle == first.StartCircle && current.EndCircle == first.EndCircle) { + break; + } + if (cycle.Count > tangents.Count) { + error = "The hull tangent traversal did not close."; + return false; + } + } + + if (current.StartCircle != first.StartCircle || current.EndCircle != first.EndCircle + || cycle.Count < 2) { + error = "The hull tangents form multiple or open cycles."; + return false; + } + return true; + } + + private static List EmitElements(IReadOnlyList circles, + IReadOnlyList cycle, float epsilon, ISet enclosed) + { + var elements = new List(cycle.Count * 2); + for (var i = 0; i < cycle.Count; i++) { + var tangent = cycle[i]; + var next = cycle[(i + 1) % cycle.Count]; + var startCircle = circles[tangent.StartCircle]; + var endCircle = circles[tangent.EndCircle]; + elements.Add(new RubberPathElement { + Type = RubberPathElementType.FreeSpan, + Start = tangent.Start, + End = tangent.End, + StartBindingIndex = startCircle.BindingIndex, + EndBindingIndex = endCircle.BindingIndex, + Length = tangent.Length, + }); + + if (next.StartCircle != tangent.EndCircle) { + return new List(); + } + var startAngle = math.atan2(tangent.End.y - endCircle.Center.y, + tangent.End.x - endCircle.Center.x); + var endAngle = math.atan2(next.Start.y - endCircle.Center.y, + next.Start.x - endCircle.Center.x); + var sweep = PositiveAngle(endAngle - startAngle); + if (sweep * endCircle.Radius <= epsilon) { + enclosed.Add(endCircle.BindingIndex); + continue; + } + elements.Add(new RubberPathElement { + Type = RubberPathElementType.SupportedArc, + Start = tangent.End, + End = next.Start, + Center = endCircle.Center, + Radius = endCircle.Radius, + StartAngleRad = startAngle, + SweepAngleRad = sweep, + StartBindingIndex = endCircle.BindingIndex, + EndBindingIndex = endCircle.BindingIndex, + Length = sweep * endCircle.Radius, + }); + } + return elements; + } + + private static void Normalize(List elements, float epsilon) + { + var first = 0; + for (var i = 1; i < elements.Count; i++) { + if (LexicographicCompare(elements[i], elements[first], epsilon) < 0) { + first = i; + } + } + if (first != 0) { + var rotated = elements.Skip(first).Concat(elements.Take(first)).ToArray(); + elements.Clear(); + elements.AddRange(rotated); + } + + var distance = 0f; + for (var i = 0; i < elements.Count; i++) { + var element = elements[i]; + element.StartDistance = distance; + elements[i] = element; + distance += element.Length; + } + } + + private static int LexicographicCompare(RubberPathElement a, RubberPathElement b, + float epsilon) + { + if (a.Start.x < b.Start.x - epsilon) { + return -1; + } + if (a.Start.x > b.Start.x + epsilon) { + return 1; + } + if (a.Start.y < b.Start.y - epsilon) { + return -1; + } + if (a.Start.y > b.Start.y + epsilon) { + return 1; + } + var binding = a.StartBindingIndex.CompareTo(b.StartBindingIndex); + return binding != 0 ? binding : a.Type.CompareTo(b.Type); + } + + private static float PositiveAngle(float angle) + { + angle %= TwoPi; + return angle < 0f ? angle + TwoPi : angle; + } + } + + public static class RubberPathValidator + { + public static bool TryValidate(IReadOnlyList elements, + float epsilon, out string error) + { + error = null; + if (elements == null || elements.Count == 0) { + error = "The rubber path is empty."; + return false; + } + + var expectedDistance = 0f; + for (var i = 0; i < elements.Count; i++) { + var element = elements[i]; + if (!math.all(math.isfinite(element.Start)) || !math.all(math.isfinite(element.End)) + || !float.IsFinite(element.Length) || element.Length <= epsilon) { + error = $"Path element {i} has invalid geometry or length."; + return false; + } + if (math.abs(element.StartDistance - expectedDistance) > epsilon * math.max(1f, expectedDistance)) { + error = $"Path element {i} has a discontinuous accumulated distance."; + return false; + } + if (element.Type == RubberPathElementType.FreeSpan) { + if (math.abs(element.Length - math.distance(element.Start, element.End)) > epsilon) { + error = $"Free span {i} has an inconsistent length."; + return false; + } + } else if (element.Type == RubberPathElementType.SupportedArc) { + if (!math.all(math.isfinite(element.Center)) || !float.IsFinite(element.Radius) + || element.Radius <= 0f || !float.IsFinite(element.SweepAngleRad) + || element.SweepAngleRad <= 0f + || math.abs(element.Length - element.Radius * element.SweepAngleRad) > epsilon + || math.abs(math.distance(element.Start, element.Center) - element.Radius) > epsilon + || math.abs(math.distance(element.End, element.Center) - element.Radius) > epsilon) { + error = $"Supported arc {i} has inconsistent circle geometry."; + return false; + } + } else { + error = $"Path element {i} has an unknown type."; + return false; + } + + var next = elements[(i + 1) % elements.Count]; + if (math.distance(element.End, next.Start) > epsilon) { + error = $"Path elements {i} and {(i + 1) % elements.Count} are not connected."; + return false; + } + expectedDistance += element.Length; + } + + if (HasPolylineSelfIntersection(elements, epsilon)) { + error = "The rubber path intersects itself."; + return false; + } + return true; + } + + private static bool HasPolylineSelfIntersection(IReadOnlyList elements, + float epsilon) + { + var points = new List(); + foreach (var element in elements) { + points.Add(element.Start); + if (element.Type != RubberPathElementType.SupportedArc) { + continue; + } + var samples = math.max(2, (int)math.ceil(element.SweepAngleRad / (math.PI / 32f))); + for (var i = 1; i < samples; i++) { + var angle = element.StartAngleRad + element.SweepAngleRad * i / samples; + points.Add(element.Center + new float2(math.cos(angle), math.sin(angle)) * element.Radius); + } + } + + for (var i = 0; i < points.Count; i++) { + var iNext = (i + 1) % points.Count; + for (var j = i + 2; j < points.Count; j++) { + var jNext = (j + 1) % points.Count; + if (i == jNext || iNext == j) { + continue; + } + if (SegmentsIntersect(points[i], points[iNext], points[j], points[jNext], epsilon)) { + return true; + } + } + } + return false; + } + + private static bool SegmentsIntersect(float2 a, float2 b, float2 c, float2 d, + float epsilon) + { + var ab = b - a; + var cd = d - c; + var denominator = Cross(ab, cd); + if (math.abs(denominator) <= epsilon) { + return false; + } + var ac = c - a; + var t = Cross(ac, cd) / denominator; + var u = Cross(ac, ab) / denominator; + return t > epsilon && t < 1f - epsilon && u > epsilon && u < 1f - epsilon; + } + + private static float Cross(float2 a, float2 b) => a.x * b.y - a.y * b.x; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs.meta new file mode 100644 index 000000000..9e311349f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3766506c3b3f40a39bc8a3eeac08386f +timeCreated: 1783936772 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs new file mode 100644 index 000000000..961f7d328 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs @@ -0,0 +1,168 @@ +// 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.Linq; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [PackAs("RubberGuide")] + [AddComponentMenu("Pinball/Rubber/Rubber Guide")] + public sealed class RubberGuideComponent : MonoBehaviour, IPackable + { + [SerializeField] private RubberGuideSlot[] _slots = Array.Empty(); + + public RubberGuideSlot[] Slots { + get => _slots ?? Array.Empty(); + set => _slots = value ?? Array.Empty(); + } + + public bool TryGetSlot(SerializedGuid id, out RubberGuideSlot slot) + { + foreach (var candidate in Slots) { + if (candidate.Id == id) { + slot = candidate; + return true; + } + } + slot = default; + return false; + } + + public IReadOnlyList ValidateSlots() + { + var errors = new List(); + var ids = new HashSet(); + for (var i = 0; i < Slots.Length; i++) { + var slot = Slots[i]; + if (slot.Id.IsEmpty) { + errors.Add($"Slot {i} has no ID."); + } else if (!ids.Add(slot.Id)) { + errors.Add($"Slot {i} duplicates ID {slot.Id}."); + } + if (!float.IsFinite(slot.LocalHeight)) { + errors.Add($"Slot {i} has a non-finite height."); + } + if (slot.Profile.Type != RubberGuideProfileType.Circle) { + errors.Add($"Slot {i} uses unsupported profile {slot.Profile.Type}."); + } else if (!float.IsFinite(slot.Profile.Radius) || slot.Profile.Radius <= 0f) { + errors.Add($"Slot {i} must have a positive finite radius."); + } + } + return errors; + } + + public void AddSlot(RubberGuideSlot slot) + { + if (slot.Id.IsEmpty) { + slot.Id = SerializedGuid.New(); + } + _slots = Slots.Concat(new[] { slot }).ToArray(); + } + + public void DuplicateSlot(int index) + { + if (index < 0 || index >= Slots.Length) { + throw new ArgumentOutOfRangeException(nameof(index)); + } + var slot = Slots[index]; + slot.Id = SerializedGuid.New(); + slot.DisplayName = string.IsNullOrEmpty(slot.DisplayName) ? "Slot" : $"{slot.DisplayName} Copy"; + AddSlot(slot); + } + + public int RepairDuplicateIds() + { + var repaired = 0; + var ids = new HashSet(); + var slots = Slots.ToArray(); + for (var i = 0; i < slots.Length; i++) { + if (!slots[i].Id.IsEmpty && ids.Add(slots[i].Id)) { + continue; + } + slots[i].Id = SerializedGuid.New(); + ids.Add(slots[i].Id); + repaired++; + } + _slots = slots; + return repaired; + } + + public byte[] Pack() => RubberGuidePackable.Pack(this); + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => Array.Empty(); + public void Unpack(byte[] bytes) => RubberGuidePackable.Unpack(bytes, this); + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) { } + } + + public struct RubberGuidePackable + { + public RubberGuideSlotPackable[] Slots; + + public static byte[] Pack(RubberGuideComponent component) + { + return PackageApi.Packer.Pack(new RubberGuidePackable { + Slots = component.Slots.Select(RubberGuideSlotPackable.From).ToArray(), + }); + } + + public static void Unpack(byte[] bytes, RubberGuideComponent component) + { + var data = PackageApi.Packer.Unpack(bytes); + component.Slots = data.Slots?.Select(slot => slot.ToSlot()).ToArray() + ?? Array.Empty(); + } + } + + public struct RubberGuideSlotPackable + { + public ulong IdA; + public ulong IdB; + public string DisplayName; + public float LocalHeight; + public int ProfileType; + public PackableFloat2 LocalCenter; + public float Radius; + public PackableFloat2[] ConvexHull; + public float RubberSupportFriction; + + public static RubberGuideSlotPackable From(RubberGuideSlot slot) + { + return new RubberGuideSlotPackable { + IdA = slot.Id.A, + IdB = slot.Id.B, + DisplayName = slot.DisplayName, + LocalHeight = slot.LocalHeight, + ProfileType = (int)slot.Profile.Type, + LocalCenter = slot.Profile.LocalCenter, + Radius = slot.Profile.Radius, + ConvexHull = slot.Profile.ConvexHull?.Select(point => (PackableFloat2)point).ToArray() + ?? Array.Empty(), + RubberSupportFriction = slot.RubberSupportFriction, + }; + } + + public RubberGuideSlot ToSlot() + { + return new RubberGuideSlot { + Id = new SerializedGuid(IdA, IdB), + DisplayName = DisplayName, + LocalHeight = LocalHeight, + Profile = new RubberGuideProfile { + Type = (RubberGuideProfileType)ProfileType, + LocalCenter = LocalCenter, + Radius = Radius, + ConvexHull = ConvexHull?.Select(point => (global::Unity.Mathematics.float2)point).ToArray() + ?? Array.Empty(), + }, + RubberSupportFriction = RubberSupportFriction, + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs.meta new file mode 100644 index 000000000..74a0d22cd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b44892bb18ee4a68acee25e959d7b034 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs new file mode 100644 index 000000000..6c6091d69 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs @@ -0,0 +1,309 @@ +// 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 Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public readonly struct RubberBakePlane + { + public readonly Vector3 Origin; + public readonly Vector3 AxisX; + public readonly Vector3 AxisY; + public readonly Vector3 Normal; + public readonly Matrix4x4 BakeFrameToLocal; + + public RubberBakePlane(Vector3 origin, Vector3 axisX, Vector3 axisY, + Vector3 normal, Matrix4x4 bakeFrameToLocal) + { + Origin = origin; + AxisX = axisX; + AxisY = axisY; + Normal = normal; + BakeFrameToLocal = bakeFrameToLocal; + } + + public Vector3 BakeToWorld(float2 point) + { + return Origin + AxisX * Physics.ScaleToWorld(point.x) + + AxisY * Physics.ScaleToWorld(point.y); + } + } + + public sealed class RubberGuideResolution + { + public bool IsValid { get; internal set; } + public RubberGuideCircle[] Circles { get; internal set; } = Array.Empty(); + public RubberBakePlane Plane { get; internal set; } + public Hash128 InputHash { get; internal set; } + public string Error { get; internal set; } + } + + public static class RubberGuideResolver + { + public const float DefaultCoplanarToleranceVpx = 0.05f; + public const float DefaultAngularToleranceDegrees = 0.1f; + public const float DefaultScaleTolerance = 1e-3f; + + public static RubberGuideResolution Resolve(RubberComponent rubber, + float coplanarToleranceVpx = DefaultCoplanarToleranceVpx, + float angularToleranceDegrees = DefaultAngularToleranceDegrees, + float scaleTolerance = DefaultScaleTolerance) + { + var result = new RubberGuideResolution(); + if (!rubber) { + result.Error = "A rubber component is required."; + return result; + } + if (rubber.GuideBindings.Count == 0) { + result.Error = "At least one guide binding is required."; + return result; + } + + var firstBinding = rubber.GuideBindings[0]; + if (!TryGetSlot(firstBinding, 0, out var firstSlot, out var error)) { + result.Error = error; + return result; + } + if (!TryCreatePlane(rubber, firstBinding.Guide, firstSlot, coplanarToleranceVpx, + angularToleranceDegrees, scaleTolerance, out var plane, out error)) { + result.Error = error; + return result; + } + + var circles = new RubberGuideCircle[rubber.GuideBindings.Count]; + var maximumAngularError = math.cos(math.radians(angularToleranceDegrees)); + for (var i = 0; i < rubber.GuideBindings.Count; i++) { + var binding = rubber.GuideBindings[i]; + if (!TryGetSlot(binding, i, out var slot, out error)) { + result.Error = error; + return result; + } + var guide = binding.Guide; + var axis = guide.transform.localToWorldMatrix.MultiplyVector(Vector3.up).normalized; + if (Vector3.Dot(axis, plane.Normal) < maximumAngularError) { + result.Error = $"Guide binding {i} is not parallel to the rubber bake plane."; + return result; + } + + var center = SlotWorldCenter(guide, slot); + var offset = center - plane.Origin; + var planeDistanceVpx = Physics.ScaleToVpx(Vector3.Dot(offset, plane.Normal)); + if (math.abs(planeDistanceVpx) > coplanarToleranceVpx) { + result.Error = $"Guide binding {i} is {math.abs(planeDistanceVpx):0.###} VPX units outside the rubber plane."; + return result; + } + + if (!TryResolveRadius(guide, slot, plane, scaleTolerance, out var radiusVpx, + out error)) { + result.Error = $"Guide binding {i}: {error}"; + return result; + } + circles[i] = new RubberGuideCircle(new float2( + Physics.ScaleToVpx(Vector3.Dot(offset, plane.AxisX)), + Physics.ScaleToVpx(Vector3.Dot(offset, plane.AxisY))), radiusVpx, i); + } + + result.Circles = circles; + result.Plane = plane; + result.InputHash = ComputeInputHash(rubber, circles, plane.BakeFrameToLocal); + result.IsValid = true; + return result; + } + + public static Vector3 SlotWorldCenter(RubberGuideComponent guide, RubberGuideSlot slot) + { + return guide.transform.TransformPoint(new Vector3(slot.Profile.LocalCenter.x, + slot.LocalHeight, slot.Profile.LocalCenter.y)); + } + + private static bool TryGetSlot(RubberGuideBinding binding, int bindingIndex, + out RubberGuideSlot slot, out string error) + { + slot = default; + error = null; + if (!binding.Guide) { + error = $"Guide binding {bindingIndex} has no guide component."; + return false; + } + if (!binding.Guide.TryGetSlot(binding.SlotId, out slot)) { + error = $"Guide binding {bindingIndex} references missing slot {binding.SlotId}."; + return false; + } + if (slot.Profile.Type != RubberGuideProfileType.Circle) { + error = $"Guide binding {bindingIndex} uses unsupported profile {slot.Profile.Type}."; + return false; + } + if (!float.IsFinite(slot.LocalHeight) || !math.all(math.isfinite(slot.Profile.LocalCenter)) + || !float.IsFinite(slot.Profile.Radius) || slot.Profile.Radius <= 0f) { + error = $"Guide binding {bindingIndex} has invalid slot geometry."; + return false; + } + return true; + } + + private static bool TryCreatePlane(RubberComponent rubber, RubberGuideComponent guide, + RubberGuideSlot slot, float coplanarToleranceVpx, float angularToleranceDegrees, + float scaleTolerance, + out RubberBakePlane plane, out string error) + { + plane = default; + error = null; + var matrix = guide.transform.localToWorldMatrix; + var normal = matrix.MultiplyVector(Vector3.up).normalized; + var axisX = Vector3.ProjectOnPlane(matrix.MultiplyVector(Vector3.right), normal).normalized; + if (!IsFinite(normal) || !IsFinite(axisX) || normal.sqrMagnitude < 0.99f + || axisX.sqrMagnitude < 0.99f) { + error = "The first guide has a degenerate transform."; + return false; + } + var axisY = Vector3.Cross(normal, axisX).normalized; + var angularThreshold = math.cos(math.radians(angularToleranceDegrees)); + var rubberNormal = rubber.transform.localToWorldMatrix.MultiplyVector(Vector3.up).normalized; + if (Vector3.Dot(rubberNormal, normal) < angularThreshold) { + error = "The rubber plane is not parallel to the first guide slot."; + return false; + } + + var origin = SlotWorldCenter(guide, slot); + var rubberPlaneDistance = Physics.ScaleToVpx(Vector3.Dot( + rubber.transform.position - origin, normal)); + if (math.abs(rubberPlaneDistance) > coplanarToleranceVpx) { + error = $"The rubber origin is {math.abs(rubberPlaneDistance):0.###} VPX units outside the guide plane."; + return false; + } + + var rubberX = rubber.transform.localToWorldMatrix.MultiplyVector(Vector3.right); + var rubberY = rubber.transform.localToWorldMatrix.MultiplyVector(Vector3.back); + var xLength = rubberX.magnitude; + var yLength = rubberY.magnitude; + if (xLength <= 0f || yLength <= 0f + || math.abs(xLength - yLength) > scaleTolerance * math.max(xLength, yLength) + || math.abs(Vector3.Dot(rubberX / xLength, rubberY / yLength)) > scaleTolerance) { + error = "The rubber transform is sheared or nonuniformly scaled in its profile plane."; + return false; + } + + var bakeFrameToLocal = CreateBakeFrameToLocal(rubber.transform, origin, axisX, axisY, + normal); + plane = new RubberBakePlane(origin, axisX, axisY, normal, bakeFrameToLocal); + return true; + } + + private static bool TryResolveRadius(RubberGuideComponent guide, RubberGuideSlot slot, + RubberBakePlane plane, float scaleTolerance, out float radiusVpx, out string error) + { + radiusVpx = 0f; + error = null; + var radialX = guide.transform.localToWorldMatrix.MultiplyVector( + Vector3.right * slot.Profile.Radius); + var radialY = guide.transform.localToWorldMatrix.MultiplyVector( + Vector3.forward * slot.Profile.Radius); + var x = new float2(Vector3.Dot(radialX, plane.AxisX), + Vector3.Dot(radialX, plane.AxisY)); + var y = new float2(Vector3.Dot(radialY, plane.AxisX), + Vector3.Dot(radialY, plane.AxisY)); + var xLength = math.length(x); + var yLength = math.length(y); + if (xLength <= 0f || yLength <= 0f + || math.abs(xLength - yLength) > scaleTolerance * math.max(xLength, yLength) + || math.abs(math.dot(x / xLength, y / yLength)) > scaleTolerance) { + error = "circle profile scale is sheared or nonuniform."; + return false; + } + radiusVpx = Physics.ScaleToVpx((xLength + yLength) * 0.5f); + return float.IsFinite(radiusVpx) && radiusVpx > 0f; + } + + private static Matrix4x4 CreateBakeFrameToLocal(Transform rubberTransform, + Vector3 origin, Vector3 axisX, Vector3 axisY, Vector3 normal) + { + var originLocal = WorldToRubberVpx(rubberTransform, origin); + var xLocal = WorldToRubberVpx(rubberTransform, + origin + axisX * Physics.ScaleToWorld(1f)) - originLocal; + var yLocal = WorldToRubberVpx(rubberTransform, + origin + axisY * Physics.ScaleToWorld(1f)) - originLocal; + var zLocal = WorldToRubberVpx(rubberTransform, + origin + normal * Physics.ScaleToWorld(1f)) - originLocal; + var matrix = Matrix4x4.identity; + matrix.SetColumn(0, new Vector4(xLocal.x, xLocal.y, xLocal.z, 0f)); + matrix.SetColumn(1, new Vector4(yLocal.x, yLocal.y, yLocal.z, 0f)); + matrix.SetColumn(2, new Vector4(zLocal.x, zLocal.y, zLocal.z, 0f)); + matrix.SetColumn(3, new Vector4(originLocal.x, originLocal.y, originLocal.z, 1f)); + return matrix; + } + + private static Vector3 WorldToRubberVpx(Transform rubberTransform, Vector3 worldPoint) + { + return rubberTransform.worldToLocalMatrix.MultiplyPoint(worldPoint).TranslateToVpx(); + } + + private static Hash128 ComputeInputHash(RubberComponent rubber, + IReadOnlyList circles, Matrix4x4 bakeFrameToLocal) + { + var hash = new RubberInputHash(); + hash.Add((uint)rubber.Thickness); + hash.Add((uint)circles.Count); + for (var i = 0; i < circles.Count; i++) { + var binding = rubber.GuideBindings[i]; + hash.Add(binding.SlotId.A); + hash.Add(binding.SlotId.B); + hash.Add(circles[i].Center.x); + hash.Add(circles[i].Center.y); + hash.Add(circles[i].Radius); + } + for (var row = 0; row < 4; row++) { + for (var column = 0; column < 4; column++) { + hash.Add(bakeFrameToLocal[row, column]); + } + } + return hash.ToHash128(); + } + + private static bool IsFinite(Vector3 value) + { + return float.IsFinite(value.x) && float.IsFinite(value.y) && float.IsFinite(value.z); + } + + private sealed class RubberInputHash + { + private uint _a = 2166136261u; + private uint _b = 2246822519u; + private uint _c = 3266489917u; + private uint _d = 668265263u; + + public void Add(float value) => Add(math.asuint(value)); + + public void Add(ulong value) + { + Add((uint)value); + Add((uint)(value >> 32)); + } + + public void Add(uint value) + { + Mix(ref _a, value, 16777619u); + Mix(ref _b, value ^ 0x9e3779b9u, 2246822519u); + Mix(ref _c, value ^ 0x85ebca6bu, 3266489917u); + Mix(ref _d, value ^ 0xc2b2ae35u, 668265263u); + } + + public Hash128 ToHash128() => new(_a, _b, _c, _d); + + private static void Mix(ref uint state, uint value, uint multiplier) + { + state ^= value; + state *= multiplier | 1u; + state ^= state >> 13; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs.meta new file mode 100644 index 000000000..1eb530518 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c4875eb237724a61bb4b23068dbc1dd4 +timeCreated: 1783936774 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs new file mode 100644 index 000000000..939105112 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs @@ -0,0 +1,108 @@ +// 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 Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [Serializable] + public struct SerializedGuid : IEquatable + { + [SerializeField] private ulong _a; + [SerializeField] private ulong _b; + + public ulong A => _a; + public ulong B => _b; + public bool IsEmpty => _a == 0UL && _b == 0UL; + + public SerializedGuid(ulong a, ulong b) + { + _a = a; + _b = b; + } + + public static SerializedGuid New() + { + var bytes = Guid.NewGuid().ToByteArray(); + return new SerializedGuid(BitConverter.ToUInt64(bytes, 0), BitConverter.ToUInt64(bytes, 8)); + } + + public bool Equals(SerializedGuid other) => _a == other._a && _b == other._b; + public override bool Equals(object obj) => obj is SerializedGuid other && Equals(other); + public override int GetHashCode() + { + unchecked { + return (_a.GetHashCode() * 397) ^ _b.GetHashCode(); + } + } + + public override string ToString() => $"{_a:x16}{_b:x16}"; + + public static bool operator ==(SerializedGuid left, SerializedGuid right) => left.Equals(right); + public static bool operator !=(SerializedGuid left, SerializedGuid right) => !left.Equals(right); + } + + public enum RubberGuideProfileType + { + Circle, + Convex2D, + } + + [Serializable] + public struct RubberGuideProfile + { + public RubberGuideProfileType Type; + public float2 LocalCenter; + public float Radius; + public float2[] ConvexHull; + + public static RubberGuideProfile Circle(float radius) + { + return new RubberGuideProfile { + Type = RubberGuideProfileType.Circle, + LocalCenter = float2.zero, + Radius = radius, + ConvexHull = Array.Empty(), + }; + } + } + + [Serializable] + public struct RubberGuideSlot + { + public SerializedGuid Id; + public string DisplayName; + public float LocalHeight; + public RubberGuideProfile Profile; + public float RubberSupportFriction; + + public static RubberGuideSlot Create(string displayName, float radius) + { + return new RubberGuideSlot { + Id = SerializedGuid.New(), + DisplayName = displayName, + Profile = RubberGuideProfile.Circle(radius), + }; + } + } + + [Serializable] + public struct RubberGuideBinding + { + public RubberGuideComponent Guide; + public SerializedGuid SlotId; + + public RubberGuideBinding(RubberGuideComponent guide, SerializedGuid slotId) + { + Guide = guide; + SlotId = slotId; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs.meta new file mode 100644 index 000000000..9c8a3e9ef --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65e8937b1c4d461cab4334fcefea7fe5 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs new file mode 100644 index 000000000..9a81dad92 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs @@ -0,0 +1,47 @@ +// 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 Unity.Mathematics; + +namespace VisualPinball.Unity +{ + public enum RubberPathSource + { + Spline, + Guides, + } + + public enum RubberColliderMode + { + Legacy, + Physical, + } + + public enum RubberPathElementType + { + FreeSpan, + SupportedArc, + } + + [Serializable] + public struct RubberPathElement + { + public RubberPathElementType Type; + public float2 Start; + public float2 End; + public float2 Center; + public float Radius; + public float StartAngleRad; + public float SweepAngleRad; + public int StartBindingIndex; + public int EndBindingIndex; + public float StartDistance; + public float Length; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs.meta new file mode 100644 index 000000000..8b8d2dcdf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: edc6c33ff95b4d5687cc2055b8eb722d diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs new file mode 100644 index 000000000..710dc7f0b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs @@ -0,0 +1,205 @@ +// 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 Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Math; + +namespace VisualPinball.Unity +{ + public static class RubberPathSplineBaker + { + private const int MaximumSubdivisionsPerArc = 4096; + private const int DeviationSamplesPerSegment = 16; + + private struct SampleNode + { + public float2 Position; + public bool IsSmooth; + public int OutgoingElementIndex; + } + + public static bool TryCreateDragPoints(IReadOnlyList elements, + Matrix4x4 bakeFrameToLocal, float renderTolerance, out DragPointData[] dragPoints, + out float maximumDeviation, out string error) + { + dragPoints = Array.Empty(); + maximumDeviation = float.PositiveInfinity; + error = null; + if (elements == null || elements.Count == 0) { + error = "The exact rubber path is empty."; + return false; + } + if (!float.IsFinite(renderTolerance) || renderTolerance <= 0f) { + error = "Render tolerance must be finite and positive."; + return false; + } + + var subdivisions = new int[elements.Count]; + for (var i = 0; i < elements.Count; i++) { + if (elements[i].Type != RubberPathElementType.SupportedArc) { + subdivisions[i] = 1; + continue; + } + var radius = elements[i].Radius; + var ratio = math.clamp(1f - renderTolerance / radius, -1f, 1f); + var maximumAngle = 2f * math.acos(ratio); + if (!float.IsFinite(maximumAngle) || maximumAngle <= 1e-5f) { + maximumAngle = math.PI / 16f; + } + subdivisions[i] = math.max(3, + (int)math.ceil(elements[i].SweepAngleRad / maximumAngle)); + } + + for (;;) { + var nodes = CreateNodes(elements, subdivisions); + if (nodes.Count < 3) { + error = "The sampled rubber path contains fewer than three distinct points."; + return false; + } + var bakeFrameDragPoints = ToDragPoints(nodes, Matrix4x4.identity); + maximumDeviation = MeasureMaximumDeviation(elements, bakeFrameDragPoints, + nodes.ConvertAll(node => node.OutgoingElementIndex)); + if (maximumDeviation <= renderTolerance) { + dragPoints = ToDragPoints(nodes, bakeFrameToLocal); + return true; + } + + var refined = false; + for (var i = 0; i < elements.Count; i++) { + if (elements[i].Type != RubberPathElementType.SupportedArc) { + continue; + } + if (subdivisions[i] >= MaximumSubdivisionsPerArc) { + error = $"The sampled spline exceeds its {renderTolerance} render tolerance."; + return false; + } + subdivisions[i] = math.min(MaximumSubdivisionsPerArc, subdivisions[i] * 2); + refined = true; + } + if (!refined) { + error = $"The sampled spline exceeds its {renderTolerance} render tolerance."; + return false; + } + } + } + + public static float MeasureMaximumDeviation(IReadOnlyList elements, + IReadOnlyList dragPoints, IReadOnlyList outgoingElementIndices) + { + if (dragPoints == null || outgoingElementIndices == null + || dragPoints.Count != outgoingElementIndices.Count) { + throw new ArgumentException("Every sampled drag point must identify its outgoing exact path element."); + } + + var maximum = 0f; + for (var i = 0; i < dragPoints.Count; i++) { + var next = (i + 1) % dragPoints.Count; + var previous = dragPoints[i].IsSmooth ? (i + dragPoints.Count - 1) % dragPoints.Count : i; + var following = dragPoints[next].IsSmooth ? (i + 2) % dragPoints.Count : next; + var curve = CatmullCurve.GetInstance( + dragPoints[previous].Center, dragPoints[i].Center, dragPoints[next].Center, + dragPoints[following].Center); + var elementIndex = outgoingElementIndices[i]; + if (elementIndex < 0 || elementIndex >= elements.Count) { + throw new ArgumentOutOfRangeException(nameof(outgoingElementIndices)); + } + for (var sample = 0; sample <= DeviationSamplesPerSegment; sample++) { + var vertex = curve.GetPointAt(sample / (float)DeviationSamplesPerSegment); + var point = new float2(vertex.X, vertex.Y); + maximum = math.max(maximum, DistanceToElement(point, elements[elementIndex])); + } + } + return maximum; + } + + private static List CreateNodes(IReadOnlyList elements, + IReadOnlyList subdivisions) + { + var nodes = new List(); + var singleFullArc = elements.Count == 1 + && elements[0].Type == RubberPathElementType.SupportedArc + && elements[0].SweepAngleRad >= 2f * math.PI - 1e-4f; + for (var elementIndex = 0; elementIndex < elements.Count; elementIndex++) { + var element = elements[elementIndex]; + if (nodes.Count == 0) { + nodes.Add(new SampleNode { + Position = element.Start, + IsSmooth = singleFullArc, + OutgoingElementIndex = elementIndex, + }); + } else { + var node = nodes[^1]; + node.OutgoingElementIndex = elementIndex; + if (!singleFullArc) { + node.IsSmooth = false; + } + nodes[^1] = node; + } + + var count = element.Type == RubberPathElementType.SupportedArc + ? subdivisions[elementIndex] + : 1; + for (var sample = 1; sample <= count; sample++) { + var isLast = sample == count; + var position = element.Type == RubberPathElementType.FreeSpan + ? math.lerp(element.Start, element.End, sample / (float)count) + : PointOnArc(element, sample / (float)count); + if (elementIndex == elements.Count - 1 && isLast + && math.distance(position, nodes[0].Position) <= 1e-4f) { + continue; + } + nodes.Add(new SampleNode { + Position = position, + IsSmooth = singleFullArc || element.Type == RubberPathElementType.SupportedArc && !isLast, + OutgoingElementIndex = isLast && elementIndex + 1 < elements.Count + ? elementIndex + 1 + : elementIndex, + }); + } + } + return nodes; + } + + private static DragPointData[] ToDragPoints(IReadOnlyList nodes, + Matrix4x4 bakeFrameToLocal) + { + var dragPoints = new DragPointData[nodes.Count]; + for (var i = 0; i < nodes.Count; i++) { + var point = bakeFrameToLocal.MultiplyPoint3x4(new Vector3( + nodes[i].Position.x, nodes[i].Position.y, 0f)); + dragPoints[i] = new DragPointData(new Vertex3D(point.x, point.y, point.z)) { + IsSmooth = nodes[i].IsSmooth, + }; + } + return dragPoints; + } + + private static float2 PointOnArc(RubberPathElement element, float t) + { + var angle = element.StartAngleRad + element.SweepAngleRad * t; + return element.Center + new float2(math.cos(angle), math.sin(angle)) * element.Radius; + } + + private static float DistanceToElement(float2 point, RubberPathElement element) + { + if (element.Type == RubberPathElementType.SupportedArc) { + return math.abs(math.distance(point, element.Center) - element.Radius); + } + var delta = element.End - element.Start; + var lengthSquared = math.lengthsq(delta); + if (lengthSquared <= 1e-12f) { + return math.distance(point, element.Start); + } + var t = math.saturate(math.dot(point - element.Start, delta) / lengthSquared); + return math.distance(point, element.Start + delta * t); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs.meta new file mode 100644 index 000000000..18b86b7da --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1ca2c4b1377b435b93e385948d270cee +timeCreated: 1783936773 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs index bd12954ce..05dd4d3f7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs @@ -46,6 +46,18 @@ internal RubberApi(GameObject go, Player player, PhysicsEngine physicsEngine) : protected override void CreateColliders(ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) { + if (ColliderComponent.Mode == RubberColliderMode.Physical) { + if (!ColliderComponent.CanUsePhysical) { + Debug.LogWarning($"Physical rubber '{MainComponent.name}' has no current valid guided bake; falling back to Legacy collision.", + MainComponent); + } else { + var physicalGenerator = new RubberPhysicalColliderGenerator(this, MainComponent, + translateWithinPlayfieldMatrix); + physicalGenerator.GenerateColliders(ColliderComponent.ZOffset, ref colliders, margin); + return; + } + } + var colliderGenerator = new RubberColliderGenerator( this, new RubberMeshGenerator(MainComponent), diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs index 2bf955476..94f56b58e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberColliderComponent.cs @@ -50,8 +50,23 @@ public class RubberColliderComponent : ColliderComponent _mode; + set => _mode = value; + } + + public RubberPhysicsMaterialAsset RubberPhysicsMaterial { + get => _rubberPhysicsMaterial; + set => _rubberPhysicsMaterial = value; + } + + public bool CanUsePhysical => MainComponent && MainComponent.HasValidGuidedPath; #endregion @@ -59,11 +74,13 @@ public class RubberColliderComponent : ColliderComponent RubberColliderPackable.Pack(this); - public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => PhysicalMaterialPackable.Pack(this, files); + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => RubberColliderReferencesPackable.Pack(this, files); public void Unpack(byte[] bytes) => RubberColliderPackable.Unpack(bytes, this); - public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) => PhysicalMaterialPackable.Unpack(data, this, files); + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) + => RubberColliderReferencesPackable.Unpack(data, this, files); #endregion diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs index 52ded96fa..599ee5193 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs @@ -26,6 +26,7 @@ using System.Linq; using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Rubber; @@ -36,7 +37,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Rubber")] [AddComponentMenu("Pinball/Game Item/Rubber")] - public class RubberComponent : MainRenderableComponent, IRubberData, IPackable + public class RubberComponent : MainRenderableComponent, IRubberData, IPackable, + IDragPointSplineOwner { #region Data @@ -50,9 +52,20 @@ public float Height { [Tooltip("How thick the rubber band is rendered.")] public int _thickness = 8; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + [SerializeField] private RubberPathSource _pathSource = RubberPathSource.Spline; + [SerializeField] private RubberGuideBinding[] _guideBindings = Array.Empty(); + [SerializeField, HideInInspector] private RubberPathElement[] _bakedPath = Array.Empty(); + [SerializeField, HideInInspector] private uint _bakeVersion; + [SerializeField, HideInInspector] private Hash128 _bakeInputHash; + [SerializeField, HideInInspector] private Matrix4x4 _bakeFrameToLocal = Matrix4x4.identity; + [SerializeField, Min(0f)] private float _restLength; + [NonSerialized] private Vertex3D[] _scalingDragPoints; @@ -60,8 +73,33 @@ public float Height { #region IRubberData - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } - public int Thickness => math.max(1, _thickness); // don't allow zero thickness + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); + public int Thickness => math.max(1, _thickness); // don't allow zero thickness + public RubberPathSource PathSource => _pathSource; + public IReadOnlyList GuideBindings => _guideBindings ?? Array.Empty(); + public IReadOnlyList BakedPath => _bakedPath ?? Array.Empty(); + public uint BakeVersion => _bakeVersion; + public Hash128 BakeInputHash => _bakeInputHash; + public Matrix4x4 BakeFrameToLocal => _bakeFrameToLocal; + public float RestLength { + get => _restLength; + set => _restLength = math.max(0f, value); + } + + public bool HasValidGuidedPath { + get => RubberAutofit.GetStatus(this).IsValid; + } #endregion @@ -69,11 +107,13 @@ public float Height { public byte[] Pack() => RubberPackable.Pack(this); - public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => Array.Empty(); + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => RubberReferencesPackable.Pack(this, refs); public void Unpack(byte[] bytes) => RubberPackable.Unpack(bytes, this); - public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) + => RubberReferencesPackable.Unpack(data, this, refs); #endregion @@ -109,9 +149,10 @@ private void Awake() #region Conversion - public override IEnumerable SetData(RubberData data) - { - var updatedComponents = new List { this }; + public override IEnumerable SetData(RubberData data) + { + var updatedComponents = new List { this }; + ResetToSplineSource(); // reset origin to bounding box center and move points accordingly var min = new float3(float.MaxValue, float.MaxValue, float.MaxValue); @@ -140,8 +181,10 @@ public override IEnumerable SetData(RubberData data) // collider data var collComponent = GetComponent(); - if (collComponent) { - collComponent.enabled = data.IsCollidable; + if (collComponent) { + collComponent.Mode = RubberColliderMode.Legacy; + collComponent.RubberPhysicsMaterial = null; + collComponent.enabled = data.IsCollidable; collComponent.HitEvent = data.HitEvent; collComponent.ZOffset = data.Height - data.HitHeight; // check if not -1 @@ -213,13 +256,103 @@ public override RubberData CopyDataTo(RubberData data, string[] materialNames, s public override void CopyFromObject(GameObject go) { var srcMainComp = go.GetComponent(); - if (srcMainComp) { - _thickness = srcMainComp._thickness; - _dragPoints = srcMainComp._dragPoints.Select(dp => dp.Clone()).ToArray(); - } - RebuildMeshes(); + if (srcMainComp) { + _thickness = srcMainComp._thickness; + DragPoints = srcMainComp.DragPoints.Select(dp => dp.Clone()).ToArray(); + _pathSource = srcMainComp._pathSource; + _guideBindings = srcMainComp.GuideBindings.ToArray(); + _bakedPath = srcMainComp.BakedPath.ToArray(); + _bakeVersion = srcMainComp._bakeVersion; + _bakeInputHash = srcMainComp._bakeInputHash; + _bakeFrameToLocal = srcMainComp._bakeFrameToLocal; + _restLength = srcMainComp._restLength; + } + RebuildMeshes(); + } + + public void SetGuideBindings(IEnumerable bindings) + { + _guideBindings = bindings?.ToArray() ?? Array.Empty(); + _pathSource = RubberPathSource.Guides; + _bakeVersion = 0; + _bakeInputHash = default; + } + + public void ApplyGuidedBake(RubberPathElement[] path, DragPointData[] sampledDragPoints, + Matrix4x4 bakeFrameToLocal, Hash128 inputHash, uint bakeVersion) + { + if (_pathSource != RubberPathSource.Guides) { + throw new InvalidOperationException("A guided bake requires guide bindings to be authoritative."); + } + _bakedPath = path?.ToArray() ?? Array.Empty(); + _bakeFrameToLocal = bakeFrameToLocal; + _bakeInputHash = inputHash; + _bakeVersion = bakeVersion; + if (sampledDragPoints != null) { + DragPoints = sampledDragPoints; + } + } + + public void DetachFromGuides() + { + ResetToSplineSource(); + var collider = GetComponent(); + if (collider) { + collider.Mode = RubberColliderMode.Legacy; + } + } + + private void ResetToSplineSource() + { + _pathSource = RubberPathSource.Spline; + _guideBindings = Array.Empty(); + _bakedPath = Array.Empty(); + _bakeVersion = 0; + _bakeInputHash = default; + _bakeFrameToLocal = Matrix4x4.identity; + _restLength = 0f; + } + + internal void RestorePackedState(RubberPathSource pathSource, RubberGuideBinding[] guideBindings, + RubberPathElement[] bakedPath, uint bakeVersion, Hash128 bakeInputHash, + Matrix4x4 bakeFrameToLocal, float restLength) + { + _pathSource = pathSource; + _guideBindings = guideBindings ?? Array.Empty(); + _bakedPath = bakedPath ?? Array.Empty(); + _bakeVersion = bakeVersion; + _bakeInputHash = bakeInputHash; + _bakeFrameToLocal = bakeFrameToLocal; + _restLength = math.max(0f, restLength); + } + + internal void RestoreGuideReferences(RubberGuideComponent[] guides) + { + if (guides == null || guides.Length != GuideBindings.Count) { + throw new ArgumentException("Guide reference count must match binding count.", nameof(guides)); + } + for (var i = 0; i < guides.Length; i++) { + _guideBindings[i].Guide = guides[i]; + } + } + + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; } + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPackable.cs index 0183c06f7..bd87d1a47 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPackable.cs @@ -1,68 +1,269 @@ -// 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 . - -// ReSharper disable MemberCanBePrivate.Global - -using System.Collections.Generic; -using System.Linq; - -namespace VisualPinball.Unity -{ - public struct RubberPackable - { - public int Thickness; - public IEnumerable DragPoints; - - public static byte[] Pack(RubberComponent comp) - { - return PackageApi.Packer.Pack(new RubberPackable { - Thickness = comp.Thickness, - DragPoints = comp.DragPoints.Select(DragPointPackable.From) - }); - } - - public static void Unpack(byte[] bytes, RubberComponent comp) - { - var data = PackageApi.Packer.Unpack(bytes); - comp._thickness = data.Thickness; - comp.DragPoints = data.DragPoints.Select(c => c.ToDragPoint()).ToArray(); - } - } - - public struct RubberColliderPackable - { - public bool IsMovable; - public bool HitEvent; - public float ZOffset; - - public static byte[] Pack(RubberColliderComponent comp) - { - return PackageApi.Packer.Pack(new RubberColliderPackable { - IsMovable = comp._isKinematic, - HitEvent = comp.HitEvent, - ZOffset = comp.ZOffset, - }); - } - - public static void Unpack(byte[] bytes, RubberColliderComponent comp) - { - var data = PackageApi.Packer.Unpack(bytes); - comp._isKinematic = data.IsMovable; - comp.HitEvent = data.HitEvent; - comp.ZOffset = data.ZOffset; - } - } -} +// 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.IO; +using System.Linq; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public struct RubberPackable + { + public int Thickness; + public IEnumerable DragPoints; + public int PathSource; + public RubberGuideBindingPackable[] GuideBindings; + public RubberPathElementPackable[] BakedPath; + public uint BakeVersion; + public string BakeInputHash; + public PackableMatrix4x4 BakeFrameToLocal; + public float RestLength; + + public static byte[] Pack(RubberComponent comp) + { + return PackageApi.Packer.Pack(new RubberPackable { + Thickness = comp.Thickness, + DragPoints = comp.DragPoints.Select(DragPointPackable.From), + PathSource = (int)comp.PathSource, + GuideBindings = comp.GuideBindings.Select(RubberGuideBindingPackable.From).ToArray(), + BakedPath = comp.BakedPath.Select(RubberPathElementPackable.From).ToArray(), + BakeVersion = comp.BakeVersion, + BakeInputHash = comp.BakeInputHash.isValid ? comp.BakeInputHash.ToString() : null, + BakeFrameToLocal = comp.BakeFrameToLocal, + RestLength = comp.RestLength, + }); + } + + public static void Unpack(byte[] bytes, RubberComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp._thickness = data.Thickness; + comp.DragPoints = data.DragPoints?.Select(c => c.ToDragPoint()).ToArray() + ?? Array.Empty(); + + var hash = default(Hash128); + if (!string.IsNullOrEmpty(data.BakeInputHash)) { + if (data.BakeInputHash.Length != 32 + || data.BakeInputHash.Any(character => !Uri.IsHexDigit(character))) { + throw new InvalidDataException($"Invalid rubber bake hash '{data.BakeInputHash}'."); + } + try { + hash = Hash128.Parse(data.BakeInputHash); + } catch (Exception exception) when (exception is ArgumentException + or FormatException) { + throw new InvalidDataException($"Invalid rubber bake hash '{data.BakeInputHash}'.", exception); + } + } + var pathSource = Enum.IsDefined(typeof(RubberPathSource), data.PathSource) + ? (RubberPathSource)data.PathSource + : RubberPathSource.Spline; + comp.RestorePackedState( + pathSource, + data.GuideBindings?.Select(binding => binding.ToBinding()).ToArray() + ?? Array.Empty(), + data.BakedPath?.Select(element => element.ToElement()).ToArray() + ?? Array.Empty(), + data.BakeVersion, + hash, + data.BakeFrameToLocal.IsZero ? Matrix4x4.identity : data.BakeFrameToLocal, + data.RestLength + ); + } + } + + public struct RubberReferencesPackable + { + public ReferencePackable[] Guides; + + public static byte[] Pack(RubberComponent comp, PackagedRefs refs) + { + return PackageApi.Packer.Pack(new RubberReferencesPackable { + Guides = comp.GuideBindings.Select(binding => refs.PackReference(binding.Guide)).ToArray(), + }); + } + + public static void Unpack(byte[] bytes, RubberComponent comp, PackagedRefs refs) + { + if (bytes == null || bytes.Length == 0) { + return; + } + var data = PackageApi.Packer.Unpack(bytes); + var packedGuides = data.Guides ?? Array.Empty(); + if (packedGuides.Length != comp.GuideBindings.Count) { + Debug.LogError($"Rubber '{comp.name}' has {comp.GuideBindings.Count} bindings but {packedGuides.Length} guide references."); + return; + } + comp.RestoreGuideReferences(packedGuides.Select(refs.Resolve).ToArray()); + } + } + + public struct RubberGuideBindingPackable + { + public ulong SlotIdA; + public ulong SlotIdB; + + public static RubberGuideBindingPackable From(RubberGuideBinding binding) + => new() { SlotIdA = binding.SlotId.A, SlotIdB = binding.SlotId.B }; + + public RubberGuideBinding ToBinding() + => new(null, new SerializedGuid(SlotIdA, SlotIdB)); + } + + public struct RubberPathElementPackable + { + public int Type; + public PackableFloat2 Start; + public PackableFloat2 End; + public PackableFloat2 Center; + public float Radius; + public float StartAngleRad; + public float SweepAngleRad; + public int StartBindingIndex; + public int EndBindingIndex; + public float StartDistance; + public float Length; + + public static RubberPathElementPackable From(RubberPathElement element) + { + return new RubberPathElementPackable { + Type = (int)element.Type, + Start = element.Start, + End = element.End, + Center = element.Center, + Radius = element.Radius, + StartAngleRad = element.StartAngleRad, + SweepAngleRad = element.SweepAngleRad, + StartBindingIndex = element.StartBindingIndex, + EndBindingIndex = element.EndBindingIndex, + StartDistance = element.StartDistance, + Length = element.Length, + }; + } + + public RubberPathElement ToElement() + { + return new RubberPathElement { + Type = (RubberPathElementType)Type, + Start = Start, + End = End, + Center = Center, + Radius = Radius, + StartAngleRad = StartAngleRad, + SweepAngleRad = SweepAngleRad, + StartBindingIndex = StartBindingIndex, + EndBindingIndex = EndBindingIndex, + StartDistance = StartDistance, + Length = Length, + }; + } + } + + public struct PackableMatrix4x4 + { + public float M00, M01, M02, M03; + public float M10, M11, M12, M13; + public float M20, M21, M22, M23; + public float M30, M31, M32, M33; + + public bool IsZero => M00 == 0f && M01 == 0f && M02 == 0f && M03 == 0f + && M10 == 0f && M11 == 0f && M12 == 0f && M13 == 0f + && M20 == 0f && M21 == 0f && M22 == 0f && M23 == 0f + && M30 == 0f && M31 == 0f && M32 == 0f && M33 == 0f; + + public static implicit operator PackableMatrix4x4(Matrix4x4 matrix) + { + return new PackableMatrix4x4 { + M00 = matrix.m00, M01 = matrix.m01, M02 = matrix.m02, M03 = matrix.m03, + M10 = matrix.m10, M11 = matrix.m11, M12 = matrix.m12, M13 = matrix.m13, + M20 = matrix.m20, M21 = matrix.m21, M22 = matrix.m22, M23 = matrix.m23, + M30 = matrix.m30, M31 = matrix.m31, M32 = matrix.m32, M33 = matrix.m33, + }; + } + + public static implicit operator Matrix4x4(PackableMatrix4x4 matrix) + { + var result = new Matrix4x4(); + result.SetRow(0, new Vector4(matrix.M00, matrix.M01, matrix.M02, matrix.M03)); + result.SetRow(1, new Vector4(matrix.M10, matrix.M11, matrix.M12, matrix.M13)); + result.SetRow(2, new Vector4(matrix.M20, matrix.M21, matrix.M22, matrix.M23)); + result.SetRow(3, new Vector4(matrix.M30, matrix.M31, matrix.M32, matrix.M33)); + return result; + } + } + + public struct RubberColliderPackable + { + public bool IsMovable; + public bool HitEvent; + public float ZOffset; + public int Mode; + + public static byte[] Pack(RubberColliderComponent comp) + { + return PackageApi.Packer.Pack(new RubberColliderPackable { + IsMovable = comp._isKinematic, + HitEvent = comp.HitEvent, + ZOffset = comp.ZOffset, + Mode = (int)comp.Mode, + }); + } + + public static void Unpack(byte[] bytes, RubberColliderComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp._isKinematic = data.IsMovable; + comp.HitEvent = data.HitEvent; + comp.ZOffset = data.ZOffset; + comp.Mode = Enum.IsDefined(typeof(RubberColliderMode), data.Mode) + ? (RubberColliderMode)data.Mode + : RubberColliderMode.Legacy; + } + } + + public struct RubberColliderReferencesPackable + { + public float Elasticity; + public float ElasticityFalloff; + public float Friction; + public float Scatter; + public bool Overwrite; + public int AssetRef; + public int RubberPhysicsMaterialRef; + + public static byte[] Pack(RubberColliderComponent comp, PackagedFiles files) + { + return PackageApi.Packer.Pack(new RubberColliderReferencesPackable { + Elasticity = comp.PhysicsElasticity, + ElasticityFalloff = comp.PhysicsElasticityFalloff, + Friction = comp.PhysicsFriction, + Scatter = comp.PhysicsScatter, + Overwrite = comp.PhysicsOverwrite, + AssetRef = files.AddAsset(comp.PhysicsMaterialReference), + RubberPhysicsMaterialRef = files.AddAsset(comp.RubberPhysicsMaterial), + }); + } + + public static void Unpack(byte[] bytes, RubberColliderComponent comp, PackagedFiles files) + { + if (bytes == null || bytes.Length == 0) { + return; + } + var data = PackageApi.Packer.Unpack(bytes); + comp.PhysicsElasticity = data.Elasticity; + comp.PhysicsElasticityFalloff = data.ElasticityFalloff; + comp.PhysicsFriction = data.Friction; + comp.PhysicsScatter = data.Scatter; + comp.PhysicsOverwrite = data.Overwrite; + comp.PhysicsMaterialReference = files.GetAsset(data.AssetRef); + comp.RubberPhysicsMaterial = files.GetAsset(data.RubberPhysicsMaterialRef); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs new file mode 100644 index 000000000..94b03d547 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs @@ -0,0 +1,91 @@ +// 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.Mathematics; +using Unity.Profiling; +using UnityEngine; + +namespace VisualPinball.Unity +{ + internal sealed class RubberPhysicalColliderGenerator + { + internal const float ArcChordToleranceVpx = 0.05f; + internal const float ArcChordRadiusFraction = 0.05f; + private static readonly ProfilerMarker PerfMarker = new("RubberPhysicalColliderGenerator"); + + private readonly RubberApi _api; + private readonly RubberComponent _rubber; + private readonly float4x4 _matrix; + + internal RubberPhysicalColliderGenerator(RubberApi api, RubberComponent rubber, + float4x4 matrix) + { + _api = api; + _rubber = rubber; + _matrix = matrix; + } + + internal void GenerateColliders(float zOffset, ref ColliderReference colliders, + float margin) + { + using var _ = PerfMarker.Auto(); + var rubberRadius = _rubber.Thickness * 0.5f; + var cordRadius = rubberRadius + margin; + foreach (var element in _rubber.BakedPath) { + if (element.Type == RubberPathElementType.FreeSpan) { + Add(element.Start, element.End, zOffset, cordRadius, ref colliders); + continue; + } + + var maximumAngle = MaximumChordAngle(element.Radius, + ChordTolerance(rubberRadius)); + var segmentCount = math.max(3, + (int)math.ceil(element.SweepAngleRad / maximumAngle)); + var start = element.Start; + for (var segment = 1; segment <= segmentCount; segment++) { + var angle = element.StartAngleRad + + element.SweepAngleRad * segment / segmentCount; + var end = element.Center + + new float2(math.cos(angle), math.sin(angle)) * element.Radius; + Add(start, end, zOffset, cordRadius, ref colliders); + start = end; + } + } + } + + internal static float MaximumChordAngle(float radius, float tolerance) + { + if (radius <= tolerance) { + return math.PI; + } + return math.max(1e-4f, 2f * math.acos(math.clamp(1f - tolerance / radius, -1f, 1f))); + } + + internal static float ChordTolerance(float cordRadius) + => math.min(ArcChordToleranceVpx, ArcChordRadiusFraction * cordRadius); + + private void Add(float2 bakeStart, float2 bakeEnd, float zOffset, + float cordRadius, ref ColliderReference colliders) + { + var start = BakeToLocal(bakeStart, zOffset); + var end = BakeToLocal(bakeEnd, zOffset); + if (math.distancesq(start, end) <= 1e-10f) { + return; + } + colliders.Add(new SweptCircleCollider(start, end, cordRadius, + _api.GetColliderInfo()), _matrix); + } + + private float3 BakeToLocal(float2 point, float zOffset) + { + var local = _rubber.BakeFrameToLocal.MultiplyPoint3x4( + new Vector3(point.x, point.y, zOffset)); + return new float3(local.x, local.y, local.z); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs.meta new file mode 100644 index 000000000..1297aa463 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: afc7003540114fa295b9efe99be200e0 +timeCreated: 1783936781 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs new file mode 100644 index 000000000..f531a3ace --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs @@ -0,0 +1,128 @@ +// 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 Newtonsoft.Json; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [PackAs("RubberPhysicsMaterial")] + [PackWith(typeof(RubberPhysicsMaterialPacker))] + [CreateAssetMenu(fileName = "Rubber Physics Material", menuName = "Pinball/Rubber Physics Material")] + public sealed class RubberPhysicsMaterialAsset : ScriptableObject + { + [Min(0f)] public float DensityKgPerCubicMeter = 1100f; + [JsonIgnore] public AnimationCurve NominalStressMpaByStretchRatio = new( + new Keyframe(1f, 0f), new Keyframe(2f, 2f)); + [Range(0f, 1f)] public float ModalDampingRatio = 0.08f; + [Min(0f)] public float BallSurfaceFriction = 0.3f; + [Min(1f)] public float MaximumStretchRatio = 2f; + + public float[] BakeStressLut(int sampleCount) + { + if (sampleCount < 2) { + throw new ArgumentOutOfRangeException(nameof(sampleCount)); + } + var curve = NominalStressMpaByStretchRatio ?? new AnimationCurve(); + var lut = new float[sampleCount]; + for (var i = 0; i < sampleCount; i++) { + var stretch = Mathf.Lerp(1f, Mathf.Max(1f, MaximumStretchRatio), i / (sampleCount - 1f)); + lut[i] = curve.Evaluate(stretch); + } + return lut; + } + } + + public sealed class RubberPhysicsMaterialPacker : IPacker, IPacker + { + public MetaPackable Pack(int instanceId, RubberPhysicsMaterialAsset material, PackagedFiles files) + { + return RubberPhysicsMaterialMetaPackable.From(instanceId, material); + } + + public MetaPackable Unpack(byte[] bytes, RubberPhysicsMaterialAsset material, PackagedFiles files) + { + var data = PackageApi.Packer.Unpack(bytes); + data.Apply(material); + return data; + } + + MetaPackable IPacker.Pack(int instanceId, ScriptableObject obj, PackagedFiles files) + => Pack(instanceId, (RubberPhysicsMaterialAsset)obj, files); + + MetaPackable IPacker.Unpack(byte[] bytes, ScriptableObject obj, PackagedFiles files) + => Unpack(bytes, (RubberPhysicsMaterialAsset)obj, files); + } + + public sealed class RubberPhysicsMaterialMetaPackable : MetaPackable + { + public CurveKeyframePackable[] Keyframes; + public int PreWrapMode; + public int PostWrapMode; + + public static RubberPhysicsMaterialMetaPackable From(int instanceId, RubberPhysicsMaterialAsset material) + { + var curve = material.NominalStressMpaByStretchRatio ?? new AnimationCurve(); + return new RubberPhysicsMaterialMetaPackable { + InstanceId = instanceId, + Keyframes = curve.keys.Select(CurveKeyframePackable.From).ToArray(), + PreWrapMode = (int)curve.preWrapMode, + PostWrapMode = (int)curve.postWrapMode, + }; + } + + public void Apply(RubberPhysicsMaterialAsset material) + { + var curve = new AnimationCurve(Keyframes?.Select(key => key.ToKeyframe()).ToArray() + ?? Array.Empty()) { + preWrapMode = (WrapMode)PreWrapMode, + postWrapMode = (WrapMode)PostWrapMode, + }; + material.NominalStressMpaByStretchRatio = curve; + } + } + + public struct CurveKeyframePackable + { + public float Time; + public float Value; + public float InTangent; + public float OutTangent; + public float InWeight; + public float OutWeight; + public int WeightedMode; + + public static CurveKeyframePackable From(Keyframe keyframe) + { + return new CurveKeyframePackable { + Time = keyframe.time, + Value = keyframe.value, + InTangent = keyframe.inTangent, + OutTangent = keyframe.outTangent, + InWeight = keyframe.inWeight, + OutWeight = keyframe.outWeight, + WeightedMode = (int)keyframe.weightedMode, + }; + } + + public Keyframe ToKeyframe() + { + return new Keyframe { + time = Time, + value = Value, + inTangent = InTangent, + outTangent = OutTangent, + inWeight = InWeight, + outWeight = OutWeight, + weightedMode = (WeightedMode)WeightedMode, + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs.meta new file mode 100644 index 000000000..1b033933b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e07f2ca836e84b9cac1ec30cca073409 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot.meta new file mode 100644 index 000000000..8a8c62967 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0e93125d51324738866a2f97b06ad9cb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs new file mode 100644 index 000000000..38e60ca14 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs @@ -0,0 +1,195 @@ +// 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.Linq; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; + +namespace VisualPinball.Unity +{ + [Serializable] + public struct SlingshotSwitchZone + { + [Range(0f, 1f)] public float Position01; + [Min(0f)] public float CloseDeflection; + [Min(0f)] public float OpenDeflection; + [Min(0f)] public float MinimumClosedTimeMs; + } + + [PackAs("RubberSlingshot")] + [AddComponentMenu("Pinball/Game Item/Rubber Slingshot")] + public sealed class RubberSlingshotComponent : MonoBehaviour, + ISwitchDeviceComponent, ICoilDeviceComponent, IPackable + { + public const string SwitchItem = "slingshot_switch"; + public const string CoilItem = "slingshot_coil"; + + [SerializeField] private RubberComponent _rubber; + [SerializeField] private RubberSpanBinding _span; + [SerializeField] private SlingshotSwitchZone[] _switchZones = { + new() { Position01 = 0.3f, CloseDeflection = 4f, OpenDeflection = 2f, MinimumClosedTimeMs = 8f }, + new() { Position01 = 0.7f, CloseDeflection = 4f, OpenDeflection = 2f, MinimumClosedTimeMs = 8f }, + }; + [SerializeField, Range(0f, 1f)] private float _armContactPosition01 = 0.5f; + [SerializeField, Min(0f)] private float _armRestClearance = 2f; + [SerializeField, Min(0f)] private float _armTipTravel = 18f; + [SerializeField] private SlingshotActuatorAsset _actuator; + [SerializeField] private GameObject _coilArmVisual; + [SerializeField] private Axis _visualRotationAxis; + [SerializeField] private float _visualRestAngle; + [SerializeField] private float _visualFiredAngle = 20f; + + public RubberComponent Rubber { get => _rubber; set => _rubber = value; } + public RubberSpanBinding Span { get => _span; set => _span = value; } + public SlingshotSwitchZone[] SwitchZones { + get => _switchZones ?? Array.Empty(); + set => _switchZones = value ?? Array.Empty(); + } + public float ArmContactPosition01 { get => _armContactPosition01; set => _armContactPosition01 = value; } + public float ArmRestClearance { get => _armRestClearance; set => _armRestClearance = value; } + public float ArmTipTravel { get => _armTipTravel; set => _armTipTravel = value; } + public SlingshotActuatorAsset Actuator { get => _actuator; set => _actuator = value; } + public GameObject CoilArmVisual { get => _coilArmVisual; set => _coilArmVisual = value; } + public Axis VisualRotationAxis { get => _visualRotationAxis; set => _visualRotationAxis = value; } + public float VisualRestAngle { get => _visualRestAngle; set => _visualRestAngle = value; } + public float VisualFiredAngle { get => _visualFiredAngle; set => _visualFiredAngle = value; } + + public static bool RuntimeAvailable => false; + + public IEnumerable AvailableSwitches => new[] { + new GamelogicEngineSwitch(SwitchItem) { + Description = "Slingshot blade switches", + IsPulseSwitch = false, + }, + }; + + public IEnumerable AvailableCoils => new[] { + new GamelogicEngineCoil(CoilItem) { Description = "Slingshot actuator" }, + }; + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IEnumerable IDeviceComponent.AvailableDeviceItems + => AvailableSwitches; + IEnumerable IDeviceComponent.AvailableDeviceItems + => AvailableCoils; + IEnumerable IWireableComponent.AvailableWireDestinations + => AvailableCoils; + IEnumerable IDeviceComponent.AvailableDeviceItems + => AvailableCoils; + + IApiCoil ICoilDeviceComponent.CoilDevice(string deviceId) => null; + + public byte[] Pack() => RubberSlingshotPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => RubberSlingshotReferencesPackable.Pack(this, root, refs, files); + + public void Unpack(byte[] bytes) => RubberSlingshotPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, + PackagedFiles files) + => RubberSlingshotReferencesPackable.Unpack(data, this, root, refs, files); + + public bool TryResolveSpan(out ResolvedRubberSpan resolved, out string error) + => RubberSpanResolver.TryResolve(_rubber, _span, out resolved, out error); + + public IReadOnlyList ValidateConfiguration(bool includeSceneUniqueness = false) + { + var errors = new List(); + if (!TryResolveSpan(out var resolved, out var spanError)) { + errors.Add(spanError); + } + if (_switchZones == null || _switchZones.Length != 2) { + errors.Add("A physical slingshot requires exactly two switch zones."); + } else { + for (var i = 0; i < _switchZones.Length; i++) { + var zone = _switchZones[i]; + if (!IsNormalized(zone.Position01)) { + errors.Add($"Switch zone {i + 1} position must be in [0, 1]."); + } + if (!float.IsFinite(zone.OpenDeflection) || zone.OpenDeflection < 0f + || !float.IsFinite(zone.CloseDeflection) + || zone.CloseDeflection <= zone.OpenDeflection) { + errors.Add($"Switch zone {i + 1} requires 0 <= open deflection < close deflection."); + } + if (!float.IsFinite(zone.MinimumClosedTimeMs) + || zone.MinimumClosedTimeMs < 0f) { + errors.Add($"Switch zone {i + 1} minimum closed time must be non-negative."); + } + } + } + if (!IsNormalized(_armContactPosition01)) { + errors.Add("Arm contact position must be in [0, 1]."); + } + if (!IsFiniteNonNegative(_armRestClearance) + || !IsFiniteNonNegative(_armTipTravel)) { + errors.Add("Arm clearance and travel must be finite and non-negative."); + } + if (!_actuator) { + errors.Add("Assign a calibrated slingshot actuator asset."); + } else { + errors.AddRange(_actuator.ValidateData()); + } + + if (includeSceneUniqueness && errors.Count == 0) { + foreach (var other in FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None)) { + if (other == this || other._rubber != _rubber + || !other.TryResolveSpan(out var otherResolved, out _)) { + continue; + } + if (otherResolved.PathElementIndex == resolved.PathElementIndex) { + errors.Add($"Rubber span is already bound by '{other.name}'."); + break; + } + } + } + return errors.Where(error => !string.IsNullOrEmpty(error)).ToArray(); + } + + internal void RestoreReferences(RubberComponent rubber, + RubberGuideComponent startGuide, RubberGuideComponent endGuide, + SlingshotActuatorAsset actuator, GameObject coilArmVisual) + { + _rubber = rubber; + _span.StartSupport.Guide = startGuide; + _span.EndSupport.Guide = endGuide; + _actuator = actuator; + _coilArmVisual = coilArmVisual; + } + + public void ValidateAfterUnpack() + { + var errors = ValidateConfiguration(); + if (errors.Count == 0) { + return; + } + enabled = false; + Debug.LogWarning( + $"Rubber slingshot '{name}' was unpacked with an invalid configuration: {string.Join(" ", errors)}", + this); + } + + private void Awake() + { + if (Application.isPlaying) { + enabled = false; + Debug.LogError($"Physical slingshot runtime unavailable for '{name}'. Authoring data is preserved, but this component does not register runtime controls.", this); + } + } + + private static bool IsNormalized(float value) + => float.IsFinite(value) && value >= 0f && value <= 1f; + + private static bool IsFiniteNonNegative(float value) + => float.IsFinite(value) && value >= 0f; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs.meta new file mode 100644 index 000000000..9af57bc03 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 846d994dad2b48cca5a5a83f165e0f8f diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs new file mode 100644 index 000000000..7bf9469f9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs @@ -0,0 +1,134 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity +{ + public struct SlingshotSwitchZonePackable + { + public float Position01; + public float CloseDeflection; + public float OpenDeflection; + public float MinimumClosedTimeMs; + + public static SlingshotSwitchZonePackable From(SlingshotSwitchZone zone) + { + return new SlingshotSwitchZonePackable { + Position01 = zone.Position01, + CloseDeflection = zone.CloseDeflection, + OpenDeflection = zone.OpenDeflection, + MinimumClosedTimeMs = zone.MinimumClosedTimeMs, + }; + } + + public SlingshotSwitchZone ToZone() + { + return new SlingshotSwitchZone { + Position01 = Position01, + CloseDeflection = CloseDeflection, + OpenDeflection = OpenDeflection, + MinimumClosedTimeMs = MinimumClosedTimeMs, + }; + } + } + + public struct RubberSlingshotPackable + { + public ulong StartSlotIdA; + public ulong StartSlotIdB; + public ulong EndSlotIdA; + public ulong EndSlotIdB; + public SlingshotSwitchZonePackable[] SwitchZones; + public float ArmContactPosition01; + public float ArmRestClearance; + public float ArmTipTravel; + public int VisualRotationAxis; + public float VisualRestAngle; + public float VisualFiredAngle; + + public static byte[] Pack(RubberSlingshotComponent component) + { + var span = component.Span; + return PackageApi.Packer.Pack(new RubberSlingshotPackable { + StartSlotIdA = span.StartSupport.SlotId.A, + StartSlotIdB = span.StartSupport.SlotId.B, + EndSlotIdA = span.EndSupport.SlotId.A, + EndSlotIdB = span.EndSupport.SlotId.B, + SwitchZones = component.SwitchZones.Select(SlingshotSwitchZonePackable.From).ToArray(), + ArmContactPosition01 = component.ArmContactPosition01, + ArmRestClearance = component.ArmRestClearance, + ArmTipTravel = component.ArmTipTravel, + VisualRotationAxis = (int)component.VisualRotationAxis, + VisualRestAngle = component.VisualRestAngle, + VisualFiredAngle = component.VisualFiredAngle, + }); + } + + public static void Unpack(byte[] bytes, RubberSlingshotComponent component) + { + var data = PackageApi.Packer.Unpack(bytes); + component.Span = new RubberSpanBinding( + new RubberGuideBinding(null, new SerializedGuid(data.StartSlotIdA, data.StartSlotIdB)), + new RubberGuideBinding(null, new SerializedGuid(data.EndSlotIdA, data.EndSlotIdB))); + component.SwitchZones = data.SwitchZones?.Select(zone => zone.ToZone()).ToArray() + ?? Array.Empty(); + component.ArmContactPosition01 = data.ArmContactPosition01; + component.ArmRestClearance = data.ArmRestClearance; + component.ArmTipTravel = data.ArmTipTravel; + component.VisualRotationAxis = Enum.IsDefined(typeof(Axis), data.VisualRotationAxis) + ? (Axis)data.VisualRotationAxis + : Axis.X; + component.VisualRestAngle = data.VisualRestAngle; + component.VisualFiredAngle = data.VisualFiredAngle; + } + } + + public struct RubberSlingshotReferencesPackable + { + public ReferencePackable RubberRef; + public ReferencePackable StartGuideRef; + public ReferencePackable EndGuideRef; + public int ActuatorRef; + public string CoilArmVisualPath; + + public static byte[] Pack(RubberSlingshotComponent component, Transform root, + PackagedRefs refs, PackagedFiles files) + { + var span = component.Span; + return PackageApi.Packer.Pack(new RubberSlingshotReferencesPackable { + RubberRef = refs.PackReference(component.Rubber), + StartGuideRef = refs.PackReference(span.StartSupport.Guide), + EndGuideRef = refs.PackReference(span.EndSupport.Guide), + ActuatorRef = files.AddAsset(component.Actuator), + CoilArmVisualPath = component.CoilArmVisual + ? component.CoilArmVisual.transform.GetPath(root, activeOnly: true) + : null, + }); + } + + public static void Unpack(byte[] bytes, RubberSlingshotComponent component, + Transform root, PackagedRefs refs, PackagedFiles files) + { + if (bytes == null || bytes.Length == 0) { + return; + } + var data = PackageApi.Packer.Unpack(bytes); + component.RestoreReferences( + refs.Resolve(data.RubberRef), + refs.Resolve(data.StartGuideRef), + refs.Resolve(data.EndGuideRef), + files.GetAsset(data.ActuatorRef), + string.IsNullOrEmpty(data.CoilArmVisualPath) + ? null + : root.FindByPath(data.CoilArmVisualPath)?.gameObject); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs.meta new file mode 100644 index 000000000..24f130a94 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c492269fa3ec47e8b28e95cb4b36ebf9 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs new file mode 100644 index 000000000..36eac6757 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs @@ -0,0 +1,136 @@ +// 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 Unity.Mathematics; + +namespace VisualPinball.Unity +{ + [Serializable] + public struct RubberSpanBinding + { + public RubberGuideBinding StartSupport; + public RubberGuideBinding EndSupport; + + public RubberSpanBinding(RubberGuideBinding startSupport, + RubberGuideBinding endSupport) + { + StartSupport = startSupport; + EndSupport = endSupport; + } + } + + public readonly struct ResolvedRubberSpan + { + public readonly int PathElementIndex; + public readonly bool IsReversed; + public readonly RubberPathElement Element; + + public ResolvedRubberSpan(int pathElementIndex, bool isReversed, + RubberPathElement element) + { + PathElementIndex = pathElementIndex; + IsReversed = isReversed; + Element = element; + } + + public float ToPathCoordinate(float authoredCoordinate01) + => IsReversed ? 1f - authoredCoordinate01 : authoredCoordinate01; + } + + public static class RubberSpanResolver + { + private const float MinimumSpanLength = 1e-4f; + + public static bool TryResolve(RubberComponent rubber, RubberSpanBinding span, + out ResolvedRubberSpan resolved, out string error) + { + resolved = default; + if (!rubber) { + error = "Select a guided rubber."; + return false; + } + if (rubber.PathSource != RubberPathSource.Guides) { + error = $"Rubber '{rubber.name}' is not guide-driven."; + return false; + } + var collider = rubber.GetComponent(); + if (!collider || collider.Mode != RubberColliderMode.Physical) { + error = $"Rubber '{rubber.name}' must use the Physical collider mode."; + return false; + } + if (!rubber.HasValidGuidedPath) { + error = $"Rubber '{rubber.name}' needs a current valid autofit bake."; + return false; + } + + var startIndex = FindBinding(rubber, span.StartSupport); + if (startIndex < 0) { + error = "The start support no longer belongs to the rubber bake."; + return false; + } + var endIndex = FindBinding(rubber, span.EndSupport); + if (endIndex < 0) { + error = "The end support no longer belongs to the rubber bake."; + return false; + } + if (startIndex == endIndex) { + error = "A slingshot span needs two different endpoint supports."; + return false; + } + + var matchCount = 0; + for (var i = 0; i < rubber.BakedPath.Count; i++) { + var element = rubber.BakedPath[i]; + if (element.Type != RubberPathElementType.FreeSpan) { + continue; + } + var forward = element.StartBindingIndex == startIndex + && element.EndBindingIndex == endIndex; + var reversed = element.StartBindingIndex == endIndex + && element.EndBindingIndex == startIndex; + if (!forward && !reversed) { + continue; + } + if (element.Length <= MinimumSpanLength + || math.distancesq(element.Start, element.End) + <= MinimumSpanLength * MinimumSpanLength) { + error = "The bound free span is degenerate."; + return false; + } + resolved = new ResolvedRubberSpan(i, reversed, element); + matchCount++; + } + + if (matchCount == 1) { + error = null; + return true; + } + error = matchCount == 0 + ? "The endpoint supports do not identify a current free span." + : "The endpoint supports identify more than one free span."; + resolved = default; + return false; + } + + private static int FindBinding(RubberComponent rubber, RubberGuideBinding endpoint) + { + if (!endpoint.Guide || endpoint.SlotId.IsEmpty + || !endpoint.Guide.TryGetSlot(endpoint.SlotId, out _)) { + return -1; + } + for (var i = 0; i < rubber.GuideBindings.Count; i++) { + var candidate = rubber.GuideBindings[i]; + if (candidate.Guide == endpoint.Guide && candidate.SlotId == endpoint.SlotId) { + return i; + } + } + return -1; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs.meta new file mode 100644 index 000000000..9b8dbd832 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3fb83340a38a46cc8e87397b5ba85113 diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs new file mode 100644 index 000000000..6a1fff24d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs @@ -0,0 +1,267 @@ +// 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.IO; +using System.Linq; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + [PackAs("SlingshotActuator")] + [PackWith(typeof(SlingshotActuatorPacker))] + [CreateAssetMenu(fileName = "Slingshot Actuator", menuName = "Pinball/Slingshot Actuator")] + public sealed class SlingshotActuatorAsset : ScriptableObject + { + [Min(0f)] public float SupplyVoltage = 48f; + [Min(0f)] public float CoilResistanceOhm = 4f; + [Min(0f)] public float TurnOffClampVoltage = 50f; + [Min(0f)] public float StrokeMeters = 0.025f; + [Min(0f)] public float MaximumCurrentAmps = 12f; + [Min(0f)] public float EffectiveMovingMassKg = 0.08f; + [Min(0f)] public float ReturnSpringNewtonPerMeter = 250f; + [Min(0f)] public float ReturnSpringPreloadNewton = 5f; + [Min(0f)] public float ViscousDampingNewtonSecondPerMeter = 1f; + [Min(0f)] public float EndStopStiffnessNewtonPerMeter = 50000f; + [Min(0f)] public float EndStopDampingNewtonSecondPerMeter = 20f; + [Min(2)] public int CurrentSampleCount = 2; + [Min(2)] public int StrokeSampleCount = 2; + public float[] ForceNewtonLut = new float[4]; + public float[] FluxLinkageWeberTurnLut = { 0f, 0f, 0.02f, 0.015f }; + + public IReadOnlyList ValidateData() + { + var errors = new List(); + ValidateFiniteNonNegative(nameof(SupplyVoltage), SupplyVoltage, false, errors); + ValidateFiniteNonNegative(nameof(CoilResistanceOhm), CoilResistanceOhm, true, errors); + ValidateFiniteNonNegative(nameof(TurnOffClampVoltage), TurnOffClampVoltage, false, errors); + ValidateFiniteNonNegative(nameof(StrokeMeters), StrokeMeters, true, errors); + ValidateFiniteNonNegative(nameof(MaximumCurrentAmps), MaximumCurrentAmps, true, errors); + ValidateFiniteNonNegative(nameof(EffectiveMovingMassKg), EffectiveMovingMassKg, true, errors); + ValidateFiniteNonNegative(nameof(ReturnSpringNewtonPerMeter), ReturnSpringNewtonPerMeter, false, errors); + ValidateFiniteNonNegative(nameof(ReturnSpringPreloadNewton), ReturnSpringPreloadNewton, false, errors); + ValidateFiniteNonNegative(nameof(ViscousDampingNewtonSecondPerMeter), ViscousDampingNewtonSecondPerMeter, false, errors); + ValidateFiniteNonNegative(nameof(EndStopStiffnessNewtonPerMeter), EndStopStiffnessNewtonPerMeter, false, errors); + ValidateFiniteNonNegative(nameof(EndStopDampingNewtonSecondPerMeter), EndStopDampingNewtonSecondPerMeter, false, errors); + + if (CurrentSampleCount < 2 || StrokeSampleCount < 2) { + errors.Add("The actuator LUT needs at least two current and two stroke samples."); + return errors; + } + int expected; + try { + expected = checked(CurrentSampleCount * StrokeSampleCount); + } catch (OverflowException) { + errors.Add("The actuator LUT dimensions are too large."); + return errors; + } + ValidateLut(nameof(ForceNewtonLut), ForceNewtonLut, expected, false, errors); + ValidateLut(nameof(FluxLinkageWeberTurnLut), FluxLinkageWeberTurnLut, + expected, true, errors); + return errors; + } + + public int Index(int currentIndex, int strokeIndex) + => currentIndex * StrokeSampleCount + strokeIndex; + + public float SampleForce(float current01, float stroke01) + => Sample(ForceNewtonLut, current01, stroke01); + + public float SampleFlux(float current01, float stroke01) + => Sample(FluxLinkageWeberTurnLut, current01, stroke01); + + public bool TryGetCurrent(float fluxLinkage, float stroke01, out float currentAmps) + => TryGetCurrent(fluxLinkage, stroke01, out currentAmps, out _); + + public bool TryGetCurrent(float fluxLinkage, float stroke01, out float currentAmps, + out bool saturated) + { + currentAmps = 0f; + saturated = false; + if (ValidateData().Count != 0 || !float.IsFinite(fluxLinkage) + || !float.IsFinite(stroke01)) { + return false; + } + stroke01 = math.saturate(stroke01); + var minimum = SampleFluxAtCurrentIndex(0, stroke01); + var maximum = SampleFluxAtCurrentIndex(CurrentSampleCount - 1, stroke01); + saturated = fluxLinkage < minimum || fluxLinkage > maximum; + var target = math.clamp(fluxLinkage, minimum, maximum); + var low = 0; + var high = CurrentSampleCount - 1; + while (low < high) { + var middle = low + (high - low) / 2; + if (SampleFluxAtCurrentIndex(middle, stroke01) < target) { + low = middle + 1; + } else { + high = middle; + } + } + var upperIndex = low; + var upperFlux = SampleFluxAtCurrentIndex(upperIndex, stroke01); + if (upperIndex == 0 || upperFlux <= target) { + currentAmps = upperIndex / (CurrentSampleCount - 1f) * MaximumCurrentAmps; + return true; + } + var lowerIndex = upperIndex - 1; + var lowerFlux = SampleFluxAtCurrentIndex(lowerIndex, stroke01); + var fraction = (target - lowerFlux) / (upperFlux - lowerFlux); + currentAmps = (lowerIndex + fraction) / (CurrentSampleCount - 1f) + * MaximumCurrentAmps; + return true; + } + + private float Sample(float[] lut, float current01, float stroke01) + { + if (lut == null || lut.Length != CurrentSampleCount * StrokeSampleCount + || CurrentSampleCount < 2 || StrokeSampleCount < 2) { + throw new InvalidOperationException("Validate the actuator LUT before sampling it."); + } + var current = math.saturate(current01) * (CurrentSampleCount - 1); + var stroke = math.saturate(stroke01) * (StrokeSampleCount - 1); + var current0 = (int)math.floor(current); + var stroke0 = (int)math.floor(stroke); + var current1 = math.min(current0 + 1, CurrentSampleCount - 1); + var stroke1 = math.min(stroke0 + 1, StrokeSampleCount - 1); + var alongStroke0 = math.lerp(lut[Index(current0, stroke0)], + lut[Index(current0, stroke1)], stroke - stroke0); + var alongStroke1 = math.lerp(lut[Index(current1, stroke0)], + lut[Index(current1, stroke1)], stroke - stroke0); + return math.lerp(alongStroke0, alongStroke1, current - current0); + } + + private float SampleFluxAtCurrentIndex(int currentIndex, float stroke01) + { + var stroke = stroke01 * (StrokeSampleCount - 1); + var stroke0 = (int)math.floor(stroke); + var stroke1 = math.min(stroke0 + 1, StrokeSampleCount - 1); + return math.lerp(FluxLinkageWeberTurnLut[Index(currentIndex, stroke0)], + FluxLinkageWeberTurnLut[Index(currentIndex, stroke1)], stroke - stroke0); + } + + private static void ValidateFiniteNonNegative(string field, float value, + bool strictlyPositive, ICollection errors) + { + if (!float.IsFinite(value) || value < 0f || strictlyPositive && value <= 0f) { + errors.Add($"{field} must be finite and {(strictlyPositive ? "positive" : "non-negative")}."); + } + } + + private void ValidateLut(string field, float[] values, int expected, + bool requireMonotoneCurrent, ICollection errors) + { + if (values == null || values.Length != expected) { + errors.Add($"{field} must contain exactly {expected} samples."); + return; + } + if (values.Any(value => !float.IsFinite(value))) { + errors.Add($"{field} contains a non-finite sample."); + return; + } + if (!requireMonotoneCurrent) { + return; + } + for (var stroke = 0; stroke < StrokeSampleCount; stroke++) { + for (var current = 1; current < CurrentSampleCount; current++) { + if (values[Index(current, stroke)] < values[Index(current - 1, stroke)]) { + errors.Add($"{field} must be monotone in current at stroke sample {stroke}."); + return; + } + } + } + } + } + + public sealed class SlingshotActuatorPacker : IPacker, + IPacker + { + public MetaPackable Pack(int instanceId, SlingshotActuatorAsset asset, + PackagedFiles files) => SlingshotActuatorPackable.From(instanceId, asset); + + public MetaPackable Unpack(byte[] bytes, SlingshotActuatorAsset asset, + PackagedFiles files) + { + var data = PackageApi.Packer.Unpack(bytes); + data.Apply(asset); + return data; + } + + MetaPackable IPacker.Pack(int instanceId, ScriptableObject obj, + PackagedFiles files) => Pack(instanceId, (SlingshotActuatorAsset)obj, files); + + MetaPackable IPacker.Unpack(byte[] bytes, ScriptableObject obj, + PackagedFiles files) => Unpack(bytes, (SlingshotActuatorAsset)obj, files); + } + + public sealed class SlingshotActuatorPackable : MetaPackable + { + public float SupplyVoltage; + public float CoilResistanceOhm; + public float TurnOffClampVoltage; + public float StrokeMeters; + public float MaximumCurrentAmps; + public float EffectiveMovingMassKg; + public float ReturnSpringNewtonPerMeter; + public float ReturnSpringPreloadNewton; + public float ViscousDampingNewtonSecondPerMeter; + public float EndStopStiffnessNewtonPerMeter; + public float EndStopDampingNewtonSecondPerMeter; + public int CurrentSampleCount; + public int StrokeSampleCount; + public float[] ForceNewtonLut; + public float[] FluxLinkageWeberTurnLut; + + public static SlingshotActuatorPackable From(int instanceId, + SlingshotActuatorAsset asset) + { + return new SlingshotActuatorPackable { + InstanceId = instanceId, + SupplyVoltage = asset.SupplyVoltage, + CoilResistanceOhm = asset.CoilResistanceOhm, + TurnOffClampVoltage = asset.TurnOffClampVoltage, + StrokeMeters = asset.StrokeMeters, + MaximumCurrentAmps = asset.MaximumCurrentAmps, + EffectiveMovingMassKg = asset.EffectiveMovingMassKg, + ReturnSpringNewtonPerMeter = asset.ReturnSpringNewtonPerMeter, + ReturnSpringPreloadNewton = asset.ReturnSpringPreloadNewton, + ViscousDampingNewtonSecondPerMeter = asset.ViscousDampingNewtonSecondPerMeter, + EndStopStiffnessNewtonPerMeter = asset.EndStopStiffnessNewtonPerMeter, + EndStopDampingNewtonSecondPerMeter = asset.EndStopDampingNewtonSecondPerMeter, + CurrentSampleCount = asset.CurrentSampleCount, + StrokeSampleCount = asset.StrokeSampleCount, + ForceNewtonLut = asset.ForceNewtonLut?.ToArray(), + FluxLinkageWeberTurnLut = asset.FluxLinkageWeberTurnLut?.ToArray(), + }; + } + + public void Apply(SlingshotActuatorAsset asset) + { + asset.SupplyVoltage = SupplyVoltage; + asset.CoilResistanceOhm = CoilResistanceOhm; + asset.TurnOffClampVoltage = TurnOffClampVoltage; + asset.StrokeMeters = StrokeMeters; + asset.MaximumCurrentAmps = MaximumCurrentAmps; + asset.EffectiveMovingMassKg = EffectiveMovingMassKg; + asset.ReturnSpringNewtonPerMeter = ReturnSpringNewtonPerMeter; + asset.ReturnSpringPreloadNewton = ReturnSpringPreloadNewton; + asset.ViscousDampingNewtonSecondPerMeter = ViscousDampingNewtonSecondPerMeter; + asset.EndStopStiffnessNewtonPerMeter = EndStopStiffnessNewtonPerMeter; + asset.EndStopDampingNewtonSecondPerMeter = EndStopDampingNewtonSecondPerMeter; + asset.CurrentSampleCount = CurrentSampleCount; + asset.StrokeSampleCount = StrokeSampleCount; + asset.ForceNewtonLut = ForceNewtonLut?.ToArray(); + asset.FluxLinkageWeberTurnLut = FluxLinkageWeberTurnLut?.ToArray(); + var errors = asset.ValidateData(); + if (errors.Count != 0) { + throw new InvalidDataException($"Invalid slingshot actuator: {string.Join(" ", errors)}"); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs.meta new file mode 100644 index 000000000..bbc54d2b5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9be1c28340ef4997bd781e46d05e1fec diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs index 505fbfeb9..b747c2d48 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Surface/SurfaceComponent.cs @@ -24,7 +24,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; using VisualPinball.Engine.VPT.Surface; @@ -35,7 +37,8 @@ namespace VisualPinball.Unity [SelectionBase] [PackAs("Surface")] [AddComponentMenu("Pinball/Game Item/Surface")] - public class SurfaceComponent : MainRenderableComponent, ISurfaceComponent, IPackable + public class SurfaceComponent : MainRenderableComponent, ISurfaceComponent, IPackable, + IDragPointSplineOwner { #region Data @@ -45,9 +48,24 @@ public class SurfaceComponent : MainRenderableComponent, ISurfaceCo [Tooltip("Bottom height of the wall, i.e. at which height the wall starts.")] public float HeightBottom; - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); [SerializeField] public Vector3 uvOffset = Vector3.zero; @@ -220,7 +238,7 @@ public override void CopyFromObject(GameObject go) if (surfaceComponent) { HeightTop = surfaceComponent.HeightTop; HeightBottom = surfaceComponent.HeightBottom; - _dragPoints = surfaceComponent._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = surfaceComponent.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); @@ -235,20 +253,39 @@ private void CenterPivot() // - changing bottom height will just change the height // - change mesh and collider creation to create top-down instead of bottom-up. - var centerVpx = DragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); - centerVpx /= DragPoints.Length; + var dragPoints = DragPoints; + var centerVpx = dragPoints.Aggregate(Vector3.zero, (current, dragPoint) => current + dragPoint.Center.ToUnityVector3()); + centerVpx /= dragPoints.Length; if (uvOffset == Vector3.zero) { uvOffset = centerVpx; } transform.Translate(centerVpx.TranslateToWorld(transform) - transform.position); - foreach (var dragPoint in DragPoints) { + foreach (var dragPoint in dragPoints) { dragPoint.Center -= centerVpx.ToVertex3D(); } + DragPoints = dragPoints; RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion #region State diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs index 27856786b..37b1a1738 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Trigger/TriggerComponent.cs @@ -27,6 +27,7 @@ using Unity.Collections; using Unity.Mathematics; using UnityEngine; +using UnityEngine.Splines; using VisualPinball.Engine.Game.Engines; using VisualPinball.Engine.Math; using VisualPinball.Engine.VPT; @@ -39,7 +40,7 @@ namespace VisualPinball.Unity [PackAs("Trigger")] [AddComponentMenu("Pinball/Game Item/Trigger")] public class TriggerComponent : MainRenderableComponent, - ITriggerComponent, IAnimationValueEmitter, IPackable + ITriggerComponent, IAnimationValueEmitter, IPackable, IDragPointSplineOwner { #region Data @@ -69,9 +70,24 @@ public float Rotation { set => transform.SetLocalYRotation(math.radians(value)); } - [SerializeField] + [SerializeField, HideInInspector] private DragPointData[] _dragPoints = { new(-50f, -50f), new(-50f, 50f), new(50f, 50f), new(50f, -50f) }; - public DragPointData[] DragPoints { get => _dragPoints; set => _dragPoints = value; } + + [SerializeField] + private DragPointSplineComponent _dragPointSpline; + + public DragPointData[] DragPoints { + get => GetOrCreateDragPointSpline().DragPoints; + set { + if (!_dragPointSpline) { + _dragPoints = value; + GetOrCreateDragPointSpline(); + } else { + _dragPointSpline.SetDragPoints(value); + } + } + } + public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); #endregion @@ -237,11 +253,28 @@ public override void CopyFromObject(GameObject go) { var triggerComponent = go.GetComponent(); if (triggerComponent) { - _dragPoints = triggerComponent._dragPoints.Select(dp => dp.Clone()).ToArray(); + DragPoints = triggerComponent.DragPoints.Select(dp => dp.Clone()).ToArray(); } RebuildMeshes(); } + private DragPointSplineComponent GetOrCreateDragPointSpline() + { + _dragPointSpline = DragPointSplineComponent.GetOrCreate(this, + _dragPointSpline, _dragPoints); + _dragPoints = null; + return _dragPointSpline; + } + + MonoBehaviour IDragPointSplineOwner.SplineOwner => this; + Transform IDragPointSplineOwner.SplineTransform => transform; + DragPointSplineComponent IDragPointSplineOwner.SplineComponent => DragPointSpline; + bool IDragPointSplineOwner.SplineClosed => true; + bool IDragPointSplineOwner.SplinePlanar => true; + void IDragPointSplineOwner.ApplySplineConstraints(Spline spline, int knotIndex, + SplineModification modification, IReadOnlyList previousPositions) { } + void IDragPointSplineOwner.RebuildSplineMeshes() => RebuildMeshes(); + #endregion #region State diff --git a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef index f0edebb73..768b51a73 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef @@ -7,6 +7,7 @@ "Unity.Burst", "Unity.Collections", "Unity.Mathematics", + "Unity.Splines", "Unity.InputSystem", "VisualPinball.Engine", "com.bartofzo.nativetrees", diff --git a/package.json b/package.json index a6e127e5d..d93a1c8f2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "com.unity.modules.unitywebrequest": "1.0.0", "com.unity.modules.unitywebrequestaudio": "1.0.0", "com.unity.nuget.newtonsoft-json": "3.2.1", + "com.unity.splines": "2.8.2", "com.unity.ugui": "2.5.0", "com.unity.test-framework": "1.7.0" },