From cd5544eaec9c8651a2166fe3a8cc9ea162d4b405 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 08:46:25 +0200 Subject: [PATCH 01/11] physics: Fix Line3D hit normal transform to world space. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hit normal was transformed back to world coordinates using the matrix directly instead of its transpose. Since the forward transform into local space uses the transpose, the inverse rotation must use the non-transposed matrix — use math.transpose() to correct this. Add a test verifying the returned hit normal points into world space. --- .../Physics/Line3DColliderTests.cs | 47 +++++++++++++++++++ .../Physics/Line3DColliderTests.cs.meta | 2 + .../Physics/Collider/Line3DCollider.cs | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs new file mode 100644 index 000000000..6bd329ab8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.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. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class Line3DColliderTests + { + [Test] + public void HitNormalIsTransformedBackToWorldSpace() + { + var collider = new Line3DCollider( + new float3(0f, 0f, 0f), + new float3(10f, 0f, 0f), + new ColliderInfo { ItemId = 1 } + ); + var ball = new BallState { + Position = new float3(5f, 2f, 2f), + Velocity = new float3(0f, -1f, -1f), + Radius = 1f + }; + var collEvent = new CollisionEventData(); + + var hitTime = collider.HitTest(ref collEvent, in ball, 2f); + + Assert.That(hitTime, Is.GreaterThanOrEqualTo(0f)); + Assert.That(collEvent.HitNormal.x, Is.EqualTo(0f).Within(1e-5f)); + Assert.That(collEvent.HitNormal.y, Is.EqualTo(math.sqrt(0.5f)).Within(1e-5f)); + Assert.That(collEvent.HitNormal.z, Is.EqualTo(math.sqrt(0.5f)).Within(1e-5f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta new file mode 100644 index 000000000..690490f87 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/Line3DColliderTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c64a52806928274b8b974b311efc219 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs index 6654a4d83..8d8aabb51 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs @@ -126,7 +126,7 @@ private static float HitTest(ref CollisionEventData collEvent, ref Line3DCollide // transform hit normal back to world coordinate system if (hitTime >= 0) { - collEvent.HitNormal = math.mul(coll._matrix, collEvent.HitNormal); + collEvent.HitNormal = math.mul(math.transpose(coll._matrix), collEvent.HitNormal); } return hitTime; From 8464202c2404011f19741798ad0583503a64d737 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 09:47:53 +0200 Subject: [PATCH 02/11] test: Add physics transform and collision regression tests. Add PhysicsRegressionTests covering known transform and collision bugs: point/lineZ/circle collider transforms, matrix scale extraction, ball radius scaling, collision event round-trips, Line3D hit event firing, and boxed Equals delegation. Includes an IL-walking helper to assert Equals(object) forwards to the typed overload. --- .../Physics/PhysicsRegressionTests.cs | 290 ++++++++++++++++++ .../Physics/PhysicsRegressionTests.cs.meta | 2 + 2 files changed, 292 insertions(+) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs new file mode 100644 index 000000000..ca5a5b8f1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs @@ -0,0 +1,290 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Reflection; +using System.Reflection.Emit; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.VPT; + +namespace VisualPinball.Unity.Test +{ + /// + /// Regression coverage for known transform and collision bugs. These tests intentionally + /// remain red until the corresponding production fixes are implemented. + /// + public class PhysicsRegressionTests + { + private const float Tolerance = 1e-5f; + + private static readonly OpCode[] OneByteOpCodes = new OpCode[0x100]; + private static readonly OpCode[] TwoByteOpCodes = new OpCode[0x100]; + + static PhysicsRegressionTests() + { + foreach (var field in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static)) { + var opCode = (OpCode)field.GetValue(null); + var value = (ushort)opCode.Value; + if (value < 0x100) { + OneByteOpCodes[value] = opCode; + } else if ((value & 0xff00) == 0xfe00) { + TwoByteOpCodes[value & 0xff] = opCode; + } + } + } + + [Test] + public void PointColliderTransformKeepsBoundsAtTransformedPoint() + { + var collider = new PointCollider(new float3(1f, 2f, 3f), new ColliderInfo { ItemId = 1 }); + var matrix = float4x4.Translate(new float3(10f, 20f, 30f)); + + collider = collider.Transform(matrix); + + AssertFloat3(collider.P, new float3(11f, 22f, 33f)); + AssertFloat3(collider.Bounds.Aabb.Min, collider.P); + AssertFloat3(collider.Bounds.Aabb.Max, collider.P); + } + + [Test] + public void LineZColliderTransformAppliesFullMatrixToBothEndpoints() + { + var source = new LineZCollider(new float2(1f, 2f), 10f, 20f, new ColliderInfo { ItemId = 1 }); + var matrix = float4x4.TRS( + new float3(10f, 20f, 30f), + quaternion.RotateZ(math.radians(90f)), + new float3(2f, 3f, 4f) + ); + var expectedLow = matrix.MultiplyPoint(new float3(source.XY, source.ZLow)); + var expectedHigh = matrix.MultiplyPoint(new float3(source.XY, source.ZHigh)); + + var transformed = source.Transform(matrix); + + AssertFloat2(transformed.XY, expectedLow.xy); + Assert.That(transformed.ZLow, Is.EqualTo(expectedLow.z).Within(Tolerance)); + Assert.That(transformed.ZHigh, Is.EqualTo(expectedHigh.z).Within(Tolerance)); + } + + [Test] + public void GetScaleReturnsTheTrsAxisScaleAfterRotation() + { + var matrix = RotatedNonUniformScale(); + + AssertFloat3(matrix.GetScale(), new float3(2f, 1f, 3f)); + } + + [Test] + public void CircleColliderRejectsRotatedNonUniformXyScale() + { + Assert.That(CircleCollider.IsTransformable(RotatedNonUniformScale()), Is.False); + } + + [Test] + public void BallTransformScalesRadiusWithUniformScale() + { + var ball = new BallState { + Position = new float3(150f, 0f, 0f), + Radius = 25f + }; + + ball.Transform(float4x4.Scale(new float3(0.5f))); + + AssertFloat3(ball.Position, new float3(75f, 0f, 0f)); + Assert.That(ball.Radius, Is.EqualTo(12.5f).Within(Tolerance)); + } + + [Test] + public void CollisionEventTransformRoundTripPreservesHitVelocity() + { + var collEvent = new CollisionEventData { HitVelocity = new float2(0f, 1f) }; + var matrix = float4x4.TRS( + float3.zero, + quaternion.RotateX(math.radians(45f)), + new float3(1f) + ); + + collEvent.Transform(matrix); + collEvent.Transform(math.inverse(matrix)); + + AssertFloat2(collEvent.HitVelocity, new float2(0f, 1f)); + } + + [Test] + public void Line3DColliderFiresHitEventForApproachingBallAboveThreshold() + { + var collider = new Line3DCollider( + new float3(0f, 0f, -1f), + new float3(0f, 0f, 1f), + new ColliderInfo { + ItemId = 1, + ItemType = ItemType.Primitive, + HitThreshold = 1f, + FireEvents = true + } + ); + var ball = new BallState { + Id = 1, + Position = new float3(1f, 0f, 0f), + EventPosition = new float3(100f, 0f, 0f), + Velocity = new float3(-10f, 0f, 0f), + Radius = 1f, + Mass = 1f + }; + var collEvent = new CollisionEventData { + HitNormal = new float3(1f, 0f, 0f), + HitDistance = 0f + }; + var state = new PhysicsState(); + var events = new NativeQueue(Allocator.Temp); + + try { + var writer = events.AsParallelWriter(); + collider.Collide(ref ball, ref writer, in collEvent, ref state); + + Assert.That(events.Count, Is.EqualTo(1)); + } finally { + events.Dispose(); + } + } + + [TestCase(typeof(Aabb), TestName = "AabbBoxedEqualsDelegatesToTypedOverload")] + [TestCase(typeof(ColliderHeader), TestName = "ColliderHeaderBoxedEqualsDelegatesToTypedOverload")] + public void BoxedEqualsDelegatesToTypedOverload(Type type) + { + var boxedEquals = type.GetMethod( + nameof(object.Equals), + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(object) }, + null + ); + var typedEquals = type.GetMethod( + nameof(object.Equals), + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { type }, + null + ); + + Assert.That(boxedEquals, Is.Not.Null); + Assert.That(typedEquals, Is.Not.Null); + Assert.That(CallsMethod(boxedEquals, typedEquals), Is.True, + $"{type.Name}.Equals(object) does not delegate to Equals({type.Name})."); + } + + private static float4x4 RotatedNonUniformScale() + { + return float4x4.TRS( + float3.zero, + quaternion.RotateZ(math.radians(45f)), + new float3(2f, 1f, 3f) + ); + } + + private static bool CallsMethod(MethodInfo caller, MethodInfo expectedCallee) + { + var body = caller.GetMethodBody(); + var il = body?.GetILAsByteArray(); + if (il == null) { + return false; + } + + var offset = 0; + while (offset < il.Length) { + var opCode = ReadOpCode(il, ref offset); + if (opCode.OperandType == OperandType.InlineMethod) { + var token = BitConverter.ToInt32(il, offset); + var calledMethod = caller.Module.ResolveMethod(token); + if (HasSameSignature(calledMethod, expectedCallee)) { + return true; + } + } + offset += GetOperandSize(opCode.OperandType, il, offset); + } + return false; + } + + private static bool HasSameSignature(MethodBase actual, MethodInfo expected) + { + if (actual.DeclaringType != expected.DeclaringType || actual.Name != expected.Name) { + return false; + } + var actualParameters = actual.GetParameters(); + var expectedParameters = expected.GetParameters(); + if (actualParameters.Length != expectedParameters.Length) { + return false; + } + for (var i = 0; i < actualParameters.Length; i++) { + if (actualParameters[i].ParameterType != expectedParameters[i].ParameterType) { + return false; + } + } + return true; + } + + private static OpCode ReadOpCode(byte[] il, ref int offset) + { + var value = il[offset++]; + return value == 0xfe ? TwoByteOpCodes[il[offset++]] : OneByteOpCodes[value]; + } + + private static int GetOperandSize(OperandType operandType, byte[] il, int offset) + { + switch (operandType) { + case OperandType.InlineNone: + return 0; + case OperandType.ShortInlineBrTarget: + case OperandType.ShortInlineI: + case OperandType.ShortInlineVar: + return 1; + case OperandType.InlineVar: + return 2; + case OperandType.InlineBrTarget: + case OperandType.InlineField: + case OperandType.InlineI: + case OperandType.InlineMethod: + case OperandType.InlineSig: + case OperandType.InlineString: + case OperandType.InlineTok: + case OperandType.InlineType: + case OperandType.ShortInlineR: + return 4; + case OperandType.InlineI8: + case OperandType.InlineR: + return 8; + case OperandType.InlineSwitch: + return 4 + BitConverter.ToInt32(il, offset) * 4; + default: + throw new ArgumentOutOfRangeException(nameof(operandType), operandType, null); + } + } + + private static void AssertFloat2(float2 actual, float2 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(Tolerance)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(Tolerance)); + } + + private static void AssertFloat3(float3 actual, float3 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(Tolerance)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(Tolerance)); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(Tolerance)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta new file mode 100644 index 000000000..b46db8820 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a240399f2c24726a78fbe54cf78b518 From 308d639c99a815ae6cf2078ff060fd673b3f54d9 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 09:57:42 +0200 Subject: [PATCH 03/11] physics(colliders): keep transformed point bounds aligned PointCollider transformed its position and then rebuilt its AABB by applying the same matrix to that already-transformed point. Translations and other transforms therefore affected broad-phase bounds twice, leaving the collider geometry and bounds in different places. Build the zero-size bounds directly from the final transformed point. Also expose runtime internals to the Unity test assembly so the collider-level regressions compile and can verify the broad-phase position alongside the narrow-phase point. --- VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs | 3 +++ .../VisualPinball.Unity/Common/AssemblyInfo.cs.meta | 2 ++ .../VisualPinball.Unity/Physics/Collider/PointCollider.cs | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs new file mode 100644 index 000000000..52d673487 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("VisualPinball.Unity.Test")] diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta new file mode 100644 index 000000000..2744f375a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 964d153f72dc44b9996b6c113577e1cc diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs index 3a7994740..8e4449706 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/PointCollider.cs @@ -152,7 +152,7 @@ public PointCollider Transform(float4x4 matrix) public void Transform(PointCollider point, float4x4 matrix) { P = matrix.MultiplyPoint(point.P); - TransformAabb(matrix); + Bounds = new ColliderBounds(Header.ItemId, Header.Id, new Aabb(P, P)); } public Aabb GetTransformedAabb(float4x4 matrix) From 9df8d8a4c7afdc34d8e12908867a4da519559b79 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 09:59:00 +0200 Subject: [PATCH 04/11] physics(colliders): transform line-z endpoints directly LineZCollider reconstructed transforms from a translation plus a separately extracted Z scale. That skipped legal rotation around Z, ignored XY scale, and expanded the height around a translated midpoint instead of transforming the original endpoints. Transform the low and high endpoints with the complete matrix, take their shared XY position, and order the resulting Z values. The collider and its bounds now follow translation, Z rotation, axis scale, and negative Z scale exactly. --- .../Physics/Collider/LineZCollider.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs index d47e97400..dded4bf18 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/LineZCollider.cs @@ -196,19 +196,12 @@ public void Transform(LineZCollider lineCollider, float4x4 matrix) } #endif - var t = matrix.GetTranslation(); - var s = matrix.GetScale(); - - XY = lineCollider.XY + t.xy; - ZLow = lineCollider.ZLow + t.z; - ZHigh = lineCollider.ZHigh + t.z; - if (s.z > Collider.Tolerance) { - var height = ZHigh - ZLow; - var zMid = ZLow + height * 0.5f; - var halfHeightScaled = height * s.z * 0.5f; - ZLow = zMid - halfHeightScaled; - ZHigh = zMid + halfHeightScaled; - } + var p1 = matrix.MultiplyPoint(new float3(lineCollider.XY, lineCollider.ZLow)); + var p2 = matrix.MultiplyPoint(new float3(lineCollider.XY, lineCollider.ZHigh)); + + XY = p1.xy; + ZLow = math.min(p1.z, p2.z); + ZHigh = math.max(p1.z, p2.z); CalculateBounds(); } From 0991126e3eb0516aaa9856634a9ce9fd48c7c64b Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:00:19 +0200 Subject: [PATCH 05/11] physics: extract scale from transform columns GetScale measured matrix rows even though Unity.Mathematics stores each transformed basis axis in a column. Once a non-uniform scale was rotated, row lengths blended the axes and could report a false uniform scale. Measure the XYZ length of each basis column instead. Scale extraction is now rotation-invariant, and collider transformability checks correctly reject rotated non-uniform XY scale instead of baking an ellipse as a circle. --- .../VisualPinball.Unity/Extensions/MathExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs index f61445305..363cbb28b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Extensions/MathExtensions.cs @@ -62,9 +62,9 @@ public static void RotationAroundAxis(ref this float3x3 m, float3 axis, float rS public static float3 GetScale(this float4x4 m) { return new float3( - math.length(new float3(m.c0.x, m.c1.x, m.c2.x)), - math.length(new float3(m.c0.y, m.c1.y, m.c2.y)), - math.length(new float3(m.c0.z, m.c1.z, m.c2.z)) + math.length(m.c0.xyz), + math.length(m.c1.xyz), + math.length(m.c2.xyz) ); } From 2c9c5378934deef160bc950792ea28bc4474f845 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:01:58 +0200 Subject: [PATCH 06/11] physics(balls): scale radius in transformed space The non-transformable-collider fallback moved a ball into local space but left its radius in world units. Uniformly scaled items therefore tested a correctly transformed center against a sphere of the wrong size, shifting collision times and contact depth. Apply the matrix scale to the radius when all three axes are uniform, where a sphere remains exactly representable by one scalar and the inverse transform restores the original value. Non-uniform transforms remain unchanged because representing those faithfully requires ellipsoid collision support. --- .../VisualPinball.Unity/VPT/Ball/BallState.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index ae9d9d2fd..be732828e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -145,7 +145,7 @@ the angular velocity used here is not the angular velocity that the ball has (wh */ } - public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero); + public void UpdateVelocities(float3 gravity) => BallVelocityPhysics.UpdateVelocities(ref this, gravity, float2.zero); public override string ToString() { @@ -154,6 +154,13 @@ public override string ToString() public void Transform(float4x4 matrix) { + var scale = matrix.GetScale(); + var hasUniformScale = math.abs(scale.x - scale.y) < Collider.Tolerance + && math.abs(scale.x - scale.z) < Collider.Tolerance; + if (hasUniformScale) { + Radius *= scale.x; + } + Position = matrix.MultiplyPoint(Position); EventPosition = matrix.MultiplyPoint(EventPosition); Velocity = matrix.MultiplyVector(Velocity); From ae2445a4f1935075a7559e87aa83efa1633e850f Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:03:20 +0200 Subject: [PATCH 07/11] physics(collisions): preserve transformed hit velocity depth CollisionEventData exposes the legacy XY hit velocity used by flippers and plungers, but a tilted-space transform can rotate part of that vector into Z. The old transform discarded that component immediately, so applying the inverse matrix returned a smaller XY velocity. Keep the transformed Z component privately alongside the public XY value and include it in subsequent transforms. Existing collider code retains its two-dimensional API while world-to-local round trips preserve the complete velocity needed by tilted plungers. --- .../Physics/Collision/CollisionEventData.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs index 41b7ef67e..598b932c6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs @@ -23,6 +23,7 @@ public struct CollisionEventData public float HitTime; public float3 HitNormal; public float2 HitVelocity; + private float _hitVelocityZ; public float HitDistance; public bool HitFlag; public float HitOrgNormalVelocity; @@ -68,7 +69,9 @@ public bool HasCollider() public void Transform(float4x4 matrix) { HitNormal = matrix.MultiplyVector(HitNormal); - HitVelocity = matrix.MultiplyVector(new float3(HitVelocity, 0)).xy; + var transformedHitVelocity = matrix.MultiplyVector(new float3(HitVelocity, _hitVelocityZ)); + HitVelocity = transformedHitVelocity.xy; + _hitVelocityZ = transformedHitVelocity.z; } } } From a8b92ab0d63d0c61b1479d6f8928ca64b11f92d6 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:04:41 +0200 Subject: [PATCH 08/11] physics(colliders): fire line-3d events on approach The line-3D event gate compared normal dot velocity directly with a positive threshold. An approaching ball produces a negative dot product, so ordinary impacts resolved physically but never crossed the event threshold; receding motion had the sign the event code expected. Convert the pre-collision dot product to a positive impact speed before comparing it with the threshold. This matches the convention used by the other static colliders and restores hit events for primitive, ramp, rubber, and wire-guide line segments. --- .../VisualPinball.Unity/Physics/Collider/Line3DCollider.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs index 8d8aabb51..bf2fea71c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Line3DCollider.cs @@ -137,11 +137,11 @@ private static float HitTest(ref CollisionEventData collEvent, ref Line3DCollide public void Collide(ref BallState ball, ref NativeQueue.ParallelWriter hitEvents, in CollisionEventData collEvent, ref PhysicsState state) { - var dot = math.dot(collEvent.HitNormal, ball.Velocity); + 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 && dot >= Header.Threshold && Header.IsPrimitive) { - // todo m_obj->m_currentHitThreshold = dot; + if (Header.FireEvents && impactSpeed >= Header.Threshold && Header.IsPrimitive) { + // todo m_obj->m_currentHitThreshold = impactSpeed; Collider.FireHitEvent(ref ball, ref hitEvents, in Header); } } From ae4b2484a05f794ef8683a35ca51be18a033e19c Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:06:29 +0200 Subject: [PATCH 09/11] physics: delegate boxed equality to typed overloads Aabb.Equals(object) and ColliderHeader.Equals(object) checked the boxed type but then passed the original object back to Equals. Overload resolution selected the same object overload, causing unbounded recursion and a process-ending stack overflow. Capture the typed value in each pattern match and call the corresponding IEquatable overload explicitly. The boxed paths now share the established field comparisons, and the regression suite description no longer labels the completed cases as intentionally red. --- .../Physics/PhysicsRegressionTests.cs | 3 +-- .../VisualPinball.Unity/Physics/Collision/Aabb.cs | 5 +++-- .../VisualPinball.Unity/Physics/Collision/ColliderHeader.cs | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs index ca5a5b8f1..0167525f8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs @@ -25,8 +25,7 @@ namespace VisualPinball.Unity.Test { /// - /// Regression coverage for known transform and collision bugs. These tests intentionally - /// remain red until the corresponding production fixes are implemented. + /// Regression coverage for physics transform and collision bugs. /// public class PhysicsRegressionTests { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs index e0dfb9fb6..260b8d9f9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/Aabb.cs @@ -169,8 +169,9 @@ private static float max(params float[] values) public readonly override bool Equals(object obj) { - if (obj is Aabb) - return Equals(obj); + if (obj is Aabb other) { + return Equals(other); + } return false; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs index c34af4011..7db433353 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderHeader.cs @@ -98,8 +98,9 @@ public readonly bool Equals(ColliderHeader other) public override readonly bool Equals(object obj) { - if (obj is ColliderHeader) - return Equals(obj); + if (obj is ColliderHeader other) { + return Equals(other); + } return false; } From a6d7cb6072aa66f05ce657246c18a85e0caf6a43 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 10:40:28 +0200 Subject: [PATCH 10/11] physics: reset transformed hit velocity Z on clear. Extract ClearHitVelocity() so clearing the collision event's hit velocity also zeroes the transformed Z component, not just the 2D vector. ClearCollider() now clears it too, and KickerApi uses the shared helper. Add a regression test covering transform after clear. --- .../Physics/PhysicsRegressionTests.cs | 21 +++++++++++++++++++ .../Physics/Collision/CollisionEventData.cs | 7 +++++++ .../VPT/Kicker/KickerApi.cs | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs index 0167525f8..b9665d564 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs @@ -123,6 +123,27 @@ public void CollisionEventTransformRoundTripPreservesHitVelocity() AssertFloat2(collEvent.HitVelocity, new float2(0f, 1f)); } + [Test] + public void CollisionEventClearResetsTransformedHitVelocity() + { + var collEvent = new CollisionEventData { HitVelocity = new float2(0f, 1f) }; + var matrix = float4x4.TRS( + float3.zero, + quaternion.RotateX(math.radians(45f)), + new float3(1f) + ); + + collEvent.Transform(matrix); + collEvent.ClearCollider(); + + AssertFloat2(collEvent.HitVelocity, float2.zero); + + collEvent.HitVelocity = new float2(0f, 1f); + collEvent.Transform(matrix); + + AssertFloat2(collEvent.HitVelocity, new float2(0f, math.sqrt(0.5f))); + } + [Test] public void Line3DColliderFiresHitEventForApproachingBallAboveThreshold() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs index 598b932c6..015af599b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/CollisionEventData.cs @@ -59,6 +59,13 @@ public void ClearCollider() { ColliderId = -1; BallId = 0; + ClearHitVelocity(); + } + + public void ClearHitVelocity() + { + HitVelocity = float2.zero; + _hitVelocityZ = 0f; } public bool HasCollider() diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs index 465759a45..74b04cac7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs @@ -218,7 +218,7 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float var rotQuaternion = new quaternion(rotMatrix); ballData.Velocity = math.mul(rotQuaternion, velocity); - ballData.IsFrozen = false; + ballData.IsFrozen = false; ballData.AngularMomentum = float3.zero; // update collision event @@ -226,7 +226,7 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float collEvent.HitDistance = 0.0f; collEvent.HitTime = -1.0f; collEvent.HitNormal = float3.zero; - collEvent.HitVelocity = float2.zero; + collEvent.ClearHitVelocity(); collEvent.HitFlag = false; collEvent.IsContact = false; From d7a4063ef622f66295d6c77b9b669dee4f07d677 Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 10 Jul 2026 11:02:54 +0200 Subject: [PATCH 11/11] physics(balls): compare transform scale relatively Ball transforms decide whether a sphere's radius can follow a collider matrix by comparing the three extracted axis scales. The previous absolute tolerance grew stricter as the matrix scale increased, allowing a matrix and its inverse to disagree and permanently shrink the persistent ball. Compare scale values relative to their magnitude and share the predicate with circle collider validation. Cover a rotated uniform scale of ten with an inverse/forward regression that previously changed radius 25 to 2.5. --- .../Physics/PhysicsRegressionTests.cs | 17 +++++++++++++++++ .../Physics/Collider/CircleCollider.cs | 2 +- .../Physics/Collider/Collider.cs | 7 +++++++ .../VisualPinball.Unity/VPT/Ball/BallState.cs | 4 ++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs index b9665d564..ce2a00dc4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs @@ -107,6 +107,23 @@ public void BallTransformScalesRadiusWithUniformScale() Assert.That(ball.Radius, Is.EqualTo(12.5f).Within(Tolerance)); } + [Test] + public void BallTransformRoundTripPreservesRadiusForLargeRotatedUniformScale() + { + var ball = new BallState { Radius = 25f }; + var matrix = float4x4.TRS( + float3.zero, + quaternion.EulerXYZ(math.radians(new float3(10f, 62f, 17f))), + new float3(10f) + ); + + ball.Transform(math.inverse(matrix)); + Assert.That(ball.Radius, Is.EqualTo(2.5f).Within(Tolerance)); + + ball.Transform(matrix); + Assert.That(ball.Radius, Is.EqualTo(25f).Within(Tolerance)); + } + [Test] public void CollisionEventTransformRoundTripPreservesHitVelocity() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs index 7c39fcb6d..75e5a4f2e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs @@ -248,7 +248,7 @@ public static bool IsTransformable(float4x4 matrix) var rotation = matrix.GetRotationVector(); // if xy-scale is not uniform or x/y rotation is not zero, we can't transform the collider - var uniformScale = math.abs(scale.x - scale.y) < Collider.Tolerance; + var uniformScale = Collider.HasEqualScale(scale.x, scale.y); var xyRotated = math.abs(rotation.x) > Collider.Tolerance || math.abs(rotation.y) > Collider.Tolerance; return uniformScale && !xyRotated; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs index f0fbfd205..e10246139 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs @@ -30,6 +30,13 @@ public struct Collider { public const float Tolerance = 1e-6f; // 1e-9f; + internal static bool HasEqualScale(float first, float second) + { + // GetScale error grows with the matrix scale, so compare relatively. + var largestMagnitude = math.max(math.abs(first), math.abs(second)); + return math.abs(first - second) <= Tolerance * largestMagnitude; + } + public ColliderHeader Header; public int Id => Header.Id; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index be732828e..4f7aeabc6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -155,8 +155,8 @@ public override string ToString() public void Transform(float4x4 matrix) { var scale = matrix.GetScale(); - var hasUniformScale = math.abs(scale.x - scale.y) < Collider.Tolerance - && math.abs(scale.x - scale.z) < Collider.Tolerance; + var hasUniformScale = Collider.HasEqualScale(scale.x, scale.y) + && Collider.HasEqualScale(scale.x, scale.z); if (hasUniformScale) { Radius *= scale.x; }