From 6e4bd6dca8dcc270dae325503f786c846f41a19c Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 14:44:52 +0200 Subject: [PATCH 1/6] tests(plunger): add characterization coverage --- VisualPinball.Engine.Test/Test/Fixtures.cs | 16 ++- .../VPT/Plunger/PlungerMeshTests.cs | 74 ++++++++++ .../VPT/PlungerPhysicsTests.cs | 129 ++++++++++++++++++ .../VPT/PlungerPhysicsTests.cs.meta | 11 ++ 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta diff --git a/VisualPinball.Engine.Test/Test/Fixtures.cs b/VisualPinball.Engine.Test/Test/Fixtures.cs index 93d80fead..fe3c6946c 100644 --- a/VisualPinball.Engine.Test/Test/Fixtures.cs +++ b/VisualPinball.Engine.Test/Test/Fixtures.cs @@ -105,7 +105,21 @@ public static class PathHelper { public static string GetFixturePath(string filename) { - return Path.GetFullPath(Path.Combine(GetTestPath(), "Fixtures~", filename)); + var expectedPath = Path.GetFullPath(Path.Combine(GetTestPath(), "Fixtures~", filename)); + if (File.Exists(expectedPath)) { + return expectedPath; + } + + var searchDir = new DirectoryInfo(Path.GetDirectoryName(new System.Uri(Assembly.GetExecutingAssembly().CodeBase).LocalPath)); + while (searchDir != null) { + var fixturePath = Path.Combine(searchDir.FullName, "VisualPinball.Engine.Test", "Fixtures~", filename); + if (File.Exists(fixturePath)) { + return fixturePath; + } + searchDir = searchDir.Parent; + } + + return expectedPath; } private static string GetTestPath() diff --git a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs new file mode 100644 index 000000000..b7c36fc7c --- /dev/null +++ b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs @@ -0,0 +1,74 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Plunger; + +namespace VisualPinball.Engine.Test.VPT.Plunger +{ + public class PlungerMeshTests + { + [Test] + [Explicit("Known failing characterization for Phase 5: the rod tip is currently open.")] + public void ShouldCloseModernRodTip() + { + var mesh = new PlungerMeshGenerator(new PlungerData()).GetMesh(0.0f, PlungerMeshGenerator.Rod); + var maxZ = mesh.Vertices.Max(v => v.Z); + + FindBoundaryEdges(mesh) + .Where(e => System.Math.Abs(mesh.Vertices[e.a].Z - maxZ) < 0.0001f && System.Math.Abs(mesh.Vertices[e.b].Z - maxZ) < 0.0001f) + .Should().BeEmpty(); + } + + [Test] + [Explicit("Known failing characterization for Phase 5: zero-loop springs currently overflow while building indices.")] + public void ShouldBuildEmptySpringForZeroLoops() + { + var data = new PlungerData { + Type = PlungerType.PlungerTypeCustom, + SpringLoops = 0.0f, + SpringEndLoops = 0.0f + }; + + var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Spring); + + mesh.Vertices.Should().BeEmpty(); + mesh.Indices.Should().BeEmpty(); + } + + private static IEnumerable<(int a, int b)> FindBoundaryEdges(Mesh mesh) + { + var edgeCounts = new Dictionary<(int a, int b), int>(); + for (var i = 0; i < mesh.Indices.Length; i += 3) { + AddEdge(mesh.Indices[i], mesh.Indices[i + 1], edgeCounts); + AddEdge(mesh.Indices[i + 1], mesh.Indices[i + 2], edgeCounts); + AddEdge(mesh.Indices[i + 2], mesh.Indices[i], edgeCounts); + } + return edgeCounts.Where(e => e.Value == 1).Select(e => e.Key); + } + + private static void AddEdge(int a, int b, IDictionary<(int a, int b), int> edgeCounts) + { + var edge = a < b ? (a, b) : (b, a); + edgeCounts.TryGetValue(edge, out var count); + edgeCounts[edge] = count + 1; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs new file mode 100644 index 000000000..b9674167c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs @@ -0,0 +1,129 @@ +// 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 FluentAssertions; +using NUnit.Framework; + +namespace VisualPinball.Unity.Test +{ + public class PlungerPhysicsTests + { + [Test] + public void ShouldFireProportionallyToPullDistance() + { + var staticState = CreateStaticState(); + + var rest = FireFrom(0.0f, staticState); + var half = FireFrom(0.5f, staticState); + var full = FireFrom(1.0f, staticState); + + rest.FireSpeed.Should().BeApproximately(0.0f, 0.0001f); + half.FireSpeed.Should().BeLessThan(0.0f); + full.FireSpeed.Should().BeLessThan(half.FireSpeed); + + var halfDistance = 0.5f - staticState.RestPosition; + var fullDistance = 1.0f - staticState.RestPosition; + half.FireSpeed.Should().BeApproximately(full.FireSpeed * halfDistance / fullDistance, 0.0001f); + } + + [Test] + [Explicit("Known failing characterization for Phase 1: non-mechanical plungers currently still apply MechStrength while idle.")] + public void ShouldNotApplyMechStrengthToNonMechanicalPlunger() + { + var staticState = CreateStaticState(); + staticState.IsMechPlunger = false; + var movementWithNoStrength = CreateMovement(staticState); + var movementWithStrength = CreateMovement(staticState); + movementWithNoStrength.AnalogPosition = 1.0f; + movementWithStrength.AnalogPosition = 1.0f; + + var velocityWithNoStrength = new PlungerVelocityState { MechStrength = 0.0f }; + var velocityWithStrength = new PlungerVelocityState { MechStrength = 100.0f }; + + PlungerVelocityPhysics.UpdateVelocities(ref movementWithNoStrength, ref velocityWithNoStrength, in staticState); + PlungerVelocityPhysics.UpdateVelocities(ref movementWithStrength, ref velocityWithStrength, in staticState); + + movementWithStrength.Speed.Should().BeApproximately(movementWithNoStrength.Speed, 0.0001f); + movementWithStrength.Position.Should().BeApproximately(movementWithNoStrength.Position, 0.0001f); + } + + [Test] + [Explicit("Known failing characterization for Phase 1: PullBackAndRetract currently disables the retract branch.")] + public void ShouldEnableRetractMotionForNormalPlungerPullBackAndRetract() + { + var movement = new PlungerMovementState { RetractMotion = true }; + var velocity = new PlungerVelocityState(); + + PlungerCommands.PullBackAndRetract(3.0f, ref velocity, ref movement); + + velocity.AddRetractMotion.Should().BeTrue(); + velocity.InitialSpeed.Should().Be(3.0f); + movement.RetractMotion.Should().BeFalse(); + } + + [Test] + [Explicit("Known failing characterization for Phase 1: non-mechanical plungers currently follow idle MechStrength toward rest.")] + public void ShouldGateAnalogPositionToMechanicalPlungers() + { + var staticState = CreateStaticState(); + staticState.IsMechPlunger = true; + var mechanicalMovement = CreateMovement(staticState); + mechanicalMovement.AnalogPosition = 0.75f; + var mechanicalVelocity = new PlungerVelocityState { MechStrength = 100.0f }; + + var nonMechanicalState = staticState; + nonMechanicalState.IsMechPlunger = false; + var nonMechanicalMovement = CreateMovement(nonMechanicalState); + nonMechanicalMovement.AnalogPosition = 0.75f; + var nonMechanicalVelocity = new PlungerVelocityState { MechStrength = 100.0f }; + + PlungerVelocityPhysics.UpdateVelocities(ref mechanicalMovement, ref mechanicalVelocity, in staticState); + PlungerVelocityPhysics.UpdateVelocities(ref nonMechanicalMovement, ref nonMechanicalVelocity, in nonMechanicalState); + + mechanicalMovement.Speed.Should().NotBeApproximately(nonMechanicalMovement.Speed, 0.0001f); + nonMechanicalMovement.Speed.Should().BeApproximately(0.0f, 0.0001f); + } + + private static PlungerMovementState FireFrom(float startPos, PlungerStaticState staticState) + { + var movement = CreateMovement(staticState); + var velocity = new PlungerVelocityState(); + + PlungerCommands.Fire(startPos, ref velocity, ref movement, in staticState); + return movement; + } + + private static PlungerMovementState CreateMovement(PlungerStaticState staticState) + { + return new PlungerMovementState { + Position = staticState.FrameEnd + staticState.RestPosition * staticState.FrameLen, + TravelLimit = staticState.FrameEnd + }; + } + + private static PlungerStaticState CreateStaticState() + { + return new PlungerStaticState { + FrameStart = 0.0f, + FrameEnd = -80.0f, + FrameLen = 80.0f, + RestPosition = 1.0f / 6.0f, + SpeedFire = 80.0f, + IsMechPlunger = false + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta new file mode 100644 index 000000000..ba9345662 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2a3c3906c5a4d9e9dff5b6fc4693d84 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 57779fcdcfb28358ef18aadd76de3d2eabf3fdda Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 14:54:06 +0200 Subject: [PATCH 2/6] plunger: fix command and analog behavior --- .../VPT/PlungerPhysicsTests.cs | 42 ++++++++++++------- .../VisualPinball.Unity/Game/Player.cs | 20 +++++---- .../VisualPinball.Unity/Input/InputManager.cs | 2 +- .../VPT/Plunger/PlungerApi.cs | 7 ++-- .../VPT/Plunger/PlungerCommands.cs | 41 +++++++++--------- .../VPT/Plunger/PlungerComponent.cs | 6 +-- .../VPT/Plunger/PlungerVelocityPhysics.cs | 7 +++- 7 files changed, 72 insertions(+), 53 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs index b9674167c..45676978e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs @@ -39,9 +39,8 @@ public void ShouldFireProportionallyToPullDistance() half.FireSpeed.Should().BeApproximately(full.FireSpeed * halfDistance / fullDistance, 0.0001f); } - [Test] - [Explicit("Known failing characterization for Phase 1: non-mechanical plungers currently still apply MechStrength while idle.")] - public void ShouldNotApplyMechStrengthToNonMechanicalPlunger() + [Test] + public void ShouldNotApplyMechStrengthToNonMechanicalPlunger() { var staticState = CreateStaticState(); staticState.IsMechPlunger = false; @@ -60,23 +59,34 @@ public void ShouldNotApplyMechStrengthToNonMechanicalPlunger() movementWithStrength.Position.Should().BeApproximately(movementWithNoStrength.Position, 0.0001f); } - [Test] - [Explicit("Known failing characterization for Phase 1: PullBackAndRetract currently disables the retract branch.")] - public void ShouldEnableRetractMotionForNormalPlungerPullBackAndRetract() + [Test] + public void ShouldEnableRetractMotionForNormalPlungerPullBackAndRetract() { var movement = new PlungerMovementState { RetractMotion = true }; var velocity = new PlungerVelocityState(); - PlungerCommands.PullBackAndRetract(3.0f, ref velocity, ref movement); - - velocity.AddRetractMotion.Should().BeTrue(); - velocity.InitialSpeed.Should().Be(3.0f); - movement.RetractMotion.Should().BeFalse(); - } - - [Test] - [Explicit("Known failing characterization for Phase 1: non-mechanical plungers currently follow idle MechStrength toward rest.")] - public void ShouldGateAnalogPositionToMechanicalPlungers() + PlungerCommands.PullBackAndRetract(3.0f, false, ref velocity, ref movement); + + velocity.AddRetractMotion.Should().BeTrue(); + velocity.InitialSpeed.Should().Be(3.0f); + movement.RetractMotion.Should().BeFalse(); + } + + [Test] + public void ShouldDisableRetractMotionForAutoPlungerPullBackAndRetract() + { + var movement = new PlungerMovementState { RetractMotion = true }; + var velocity = new PlungerVelocityState(); + + PlungerCommands.PullBackAndRetract(3.0f, true, ref velocity, ref movement); + + velocity.AddRetractMotion.Should().BeFalse(); + velocity.InitialSpeed.Should().Be(3.0f); + movement.RetractMotion.Should().BeFalse(); + } + + [Test] + public void ShouldGateAnalogPositionToMechanicalPlungers() { var staticState = CreateStaticState(); staticState.IsMechPlunger = true; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 5f9446464..efabb3783 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -289,9 +289,10 @@ private void OnDestroy() _wirePlayer.OnDestroy(); _displayPlayer.OnDestroy(); - foreach (var (action, callback) in _actions) { - action.performed -= callback; - } + foreach (var (action, callback) in _actions) { + action.performed -= callback; + action.canceled -= callback; + } } #endregion @@ -300,12 +301,13 @@ private void OnDestroy() public void Register(PlungerApi plungerApi, PlungerComponent component, InputActionReference actionRef) { - Register(plungerApi, component); - if (actionRef != null) { - actionRef.action.performed += plungerApi.OnAnalogPlunge; - _actions.Add((actionRef.action, plungerApi.OnAnalogPlunge)); - } - } + Register(plungerApi, component); + if (actionRef != null) { + actionRef.action.performed += plungerApi.OnAnalogPlunge; + actionRef.action.canceled += plungerApi.OnAnalogPlunge; + _actions.Add((actionRef.action, plungerApi.OnAnalogPlunge)); + } + } public void Register(PlayfieldApi playfieldApi) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs index 26c3c6f76..1e1f4b83d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Input/InputManager.cs @@ -172,7 +172,7 @@ public static InputActionAsset GetDefaultInputActionAsset() map.AddAction(InputConstants.ActionFrontBuyIn, InputActionType.Button, "/2"); map.AddAction(InputConstants.ActionStartGame, InputActionType.Button, "/1"); map.AddAction(InputConstants.ActionPlunger, InputActionType.Button, "/enter"); - map.AddAction(InputConstants.ActionPlungerAnalog, InputActionType.Button, "/rightStick/down"); + map.AddAction(InputConstants.ActionPlungerAnalog, InputActionType.Value, "/rightStick/y", processors: "invert,axisDeadzone"); map.AddAction(InputConstants.ActionInsertCoin1, InputActionType.Button, "/5"); map.AddAction(InputConstants.ActionInsertCoin2, InputActionType.Button, "/4"); map.AddAction(InputConstants.ActionInsertCoin3, InputActionType.Button, "/3"); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs index 99192ba92..bfa86357b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerApi.cs @@ -64,7 +64,7 @@ public class PlungerApi : CollidableApi(); // 0 = resting pos, 1 = pulled back + var pos = math.clamp(ctx.ReadValue(), 0.0f, 1.0f); // 0 = resting pos, 1 = pulled back PhysicsEngine.MutateState((ref PhysicsState state) => { ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); plungerState.Movement.AnalogPosition = pos; @@ -101,10 +101,11 @@ public void PullBack() var doRetract = DoRetract; var speedPull = collComponent.SpeedPull; + var isAutoPlunger = collComponent.IsAutoPlunger; PhysicsEngine.MutateState((ref PhysicsState state) => { ref var plungerState = ref state.PlungerStates.GetValueByRef(ItemId); if (doRetract) { - PlungerCommands.PullBackAndRetract(speedPull, ref plungerState.Velocity, ref plungerState.Movement); + PlungerCommands.PullBackAndRetract(speedPull, isAutoPlunger, ref plungerState.Velocity, ref plungerState.Movement); } else { PlungerCommands.PullBack(speedPull, ref plungerState.Velocity, ref plungerState.Movement); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs index 8cf8a1b17..c50a865b5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCommands.cs @@ -19,25 +19,28 @@ namespace VisualPinball.Unity internal static class PlungerCommands { - public static void PullBack(float speed, ref PlungerVelocityState velocity, ref PlungerMovementState movement) - { - movement.Speed = 0.0f; - velocity.PullForce = speed; - - // deactivate the retract code - velocity.AddRetractMotion = false; - } - - public static void PullBackAndRetract(float speedPull, ref PlungerVelocityState velocity, ref PlungerMovementState movement) - { - movement.Speed = 0.0f; - velocity.PullForce = speedPull; - - // deactivate the retract code - velocity.AddRetractMotion = false; - movement.RetractMotion = false; - velocity.InitialSpeed = speedPull; - } + public static void PullBack(float speed, ref PlungerVelocityState velocity, ref PlungerMovementState movement) + { + movement.Speed = 0.0f; + velocity.PullForce = speed; + + // deactivate the retract code + velocity.AddRetractMotion = false; + movement.RetractMotion = false; + velocity.InitialSpeed = 0.0f; + velocity.RetractWaitLoop = 0; + } + + public static void PullBackAndRetract(float speedPull, bool isAutoPlunger, ref PlungerVelocityState velocity, ref PlungerMovementState movement) + { + movement.Speed = 0.0f; + velocity.PullForce = speedPull; + + velocity.AddRetractMotion = !isAutoPlunger; + movement.RetractMotion = false; + velocity.InitialSpeed = speedPull; + velocity.RetractWaitLoop = 0; + } public static void Fire(float startPos, ref PlungerVelocityState velocity, ref PlungerMovementState movement, in PlungerStaticState staticState) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs index 1ac64443c..251f8f0d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs @@ -303,9 +303,9 @@ internal PlungerState CreateState() FireTimer = 0 }, new PlungerVelocityState { - Mech0 = 0f, - Mech1 = 0f, - Mech2 = 0f, + Mech0 = restPos, + Mech1 = restPos, + Mech2 = restPos, PullForce = 0f, InitialSpeed = 0f, AutoFireTimer = 0, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs index daf53c805..6bcb8edf6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerVelocityPhysics.cs @@ -14,7 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using VisualPinball.Engine.VPT.Plunger; +using Unity.Mathematics; +using VisualPinball.Engine.VPT.Plunger; namespace VisualPinball.Unity { @@ -28,7 +29,9 @@ internal static void UpdateVelocities(ref PlungerMovementState movement, ref Plu // maximum retracted position) var pos = (movement.Position - staticState.FrameEnd) / staticState.FrameLen; - var mech = staticState.IsMechPlunger ? movement.AnalogPosition : 0.0f; + var mech = staticState.IsMechPlunger + ? math.clamp(math.lerp(staticState.RestPosition, 1.0f, movement.AnalogPosition), 0.0f, 1.0f) + : staticState.RestPosition; // calculate the delta from the last reading var dMech = velocity.Mech0 - mech; From 88bcd22a212c44035c39dd6ea324f9f0b9f65550 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 15:03:49 +0200 Subject: [PATCH 3/6] plunger: use local z for collider and meshes --- .../VPT/Plunger/PlungerMeshTests.cs | 52 ++++++++++++++----- .../VPT/Plunger/PlungerMeshGenerator.cs | 35 ++++++++----- .../VPT/PlungerPhysicsTests.cs | 40 +++++++++++--- .../VPT/Plunger/PlungerCollider.cs | 14 ++--- .../VPT/Plunger/PlungerFlatMeshComponent.cs | 8 +-- .../VPT/Plunger/PlungerRodMeshComponent.cs | 6 +-- .../VPT/Plunger/PlungerSpringMeshComponent.cs | 4 +- 7 files changed, 112 insertions(+), 47 deletions(-) diff --git a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs index b7c36fc7c..ff1b510a3 100644 --- a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs +++ b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs @@ -37,9 +37,9 @@ public void ShouldCloseModernRodTip() .Should().BeEmpty(); } - [Test] - [Explicit("Known failing characterization for Phase 5: zero-loop springs currently overflow while building indices.")] - public void ShouldBuildEmptySpringForZeroLoops() + [Test] + [Explicit("Known failing characterization for Phase 5: zero-loop springs currently overflow while building indices.")] + public void ShouldBuildEmptySpringForZeroLoops() { var data = new PlungerData { Type = PlungerType.PlungerTypeCustom, @@ -49,12 +49,28 @@ public void ShouldBuildEmptySpringForZeroLoops() var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Spring); - mesh.Vertices.Should().BeEmpty(); - mesh.Indices.Should().BeEmpty(); - } + mesh.Vertices.Should().BeEmpty(); + mesh.Indices.Should().BeEmpty(); + } + + [TestCase(PlungerMeshGenerator.Rod)] + [TestCase(PlungerMeshGenerator.Spring)] + [TestCase(PlungerMeshGenerator.Flat)] + public void ShouldBuildLocalMeshIndependentOfZAdjust(string meshId) + { + var baseMesh = new PlungerMeshGenerator(CreateData(meshId, 0.0f)).GetLocalMesh(meshId); + var elevatedMesh = new PlungerMeshGenerator(CreateData(meshId, 17.0f)).GetLocalMesh(meshId); + + elevatedMesh.Vertices.Should().HaveCount(baseMesh.Vertices.Length); + for (var i = 0; i < baseMesh.Vertices.Length; i++) { + elevatedMesh.Vertices[i].X.Should().BeApproximately(baseMesh.Vertices[i].X, 0.0001f); + elevatedMesh.Vertices[i].Y.Should().BeApproximately(baseMesh.Vertices[i].Y, 0.0001f); + elevatedMesh.Vertices[i].Z.Should().BeApproximately(baseMesh.Vertices[i].Z, 0.0001f); + } + } - private static IEnumerable<(int a, int b)> FindBoundaryEdges(Mesh mesh) - { + private static IEnumerable<(int a, int b)> FindBoundaryEdges(Mesh mesh) + { var edgeCounts = new Dictionary<(int a, int b), int>(); for (var i = 0; i < mesh.Indices.Length; i += 3) { AddEdge(mesh.Indices[i], mesh.Indices[i + 1], edgeCounts); @@ -68,7 +84,19 @@ private static void AddEdge(int a, int b, IDictionary<(int a, int b), int> edgeC { var edge = a < b ? (a, b) : (b, a); edgeCounts.TryGetValue(edge, out var count); - edgeCounts[edge] = count + 1; - } - } -} + edgeCounts[edge] = count + 1; + } + + private static PlungerData CreateData(string meshId, float zAdjust) + { + return new PlungerData { + Type = meshId switch { + PlungerMeshGenerator.Flat => PlungerType.PlungerTypeFlat, + PlungerMeshGenerator.Spring => PlungerType.PlungerTypeCustom, + _ => PlungerType.PlungerTypeModern + }, + ZAdjust = zAdjust + }; + } + } +} diff --git a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs index 8b0d254bd..8a1bb4b4c 100644 --- a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs +++ b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs @@ -70,12 +70,23 @@ public PlungerMeshGenerator(PlungerData data) Init(0); } - public Mesh GetMesh(float height, string id) - { - Init(height); - switch (id) { - case Flat: - return BuildFlatMesh(); + public Mesh GetMesh(float height, string id) + { + Init(height); + return BuildMesh(id); + } + + public Mesh GetLocalMesh(string id) + { + Init(0.0f, false); + return BuildMesh(id); + } + + private Mesh BuildMesh(string id) + { + switch (id) { + case Flat: + return BuildFlatMesh(); case Rod: CalculateArraySizes(); return BuildRodMesh(); @@ -116,8 +127,8 @@ public PbrMaterial GetMaterial(Table.Table table) } - private void Init(float height) - { + private void Init(float height, bool includeZAdjust = true) + { var stroke = _data.Stroke; _beginY = 0; _endY = -stroke; @@ -141,10 +152,10 @@ private void Init(float height) // figure the width in relative units (0..1) of each cell _cellWid = 1.0f / _srcCells; - _zHeight = height + _data.ZAdjust; - _zScale = 1f; - _desc = GetPlungerDesc(); - } + _zHeight = height + (includeZAdjust ? _data.ZAdjust : 0.0f); + _zScale = 1f; + _desc = GetPlungerDesc(); + } private PlungerDesc GetPlungerDesc() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs index 45676978e..fd01c8681 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs @@ -14,8 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using FluentAssertions; -using NUnit.Framework; +using FluentAssertions; +using NUnit.Framework; +using UnityEngine; +using VisualPinball.Engine.VPT; +using EnginePlunger = VisualPinball.Engine.VPT.Plunger.Plunger; namespace VisualPinball.Unity.Test { @@ -87,8 +90,8 @@ public void ShouldDisableRetractMotionForAutoPlungerPullBackAndRetract() [Test] public void ShouldGateAnalogPositionToMechanicalPlungers() - { - var staticState = CreateStaticState(); + { + var staticState = CreateStaticState(); staticState.IsMechPlunger = true; var mechanicalMovement = CreateMovement(staticState); mechanicalMovement.AnalogPosition = 0.75f; @@ -103,9 +106,32 @@ public void ShouldGateAnalogPositionToMechanicalPlungers() PlungerVelocityPhysics.UpdateVelocities(ref mechanicalMovement, ref mechanicalVelocity, in staticState); PlungerVelocityPhysics.UpdateVelocities(ref nonMechanicalMovement, ref nonMechanicalVelocity, in nonMechanicalState); - mechanicalMovement.Speed.Should().NotBeApproximately(nonMechanicalMovement.Speed, 0.0001f); - nonMechanicalMovement.Speed.Should().BeApproximately(0.0f, 0.0001f); - } + mechanicalMovement.Speed.Should().NotBeApproximately(nonMechanicalMovement.Speed, 0.0001f); + nonMechanicalMovement.Speed.Should().BeApproximately(0.0f, 0.0001f); + } + + [Test] + public void ShouldBuildColliderInPlungerLocalZ() + { + var go = new GameObject("plunger"); + try { + var comp = go.AddComponent(); + comp.Position = new Vector3(100.0f, 200.0f, 17.0f); + var collComp = go.AddComponent(); + + var collider = new PlungerCollider(comp, collComp, new ColliderInfo { + ItemId = 1, + ItemType = ItemType.Plunger + }); + + collider.LineSegBase.ZLow.Should().Be(0.0f); + collider.LineSegBase.ZHigh.Should().Be(EnginePlunger.PlungerHeight); + collider.Bounds.Aabb.ZLow.Should().Be(0.0f); + collider.Bounds.Aabb.ZHigh.Should().Be(EnginePlunger.PlungerHeight); + } finally { + Object.DestroyImmediate(go); + } + } private static PlungerMovementState FireFrom(float startPos, PlungerStaticState staticState) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs index a25138fb3..a735bb127 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs @@ -63,7 +63,7 @@ public PlungerCollider(PlungerComponent comp, PlungerColliderComponent collComp, { Header.Init(info, ColliderType.Plunger); - var zHeight = comp.Position.z; + const float zHeight = 0.0f; var x = -comp.Width; var x2 = comp.Width; var y = comp.Height; @@ -90,12 +90,12 @@ public PlungerCollider(PlungerComponent comp, PlungerColliderComponent collComp, Bounds = new ColliderBounds(Header.ItemId, Header.Id, new Aabb( x - 0.1f, x2 + 0.1f, - frameTop - 0.1f, - y + 0.1f, - 0, - 50 - )); - } + frameTop - 0.1f, + y + 0.1f, + zHeight, + zHeight + Plunger.PlungerHeight + )); + } #region Transformation diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs index 1d0e51982..7c3c03a0c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerFlatMeshComponent.cs @@ -27,10 +27,10 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mesh/Plunger Flat Mesh")] public class PlungerFlatMeshComponent : PlungerMeshComponent { - protected override Mesh GetMesh(PlungerData data) - => new PlungerMeshGenerator(data) - .GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Flat) - .TransformToWorld(); + protected override Mesh GetMesh(PlungerData data) + => new PlungerMeshGenerator(data) + .GetLocalMesh(PlungerMeshGenerator.Flat) + .TransformToWorld(); protected override PbrMaterial GetMaterial(PlungerData data, Table table) => new PlungerMeshGenerator(data).GetMaterial(table); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs index 02e8439f5..68488b3ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerRodMeshComponent.cs @@ -56,9 +56,9 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac #endregion - protected override Mesh GetMesh(PlungerData data) - => new PlungerMeshGenerator(data) - .GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Rod); + protected override Mesh GetMesh(PlungerData data) + => new PlungerMeshGenerator(data) + .GetLocalMesh(PlungerMeshGenerator.Rod); protected override PbrMaterial GetMaterial(PlungerData data, Table table) => new PlungerMeshGenerator(data).GetMaterial(table); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs index 5bbbc0566..de9056a85 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerSpringMeshComponent.cs @@ -53,8 +53,8 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac #endregion - protected override Mesh GetMesh(PlungerData data) - => new PlungerMeshGenerator(data).GetMesh(MainComponent.Position.z, PlungerMeshGenerator.Spring); + protected override Mesh GetMesh(PlungerData data) + => new PlungerMeshGenerator(data).GetLocalMesh(PlungerMeshGenerator.Spring); protected override PbrMaterial GetMaterial(PlungerData data, Table table) => new PlungerMeshGenerator(data).GetMaterial(table); From fe92ce100c7155d9d2b020411f792f89023aa877 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 15:15:02 +0200 Subject: [PATCH 4/6] plunger: handle kinematic motion --- .../VPT/PlungerPhysicsTests.cs | 25 +++++++++++++++++++ .../VisualPinball.Unity/Game/PhysicsCycle.cs | 12 ++++++--- .../Game/PhysicsStaticCollision.cs | 13 +++++++--- .../VPT/Plunger/PlungerCollider.cs | 10 ++++---- .../VPT/Plunger/PlungerComponent.cs | 9 ++++--- .../VPT/Plunger/PlungerStaticState.cs | 13 +++++----- 6 files changed, 60 insertions(+), 22 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs index fd01c8681..957d608c0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs @@ -16,9 +16,11 @@ using FluentAssertions; using NUnit.Framework; +using Unity.Mathematics; using UnityEngine; using VisualPinball.Engine.VPT; using EnginePlunger = VisualPinball.Engine.VPT.Plunger.Plunger; +using Random = Unity.Mathematics.Random; namespace VisualPinball.Unity.Test { @@ -132,6 +134,29 @@ public void ShouldBuildColliderInPlungerLocalZ() Object.DestroyImmediate(go); } } + + [Test] + public void ShouldNotImpactWhenKinematicSurfaceRecedesFasterThanBall() + { + var staticState = CreateStaticState(); + var movement = CreateMovement(staticState); + var ball = new BallState { + Position = new float3(0.0f, 0.0f, 0.0f), + Velocity = new float3(0.0f, -1.0f, 0.0f), + Radius = 1.0f, + Mass = 1.0f + }; + var collision = new CollisionEventData { + HitNormal = new float3(0.0f, 1.0f, 0.0f), + HitDistance = 0.0f + }; + var random = new Random(1); + + PlungerCollider.Collide(ref ball, ref collision, ref movement, in staticState, + new float3(0.0f, -10.0f, 0.0f), ref random); + + ball.Velocity.y.Should().BeApproximately(-1.0f, 0.0001f); + } private static PlungerMovementState FireFrom(float startPos, PlungerStaticState staticState) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index f5f3c71f7..d5af3697d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -115,9 +115,15 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov using (var enumerator = state.PlungerStates.GetEnumerator()) { while (enumerator.MoveNext()) { ref var plungerState = ref enumerator.Current.Value; - ref var plungerCollider = ref state.Colliders.Plunger(plungerState.Static.ColliderId); - PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider, - in plungerState.Static, hitTime, ref state.EventQueue); + if (plungerState.Static.IsKinematic) { + ref var plungerCollider = ref state.KinematicColliders.Plunger(plungerState.Static.ColliderId); + PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider, + in plungerState.Static, hitTime, ref state.EventQueue); + } else { + ref var plungerCollider = ref state.Colliders.Plunger(plungerState.Static.ColliderId); + PlungerDisplacementPhysics.UpdateDisplacement(enumerator.Current.Key, ref plungerState.Movement, ref plungerCollider, + in plungerState.Static, hitTime, ref state.EventQueue); + } } } // spinners diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 953bef7d4..ae3efb4b6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -133,10 +133,15 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r in ball.CollisionEvent, ref state); break; - case ColliderType.Plunger: - ref var plungerState = ref state.GetPlungerState(colliderId, ref colliders); - PlungerCollider.Collide(ref ball, ref ball.CollisionEvent, ref plungerState.Movement, in plungerState.Static, ref state.Env.Random); - break; + case ColliderType.Plunger: + ref var plungerState = ref state.GetPlungerState(colliderId, ref colliders); + var plungerSurfaceVelocity = state.GetKinematicSurfaceVelocity( + in ball.CollisionEvent, + ball.Position - ball.Radius * ball.CollisionEvent.HitNormal + ); + PlungerCollider.Collide(ref ball, ref ball.CollisionEvent, ref plungerState.Movement, + in plungerState.Static, in plungerSurfaceVelocity, ref state.Env.Random); + break; case ColliderType.Spinner: ref var spinnerState = ref state.GetSpinnerState(colliderId, ref colliders); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs index a735bb127..7aa0ad0cd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs @@ -310,11 +310,11 @@ private static void UpdateCollision(ref CollisionEventData collEvent, #region Collision - public static void Collide(ref BallState ball, ref CollisionEventData collEvent, - ref PlungerMovementState movement, in PlungerStaticState staticState, ref Random random) - { - var dot = (ball.Velocity.x - collEvent.HitVelocity.x) * collEvent.HitNormal.x - + (ball.Velocity.y - collEvent.HitVelocity.y) * collEvent.HitNormal.y; + public static void Collide(ref BallState ball, ref CollisionEventData collEvent, + ref PlungerMovementState movement, in PlungerStaticState staticState, in float3 surfaceVelocity, ref Random random) + { + var hitVelocity = new float3(collEvent.HitVelocity, 0.0f) + surfaceVelocity; + var dot = math.dot(ball.Velocity - hitVelocity, collEvent.HitNormal); // HACK to stop the ball from spinning. ball.AngularMomentum.z *= 0.6f; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs index 251f8f0d5..f6f40e552 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerComponent.cs @@ -280,10 +280,11 @@ internal PlungerState CreateState() var position = frameTop + restPos * frameLen; return new PlungerState( - new PlungerStaticState { - MomentumXfer = collComponent.MomentumXfer, - ScatterVelocity = collComponent.ScatterVelocity, - FrameStart = frameBottom, + new PlungerStaticState { + IsKinematic = collComponent.IsKinematic, + MomentumXfer = collComponent.MomentumXfer, + ScatterVelocity = collComponent.ScatterVelocity, + FrameStart = frameBottom, FrameEnd = frameTop, FrameLen = frameLen, RestPosition = restPos, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs index 195bf81f9..885f11319 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerStaticState.cs @@ -16,12 +16,13 @@ namespace VisualPinball.Unity { - internal struct PlungerStaticState - { - public int ColliderId; - - // collision - public float MomentumXfer; + internal struct PlungerStaticState + { + public int ColliderId; + public bool IsKinematic; + + // collision + public float MomentumXfer; public float ScatterVelocity; // displacement From d49d5cc6890f907c577d1f81016d3dbfc8d5bd51 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 15:18:08 +0200 Subject: [PATCH 5/6] tests(plunger): cover transformed collision assumptions --- .../VPT/PlungerPhysicsTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs index 957d608c0..0ab088681 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/PlungerPhysicsTests.cs @@ -135,6 +135,40 @@ public void ShouldBuildColliderInPlungerLocalZ() } } + [Test] + public void ShouldTreatRotatedAndScaledPlungersAsNonTransformable() + { + PlungerCollider.IsTransformable(float4x4.Translate(new float3(10.0f, 20.0f, 30.0f))).Should().BeTrue(); + PlungerCollider.IsTransformable(float4x4.RotateZ(math.radians(15.0f))).Should().BeFalse(); + PlungerCollider.IsTransformable(float4x4.RotateX(math.radians(15.0f))).Should().BeFalse(); + PlungerCollider.IsTransformable(float4x4.Scale(2.0f)).Should().BeFalse(); + } + + [Test] + public void ShouldRoundTripPlungerTipVelocityThroughRotatedCollisionSpace() + { + var ball = new BallState { + Position = new float3(0.0f, 0.0f, 1.0f), + Velocity = new float3(0.0f, -1.0f, 0.0f), + Radius = 1.0f, + Mass = 1.0f, + CollisionEvent = new CollisionEventData { + HitNormal = new float3(0.0f, 1.0f, 0.0f), + HitVelocity = new float2(0.0f, -12.0f) + } + }; + var rotation = float4x4.RotateX(math.radians(45.0f)); + + ball.Transform(rotation); + ball.Transform(math.inverse(rotation)); + + ball.CollisionEvent.HitNormal.x.Should().BeApproximately(0.0f, 0.0001f); + ball.CollisionEvent.HitNormal.y.Should().BeApproximately(1.0f, 0.0001f); + ball.CollisionEvent.HitNormal.z.Should().BeApproximately(0.0f, 0.0001f); + ball.CollisionEvent.HitVelocity.x.Should().BeApproximately(0.0f, 0.0001f); + ball.CollisionEvent.HitVelocity.y.Should().BeApproximately(-12.0f, 0.0001f); + } + [Test] public void ShouldNotImpactWhenKinematicSurfaceRecedesFasterThanBall() { From 47984345ba74298ae74055fa1a93d8429d69c868 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 14 Jul 2026 15:26:17 +0200 Subject: [PATCH 6/6] plunger: close rod tip mesh --- .../VPT/Plunger/PlungerMeshTests.cs | 51 ++++++--- .../VPT/Plunger/PlungerMeshGenerator.cs | 100 +++++++++++------- 2 files changed, 102 insertions(+), 49 deletions(-) diff --git a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs index ff1b510a3..efd810f7f 100644 --- a/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs +++ b/VisualPinball.Engine.Test/VPT/Plunger/PlungerMeshTests.cs @@ -25,23 +25,35 @@ namespace VisualPinball.Engine.Test.VPT.Plunger { public class PlungerMeshTests { - [Test] - [Explicit("Known failing characterization for Phase 5: the rod tip is currently open.")] - public void ShouldCloseModernRodTip() - { - var mesh = new PlungerMeshGenerator(new PlungerData()).GetMesh(0.0f, PlungerMeshGenerator.Rod); - var maxZ = mesh.Vertices.Max(v => v.Z); + [Test] + public void ShouldCloseModernRodTip() + { + var mesh = new PlungerMeshGenerator(new PlungerData()).GetMesh(0.0f, PlungerMeshGenerator.Rod); + var maxZ = mesh.Vertices.Max(v => v.Z); FindBoundaryEdges(mesh) .Where(e => System.Math.Abs(mesh.Vertices[e.a].Z - maxZ) < 0.0001f && System.Math.Abs(mesh.Vertices[e.b].Z - maxZ) < 0.0001f) - .Should().BeEmpty(); - } - + .Should().BeEmpty(); + } + + [Test] + public void ShouldKeepCustomRodTipCapCoplanarWithOffsetFirstTipPoint() + { + var data = new PlungerData { + Type = PlungerType.PlungerTypeCustom, + TipShape = "5 .34; 10 .5" + }; + + var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Rod); + var maxZ = mesh.Vertices.Max(v => v.Z); + + mesh.Vertices.Count(v => System.Math.Abs(v.Z - maxZ) < 0.0001f).Should().BeGreaterThan(1); + } + [Test] - [Explicit("Known failing characterization for Phase 5: zero-loop springs currently overflow while building indices.")] public void ShouldBuildEmptySpringForZeroLoops() - { - var data = new PlungerData { + { + var data = new PlungerData { Type = PlungerType.PlungerTypeCustom, SpringLoops = 0.0f, SpringEndLoops = 0.0f @@ -53,6 +65,21 @@ public void ShouldBuildEmptySpringForZeroLoops() mesh.Indices.Should().BeEmpty(); } + [Test] + public void ShouldBuildEmptySpringForNegativeLoops() + { + var data = new PlungerData { + Type = PlungerType.PlungerTypeCustom, + SpringLoops = -1.0f, + SpringEndLoops = 0.0f + }; + + var mesh = new PlungerMeshGenerator(data).GetMesh(0.0f, PlungerMeshGenerator.Spring); + + mesh.Vertices.Should().BeEmpty(); + mesh.Indices.Should().BeEmpty(); + } + [TestCase(PlungerMeshGenerator.Rod)] [TestCase(PlungerMeshGenerator.Spring)] [TestCase(PlungerMeshGenerator.Flat)] diff --git a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs index 8a1bb4b4c..2125d0783 100644 --- a/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs +++ b/VisualPinball.Engine/VPT/Plunger/PlungerMeshGenerator.cs @@ -186,7 +186,7 @@ private void CalculateArraySizes() // spirals, where each spiral has 'springLoops' loops // times 'circlePoints' vertices. _latheVts = _lathePoints * _circlePoints; - _springVts = (int)((_springLoops + _springEndLoops) * _circlePoints) * 3; + _springVts = System.Math.Max(0, (int)((_springLoops + _springEndLoops) * _circlePoints) * 3); // For the lathed section, we need two triangles == 6 // indices for every point on every lathe circle past @@ -204,13 +204,13 @@ private void CalculateArraySizes() // of sets. 12*vts/3 = 4*vts. // // The spring only applies to the custom plunger. - _springIndices = 0; - if (_data.Type == PlungerType.PlungerTypeCustom) { - _springIndices = 4 * _springVts - 12; - if (_springVts < 0) { - _springIndices = 0; - } - } + _springIndices = 0; + if (_data.Type == PlungerType.PlungerTypeCustom) { + _springIndices = 4 * _springVts - 12; + if (_springVts <= 3) { + _springIndices = 0; + } + } } /// @@ -308,12 +308,12 @@ public Vertex3DNoTex2[] BuildFlatVertices(int frame) /// cylinder. /// /// - private Mesh BuildRodMesh() - { - var mesh = new Mesh("rod") { - Vertices = BuildRodVertices(0), - Indices = new int[_latheIndices] - }; + private Mesh BuildRodMesh() + { + var mesh = new Mesh("rod") { + Vertices = BuildRodVertices(0), + Indices = new int[_latheIndices + 3 * _circlePoints] + }; // set up the vertex list for the lathe circles var k = 0; @@ -325,13 +325,25 @@ private Mesh BuildRodMesh() mesh.Indices[k++] = (m + offset + 1 + _lathePoints) % _latheVts; mesh.Indices[k++] = (m + offset + 1) % _latheVts; - mesh.Indices[k++] = (m + offset) % _latheVts; - } - } - - mesh.AnimationFrames = new List(1); - mesh.AnimationDefaultPosition = DefaultPosition; - var vertices = BuildRodVertices(NumFrames); + mesh.Indices[k++] = (m + offset) % _latheVts; + } + } + + // Close the front tip. The side faces connect the lathe rings but leave + // the first ring open, so add a center vertex and fan triangles. + var tipCenter = _latheVts; + for (var l = 0; l < _circlePoints; l++) { + var current = l * _lathePoints; + var next = ((l + 1) % _circlePoints) * _lathePoints; + + mesh.Indices[k++] = tipCenter; + mesh.Indices[k++] = next; + mesh.Indices[k++] = current; + } + + mesh.AnimationFrames = new List(1); + mesh.AnimationDefaultPosition = DefaultPosition; + var vertices = BuildRodVertices(NumFrames); mesh.AnimationFrames.Add(vertices.Select(v => new Mesh.VertData(v.X, v.Y, v.Z, v.Nx, v.Ny, v.Nz)).ToArray()); return mesh; @@ -339,11 +351,11 @@ private Mesh BuildRodMesh() public Vertex3DNoTex2[] BuildRodVertices(int frame) { - if (_lathePoints == 0) { - CalculateArraySizes(); - } - var vertices = new Vertex3DNoTex2[_latheVts]; - var yTip = _beginY + _dyPerFrame * frame; + if (_lathePoints == 0) { + CalculateArraySizes(); + } + var vertices = new Vertex3DNoTex2[_latheVts + 1]; + var yTip = _beginY + _dyPerFrame * frame; var tu = 0.51f; var stepU = 1.0f / _circlePoints; @@ -396,11 +408,22 @@ public Vertex3DNoTex2[] BuildRodVertices(int frame) Tu = tu, Tv = tv }; - } - } - - return vertices; - } + } + } + + vertices[i] = new Vertex3DNoTex2 { + X = 0.0f, + Y = ((_data.Width + _zHeight) * _zScale) * ScaleInv, + Z = -(_desc.c[0].y + yTip) * ScaleInv, + Nx = 0.0f, + Ny = 0.0f, + Nz = 1.0f, + Tu = 0.5f, + Tv = _desc.c[0].tv + }; + + return vertices; + } /// /// Build the spring. @@ -487,12 +510,15 @@ private Mesh BuildSpringMesh() public Vertex3DNoTex2[] BuildSpringVertices(int frame) { - if (_lathePoints == 0) { - CalculateArraySizes(); - } - var vertices = new Vertex3DNoTex2[_springVts]; - - var springGaugeRel = _springGauge / _data.Width; + if (_lathePoints == 0) { + CalculateArraySizes(); + } + var vertices = new Vertex3DNoTex2[_springVts]; + if (_springVts == 0) { + return vertices; + } + + var springGaugeRel = _springGauge / _data.Width; var yTip = _beginY + _dyPerFrame * frame; ref var c = ref _desc.c[_lathePoints - 2];