From a8c6a2269418cb2a4be6e4c0fb86ea11ae80ac2e Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 15:07:15 +0200 Subject: [PATCH 1/9] rubber: add guide data and packaging --- .../VPT/RubberGuideTests.cs | 293 +++++++++++++++ .../VPT/RubberGuideTests.cs.meta | 2 + .../VisualPinball.Unity/VPT/Rubber/Guide.meta | 8 + .../VPT/Rubber/Guide/RubberGuideComponent.cs | 168 +++++++++ .../Rubber/Guide/RubberGuideComponent.cs.meta | 2 + .../VPT/Rubber/Guide/RubberGuideTypes.cs | 108 ++++++ .../VPT/Rubber/Guide/RubberGuideTypes.cs.meta | 2 + .../VPT/Rubber/Guide/RubberPath.cs | 47 +++ .../VPT/Rubber/Guide/RubberPath.cs.meta | 2 + .../VPT/Rubber/RubberColliderComponent.cs | 25 +- .../VPT/Rubber/RubberComponent.cs | 143 +++++++- .../VPT/Rubber/RubberPackable.cs | 337 ++++++++++++++---- .../VPT/Rubber/RubberPhysicsMaterialAsset.cs | 128 +++++++ .../Rubber/RubberPhysicsMaterialAsset.cs.meta | 2 + 14 files changed, 1178 insertions(+), 89 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideTypes.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPath.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicsMaterialAsset.cs.meta 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..f399db5b6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs @@ -0,0 +1,293 @@ +// 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) }); + rubber.ApplyGuidedBake(new[] { + new RubberPathElement { + Type = RubberPathElementType.SupportedArc, + Start = new float2(10f, 0f), + End = new float2(10f, 0f), + Center = float2.zero, + Radius = 10f, + SweepAngleRad = 2f * math.PI, + StartBindingIndex = 0, + EndBindingIndex = 0, + Length = 20f * math.PI, + }, + }, null, Matrix4x4.identity, new Hash128(9, 8, 7, 6), 1); + + 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/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/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/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/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 4c56467f3..9966e4677 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs @@ -55,8 +55,16 @@ public float Height { [SerializeField, HideInInspector] private DragPointData[] _dragPoints; - [SerializeField] - private DragPointSplineComponent _dragPointSpline; + [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; @@ -76,8 +84,33 @@ public DragPointData[] DragPoints { } } } - public DragPointSplineComponent DragPointSpline => GetOrCreateDragPointSpline(); - public int Thickness => math.max(1, _thickness); // don't allow zero thickness + 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 { + if (_pathSource != RubberPathSource.Guides || _bakedPath == null || _bakedPath.Length == 0 + || _guideBindings == null || _guideBindings.Length == 0) { + return false; + } + foreach (var binding in _guideBindings) { + if (!binding.Guide || !binding.Guide.TryGetSlot(binding.SlotId, out _)) { + return false; + } + } + return true; + } + } #endregion @@ -85,11 +118,13 @@ public DragPointData[] DragPoints { 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 @@ -125,9 +160,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); @@ -156,8 +192,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 @@ -229,12 +267,83 @@ 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; + } + + 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() { 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/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 From ab254ae4e9a2765a81317c31693ce4771d54da5e Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 15:18:18 +0200 Subject: [PATCH 2/9] rubber: add circular guide autofit --- .../VPT/Rubber/RubberGuideCommands.cs | 269 +++++++++ .../VPT/Rubber/RubberGuideCommands.cs.meta | 3 + .../Rubber/RubberGuideDependencyTracker.cs | 215 +++++++ .../RubberGuideDependencyTracker.cs.meta | 3 + .../VPT/Rubber/RubberGuideInspector.cs | 140 +++++ .../VPT/Rubber/RubberGuideInspector.cs.meta | 3 + .../VPT/Rubber/RubberInspector.cs | 198 ++++++- .../VPT/RubberAutofitTests.cs | 342 +++++++++++ .../VPT/RubberAutofitTests.cs.meta | 3 + .../VPT/RubberGuideTests.cs | 15 +- .../VPT/Rubber/Guide/RubberAutofit.cs | 121 ++++ .../VPT/Rubber/Guide/RubberAutofit.cs.meta | 3 + .../Rubber/Guide/RubberCircleHullSolver.cs | 561 ++++++++++++++++++ .../Guide/RubberCircleHullSolver.cs.meta | 3 + .../VPT/Rubber/Guide/RubberGuideResolver.cs | 309 ++++++++++ .../Rubber/Guide/RubberGuideResolver.cs.meta | 3 + .../VPT/Rubber/Guide/RubberPathSplineBaker.cs | 205 +++++++ .../Guide/RubberPathSplineBaker.cs.meta | 3 + .../VPT/Rubber/RubberComponent.cs | 15 +- 19 files changed, 2372 insertions(+), 42 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberCircleHullSolver.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberGuideResolver.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberPathSplineBaker.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs new file mode 100644 index 000000000..7909620ef --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs @@ -0,0 +1,269 @@ +// 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 UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + public static class RubberGuideCommands + { + private const string MenuRoot = "GameObject/Pinball/Rubber/"; + + [MenuItem(MenuRoot + "Create Rubber Around Selected Guides", false, 40)] + private static void CreateRubberAroundSelectedGuides() + { + var guides = SelectedGuides(); + if (!RubberGuideSlotPickerWindow.TryPick(guides, out var bindings)) { + return; + } + var firstSlot = bindings[0].Guide.Slots.First(slot => 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.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName); + rubber.SetGuideBindings(bindings); + if (!RubberAutofit.TryBake(rubber, out _, out var error)) { + EditorUtility.DisplayDialog("Rubber Autofit Failed", + $"The bindings were preserved and the previous sampled path was retained.\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..a460d4e5b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs @@ -0,0 +1,215 @@ +// 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); + AddDependency(binding.Guide.gameObject.GetEntityId(), rubber); + AddDependency(binding.Guide.transform.GetEntityId(), rubber); + } + AddDependency(rubber.transform.GetEntityId(), rubber); + } + _indexDirty = false; + } + + 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 b7d75946a..5b0303d9b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs @@ -16,22 +16,27 @@ // ReSharper disable AssignmentInConditionalExpression -using UnityEditor; -using UnityEngine; +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 - { - private SerializedProperty _thicknessProperty; + { + private SerializedProperty _thicknessProperty; + private SerializedProperty _restLengthProperty; protected override void OnEnable() { base.OnEnable(); - _thicknessProperty = serializedObject.FindProperty(nameof(RubberComponent._thickness)); + _thicknessProperty = serializedObject.FindProperty(nameof(RubberComponent._thickness)); + _restLengthProperty = serializedObject.FindProperty("_restLength"); } public override void OnInspectorGUI() @@ -42,23 +47,182 @@ 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); - - DragPointSplineInspectorGUI.OnInspectorGUI(MainComponent.DragPointSpline); + } + 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 DrawPathSection() + { + EditorGUILayout.LabelField("Path", EditorStyles.boldLabel); + var source = (RubberPathSource)EditorGUILayout.EnumPopup("Source", MainComponent.PathSource); + if (source != MainComponent.PathSource) { + Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Change Rubber Path Source"); + if (source == RubberPathSource.Guides) { + MainComponent.SetGuideBindings(MainComponent.GuideBindings); + } else { + MainComponent.DetachFromGuides(); + } + EditorUtility.SetDirty(MainComponent); + } + + 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 bindings = MainComponent.GuideBindings.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) { + bindings[i].Guide = guide; + bindings[i].SlotId = default; + ApplyBindings(bindings, "Change Rubber Guide Binding"); + } + 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; + ApplyBindings(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; + ApplyBindings(bindings, "Change Rubber Guide Slot"); + } + } + } else if (guide) { + EditorGUILayout.HelpBox("This guide has no slots.", MessageType.Error); + } + if (GUILayout.Button("Remove Binding")) { + ApplyBindings(bindings.Where((_, index) => index != i).ToArray(), + "Remove Rubber Guide Binding"); + EditorGUILayout.EndVertical(); + break; + } + 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")) { + Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Detach Rubber From Guides"); + MainComponent.DetachFromGuides(); + MainComponent.RebuildMeshes(); + EditorUtility.SetDirty(MainComponent); + } + EditorGUILayout.EndHorizontal(); + } + + private void ApplyBindings(RubberGuideBinding[] bindings, string undoName) + { + Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, undoName); + MainComponent.SetGuideBindings(bindings); + EditorUtility.SetDirty(MainComponent); + } + + 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.Test/VPT/RubberAutofitTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs new file mode 100644 index 000000000..f37b7e51b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs @@ -0,0 +1,342 @@ +// 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 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 index f399db5b6..3f61eb495 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberGuideTests.cs @@ -214,19 +214,8 @@ public IEnumerator ShouldRoundTripGuideAndMaterialReferencesThroughPackage() var rubber = rubberObject.AddComponent(); rubber.DragPoints = SquareDragPoints(); rubber.SetGuideBindings(new[] { new RubberGuideBinding(guide, slot.Id) }); - rubber.ApplyGuidedBake(new[] { - new RubberPathElement { - Type = RubberPathElementType.SupportedArc, - Start = new float2(10f, 0f), - End = new float2(10f, 0f), - Center = float2.zero, - Radius = 10f, - SweepAngleRad = 2f * math.PI, - StartBindingIndex = 0, - EndBindingIndex = 0, - Length = 20f * math.PI, - }, - }, null, Matrix4x4.identity, new Hash128(9, 8, 7, 6), 1); + Assert.That(RubberAutofit.TryBake(rubber, out _, out var bakeError), + Is.True, bakeError); var collider = rubberObject.AddComponent(); collider.Mode = RubberColliderMode.Physical; 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..d37b421b8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs @@ -0,0 +1,121 @@ +// 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; + } + + 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(RubberPathSource.Spline, 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/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/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/RubberComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs index 9966e4677..599ee5193 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberComponent.cs @@ -98,18 +98,7 @@ public float RestLength { } public bool HasValidGuidedPath { - get { - if (_pathSource != RubberPathSource.Guides || _bakedPath == null || _bakedPath.Length == 0 - || _guideBindings == null || _guideBindings.Length == 0) { - return false; - } - foreach (var binding in _guideBindings) { - if (!binding.Guide || !binding.Guide.TryGetSlot(binding.SlotId, out _)) { - return false; - } - } - return true; - } + get => RubberAutofit.GetStatus(this).IsValid; } #endregion @@ -285,6 +274,8 @@ public void SetGuideBindings(IEnumerable bindings) { _guideBindings = bindings?.ToArray() ?? Array.Empty(); _pathSource = RubberPathSource.Guides; + _bakeVersion = 0; + _bakeInputHash = default; } public void ApplyGuidedBake(RubberPathElement[] path, DragPointData[] sampledDragPoints, From 24f6725c0068ed048ebcd46fdf51eadb33cf140c Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 15:20:16 +0200 Subject: [PATCH 3/9] tests(rubber): lock legacy compatibility --- .../Physics/LegacySlingshotTests.cs | 27 +++++++++++++++++++ .../Physics/LegacySlingshotTests.cs.meta | 2 ++ .../VPT/RubberTests.cs | 23 +++++++++++++++- .../Physics/Collider/LineSlingshotCollider.cs | 20 +++++++++----- 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/LegacySlingshotTests.cs.meta 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/VPT/RubberTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs index 1a2d9928e..95f38abec 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberTests.cs @@ -34,7 +34,7 @@ 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); @@ -91,6 +91,27 @@ public IEnumerator ShouldKeepGeneratedSplinesOutOfPackageRoundTrips() } } + [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); 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 From ea2b6340d62de7787162a65c8f218b25af06b84f Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 15:35:08 +0200 Subject: [PATCH 4/9] physics(rubber): add round-cord colliders --- .../VPT/Rubber/RubberColliderInspector.cs | 58 +++- .../Physics/SweptCircleColliderTests.cs | 267 ++++++++++++++++++ .../Physics/SweptCircleColliderTests.cs.meta | 3 + .../VisualPinball.Unity/Game/PhysicsState.cs | 9 + .../Game/PhysicsStaticCollision.cs | 5 + .../Physics/Collider/Collider.cs | 2 + .../Physics/Collider/ColliderReference.cs | 32 +++ .../Physics/Collider/SweptCircleCollider.cs | 246 ++++++++++++++++ .../Collider/SweptCircleCollider.cs.meta | 3 + .../Physics/Collision/ColliderType.cs | 1 + .../Physics/NativeColliders.cs | 33 ++- .../VPT/ColliderComponent.cs | 39 ++- .../VPT/Rubber/RubberApi.cs | 12 + .../Rubber/RubberPhysicalColliderGenerator.cs | 85 ++++++ .../RubberPhysicalColliderGenerator.cs.meta | 3 + 15 files changed, 791 insertions(+), 7 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs.meta 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(() => 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 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 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 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/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 43776bb74..c267c1e37 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -377,6 +377,11 @@ internal void TransformKinematicColliders(int colliderId, float4x4 matrix) line3DCollider.Transform(KinematicCollidersAtIdentity.Line3D(colliderId), matrix); break; + case ColliderType.SweptCircle: + ref var sweptCircleCollider = ref KinematicColliders.SweptCircle(colliderId); + sweptCircleCollider.Transform(KinematicCollidersAtIdentity.SweptCircle(colliderId), matrix); + break; + case ColliderType.Triangle: ref var triangleCollider = ref KinematicColliders.Triangle(colliderId); triangleCollider.Transform(KinematicCollidersAtIdentity.Triangle(colliderId), matrix); @@ -434,6 +439,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/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..ad2d807a2 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("Swept-circle colliders must be transformed before kinematic registration."); + } + #endif + sweptCircleCollider.Transform(SweptCircleColliders[lookup.Index], 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,21 @@ internal void Add(Line3DCollider collider, float4x4 matrix) Line3DColliders.Add(collider); } + internal void Add(SweptCircleCollider collider, float4x4 matrix) + { +#if UNITY_EDITOR + if (!SweptCircleCollider.IsTransformable(matrix)) { + throw new InvalidOperationException("A swept-circle collider requires a uniformly scaled transform."); + } +#endif + 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 +562,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/SweptCircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs new file mode 100644 index 000000000..c53f44f2e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs @@ -0,0 +1,246 @@ +// 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 (axisLengthSquared <= 1e-10f || radius <= 0f) { + return -1f; + } + + var closest = 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 (hitDistance <= 0f) { + hitTime = 0f; + } else if (math.abs(normalVelocity) <= PhysicsConstants.ContactVel) { + isContact = true; + hitTime = 0f; + } else { + hitTime = -hitDistance / normalVelocity; + } + } else { + hitTime = FirstRayCapsuleHit(ball.Position, ball.Velocity, V1, V2, + radius, dTime); + if (hitTime < 0f) { + return -1f; + } + var hitPosition = ball.Position + ball.Velocity * hitTime; + closest = 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/Rubber/RubberApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs index bd12954ce..1f59bc14d 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.LogError($"Physical rubber '{MainComponent.name}' has no current valid guided bake; no colliders were registered.", + MainComponent); + return; + } + 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/RubberPhysicalColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs new file mode 100644 index 000000000..d55a5b2ca --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs @@ -0,0 +1,85 @@ +// 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; + 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 cordRadius = _rubber.Thickness * 0.5f + 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, ArcChordToleranceVpx); + 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))); + } + + 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 From ba82c4ca2b59e263655315c99733ba2da8a38949 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 15:45:45 +0200 Subject: [PATCH 5/9] rubber: add physical slingshot authoring --- .../VPT/Rubber/RubberSlingshotInspector.cs | 135 +++++++++ .../Rubber/RubberSlingshotInspector.cs.meta | 2 + .../VPT/Rubber/RubberSlingshotWizard.cs | 139 +++++++++ .../VPT/Rubber/RubberSlingshotWizard.cs.meta | 2 + .../VPT/Rubber/SlingshotActuatorInspector.cs | 239 ++++++++++++++++ .../Rubber/SlingshotActuatorInspector.cs.meta | 2 + .../VPT/RubberSlingshotTests.cs | 269 ++++++++++++++++++ .../VPT/RubberSlingshotTests.cs.meta | 2 + .../VPT/Rubber/Slingshot.meta | 8 + .../Slingshot/RubberSlingshotComponent.cs | 183 ++++++++++++ .../RubberSlingshotComponent.cs.meta | 2 + .../Slingshot/RubberSlingshotPackable.cs | 134 +++++++++ .../Slingshot/RubberSlingshotPackable.cs.meta | 2 + .../VPT/Rubber/Slingshot/RubberSpanBinding.cs | 136 +++++++++ .../Slingshot/RubberSpanBinding.cs.meta | 2 + .../Slingshot/SlingshotActuatorAsset.cs | 255 +++++++++++++++++ .../Slingshot/SlingshotActuatorAsset.cs.meta | 2 + 17 files changed, 1514 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/SlingshotActuatorInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotPackable.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSpanBinding.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs.meta 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..c7c0c026f --- /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..943c0796e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs @@ -0,0 +1,139 @@ +// 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(span); + } + } + _spanLabels = _spans.Select(SpanLabel).ToArray(); + } + + private string SpanLabel(RubberPathElement span) + { + var start = _rubber.GuideBindings[span.StartBindingIndex]; + var end = _rubber.GuideBindings[span.EndBindingIndex]; + return $"{BindingLabel(start)} -> {BindingLabel(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 span = _spans[_spanIndex]; + 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 = new RubberSpanBinding( + _rubber.GuideBindings[span.StartBindingIndex], + _rubber.GuideBindings[span.EndBindingIndex]); + component.Actuator = _actuator; + EditorUtility.SetDirty(component); + Selection.activeGameObject = gameObject; + Close(); + } + } +} 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.Test/VPT/RubberSlingshotTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs new file mode 100644 index 000000000..819ab6a7d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs @@ -0,0 +1,269 @@ +// 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 NUnit.Framework; +using UnityEngine; +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 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 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/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..a42899525 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs @@ -0,0 +1,183 @@ +// 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; + } + + 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..0c349acc9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs @@ -0,0 +1,255 @@ +// 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) + { + currentAmps = 0f; + if (ValidateData().Count != 0 || !float.IsFinite(fluxLinkage)) { + return false; + } + stroke01 = math.saturate(stroke01); + var minimum = SampleFluxAtCurrentIndex(0, stroke01); + var maximum = SampleFluxAtCurrentIndex(CurrentSampleCount - 1, stroke01); + var target = math.clamp(fluxLinkage, minimum, maximum); + var low = 0; + var high = CurrentSampleCount - 1; + while (high - low > 1) { + var middle = low + (high - low) / 2; + if (SampleFluxAtCurrentIndex(middle, stroke01) <= target) { + low = middle; + } else { + high = middle; + } + } + var lowerFlux = SampleFluxAtCurrentIndex(low, stroke01); + var upperFlux = SampleFluxAtCurrentIndex(high, stroke01); + var fraction = upperFlux > lowerFlux + ? (target - lowerFlux) / (upperFlux - lowerFlux) + : 0f; + currentAmps = (low + 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 From 12a9cee5958b91a7c30ac574fb8361bbab8ba876 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 22:08:11 +0200 Subject: [PATCH 6/9] editor(rubber): harden guided rebakes --- .../VPT/Rubber/RubberGuideCommands.cs | 20 ++++++++++++++++--- .../Rubber/RubberGuideDependencyTracker.cs | 14 ++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs index 7909620ef..b3f13dc6f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs @@ -171,10 +171,17 @@ private static void BindSelectedGuidesToRubber(string undoName) return; } Undo.RegisterFullObjectHierarchyUndo(rubber.gameObject, undoName); - rubber.SetGuideBindings(bindings); - if (!RubberAutofit.TryBake(rubber, out _, out var error)) { + var convertingSpline = rubber.PathSource == RubberPathSource.Spline; + string error; + var succeeded = convertingSpline + ? RubberAutofit.TryConvertToGuides(rubber, bindings, out _, out error) + : TryReplaceGuideBindings(rubber, bindings, out error); + if (!succeeded) { + var retainedState = convertingSpline + ? "The manual spline remains authoritative." + : "The bindings were preserved and the previous sampled path was retained."; EditorUtility.DisplayDialog("Rubber Autofit Failed", - $"The bindings were preserved and the previous sampled path was retained.\n\n{error}", "OK"); + $"{retainedState}\n\n{error}", "OK"); } else { PrefabUtility.RecordPrefabInstancePropertyModifications(rubber); } @@ -182,6 +189,13 @@ private static void BindSelectedGuidesToRubber(string undoName) RubberGuideDependencyTracker.RebuildSoon(); } + private static bool TryReplaceGuideBindings(RubberComponent rubber, + RubberGuideBinding[] bindings, out string error) + { + rubber.SetGuideBindings(bindings); + return RubberAutofit.TryBake(rubber, out _, out error); + } + private static RubberComponent SelectedRubber() { if (Selection.activeGameObject) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs index a460d4e5b..94cadccbd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideDependencyTracker.cs @@ -195,14 +195,22 @@ private static void RebuildIndex() continue; } AddDependency(binding.Guide.GetEntityId(), rubber); - AddDependency(binding.Guide.gameObject.GetEntityId(), rubber); - AddDependency(binding.Guide.transform.GetEntityId(), rubber); + AddTransformHierarchyDependencies(binding.Guide.transform, rubber); } - AddDependency(rubber.transform.GetEntityId(), 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)) { From 3db1ac8304dca95ddc5db8b9f5c55df8f5a7906a Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 13 Jul 2026 22:42:44 +0200 Subject: [PATCH 7/9] physics(rubber): stabilize round-cord contacts --- .../Physics/SweptCircleColliderTests.cs | 92 ++++++++++++++++++- .../VisualPinball.Unity/Game/PhysicsState.cs | 5 - .../Physics/Collider/ColliderReference.cs | 27 ++++-- .../Physics/Collider/SweptCircleCollider.cs | 25 +++-- .../Rubber/RubberPhysicalColliderGenerator.cs | 10 +- 5 files changed, 131 insertions(+), 28 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs index 85403831d..2ddefd752 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs @@ -100,14 +100,38 @@ public void ShouldReportRestingContactWithoutJitterImpulse() } [Test] - public void ShouldIgnoreDegenerateCordSegment() + 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, 3f, 0f), Radius = 1f }; + 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(-1f)); + 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] @@ -159,6 +183,57 @@ public void ShouldSurviveNativeColliderAllocation() } } + [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() { @@ -170,6 +245,17 @@ public void ArcChordSubdivisionShouldRespectSagittaTolerance() 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 PhysicalRubberGeneratorShouldCreateContinuousRoundCordSegments() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index c267c1e37..5fb88d2c6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -377,11 +377,6 @@ internal void TransformKinematicColliders(int colliderId, float4x4 matrix) line3DCollider.Transform(KinematicCollidersAtIdentity.Line3D(colliderId), matrix); break; - case ColliderType.SweptCircle: - ref var sweptCircleCollider = ref KinematicColliders.SweptCircle(colliderId); - sweptCircleCollider.Transform(KinematicCollidersAtIdentity.SweptCircle(colliderId), matrix); - break; - case ColliderType.Triangle: ref var triangleCollider = ref KinematicColliders.Triangle(colliderId); triangleCollider.Transform(KinematicCollidersAtIdentity.Triangle(colliderId), matrix); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs index ad2d807a2..43be23940 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderReference.cs @@ -155,11 +155,11 @@ public void TransformToIdentity(ref NativeParallelHashMap itemIdT case ColliderType.SweptCircle: ref var sweptCircleCollider = ref SweptCircleColliders.GetElementAsRef(lookup.Index); #if UNITY_EDITOR - if (!sweptCircleCollider.Header.IsTransformed) { - throw new InvalidOperationException("Swept-circle colliders must be transformed before kinematic registration."); + if (sweptCircleCollider.Header.IsTransformed) { + throw new InvalidOperationException("A transformed swept-circle collider shouldn't have been added as a kinematic collider."); } #endif - sweptCircleCollider.Transform(SweptCircleColliders[lookup.Index], math.inverse(matrix)); + sweptCircleCollider.TransformAabb(math.inverse(matrix)); break; case ColliderType.Triangle: @@ -364,13 +364,24 @@ internal void Add(Line3DCollider collider, float4x4 matrix) internal void Add(SweptCircleCollider collider, float4x4 matrix) { -#if UNITY_EDITOR - if (!SweptCircleCollider.IsTransformable(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."); } -#endif - collider.Header.IsTransformed = true; - collider.Transform(matrix); + 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)); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs index c53f44f2e..89869a556 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/SweptCircleCollider.cs @@ -50,11 +50,14 @@ public float HitTest(ref CollisionEventData collEvent, in BallState ball, float var radius = ball.Radius + CordRadius; var axis = V2 - V1; var axisLengthSquared = math.lengthsq(axis); - if (axisLengthSquared <= 1e-10f || radius <= 0f) { + if (radius <= 0f) { return -1f; } - var closest = ClosestPoint(ball.Position, V1, axis, axisLengthSquared); + 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); @@ -69,22 +72,24 @@ public float HitTest(ref CollisionEventData collEvent, in BallState ball, float float hitTime; var isContact = false; if (hitDistance < PhysicsConstants.PhysTouch) { - if (hitDistance <= 0f) { - hitTime = 0f; - } else if (math.abs(normalVelocity) <= PhysicsConstants.ContactVel) { + if (math.abs(normalVelocity) <= PhysicsConstants.ContactVel) { isContact = true; hitTime = 0f; } else { - hitTime = -hitDistance / normalVelocity; + hitTime = math.max(0f, -hitDistance / normalVelocity); } } else { - hitTime = FirstRayCapsuleHit(ball.Position, ball.Velocity, V1, V2, - radius, dTime); - if (hitTime < 0f) { + 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 = ClosestPoint(hitPosition, V1, axis, axisLengthSquared); + 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs index d55a5b2ca..94b03d547 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberPhysicalColliderGenerator.cs @@ -15,6 +15,7 @@ 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; @@ -33,14 +34,16 @@ internal void GenerateColliders(float zOffset, ref ColliderReference colliders, float margin) { using var _ = PerfMarker.Auto(); - var cordRadius = _rubber.Thickness * 0.5f + margin; + 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, ArcChordToleranceVpx); + var maximumAngle = MaximumChordAngle(element.Radius, + ChordTolerance(rubberRadius)); var segmentCount = math.max(3, (int)math.ceil(element.SweepAngleRad / maximumAngle)); var start = element.Start; @@ -63,6 +66,9 @@ internal static float MaximumChordAngle(float radius, float tolerance) 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) { From 04d984396dbf0ca0aa3cd6fb81d5c541cae7286a Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 02:57:53 +0200 Subject: [PATCH 8/9] rubber: harden physical slingshot authoring --- .../Packaging/PackageReader.cs | 4 + .../VPT/Rubber/RubberSlingshotInspector.cs | 4 +- .../VPT/Rubber/RubberSlingshotWizard.cs | 41 ++++-- .../VPT/RubberSlingshotTests.cs | 120 ++++++++++++++++++ .../Event/EventTranslateComponent.cs | 6 + .../Packaging/RuntimePackageReader.cs | 4 + .../Slingshot/RubberSlingshotComponent.cs | 12 ++ .../Slingshot/SlingshotActuatorAsset.cs | 32 +++-- 8 files changed, 201 insertions(+), 22 deletions(-) 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/VPT/Rubber/RubberSlingshotInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs index c7c0c026f..fcb13fb62 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotInspector.cs @@ -113,9 +113,9 @@ private void OnSceneGUI() var armPosition = math.lerp(start, end, component.ArmContactPosition01); var arm = ToWorld(rubber, armPosition); var armRest = ToWorld(rubber, - armPosition - inward * component.ArmRestClearance); + armPosition + inward * component.ArmRestClearance); var armFired = ToWorld(rubber, - armPosition + inward * (component.ArmTipTravel - component.ArmRestClearance)); + 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs index 943c0796e..0f284d61f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberSlingshotWizard.cs @@ -18,7 +18,7 @@ internal sealed class RubberSlingshotWizard : EditorWindow private RubberComponent _rubber; private SlingshotActuatorAsset _actuator; private int _spanIndex; - private readonly List _spans = new(); + private readonly List _spans = new(); private string[] _spanLabels = System.Array.Empty(); [MenuItem("GameObject/Pinball/Create Physical Slingshot On Rubber", false, 21)] @@ -98,17 +98,19 @@ private void RefreshSpans() || (other.element.StartBindingIndex == span.EndBindingIndex && other.element.EndBindingIndex == span.StartBindingIndex)); if (duplicateCount == 1 && !boundSpanIndices.Contains(pathIndex)) { - _spans.Add(span); + _spans.Add(new RubberSpanBinding( + _rubber.GuideBindings[span.StartBindingIndex], + _rubber.GuideBindings[span.EndBindingIndex])); } } _spanLabels = _spans.Select(SpanLabel).ToArray(); } - private string SpanLabel(RubberPathElement span) + private static string SpanLabel(RubberSpanBinding span) { - var start = _rubber.GuideBindings[span.StartBindingIndex]; - var end = _rubber.GuideBindings[span.EndBindingIndex]; - return $"{BindingLabel(start)} -> {BindingLabel(end)}"; + var start = BindingLabel(span.StartSupport); + var end = BindingLabel(span.EndSupport); + return $"{start} -> {end}"; } private static string BindingLabel(RubberGuideBinding binding) @@ -121,19 +123,38 @@ private static string BindingLabel(RubberGuideBinding binding) private void CreateSlingshot() { - var span = _spans[_spanIndex]; + 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 = new RubberSpanBinding( - _rubber.GuideBindings[span.StartBindingIndex], - _rubber.GuideBindings[span.EndBindingIndex]); + 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.Test/VPT/RubberSlingshotTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs index 819ab6a7d..aa6f52a03 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberSlingshotTests.cs @@ -9,8 +9,11 @@ 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 @@ -130,6 +133,41 @@ public void ShouldValidateAndInvertActuatorFluxLut() } } + [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() { @@ -200,6 +238,88 @@ public void ShouldImportRectangularActuatorCsvInCurrentMajorOrder() 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() { 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/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/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs index a42899525..38e60ca14 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/RubberSlingshotComponent.cs @@ -166,6 +166,18 @@ internal void RestoreReferences(RubberComponent rubber, _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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs index 0c349acc9..6a1fff24d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Slingshot/SlingshotActuatorAsset.cs @@ -78,31 +78,43 @@ 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; - if (ValidateData().Count != 0 || !float.IsFinite(fluxLinkage)) { + 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 (high - low > 1) { + while (low < high) { var middle = low + (high - low) / 2; - if (SampleFluxAtCurrentIndex(middle, stroke01) <= target) { - low = middle; + if (SampleFluxAtCurrentIndex(middle, stroke01) < target) { + low = middle + 1; } else { high = middle; } } - var lowerFlux = SampleFluxAtCurrentIndex(low, stroke01); - var upperFlux = SampleFluxAtCurrentIndex(high, stroke01); - var fraction = upperFlux > lowerFlux - ? (target - lowerFlux) / (upperFlux - lowerFlux) - : 0f; - currentAmps = (low + fraction) / (CurrentSampleCount - 1f) * MaximumCurrentAmps; + 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; } From 31ba62d12a699354e264524a356cdc2cf1ee7b1c Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 14:15:35 +0200 Subject: [PATCH 9/9] rubber: harden invalid guided state --- .../VPT/Rubber/RubberGuideCommands.cs | 11 +- .../VPT/Rubber/RubberInspector.cs | 145 +++++++++++++++--- .../Physics/SweptCircleColliderTests.cs | 44 ++++++ .../VPT/RubberAutofitTests.cs | 93 +++++++++++ .../VPT/Rubber/Guide/RubberAutofit.cs | 25 ++- .../VPT/Rubber/RubberApi.cs | 10 +- 6 files changed, 291 insertions(+), 37 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs index b3f13dc6f..07698e36b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberGuideCommands.cs @@ -170,13 +170,15 @@ private static void BindSelectedGuidesToRubber(string undoName) 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) - : TryReplaceGuideBindings(rubber, bindings, 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."; @@ -189,13 +191,6 @@ private static void BindSelectedGuidesToRubber(string undoName) RubberGuideDependencyTracker.RebuildSoon(); } - private static bool TryReplaceGuideBindings(RubberComponent rubber, - RubberGuideBinding[] bindings, out string error) - { - rubber.SetGuideBindings(bindings); - return RubberAutofit.TryBake(rubber, out _, out error); - } - private static RubberComponent SelectedRubber() { if (Selection.activeGameObject) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs index 5b0303d9b..7c769d48b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Rubber/RubberInspector.cs @@ -30,6 +30,7 @@ public class RubberInspector : MainInspector { private SerializedProperty _thicknessProperty; private SerializedProperty _restLengthProperty; + private bool _bindingChangeScheduled; protected override void OnEnable() { @@ -86,13 +87,14 @@ private void DrawPathSection() EditorGUILayout.LabelField("Path", EditorStyles.boldLabel); var source = (RubberPathSource)EditorGUILayout.EnumPopup("Source", MainComponent.PathSource); if (source != MainComponent.PathSource) { - Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Change Rubber Path Source"); if (source == RubberPathSource.Guides) { - MainComponent.SetGuideBindings(MainComponent.GuideBindings); + EditorUtility.DisplayDialog("Guide Bindings Required", + "Select the rubber and its guides, then use GameObject > Pinball > Rubber > Bind Selected Guides.", + "OK"); } else { - MainComponent.DetachFromGuides(); + ScheduleBindingsChange(MainComponent.GuideBindings.ToArray(), + Array.Empty(), "Detach Rubber From Guides"); } - EditorUtility.SetDirty(MainComponent); } if (MainComponent.PathSource != RubberPathSource.Guides) { @@ -101,15 +103,22 @@ private void DrawPathSection() return; } - var bindings = MainComponent.GuideBindings.ToArray(); + 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) { - bindings[i].Guide = guide; - bindings[i].SlotId = default; - ApplyBindings(bindings, "Change Rubber Guide Binding"); + 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); @@ -122,24 +131,24 @@ private void DrawPathSection() var nextSlot = EditorGUILayout.Popup("Slot", 0, labels); if (nextSlot > 0) { bindings[i].SlotId = guide.Slots[nextSlot - 1].Id; - ApplyBindings(bindings, "Repair Rubber Guide Slot"); + 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; - ApplyBindings(bindings, "Change Rubber Guide Slot"); + ScheduleBindingsChange(expectedBindings, bindings, "Change Rubber Guide Slot"); } } } else if (guide) { EditorGUILayout.HelpBox("This guide has no slots.", MessageType.Error); } - if (GUILayout.Button("Remove Binding")) { - ApplyBindings(bindings.Where((_, index) => index != i).ToArray(), - "Remove Rubber Guide Binding"); - EditorGUILayout.EndVertical(); - break; + 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(); } @@ -159,19 +168,109 @@ private void DrawPathSection() } } if (GUILayout.Button("Detach From Guides")) { - Undo.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, "Detach Rubber From Guides"); - MainComponent.DetachFromGuides(); - MainComponent.RebuildMeshes(); - EditorUtility.SetDirty(MainComponent); + ScheduleBindingsChange(expectedBindings, Array.Empty(), + "Detach Rubber From Guides"); } EditorGUILayout.EndHorizontal(); } - private void ApplyBindings(RubberGuideBinding[] bindings, string undoName) + 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.RegisterFullObjectHierarchyUndo(MainComponent.gameObject, undoName); - MainComponent.SetGuideBindings(bindings); - EditorUtility.SetDirty(MainComponent); + 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() diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs index 2ddefd752..407dda4bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SweptCircleColliderTests.cs @@ -11,6 +11,8 @@ using Unity.Collections; using Unity.Mathematics; using UnityEngine; +using UnityEngine.TestTools; +using VisualPinball.Engine.Math; namespace VisualPinball.Unity.Test { @@ -256,6 +258,38 @@ public void ThinCordShouldUseRadiusRelativeChordTolerance() .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() { @@ -344,6 +378,16 @@ public void PhysicalRubberGeneratorShouldApplyOffsetAlongBakeNormal() } } + 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), diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs index f37b7e51b..67676871a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/RubberAutofitTests.cs @@ -215,6 +215,99 @@ public void FailedRebindShouldRetainLastBakedGeometry() } } + [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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs index d37b421b8..9246042f8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/Guide/RubberAutofit.cs @@ -74,7 +74,30 @@ public static bool TryConvertToGuides(RubberComponent rubber, 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; @@ -86,7 +109,7 @@ public static bool TryConvertToGuides(RubberComponent rubber, return true; } - rubber.RestorePackedState(RubberPathSource.Spline, previousBindings, + rubber.RestorePackedState(previousSource, previousBindings, previousPath, previousVersion, previousHash, previousFrame, previousRestLength); return false; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs index 1f59bc14d..05dd4d3f7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Rubber/RubberApi.cs @@ -48,14 +48,14 @@ protected override void CreateColliders(ref ColliderReference colliders, float4x { if (ColliderComponent.Mode == RubberColliderMode.Physical) { if (!ColliderComponent.CanUsePhysical) { - Debug.LogError($"Physical rubber '{MainComponent.name}' has no current valid guided bake; no colliders were registered.", + 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 physicalGenerator = new RubberPhysicalColliderGenerator(this, MainComponent, - translateWithinPlayfieldMatrix); - physicalGenerator.GenerateColliders(ColliderComponent.ZOffset, ref colliders, margin); - return; } var colliderGenerator = new RubberColliderGenerator(