diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b6fe344..7f6210389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ Built with Unity 6.5 - Spring-hinge bash toys with a finite-inertia analytic box collider, reciprocal Spatial magnet holds, packaged player reconstruction, editor setup/handles, and a Play Mode qualification sample. - Motion Transform input ranges for animating a follower during only part of its source travel. -- Actuator Transform input ranges for animating a follower during only part of its source travel. - Wire Rail component with native spline authoring, fixtures (rings, rungs, cradles, stands, hairpins, and elbows), and an inferred ball-channel collider ([Documentation](https://docs.visualpinball.org/creators-guide/editor/wire-rails/index.html)). - Make packaging functional ([#557](https://github.com/freezy/VisualPinball.Engine/pull/557)) - New threading model ([#552](https://github.com/freezy/VisualPinball.Engine/pull/552)) diff --git a/VisualPinball.Engine/Game/EventId.cs b/VisualPinball.Engine/Game/EventId.cs index 8fb9089c3..33020e1ad 100644 --- a/VisualPinball.Engine/Game/EventId.cs +++ b/VisualPinball.Engine/Game/EventId.cs @@ -42,5 +42,8 @@ public enum EventId MagnetEventsBallExited = 1501, MagnetEventsBallGrabbed = 1502, MagnetEventsBallReleased = 1503, + + // Physics diagnostics + PhysicsDiagnosticsUnsupportedOwnedInteraction = 1600, } } diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md new file mode 100644 index 000000000..03d819ad7 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -0,0 +1,119 @@ +--- +uid: spring_hinges +title: Spring Hinge Bash Toys +description: Author a spring-returning toy with reciprocal ball impact and an optional moving magnet. +--- + +# Spring Hinge Bash Toys + +A Spring Hinge simulates a rigid toy rotating about one fixed axis. Balls push its analytic box with finite inertia, and its spring and damping return it toward an equilibrium angle. An optional child Spatial magnet can attract and carry one ball while applying the equal reaction torque to the toy. + +## Create a Toy + +For an existing model, make the rotating object's local origin the physical pivot, select that object, and choose **GameObject > Pinball > Add Spring Hinge**. VPE adds Spring Hinge and Spring Hinge Collider to the selected object and disables its independent colliders. Other selected moving parts are parented below the active object while preserving their world poses. Keep fixed brackets outside this hierarchy, then use the scene handles to set the axis, centre of mass, analytic box, and travel limits. + +Choose **GameObject > Pinball > Spring Hinge Bash Toy** to create a complete example object with a cube visual, analytic box, and owned magnet. The bash preset starts at 0 degrees and stops at 20 degrees with a low-elasticity box. + +Spring Hinge Collider also applies the physics angle directly to the rotating object around its local origin. It uses the Hinge Axis from Spring Hinge, caches the authored local rotation when play starts, and treats that pose as zero degrees. Do not animate or move the object from another behavior during play. + +## Understand the Angles + +Angles are signed numbers around **Hinge Axis**. Point your right thumb in the direction of the axis: the direction your fingers curl is positive. A ball can therefore produce either a positive or negative angle depending on the axis direction, where it touches the toy, and which way it is moving. The words minimum and maximum only mean the lower and higher numbers; they do not mean hit position and return position. + +For a one-sided bash toy, make zero one of the stops. If the hit produces a negative angle, use a range such as **Minimum Angle = -19** and **Maximum Angle = 0**. If you prefer the hit to display as a positive angle, reverse the Hinge Axis and use **Minimum Angle = 0** and **Maximum Angle = 19**. A range of -19 to 19 lets the spring pass through zero and swing to the other side. + +## Spring Hinge Inspector + +### Pivot and Mass + +| Setting | What it does | +| --- | --- | +| **Hinge Axis** | Sets the line around which the toy rotates, using the GameObject's local X, Y, and Z directions. The local origin is always the pivot point. Reverse all three values to reverse which physical movement is called positive without changing the pivot line. | +| **Centre Of Mass** | Places the toy's balance point relative to the pivot, in VPX units. Its position controls how gravity pulls on the toy. Moving it farther from the pivot generally gives gravity more turning power. The yellow Scene view handle can be used to place it visually. | +| **Toy Mass (ball-relative)** | Sets the unloaded toy's weight relative to a standard ball. A value of 1 means the toy weighs as much as one standard ball. With automatic inertia, a heavier toy reacts more slowly to the same hit. When **Override Inertia** is enabled, this value still affects gravity, while **Manual Inertia** controls resistance to impacts and turning. A captured ball adds its own weight and resistance automatically. | +| **Override Inertia** | Chooses how VPE calculates the toy's resistance to being rotated. Leave it disabled for an estimate based on Toy Mass, Centre Of Mass, and Mass Box Half Extents. Enable it when you want to tune that resistance directly. | +| **Manual Inertia** | Appears when **Override Inertia** is enabled. Higher values make the toy harder to start and stop, so the same ball hit moves it less and it swings more slowly. Lower values make it react more quickly. This is a tuning value rather than a weight in kilograms. | +| **Mass Box Half Extents** | Appears when **Override Inertia** is disabled. Describes half the width, height, and depth of a simple box used only to estimate how the toy's mass is spread around the pivot. For example, X = 25 means a total width of 50 VPX units. This box does not collide with the ball. | + +### Spring and Stops + +| Setting | What it does | +| --- | --- | +| **Spring Stiffness** | Controls how strongly the spring pulls the toy toward Equilibrium Angle. A higher value returns the toy harder and usually faster. A lower value feels softer and allows the ball or captured weight to push it farther. | +| **Spring Damping** | Removes swinging over time. A higher value settles the toy sooner but can make it feel sluggish. A lower value allows it to swing back and forth for longer. Increase this if the toy keeps oscillating too much. | +| **Equilibrium Angle** | Sets the angle toward which the spring pulls. Gravity and a captured ball can make the final settled angle differ from this value. The value may be outside the allowed range when the spring should keep the toy pressed against a stop. | +| **Minimum Angle** | Sets the lower hard stop. The toy cannot rotate to a smaller signed angle. This is a number limit, not automatically the hit side. | +| **Maximum Angle** | Sets the upper hard stop. The toy cannot rotate to a larger signed angle. This is a number limit, not automatically the return side. | +| **Initial Angle** | Sets the toy's angle when play starts. Zero uses the local rotation authored in the Unity scene. The value must lie between Minimum Angle and Maximum Angle. | + +### Angle Switch + +| Setting | What it does | +| --- | --- | +| **Enable Angle Switch** | Adds a switch that game logic can use to detect that the toy has moved far enough in the positive-angle direction. Leave it disabled when the table does not need an angle switch. | +| **Switch Close Angle** | Closes the switch when the toy reaches or rises above this angle. For a bash toy that moves into negative angles, reverse Hinge Axis so the hit direction is positive before using this switch. | +| **Switch Open Angle** | Opens the switch again when the toy returns to or below this angle. Set it lower than Switch Close Angle so tiny movements near the threshold do not rapidly turn the switch on and off. | + +### Setup Buttons + +| Button | What it does | +| --- | --- | +| **Add Analytic Box Proxy** | Appears when the GameObject has no Spring Hinge Collider and adds one. The toy needs this collider for balls to hit it and for its visible transform to follow the simulated angle. | +| **Fit From Renderers** | Fits Centre Of Mass, Mass Box Half Extents, and the collision box around the object's renderers and their children. Treat the result as a starting point: the renderer bounds cannot tell whether a model is hollow or where its real weight is concentrated. | +| **Apply Bash Preset** | Replaces the current hinge and collider settings with useful starting values for a simple one-sided bash toy. If the toy contains an owned magnet, the button also applies the recommended magnet settings. Review the fitted size, axis, travel direction, and magnet position afterward. | + +## Spring Hinge Collider Inspector + +### Analytic Box + +| Setting | What it does | +| --- | --- | +| **Local Centre** | Positions the middle of the collision box relative to the hinge pivot, in VPX units along the GameObject's local axes. Move this until the box covers the part of the model that the ball can hit. | +| **Local Rotation** | Rotates the collision box relative to the GameObject, in degrees. Use it when the hittable face is tilted or does not line up with the object's local axes. | +| **Half Extents** | Sets half the collision box's width, height, and depth in VPX units. The full size is twice these values. Keep all three values above zero. | +| **Show Collider** | Draws the collision box in green in the Scene view. In Play Mode it follows the current simulated angle, making it useful for checking that the visible toy and collision box move together. It has no effect on physics. | +| **Hit Event** | Allows the collider to send a Hit event to game logic when a ball strikes it. Turning this off does not stop the physical collision. | +| **Hit Threshold** | Sets the minimum impact speed required to send a Hit event. A value of zero reports every new impact. Raising it filters out gentle touches and resting contact. It does not change how the ball or toy moves. | + +### Physics Material + +| Setting | What it does | +| --- | --- | +| **Preset** | Uses the bounce and friction values from a Physics Material asset. It is available when **Overwrite Physics** is disabled. Use a preset when several objects should share the same surface behavior. | +| **Overwrite Physics** | When enabled, the Elasticity, Elasticity Falloff, and Friction values below are used. When disabled, the selected Preset supplies those values. | +| **Elasticity** | Controls how much relative speed is returned after a collision. Zero gives almost no extra bounce; higher values make the ball and toy separate more sharply. A low value usually suits a heavy bash toy. | +| **Elasticity Falloff** | Reduces bounce for faster impacts. Zero keeps the same elasticity at every speed. Higher values make fast shots less bouncy while leaving slow contacts closer to the Elasticity setting. | +| **Friction** | Controls how strongly the surface grips a ball sliding across it. Higher values remove more sideways sliding and can transfer more sideways turning force to the toy. Lower values let the ball slide more freely. | + +The collider also drives the visible rotation. It takes the angle and axis from Spring Hinge and applies them on top of the GameObject's authored local rotation. There is no separate transform or axis setting on this component. + +## Scene View Guides + +The cyan line and arrow show the hinge axis. The orange arc shows the allowed angle range. The yellow sphere shows Centre Of Mass. When Spring Hinge Collider is selected, the cyan wire box is the editable collision box and the two orange wire boxes show where that box will be at Minimum Angle and Maximum Angle. Enable **Show Collider** to draw the current box in green. + +Version one supports one box attached to the hinge. Remove or disable Unity mesh and static colliders on the moving visual so the ball cannot hit two collision shapes for the same toy. + +The box is tested continuously against the ball, including its faces, edges, corners, and motion while the toy returns. The current implementation is tested for ordinary pinball shot speeds and moderately moving toys. Very small boxes, extremely stiff springs, or unusually fast mechanisms need additional play testing; see the developer qualification document for the measured limits. + +## Couple a Magnet + +Create a child GameObject below the rotating object, position it at the physical magnet pole in the toy, and add a Magnet component. Here, "below" means a descendant in the Unity hierarchy; the magnet may physically sit anywhere in the toy, such as Mechagodzilla's belly. Select **Spatial**, then enable **Couple To Parent Hinge**. Spatial magnets always use the Physical response, so there is no separate response setting to configure. The child inherits the toy's rotation and resolves the Spring Hinge from its parent. Only one owned magnet and one attached ball are supported per hinge; the inspector rejects unsupported types or duplicates. + +The magnet transform is the moving pole. **Held Ball Centre Offset** is a separate target in VPX units along the magnet's local axes at the authored rest pose. These distances ignore GameObject and parent scale, just like the magnet's radius and other dimensions, so scaling the visual model does not move the hold point. The green Scene view sphere shows the size and position of a standard held ball. + +Click **Fit Hold Point to Collider** to place a standard 25-unit-radius ball against the nearest face, edge, or corner of the Spring Hinge Collider. The inspector warns when the target would put that ball inside the collider or leave a gap, because either placement prevents capture. Adjust the offset manually after fitting when the table uses a different ball radius. + +**Hold Stiffness** and **Hold Damping** control attachment compliance. **Max Hold Force** is the current-dependent capacity. These settings are independent of Influence Radius. The inspector estimates the fastest standard ball the magnet can capture at full power while the toy is stationary. This is a starting point rather than a guarantee because coil rise time and toy motion also affect a real hit. Increase **Strength** when the ball reaches the hold point but bounces away without being captured; increase **Max Hold Force** when it captures and then immediately breaks free. The bash preset supplies values intended for ordinary pinball shot speeds. A captured ball's existing spin slows smoothly while it remains held. Turning the coil off honors coil decay before release. Release preserves the remaining ball and hinge velocity and ball spin. + +## Validate in Play Mode + +Test both magnet-off impacts and magnet-on capture, a timed release in each travel direction, a second-ball strike, and balls resting on passive playfield geometry. + +## First-release Limits + +- The pivot frame is fixed and must have nonzero orthogonal axes. Moving bases, shear, nested hinges, hinge-to-hinge contact, motors, and flexible toys are not supported. +- Collision uses one box proxy. Triangle, compound, and arbitrary mesh proxies are not supported. +- One Spatial magnet may own one ball. Other balls remain free and can strike the toy or held ball. +- Playfield and passive-surface support are qualified by the current sequential solver. Simultaneous squeezed contacts are an approximation and can retain a one-tick support lag. +- If an attached ball reaches a flipper, plunger, kicker, bumper, slingshot, or turntable, VPE releases it before the existing active mechanism runs and emits a rate-limited diagnostic. Place the held-ball sweep away from those mechanisms. +- Runtime save states and an isolated editor physics preview are not included. Packaged tables preserve authored values and hierarchy, not captured-ball IDs or warm solver state. diff --git a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml index a1ec6bc35..c8c25eb0e 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/creators-guide/toc.yml @@ -118,8 +118,10 @@ href: manual/mechanisms/plungers.md - name: Slingshots href: manual/mechanisms/slingshots.md - - name: Magnets and Turntables - href: manual/mechanisms/magnets.md + - name: Magnets and Turntables + href: manual/mechanisms/magnets.md + - name: Spring Hinge Bash Toys + href: manual/mechanisms/spring-hinges.md - name: Light Groups href: manual/mechanisms/light-groups.md - name: Teleporters diff --git a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md new file mode 100644 index 000000000..ddbff3d76 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -0,0 +1,322 @@ +--- +uid: developer-guide-spring-hinge-magnet-implementation-plan +title: Spring Hinge and Magnetic Bash Toy Implementation Plan +description: Proposed implementation of a ball-driven spring hinge and a magnet that transmits a captured ball's load to the hinge. +--- + +# Spring hinge and magnetic bash toy implementation plan + +Status: implementation in progress, revised after Fable 5.1 review on 2026-09-07. Phase 0 adds the numerical contract fixtures; runtime and editor integration follow in phases 1–7. Code baseline: `VisualPinball.Engine` commit `2a830b504853e0d83b39795129ed884545d5cbff`. See section 14 for the review and disposition of its findings. + +## 1. Behavior and first-release boundaries + +A ball hits a toy and rotates it backward around a fixed pivot against a return spring, typically through 0–20 degrees. The ball and toy exchange momentum at contact. With suitable inertia and low surface elasticity, the ball continues forward while pushing the toy; the spring progressively resists the displacement. A weak shot produces a smaller deflection, while a strong shot can reach the mechanical stop. Contact can separate naturally. The toy returns under spring torque, gravity, and damping. + +An optional energized magnet on the toy can capture the ball at the impact region. The captured ball remains a live ball with mass, gravity, spin, and collisions. Its load affects hinge acceleration and the oscillation period. Switching off the magnet releases the ball with its existing velocity. There is no animation-derived slowdown, no parenting/freezing of the ball, and no manual addition of its mass to the hinge. + +Implement this as another specialized dynamic mechanism in VPE's existing physics loop, using the flipper's scheduling and reciprocal-contact architecture as the precedent. Do not introduce a second general contact solver, a new collider store, a global collider-reference migration, or Unity Rigidbody/HingeJoint simulation. + +Version one supports: + +- A fixed hinge base, an arbitrary fixed axis in playfield space, and one rotational degree of freedom. +- Arbitrary visual meshes moving with one authored closed box collision proxy. The proxy may be thin, offset, and rotated relative to the hinge. It represents the ball-contact region, not necessarily the complete visual silhouette. +- Several independent hinges on a table, at most one owned Spatial magnet per hinge, and at most one magnetically attached ball per owned magnet. Other balls can strike the toy or the held ball. +- Contact with the playfield and passive surfaces, magnetic capture/release, angular limits, threading, table packaging, and straightforward editor setup. +- Existing magnet coil, Hit, and Ball Held device semantics, with explicit ownership and lifecycle rules. + +Deferred work: triangle-mesh or compound proxies, multiple held balls on one hinge, nested joints, moving hinge bases, hinge-to-hinge geometry contact, motors, flexible toys, and an isolated editor physics-preview world. Existing hit targets and unowned magnets retain their behavior. Holding a ball while it contacts an active legacy mechanism such as a flipper, plunger, kicker, bumper/slingshot, or turntable is outside the first-release qualification envelope; section 10 defines validation and runtime handling. Free balls still interact with existing table items normally. + +These restrictions reduce the first implementation to the requested toy-and-magnet behavior. The inspector must show them explicitly; it must not offer unsupported mesh or arbitrary owned-magnet modes. + +## 2. Current implementation and exact integration seams + +All source paths in this document are relative to the `VisualPinball.Engine/` repository. Abbreviations: `R` = `VisualPinball.Unity/VisualPinball.Unity`, `E` = `VisualPinball.Unity/VisualPinball.Unity.Editor`, `T` = `VisualPinball.Unity/VisualPinball.Unity.Test`. + +| Existing source | Observed behavior | Required work | +| --- | --- | --- | +| `R/VPT/HitTarget/TargetCollider.cs`, `HitTargetAnimation.cs` | Wall response precedes a fixed-angle animation. | Leave this behavior intact; the hinge is an independent mechanism. | +| `R/VPT/Flipper/FlipperCollider.cs`, `FlipperMovementState.cs`, `FlipperVelocityPhysics.cs` | Finite rotational response, relative contact velocities, reciprocal impact/contact impulses, and stop timing. | Reuse the scheduling and mechanical principles without importing flipper shape/solenoid/live-catch assumptions. | +| `R/Game/PhysicsUpdate.cs:118` | Once-per-tick velocity updates, followed by magnets and then `PhysicsCycle.Simulate`. | Add hinge velocity preparation and an owner-aware magnet pass on this same tick. | +| `R/Game/PhysicsCycle.cs:99`, `ApplyFlipperTime` | Angular displacement per accepted substep and step shortening at stops. | Add hinge displacement and hinge-stop time selection. | +| `R/Physics/Collision/ColliderType.cs`, `R/Physics/NativeColliders.cs`, `R/Physics/Collider/ColliderReference.cs` | Per-shape buffers, lookup switches, static/kinematic stores. | Add `SpringHinge` as a specialized collider type in the existing static store, including allocation/copy/disposal/debug dispatch. | +| `R/Game/PhysicsState.cs:391`, `R/Game/PhysicsStaticCollision.cs`, `R/Physics/Collision/ContactPhysics.cs:78` | Shape hit-test, collision, and contact dispatch. | Add dedicated hinge hit-test/impact/contact branches; never run its wall/target handler as well. | +| `R/Game/PhysicsEngine.cs:750`, `PhysicsEngineContext.cs:295`, `PhysicsState` constructor | Component registration and native state construction. | Register a hinge state map and owner lookup; add accessors and every `CreateState`/constructor/disposal argument. | +| `R/VPT/Magnet/MagnetPhysics.cs:322`, `MagnetState.cs` | Physical hold is a capped acceleration spring; it currently affects only the ball. Owner/local pose is absent. | Add owner-local state and a reciprocal finite-inertia hold; preserve the unowned paths. | +| `R/Game/PhysicsEngineThreading.cs` constructor, `SnapshotAnimations`, `ApplyMovementsFromSnapshot`; `R/Game/PhysicsMovements.cs` | Snapshot ID arrays, float animation channels, direct snapshot application, synchronous movement path. | Add hinge IDs/counts/output/emitters to both paths. Current rendering does not interpolate snapshots. | +| `R/Simulation/SimulationState.cs` | Fixed snapshot capacities and native arrays. | Include hinges in source-count/overflow checks and guarantee matching ball/hinge publication time. | +| `R/VPT/Magnet/MagnetPackable.cs`, `R/Packaging/RuntimePackageReader.cs` | Magnet version 3; component values and hierarchy restore before reference resolution. | Version new owned settings; add hinge/proxy packaging and hierarchy-based owner resolution after unpack. | +| `T/Physics/MagnetPhysicsTests.cs`, `PhysicsRegressionTests.cs` | Existing magnet, contact, transform, and state regression coverage. | Add independent numerical fixtures and targeted regressions. | + +Line numbers are orientation aids for this baseline, not permanent API contracts. All new state is unmanaged and Burst-compatible. Keep runtime, editor, and test responsibilities in their existing layers. + +## 3. Physical and ownership invariants + +1. The simulation owns hinge angle, angular velocity, proxy pose, and magnet target. Render transforms are outputs, never the source of inferred hinge velocity. +2. The toy's authored mass/inertia includes its physical magnet hardware but excludes any captured balls. Captured balls remain in `BallState`; do not add their inertia or gravity to the toy a second time. +3. Impact/contact forces and magnetic attraction/hold reactions act on both ball and toy. Reaction torque is projected onto the allowed axis; the fixed bearing carries the constrained force/torque components. +4. Contact normal response is unilateral. The magnet is the only source of tensile attachment force. Surface elasticity and return-spring torque are separate properties. +5. Every force is integrated once per outer tick. Every collision is resolved at its accepted hit time. Per-substep contact support integrates only over the accepted substep. There is no trial-state replay and no warm-start impulse applied once per solver iteration. +6. An attachment's force magnitude is bounded by current-dependent holding capacity. Damping is relative to owner motion and must have a reciprocal reaction. No ball-only spin multiplication in the owned hold path. +7. Capture/release preserves momentum through impulses and existing velocities. Release clears ownership, not velocity; removal of a ball does not instantly speed up the toy. +8. Spring stiffness and physical damping stay unchanged on capture. A UI damping ratio is converted using the unloaded toy once, not recalculated from loaded inertia. +9. Distinguish mechanical owner IDs, collider IDs, and gameplay item IDs. A child magnet keeps its own coil/switch identity while transmitting load to its parent hinge. +10. Numerical iterations and collision feature changes cannot fire repeated hit/capture/release events. Reset, disable, and destruction clear actual runtime IDs and cached state. + +## 4. Authoring contract + +Add `SpringHingeComponent` and `SpringHingeColliderComponent`, with `SpringHingeApi`, state structs, and versioned packables. The setup action **Add Spring Hinge** creates a dedicated pivot root when needed, preserves selected objects' world poses with Undo, and moves only the chosen visual parts under it. Fixed brackets remain outside. The collider component authors the single physical box; selected visuals do not keep overlapping independent static colliders. + +| Property | Authoring and storage policy | +| --- | --- | +| Pivot / axis | Position and axis scene handles independent of imported mesh origin. Store a local reference frame; runtime axis is normalized. | +| Rest / minimum / maximum angle | Degrees in the inspector, radians at runtime. Bash preset starts at 0 degrees with a 20-degree upper stop. | +| Toy mass | Multiples of VPE's standard ball mass, which defaults to 1. Label it as ball-relative mass, not kilograms. | +| Centre of mass / inertia | Editable centre marker and an inertia estimate from an independent box mass proxy. Include the parallel-axis term about the actual hinge. Manual inertia override for hollow/unusual toys. | +| Spring stiffness | Torsional stiffness with a clear unit label and presets; linear spring first. | +| Damping | Physical damping coefficient; optional ratio authoring converted from unloaded inertia. | +| Preload | Optional spring equilibrium beyond the lower stop, represented by one canonical equilibrium angle. Do not expose two independently additive preload settings. | +| Stops | Zero stop restitution in version one. Bound angle and outward speed; returning motion is allowed immediately. | +| Collision box | Local centre, orientation, and half-extents. Scene handles show current and full-travel poses. The collision box and mass proxy may differ. | +| Material | Existing elasticity/falloff/friction settings. Low elasticity in the bash preset. | +| Switch | Optional angle switch with different close/open thresholds; optional impact pulse. | + +Bake scale into proxy dimensions and centre-of-mass coordinates before simulation; use a rigid hinge frame afterward. The initial release rejects sheared/invalid transforms, runtime base motion, nonpositive inertia, inverted limits, duplicate drivers, nested hinges, and simultaneous hit-target animation. Editing mass, pivot, scale, or mass proxy recomputes estimated inertia. A uniform cube with known dimensions is the calibration fixture; arbitrary render-mesh volume integration is not required. + +Add the existing magnet as a child of the hinge and select Spatial physical behavior. Enable a new explicit **Couple to parent hinge** setting; the setup action sets it automatically. Resolve the nearest ancestor `SpringHingeComponent`, display the owner, and reject ambiguity. Store a local pole point and a distinct **held ball centre** offset. Place the latter just outside the intended collision face for the standard ball radius; radius-dependent adjustment must be included if a different ball is used. A target inside the box is invalid. Spatial attraction remains a radial point-field approximation, not a new magnetic surface-field model. + +The coupled magnet exposes current-dependent holding capacity, hold stiffness/compliance, and relative damping separately from the influence radius. Derive sensible defaults from existing magnet strength, but changing influence range must not silently change attachment rigidity. Keep coil mapping, rise/fall time, capture region, and Ball Held switch familiar. Version one rejects owned Playfield/Cylindrical modes; the current cylindrical field is upright and does not become an arbitrarily rotating surface by parenting it. + +Initial testing uses automated Play Mode fixtures that run through the real Player and PhysicsEngine with hardware output disabled. Scene gizmos provide the authoring preview; table-specific interactive testing uses the table's normal ball launch and game controls. + +## 5. State and collider integration + +| New/extended data | Contents | +| --- | --- | +| `SpringHingeStaticState` | Owner ID; fixed pivot and orthonormal reference frame; unloaded mass/centre of mass/inertia; spring equilibrium/stiffness/damping; limits; proxy parameters; full-travel bound and maximum point radius. | +| `SpringHingeMovementState` | Angle, angular velocity, tick-start angular velocity/angle error/gravity torque, committed hold reaction for the tick, continuous acceleration, current stop/blocked-torque state, and switch/hit event state. Angular velocity is canonical and angular momentum is derived as `I*omega`. | +| `SpringHingeCollider` | Collider header, hinge owner ID, immutable box-in-hinge frame and dimensions, full-travel bounds, distance/hit-test helpers. | +| Extended `MagnetState` | Optional runtime hinge owner ID, local pole/hold frame, explicit owned mode, hold stiffness/damping/force capacity, attached ball ID/generation, and capture/release hysteresis. | +| Hinge owner registry | Stable component/parent resolution built during initialization, separate from main-thread kinematic transform tracking. | + +Place `ColliderType.SpringHinge` in the existing static collider store. Here static storage means the broadphase bound is fixed, not that the response has infinite inertia, exactly as for the flipper. The specialized collider gets its current angle from the hinge state during hit testing. A dedicated generated collider header routes directly to hinge physics; it must not retain `ItemType.HitTarget` and invoke `TargetCollider` first. Assign event identity to the hinge API; the child magnet retains its own identity. + +Extend all type-specific storage paths in `NativeColliders` and `ColliderReference`, including buffer allocation/copy/disposal, lookup construction, header/bounds access, transformation/debug enumeration, and count reporting. Add the new branches in `PhysicsState.HitTest`, `PhysicsStaticCollision`, `ContactPhysics.Update`, and generic collider-bound access. Keep `CollisionEventData.ColliderId` and `IsKinematic` unchanged. Bounds remain valid for the full allowed rotation. + +Initialization is two-pass: discover/register hinge and magnet components, then resolve owners and bake frames/proxies. Resolve from the same restored hierarchy in authoring and packaged player tables; component `Awake` order must not be assumed. Use owner state accessors rather than finding transforms from a Burst hot path. Add native maps and any scratch space to `PhysicsEngineContext.CreateState`, the `PhysicsState` constructor, and disposal. Preallocate for the validated number of hinges; do not allocate during ticks. + +## 6. Units, inertia, and gravity + +The actual constants are in `VisualPinball.Engine/Common/Constants.cs`: `PhysicsStepTime = 1000` microseconds, `DefaultStepTime = 10000` microseconds, `DefaultStepTimeS = 0.01`, and `PhysFactor = 0.1`. One outer tick is 1 ms, represented as `h = 0.1` normalized time units. Runtime linear velocity is VPU per normalized time unit; angular velocity is radians per normalized time unit. + +Use ball-relative mass throughout. Runtime inertia has units `ball-mass * VPU²`, stiffness has `ball-mass * VPU² / normalized-time² / rad`, and damping has `ball-mass * VPU² / normalized-time / rad`. If stiffness and damping are authored per second with positions already in VPU, multiply stiffness by `T²` and damping by `T`, where `T = 0.01 s`. Convert degrees once to radians. Use `Physics.WorldToVpx`/`VpxToWorld` for scene geometry. `PhysicsConstants.MToVpu` and the matrix's rounded scale differ slightly; use the existing geometry conversion for geometry and the existing `Ms2ToVpuVpt2` for cabinet acceleration, with tests rather than another invented conversion constant. + +Let `a` be the unit axis, `p` the fixed pivot, `x_cm` the toy centre of mass in its current pose, `theta` the angle, and `omega` the angular speed: + +```text +surfaceVelocity(x) = omega * cross(a, x - p) +gravityTorque = dot(a, cross(x_cm - p, toyMass * effectiveGravity)) +springTorque = -k * (theta - equilibriumAngle) - c * omega +I * angularAcceleration = gravityTorque + springTorque + contactTorque + magneticTorque +``` + +`effectiveGravity` is table gravity plus the same cabinet inertial acceleration/sign used by `BallVelocityPhysics`. Apply toy gravity once and ball gravity once. A captured ball's weight reaches the hinge through its actual hold/contact reaction; a ball also resting on the playfield does not transfer its full weight to the hinge. + +A tightly held point-mass ball at perpendicular distance `r` gives approximately `I_loaded = I_toy + m_ball * r²`. This is an analytic check on the coupled behavior, not a value written into hinge state. Ball spin is free; locking orientation would be a different model. With weak damping and negligible gravity torque, the period is approximately `2*pi*sqrt(I_loaded/k)`. Gravity can change the equilibrium and restoring stiffness, so added weight does not universally make every pendulum slower. + +## 7. Fixed scheduler contract + +Retain VPE's existing kick-then-drift collision timeline. All continuous velocity preparation happens once per outer tick; all pose displacement and impact/contact response happens inside the existing accepted-hit-time loop. There is no per-ball migration between timelines and no replay of full-tick forces on shorter collision substeps. + +The outer-tick order is: + +1. Apply queued commands, cabinet input, and existing prescribed kinematics. Reset ball external acceleration and run the existing ball velocity preparation exactly once. +2. Compute and save each hinge's tick-start `omegaOld`, angle error, gravity/cabinet torque, and `h = physicsDiffTime`. A hinge without an owned magnet commits its free implicit step here: `omegaFree = (I*omegaOld + h*tauGravity - h*k*(theta-equilibriumAngle)) / (I + h*c + h²*k)`. A hinge with an owned magnet does not commit a free step yet, including when it has no attached ball at tick start. +3. Advance every magnet's coil current once. Preserve the unowned magnet update behavior and its existing ownership first; then determine owned-magnet capture/release eligibility. Evaluate owned free-field contributions from tick-start poses into preallocated ball-impulse and owner-angular-impulse buffers before committing owned hinge velocities. The magnet that captures a ball applies no separate attraction to that ball on this tick: its hold block replaces that contribution. Commit each owned hinge exactly once: the free implicit step including other field reactions if no ball is attached after eligibility, or the local hold block if a ball is attached. Other magnets' forces on that ball are included in `vPre`; reactions from attracting other balls are included in `QOther` in section 9. This also handles a newly captured or newly released ball without replaying spring/gravity integration. +4. Run the existing `PhysicsCycle.Simulate` for the tick. Add hinge stop times to the candidate hit-time minimum, add hinge hit tests, and displace hinge angle alongside balls/flippers before resolving the accepted collision. After an impulse changes motion, recompute continuous spring/damping/gravity torque at the current pose/speed without integrating it again; keep the committed magnetic reaction torque fixed for the remainder of the tick. Never reinterpret the impact's instantaneous velocity jump as a continuous `deltaOmega/h` torque. +5. Process sustained contacts through the existing contact pass, with a specialized hinge branch. Continue for remaining substep time. Fire committed events once and publish matching ball/hinge snapshots. + +Within a collision substep, trajectories are the velocities already prepared at the tick boundary plus preceding collision/contact impulses. Continuous TOI prediction uses those piecewise-constant velocities and `theta(t) = theta0 + omega*t`, bounded by stop arrival. Do not include another force-driven acceleration trajectory in narrowphase that actual displacement does not follow. Spring/hold forces are recalculated next outer tick. This is an intentional first-order splitting approximation matching the existing flipper contract. Ordinary magnetic capture/release decisions occur at tick boundaries; mid-tick collisions affect the next hold solve up to 1 ms later. A future stop truncates motion predicted by the free implicit spring step without replaying that step. Lifecycle and unsupported-interaction releases remain immediate exceptions. Phase-0 fixtures qualify `holdFrequency*h <= 0.2`; combined with the initial ten-to-one hold/hinge frequency ratio this requires a loaded hinge period of at least 0.314 s at the 1 ms tick. Implicit Euler contributes an approximate numerical damping ratio `h*hingeFrequency/2` (about 0.01 in the reference oscillator). The reference support-lag fixture bounds residual hold acceleration below 0.75 VPU per normalized-time squared. The single-projection saturated hold is qualified only while its constitutive residual is below ten times the force-cap impulse; tables relying on sustained saturation require tighter validation. + +Clamp the result of `ApplyStaticTime` to the previously selected earliest positive mechanism-stop time and confirmed hinge TOI, after all candidate processing. The current rule can raise the interval after a stop was selected. Apply the stop-time correction to flippers as well as hinges and run flipper regressions. Use dedicated just-before-stop and repeated-zero-time fixtures; distinguish a zero-time contact progress rule from permission to overrun a positive stop time. At a stop, zero outward speed, retain the blocked-direction sign, and allow any impulse away from the stop. Do not inject energy with a rebound coefficient in version one. + +## 8. Analytic box collision and reciprocal contact + +### Geometry and continuous detection + +Use one oriented box fixed in the hinge-local frame, with a fixed full-travel broadphase AABB. A conservative sphere about the pivot with radius equal to the farthest box corner is sufficient for the initial bound; optionally tighten along the hinge axis. The bound includes intermediate arc positions and remains valid if an impact reverses rotation. Keep the render mesh separate from this proxy. + +For time-of-impact, transform the sphere centre at time `t` into the predicted box frame and compute its closest point by clamping to the box half-extents. Distance from the sphere centre to that point minus ball radius gives the outside separation. Handle centres inside the box with the nearest outward face and signed depth. Return a world-space witness point and normal; this full 3-D calculation covers side faces, edges, and corners. Merely reusing a two-dimensional rotating line with a height-band test would miss end-face and corner cases for an arbitrary hinge orientation. + +Find the first zero of separation with conservative advancement over the interval before the next stop. A safe global distance-rate bound for the piecewise-constant motion is `length(ballVelocity) + abs(omega)*maxProxyRadius`. Use that to advance by separation/rate with a conservative factor, then refine near contact. Endpoint sign tests alone are insufficient: the toy can pass through and leave again. Bound iterations, and on failure subdivide time with a conservative speculative-contact fallback; never silently accept a missed crossing. Validate numerical slop against ball radius. No general sphere-to-triangle mesh distance subsystem is needed. + +Inside `HitTest`, distinguish an approaching impact from sustained contact using relative normal velocity, separation, and existing contact tolerances. Produce contact data in one documented frame. Rotating normals, hit distance, original relative velocity, and force vectors must be transformed consistently. All later response uses the same current witness and hinge state. + +### Impact and sustained contact + +For normal `n` from toy to ball, `r = witness-pivot`, `s = dot(axis, cross(r,n))`, and pre-impact relative normal velocity `vn`: + +```text +inverseEffectiveMass = ball.InvMass + s*s / hinge.Inertia +J = -(1 + restitution) * vn / inverseEffectiveMass +ball.Velocity += J * n * ball.InvMass +hinge.AngularVelocity -= J * s / hinge.Inertia +``` + +Apply impact only when approaching. At an active stop, suppress the hinge response only if the proposed impulse pushes farther into that stop; an impulse away from the stop must still recoil the toy. Retest the stop after response. Use existing material elasticity/falloff/LUT semantics but no flipper solenoid, live-catch, scatter-driven speed, or special recoil heuristics. The normal impulse through a sphere centre leaves ball spin unchanged; friction uses the real surface lever arms and applies reciprocal angular impulses. + +Add `SpringHingeCollider.Contact` to `ContactPhysics.Update`, following `FlipperCollider.Contact`: compute ball and hinge surface velocities and accelerations, including rotating-normal/centripetal terms; solve the nonnegative force required to prevent approaching normal acceleration; apply the resulting impulse over the accepted substep; then bound tangential friction by the actual support load. Use the ball's `ExternalAcceleration` exactly once and the hinge's tick acceleration/stop state, rather than assuming an infinitely heavy moving wall. Derive arbitrary-axis cross products instead of copying z-only helper functions. + +The toy/playfield/ball-ball interactions remain sequential under VPE's current solver. They must pass the multiball and squeezed-contact fixtures; they are not exact simultaneous constraints. If qualification fails, first improve bounded local hinge contact iteration within this contract. A general island solver is a separate architecture decision and must not be introduced silently during implementation. + +### Ball-ball broadphase after impulses + +Static broadphase re-queries current ball bounds each substep, but the ball-ball octree is built once per cycle. `BallState.Aabb` is generously inflated by the full speed even though a tick travels about one tenth of that distance. This is useful margin, not a proof for a stationary ball suddenly accelerated by a fast returning toy or a nearby magnet. + +Track the ball AABBs inserted into the dynamic octree. After any new hinge/contact/hold response, test whether the ball's conservative remaining-tick envelope fits its inserted bound. Rebuild/refit the existing ball octree only if containment fails, before the next ball-ball query. This is a conditional correction, not an unconditional new broadphase every substep. Include the just-stationary-ball counterexample in tests. Do not constrain user physics with a guessed speed-multiplier limit to preserve stale bounds. + +## 9. Reciprocal owned magnet and attachment + +### Frames, force evaluation, and capture + +Add a hinge-local pole position and held-centre target. Compute their current world/playfield pose from hinge state each tick; their velocity is `omega * cross(axis, point-pivot)`. The new path never derives velocity from rendered transforms. `OwnerId` is runtime-only. Preserve the child magnet's coil and switch IDs. + +Refactor owned force evaluation to return acceleration/force contributions before committing them. Existing functions often call an acceleration variable `force`; convert with the actual ball mass before calculating reaction impulse. Attraction is a central ball-to-pole force with its reaction at the pole, giving reciprocal hinge torque. If a damping model applies a noncentral force, its reaction couple must be defined; do not claim angular-momentum conservation just from opposite linear forces. Keep the old unowned damping/profile behavior unchanged. + +An owned magnet can attract several free balls, but attachment ownership is exclusive: one attached ball per owned magnet, one owning magnet per ball. Choose a deterministic eligible ball using distance and then stable ball ID. Other magnets may continue applying attraction, but must not also execute their old grab/hold path on an already attached ball. Add a central ownership check for legacy grabs and kicker captures. Preserve the existing 64-ball bookkeeping limit and report capacity overflow rather than alias bits. + +Capture eligibility requires an energized field, a near-surface gap, a nonpenetrating target, and relative motion that available magnetic work can arrest. Let `u = cross(axis, ballPosition-pivot)`, `vRel = ballVelocity-u*omega`, and `K = identity/m + outer(u,u)/I`. Required relative kinetic energy is `0.5*dot(vRel, inverse(K)*vRel)`; in one direction this reduces to `vRel²/(2*Kdirection)`. At an outward-blocking stop use zero inverse hinge inertia in that direction. The current ball-only stopping-distance inequality assumes an immovable owner and is not copied unchanged. + +Define version-one capture-work eligibility as the explicit heuristic `Wcapture = max(0, min(fieldForceNow, maxHoldForce(current))) * max(0, GrabRadius-distanceToHoldTarget)`, with force magnitudes in engine force units, and require relative energy no greater than this budget. It generalizes the existing stopping-distance check; it is not an exact integral of magnetic potential. Qualify weakening-field, boundary, and zero-relative-speed cases. Existing attachment ownership wins until released; new owned capture excludes balls already held by a legacy magnet or frozen by a kicker. New owned candidates are arbitrated before field/hold application, and the committed capped hold determines whether capture persists. + +### Once-per-tick implicit hold + +Keep the ball orientation free and constrain its centre near the local target with a compliant translational hold. Avoid a ball-only critically damped spring plus an after-the-fact reaction: a light hinge changes the effective mass and therefore damping/stability. This is a small local hinge/held-ball velocity solve in the magnet pass, not a second contact/island solver. + +For one held ball, solve hinge scalar speed and ball's three linear velocity components together once per outer tick. Use the virtual hinge material point currently coincident with the ball centre for the reaction Jacobian: `u = cross(axis, ballPosition-pivot)`. The target supplies only the displacement error `C = ballPosition-target`; interpret its update in the locally co-rotating frame. Let `vPre` include the ball's ordinary external kick and other magnets' contributions, `omegaOld` be the saved tick-start hinge speed, and `QOther` the accumulated angular impulse from attracting other balls. A linearized implicit model is: + +```text +m * (vNew - vPre) = P +I * (omegaNew - omegaOld) = h*tauGravity + QOther - h*k*(angleError + h*omegaNew) - h*c*omegaNew - dot(u,P) +vRelativeNew = vNew - u*omegaNew +P = -h*holdStiffness*(C + h*vRelativeNew) - h*holdDamping*vRelativeNew +length(P) <= maxHoldForce(current) * h +``` + +Solve the unconstrained small block system first. If `length(P)` exceeds `Pmax = maxHoldForce(current)*h`, version one projects `P` once onto that sphere along its unconstrained direction. Then set `vNew = vPre+P/m` and recompute `omegaNew = (I*omegaOld+h*tauGravity+QOther-h*k*angleError-dot(u,P))/(I+h*c+h²*k)`. The saturated solution preserves the committed momentum balance but is an approximation to the exact nonlinear capped damper; measure its constitutive residual and acceptable saturation envelope in phase 0. Do not label that residual a converged exact solution. Independent axis clamping is prohibited because it exceeds the vector force cap. + +If the hinge is already at a stop and the candidate speed is outward, resolve the hold with fixed `omegaNew = 0`, project its impulse if needed, and compute the resulting bearing reaction. Check that this reaction has the permitted unilateral sign; otherwise use the free candidate because the hinge can leave the stop. An approaching future stop still belongs to substep stop-time handling. Include the unconstrained block reference, already-stopped hold, release-from-stop, zero-cap, and cap-saturation fixtures. Handle nonpositive parameters and singular cases explicitly. No branch commits both a free hinge step and the coupled block. + +Record exactly `ball.ExternalAcceleration += P/(ball.Mass*h)` from the full committed capped impulse, including damping. The old unowned magnet path records only its spring acceleration; that is not the contract for this new path. The hold is applied once; `ContactPhysics` balances the recorded load against surfaces over its substeps and does not apply the hold again. Save the owner's committed magnetic torque as `(QOther-dot(u,P))/h` and hold it fixed for the remaining tick. After collisions, re-evaluate only spring/damping/gravity and stop reactions for continuous contact acceleration. Audit `BallSpinHackPhysics` and other post-collision corrections; disable an incompatible spin hack for attached balls if it removes momentum without a physical reaction. + +Using the actual ball-centre point for both the velocity Jacobian and the reaction arm makes the ball impulse and hinge reaction act at the same point: projected angular momentum exchange is exact for that impulse even with finite displacement error. The mount transmits the equivalent wrench to the toy; the ball orientation remains free. Using co-rotating `C+h*vRelativeNew` is a local first-order integration approximation, whose truncation error must converge under step refinement. It must not be described as the exact inertial-frame displacement derivative at the separate target. Surface friction/spin torque still uses the actual contact witness. + +Separate attachment stiffness from magnetic holding capacity. As an initial qualification setting, require the small-signal attachment frequency to be at least ten times the loaded hinge frequency, while keeping the tick resolution adequate for measured convergence. The ratio is a starting test condition, not a universal proof; verify period error under tighter holds and halved ticks. If a force cap saturates under ordinary gravity, the rigidly held inertia identity is not an appropriate expected result. Show an authoring diagnostic for a weak/compliant hold instead of promising the same added-mass response at every setting. + +### Release and lifecycle + +Advance coil rise/fall once per tick using existing behavior. On switch-off, reduce hold capacity with decaying current; release when current/gap/work criteria or persistent cap saturation under separating load require it. A hard second-ball hit can break attachment on the next eligibility check. Distinguish temporary saturation during capture from sustained breakaway; use bounded gap and separating-velocity hysteresis. + +Release clears attachment IDs/state and emits Ball Released once, preserving current ball velocity/spin and hinge angular velocity. Never transfer the detached ball's angular momentum back into the toy. Keep it collidable at its current position. Release also precedes ball destruction/reuse, manual control, kicker freeze, magnet/hinge disable, and table reset. Avoid removing owner state before cleaning its attachment; include generation/stale-ID checks. + +## 10. Legacy interaction and first-release validation + +No general legacy-mechanism adapters are introduced. The existing ball-ball, passive surface, target, trigger, and impact dispatch remain in their current order. Add hinge handling through its own type and keep each hit event/response single-owned. + +For a held ball, qualify passive surface support and ball-ball impacts first. Before enabling the feature on a table, editor validation expands the whole held-ball centre sweep by its radius and checks it against the swept bounds of flippers/plungers and the capture/force regions of kickers, bumpers/slingshots, and turntables. A coarse overlap is a warning requiring visual inspection or a tighter query, not proof of an actual collision. Unsupported actual overlaps are reported as placement errors for version one. The diagnostic includes the two named items and their swept volumes. + +Runtime must still behave safely if table scripts bypass authoring validation. If an attached ball reaches an unsupported active-mechanism interaction, release its attachment before the legacy handler, retain its current velocities, emit the release event once, and issue a rate-limited diagnostic. This is an explicit unsupported-placement fallback, not claimed physical magnet breakaway. Kicker capture always follows the same release-before-freeze rule. A free ball may contact these mechanisms normally. + +If the actual toy cannot be placed within this envelope, extending that specific interaction becomes required follow-up work before claiming the user's table is supported. Do not equate passing the isolated demo with universal table compatibility. + +## 11. Threading, events, and package reconstruction + +Add hinge state to `PhysicsEngineContext` and the `PhysicsState` constructor/accessors; register it in `PhysicsEngine.Register`, include it in `CreateState`, and dispose all native buffers. The simulation thread is the only mutable owner. Inspector/API updates use the existing mutation/command boundary. Geometry/base edits require a safe reset/rebuild; ordinary coil changes preserve current motion. + +Add a hinge snapshot ID array in the `PhysicsEngineThreading` constructor, include its source count in `MaxFloatAnimations` checks, write hinge angle in `SnapshotAnimations`, and add the appropriate float emitter. Add `PhysicsMovements.ApplySpringHingeMovement` to the synchronous movement path. Register emitters only after IDs are resolved. Snapshot overflow must not render the ball from a newer state while leaving its owner stale; validate capacity on load and report/fail unsupported capacity rather than silently desynchronize the pair. + +`ApplyMovementsFromSnapshot` currently applies one snapshot directly. Version one publishes and renders hinge and balls from the same snapshot and timestamp. It does not add hinge-only interpolation or assume ball interpolation exists. A later smooth-rendering change would retain previous/current samples for both ball and hinge and use one interpolation time; that is outside this feature's first pass. Exclude the hinge and physics-owned magnet descendants from prescribed kinematic-transform detection to avoid render-to-physics feedback. + +Send impact pulses, angle-switch transitions, capture and release events only after committed physics transitions. Preserve source device IDs. A normal feature change, repeated contact substep, or numerical hold iteration cannot generate another scoring hit. Angle switches use close/open hysteresis. Existing coil current handling and Ball Held switch names are preserved. + +Package `SpringHingeComponent`/collider values, visual hierarchy, local box/mass frames, material references, and optional angle-switch settings via the existing `IPackable` and reference facilities. For magnet version 4, add an explicit owned-mode flag, local hold offset, and independent hold parameters; old versions default to unowned behavior even when parented beneath a new hinge. Resolve runtime `OwnerId` from the restored parent hierarchy after components exist, not from an opaque serialized instance ID. An explicit mode flag prevents a future reparent from silently changing old magnet behavior. Preserve the hierarchy even if package optimization would otherwise strip an empty pivot node. + +The table asset must not persist captured-ball IDs, warm solver state, or runtime angle. Runtime save states are a separate feature. Test package round-trip in the HDRP authoring project and reconstruct the same component hierarchy and collider/device IDs in `VisualPinball.Engine.Player` without relying on editor-only mesh data or `Awake` ordering. + +## 12. Implementation phases and file-level deliverables + +| Phase | Deliverables | Gate | +| --- | --- | --- | +| 0. Numerical fixtures | Add pure fixture builders in `T/Physics`; fix the tick contract from section 7; implement test references for oscillator, impact, hold block, and stop. | Unit conventions, unconstrained spring step, reciprocal impact, and capped local hold are independently verified before integration. | +| 1. Runtime skeleton | `R/VPT/SpringHinge/{SpringHingeComponent,SpringHingeColliderComponent,SpringHingeApi,SpringHingeState,SpringHingeVelocityPhysics,SpringHingeDisplacementPhysics}.cs`; context/register/state/accessor/disposal seams. | A hinge evolves in the existing tick/substep loop and reaches/releases stops correctly. | +| 2. Specialized collider | `SpringHingeCollider.cs`, collider generator, `ColliderType`, `NativeColliders`, `ColliderReference`, bounds and hit-test/collision/contact switches. | Nonmagnetic ball pushes the box, rebounds/returns correctly, and cannot tunnel in the tested envelope. | +| 3. Owned magnet | Extended magnet state/component/update; local implicit hold helper; owner registry and capture/release lifecycle. | Loaded period, gravity transfer, momentum, release and second-ball breakaway pass. | +| 4. Integration qualification | Conditional dynamic-octree containment/refit, event deduplication, spin-hack audit, supported/unsupported legacy interaction handling. | Passive support and multiball work; unsupported active contact diagnoses/releases predictably; no duplicate impulses/events. | +| 5. Render and packages | Threaded/synchronous hinge output, source-count checks, hinge packables, magnet version 4, hierarchy reconstruction. | Authoring and player load the same mechanism; no render feedback, stale IDs, or native leaks. | +| 6. Authoring | `E/VPT/SpringHinge` inspectors/setup/handles, box mass-property helper, magnet inspector owner controls, validation, bash preset. | An author can configure the visual toy, proxy, pivot, spring, and magnet without a behavior script. | +| 7. Demonstration and docs | Dedicated shot-control Play Mode fixture/demo package, creator guide, changelog, regression/performance results. | The acceptance matrix passes and first-release limits are documented. | + +The phase-0 spike validates a chosen architecture; it does not leave the main scheduler undecided. If the existing sequential contact architecture fails the user's required passive-support or multiball cases, stop claiming readiness, record the specific failed fixture, and propose the smallest justified solver extension. Do not solve the problem by disabling collisions, parenting the ball, increasing mass twice, or silently removing the requirement. + +Phases 0–7 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, integration qualification, coherent render/package reconstruction, authoring workflow, Play Mode sample, and published qualification results alongside this plan. + +## 13. Acceptance and regression matrix + +Thresholds are initial qualification targets to establish with the numerical fixtures. Record step size, solver settings, mass/scale, proxy dimensions, and hold-frequency ratio with results. Test against independent equations or a finer-step reference, not duplicate implementation arithmetic. + +| Fixture | Required result | +| --- | --- | +| Unit conversion | Numeric checks for degrees/radians, normalized time, ball-relative inertia, geometry scale, and cabinet acceleration sign/scale. | +| Free torsional oscillator | Lightly damped, gravity-free small-angle period within 1% of `2*pi*sqrt(I/k)`; error decreases when halving the tick. | +| Gravity and preload | Correct torque sign about arbitrary axes, zero torque for a vertical axis, equilibrium/preload against either stop, and correct cabinet inertial torque. | +| Isolated impact | Angular momentum about the hinge axis within 0.1% for a frictionless short impact away from stops with negligible external torque; velocities/restitution match an independent solution. | +| Lever arm | Near-axis and far-axis impacts give the expected difference; arbitrary frame rotations preserve the physical result. | +| Sustained push | Ball continues with an appropriate low-elasticity toy, spring resistance grows, and no repeated static wall bounce occurs. | +| Stops | Exact stop arrival ordering, blocked-direction finite response, no outward resting velocity, immediate ability to move away, no zero-time infinite loop. | +| Passive energy | No secular energy growth over 10 seconds without actuation/external work; quantify numerical damping rather than claiming exact energy conservation. | +| Local hold block | Closed-form free/coupled cases, reciprocal momentum with spring/gravity off, vector force cap, cap-active residual, very light/heavy toy, and damping stability. | +| Loaded inertia | Tight gravity-free hold converges to point-mass loaded period within 2%; verify at capture distances r and 2r. Document hold/hinge frequency ratio and force-cap headroom. | +| Weight and playfield support | Expected equilibrium without playfield support; for a held-centre target at resting ball height, near-zero vertical hold load as the playfield supports gravity. The hinge receives the full actual magnetic reaction, not a manually reduced weight. Measure the residual one-tick support lag against hold damping. | +| Moving capture | Capture with owner swinging toward/away from the ball transfers incoming momentum rather than discarding it. | +| Release | Coil decay honored; ball position/velocity/spin and hinge speed continuous at release in either direction; no instantaneous speed gain when mass detaches. | +| Breakaway and ownership | Another ball can knock the held ball free; weak magnet fails capture; competing magnets cannot create two holds; deterministic arbitration and one release event. | +| 3-D box CCD | Thin face, edge, corner, box end-face, off-axis proxy, long lever arm, fast shot, return into a stationary ball, and interior recovery. | +| Contact error | Target maximum penetration below 0.5% of ball radius in the documented speed/stiffness envelope; bounded fallback diagnostics on exhausted TOI budget. | +| Dynamic bounds | A stationary ball accelerated beyond its inserted ball-octree margin still collides with a second ball in the remaining tick; no unconditional refit cost. | +| Multiball | Two balls on one toy, strike on a held ball, and held-ball/playfield support are stable; reverse registration order and quantify sequential-solver error. | +| Existing items | Free balls preserve target/trigger/bumper/flipper behavior; unsupported attached-ball active interactions release before legacy effects with one diagnostic. | +| Lifecycle | Disable/delete/reset/manual control/kicker freeze/ball-ID reuse and repeated Play Mode do not retain ownership or leak native allocations. | +| Rendering | Threaded and synchronous paths at 30/60/144 Hz apply matching ball/hinge simulation times; render hitches do not drive the hinge. | +| Packaging | Values, pivot node, material/device bindings, explicit owned flag and parent resolution round-trip; version-3 magnets remain unowned and unchanged. | + +Run the existing magnet, contact/transform/kinematic, flipper, target, turntable, and cabinet suites when their shared paths change. The checked-in test project targets `netcoreapp3.1` and references Unity assemblies: distinguish managed numeric tests from cases requiring the Unity Editor/native runtime. Validate Burst compilation and EditMode/PlayMode behavior in the configured Unity project. A managed build alone is not physics validation. + +Benchmark no hinges, one moving hinge, one held ball, and several hinges with multiball. Record physics time, TOI iterations/fallbacks, active contacts, refit counts, and allocations. Require zero per-tick managed allocations; target under 1% added physics cost for tables without this feature on the same hardware. Establish and publish the supported speed, stiffness, proxy size, and hold-compliance envelope from measured convergence. Automatic substep growth must be bounded and reported; no claims about arbitrarily stiff springs at fixed cost. + +## 14. Fable 5.1 review and disposition + +The initial, broader draft was reviewed through Claude Code using `claude-fable-5-1` at high effort in persistent session `16755288-b5d6-4dff-b5cb-8d9ad1be422b`. The first review completed successfully and read the plan and relevant repository code. Its verdict was that the initial plan was not implementable as written because the scheduler was undecided and the generic solver/collider migration was too broad. This revision addresses that verdict with a fixed implementation contract. Review is design feedback, not evidence of implemented or tested behavior. + +| Review finding | Disposition in this revision | +| --- | --- | +| 1. Unresolved scheduler | Accepted: preserve once-per-tick velocity preparation and per-substep displacement/contact. Specify exact order and stop/minimum-step interaction. | +| 2. Generic rotating-mesh CCD | Accepted scope reduction: one analytic 3-D box proxy. Retain full sphere-box distance/TOI rather than a 2-D line plus height band, which is insufficient for arbitrary-axis corners/end faces. | +| 3. Unnecessary collider-store migration | Accepted: specialized `ColliderType.SpringHinge` in the static store; no `ColliderSet` migration. | +| 4. Second island/contact solver | Accepted: existing sequential contact model with hinge response and explicit qualification limits. | +| 5. Reciprocal magnet | Accepted owner/reaction requirement; refine the suggested ball-only explicit spring into a local finite-inertia implicit velocity block with bounded force and defined torque accounting. | +| 6. Loaded-inertia test depends on hold stiffness | Accepted: separate stiffness from range, state the initial frequency-ratio/headroom conditions, and require convergence/stability tests. | +| 7. Ball-relative units | Accepted: use existing mass convention and exact normalized-time constants; no invented kilogram calibration. | +| 8. Ball bounds already have margin | Partially accepted: avoid unconditional refits. Retain a containment check and conditional refit because initial low speed does not bound a later impulse. | +| 9. Legacy placement restriction | Accepted with explicit supported passive contacts, editor sweep validation, and runtime release-before-unsupported-interaction behavior. | +| 10. Missing lifecycle/package/render seams | Accepted concrete constructor/register/snapshot/disposal seams and hierarchy resolution. Add an explicit versioned ownership flag to preserve old-package behavior. Defer an isolated preview context in favor of a real test scene. | + +The same Fable 5.1 session completed a focused follow-up review successfully. Its verdict was: “the revised plan is implementable in architecture.” It requested four concrete corrections, all incorporated afterward: defer the owned hinge's velocity commit until capture eligibility is known; use the actual ball-centre material point for reciprocal hold momentum; report the full capped hold impulse as external acceleration and freeze its reaction for the tick; and define one projected force-cap solve with a stated approximation envelope. Additional edits specify capture-work eligibility, the StaticTime clamp, playfield-support expectations, and the one-tick splitting limits. These final textual corrections were checked locally; there was no third model pass and no implementation/test execution. + +Full review transcripts were retained as local planning-task artifacts outside the repository. The first transcript evaluates the initial broader draft, and the second evaluates the narrowed revision before the final corrections above. They are not required to build, test, or understand this implementation. + +## References + +- [OpenStax: angular momentum and capture](https://openstax.org/books/university-physics-volume-1/pages/11-3-conservation-of-angular-momentum). +- [OpenStax: physical pendulums](https://openstax.org/books/university-physics-volume-1/pages/15-4-pendulums). +- [Box2D: revolute joint spring and limit concepts](https://box2d.org/documentation/group__revolute__joint.html). + +These support the mechanics; implementation follows VPE's existing specialized-mechanism architecture and adds no external physics-engine dependency. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md new file mode 100644 index 000000000..a1d45a1bb --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md @@ -0,0 +1,26 @@ +# Spring-hinge qualification results + +The spring-hinge and owned-magnet implementation is qualified in the Unity 6000.5.0f1 HDRP project on Windows with the 1 ms VPE physics tick (`h = 0.1` normalized time units). The reference toy uses one ball-relative toy mass, a 25 × 50 × 10 mm mass/proxy half-extents box centred 50 mm below the pivot, a 0-to-20-degree travel range, and a standard ball of mass 1 and radius 25 VPU. The attachment fixtures enforce `hold frequency × tick <= 0.2`, use an initial hold-to-loaded-hinge frequency ratio of at least 10:1, and keep the vector force cap above the ordinary-gravity demand. + +## Acceptance evidence + +| Acceptance area | Evidence and result | +| --- | --- | +| Units, oscillator, gravity, preload, and stops | `SpringHingeNumericalFixtureTests` and `SpringHingePhysicsTests`: analytic period error below 1%, convergent half-step result, arbitrary-axis gravity sign, vertical-axis zero torque, cabinet acceleration scale, exact stop ordering, blocked outward speed, and immediate inward departure pass. | +| Reciprocal impact, lever arm, sustained push, energy, and 3-D CCD | `SpringHingeNumericalFixtureTests`, `SpringHingePhysicsTests`, `SpringHingeColliderTests`, and `SpringHingeQualificationTests`: independent finite-inertia impulse solution, angular momentum error below 0.1%, near/far arms, rotated/off-axis box, face/edge/corner/end-face, fast/return shots, interior recovery, conservative fallback bounds, exhausted-static-count TOI clamp, sustained contact, ten-second passive-energy bounds, and 0.5%-radius accepted-TOI penetration bounds at 8/18/30 VPU pass. | +| Hold, loaded inertia, support, capture, release, and breakaway | `SpringHingeNumericalFixtureTests` and `OwnedMagnetPhysicsTests`: closed-form free/coupled response, reciprocal momentum, vector cap/residual, light/heavy toy, 10:1 attachment frequency, loaded-period convergence within 2% at `r` and `2r`, full gravity reaction, bounded one-tick support lag below 0.75 VPU per normalized-time squared, moving capture, coil-decay release continuity, second-ball breakaway, weak-capture rejection, deterministic ownership, and one-shot events pass. | +| Dynamic bounds, multiball, existing items, and lifecycle | `SpringHingeIntegrationTests`, `PhysicsRegressionTests`, and existing magnet/target/flipper/turntable/cabinet suites: conditional refit, attached-ball spin exclusion, passive support, registration-order stability, release-before-unsupported-active behavior, reset/disable/delete/ID reuse, and free-ball regression behavior pass. | +| Rendering and packaging | `SpringHingePackagingTests`: synchronous and threaded source/capacity behavior, same-snapshot ball/hinge publication, transform-feedback exclusion, hierarchy and material/device references, magnet package v4, and version-3 unowned compatibility pass. | +| Authoring and Play Mode | `SpringHingeAuthoringTests` and `SpringHingePlayModeFixtureTests`: selection setup, world-pose preservation, collider disabling, VPX-unit fit, validation, follower/reference round-trip, real `Player`/`PhysicsEngine` startup, shot creation, magnet control, release, reset, and teardown pass. | + +The final Unity qualification run passed 183/183 spring-hinge, magnet, packaging, authoring, shared physics-regression, turntable, and cabinet tests (job `114fbb76c73b4829a0eb2c8b03e42a84`). The real-player fixture separately passed 1/1 (job `1232ebd7f547402eb2200e89dc868f87`). Tests use independent equations or finer-step references where numerical agreement is claimed. + +## Bounded work and performance + +The hinge collider performs at most 32 conservative-advancement iterations. A stalled near-contact case preserves the proven-safe lower time and uses at most 32 local fallback segments plus 14 refinements. Dynamic ball broadphase rebuilds only when the remaining-tick envelope escapes the inserted bound; tests assert both the refit and no-refit paths. Collision/contact buffers and hinge/magnet state are preallocated, and the qualification benchmark requires zero managed allocations across the measured empty, one-hinge, held-ball, and several-hinge loops. + +The no-feature path adds an empty native-map scan in the shared tick. On the qualification machine, 100,000 measured iterations took 11.300 ms for an empty scan, 44.664 ms for one moving hinge, 273.385 ms for eight hinges, and 92.257 ms for the owned hold solve, with zero managed bytes allocated in every loop (job `7b4aec17c870474db0208443d4db6715`). This microbenchmark is not a substitute for a representative full-table profiler capture. The release target remains under 1% added physics time on tables with no hinges; verify that target on the shipping table and hardware before release. + +## Supported envelope and limits + +The published envelope is the one in the [creator guide](../creators-guide/manual/mechanisms/spring-hinges.md): fixed rigid pivot; one positive-extents analytic box; one owned Spatial Physical magnet and one attached ball; a loaded period of at least 0.314 seconds at the 1 ms tick; `hold frequency × tick <= 0.2`; constitutive residual below ten times the force-cap impulse; and penetration below 0.5% of ball radius in the tested 8/18/30 VPU shot range. Several hinges and multiple free balls are supported, but simultaneous passive constraints use VPE's sequential solver and retain measured ordering error. Unsupported active-mechanism contact releases the ball before legacy handling. Triangle/compound proxies, moving bases, nested hinges, multiple held balls, motors, flexible toys, runtime save state, and editor physics preview remain outside version one. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml index ed89a281b..cdace5254 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml @@ -6,6 +6,8 @@ href: threading-model.md - name: Nudge System href: nudge-system.md +- name: Spring Hinge Qualification + href: spring-hinge-qualification.md - name: Packaging items: - name: Overview diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index f13f2cde7..c57955e2c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -16,12 +16,16 @@ using UnityEditor; using UnityEngine; +using float3 = global::Unity.Mathematics.float3; +using VisualPinball.Engine.Common; namespace VisualPinball.Unity.Editor { [CustomEditor(typeof(MagnetComponent))] public class MagnetInspector : ItemInspector { + private const float OrdinaryShotMinimumSpeed = 8f; + private SerializedProperty _radiusProperty; private SerializedProperty _strengthProperty; private SerializedProperty _magnetTypeProperty; @@ -40,6 +44,13 @@ public class MagnetInspector : ItemInspector private SerializedProperty _isKinematicProperty; private SerializedProperty _drawDebugForcesProperty; private SerializedProperty _hitThresholdProperty; + private SerializedProperty _coupleToParentHingeProperty; + private SerializedProperty _heldBallCentreOffsetProperty; + private SerializedProperty _holdStiffnessProperty; + private SerializedProperty _holdDampingProperty; + private SerializedProperty _maxHoldForceProperty; + private IApiCoil _runtimeCoil; + private bool? _lastRuntimeCoilStatus; protected override MonoBehaviour UndoTarget => target as MonoBehaviour; @@ -65,10 +76,22 @@ protected override void OnEnable() _isKinematicProperty = serializedObject.FindProperty(nameof(MagnetComponent.IsKinematic)); _drawDebugForcesProperty = serializedObject.FindProperty(nameof(MagnetComponent.DrawDebugForces)); _hitThresholdProperty = serializedObject.FindProperty(nameof(MagnetComponent.HitThreshold)); + _coupleToParentHingeProperty = serializedObject.FindProperty(nameof(MagnetComponent.CoupleToParentHinge)); + _heldBallCentreOffsetProperty = serializedObject.FindProperty(nameof(MagnetComponent.HeldBallCentreOffset)); + _holdStiffnessProperty = serializedObject.FindProperty(nameof(MagnetComponent.HoldStiffness)); + _holdDampingProperty = serializedObject.FindProperty(nameof(MagnetComponent.HoldDamping)); + _maxHoldForceProperty = serializedObject.FindProperty(nameof(MagnetComponent.MaxHoldForce)); + } + + protected override void OnDisable() + { + SetRuntimeCoil(null, null); + base.OnDisable(); } public override void OnInspectorGUI() { + UpdateRuntimeCoilSubscription(); BeginEditing(); OnPreInspectorGUI(); if (Application.isPlaying) { @@ -83,7 +106,6 @@ public override void OnInspectorGUI() var isSpatial = _magnetTypeProperty.enumValueIndex == (int)MagnetType.Spatial; var isCylindrical = _magnetTypeProperty.enumValueIndex == (int)MagnetType.Cylindrical; var isThreeDimensional = isSpatial || isCylindrical; - PropertyField(_radiusProperty, isCylindrical ? "Influence Distance" : "Influence Radius"); if (isCylindrical) { PropertyField(_cylinderRadiusProperty); @@ -127,6 +149,19 @@ public override void OnInspectorGUI() PropertyField(_grabRadiusProperty); } + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("Spring Hinge Ownership", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(Application.isPlaying)) { + PropertyField(_coupleToParentHingeProperty); + } + if (_coupleToParentHingeProperty.hasMultipleDifferentValues || _coupleToParentHingeProperty.boolValue) { + PropertyField(_heldBallCentreOffsetProperty); + PropertyField(_holdStiffnessProperty); + PropertyField(_holdDampingProperty); + PropertyField(_maxHoldForceProperty); + DrawOwnedModeValidation(isSpatial); + } + EditorGUILayout.Space(8f); PropertyField(_isEnabledOnStartProperty); // kinematic registration is fixed at startup; toggling during play would silently do nothing @@ -139,6 +174,124 @@ public override void OnInspectorGUI() EndEditing(); } + private void UpdateRuntimeCoilSubscription() + { + var magnet = target as MagnetComponent; + if (!Application.isPlaying || !magnet || magnet.MagnetApi == null) { + SetRuntimeCoil(null, null); + return; + } + + var coil = ((ICoilDeviceComponent)magnet).CoilDevice(MagnetComponent.MagnetCoilItem); + SetRuntimeCoil(coil, magnet.MagnetApi.IsEnabled); + } + + private void SetRuntimeCoil(IApiCoil coil, bool? isEnabled) + { + if (ReferenceEquals(_runtimeCoil, coil)) { + return; + } + if (_runtimeCoil != null) { + _runtimeCoil.CoilStatusChanged -= OnRuntimeCoilStatusChanged; + } + _runtimeCoil = coil; + _lastRuntimeCoilStatus = isEnabled; + if (_runtimeCoil != null) { + _runtimeCoil.CoilStatusChanged += OnRuntimeCoilStatusChanged; + } + } + + private void OnRuntimeCoilStatusChanged(object sender, NoIdCoilEventArgs eventArgs) + { + if (_lastRuntimeCoilStatus == eventArgs.IsEnergized) { + return; + } + _lastRuntimeCoilStatus = eventArgs.IsEnergized; + Repaint(); + } + + private void DrawOwnedModeValidation(bool isSpatial) + { + var magnet = target as MagnetComponent; + var owner = magnet ? magnet.GetComponentInParent() : null; + using (new EditorGUI.DisabledScope(true)) { + EditorGUILayout.ObjectField("Resolved Owner", owner, + typeof(SpringHingeComponent), true); + } + if (!owner) { + EditorGUILayout.HelpBox("Owned mode requires a parent Spring Hinge.", MessageType.Error); + } + if (!isSpatial) { + EditorGUILayout.HelpBox("Owned mode requires a Spatial magnet.", MessageType.Error); + if (GUILayout.Button("Use Spatial Mode")) { + _magnetTypeProperty.enumValueIndex = (int)MagnetType.Spatial; + } + } + if (owner) { + var ownedCount = 0; + foreach (var candidate in owner.GetComponentsInChildren(true)) { + if (candidate.CoupleToParentHinge) { + ownedCount++; + } + } + if (ownedCount > 1) { + EditorGUILayout.HelpBox("Only one owned magnet is supported per spring hinge.", MessageType.Error); + } else if (isSpatial) { + DrawCaptureEstimate(); + } + + var proxy = owner.GetComponent(); + if (!proxy) { + EditorGUILayout.HelpBox("The parent Spring Hinge needs a Spring Hinge Collider before its hold point can be checked.", MessageType.Error); + } else { + if (SpringHingeAuthoring.TryGetHeldBallCentreGap(owner, proxy, magnet, out var gap) + && Mathf.Abs(gap) > PhysicsConstants.PhysTouch) { + var message = gap < 0f + ? $"The hold point puts a standard ball {-gap:0.##} units inside the hinge collider, so it cannot be grabbed." + : $"The hold point leaves a standard ball {gap:0.##} units away from the hinge collider, so it cannot be grabbed."; + EditorGUILayout.HelpBox(message, MessageType.Warning); + } + using (new EditorGUI.DisabledScope(Application.isPlaying + || _heldBallCentreOffsetProperty.hasMultipleDifferentValues)) { + if (GUILayout.Button("Fit Hold Point to Collider") + && SpringHingeAuthoring.TryGetFittedHeldBallCentreOffset(owner, proxy, + magnet, out var offset)) { + _heldBallCentreOffsetProperty.vector3Value = offset; + } + } + } + } + } + + private void DrawCaptureEstimate() + { + if (_radiusProperty.hasMultipleDifferentValues + || _strengthProperty.hasMultipleDifferentValues + || _poleRadiusProperty.hasMultipleDifferentValues + || _grabBallProperty.hasMultipleDifferentValues + || _grabRadiusProperty.hasMultipleDifferentValues + || _heldBallCentreOffsetProperty.hasMultipleDifferentValues + || _maxHoldForceProperty.hasMultipleDifferentValues) { + return; + } + var state = new MagnetState { + Radius = _radiusProperty.floatValue, + Strength = _strengthProperty.floatValue, + EffectiveCurrent = 1f, + EffectiveStrength = _strengthProperty.floatValue, + PoleRadius = _poleRadiusProperty.floatValue, + GrabRadius = _grabBallProperty.boolValue ? _grabRadiusProperty.floatValue : 0f, + MaxHoldForce = _maxHoldForceProperty.floatValue + }; + var pole = float3.zero; + var target = (float3)_heldBallCentreOffsetProperty.vector3Value; + var speed = OwnedMagnetPhysics.EstimateStationaryHingeCaptureSpeed(in state, + in pole, in target); + var message = $"Best-case full-power capture speed at the hold point: about {speed:0.#} VPE ball-speed units for a standard ball while the toy is stationary. Actual capture may be lower because the ball enters the grab area away from this point, the coil takes time to energize, and the toy may be moving."; + EditorGUILayout.HelpBox(message, + speed < OrdinaryShotMinimumSpeed ? MessageType.Warning : MessageType.Info); + } + private void DrawColliderFit() { if (!TryGetChildColliderSize(out var radius, out var height, out var colliderName, out var error)) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerColliderInspector.cs index 68c003a1d..8270b5697 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Spinner/SpinnerColliderInspector.cs @@ -23,7 +23,6 @@ namespace VisualPinball.Unity.Editor [CustomEditor(typeof(SpinnerColliderComponent)), CanEditMultipleObjects] public class SpinnerColliderInspector : ColliderInspector { - private SerializedProperty _massProperty; private SerializedProperty _elasticityProperty; private SerializedProperty _zPosProperty; private SerializedProperty _distanceProperty; @@ -32,7 +31,6 @@ public class SpinnerColliderInspector : ColliderInspector Selection.transforms.Length > 0; + + [MenuItem("GameObject/Pinball/Spring Hinge Bash Toy", false, 13)] + private static void CreateBashToyMenu(MenuCommand command) + { + var context = command.context as GameObject; + var root = CreateBashToy(context ? context.transform : null); + Selection.activeGameObject = root; + } + + public static GameObject CreateBashToy(Transform parent = null) + { + var root = new GameObject("Spring Hinge Bash Toy"); + Undo.RegisterCreatedObjectUndo(root, "Create Spring Hinge Bash Toy"); + if (parent) { + GameObjectUtility.SetParentAndAlign(root, parent.gameObject); + } + + var hinge = Undo.AddComponent(root); + var proxy = Undo.AddComponent(root); + + var visual = GameObject.CreatePrimitive(PrimitiveType.Cube); + visual.name = "Toy Visual"; + visual.transform.SetParent(root.transform, false); + visual.transform.localPosition = Vector3.down * Physics.ScaleToWorld(50f); + visual.transform.localScale = new Vector3( + Physics.ScaleToWorld(50f), Physics.ScaleToWorld(100f), Physics.ScaleToWorld(20f)); + var unityCollider = visual.GetComponent(); + if (unityCollider) { + UnityEngine.Object.DestroyImmediate(unityCollider); + } + + var magnetObject = new GameObject("Owned Magnet"); + magnetObject.transform.SetParent(root.transform, false); + magnetObject.transform.localPosition = Vector3.down * Physics.ScaleToWorld(100f); + var magnet = Undo.AddComponent(magnetObject); + + ApplyBashPreset(hinge, proxy, magnet); + EditorUtility.SetDirty(root); + return root; + } + + public static GameObject AddSpringHinge(IReadOnlyList visualParts, + Transform activeVisual) + { + if (visualParts == null || visualParts.Count == 0) { + return null; + } + + activeVisual = activeVisual ? activeVisual : visualParts[0]; + var rotatingObject = activeVisual.gameObject; + var hinge = rotatingObject.GetComponent() + ?? Undo.AddComponent(rotatingObject); + var proxy = rotatingObject.GetComponent() + ?? Undo.AddComponent(rotatingObject); + foreach (var visualPart in visualParts) { + if (!visualPart || visualPart == activeVisual || visualPart.IsChildOf(activeVisual) + || IsAncestorSelected(visualPart, visualParts)) { + continue; + } + if (activeVisual.IsChildOf(visualPart)) { + Debug.LogWarning($"Cannot add selected ancestor '{visualPart.name}' below spring hinge '{activeVisual.name}'. Select the common rotating root as the active object.", activeVisual); + continue; + } + Undo.SetTransformParent(visualPart, activeVisual, "Add Visual To Spring Hinge"); + } + DisableIndependentColliders(activeVisual); + + var ownedMagnets = rotatingObject.GetComponentsInChildren(true); + var magnet = ownedMagnets.Length == 1 ? ownedMagnets[0] : null; + ApplyBashPreset(hinge, proxy, magnet); + FitFromVisuals(hinge, proxy); + EditorUtility.SetDirty(rotatingObject); + return rotatingObject; + } + + private static bool IsAncestorSelected(Transform candidate, + IReadOnlyList selected) + { + for (var parent = candidate.parent; parent; parent = parent.parent) { + for (var i = 0; i < selected.Count; i++) { + if (selected[i] == parent) { + return true; + } + } + } + return false; + } + + private static void DisableIndependentColliders(Transform visualPart) + { + foreach (var collider in visualPart.GetComponentsInChildren(true)) { + Undo.RecordObject(collider, "Disable Independent Collider"); + collider.enabled = false; + } + foreach (var behaviour in visualPart.GetComponentsInChildren(true)) { + if (behaviour is not ICollidableComponent + || behaviour is MagnetComponent + || behaviour is SpringHingeColliderComponent) { + continue; + } + Undo.RecordObject(behaviour, "Disable Independent Collider"); + behaviour.enabled = false; + } + } + + public static void ApplyBashPreset(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, MagnetComponent magnet = null) + { + hinge.HingeAxis = Vector3.right; + hinge.CentreOfMass = new Vector3(0f, -50f, 0f); + hinge.ToyMass = 1f; + hinge.OverrideInertia = false; + hinge.MassBoxHalfExtents = new Vector3(25f, 50f, 10f); + hinge.SpringStiffness = 100f; + hinge.SpringDamping = 5f; + hinge.EquilibriumAngle = 0f; + hinge.MinimumAngle = 0f; + hinge.MaximumAngle = 20f; + hinge.InitialAngle = 0f; + + proxy.LocalCentre = new Vector3(0f, -50f, 0f); + proxy.LocalRotation = Vector3.zero; + proxy.HalfExtents = new Vector3(25f, 50f, 10f); + proxy.Elasticity = 0.1f; + proxy.ElasticityFalloff = 0.5f; + proxy.Friction = 0.3f; + proxy.HitEvent = true; + proxy.HitThreshold = 0f; + + if (!magnet) { + return; + } + magnet.MagnetType = MagnetType.Spatial; + magnet.ForceProfile = MagnetForceProfile.Physical; + magnet.Radius = MagnetComponent.DefaultInfluenceRadius; + magnet.Strength = BashMagnetStrength; + magnet.PoleRadius = MagnetComponent.DefaultPoleRadius; + magnet.GrabBall = true; + magnet.GrabRadius = MagnetComponent.DefaultGrabRadius; + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = Vector3.down * StandardBallRadiusVpx; + magnet.HoldStiffness = BashMagnetHoldStiffness; + magnet.HoldDamping = BashMagnetHoldDamping; + magnet.MaxHoldForce = BashMagnetMaxHoldForce; + magnet.IsKinematic = false; + } + + public static bool TryGetVisualBounds(SpringHingeComponent hinge, + out Vector3 centreVpx, out Vector3 halfExtentsVpx) + { + centreVpx = Vector3.zero; + halfExtentsVpx = Vector3.zero; + if (!hinge) { + return false; + } + + var renderers = hinge.GetComponentsInChildren(true); + var minimum = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + var maximum = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + var found = false; + foreach (var renderer in renderers) { + if (!renderer || renderer.hideFlags.HasFlag(HideFlags.DontSave)) { + continue; + } + var bounds = renderer.bounds; + for (var corner = 0; corner < 8; corner++) { + var world = bounds.center + Vector3.Scale(bounds.extents, new Vector3( + (corner & 1) == 0 ? -1f : 1f, + (corner & 2) == 0 ? -1f : 1f, + (corner & 4) == 0 ? -1f : 1f)); + var local = hinge.transform.InverseTransformPoint(world); + minimum = Vector3.Min(minimum, local); + maximum = Vector3.Max(maximum, local); + found = true; + } + } + if (!found) { + return false; + } + centreVpx = (minimum + maximum) * (0.5f / Physics.ScaleInv); + halfExtentsVpx = (maximum - minimum) * (0.5f / Physics.ScaleInv); + return true; + } + + public static bool FitFromVisuals(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy) + { + if (!hinge || !proxy || !TryGetVisualBounds(hinge, out var centre, out var halfExtents)) { + return false; + } + hinge.CentreOfMass = centre; + hinge.MassBoxHalfExtents = halfExtents; + proxy.LocalCentre = centre; + proxy.LocalRotation = Vector3.zero; + proxy.HalfExtents = halfExtents; + return true; + } + + public static IReadOnlyList Validate(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy) + { + var issues = new List(); + if (!hinge) { + issues.Add("A Spring Hinge component is required."); + return issues; + } + if (hinge.HingeAxis.sqrMagnitude < 1e-8f) { + issues.Add("Hinge Axis must be non-zero."); + } + if (hinge.transform.parent && hinge.transform.parent.GetComponentInParent()) { + issues.Add("Nested spring hinges are not supported."); + } + if (!HasRigidFrame(hinge.transform)) { + issues.Add("The spring-hinge frame must have non-zero orthogonal axes; bake shear into the visual mesh before simulation."); + } + if (hinge.MinimumAngle >= hinge.MaximumAngle) { + issues.Add("Minimum Angle must be lower than Maximum Angle."); + } + if (hinge.InitialAngle < hinge.MinimumAngle || hinge.InitialAngle > hinge.MaximumAngle) { + issues.Add("Initial Angle must lie between the hard stops."); + } + if (!proxy || proxy.HalfExtents.x <= 0f || proxy.HalfExtents.y <= 0f || proxy.HalfExtents.z <= 0f) { + issues.Add("The analytic box proxy needs three positive half-extents."); + } + + if (hinge.GetComponentInChildren(true)) { + issues.Add("Remove hit-target animation from spring-hinge visuals; the hinge is their only animation driver."); + } + + var ownedMagnets = hinge.GetComponentsInChildren(true); + var ownedCount = 0; + foreach (var magnet in ownedMagnets) { + if (!magnet.CoupleToParentHinge) { + continue; + } + ownedCount++; + if (magnet.MagnetType != MagnetType.Spatial) { + issues.Add($"Owned magnet '{magnet.name}' must use Spatial type."); + } + } + if (ownedCount > 1) { + issues.Add("Only one owned magnet is supported per spring hinge."); + } + return issues; + } + + public static bool TryGetHeldBallCentreGap(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, MagnetComponent magnet, out float gap) + { + gap = 0f; + if (!hinge || !proxy || !magnet) { + return false; + } + var target = ToPlayfieldVpx(hinge, magnet.GetHeldBallCentreWorldPosition()); + return TryGetProxyDistance(hinge, proxy, target, StandardBallRadiusVpx, + out gap, out _, out _); + } + + public static bool TryGetFittedHeldBallCentreOffset(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, MagnetComponent magnet, out Vector3 offset) + { + offset = Vector3.zero; + if (!hinge || !proxy || !magnet) { + return false; + } + var pole = ToPlayfieldVpx(hinge, magnet.transform.position); + if (!TryGetProxyDistance(hinge, proxy, pole, 0f, + out _, out var witness, out var normal)) { + return false; + } + var heldCentre = ToWorld(hinge, witness + normal * StandardBallRadiusVpx); + offset = Quaternion.Inverse(magnet.transform.rotation) + * (heldCentre - magnet.transform.position) / Physics.ScaleInv; + return true; + } + + private static bool TryGetProxyDistance(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, Vector3 point, float sphereRadius, + out float separation, out Vector3 witness, out Vector3 normal) + { + separation = 0f; + witness = Vector3.zero; + normal = Vector3.zero; + try { + var collider = SpringHingeColliderGenerator.Create(hinge, proxy, + new ColliderInfo { ItemId = hinge.ItemId }, 0f); + var hingeState = hinge.CreateState(); + hingeState.Movement.Angle = 0f; + var pointVpx = (float3)point; + var distance = collider.Distance(in hingeState, in pointVpx, sphereRadius); + separation = distance.Separation; + witness = distance.Witness; + normal = distance.Normal; + return true; + } catch (InvalidOperationException) { + return false; + } + } + + private static Vector3 ToPlayfieldVpx(SpringHingeComponent hinge, Vector3 worldPoint) + { + var playfield = hinge.GetComponentInParent(); + return playfield ? worldPoint.TranslateToVpx(playfield.transform) : worldPoint.TranslateToVpx(); + } + + private static Vector3 ToWorld(SpringHingeComponent hinge, Vector3 point) + { + var playfield = hinge.GetComponentInParent(); + return playfield ? point.TranslateToWorld(playfield.transform) : point.TranslateToWorld(); + } + + private static bool HasRigidFrame(Transform transform) + { + var matrix = transform.localToWorldMatrix; + var x = matrix.GetColumn(0); + var y = matrix.GetColumn(1); + var z = matrix.GetColumn(2); + if (x.sqrMagnitude < 1e-8f || y.sqrMagnitude < 1e-8f || z.sqrMagnitude < 1e-8f) { + return false; + } + x.Normalize(); + y.Normalize(); + z.Normalize(); + return Mathf.Abs(Vector3.Dot(x, y)) < 1e-4f + && Mathf.Abs(Vector3.Dot(x, z)) < 1e-4f + && Mathf.Abs(Vector3.Dot(y, z)) < 1e-4f; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs.meta new file mode 100644 index 000000000..fcff7678f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b40ab811cc04e1ca14c955bb4bd00ac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs new file mode 100644 index 000000000..cf1c201fc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.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 UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(SpringHingeColliderComponent)), CanEditMultipleObjects] + public class SpringHingeColliderInspector : ItemInspector + { + private SerializedProperty _localCentre; + private SerializedProperty _localRotation; + private SerializedProperty _halfExtents; + private SerializedProperty _showColliderMesh; + private SerializedProperty _elasticity; + private SerializedProperty _elasticityFalloff; + private SerializedProperty _friction; + private SerializedProperty _hitEvent; + private SerializedProperty _hitThreshold; + private SerializedProperty _overwritePhysics; + private SerializedProperty _physicsMaterial; + private bool _materialFoldout = true; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + _localCentre = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.LocalCentre)); + _localRotation = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.LocalRotation)); + _halfExtents = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.HalfExtents)); + _showColliderMesh = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.ShowColliderMesh)); + _elasticity = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.Elasticity)); + _elasticityFalloff = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.ElasticityFalloff)); + _friction = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.Friction)); + _hitEvent = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.HitEvent)); + _hitThreshold = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.HitThreshold)); + _overwritePhysics = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.OverwritePhysics)); + _physicsMaterial = serializedObject.FindProperty(nameof(SpringHingeColliderComponent.PhysicsMaterial)); + } + + public override void OnInspectorGUI() + { + EditorGUILayout.HelpBox("This component applies the simulated hinge angle to the GameObject. Its authored local rotation is the zero-angle pose, and the rotation axis comes from Spring Hinge.", MessageType.Info); + BeginEditing(); + EditorGUILayout.LabelField("Analytic Box", EditorStyles.boldLabel); + PropertyField(_localCentre, updateColliders: true); + PropertyField(_localRotation, updateColliders: true); + PropertyField(_halfExtents, updateColliders: true); + EditorGUI.BeginChangeCheck(); + PropertyField(_showColliderMesh, "Show Collider"); + if (EditorGUI.EndChangeCheck()) { + SceneView.RepaintAll(); + } + PropertyField(_hitEvent); + if (_hitEvent.hasMultipleDifferentValues || _hitEvent.boolValue) { + PropertyField(_hitThreshold); + } + + if (_materialFoldout = EditorGUILayout.BeginFoldoutHeaderGroup(_materialFoldout, "Physics Material")) { + using (new EditorGUI.DisabledScope(_overwritePhysics.boolValue)) { + PropertyField(_physicsMaterial, "Preset", updateColliders: true); + } + PropertyField(_overwritePhysics, updateColliders: true); + using (new EditorGUI.DisabledScope(!_overwritePhysics.boolValue)) { + PropertyField(_elasticity, updateColliders: true); + PropertyField(_elasticityFalloff, updateColliders: true); + PropertyField(_friction, updateColliders: true); + } + } + EditorGUILayout.EndFoldoutHeaderGroup(); + EndEditing(); + } + + private void OnSceneGUI() + { + if (target is not SpringHingeColliderComponent proxy) { + return; + } + var hinge = proxy.GetComponent(); + if (!hinge) { + return; + } + + var hingePose = hinge.transform.localToWorldMatrix; + var centre = hingePose.MultiplyPoint3x4(proxy.LocalCentre * Physics.ScaleInv); + var rotation = hingePose.rotation * Quaternion.Euler(proxy.LocalRotation); + var handleSize = HandleUtility.GetHandleSize(centre) * 0.5f; + EditorGUI.BeginChangeCheck(); + var movedCentre = Handles.PositionHandle(centre, rotation); + var resized = Handles.ScaleHandle(proxy.HalfExtents * Physics.ScaleInv, centre, rotation, handleSize); + if (EditorGUI.EndChangeCheck()) { + Undo.RecordObject(proxy, "Edit Spring Hinge Proxy"); + proxy.LocalCentre = hingePose.inverse.MultiplyPoint3x4(movedCentre) / Physics.ScaleInv; + proxy.HalfExtents = Vector3.Max(resized / Physics.ScaleInv, Vector3.one * 0.001f); + proxy.CollidersDirty = true; + EditorUtility.SetDirty(proxy); + } + + var matrix = hingePose + * Matrix4x4.TRS(proxy.LocalCentre * Physics.ScaleInv, + Quaternion.Euler(proxy.LocalRotation), Vector3.one); + if (!proxy.ShowColliderMesh) { + using (new Handles.DrawingScope(new Color(0f, 1f, 1f, 0.8f), matrix)) { + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * (2f * Physics.ScaleInv)); + } + } + if (!Application.isPlaying) { + DrawSweep(hinge, proxy, hinge.MinimumAngle, new Color(1f, 0.6f, 0f, 0.35f)); + DrawSweep(hinge, proxy, hinge.MaximumAngle, new Color(1f, 0.6f, 0f, 0.35f)); + } + } + + private static void DrawSweep(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, float angle, Color color) + { + var axis = hinge.HingeAxis.sqrMagnitude > 1e-8f + ? hinge.HingeAxis.normalized + : Vector3.right; + var rotation = Quaternion.AngleAxis(angle, axis); + var matrix = hinge.transform.localToWorldMatrix + * Matrix4x4.Rotate(rotation) + * Matrix4x4.TRS(proxy.LocalCentre * Physics.ScaleInv, + Quaternion.Euler(proxy.LocalRotation), Vector3.one); + using (new Handles.DrawingScope(color, matrix)) { + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * (2f * Physics.ScaleInv)); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs.meta new file mode 100644 index 000000000..03c29c7d1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 782c5ad411624186a68ef85f146132f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs new file mode 100644 index 000000000..aac16b508 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs @@ -0,0 +1,163 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(SpringHingeComponent)), CanEditMultipleObjects] + public class SpringHingeInspector : ItemInspector + { + private SerializedProperty _axis; + private SerializedProperty _centreOfMass; + private SerializedProperty _toyMass; + private SerializedProperty _overrideInertia; + private SerializedProperty _manualInertia; + private SerializedProperty _massBoxHalfExtents; + private SerializedProperty _springStiffness; + private SerializedProperty _springDamping; + private SerializedProperty _equilibriumAngle; + private SerializedProperty _minimumAngle; + private SerializedProperty _maximumAngle; + private SerializedProperty _initialAngle; + private SerializedProperty _enableAngleSwitch; + private SerializedProperty _switchCloseAngle; + private SerializedProperty _switchOpenAngle; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + _axis = serializedObject.FindProperty(nameof(SpringHingeComponent.HingeAxis)); + _centreOfMass = serializedObject.FindProperty(nameof(SpringHingeComponent.CentreOfMass)); + _toyMass = serializedObject.FindProperty(nameof(SpringHingeComponent.ToyMass)); + _overrideInertia = serializedObject.FindProperty(nameof(SpringHingeComponent.OverrideInertia)); + _manualInertia = serializedObject.FindProperty(nameof(SpringHingeComponent.ManualInertia)); + _massBoxHalfExtents = serializedObject.FindProperty(nameof(SpringHingeComponent.MassBoxHalfExtents)); + _springStiffness = serializedObject.FindProperty(nameof(SpringHingeComponent.SpringStiffness)); + _springDamping = serializedObject.FindProperty(nameof(SpringHingeComponent.SpringDamping)); + _equilibriumAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.EquilibriumAngle)); + _minimumAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.MinimumAngle)); + _maximumAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.MaximumAngle)); + _initialAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.InitialAngle)); + _enableAngleSwitch = serializedObject.FindProperty(nameof(SpringHingeComponent.EnableAngleSwitch)); + _switchCloseAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.SwitchCloseAngle)); + _switchOpenAngle = serializedObject.FindProperty(nameof(SpringHingeComponent.SwitchOpenAngle)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + EditorGUILayout.LabelField("Pivot and Mass", EditorStyles.boldLabel); + PropertyField(_axis); + PropertyField(_centreOfMass); + PropertyField(_toyMass, "Toy Mass (ball-relative)"); + PropertyField(_overrideInertia); + if (_overrideInertia.hasMultipleDifferentValues || _overrideInertia.boolValue) { + PropertyField(_manualInertia); + } else { + PropertyField(_massBoxHalfExtents); + } + + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("Spring and Stops", EditorStyles.boldLabel); + PropertyField(_springStiffness); + PropertyField(_springDamping); + PropertyField(_equilibriumAngle); + PropertyField(_minimumAngle); + PropertyField(_maximumAngle); + PropertyField(_initialAngle); + + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("Angle Switch", EditorStyles.boldLabel); + PropertyField(_enableAngleSwitch); + if (_enableAngleSwitch.hasMultipleDifferentValues || _enableAngleSwitch.boolValue) { + PropertyField(_switchCloseAngle); + PropertyField(_switchOpenAngle); + } + EndEditing(); + + if (targets.Length == 1) { + DrawSetupActions((SpringHingeComponent)target); + } + EditorGUILayout.HelpBox("The analytic proxy supports the spring hinge, balls, and passive surfaces. An attached ball releases before unsupported active mechanisms such as flippers and bumpers act on it.", MessageType.Info); + } + + private static void DrawSetupActions(SpringHingeComponent hinge) + { + var proxy = hinge.GetComponent(); + if (!proxy) { + if (GUILayout.Button("Add Analytic Box Proxy")) { + Undo.AddComponent(hinge.gameObject); + } + return; + } + + using (new EditorGUILayout.HorizontalScope()) { + if (GUILayout.Button("Fit From Renderers")) { + Undo.RecordObjects(new Object[] { hinge, proxy }, "Fit Spring Hinge Visual Bounds"); + if (!SpringHingeAuthoring.FitFromVisuals(hinge, proxy)) { + Debug.LogWarning($"Spring hinge '{hinge.name}' has no renderers to fit.", hinge); + } + EditorUtility.SetDirty(hinge); + EditorUtility.SetDirty(proxy); + } + if (GUILayout.Button("Apply Bash Preset")) { + var magnet = hinge.GetComponentInChildren(true); + var objects = magnet ? new Object[] { hinge, proxy, magnet } : new Object[] { hinge, proxy }; + Undo.RecordObjects(objects, "Apply Spring Hinge Bash Preset"); + SpringHingeAuthoring.ApplyBashPreset(hinge, proxy, magnet); + foreach (var changed in objects) { + EditorUtility.SetDirty(changed); + } + } + } + + foreach (var issue in SpringHingeAuthoring.Validate(hinge, proxy)) { + EditorGUILayout.HelpBox(issue, MessageType.Error); + } + } + + private void OnSceneGUI() + { + if (target is not SpringHingeComponent hinge) { + return; + } + var pivot = hinge.transform.position; + var localAxis = hinge.HingeAxis.sqrMagnitude > 1e-8f ? hinge.HingeAxis.normalized : Vector3.right; + var worldAxis = hinge.transform.TransformDirection(localAxis).normalized; + var localReference = Vector3.Cross(localAxis, Vector3.forward); + if (localReference.sqrMagnitude < 1e-6f) { + localReference = Vector3.Cross(localAxis, Vector3.up); + } + var worldReference = hinge.transform.TransformDirection(localReference.normalized).normalized; + var radius = HandleUtility.GetHandleSize(pivot) * 0.3f; + + Handles.color = Color.cyan; + Handles.DrawLine(pivot - worldAxis * radius, pivot + worldAxis * radius, 3f); + Handles.ArrowHandleCap(0, pivot, Quaternion.LookRotation(worldAxis), radius, EventType.Repaint); + Handles.color = new Color(1f, 0.65f, 0.1f, 0.9f); + var start = Quaternion.AngleAxis(hinge.MinimumAngle, worldAxis) * worldReference; + Handles.DrawWireArc(pivot, worldAxis, start, + hinge.MaximumAngle - hinge.MinimumAngle, radius); + + var centreWorld = hinge.transform.TransformPoint(hinge.CentreOfMass * Physics.ScaleInv); + EditorGUI.BeginChangeCheck(); + var movedCentre = Handles.PositionHandle(centreWorld, hinge.transform.rotation); + if (EditorGUI.EndChangeCheck()) { + Undo.RecordObject(hinge, "Move Spring Hinge Centre of Mass"); + hinge.CentreOfMass = hinge.transform.InverseTransformPoint(movedCentre) / Physics.ScaleInv; + EditorUtility.SetDirty(hinge); + } + Handles.color = Color.yellow; + Handles.SphereHandleCap(0, centreWorld, Quaternion.identity, radius * 0.12f, EventType.Repaint); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs.meta new file mode 100644 index 000000000..b4de8a55d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 295970c11bdf44a0b4bda663f1520bf6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index fa0f350b5..68a732cd6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -1896,12 +1896,15 @@ internal sealed class PhysicsStateHarness : IDisposable internal NativeParallelHashMap Balls; internal NativeParallelHashMap KinematicTransforms; internal NativeParallelHashMap KinematicVelocities; + internal NativeParallelHashMap MagnetStates; + internal NativeParallelHashMap SpringHingeStates; internal InsideOfs InsideOfs; internal NativeQueue EventQueue; private PhysicsEnv _env; private NativeOctree _octree; private NativeColliders _colliders; + private bool _ownsColliders; private NativeColliders _kinematicColliders; private NativeColliders _kinematicCollidersAtIdentity; private NativeParallelHashMap _kinematicTargetTransforms; @@ -1913,7 +1916,6 @@ internal sealed class PhysicsStateHarness : IDisposable private NativeParallelHashMap _gateStates; private NativeParallelHashMap _hitTargetStates; private NativeParallelHashMap _kickerStates; - private NativeParallelHashMap _magnetStates; private NativeParallelHashMap _plungerStates; private NativeParallelHashMap _spinnerStates; private NativeParallelHashMap _surfaceStates; @@ -1929,6 +1931,12 @@ internal PhysicsStateHarness() Balls = new NativeParallelHashMap(4, Allocator.Persistent); KinematicTransforms = new NativeParallelHashMap(4, Allocator.Persistent); KinematicVelocities = new NativeParallelHashMap(4, Allocator.Persistent); + MagnetStates = new NativeParallelHashMap(4, Allocator.Persistent); + SpringHingeStates = new NativeParallelHashMap(4, Allocator.Persistent); + _flipperStates = new NativeParallelHashMap(1, Allocator.Persistent); + _gateStates = new NativeParallelHashMap(1, Allocator.Persistent); + _plungerStates = new NativeParallelHashMap(1, Allocator.Persistent); + _spinnerStates = new NativeParallelHashMap(1, Allocator.Persistent); InsideOfs = new InsideOfs(Allocator.Persistent); EventQueue = new NativeQueue(Allocator.Persistent); } @@ -1940,18 +1948,37 @@ internal PhysicsState CreateState() ref _kinematicCollidersAtIdentity, ref KinematicTransforms, ref _kinematicTargetTransforms, ref _nonTransformableColliderTransforms, ref _kinematicColliderLookups, ref events, ref InsideOfs, ref Balls, ref _bumperStates, ref _dropTargetStates, ref _flipperStates, ref _gateStates, - ref _hitTargetStates, ref _kickerStates, ref _magnetStates, ref _plungerStates, ref _spinnerStates, + ref _hitTargetStates, ref _kickerStates, ref MagnetStates, ref _plungerStates, ref _spinnerStates, + ref SpringHingeStates, ref _surfaceStates, ref _turntableStates, ref _triggerStates, ref _disabledCollisionItems, ref _swapBallCollisionHandling, ref _elasticityLuts, ref _frictionLuts, ref KinematicVelocities); } + internal void SetStaticColliders(ref ColliderReference colliders) + { + if (_ownsColliders) { + _colliders.Dispose(); + } + _colliders = new NativeColliders(ref colliders, Allocator.Persistent); + _ownsColliders = true; + } + public void Dispose() { Balls.Dispose(); KinematicTransforms.Dispose(); KinematicVelocities.Dispose(); + MagnetStates.Dispose(); + SpringHingeStates.Dispose(); + _flipperStates.Dispose(); + _gateStates.Dispose(); + _plungerStates.Dispose(); + _spinnerStates.Dispose(); InsideOfs.Dispose(); EventQueue.Dispose(); + if (_ownsColliders) { + _colliders.Dispose(); + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs new file mode 100644 index 000000000..2f0bbaf5f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs @@ -0,0 +1,781 @@ +// 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; +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; +using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity.Test +{ + public class OwnedMagnetPhysicsTests + { + [Test] + public void UnconstrainedHoldExchangesProjectedMomentumReciprocally() + { + var hinge = CreateHinge(inertia: 5f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var magnet = CreateMagnet(stiffness: 40f, damping: 8f, maxForce: 10000f); + var target = new float3(2f, 0f, 0f); + var ball = CreateBall(1, target + new float3(0.2f, 0.1f, 0f), + new float3(0.3f, 1.2f, 0f)); + var before = ProjectedAngularMomentum(in ball, in hinge); + var oldVelocity = ball.Velocity; + + var held = OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, + in target, 0.01f, out var saturated); + + Assert.That(held, Is.True); + Assert.That(saturated, Is.False); + Assert.That(ProjectedAngularMomentum(in ball, in hinge), + Is.EqualTo(before).Within(2e-5f)); + AssertFloat3(ball.ExternalAcceleration, (ball.Velocity - oldVelocity) / 0.01f); + } + + [Test] + public void TightHoldApproachesLoadedHingeInertiaWithoutMassDoubleCounting() + { + const float step = 0.001f; + const float toyInertia = 4f; + const float hingeStiffness = 2f; + const float angleError = 0.2f; + const float radius = 2f; + var hinge = CreateHinge(inertia: toyInertia, stiffness: hingeStiffness, + angle: angleError); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + var magnet = CreateMagnet(stiffness: 1e9f, damping: 0f, maxForce: 1e9f); + var target = new float3(radius, 0f, 0f); + var ball = CreateBall(1, target, float3.zero); + + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out _); + + var loadedInertia = toyInertia + ball.Mass * radius * radius; + var expected = -step * hingeStiffness * angleError + / (loadedInertia + step * step * hingeStiffness); + Assert.That(hinge.Movement.AngularVelocity, Is.EqualTo(expected).Within(2e-7f)); + } + + [Test] + public void HoldCapIsAProjectedVectorImpulse() + { + const float step = 0.1f; + var hinge = CreateHinge(inertia: 2f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + var magnet = CreateMagnet(stiffness: 100f, damping: 20f, maxForce: 3f); + var target = new float3(2f, 0f, 0f); + var ball = CreateBall(1, target + new float3(1f, 1f, 1f), + new float3(3f, -2f, 1f)); + var oldVelocity = ball.Velocity; + + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out var saturated); + + var impulse = ball.Mass * (ball.Velocity - oldVelocity); + Assert.That(saturated, Is.True); + Assert.That(math.length(impulse), Is.EqualTo(magnet.MaxHoldForce * step).Within(2e-6f)); + } + + [Test] + public void OwnedHoldSmoothlyDampsBallSpin() + { + const float step = 0.1f; + var hinge = CreateHinge(inertia: 10f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + var magnet = CreateMagnet(stiffness: 4f, damping: 4f, maxForce: 50f); + var target = new float3(2f, 0f, 0f); + var ball = CreateBall(1, target, float3.zero); + ball.AngularMomentum = new float3(3f, -4f, 5f); + var originalSpin = ball.AngularMomentum; + + Assert.That(OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, + in target, step, out _), Is.True); + + AssertFloat3(ball.AngularMomentum, originalSpin * 0.95f); + Assert.That(math.length(ball.AngularMomentum), Is.GreaterThan(0f), + "capture must damp spin gradually rather than stop it in one tick"); + } + + [Test] + public void BallGravityTransfersThroughHoldWithoutDoubleCountingMass() + { + const float step = 0.01f; + var gravity = new float3(0f, -1f, 0f); + var hinge = CreateHinge(inertia: 4f); + hinge.Static.CentreOfMassArm = new float3(2f, 0f, 0f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, in gravity, step); + var magnet = CreateMagnet(stiffness: 1e7f, damping: 1000f, maxForce: 1e9f); + var target = new float3(2f, 0f, 0f); + var ball = CreateBall(1, target, gravity * step); + + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out _); + + var toyTorque = math.dot(hinge.Static.Axis, + math.cross(hinge.Static.CentreOfMassArm, hinge.Static.Mass * gravity)); + var ballTorque = math.dot(hinge.Static.Axis, + math.cross(ball.Position - hinge.Static.Pivot, ball.Mass * gravity)); + Assert.That(ProjectedAngularMomentum(in ball, in hinge), + Is.EqualTo(step * (toyTorque + ballTorque)).Within(2e-4f)); + } + + [TestCase(1f)] + [TestCase(2f)] + public void LoadedOscillationPeriodConvergesToPointMassIdentity(float radius) + { + var coarse = MeasureLoadedPeriod(radius, 0.0002f); + var fine = MeasureLoadedPeriod(radius, 0.0001f); + var expected = math.TAU * math.sqrt((4f + radius * radius) / 200f); + + Assert.That(coarse, Is.EqualTo(expected).Within(expected * 0.02f)); + Assert.That(fine, Is.EqualTo(expected).Within(expected * 0.012f)); + Assert.That(math.abs(fine - expected), Is.LessThanOrEqualTo(math.abs(coarse - expected) + 0.002f)); + } + + [Test] + public void ActiveStopBlocksOutwardHoldButAllowsReleaseDirection() + { + const float step = 0.01f; + var hinge = CreateHinge(inertia: 2f); + hinge.Movement.ActiveStop = 1; + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + var magnet = CreateMagnet(stiffness: 100f, damping: 0f, maxForce: 10000f); + var target = new float3(2f, 0f, 0f); + var ball = CreateBall(1, target + new float3(0f, 1f, 0f), float3.zero); + + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out _); + + Assert.That(hinge.Movement.AngularVelocity, Is.Zero); + Assert.That(hinge.Movement.ActiveStop, Is.EqualTo(1)); + + hinge = CreateHinge(inertia: 2f); + hinge.Movement.ActiveStop = 1; + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + ball = CreateBall(1, target - new float3(0f, 1f, 0f), float3.zero); + + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out _); + + Assert.That(hinge.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(hinge.Movement.ActiveStop, Is.Zero); + } + + [Test] + public void CaptureArbitrationChoosesNearestThenStableBallId() + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var magnet = CreateMagnet(stiffness: 100f, damping: 10f, maxForce: 10000f); + harness.MagnetStates.Add(20, magnet); + harness.Balls.Add(2, CreateBall(2, new float3(10.1f, 3f, 0f), float3.zero)); + harness.Balls.Add(1, CreateBall(1, new float3(9.9f, 3f, 0f), float3.zero)); + var state = harness.CreateState(); + ref var stateHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, 0.01f); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + Assert.That(state.MagnetStates[20].AttachedBallId, Is.EqualTo(1)); + Assert.That(state.Balls[1].AttachedMagnetId, Is.EqualTo(20)); + Assert.That(state.Balls[2].AttachedMagnetId, Is.Zero); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void CaptureRejectsHeldTargetAwayFromProxySurface() + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var magnet = CreateMagnet(stiffness: 100f, damping: 10f, maxForce: 10000f); + magnet.LocalHeldCentreArm = new float3(10f, 10f, 0f); + harness.MagnetStates.Add(20, magnet); + harness.Balls.Add(1, CreateBall(1, new float3(10f, 10f, 0f), float3.zero)); + var state = harness.CreateState(); + ref var stateHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, 0.01f); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + Assert.That(state.MagnetStates[20].AttachedBallId, Is.Zero); + Assert.That(state.Balls[1].AttachedMagnetId, Is.Zero); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void CaptureRequiresAvailableWorkEvenAtZeroRelativeSpeed() + { + var hinge = CreateHinge(inertia: 10f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var magnet = CreateMagnet(stiffness: 100f, damping: 10f, maxForce: 10000f); + var pole = new float3(10f, 0f, 0f); + var target = new float3(10f, 3f, 0f); + var ball = CreateBall(1, target, float3.zero); + + magnet.EffectiveCurrent = 0f; + magnet.EffectiveStrength = 0f; + Assert.That(OwnedMagnetPhysics.CanCapture(in ball, in magnet, in hinge, + in pole, in target), Is.False); + + magnet.EffectiveCurrent = 1f; + magnet.EffectiveStrength = magnet.Strength; + Assert.That(OwnedMagnetPhysics.CanCapture(in ball, in magnet, in hinge, + in pole, in target), Is.True); + } + + [TestCase(0.8f, true)] + [TestCase(1.2f, false)] + public void CaptureUsesSolverContactTolerance(float penetrationMultiplier, + bool shouldCapture) + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + harness.MagnetStates.Add(20, + CreateMagnet(stiffness: 100f, damping: 10f, maxForce: 10000f)); + harness.Balls.Add(1, CreateBall(1, + new float3(10f, 3f - PhysicsConstants.PhysTouch * penetrationMultiplier, 0f), + float3.zero)); + var state = harness.CreateState(); + ref var stateHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, 0.01f); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + Assert.That(state.MagnetStates[20].AttachedBallId, + Is.EqualTo(shouldCapture ? 1 : 0)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void CaptureEstimateBoundsStationaryHingeThreshold() + { + var hinge = CreateHinge(inertia: 1e20f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var magnet = CreateMagnet(stiffness: 4f, damping: 4f, maxForce: 50f); + var pole = new float3(10f, 0f, 0f); + var target = new float3(10f, 3f, 0f); + var speed = OwnedMagnetPhysics.EstimateStationaryHingeCaptureSpeed(in magnet, + in pole, in target); + var accepted = CreateBall(1, target, new float3(0f, 0f, speed * 0.99f)); + var rejected = CreateBall(2, target, new float3(0f, 0f, speed * 1.01f)); + + Assert.That(OwnedMagnetPhysics.CanCapture(in accepted, in magnet, in hinge, + in pole, in target), Is.True); + Assert.That(OwnedMagnetPhysics.CanCapture(in rejected, in magnet, in hinge, + in pole, in target), Is.False); + } + + [Test] + public void BashMagnetCapturesPostImpactStandardBall() + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 2500f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + var pivot = float3.zero; + var centre = new float3(100f, 0f, 0f); + var extents = new float3(10f, 100f, 100f); + var x = new float3(1f, 0f, 0f); + var y = new float3(0f, 1f, 0f); + var z = new float3(0f, 0f, 1f); + references.Add(new SpringHingeCollider(hinge.AnimationItemId, in pivot, + in centre, in extents, in x, in y, in z, + new ColliderInfo { ItemId = hinge.AnimationItemId })); + harness.SetStaticColliders(ref references); + + var target = new float3(135f, 0f, 0f); + var magnet = CreateMagnet(stiffness: 4f, damping: 4f, maxForce: 50f); + magnet.Radius = 45.5f; + magnet.Strength = 40000f; + magnet.EffectiveStrength = magnet.Strength; + magnet.PoleRadius = 11.79f; + magnet.GrabRadius = 20.009268f; + magnet.LocalPoleArm = new float3(110f, 0f, 0f); + magnet.LocalHeldCentreArm = target; + harness.MagnetStates.Add(20, magnet); + harness.Balls.Add(1, new BallState { + Id = 1, + Position = target - new float3(PhysicsConstants.PhysTouch * 0.8f, 0f, 0f), + Velocity = new float3(3f, 0f, 0f), + Mass = 1f, + Radius = 25f + }); + var state = harness.CreateState(); + ref var stateHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, + PhysicsConstants.PhysFactor); + + OwnedMagnetPhysics.Update(ref state, PhysicsConstants.PhysFactor); + + Assert.That(state.MagnetStates[20].AttachedBallId, Is.EqualTo(1)); + Assert.That(state.Balls[1].AttachedMagnetId, Is.EqualTo(20)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void SchedulerAdvancesOwnedCoilAndCommitsHingeExactlyOnce() + { + using var harness = new PhysicsStateHarness(); + var hinge = CreateHinge(inertia: 4f, stiffness: 2f, angle: 0.2f); + harness.SpringHingeStates.Add(12, hinge); + var magnet = CreateMagnet(stiffness: 100f, damping: 10f, maxForce: 1000f); + magnet.EffectiveCurrent = 0f; + magnet.EffectiveStrength = 0f; + magnet.RiseTime = 1f; + harness.MagnetStates.Add(20, magnet); + var state = harness.CreateState(); + PhysicsUpdate.UpdateSpringHingeVelocities(ref state, float3.zero, float2.zero, 0.1f); + + PhysicsUpdate.UpdateMagnetsAndCommitSpringHinges(ref state, 0.1f); + + var expectedCurrent = 0.1f / 1.1f; + Assert.That(state.MagnetStates[20].EffectiveCurrent, + Is.EqualTo(expectedCurrent).Within(1e-6f)); + Assert.That(state.SpringHingeStates[12].Movement.VelocityCommitted, Is.True); + var expectedOmega = -0.1f * 2f * 0.2f / (4f + 0.01f * 2f); + Assert.That(state.SpringHingeStates[12].Movement.AngularVelocity, + Is.EqualTo(expectedOmega).Within(1e-6f)); + } + + [Test] + public void SchedulerPreservesLegacyMagnetMembership() + { + using var harness = new PhysicsStateHarness(); + var magnet = CreateMagnet(stiffness: 0f, damping: 0f, maxForce: 0f); + magnet.CoupleToHinge = false; + magnet.GrabRadius = 0f; + magnet.LocalPoleArm = float3.zero; + magnet.Position = float2.zero; + magnet.Height = 0f; + harness.MagnetStates.Add(20, magnet); + harness.Balls.Add(1, CreateBall(1, new float3(2f, 0f, 0f), float3.zero)); + var state = harness.CreateState(); + + PhysicsUpdate.UpdateMagnetsAndCommitSpringHinges(ref state, 0.01f); + + Assert.That(harness.InsideOfs.IsInsideOf(20, 1), Is.True); + } + + [Test] + public void ReleasePreservesBallAndHingeMotionAndFiresOnce() + { + using var harness = CreateAttachedHarness(out var references, out var transforms); + try { + ref var magnet = ref harness.MagnetStates.GetValueByRef(20); + magnet.EffectiveCurrent = 0f; + magnet.EffectiveStrength = 0f; + ref var ball = ref harness.Balls.GetValueByRef(1); + ball.Velocity = new float3(1f, 2f, 3f); + ball.AngularMomentum = new float3(4f, 5f, 6f); + var oldVelocity = ball.Velocity; + var oldSpin = ball.AngularMomentum; + ref var hinge = ref harness.SpringHingeStates.GetValueByRef(12); + hinge.Movement.AngularVelocity = 0.75f; + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var state = harness.CreateState(); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + AssertFloat3(state.Balls[1].Velocity, oldVelocity); + AssertFloat3(state.Balls[1].AngularMomentum, oldSpin); + Assert.That(state.SpringHingeStates[12].Movement.AngularVelocity, + Is.EqualTo(0.75f).Within(1e-6f)); + Assert.That(state.MagnetStates[20].AttachedBallId, Is.Zero); + Assert.That(state.Balls[1].AttachedMagnetId, Is.Zero); + Assert.That(CountEvents(harness, EventId.MagnetEventsBallReleased), Is.EqualTo(1)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void HardSecondBallHitReleasesHeldBallBeforeCapturingReplacement() + { + using var harness = CreateAttachedHarness(out var references, out var transforms); + try { + ref var heldBall = ref harness.Balls.GetValueByRef(1); + heldBall.Velocity = new float3(0f, 100f, 0f); + harness.Balls.Add(2, CreateBall(2, new float3(10f, 3f, 0f), float3.zero)); + ref var hinge = ref harness.SpringHingeStates.GetValueByRef(12); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var state = harness.CreateState(); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + Assert.That(state.Balls[1].AttachedMagnetId, Is.Zero); + Assert.That(state.Balls[2].AttachedMagnetId, Is.EqualTo(20)); + Assert.That(state.MagnetStates[20].AttachedBallId, Is.EqualTo(2)); + var released = 0; + var grabbed = 0; + while (harness.EventQueue.TryDequeue(out var eventData)) { + released += eventData.EventId == EventId.MagnetEventsBallReleased ? 1 : 0; + grabbed += eventData.EventId == EventId.MagnetEventsBallGrabbed ? 1 : 0; + } + Assert.That(released, Is.EqualTo(1)); + Assert.That(grabbed, Is.EqualTo(1)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void SustainedSeparatingCapSaturationReleasesOnce() + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var magnet = CreateMagnet(stiffness: 1000f, damping: 100f, maxForce: 0.01f); + magnet.AttachedBallId = 1; + var ball = CreateBall(1, new float3(10f, 3.1f, 0f), new float3(0f, 10f, 0f)); + ball.AttachedMagnetId = 20; + harness.Balls.Add(1, ball); + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + harness.MagnetStates.Add(20, magnet); + var state = harness.CreateState(); + + for (var i = 0; i < 3; i++) { + ref var stateHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, 0.01f); + OwnedMagnetPhysics.Update(ref state, 0.01f); + ref var stateBall = ref state.Balls.GetValueByRef(1); + stateBall.Position += stateBall.Velocity * 0.01f; + } + + Assert.That(state.MagnetStates[20].AttachedBallId, Is.Zero); + Assert.That(state.Balls[1].AttachedMagnetId, Is.Zero); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void AttachedBallUsesGapHysteresisBeyondGrabRadius() + { + using var harness = CreateAttachedHarness(out var references, out var transforms); + try { + ref var ball = ref harness.Balls.GetValueByRef(1); + ball.Position = new float3(16f, 3f, 0f); + ref var hinge = ref harness.SpringHingeStates.GetValueByRef(12); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, 0.01f); + var state = harness.CreateState(); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + Assert.That(state.MagnetStates[20].AttachedBallId, Is.EqualTo(1)); + Assert.That(state.Balls[1].AttachedMagnetId, Is.EqualTo(20)); + Assert.That(CountEvents(harness, EventId.MagnetEventsBallReleased), Is.Zero); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void EqualDistanceCaptureUsesBallIdIndependentOfRegistrationOrder() + { + Assert.That(CaptureFromRegistrationOrder(1, 2), Is.EqualTo(1)); + Assert.That(CaptureFromRegistrationOrder(2, 1), Is.EqualTo(1)); + } + + [Test] + public void UnsupportedActiveInteractionReleasesOnceAndPreservesMotion() + { + using var harness = CreateAttachedHarness(out var references, out var transforms); + try { + ref var ball = ref harness.Balls.GetValueByRef(1); + ball.Velocity = new float3(1f, 2f, 3f); + ball.AngularMomentum = new float3(4f, 5f, 6f); + var velocity = ball.Velocity; + var spin = ball.AngularMomentum; + var state = harness.CreateState(); + + Assert.That(MagnetPhysics.ReleaseOwnedAttachmentForUnsupportedInteraction( + ref state, ref ball, 77), Is.True); + Assert.That(MagnetPhysics.ReleaseOwnedAttachmentForUnsupportedInteraction( + ref state, ref ball, 77), Is.False); + + AssertFloat3(ball.Velocity, velocity); + AssertFloat3(ball.AngularMomentum, spin); + Assert.That(ball.AttachedMagnetId, Is.Zero); + Assert.That(state.MagnetStates[20].AttachedBallId, Is.Zero); + var released = 0; + var diagnosed = 0; + while (harness.EventQueue.TryDequeue(out var eventData)) { + released += eventData.EventId == EventId.MagnetEventsBallReleased ? 1 : 0; + diagnosed += eventData.EventId == EventId.PhysicsDiagnosticsUnsupportedOwnedInteraction ? 1 : 0; + } + Assert.That(released, Is.EqualTo(1)); + Assert.That(diagnosed, Is.EqualTo(1)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void TurntableReleasesAttachedBallBeforeApplyingLegacyForce() + { + using var harness = CreateAttachedHarness(out var references, out var transforms); + try { + var state = harness.CreateState(); + var turntable = new TurntableState { + Position = float2.zero, + Radius = 100f, + Speed = 10f, + TargetSpeed = 10f, + MotorOn = true + }; + + TurntablePhysics.Update(77, ref turntable, ref state, PhysicsConstants.PhysFactor); + + Assert.That(state.Balls[1].AttachedMagnetId, Is.Zero); + Assert.That(state.MagnetStates[20].AttachedBallId, Is.Zero); + Assert.That(math.lengthsq(state.Balls[1].Velocity), Is.GreaterThan(0f)); + var first = harness.EventQueue.Dequeue(); + var second = harness.EventQueue.Dequeue(); + Assert.That(first.EventId, Is.EqualTo(EventId.MagnetEventsBallReleased)); + Assert.That(second.EventId, Is.EqualTo(EventId.PhysicsDiagnosticsUnsupportedOwnedInteraction)); + Assert.That(second.IntParam, Is.EqualTo(77)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void PassiveSupportBalancesFullOwnedHoldLoad() + { + const float step = PhysicsConstants.PhysFactor; + var gravity = new float3(0f, -1f, 0f); + var hinge = CreateHinge(inertia: 10f); + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, in gravity, step); + var magnet = CreateMagnet(stiffness: 10000f, damping: 1000f, maxForce: 10000f); + var target = new float3(10f, 3f, 0f); + var ball = CreateBall(1, target, gravity * step); + + Assert.That(OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, + in target, step, out _), Is.True); + var contact = new CollisionEventData { + HitNormal = new float3(0f, 1f, 0f), + HitOrgNormalVelocity = ball.Velocity.y + }; + BallCollider.HandleStaticContact(ref ball, in contact, 0f, step, + in gravity, float3.zero); + + Assert.That(ball.Velocity.y, Is.GreaterThanOrEqualTo(-1e-5f)); + Assert.That(hinge.Movement.CommittedMagneticTorque, Is.Not.Zero); + } + + private static MagnetState CreateMagnet(float stiffness, float damping, float maxForce) + { + return new MagnetState { + Radius = 30f, + Strength = 1000f, + EffectiveCurrent = 1f, + EffectiveStrength = 1000f, + PoleRadius = 5f, + GrabRadius = 5f, + MagnetType = MagnetType.Spatial, + Profile = MagnetForceProfile.Physical, + CoupleToHinge = true, + HingeOwnerId = 12, + LocalPoleArm = new float3(10f, 0f, 0f), + LocalHeldCentreArm = new float3(10f, 3f, 0f), + HoldStiffness = stiffness, + HoldDamping = damping, + MaxHoldForce = maxForce, + IsEnabled = true, + CommandedPower = 1f + }; + } + + private static float MeasureLoadedPeriod(float radius, float step) + { + var hinge = CreateHinge(inertia: 4f, stiffness: 200f, angle: 0.05f); + var magnet = CreateMagnet(stiffness: 5e5f, damping: 1400f, maxForce: 1e9f); + var referenceArm = new float3(radius, 0f, 0f); + var initialTarget = SpringHingeVelocityPhysics.RotateAroundAxis(in referenceArm, + hinge.Static.Axis, hinge.Movement.Angle); + var ball = CreateBall(1, initialTarget, float3.zero); + var previousAngle = hinge.Movement.Angle; + var firstDownwardCrossing = -1f; + var elapsed = 0f; + for (var i = 0; i < 25000; i++) { + SpringHingeVelocityPhysics.PrepareVelocity(ref hinge, float3.zero, step); + var target = SpringHingeVelocityPhysics.RotateAroundAxis(in referenceArm, + hinge.Static.Axis, hinge.Movement.Angle); + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, in target, + step, out var saturated); + Assert.That(saturated, Is.False); + ball.Position += ball.Velocity * step; + SpringHingeDisplacementPhysics.UpdateDisplacement(ref hinge, step); + elapsed += step; + if (previousAngle > 0f && hinge.Movement.Angle <= 0f) { + var crossing = elapsed - step * (-hinge.Movement.Angle) + / (previousAngle - hinge.Movement.Angle); + if (firstDownwardCrossing >= 0f) { + return crossing - firstDownwardCrossing; + } + firstDownwardCrossing = crossing; + } + previousAngle = hinge.Movement.Angle; + } + Assert.Fail("Loaded hinge did not complete a measured period."); + return float.NaN; + } + + private static SpringHingeState CreateHinge(float inertia, float stiffness = 0f, + float angle = 0f) + { + return new SpringHingeState(12, new SpringHingeStaticState { + OwnerId = 12, + Pivot = float3.zero, + Axis = new float3(0f, 0f, 1f), + Mass = 1f, + Inertia = inertia, + EquilibriumAngle = 0f, + Stiffness = stiffness, + MinimumAngle = -math.PI, + MaximumAngle = math.PI + }, new SpringHingeMovementState { Angle = angle }); + } + + private static SpringHingeCollider CreateCollider() + { + var pivot = float3.zero; + var centre = new float3(10f, 0f, 0f); + var extents = new float3(5f, 2f, 2f); + var x = new float3(1f, 0f, 0f); + var y = new float3(0f, 1f, 0f); + var z = new float3(0f, 0f, 1f); + return new SpringHingeCollider(12, in pivot, in centre, in extents, + in x, in y, in z, new ColliderInfo { ItemId = 12 }); + } + + private static PhysicsStateHarness CreateAttachedHarness(out ColliderReference references, + out NativeParallelHashMap transforms) + { + var harness = new PhysicsStateHarness(); + transforms = new NativeParallelHashMap(1, Allocator.Temp); + references = new ColliderReference(ref transforms, Allocator.Temp); + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var magnet = CreateMagnet(stiffness: 1000f, damping: 100f, maxForce: 10000f); + magnet.AttachedBallId = 1; + var ball = CreateBall(1, new float3(10f, 3f, 0f), float3.zero); + ball.AttachedMagnetId = 20; + harness.Balls.Add(1, ball); + var bitIndex = harness.InsideOfs.GetOrCreateBitIndex(1); + magnet.GrabbedBalls.SetBits(bitIndex, true); + harness.MagnetStates.Add(20, magnet); + return harness; + } + + private static int CaptureFromRegistrationOrder(int firstBallId, int secondBallId) + { + using var harness = new PhysicsStateHarness(); + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var hinge = CreateHinge(inertia: 10f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + harness.MagnetStates.Add(20, + CreateMagnet(stiffness: 1000f, damping: 100f, maxForce: 10000f)); + harness.Balls.Add(firstBallId, CreateBall(firstBallId, + firstBallId == 1 ? new float3(9f, 3f, 0f) : new float3(11f, 3f, 0f), + float3.zero)); + harness.Balls.Add(secondBallId, CreateBall(secondBallId, + secondBallId == 1 ? new float3(9f, 3f, 0f) : new float3(11f, 3f, 0f), + float3.zero)); + ref var stateHinge = ref harness.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + SpringHingeVelocityPhysics.PrepareVelocity(ref stateHinge, float3.zero, 0.01f); + var state = harness.CreateState(); + + OwnedMagnetPhysics.Update(ref state, 0.01f); + + return state.MagnetStates[20].AttachedBallId; + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + private static int CountEvents(PhysicsStateHarness harness, EventId eventId) + { + var count = 0; + while (harness.EventQueue.TryDequeue(out var eventData)) { + if (eventData.EventId == eventId) { + count++; + } + } + return count; + } + + private static BallState CreateBall(int id, in float3 position, in float3 velocity) + => new() { Id = id, Position = position, Velocity = velocity, Mass = 1f, Radius = 1f }; + + private static float ProjectedAngularMomentum(in BallState ball, in SpringHingeState hinge) + => math.dot(hinge.Static.Axis, math.cross(ball.Position - hinge.Static.Pivot, + ball.Mass * ball.Velocity) + ball.AngularMomentum) + + hinge.Static.Inertia * hinge.Movement.AngularVelocity; + + private static void AssertFloat3(in float3 actual, in float3 expected) + { + Assert.That(actual.x, Is.EqualTo(expected.x).Within(2e-5f)); + Assert.That(actual.y, Is.EqualTo(expected.y).Within(2e-5f)); + Assert.That(actual.z, Is.EqualTo(expected.z).Within(2e-5f)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs.meta new file mode 100644 index 000000000..dbda3ddec --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b62ef2617361477da8c8097869d97331 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs new file mode 100644 index 000000000..792dcc6e4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs @@ -0,0 +1,237 @@ +// 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.Collections; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class PhysicsKinematicsTests + { + [Test] + public void LinearCatchUpSpeedDoesNotCompound() + { + const int itemId = 1; + const ulong appliedTimeUsec = 10_000_000; + const float measuredSpeed = 0.38f; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + + transforms.Add(itemId, float4x4.identity); + targets.Add(itemId, float4x4.Translate(new float3(60f, 0f, 0f))); + velocities.Add(itemId, new KinematicVelocityState { + LinearVelocity = new float3(measuredSpeed, 0f, 0f), + LastUpdateUsec = 1_000_000, + LastAppliedUsec = appliedTimeUsec, + PaceSpeed = measuredSpeed, + }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + var maximumCatchUpSpeed = measuredSpeed * 1.25f; + + for (var i = 0; i < 40; i++) { + PhysicsKinematics.StepKinematics(ref state, appliedTimeUsec + (ulong)i * 1_000); + var velocity = velocities[itemId]; + Assert.That(math.length(velocity.StepVelocity), Is.LessThanOrEqualTo(maximumCatchUpSpeed + 1e-5f)); + Assert.That(math.length(state.GetKinematicVelocityAt(itemId, float3.zero)), + Is.LessThanOrEqualTo(maximumCatchUpSpeed + 1e-5f)); + } + } + + [Test] + public void SettledCatchUpClearsStepAndPace() + { + const int itemId = 1; + const ulong appliedTimeUsec = 10_000_000; + const float measuredSpeed = 0.38f; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + var target = float4x4.Translate(new float3(60f, 0f, 0f)); + + transforms.Add(itemId, float4x4.identity); + targets.Add(itemId, target); + velocities.Add(itemId, new KinematicVelocityState { + LinearVelocity = new float3(measuredSpeed, 0f, 0f), + LastUpdateUsec = 1_000_000, + LastAppliedUsec = appliedTimeUsec, + PaceSpeed = measuredSpeed, + }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + + for (var i = 0; i < 900; i++) { + PhysicsKinematics.StepKinematics(ref state, appliedTimeUsec + (ulong)i * 1_000); + } + + var velocity = velocities[itemId]; + Assert.That(transforms[itemId], Is.EqualTo(target)); + Assert.That(velocity.StepVelocity, Is.EqualTo(float3.zero)); + Assert.That(velocity.PaceSpeed, Is.Zero); + Assert.That(velocity.PaceAngularSpeed, Is.Zero); + } + + [Test] + public void AngularCatchUpSpeedDoesNotCompound() + { + const int itemId = 1; + const ulong appliedTimeUsec = 10_000_000; + const float measuredSpeed = 0.009f; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + + transforms.Add(itemId, float4x4.identity); + targets.Add(itemId, float4x4.RotateZ(math.radians(7.5f))); + velocities.Add(itemId, new KinematicVelocityState { + AngularVelocity = new float3(0f, 0f, measuredSpeed), + LastUpdateUsec = 1_000_000, + LastAppliedUsec = appliedTimeUsec, + PaceAngularSpeed = measuredSpeed, + }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + var maximumCatchUpSpeed = measuredSpeed * 1.25f; + + for (var i = 0; i < 10; i++) { + PhysicsKinematics.StepKinematics(ref state, appliedTimeUsec + (ulong)i * 1_000); + var velocity = velocities[itemId]; + Assert.That(math.length(velocity.StepAngularVelocity), Is.LessThanOrEqualTo(maximumCatchUpSpeed + 1e-5f)); + Assert.That(math.length(state.GetKinematicVelocityAt(itemId, new float3(1f, 0f, 0f))), + Is.LessThanOrEqualTo(maximumCatchUpSpeed + 1e-5f)); + } + } + + [Test] + public void VelocitylessTargetSnapsWithoutSurfaceVelocity() + { + const int itemId = 1; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + var target = float4x4.Translate(new float3(30f, 0f, 0f)); + + transforms.Add(itemId, float4x4.identity); + targets.Add(itemId, target); + velocities.Add(itemId, new KinematicVelocityState { LastAppliedUsec = 10_000_000 }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + + PhysicsKinematics.StepKinematics(ref state, 10_000_000); + + Assert.That(transforms[itemId], Is.EqualTo(target)); + Assert.That(state.GetKinematicVelocityAt(itemId, target.c3.xyz), Is.EqualTo(float3.zero)); + } + + [Test] + public void PacelessLinearAxisSnapsInsteadOfUsingCatchUpCeiling() + { + const int itemId = 1; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + var target = math.mul(float4x4.Translate(new float3(30f, 0f, 0f)), + float4x4.RotateZ(math.radians(7.5f))); + + transforms.Add(itemId, float4x4.identity); + targets.Add(itemId, target); + velocities.Add(itemId, new KinematicVelocityState { + AngularVelocity = new float3(0f, 0f, 0.009f), + LastAppliedUsec = 10_000_000, + PaceAngularSpeed = 0.009f, + }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + + PhysicsKinematics.StepKinematics(ref state, 10_000_000); + + var velocity = velocities[itemId]; + Assert.That(transforms[itemId], Is.EqualTo(target)); + Assert.That(velocity.StepVelocity, Is.EqualTo(float3.zero)); + Assert.That(velocity.StepAngularVelocity, Is.EqualTo(float3.zero)); + } + + [Test] + public void SettledPoseExpiresVelocityWhenTransformProducerStalls() + { + const int itemId = 1; + const ulong sampleTimeUsec = 1_000_000; + const ulong appliedTimeUsec = 10_000_000; + using var transforms = new NativeParallelHashMap(1, Allocator.Temp); + using var targets = new NativeParallelHashMap(1, Allocator.Temp); + using var velocities = new NativeParallelHashMap(1, Allocator.Temp); + using var colliderLookups = new NativeParallelHashMap(1, Allocator.Temp); + + var pose = float4x4.Translate(new float3(100f, 200f, 300f)); + transforms.Add(itemId, pose); + targets.Add(itemId, pose); + velocities.Add(itemId, new KinematicVelocityState { + LinearVelocity = new float3(2f, 3f, -4f), + AngularVelocity = new float3(0.1f, 0.2f, 0.3f), + Pivot = pose.c3.xyz, + LastUpdateUsec = sampleTimeUsec, + LastAppliedUsec = appliedTimeUsec, + }); + + var state = new PhysicsState { + KinematicTransforms = transforms, + KinematicTargetTransforms = targets, + KinematicVelocities = velocities, + KinematicColliderLookups = colliderLookups, + }; + + PhysicsKinematics.StepKinematics(ref state, + appliedTimeUsec + PhysicsKinematics.KinematicVelocityTimeoutUsec - 1); + Assert.That(state.GetKinematicVelocityAt(itemId, pose.c3.xyz), Is.EqualTo(new float3(2f, 3f, -4f))); + + PhysicsKinematics.StepKinematics(ref state, + appliedTimeUsec + PhysicsKinematics.KinematicVelocityTimeoutUsec); + Assert.That(state.GetKinematicVelocityAt(itemId, pose.c3.xyz), Is.EqualTo(float3.zero)); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs.meta new file mode 100644 index 000000000..c6fdeb467 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6e81b32799cb4ce292f7bb8ac0d1d912 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs index ebb37812e..32b80a745 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsRegressionTests.cs @@ -213,6 +213,91 @@ public void OrthogonalContactFrictionIgnoresNormalSolverExitVelocity() "friction at another contact must not consume a normal-solver artifact as physical slip"); } + [Test] + public void DeepStaticTriangleContactRecoversBeforeTheBallReachesTheBackSide() + { + var triangle = new TriangleCollider( + new float3(-1000f, -1000f, 0f), + new float3(-1000f, 1000f, 0f), + new float3(1000f, -1000f, 0f), + new ColliderInfo { Id = 1, ItemId = 1, ItemType = ItemType.Primitive }); + var ball = new BallState { + Id = 7, + Mass = 1f, + Radius = 25f, + Position = new float3(-100f, -100f, 12f), + }; + var insideOfs = default(InsideOfs); + + for (var i = 0; i < 3; i++) { + var contact = new CollisionEventData { ColliderId = 1 }; + Assert.That(triangle.HitTest(ref contact, in insideOfs, in ball, 0.1f), Is.Zero, + "the triangle must keep reporting contact while the ball is being recovered"); + BallCollider.HandleStaticContact(ref ball, in contact, 0f, 0.1f, float3.zero, float3.zero); + } + + Assert.That(ball.Position.z, Is.GreaterThan(24.5f), + "the ball center must recover to the free side before the one-sided triangle rejects it"); + } + + [Test] + public void StaticContactInsideTheTouchBandDoesNotMoveTheBall() + { + var initialPosition = new float3(1f, 2f, 24.95f); + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Position = initialPosition, + }; + var contact = new CollisionEventData { + ColliderId = 1, + HitNormal = new float3(0f, 0f, 1f), + HitDistance = -0.05f, + IsContact = true, + }; + + BallCollider.HandleStaticContact(ref ball, in contact, 0f, 0.1f, float3.zero, float3.zero); + + Assert.That(ball.Position, Is.EqualTo(initialPosition)); + } + + [Test] + public void KinematicContactKeepsEmbeddedCarryPosition() + { + var ball = new BallState { + Mass = 1f, + Radius = 25f, + Position = new float3(0f, 0f, 12f), + }; + var contact = new CollisionEventData { + ColliderId = 1, + HitNormal = new float3(0f, 0f, 1f), + HitDistance = -13f, + IsContact = true, + IsKinematic = true, + }; + + BallCollider.HandleStaticContact(ref ball, in contact, 0f, 0.1f, float3.zero, float3.zero); + + Assert.That(ball.Position.z, Is.EqualTo(12f).Within(Tolerance)); + } + + [Test] + public void DestroyedBallIsReleasedFromEveryKicker() + { + using var kickerStates = new NativeParallelHashMap(2, Allocator.Temp); + kickerStates.Add(1, new KickerState(default, + new KickerCollisionState { BallId = 7, LastCapturedBallId = 7 }, default)); + kickerStates.Add(2, new KickerState(default, + new KickerCollisionState { BallId = 9, LastCapturedBallId = 9 }, default)); + + PhysicsEngine.ReleaseDestroyedBallFromKickers(7, kickerStates); + + Assert.That(kickerStates[1].Collision.BallId, Is.Zero); + Assert.That(kickerStates[1].Collision.LastCapturedBallId, Is.EqualTo(7)); + Assert.That(kickerStates[2].Collision.BallId, Is.EqualTo(9)); + } + [Test] public void FrictionLoadExcludesAccelerationSupportedByAnotherContact() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs new file mode 100644 index 000000000..7a11f942a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs @@ -0,0 +1,486 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; + +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeColliderTests + { + [Test] + public void BoxDistanceCoversFaceEdgeCornerAndInside() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + + var face = collider.Distance(in hinge, new float3(15.5f, 0f, 0f), 0.5f); + var edge = collider.Distance(in hinge, new float3(16f, 3f, 0f), 0.5f); + var corner = collider.Distance(in hinge, new float3(16f, 3f, 3f), 0.5f); + var inside = collider.Distance(in hinge, new float3(10f, 0f, 0f), 0.5f); + + Assert.That(face.Separation, Is.EqualTo(0f).Within(1e-6f)); + Assert.That(edge.Separation, Is.EqualTo(math.sqrt(2f) - 0.5f).Within(1e-6f)); + Assert.That(corner.Separation, Is.EqualTo(math.sqrt(3f) - 0.5f).Within(1e-6f)); + Assert.That(inside.Separation, Is.EqualTo(-2.5f).Within(1e-6f)); + Assert.That(math.length(inside.Normal), Is.EqualTo(1f).Within(1e-6f)); + } + + [Test] + public void LinearSweepFindsFirstFaceImpact() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(20f, 0f, 0f), new float3(-10f, 0f, 0f)); + var collEvent = new CollisionEventData(); + + var time = collider.HitTest(ref collEvent, in hinge, in ball, 1f); + + Assert.That(time, Is.EqualTo(0.4f).Within(2e-4f)); + Assert.That(collEvent.HitNormal, Is.EqualTo(new float3(1f, 0f, 0f))); + Assert.That(collEvent.IsContact, Is.False); + } + + [Test] + public void RotatingBoxCannotPassThroughStationaryBall() + { + var collider = CreateCollider(centreArm: new float3(5f, 0f, 0f), + halfExtents: new float3(4f, 1f, 1f)); + var hinge = CreateHinge(angularVelocity: math.TAU); + var ball = CreateBall(new float3(0f, 5f, 0f), float3.zero); + var collEvent = new CollisionEventData(); + + var time = collider.HitTest(ref collEvent, in hinge, in ball, 1f); + + Assert.That(time, Is.GreaterThanOrEqualTo(0f).And.LessThan(0.25f)); + Assert.That(collEvent.HitOrgNormalVelocity, Is.LessThan(0f)); + } + + [Test] + public void HighSpeedSweepUsesConservativeFallbackWithoutZeroTimeImpact() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(20f, 0f, 0f), new float3(-100000f, 0f, 0f)); + var collEvent = new CollisionEventData(); + + var time = collider.HitTest(ref collEvent, in hinge, in ball, 0.001f); + + Assert.That(time, Is.GreaterThan(0f).And.LessThanOrEqualTo(0.000045f)); + Assert.That(collEvent.HitOrgNormalVelocity, Is.LessThan(0f)); + } + + [Test] + public void ConservativeFallbackRejectsFastNearMiss() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(20f, 3.1f, 0f), new float3(-100000f, -10f, 0f)); + var collEvent = new CollisionEventData(); + + var time = collider.HitTest(ref collEvent, in hinge, in ball, 0.001f); + + Assert.That(time, Is.EqualTo(-1f)); + } + + [Test] + public void FallbackPreservesConservativeAdvancementProgress() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(16.1f, 0f, 0f), new float3(-17f, 0f, 0f)); + var collEvent = new CollisionEventData(); + + var time = collider.HitTest(ref collEvent, in hinge, in ball, 0.1f); + + Assert.That(time, Is.EqualTo(0.1f / 17f).Within(2e-5f)); + Assert.That(math.abs(collEvent.HitDistance), Is.LessThanOrEqualTo(2e-4f)); + } + + [Test] + public void DistanceSupportsNonBasisReferenceFrame() + { + var rotation = quaternion.EulerXYZ(math.radians(new float3(17f, -23f, 31f))); + var axisX = math.mul(rotation, new float3(1f, 0f, 0f)); + var axisY = math.mul(rotation, new float3(0f, 1f, 0f)); + var axisZ = math.mul(rotation, new float3(0f, 0f, 1f)); + var collider = CreateCollider(referenceAxisX: axisX, referenceAxisY: axisY, + referenceAxisZ: axisZ); + var hinge = CreateHinge(); + + var distance = collider.Distance(in hinge, + new float3(10f, 0f, 0f) + axisX * 5.5f, 0.5f); + + Assert.That(distance.Separation, Is.EqualTo(0f).Within(2e-5f)); + Assert.That(math.dot(distance.Normal, axisX), Is.EqualTo(1f).Within(2e-5f)); + } + + [Test] + public void ImpactIsReciprocalForFiniteHingeInertia() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(10f, 3f, 0f), new float3(0f, -2f, 0f)); + var before = ProjectedAngularMomentum(in ball, in hinge); + var collEvent = new CollisionEventData { HitNormal = new float3(0f, 1f, 0f) }; + + collider.Collide(ref ball, ref hinge, in collEvent, ref state); + + var surfaceVelocity = hinge.Movement.AngularVelocity * 10f; + Assert.That(ball.Velocity.y - surfaceVelocity, Is.GreaterThan(0f)); + Assert.That(hinge.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(ProjectedAngularMomentum(in ball, in hinge), Is.EqualTo(before).Within(2e-5f)); + } + + [Test] + public void SustainedContactAppliesReciprocalSupportImpulse() + { + var collider = CreateCollider(); + var hinge = CreateHinge(); + var ball = CreateBall(new float3(10f, 3f, 0f), float3.zero); + var collEvent = new CollisionEventData { + HitNormal = new float3(0f, 1f, 0f), + HitDistance = 0f, + IsContact = true + }; + var before = ProjectedAngularMomentum(in ball, in hinge); + var acceleration = new float3(0f, -0.1f, 0f); + + collider.Contact(ref ball, ref hinge, in collEvent, 0.1f, in acceleration, + in acceleration, in ball.Velocity, in ball.AngularMomentum); + + Assert.That(ball.Velocity.y, Is.GreaterThan(0f)); + Assert.That(hinge.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(ProjectedAngularMomentum(in ball, in hinge), Is.EqualTo(before).Within(2e-5f)); + } + + [Test] + public void FullTravelBoundContainsAllRotatedCorners() + { + var collider = CreateCollider(centreArm: new float3(5f, 3f, 2f), + halfExtents: new float3(4f, 2f, 1f)); + var radius = collider.MaxProxyRadius; + + Assert.That(collider.Bounds.Aabb.Left, Is.EqualTo(-radius)); + Assert.That(collider.Bounds.Aabb.Right, Is.EqualTo(radius)); + Assert.That(collider.Bounds.Aabb.Top, Is.EqualTo(-radius)); + Assert.That(collider.Bounds.Aabb.Bottom, Is.EqualTo(radius)); + Assert.That(collider.Bounds.Aabb.ZLow, Is.EqualTo(-radius)); + Assert.That(collider.Bounds.Aabb.ZHigh, Is.EqualTo(radius)); + var bounds = collider.Bounds.Aabb; + for (var angleIndex = 0; angleIndex < 9; angleIndex++) { + var angle = math.TAU * angleIndex / 9f; + for (var x = -1; x <= 1; x += 2) { + for (var y = -1; y <= 1; y += 2) { + for (var z = -1; z <= 1; z += 2) { + var corner = new float3(5f, 3f, 2f) + new float3(4f * x, 2f * y, z); + var rotated = SpringHingeVelocityPhysics.RotateAroundAxis(in corner, + new float3(0f, 0f, 1f), angle); + Assert.That(rotated.x, Is.InRange(bounds.Left, bounds.Right)); + Assert.That(rotated.y, Is.InRange(bounds.Top, bounds.Bottom)); + Assert.That(rotated.z, Is.InRange(bounds.ZLow, bounds.ZHigh)); + } + } + } + } + } + + [Test] + public void ComponentGeometryUsesAuthoredVpxUnits() + { + var hingeObject = new GameObject("spring-hinge-vpx-units-test"); + var magnetObject = new GameObject("owned-magnet-vpx-units-test"); + try { + var hinge = hingeObject.AddComponent(); + hinge.CentreOfMass = new Vector3(50f, 0f, 0f); + var proxy = hingeObject.AddComponent(); + proxy.LocalCentre = new Vector3(30f, 0f, 0f); + proxy.HalfExtents = new Vector3(10f, 20f, 30f); + + var hingeState = hinge.CreateState(); + var collider = SpringHingeColliderGenerator.Create(hinge, proxy, + new ColliderInfo { ItemId = hinge.ItemId }, 0f); + + magnetObject.transform.SetParent(hingeObject.transform, false); + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.ForceProfile = MagnetForceProfile.Physical; + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = new Vector3(25f, 0f, 0f); + var magnetState = magnet.CreateState(); + + Assert.That(math.distance(hingeState.Static.CentreOfMassArm, + new float3(50f, 0f, 0f)), Is.LessThan(1e-4f)); + Assert.That(math.distance(collider.CentreArm, + new float3(30f, 0f, 0f)), Is.LessThan(1e-4f)); + Assert.That(math.distance(collider.HalfExtents, + new float3(10f, 20f, 30f)), Is.LessThan(1e-4f)); + Assert.That(math.distance(magnetState.LocalHeldCentreArm, + new float3(25f, 0f, 0f)), Is.LessThan(1e-4f)); + } finally { + UnityEngine.Object.DestroyImmediate(hingeObject); + } + } + + [Test] + public void HeldBallOffsetIgnoresScaledVisualHierarchy() + { + var hingeObject = new GameObject("scaled-spring-hinge-held-ball-test"); + var magnetObject = new GameObject("scaled-owned-magnet-held-ball-test"); + try { + hingeObject.transform.localScale = Vector3.one * 0.102f; + var hinge = hingeObject.AddComponent(); + magnetObject.transform.SetParent(hingeObject.transform, false); + magnetObject.transform.localPosition = new Vector3(0.2f, 0.3f, 0.4f); + magnetObject.transform.localRotation = Quaternion.Euler(17f, 31f, 43f); + magnetObject.transform.localScale = Vector3.one * 0.6994f; + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = new Vector3(25f, 0f, 0f); + + var state = magnet.CreateState(); + var offset = state.LocalHeldCentreArm - state.LocalPoleArm; + + Assert.That(math.length(offset), Is.EqualTo(25f).Within(1e-3f)); + Assert.That(Vector3.Distance(magnet.transform.position, + magnet.GetHeldBallCentreWorldPosition()), + Is.EqualTo(Physics.ScaleToWorld(25f)).Within(1e-6f)); + } finally { + UnityEngine.Object.DestroyImmediate(hingeObject); + } + } + + [Test] + public void GeneratorRejectsShearedBoxFrame() + { + var gameObject = new GameObject("spring-hinge-sheared-test"); + try { + gameObject.transform.localScale = new Vector3(2f, 1f, 1f); + var hinge = gameObject.AddComponent(); + var collider = gameObject.AddComponent(); + collider.LocalRotation = new Vector3(0f, 0f, 45f); + var info = new ColliderInfo { ItemId = hinge.ItemId }; + + Assert.Throws(() => + SpringHingeColliderGenerator.Create(hinge, collider, info, 0f)); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void SpecializedColliderRoundTripsThroughNativeStorage() + { + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + try { + var id = references.Add(CreateCollider()); + var native = new NativeColliders(ref references, Allocator.Temp); + try { + Assert.That(native.GetHeader(id).Type, Is.EqualTo(ColliderType.SpringHinge)); + Assert.That(native.SpringHinge(id).HingeOwnerId, Is.EqualTo(12)); + Assert.That(native.GetAabb(id), Is.EqualTo(CreateCollider().Bounds.Aabb)); + Assert.That(native.ToArray()[id], Is.TypeOf()); + } finally { + native.Dispose(); + } + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void PhysicsStateDispatchesHitAndReciprocalCollision() + { + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + using var harness = new PhysicsStateHarness(); + try { + var colliderId = references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var hinge = CreateHinge(); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + var state = harness.CreateState(); + var ball = CreateBall(new float3(10f, 3f, 0f), new float3(0f, -2f, 0f)); + ball.CollisionEvent.ClearCollider(0.1f); + var collEvent = new CollisionEventData(); + var contacts = new NativeList(Allocator.Temp); + try { + var hitTime = state.HitTest(ref state.Colliders, colliderId, ref ball, + ref collEvent, ref contacts); + Assert.That(hitTime, Is.EqualTo(0f)); + collEvent.SetCollider(colliderId, false); + collEvent.HitTime = hitTime; + ball.CollisionEvent = collEvent; + var before = ProjectedAngularMomentum(in ball, in hinge); + + PhysicsStaticCollision.Collide(hitTime, ref ball, ref state); + + ref var updatedHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + Assert.That(updatedHinge.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(ProjectedAngularMomentum(in ball, in updatedHinge), + Is.EqualTo(before).Within(2e-5f)); + } finally { + contacts.Dispose(); + } + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + [Test] + public void StopBearingBlocksOnlyOutwardImpact() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + var collider = CreateCollider(); + var hinge = CreateHinge(); + hinge.Movement.ActiveStop = 1; + var outwardBall = CreateBall(new float3(10f, -3f, 0f), new float3(0f, 2f, 0f)); + var outwardEvent = new CollisionEventData { HitNormal = new float3(0f, -1f, 0f) }; + + collider.Collide(ref outwardBall, ref hinge, in outwardEvent, ref state); + + Assert.That(hinge.Movement.AngularVelocity, Is.Zero); + Assert.That(outwardBall.Velocity.y, Is.LessThan(0f)); + + var inwardBall = CreateBall(new float3(10f, 3f, 0f), new float3(0f, -2f, 0f)); + var inwardEvent = new CollisionEventData { HitNormal = new float3(0f, 1f, 0f) }; + collider.Collide(ref inwardBall, ref hinge, in inwardEvent, ref state); + + Assert.That(hinge.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(hinge.Movement.ActiveStop, Is.Zero); + } + + [Test] + public void HitTestPreservesVelocityDepartingEitherStop() + { + var collider = CreateCollider(); + var lower = CreateHinge(angularVelocity: 1f); + lower.Static.MinimumAngle = 0f; + lower.Static.MaximumAngle = math.PI; + lower.Movement.Angle = 0f; + var lowerBall = CreateBall(new float3(10f, 3f, 0f), float3.zero); + var lowerEvent = new CollisionEventData(); + + var lowerTime = collider.HitTest(ref lowerEvent, in lower, in lowerBall, 0.1f); + + Assert.That(lowerTime, Is.Zero); + Assert.That(lowerEvent.HitOrgNormalVelocity, Is.EqualTo(-10f).Within(1e-5f)); + Assert.That(lowerEvent.IsContact, Is.False); + + var upper = CreateHinge(angularVelocity: -1f); + upper.Static.MinimumAngle = -math.PI; + upper.Static.MaximumAngle = 0f; + upper.Movement.Angle = 0f; + var upperBall = CreateBall(new float3(10f, -3f, 0f), float3.zero); + var upperEvent = new CollisionEventData(); + + var upperTime = collider.HitTest(ref upperEvent, in upper, in upperBall, 0.1f); + + Assert.That(upperTime, Is.Zero); + Assert.That(upperEvent.HitOrgNormalVelocity, Is.EqualTo(-10f).Within(1e-5f)); + Assert.That(upperEvent.IsContact, Is.False); + } + + [Test] + public void ContactPhysicsDispatchesReciprocalHingeContact() + { + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + using var harness = new PhysicsStateHarness(); + try { + var colliderId = references.Add(CreateCollider()); + harness.SetStaticColliders(ref references); + var hinge = CreateHinge(); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + var state = harness.CreateState(); + var ball = CreateBall(new float3(10f, 3f, 0f), float3.zero); + ball.ExternalAcceleration = new float3(0f, -0.1f, 0f); + var contact = new ContactBufferElement(ball.Id, new CollisionEventData { + ColliderId = colliderId, + HitNormal = new float3(0f, 1f, 0f), + IsContact = true + }) { + FrictionAcceleration = ball.ExternalAcceleration + }; + var colliders = state.Colliders; + + ContactPhysics.Update(ref contact, ref ball, ref state, ref colliders, 0.1f); + + ref var updatedHinge = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + Assert.That(ball.Velocity.y, Is.GreaterThan(0f)); + Assert.That(updatedHinge.Movement.AngularVelocity, Is.LessThan(0f)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + private static SpringHingeCollider CreateCollider(float3? centreArm = null, + float3? halfExtents = null, float3? referenceAxisX = null, + float3? referenceAxisY = null, float3? referenceAxisZ = null) + { + var pivot = float3.zero; + var centre = centreArm ?? new float3(10f, 0f, 0f); + var extents = halfExtents ?? new float3(5f, 2f, 2f); + var x = referenceAxisX ?? new float3(1f, 0f, 0f); + var y = referenceAxisY ?? new float3(0f, 1f, 0f); + var z = referenceAxisZ ?? new float3(0f, 0f, 1f); + var info = new ColliderInfo { + ItemId = 12, + Material = new PhysicsMaterialData { + Elasticity = 0.5f, + Friction = 0.3f + } + }; + return new SpringHingeCollider(12, in pivot, in centre, in extents, in x, in y, in z, info); + } + + private static SpringHingeState CreateHinge(float angularVelocity = 0f) + { + return new SpringHingeState(12, new SpringHingeStaticState { + OwnerId = 12, + Pivot = float3.zero, + Axis = new float3(0f, 0f, 1f), + Mass = 1f, + Inertia = 20f, + MinimumAngle = -math.PI, + MaximumAngle = math.PI + }, new SpringHingeMovementState { + AngularVelocity = angularVelocity + }); + } + + private static BallState CreateBall(in float3 position, in float3 velocity) + { + return new BallState { + Id = 1, + Position = position, + Velocity = velocity, + Mass = 1f, + Radius = 1f + }; + } + + private static float ProjectedAngularMomentum(in BallState ball, in SpringHingeState hinge) + => math.dot(hinge.Static.Axis, math.cross(ball.Position - hinge.Static.Pivot, + ball.Mass * ball.Velocity) + ball.AngularMomentum) + + hinge.Static.Inertia * hinge.Movement.AngularVelocity; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs.meta new file mode 100644 index 000000000..568661f98 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ca2a09ad2f1b4bed937dbdc51d069d87 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs new file mode 100644 index 000000000..9c7e0a87a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs @@ -0,0 +1,145 @@ +// 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 NativeTrees; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeIntegrationTests + { + [Test] + public void DynamicBroadPhaseRefitsAfterAStationaryBallIsAccelerated() + { + var balls = new NativeParallelHashMap(2, Allocator.Temp); + var overlaps = new NativeParallelHashSet(2, Allocator.Temp); + NativeTrees.AABB bounds = new Aabb(new float3(-100f), new float3(100f)); + var octree = new NativeOctree(bounds, 16, 4, Allocator.Temp); + try { + balls.Add(1, CreateBall(1, float3.zero, float3.zero)); + balls.Add(2, CreateBall(2, new float3(20f, 0f, 0f), float3.zero)); + PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls); + + var other = balls[2]; + PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls); + Assert.That(overlaps.Contains(1), Is.False); + + ref var accelerated = ref balls.GetValueByRef(1); + accelerated.Velocity = new float3(40f, 0f, 0f); + Assert.That(PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref octree, + ref balls, 0.5f), Is.True); + + PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls); + Assert.That(overlaps.Contains(1), Is.True, + "the second ball must query the accelerated ball's refitted swept bounds"); + } finally { + octree.Dispose(); + overlaps.Dispose(); + balls.Dispose(); + } + } + + [Test] + public void DynamicBroadPhaseDoesNotRefitWhileInsertedBoundsContainMotion() + { + var balls = new NativeParallelHashMap(1, Allocator.Temp); + NativeTrees.AABB bounds = new Aabb(new float3(-100f), new float3(100f)); + var octree = new NativeOctree(bounds, 8, 3, Allocator.Temp); + try { + balls.Add(1, CreateBall(1, float3.zero, new float3(2f, 0f, 0f))); + PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls); + + Assert.That(PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref octree, + ref balls, 0.1f), Is.False); + } finally { + octree.Dispose(); + balls.Dispose(); + } + } + + [Test] + public void SpinCorrectionDoesNotRemoveMomentumFromAttachedBall() + { + var ball = CreateBall(1, float3.zero, float3.zero); + ball.AngularMomentum = new float3(100f, 0f, 0f); + ball.LastPositions = new BallPositions(new float3(1f, 0f, 0f)); + var freeBall = ball; + var attachedBall = ball; + attachedBall.AttachedMagnetId = 20; + + PhysicsCycle.ApplyBallSpinCorrection(ref freeBall); + PhysicsCycle.ApplyBallSpinCorrection(ref attachedBall); + + Assert.That(math.length(freeBall.AngularMomentum), Is.LessThan(100f)); + Assert.That(attachedBall.AngularMomentum, Is.EqualTo(ball.AngularMomentum)); + } + + [Test] + public void UnsupportedActiveColliderClassificationExcludesPassiveSurfaces() + { + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.Bumper), Is.True); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.Flipper), Is.True); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.LineSlingShot), Is.True); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.Plunger), Is.True); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.KickerCircle), Is.True); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.Plane), Is.False); + Assert.That(PhysicsStaticCollision.IsUnsupportedActiveCollider(ColliderType.SpringHinge), Is.False); + } + + [Test] + public void HingeImpactUsesThresholdAndDeduplicatesRepeatedPosition() + { + using var harness = new PhysicsStateHarness(); + var state = harness.CreateState(); + var collider = CreateCollider(2f); + var hinge = new SpringHingeState(12, new SpringHingeStaticState { + OwnerId = 12, + Pivot = float3.zero, + Axis = new float3(0f, 0f, 1f), + Inertia = 10f, + MinimumAngle = -math.PI, + MaximumAngle = math.PI + }, default); + var ball = CreateBall(1, new float3(10f, 3f, 0f), new float3(0f, -1f, 0f)); + + collider.Collide(ref ball, ref hinge, default, ref state); + Assert.That(harness.EventQueue.Count, Is.Zero); + + ball.Velocity = new float3(0f, -3f, 0f); + collider.Collide(ref ball, ref hinge, default, ref state); + ball.Velocity = new float3(0f, -3f, 0f); + collider.Collide(ref ball, ref hinge, default, ref state); + + Assert.That(harness.EventQueue.Count, Is.EqualTo(1)); + Assert.That(harness.EventQueue.Dequeue().EventId, Is.EqualTo(EventId.HitEventsHit)); + } + + private static SpringHingeCollider CreateCollider(float hitThreshold) + { + var pivot = float3.zero; + var centre = new float3(10f, 0f, 0f); + var extents = new float3(5f, 2f, 2f); + var x = new float3(1f, 0f, 0f); + var y = new float3(0f, 1f, 0f); + var z = new float3(0f, 0f, 1f); + return new SpringHingeCollider(12, in pivot, in centre, in extents, + in x, in y, in z, new ColliderInfo { + ItemId = 12, + FireEvents = true, + HitThreshold = hitThreshold + }); + } + + private static BallState CreateBall(int id, in float3 position, in float3 velocity) + => new() { Id = id, Position = position, Velocity = velocity, Mass = 1f, Radius = 1f }; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs.meta new file mode 100644 index 000000000..337406214 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5dfb7ca388d34473aeb04a439af5615c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs new file mode 100644 index 000000000..74c01c89c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs @@ -0,0 +1,311 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using NUnit.Framework; +using Unity.Mathematics; + +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeNumericalFixtureTests + { + [Test] + public void UnitContractUsesBallRelativeVpuAndNormalizedTime() + { + const float ballMass = 1f; + const float armVpu = 50f; + var pointMassInertia = ballMass * armVpu * armVpu; + var accelerationScale = PhysicsConstants.MToVpu + * PhysicsConstants.DefaultStepTimeS * PhysicsConstants.DefaultStepTimeS; + + Assert.That(PhysicsConstants.PhysicsStepTimeS, Is.EqualTo(0.001d).Within(1e-12d)); + Assert.That(PhysicsConstants.PhysFactor, Is.EqualTo(0.1f).Within(1e-7f)); + Assert.That(pointMassInertia, Is.EqualTo(2500f)); + Assert.That(PhysicsConstants.Ms2ToVpuVpt2, Is.EqualTo(accelerationScale).Within(1e-7f)); + Assert.That(new float3(0f, -accelerationScale, 0f).y, Is.LessThan(0f), + "positive cabinet acceleration produces the opposite inertial acceleration"); + } + + [Test] + public void ImplicitSpringPeriodAndDampingMatchIndependentReferences() + { + const float inertia = 2500f; + const float stiffness = 100f; + var expectedPeriod = 2f * math.PI * math.sqrt(inertia / stiffness); + var coarse = MeasureOscillator(0.1f, inertia, stiffness); + var fine = MeasureOscillator(0.05f, inertia, stiffness); + var coarseError = math.abs(coarse.Period - expectedPeriod); + var fineError = math.abs(fine.Period - expectedPeriod); + var naturalFrequency = math.sqrt(stiffness / inertia); + var expectedCycleDecay = math.exp(-math.PI * 0.1f * naturalFrequency); + + Assert.That(coarseError / expectedPeriod, Is.LessThan(0.0002f)); + Assert.That(coarseError / fineError, Is.GreaterThan(3f)); + Assert.That(coarse.AmplitudeRatio, Is.EqualTo(expectedCycleDecay).Within(0.002f)); + Assert.That(0.1f * naturalFrequency / 2f, Is.EqualTo(0.01f).Within(1e-6f), + "implicit Euler contributes approximately h*omegaN/2 damping ratio"); + } + + [Test] + public void ReciprocalImpactPinsRestitutionAndAngularMomentum() + { + var input = CreateImpactInput(); + var arm = input.Witness - input.Pivot; + var velocityBefore = math.dot(input.BallVelocity + - input.HingeAngularVelocity * math.cross(input.Axis, arm), input.Normal); + var momentumBefore = ProjectedAngularMomentum(1f / input.BallInverseMass, input.BallVelocity, + input.Axis, input.Pivot, input.Witness, input.HingeInertia, input.HingeAngularVelocity); + + var result = SpringHingeNumericalFixtures.SolveImpact(input); + var velocityAfter = math.dot(result.BallVelocity + - result.HingeAngularVelocity * math.cross(input.Axis, arm), input.Normal); + var momentumAfter = ProjectedAngularMomentum(1f / input.BallInverseMass, result.BallVelocity, + input.Axis, input.Pivot, input.Witness, input.HingeInertia, result.HingeAngularVelocity); + + Assert.That(result.Impulse, Is.GreaterThan(0f)); + Assert.That(velocityAfter, Is.EqualTo(-input.Restitution * velocityBefore).Within(1e-5f)); + Assert.That(momentumAfter, Is.EqualTo(momentumBefore).Within(math.abs(momentumBefore) * 0.001f)); + } + + [Test] + public void LocalHoldSatisfiesBothCoupledEquations() + { + var input = CreateCoupledHoldInput(); + var result = SpringHingeNumericalFixtures.SolveHold(input); + var relativeVelocity = result.BallVelocity - input.ArmJacobian * result.HingeAngularVelocity; + var expectedImpulse = -input.Step * input.HoldStiffness + * (input.PositionError + input.Step * relativeVelocity) + - input.Step * input.HoldDamping * relativeVelocity; + var hingeResidual = input.HingeInertia * (result.HingeAngularVelocity - input.HingeAngularVelocity) + - (input.Step * input.ExternalTorque + input.OtherAngularImpulse + - input.Step * input.HingeStiffness * (input.AngleError + input.Step * result.HingeAngularVelocity) + - input.Step * input.HingeDamping * result.HingeAngularVelocity + - math.dot(input.ArmJacobian, result.Impulse)); + var momentumBefore = input.HingeInertia * input.HingeAngularVelocity + + math.dot(input.ArmJacobian, input.BallMass * input.BallVelocity); + var momentumAfter = input.HingeInertia * result.HingeAngularVelocity + + math.dot(input.ArmJacobian, input.BallMass * result.BallVelocity); + var externalAngularImpulse = input.Step * input.ExternalTorque + input.OtherAngularImpulse + - input.Step * input.HingeStiffness * (input.AngleError + input.Step * result.HingeAngularVelocity) + - input.Step * input.HingeDamping * result.HingeAngularVelocity; + + Assert.That(result.IsCapped, Is.False); + Assert.That(math.length(result.Impulse - expectedImpulse), Is.LessThan(2e-5f)); + Assert.That(math.abs(hingeResidual), Is.LessThan(2e-5f)); + Assert.That(momentumAfter, Is.EqualTo(momentumBefore + externalAngularImpulse).Within(2e-5f)); + } + + [TestCase(0f, 0f)] + [TestCase(80f, 0f)] + public void ZeroHoldDoesNotDoubleCommitFreeHingeStep(float holdStiffness, float maximumForce) + { + var input = CreateCoupledHoldInput(); + input.HoldStiffness = holdStiffness; + input.HoldDamping = 0f; + input.MaximumHoldForce = maximumForce; + var expected = SpringHingeNumericalFixtures.StepUnconstrained(CreateFreeInput(input)); + + var result = SpringHingeNumericalFixtures.SolveHold(input); + + Assert.That(result.Impulse, Is.EqualTo(float3.zero)); + Assert.That(result.HingeAngularVelocity, + Is.EqualTo(expected.AngularVelocity).Within(1e-6f)); + } + + [Test] + public void LocalHoldUsesOneVectorCapAndPublishesFullImpulseConversions() + { + var input = CreateCoupledHoldInput(); + input.MaximumHoldForce = 100f; + var result = SpringHingeNumericalFixtures.SolveHold(input); + var cap = input.MaximumHoldForce * input.Step; + + Assert.That(result.IsCapped, Is.True); + Assert.That(math.length(result.Impulse), Is.EqualTo(cap).Within(1e-5f)); + Assert.That(result.ConstitutiveResidual / cap, Is.LessThan(10f), + "single projection is qualified only while the constitutive residual is below ten caps"); + Assert.That(result.ExternalAcceleration, + Is.EqualTo(result.Impulse / (input.BallMass * input.Step))); + Assert.That(result.CommittedMagneticTorque, + Is.EqualTo((input.OtherAngularImpulse - math.dot(input.ArmJacobian, result.Impulse)) / input.Step).Within(1e-5f)); + } + + [TestCase(0.02f)] + [TestCase(20000f)] + public void HoldRemainsFiniteForVeryLightAndHeavyHinges(float inertia) + { + var input = CreateCoupledHoldInput(); + input.HingeInertia = inertia; + input.HoldDamping = 40f; + var result = SpringHingeNumericalFixtures.SolveHold(input); + + Assert.That(math.all(math.isfinite(result.BallVelocity)), Is.True); + Assert.That(math.isfinite(result.HingeAngularVelocity), Is.True); + } + + [Test] + public void DegenerateHoldFallsBackToFreeHingeStep() + { + var input = CreateCoupledHoldInput(); + input.BallMass = 0f; + var expected = SpringHingeNumericalFixtures.StepUnconstrained(CreateFreeInput(input)); + + var result = SpringHingeNumericalFixtures.SolveHold(input); + + Assert.That(result.Impulse, Is.EqualTo(float3.zero)); + Assert.That(result.HingeAngularVelocity, Is.EqualTo(expected.AngularVelocity)); + } + + [Test] + public void StopHoldUsesOnlyAUnilateralBearingReaction() + { + var input = CreateCoupledHoldInput(); + input.HingeAngularVelocity = 0f; + input.ActiveStop = 1; + input.ExternalTorque = 25f; + input.PositionError = float3.zero; + input.BallVelocity = float3.zero; + var held = SpringHingeNumericalFixtures.SolveHold(input); + Assert.That(held.IsStopHeld, Is.True); + Assert.That(held.HingeAngularVelocity, Is.Zero); + Assert.That(held.BearingImpulse, Is.LessThanOrEqualTo(0f)); + + input.ExternalTorque = -25f; + var leaving = SpringHingeNumericalFixtures.SolveHold(input); + Assert.That(leaving.IsStopHeld, Is.False); + Assert.That(leaving.HingeAngularVelocity, Is.LessThan(0f)); + } + + [Test] + public void SupportLagEnvelopeIsBoundedAtQualifiedHoldStep() + { + var input = CreateCoupledHoldInput(); + input.PositionError = float3.zero; + input.ArmJacobian = float3.zero; + input.BallVelocity = new float3(0f, 0f, -0.18f); + input.ExternalTorque = 0f; + input.OtherAngularImpulse = 0f; + input.HingeStiffness = 0f; + input.HingeDamping = 0f; + input.HoldStiffness = 4f; + input.HoldDamping = 0.4f; + input.MaximumHoldForce = 100f; + var result = SpringHingeNumericalFixtures.SolveHold(input); + + Assert.That(math.abs(result.BallVelocity.z), Is.LessThan(math.abs(input.BallVelocity.z))); + Assert.That(math.abs(result.ExternalAcceleration.z), Is.LessThan(0.75f)); + } + + [Test] + public void FrequencyEnvelopePublishesMinimumLoadedPeriod() + { + const float step = PhysicsConstants.PhysFactor; + var minimumHingePeriod = 2f * math.PI * SpringHingeNumericalFixtures.MinimumHoldToHingeFrequencyRatio + * step / SpringHingeNumericalFixtures.MaximumQualifiedHoldFrequencyStep; + + Assert.That(minimumHingePeriod * PhysicsConstants.DefaultStepTimeS, + Is.EqualTo(0.314159f).Within(1e-5f)); + } + + [Test] + public void StopArrivalClampsOvershootAndMakesZeroTimeProgressExplicit() + { + const float maximumAngle = 0.35f; + var hitTime = SpringHingeNumericalFixtures.TimeToStop(0.34f, 0.5f, -0.1f, maximumAngle); + Assert.That(hitTime, Is.EqualTo(0.02f).Within(1e-6f)); + Assert.That(SpringHingeNumericalFixtures.TimeToStop(maximumAngle, 0.5f, -0.1f, maximumAngle), Is.EqualTo(-1f)); + Assert.That(SpringHingeNumericalFixtures.TimeToStop(0.36f, -0.2f, -0.1f, maximumAngle), Is.EqualTo(-1f)); + + var overshot = SpringHingeNumericalFixtures.ApplyStop( + new SpringHingeNumericalFixtures.HingeStep(0.36f, -0.2f), -0.1f, maximumAngle); + Assert.That(overshot.Angle, Is.EqualTo(maximumAngle)); + Assert.That(overshot.AngularVelocity, Is.EqualTo(-0.2f)); + } + + private static SpringHingeNumericalFixtures.ImpactInput CreateImpactInput() + { + var normal = math.normalizesafe(new float3(-3f, 5f, 1f)); + return new SpringHingeNumericalFixtures.ImpactInput { + Axis = math.normalizesafe(new float3(1f, 2f, 3f)), + Pivot = new float3(-7f, 4f, 2f), + Witness = new float3(18f, -5f, 11f), + Normal = normal, + BallVelocity = -12f * normal, + BallInverseMass = 1f / 1.35f, + HingeInertia = 4800f, + HingeAngularVelocity = 0.17f, + Restitution = 0.2f + }; + } + + private static SpringHingeNumericalFixtures.HoldInput CreateCoupledHoldInput() + { + return new SpringHingeNumericalFixtures.HoldInput { + BallVelocity = new float3(9f, -7f, 5f), BallMass = 1.2f, + AngleError = 0.2f, HingeAngularVelocity = -0.5f, HingeInertia = 20f, + ExternalTorque = 1f, OtherAngularImpulse = 0.15f, + ArmJacobian = new float3(3f, -1f, 2f), PositionError = new float3(4f, -3f, 2f), + HingeStiffness = 5f, HingeDamping = 0.3f, + HoldStiffness = 120f, HoldDamping = 18f, MaximumHoldForce = 10000f, Step = 0.1f + }; + } + + private static SpringHingeNumericalFixtures.HingeInput CreateFreeInput( + SpringHingeNumericalFixtures.HoldInput input) + { + return new SpringHingeNumericalFixtures.HingeInput { + Angle = input.AngleError, AngularVelocity = input.HingeAngularVelocity, + Inertia = input.HingeInertia, Stiffness = input.HingeStiffness, + Damping = input.HingeDamping, + ExternalTorque = input.ExternalTorque + input.OtherAngularImpulse / input.Step, + Step = input.Step + }; + } + + private static (float Period, float AmplitudeRatio) MeasureOscillator(float step, float inertia, float stiffness) + { + var input = new SpringHingeNumericalFixtures.HingeInput { Angle = 0.05f, Inertia = inertia, Stiffness = stiffness, Step = step }; + var previousAngle = input.Angle; + var firstCrossing = -1f; + var firstCrossingSpeed = -1f; + for (var i = 1; i < 20000; i++) { + var state = SpringHingeNumericalFixtures.StepUnconstrained(input); + input.Angle = state.Angle; + input.AngularVelocity = state.AngularVelocity; + if (previousAngle <= 0f && state.Angle > 0f) { + var crossing = (i - 1 - previousAngle / (state.Angle - previousAngle)) * step; + if (firstCrossing < 0f) { + firstCrossing = crossing; + firstCrossingSpeed = state.AngularVelocity; + } else { + return (crossing - firstCrossing, state.AngularVelocity / firstCrossingSpeed); + } + } + previousAngle = state.Angle; + } + Assert.Fail("oscillator did not complete two measured cycles"); + return default; + } + + private static float ProjectedAngularMomentum(float ballMass, float3 ballVelocity, float3 axis, + float3 pivot, float3 witness, float hingeInertia, float hingeAngularVelocity) + { + return math.dot(axis, math.cross(witness - pivot, ballMass * ballVelocity)) + + hingeInertia * hingeAngularVelocity; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs.meta new file mode 100644 index 000000000..9af20d649 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7bfa83b353e44148b0b13fe19ad6e49d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs new file mode 100644 index 000000000..3f1d11e97 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs @@ -0,0 +1,275 @@ +// 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 Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + /// + /// Pure numerical fixtures shared by the spring-hinge integration tests. + /// They express the accepted kick-then-drift tick contract without requiring + /// a running physics engine or Unity scene. + /// + internal static class SpringHingeNumericalFixtures + { + internal const float MaximumQualifiedHoldFrequencyStep = 0.2f; + internal const float MinimumHoldToHingeFrequencyRatio = 10f; + + internal struct HingeInput + { + internal float Angle; + internal float AngularVelocity; + internal float Inertia; + internal float Stiffness; + internal float Damping; + internal float EquilibriumAngle; + internal float ExternalTorque; + internal float Step; + } + + internal readonly struct HingeStep + { + internal readonly float Angle; + internal readonly float AngularVelocity; + + internal HingeStep(float angle, float angularVelocity) + { + Angle = angle; + AngularVelocity = angularVelocity; + } + } + + internal struct ImpactInput + { + internal float3 BallVelocity; + internal float BallInverseMass; + internal float HingeAngularVelocity; + internal float HingeInertia; + internal float3 Axis; + internal float3 Pivot; + internal float3 Witness; + internal float3 Normal; + internal float Restitution; + } + + internal readonly struct ImpactResult + { + internal readonly float3 BallVelocity; + internal readonly float HingeAngularVelocity; + internal readonly float Impulse; + + internal ImpactResult(float3 ballVelocity, float hingeAngularVelocity, float impulse) + { + BallVelocity = ballVelocity; + HingeAngularVelocity = hingeAngularVelocity; + Impulse = impulse; + } + } + + internal struct HoldInput + { + internal float3 BallVelocity; + internal float BallMass; + internal float AngleError; + internal float HingeAngularVelocity; + internal float HingeInertia; + internal float ExternalTorque; + internal float OtherAngularImpulse; + internal float3 ArmJacobian; + internal float3 PositionError; + internal float HingeStiffness; + internal float HingeDamping; + internal float HoldStiffness; + internal float HoldDamping; + internal float MaximumHoldForce; + internal float Step; + internal sbyte ActiveStop; + } + + internal readonly struct HoldResult + { + internal readonly float3 BallVelocity; + internal readonly float HingeAngularVelocity; + internal readonly float3 Impulse; + internal readonly float3 ExternalAcceleration; + internal readonly float CommittedMagneticTorque; + internal readonly float ConstitutiveResidual; + internal readonly float BearingImpulse; + internal readonly bool IsCapped; + internal readonly bool IsStopHeld; + + internal HoldResult(float3 ballVelocity, float hingeAngularVelocity, float3 impulse, + float3 externalAcceleration, float committedMagneticTorque, float constitutiveResidual, + float bearingImpulse, bool isCapped, bool isStopHeld) + { + BallVelocity = ballVelocity; + HingeAngularVelocity = hingeAngularVelocity; + Impulse = impulse; + ExternalAcceleration = externalAcceleration; + CommittedMagneticTorque = committedMagneticTorque; + ConstitutiveResidual = constitutiveResidual; + BearingImpulse = bearingImpulse; + IsCapped = isCapped; + IsStopHeld = isStopHeld; + } + } + + internal static HingeStep StepUnconstrained(in HingeInput input) + { + var angleError = input.Angle - input.EquilibriumAngle; + var denominator = input.Inertia + input.Step * input.Damping + input.Step * input.Step * input.Stiffness; + if (input.Inertia <= 0f || input.Step <= 0f || denominator <= 0f) { + return new HingeStep(input.Angle, input.AngularVelocity); + } + var nextVelocity = (input.Inertia * input.AngularVelocity + input.Step * input.ExternalTorque + - input.Step * input.Stiffness * angleError) / denominator; + return new HingeStep(input.Angle + input.Step * nextVelocity, nextVelocity); + } + + internal static HingeStep ApplyStop(in HingeStep step, float minimumAngle, float maximumAngle) + { + var angle = math.clamp(step.Angle, minimumAngle, maximumAngle); + var velocity = step.AngularVelocity; + if ((angle <= minimumAngle && velocity < 0f) || (angle >= maximumAngle && velocity > 0f)) { + velocity = 0f; + } + return new HingeStep(angle, velocity); + } + + internal static float TimeToStop(float angle, float angularVelocity, float minimumAngle, float maximumAngle) + { + if (angle < minimumAngle || angle > maximumAngle) { + return -1f; + } + if (angularVelocity > 0f && angle < maximumAngle) { + return (maximumAngle - angle) / angularVelocity; + } + if (angularVelocity < 0f && angle > minimumAngle) { + return (minimumAngle - angle) / angularVelocity; + } + return -1f; + } + + internal static ImpactResult SolveImpact(in ImpactInput input) + { + var arm = input.Witness - input.Pivot; + var jacobian = math.dot(input.Axis, math.cross(arm, input.Normal)); + var relativeNormalVelocity = math.dot(input.BallVelocity + - input.HingeAngularVelocity * math.cross(input.Axis, arm), input.Normal); + if (relativeNormalVelocity >= 0f || input.BallInverseMass <= 0f || input.HingeInertia <= 0f) { + return new ImpactResult(input.BallVelocity, input.HingeAngularVelocity, 0f); + } + var inverseEffectiveMass = input.BallInverseMass + jacobian * jacobian / input.HingeInertia; + var impulse = -(1f + input.Restitution) * relativeNormalVelocity / inverseEffectiveMass; + return new ImpactResult( + input.BallVelocity + impulse * input.Normal * input.BallInverseMass, + input.HingeAngularVelocity - impulse * jacobian / input.HingeInertia, + impulse + ); + } + + internal static HoldResult SolveHold(in HoldInput input) + { + var freeStep = StepUnconstrained(new HingeInput { + Angle = input.AngleError, + AngularVelocity = input.HingeAngularVelocity, + Inertia = input.HingeInertia, + Stiffness = input.HingeStiffness, + Damping = input.HingeDamping, + EquilibriumAngle = 0f, + ExternalTorque = input.ExternalTorque + input.OtherAngularImpulse / math.max(input.Step, float.Epsilon), + Step = input.Step + }); + var hingeDenominator = input.HingeInertia + input.Step * input.HingeDamping + + input.Step * input.Step * input.HingeStiffness; + if (input.BallMass <= 0f || input.HingeInertia <= 0f || input.Step <= 0f + || input.HoldStiffness < 0f || input.HoldDamping < 0f || input.HingeStiffness < 0f + || input.HingeDamping < 0f || hingeDenominator <= 0f) { + return FreeHoldResult(input, freeStep.AngularVelocity); + } + + var freeNumerator = input.HingeInertia * input.HingeAngularVelocity + + input.Step * input.ExternalTorque + input.OtherAngularImpulse + - input.Step * input.HingeStiffness * input.AngleError; + var holdFactor = input.Step * (input.HoldDamping + input.Step * input.HoldStiffness); + var bias = -input.Step * input.HoldStiffness * input.PositionError; + var inverseBallMass = 1f / input.BallMass; + var omegaFree = freeNumerator / hingeDenominator; + var relativeFreeVelocity = input.BallVelocity - input.ArmJacobian * omegaFree; + var rhs = bias - holdFactor * relativeFreeVelocity; + var effectiveInverseMass = inverseBallMass * float3x3.identity + + Outer(input.ArmJacobian) / hingeDenominator; + var matrix = float3x3.identity + holdFactor * effectiveInverseMass; + var impulse = math.mul(math.inverse(matrix), rhs); + var isCapped = ProjectToCap(ref impulse, input.MaximumHoldForce, input.Step); + var nextBallVelocity = input.BallVelocity + impulse * inverseBallMass; + var nextHingeVelocity = (freeNumerator - math.dot(input.ArmJacobian, impulse)) / hingeDenominator; + + if (input.ActiveStop != 0 && input.ActiveStop * nextHingeVelocity > 0f) { + var fixedImpulse = SolveFixedOwnerImpulse(input, bias, holdFactor, inverseBallMass); + var fixedIsCapped = ProjectToCap(ref fixedImpulse, input.MaximumHoldForce, input.Step); + var bearingImpulse = math.dot(input.ArmJacobian, fixedImpulse) - freeNumerator; + if (input.ActiveStop * bearingImpulse <= 0f) { + return CreateHoldResult(input, fixedImpulse, input.BallVelocity + fixedImpulse * inverseBallMass, + 0f, fixedIsCapped, bearingImpulse, true); + } + } + + return CreateHoldResult(input, impulse, nextBallVelocity, nextHingeVelocity, isCapped, 0f, false); + } + + private static HoldResult FreeHoldResult(in HoldInput input, float hingeAngularVelocity) + { + return new HoldResult(input.BallVelocity, hingeAngularVelocity, float3.zero, float3.zero, + input.Step > 0f ? input.OtherAngularImpulse / input.Step : 0f, 0f, 0f, false, false); + } + + private static HoldResult CreateHoldResult(in HoldInput input, float3 impulse, float3 ballVelocity, + float hingeAngularVelocity, bool isCapped, float bearingImpulse, bool isStopHeld) + { + var relativeVelocity = ballVelocity - input.ArmJacobian * hingeAngularVelocity; + var requestedImpulse = -input.Step * input.HoldStiffness + * (input.PositionError + input.Step * relativeVelocity) + - input.Step * input.HoldDamping * relativeVelocity; + return new HoldResult(ballVelocity, hingeAngularVelocity, impulse, + impulse / (input.BallMass * input.Step), + (input.OtherAngularImpulse - math.dot(input.ArmJacobian, impulse)) / input.Step, + math.length(impulse - requestedImpulse), bearingImpulse, isCapped, isStopHeld); + } + + private static float3 SolveFixedOwnerImpulse(in HoldInput input, float3 bias, float holdFactor, + float inverseBallMass) + { + return (bias - holdFactor * input.BallVelocity) / (1f + holdFactor * inverseBallMass); + } + + private static bool ProjectToCap(ref float3 impulse, float maximumHoldForce, float step) + { + var cap = math.max(0f, maximumHoldForce) * step; + var impulseLength = math.length(impulse); + if (impulseLength <= cap) { + return false; + } + impulse = impulseLength > 0f ? impulse * (cap / impulseLength) : float3.zero; + return true; + } + + private static float3x3 Outer(float3 value) + { + return new float3x3(value * value.x, value * value.y, value * value.z); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs.meta new file mode 100644 index 000000000..a9e181047 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ef33b1c6d6e4c0c84db9ea362872ebc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs new file mode 100644 index 000000000..0ce9f18b4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs @@ -0,0 +1,237 @@ +// 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 NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Unity.Simulation; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingePackagingTests + { + [Test] + public void HingeAndColliderValuesRoundTrip() + { + var gameObject = new GameObject("Spring Hinge"); + try { + var hinge = gameObject.AddComponent(); + var collider = gameObject.AddComponent(); + hinge.HingeAxis = new Vector3(0f, 0f, 1f); + hinge.CentreOfMass = new Vector3(1f, 2f, 3f); + hinge.ToyMass = 4f; + hinge.OverrideInertia = false; + hinge.ManualInertia = 5f; + hinge.MassBoxHalfExtents = new Vector3(6f, 7f, 8f); + hinge.SpringStiffness = 9f; + hinge.SpringDamping = 10f; + hinge.EquilibriumAngle = 11f; + hinge.MinimumAngle = -12f; + hinge.MaximumAngle = 13f; + hinge.InitialAngle = 3f; + hinge.EnableAngleSwitch = true; + hinge.SwitchCloseAngle = 23f; + hinge.SwitchOpenAngle = 17f; + collider.LocalCentre = new Vector3(14f, 15f, 16f); + collider.LocalRotation = new Vector3(17f, 18f, 19f); + collider.HalfExtents = new Vector3(20f, 21f, 22f); + collider.Elasticity = 0.4f; + collider.ElasticityFalloff = 0.5f; + collider.Friction = 0.6f; + collider.HitEvent = false; + collider.HitThreshold = 7f; + collider.OverwritePhysics = false; + var hingeBytes = hinge.Pack(); + var colliderBytes = collider.Pack(); + var refs = new PackagedRefs(gameObject.transform); + var files = new PackagedFiles(null, refs); + var colliderReferenceBytes = collider.PackReferences( + gameObject.transform, refs, files); + + hinge.HingeAxis = Vector3.right; + hinge.CentreOfMass = Vector3.zero; + hinge.ToyMass = 1f; + collider.LocalCentre = Vector3.zero; + collider.HitEvent = true; + collider.OverwritePhysics = true; + hinge.Unpack(hingeBytes); + collider.Unpack(colliderBytes); + collider.UnpackReferences(colliderReferenceBytes, + gameObject.transform, refs, files); + + Assert.That(hinge.HingeAxis, Is.EqualTo(new Vector3(0f, 0f, 1f))); + Assert.That(hinge.CentreOfMass, Is.EqualTo(new Vector3(1f, 2f, 3f))); + Assert.That(hinge.ToyMass, Is.EqualTo(4f)); + Assert.That(hinge.OverrideInertia, Is.False); + Assert.That(hinge.ManualInertia, Is.EqualTo(5f)); + Assert.That(hinge.MassBoxHalfExtents, Is.EqualTo(new Vector3(6f, 7f, 8f))); + Assert.That(hinge.SpringStiffness, Is.EqualTo(9f)); + Assert.That(hinge.SpringDamping, Is.EqualTo(10f)); + Assert.That(hinge.EquilibriumAngle, Is.EqualTo(11f)); + Assert.That(hinge.MinimumAngle, Is.EqualTo(-12f)); + Assert.That(hinge.MaximumAngle, Is.EqualTo(13f)); + Assert.That(hinge.InitialAngle, Is.EqualTo(3f)); + Assert.That(hinge.EnableAngleSwitch, Is.True); + Assert.That(hinge.SwitchCloseAngle, Is.EqualTo(23f)); + Assert.That(hinge.SwitchOpenAngle, Is.EqualTo(17f)); + Assert.That(collider.LocalCentre, Is.EqualTo(new Vector3(14f, 15f, 16f))); + Assert.That(collider.LocalRotation, Is.EqualTo(new Vector3(17f, 18f, 19f))); + Assert.That(collider.HalfExtents, Is.EqualTo(new Vector3(20f, 21f, 22f))); + Assert.That(collider.Elasticity, Is.EqualTo(0.4f)); + Assert.That(collider.ElasticityFalloff, Is.EqualTo(0.5f)); + Assert.That(collider.Friction, Is.EqualTo(0.6f)); + Assert.That(collider.HitEvent, Is.False); + Assert.That(collider.HitThreshold, Is.EqualTo(7f)); + Assert.That(collider.OverwritePhysics, Is.False); + Assert.That(collider.PhysicsMaterial, Is.Null); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void OwnedMagnetVersionFourRoundTripsAndOlderVersionsStayUnowned() + { + var gameObject = new GameObject("Magnet"); + try { + var magnet = gameObject.AddComponent(); + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = new Vector3(1f, 2f, 3f); + magnet.HoldStiffness = 4f; + magnet.HoldDamping = 5f; + magnet.MaxHoldForce = 6f; + var bytes = magnet.Pack(); + + magnet.CoupleToParentHinge = false; + magnet.HeldBallCentreOffset = Vector3.zero; + magnet.HoldStiffness = 0f; + magnet.HoldDamping = 0f; + magnet.MaxHoldForce = 0f; + magnet.Unpack(bytes); + + Assert.That(magnet.CoupleToParentHinge, Is.True); + Assert.That(magnet.HeldBallCentreOffset, Is.EqualTo(new Vector3(1f, 2f, 3f))); + Assert.That(magnet.HoldStiffness, Is.EqualTo(4f)); + Assert.That(magnet.HoldDamping, Is.EqualTo(5f)); + Assert.That(magnet.MaxHoldForce, Is.EqualTo(6f)); + + magnet.CoupleToParentHinge = true; + magnet.Unpack(PackageApi.Packer.Pack(new MagnetPackable { Version = 3 })); + Assert.That(magnet.CoupleToParentHinge, Is.False); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void RestoredHierarchyResolvesOwnerAndDisablesPrescribedMotion() + { + var hingeObject = new GameObject("Spring Hinge"); + var pivotObject = new GameObject("Preserved Pivot"); + var magnetObject = new GameObject("Owned Magnet"); + try { + var hinge = hingeObject.AddComponent(); + pivotObject.transform.SetParent(hingeObject.transform, false); + magnetObject.transform.SetParent(pivotObject.transform, false); + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.ForceProfile = MagnetForceProfile.Physical; + magnet.CoupleToParentHinge = true; + magnet.IsKinematic = true; + + var state = magnet.CreateState(); + + Assert.That(state.CoupleToHinge, Is.True); + Assert.That(state.HingeOwnerId, Is.EqualTo(hinge.ItemId)); + Assert.That(((IKinematicTransformComponent)magnet).IsKinematic, Is.False); + } finally { + UnityEngine.Object.DestroyImmediate(hingeObject); + } + } + + [Test] + public void SynchronousMovementPublishesHingeAngle() + { + var states = new NativeParallelHashMap(1, Allocator.Temp); + try { + states.Add(12, new SpringHingeState(12, default, + new SpringHingeMovementState { Angle = 0.75f })); + var emitter = new RecordingEmitter(); + var emitters = new Dictionary> { { 12, emitter } }; + + new PhysicsMovements().ApplySpringHingeMovement(ref states, emitters); + + Assert.That(emitter.Value, Is.EqualTo(0.75f)); + } finally { + states.Dispose(); + } + } + + [Test] + public void AngleSwitchUsesSeparateCloseAndOpenThresholds() + { + var gameObject = new GameObject("Table"); + var hingeObject = new GameObject("Spring Hinge"); + try { + hingeObject.transform.SetParent(gameObject.transform, false); + gameObject.AddComponent(); + var hinge = hingeObject.AddComponent(); + hinge.EnableAngleSwitch = true; + hinge.SwitchCloseAngle = 10f; + hinge.SwitchOpenAngle = 5f; + var api = new SpringHingeApi(hinge, null); + var angleSwitch = (DeviceSwitch)((IApiSwitchDevice)api).Switch( + SpringHingeComponent.AngleSwitchItem); + var transitions = new List(); + angleSwitch.Switch += (_, args) => transitions.Add(args.IsEnabled); + + api.OnAngleChanged(math.radians(11f)); + api.OnAngleChanged(math.radians(8f)); + api.OnAngleChanged(math.radians(4f)); + + Assert.That(transitions, Is.EqualTo(new[] { true, false })); + } finally { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + + [Test] + public void OwnedSnapshotsRejectPartialBallOrHingeOutput() + { + Assert.DoesNotThrow(() => PhysicsEngineThreading.ValidateOwnedSnapshotCapacity( + 0, SimulationState.MaxBalls + 1, SimulationState.MaxFloatAnimations + 1)); + Assert.Throws(() => + PhysicsEngineThreading.ValidateOwnedSnapshotCapacity( + 1, SimulationState.MaxBalls + 1, 1)); + Assert.Throws(() => + PhysicsEngineThreading.ValidateOwnedSnapshotCapacity( + 1, 1, SimulationState.MaxFloatAnimations + 1)); + Assert.That(PhysicsEngineThreading.ShouldSuppressOwnedSnapshot( + 0, SimulationState.MaxBalls + 1), Is.False); + Assert.That(PhysicsEngineThreading.ShouldSuppressOwnedSnapshot( + 1, SimulationState.MaxBalls), Is.False); + Assert.That(PhysicsEngineThreading.ShouldSuppressOwnedSnapshot( + 1, SimulationState.MaxBalls + 1), Is.True); + } + + private sealed class RecordingEmitter : IAnimationValueEmitter + { + public float Value { get; private set; } + public event Action OnAnimationValueChanged; + + public void UpdateAnimationValue(float value) + { + Value = value; + OnAnimationValueChanged?.Invoke(value); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs.meta new file mode 100644 index 000000000..d998d9fd5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6fc89394b724a938254da64af8197ce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs new file mode 100644 index 000000000..c2a1b78e0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs @@ -0,0 +1,248 @@ +// 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; +using NativeTrees; +using Unity.Collections; +using Unity.Mathematics; + +using VisualPinball.Engine.Common; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingePhysicsTests + { + [Test] + public void VelocityPreparationUsesImplicitSpringStepOnce() + { + var state = CreateState(angle: 0.2f, angularVelocity: -0.5f); + const float step = 0.1f; + var gravity = new float3(0f, 0f, -0.002f); + var expectedGravityTorque = -0.1f * math.cos(state.Movement.Angle); + var expected = (state.Static.Inertia * state.Movement.AngularVelocity + + step * expectedGravityTorque + - step * state.Static.Stiffness * (state.Movement.Angle - state.Static.EquilibriumAngle)) + / (state.Static.Inertia + step * state.Static.Damping + + step * step * state.Static.Stiffness); + + SpringHingeVelocityPhysics.UpdateVelocity(ref state, gravity, step); + + Assert.That(state.Movement.AngularVelocity, Is.EqualTo(expected).Within(1e-6f)); + Assert.That(state.Movement.TickStartAngularVelocity, Is.EqualTo(-0.5f)); + Assert.That(state.Movement.TickStartAngleError, Is.EqualTo(0.2f)); + Assert.That(state.Movement.TickStep, Is.EqualTo(step)); + } + + [Test] + public void ArbitraryAxisGravityTorqueUsesRotatedCentreOfMass() + { + var state = CreateState(angle: math.PI / 2f); + state.Static.Axis = new float3(1f, 0f, 0f); + state.Static.CentreOfMassArm = new float3(0f, 50f, 0f); + state.Static.Mass = 1.5f; + var gravity = new float3(0f, -0.002f, 0f); + const float expected = 0.15f; + + SpringHingeVelocityPhysics.UpdateVelocity(ref state, gravity, 0.1f); + + Assert.That(state.Movement.GravityTorque, Is.EqualTo(expected).Within(1e-6f)); + } + + [Test] + public void PhysicsUpdateAppliesCabinetAccelerationAndEnumeratesHinges() + { + using var harness = new PhysicsStateHarness(); + var hinge = CreateState(); + hinge.Static.Axis = new float3(0f, 0f, 1f); + hinge.Static.Stiffness = 0f; + hinge.Static.Damping = 0f; + hinge.Static.CentreOfMassArm = new float3(0f, 50f, 0f); + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + var state = harness.CreateState(); + var cabinetAcceleration = new float2(2f, 0f); + var expectedAcceleration = -2f * PhysicsConstants.MToVpu + * PhysicsConstants.DefaultStepTimeS * PhysicsConstants.DefaultStepTimeS; + + PhysicsUpdate.UpdateSpringHingeVelocities(ref state, float3.zero, cabinetAcceleration, 0.1f); + + ref var updated = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + Assert.That(updated.Movement.EffectiveGravity.x, Is.EqualTo(expectedAcceleration).Within(1e-7f)); + Assert.That(updated.Movement.GravityTorque, Is.EqualTo(-50f * expectedAcceleration).Within(1e-6f)); + } + + [Test] + public void StopArrivalShortensStepAndBlocksOutwardVelocity() + { + var state = CreateState(angle: 0.24f, angularVelocity: 0.5f); + state.Static.MaximumAngle = 0.25f; + var stopTime = SpringHingeDisplacementPhysics.GetStopTime(state); + + SpringHingeDisplacementPhysics.UpdateDisplacement(ref state, stopTime); + + Assert.That(stopTime, Is.EqualTo(0.02f).Within(1e-6f)); + Assert.That(state.Movement.Angle, Is.EqualTo(0.25f)); + Assert.That(state.Movement.AngularVelocity, Is.Zero); + Assert.That(state.Movement.ActiveStop, Is.EqualTo(1)); + Assert.That(SpringHingeDisplacementPhysics.GetStopTime(state), Is.EqualTo(-1f), + "an outward resting stop must not create a zero-time scheduler loop"); + } + + [Test] + public void SpringTorqueCanReleaseHingeFromStopImmediately() + { + var state = CreateState(angle: 0.25f); + state.Static.MaximumAngle = 0.25f; + state.Static.EquilibriumAngle = 0f; + state.Movement.ActiveStop = 1; + + SpringHingeVelocityPhysics.UpdateVelocity(ref state, float3.zero, 0.1f); + + Assert.That(state.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(state.Movement.ActiveStop, Is.Zero); + } + + [Test] + public void RestingStopPreservesBlockedTorqueAcrossDisplacement() + { + var state = CreateState(angle: 0.25f); + state.Static.MaximumAngle = 0.25f; + state.Static.EquilibriumAngle = 0.5f; + state.Movement.ActiveStop = 1; + + SpringHingeVelocityPhysics.UpdateVelocity(ref state, float3.zero, 0.1f); + SpringHingeDisplacementPhysics.UpdateDisplacement(ref state, 0.01f); + + Assert.That(state.Movement.ActiveStop, Is.EqualTo(1)); + Assert.That(state.Movement.BlockedTorque, Is.GreaterThan(0f)); + Assert.That(state.Movement.ContinuousAngularAcceleration, Is.Zero); + } + + [Test] + public void SlowInwardMotionEscapesStopToleranceBand() + { + var state = CreateState(angle: 0.25f - 0.5e-6f, angularVelocity: -0.25e-6f); + state.Static.MaximumAngle = 0.25f; + + SpringHingeDisplacementPhysics.UpdateDisplacement(ref state, 1f); + + Assert.That(state.Movement.Angle, Is.EqualTo(0.25f - 0.75e-6f).Within(3e-8f)); + Assert.That(state.Movement.Angle, Is.LessThan(0.25f - 0.5e-6f)); + Assert.That(state.Movement.AngularVelocity, Is.LessThan(0f)); + Assert.That(state.Movement.ActiveStop, Is.Zero); + } + + [Test] + public void DegenerateVelocityStepPreservesIncomingMotion() + { + var state = CreateState(angle: 0.1f, angularVelocity: -0.4f); + + SpringHingeVelocityPhysics.UpdateVelocity(ref state, float3.zero, 0f); + + Assert.That(state.Movement.AngularVelocity, Is.EqualTo(-0.4f)); + } + + [Test] + public void PhysicsCycleShortensSubstepAtHingeStop() + { + using var harness = new PhysicsStateHarness(); + var hinge = CreateState(angle: 0.24f, angularVelocity: 0.5f); + hinge.Static.MaximumAngle = 0.25f; + harness.SpringHingeStates.Add(hinge.AnimationItemId, hinge); + var state = harness.CreateState(); + var overlapping = new NativeParallelHashSet(1, Allocator.Temp); + NativeTrees.AABB bounds = new Aabb(new float3(-100f), new float3(100f)); + var kinematicOctree = new NativeOctree(bounds, 16, 4, Allocator.Temp); + var ballOctree = new NativeOctree(bounds, 16, 4, Allocator.Temp); + var cycle = new PhysicsCycle(Allocator.Temp); + try { + cycle.Simulate(ref state, ref overlapping, ref kinematicOctree, ref ballOctree, 0.1f); + + ref var updated = ref state.SpringHingeStates.GetValueByRef(hinge.AnimationItemId); + Assert.That(updated.Movement.Angle, Is.EqualTo(0.25f)); + Assert.That(updated.Movement.AngularVelocity, Is.Zero); + Assert.That(updated.Movement.ActiveStop, Is.EqualTo(1)); + } finally { + cycle.Dispose(); + ballOctree.Dispose(); + kinematicOctree.Dispose(); + overlapping.Dispose(); + } + } + + [Test] + public void StaticProgressRuleCannotOverrunSelectedMechanismStop() + { + var hitTime = 0.1f; + PhysicsCycle.ClampToMechanismStop(ref hitTime, 0.02f); + Assert.That(hitTime, Is.EqualTo(0.02f)); + + hitTime = 0.005f; + PhysicsCycle.ClampToMechanismStop(ref hitTime, 0.02f); + Assert.That(hitTime, Is.EqualTo(0.005f)); + } + + [Test] + public void ExhaustedStaticProgressCannotOverrunAcceptedSpringHingeHit() + { + var transforms = new NativeParallelHashMap(1, Allocator.Temp); + var references = new ColliderReference(ref transforms, Allocator.Temp); + using var harness = new PhysicsStateHarness(); + try { + var pivot = float3.zero; + var centre = new float3(10f, 0f, 0f); + var extents = new float3(5f, 2f, 2f); + var x = new float3(1f, 0f, 0f); + var y = new float3(0f, 1f, 0f); + var z = new float3(0f, 0f, 1f); + var colliderId = references.Add(new SpringHingeCollider(12, in pivot, in centre, + in extents, in x, in y, in z, new ColliderInfo { ItemId = 12 })); + harness.SetStaticColliders(ref references); + var state = harness.CreateState(); + var ball = new BallState { + Id = 1, + CollisionEvent = new CollisionEventData { + ColliderId = colliderId, + HitTime = 0.001f + } + }; + var acceptedHingeTime = -1f; + PhysicsCycle.RecordSpringHingeHitTime(ref acceptedHingeTime, in ball, ref state); + var hitTime = ball.CollisionEvent.HitTime; + var exhaustedStaticCount = 0f; + + PhysicsCycle.ApplyStaticTime(ref hitTime, ref exhaustedStaticCount, in ball); + Assert.That(hitTime, Is.EqualTo(PhysicsConstants.StaticTime)); + PhysicsCycle.ClampToSpringHingeHit(ref hitTime, acceptedHingeTime); + + Assert.That(hitTime, Is.EqualTo(0.001f)); + } finally { + references.Dispose(); + transforms.Dispose(); + } + } + + private static SpringHingeState CreateState(float angle = 0f, float angularVelocity = 0f) + { + return new SpringHingeState(12, new SpringHingeStaticState { + OwnerId = 12, + Axis = new float3(1f, 0f, 0f), + CentreOfMassArm = new float3(0f, 50f, 0f), + Mass = 1f, + Inertia = 20f, + Stiffness = 5f, + Damping = 0.3f, + MinimumAngle = -0.25f, + MaximumAngle = 0.25f + }, new SpringHingeMovementState { + Angle = angle, + AngularVelocity = angularVelocity + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs.meta new file mode 100644 index 000000000..aa52c1979 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2183bf20e04149c6b1e970cce15f4f60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs new file mode 100644 index 000000000..f42ebd50a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.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; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingePlayModeFixtureTests + { + [UnityTearDown] + public IEnumerator LeavePlayModeAfterEachTest() + { + if (Application.isPlaying) { + yield return new ExitPlayMode(); + } + } + + [UnityTest] + public IEnumerator RealPlayerPhysicsRunsShotMagnetReleaseAndReset() + { + Assert.That(Application.isPlaying, Is.False); + yield return new EnterPlayMode(); + Assert.That(Application.isPlaying, Is.True); + var fixture = CreateFixture(); + try { + fixture.Root.SetActive(true); + yield return null; + yield return null; + + Assert.That(fixture.PhysicsEngine.IsInitialized, Is.True); + Assert.That(fixture.Hinge.SpringHingeApi, Is.Not.Null); + Assert.That(fixture.Magnet.MagnetApi, Is.Not.Null); + + fixture.Magnet.MagnetApi.IsEnabled = true; + var ballId = fixture.Player.BallManager.CreateBall( + new DebugBallCreator(400f, 900f, 0f, 180f, 18f), + 25f, 1f, fixture.BallPrefab); + yield return null; + + Assert.That(ballId, Is.Not.Zero); + Assert.That(fixture.Player.BallManager.NumBallsCreated, Is.EqualTo(1)); + Assert.That(fixture.Magnet.MagnetApi.IsEnabled, Is.True); + + fixture.Magnet.MagnetApi.ReleaseBall(); + fixture.Magnet.MagnetApi.IsEnabled = false; + fixture.Hinge.SpringHingeApi.Reset(fixture.Hinge.InitialAngle); + fixture.Player.BallManager.DestroyBall(ballId); + yield return null; + + Assert.That(fixture.Magnet.MagnetApi.IsEnabled, Is.False); + } finally { + Object.DestroyImmediate(fixture.Root); + } + yield return new ExitPlayMode(); + Assert.That(Application.isPlaying, Is.False); + } + + private static Fixture CreateFixture() + { + var root = new GameObject("Spring Hinge Play Mode Fixture"); + root.SetActive(false); + root.AddComponent(); + root.AddComponent(); + var player = root.AddComponent(); + var physicsEngine = root.AddComponent(); + + var playfieldObject = new GameObject("Playfield"); + playfieldObject.transform.SetParent(root.transform, false); + var playfield = playfieldObject.AddComponent(); + playfield.GlassHeight = 500f; + playfield.RenderSlope = 0f; + + var hingeObject = new GameObject("Spring Hinge"); + hingeObject.transform.SetParent(playfieldObject.transform, false); + hingeObject.transform.localPosition = new Vector3(-0.4f, 0f, 0.9f); + var hinge = hingeObject.AddComponent(); + hinge.HingeAxis = Vector3.forward; + hinge.MinimumAngle = -20f; + hinge.MaximumAngle = 20f; + hinge.OverrideInertia = false; + var proxy = hingeObject.AddComponent(); + proxy.LocalCentre = new Vector3(0f, 50f, 0f); + proxy.HalfExtents = new Vector3(25f, 50f, 10f); + + var magnetObject = new GameObject("Owned Magnet"); + magnetObject.transform.SetParent(hingeObject.transform, false); + magnetObject.transform.localPosition = new Vector3(0f, 0.05f, 0f); + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.ForceProfile = MagnetForceProfile.Physical; + magnet.CoupleToParentHinge = true; + magnet.GrabBall = true; + magnet.IsEnabledOnStart = false; + magnet.HeldBallCentreOffset = new Vector3(0f, 25f, 0f); + magnet.HoldStiffness = 2f; + magnet.HoldDamping = 2f; + magnet.MaxHoldForce = 10f; + + var prefabHolder = new GameObject("Test Ball Prefab Holder"); + prefabHolder.transform.SetParent(root.transform, false); + prefabHolder.SetActive(false); + var ballPrefab = new GameObject("Test Ball Prefab"); + ballPrefab.transform.SetParent(prefabHolder.transform, false); + ballPrefab.AddComponent(); + + return new Fixture(root, player, physicsEngine, hinge, magnet, ballPrefab); + } + + private readonly struct Fixture + { + internal readonly GameObject Root; + internal readonly Player Player; + internal readonly PhysicsEngine PhysicsEngine; + internal readonly SpringHingeComponent Hinge; + internal readonly MagnetComponent Magnet; + internal readonly GameObject BallPrefab; + + internal Fixture(GameObject root, Player player, PhysicsEngine physicsEngine, + SpringHingeComponent hinge, MagnetComponent magnet, GameObject ballPrefab) + { + Root = root; + Player = player; + PhysicsEngine = physicsEngine; + Hinge = hinge; + Magnet = magnet; + BallPrefab = ballPrefab; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs.meta new file mode 100644 index 000000000..6e4eefa7b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f53dcd3a7c5c49bca268e4be75731a65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs new file mode 100644 index 000000000..7b4accee5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs @@ -0,0 +1,204 @@ +// 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.Diagnostics; +using NUnit.Framework; +using Unity.Mathematics; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeQualificationTests + { + private const int Iterations = 100000; + private const float Tick = 0.1f; + + [Test] + public void PassiveHingeDoesNotGainEnergyOverTenSeconds() + { + var hinge = CreateHinge(1); + hinge.Static.Damping = 0f; + hinge.Static.MinimumAngle = -math.PI; + hinge.Static.MaximumAngle = math.PI; + hinge.Movement.Angle = 0.2f; + var initialEnergy = MechanicalEnergy(in hinge); + var maximumEnergy = initialEnergy; + + for (var tick = 0; tick < 10000; tick++) { + SpringHingeVelocityPhysics.UpdateVelocity(ref hinge, float3.zero, Tick); + SpringHingeDisplacementPhysics.UpdateDisplacement(ref hinge, Tick); + maximumEnergy = math.max(maximumEnergy, MechanicalEnergy(in hinge)); + } + + var finalEnergy = MechanicalEnergy(in hinge); + Assert.That(maximumEnergy, Is.LessThanOrEqualTo(initialEnergy * 1.00001f)); + Assert.That(finalEnergy, Is.LessThan(initialEnergy), + "the implicit step may damp passive motion but must not create energy"); + } + + [TestCase(8f)] + [TestCase(18f)] + [TestCase(30f)] + public void ShotEnvelopeKeepsAcceptedToiPenetrationBelowHalfPercentRadius(float speed) + { + const float radius = 25f; + var pivot = float3.zero; + var centreArm = new float3(50f, 0f, 0f); + var halfExtents = new float3(25f, 25f, 10f); + var axisX = new float3(1f, 0f, 0f); + var axisY = new float3(0f, 1f, 0f); + var axisZ = new float3(0f, 0f, 1f); + var info = new ColliderInfo { + ItemId = 1, + Material = new PhysicsMaterialData { Elasticity = 0.1f, Friction = 0.3f } + }; + var collider = new SpringHingeCollider(1, in pivot, in centreArm, in halfExtents, + in axisX, in axisY, in axisZ, info); + var hinge = CreateHinge(1); + var ball = new BallState { + Id = 2, + Position = new float3(50f, 50f + speed * Tick * 0.5f, 0f), + Velocity = new float3(0f, -speed, 0f), + Mass = 1f, + Radius = radius + }; + var collision = new CollisionEventData(); + + var time = collider.HitTest(ref collision, in hinge, in ball, Tick); + + Assert.That(time, Is.EqualTo(Tick * 0.5f).Within(2e-4f)); + Assert.That(collision.HitDistance, + Is.GreaterThanOrEqualTo(-radius * 0.005f).And.LessThanOrEqualTo(radius * 0.005f)); + ball.Position += ball.Velocity * time; + SpringHingeDisplacementPhysics.UpdateDisplacement(ref hinge, time); + var distanceAtImpact = collider.Distance(in hinge, in ball.Position, radius); + Assert.That(distanceAtImpact.Separation, Is.GreaterThanOrEqualTo(-radius * 0.005f)); + } + + [Test] + public void TickAndHoldLoopsStayAllocationFreeWithinBoundedRuntime() + { + using var emptyHarness = new PhysicsStateHarness(); + var emptyState = emptyHarness.CreateState(); + using var oneHarness = new PhysicsStateHarness(); + oneHarness.SpringHingeStates.Add(100, CreateHinge(100)); + var oneState = oneHarness.CreateState(); + using var loadedHarness = new PhysicsStateHarness(); + for (var i = 0; i < 8; i++) { + loadedHarness.SpringHingeStates.Add(100 + i, CreateHinge(100 + i)); + } + var loadedState = loadedHarness.CreateState(); + var hinge = CreateHinge(1); + var ball = new BallState { + Id = 2, + Position = new float3(0f, 50f, 0f), + Velocity = new float3(0.1f, -0.2f, 0.05f), + Mass = 1f, + Radius = 25f + }; + var magnet = new MagnetState { + EffectiveCurrent = 1f, + HoldStiffness = 25f, + HoldDamping = 10f, + MaxHoldForce = 1000f + }; + + // Warm JIT and native-container enumerators before allocation measurement. + PhysicsUpdate.UpdateSpringHingeVelocities(ref emptyState, float3.zero, float2.zero, 0.1f); + PhysicsUpdate.UpdateSpringHingeVelocities(ref oneState, float3.zero, float2.zero, 0.1f); + PhysicsUpdate.UpdateSpringHingeVelocities(ref loadedState, float3.zero, float2.zero, 0.1f); + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, + new float3(0f, 50f, 0f), 0.1f, out _); + + var empty = MeasureTickLoop(ref emptyState); + var one = MeasureTickLoop(ref oneState); + var several = MeasureTickLoop(ref loadedState); + var hold = MeasureHoldLoop(ref ball, ref hinge, in magnet); + + UnityEngine.Debug.Log( + $"spring-hinge benchmark ({Iterations} iterations): empty={empty.ElapsedMilliseconds:0.###}ms/{empty.AllocatedBytes}B, " + + $"one={one.ElapsedMilliseconds:0.###}ms/{one.AllocatedBytes}B, " + + $"eight={several.ElapsedMilliseconds:0.###}ms/{several.AllocatedBytes}B, hold={hold.ElapsedMilliseconds:0.###}ms/{hold.AllocatedBytes}B"); + Assert.That(empty.AllocatedBytes, Is.Zero, "empty hinge scan must not allocate per tick"); + Assert.That(one.AllocatedBytes, Is.Zero, "one-hinge scan must not allocate per tick"); + Assert.That(several.AllocatedBytes, Is.Zero, "several-hinge scan must not allocate per tick"); + Assert.That(hold.AllocatedBytes, Is.Zero, "owned hold solve must not allocate per tick"); + Assert.That(empty.ElapsedMilliseconds, Is.LessThan(5000f)); + Assert.That(one.ElapsedMilliseconds, Is.LessThan(5000f)); + Assert.That(several.ElapsedMilliseconds, Is.LessThan(5000f)); + Assert.That(hold.ElapsedMilliseconds, Is.LessThan(5000f)); + } + + private static Measurement MeasureTickLoop(ref PhysicsState state) + { + var before = GC.GetAllocatedBytesForCurrentThread(); + var started = Stopwatch.GetTimestamp(); + for (var i = 0; i < Iterations; i++) { + PhysicsUpdate.UpdateSpringHingeVelocities(ref state, + float3.zero, float2.zero, 0.1f); + } + var elapsed = Stopwatch.GetTimestamp() - started; + return new Measurement(elapsed * 1000.0 / Stopwatch.Frequency, + GC.GetAllocatedBytesForCurrentThread() - before); + } + + private static Measurement MeasureHoldLoop(ref BallState ball, + ref SpringHingeState hinge, in MagnetState magnet) + { + // Re-seeding is deliberately timed, so this is a conservative solve cost rather than a solver-only number. + var before = GC.GetAllocatedBytesForCurrentThread(); + var started = Stopwatch.GetTimestamp(); + for (var i = 0; i < Iterations; i++) { + ball.Position = new float3(0f, 50f, 0f); + ball.Velocity = new float3(0.1f, -0.2f, 0.05f); + hinge.Movement = new SpringHingeMovementState { + TickStartAngularVelocity = 0.01f, + TickStep = 0.1f + }; + OwnedMagnetPhysics.SolveHold(ref ball, ref hinge, in magnet, + new float3(0f, 50f, 0f), 0.1f, out _); + } + var elapsed = Stopwatch.GetTimestamp() - started; + return new Measurement(elapsed * 1000.0 / Stopwatch.Frequency, + GC.GetAllocatedBytesForCurrentThread() - before); + } + + private static SpringHingeState CreateHinge(int id) + => new(id, new SpringHingeStaticState { + OwnerId = id, + Pivot = float3.zero, + Axis = new float3(0f, 0f, 1f), + CentreOfMassArm = new float3(0f, 50f, 0f), + Mass = 1f, + Inertia = 2500f, + Stiffness = 100f, + Damping = 5f, + MinimumAngle = -math.PI, + MaximumAngle = math.PI + }, default); + + private static float MechanicalEnergy(in SpringHingeState hinge) + { + var angleError = hinge.Movement.Angle - hinge.Static.EquilibriumAngle; + return 0.5f * hinge.Static.Inertia * hinge.Movement.AngularVelocity * hinge.Movement.AngularVelocity + + 0.5f * hinge.Static.Stiffness * angleError * angleError; + } + + private readonly struct Measurement + { + internal readonly double ElapsedMilliseconds; + internal readonly long AllocatedBytes; + + internal Measurement(double elapsedMilliseconds, long allocatedBytes) + { + ElapsedMilliseconds = elapsedMilliseconds; + AllocatedBytes = allocatedBytes; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs.meta new file mode 100644 index 000000000..6753bfc4d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b9376b161d349d2882d916e7538c90c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpinnerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpinnerTests.cs index b91ae0562..66d4e81f6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpinnerTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpinnerTests.cs @@ -29,6 +29,82 @@ namespace VisualPinball.Unity.Test { public class SpinnerTests { + [Test] + public void ShouldMatchVpxAngularVelocityTransferAtDefaultHeight() + { + var collider = CreateSpinnerCollider(60f, float4x4.identity); + var movement = new SpinnerMovementState(); + var state = new SpinnerStaticState { Damping = 0.9f }; + var collEvent = new CollisionEventData { + HitNormal = new float3(0f, 1f, 0f), + HitFlag = false + }; + var ball = new BallState { Velocity = new float3(0f, 12f, 0f) }; + + collider.Collide(in ball, ref collEvent, ref movement, in state); + + Assert.That(movement.AngleSpeed, Is.EqualTo(12f / 30f * 0.9f).Within(1e-6f)); + } + + [Test] + public void ShouldNotUseColliderOffsetAsAngularVelocityLever() + { + var collider = CreateSpinnerCollider(60f, float4x4.identity, colliderHeight: 120f); + + var angleSpeed = Collide(in collider, new float3(0f, 12f, 0f), new float3(0f, 1f, 0f)); + + Assert.That(angleSpeed, Is.EqualTo(12f / 30f).Within(1e-6f)); + } + + [Test] + public void ShouldNotMovePlateEdgeFasterThanIncomingBall() + { + const float ballSpeed = 12f; + var collider = CreateSpinnerCollider(0f, float4x4.identity); + + var angleSpeed = Collide(in collider, new float3(0f, ballSpeed, 0f), new float3(0f, 1f, 0f)); + + Assert.That(angleSpeed * SpinnerCollider.DefaultPlateRadius, Is.LessThanOrEqualTo(ballSpeed)); + } + + [Test] + public void ShouldKeepAngularVelocityPhysicalAcrossColliderTransforms() + { + const float height = 0f; + const float worldSpeed = 12f; + var scale = new float3(2f); + var matrix = float4x4.TRS(new float3(100f, 200f, 300f), quaternion.RotateZ(math.radians(37f)), scale); + var transformedCollider = CreateSpinnerCollider(height, matrix); + var transformedNormal = new float3(transformedCollider.LineSeg0.Normal, 0f); + var transformedSpeed = Collide(in transformedCollider, transformedNormal * worldSpeed, transformedNormal); + + var localCollider = CreateSpinnerCollider(height, matrix, true); + var localNormal = new float3(localCollider.LineSeg0.Normal, 0f); + var localSpeed = Collide(in localCollider, localNormal * (worldSpeed / scale.y), localNormal); + + Assert.That(transformedSpeed, Is.EqualTo(worldSpeed / (SpinnerCollider.DefaultPlateRadius * scale.y)).Within(1e-6f)); + Assert.That(localSpeed, Is.EqualTo(transformedSpeed).Within(1e-6f)); + Assert.That(transformedCollider.Bounds.Aabb, Is.EqualTo(transformedCollider.LineSeg0.Bounds.Aabb)); + } + + [Test] + public void ShouldApplyVpxPlateRotationDirection() + { + var go = new GameObject("Spinner Plate Animation Test"); + try { + var animation = go.AddComponent(); + animation.RotationVector = Vector3.right; + InvokeNonPublic(animation, "Start"); + + InvokeNonPublic(animation, "OnAnimationValueChanged", math.PI * 0.5f); + + var expected = Quaternion.AngleAxis(-90f, Vector3.right); + Assert.That(Quaternion.Angle(animation.transform.localRotation, expected), Is.LessThan(1e-4f)); + } finally { + Object.DestroyImmediate(go); + } + } + [Test] public void ShouldGenerateColliderAtThreeDimensionalOffset() { @@ -106,5 +182,46 @@ public void ShouldWriteImportedSpinnerData() Object.DestroyImmediate(go); } + private static float Collide(in SpinnerCollider collider, float3 velocity, float3 normal) + { + var movement = new SpinnerMovementState(); + var state = new SpinnerStaticState { Damping = 1f }; + var collEvent = new CollisionEventData { HitNormal = normal }; + var ball = new BallState { Velocity = velocity }; + var mutableCollider = collider; + mutableCollider.Collide(in ball, ref collEvent, ref movement, in state); + return movement.AngleSpeed; + } + + private static SpinnerCollider CreateSpinnerCollider(float height, float4x4 matrix, bool isKinematic = false, float colliderHeight = 0f) + { + var go = new GameObject("Spinner Collision Test"); + var nonTransformableColliderTransforms = new NativeParallelHashMap(1, Allocator.Temp); + var colliders = new ColliderReference(ref nonTransformableColliderTransforms, Allocator.Temp, isKinematic); + + try { + var spinner = go.AddComponent(); + spinner.Position = new Vector3(0f, 0f, height); + var colliderComponent = go.AddComponent(); + colliderComponent.ZPosition = colliderHeight; + + var api = new SpinnerApi(go, null, null); + ((IApiColliderGenerator)api).CreateColliders(ref colliders, matrix, 0f); + + return colliders.SpinnerColliders[0]; + } finally { + colliders.Dispose(); + nonTransformableColliderTransforms.Dispose(); + Object.DestroyImmediate(go); + } + } + + private static void InvokeNonPublic(object target, string methodName, params object[] args) + { + var method = target.GetType().GetMethod(methodName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null, $"Could not find {methodName} on {target.GetType().Name}."); + method.Invoke(target, args); + } + } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge.meta new file mode 100644 index 000000000..052b74e6d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b4a68c151b64c52b4d08bd340f2d5e1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs new file mode 100644 index 000000000..3782336f8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -0,0 +1,264 @@ +// 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 NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeAuthoringTests + { + [Test] + public void BashSetupCreatesCompleteOwnedRotatingObject() + { + var root = SpringHingeAuthoring.CreateBashToy(); + try { + var hinge = root.GetComponent(); + var proxy = root.GetComponent(); + var magnet = root.GetComponentInChildren(); + + Assert.That(hinge, Is.Not.Null); + Assert.That(proxy, Is.Not.Null); + Assert.That(magnet.CoupleToParentHinge, Is.True); + Assert.That(magnet.MagnetType, Is.EqualTo(MagnetType.Spatial)); + Assert.That(magnet.ForceProfile, Is.EqualTo(MagnetForceProfile.Physical)); + Assert.That(magnet.Strength, Is.EqualTo(40000f)); + Assert.That(magnet.HoldStiffness, Is.EqualTo(4f)); + Assert.That(magnet.HoldDamping, Is.EqualTo(4f)); + Assert.That(magnet.MaxHoldForce, Is.EqualTo(50f)); + Assert.That(magnet.GetComponentInParent(), Is.SameAs(proxy)); + Assert.That(root.GetComponentInChildren(), Is.Null); + Assert.That(SpringHingeAuthoring.Validate(hinge, proxy), Is.Empty); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void SpatialOwnedMagnetUsesPhysicalResponseWithoutVisibleProfileSetup() + { + var root = SpringHingeAuthoring.CreateBashToy(); + try { + var hinge = root.GetComponent(); + var proxy = root.GetComponent(); + var magnet = root.GetComponentInChildren(); + magnet.MagnetType = MagnetType.Spatial; + magnet.ForceProfile = MagnetForceProfile.VpxCompatible; + + Assert.That(SpringHingeAuthoring.Validate(hinge, proxy), Is.Empty); + var state = magnet.CreateState(); + Assert.That(state.CoupleToHinge, Is.True); + Assert.That(state.Profile, Is.EqualTo(MagnetForceProfile.Physical)); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void HoldPointFitAccountsForScaledVisualHierarchyAndBallRadius() + { + var root = new GameObject("Scaled Spring Hinge"); + var magnetObject = new GameObject("Owned Magnet"); + try { + root.transform.localScale = Vector3.one * 0.1f; + var hinge = root.AddComponent(); + var proxy = root.AddComponent(); + proxy.LocalCentre = Vector3.zero; + proxy.HalfExtents = Vector3.one * 100f; + + magnetObject.transform.SetParent(root.transform, false); + magnetObject.transform.localPosition = Vector3.right * Physics.ScaleToWorld(99f); + magnetObject.transform.localScale = Vector3.one * 0.7f; + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = Vector3.zero; + + Assert.That(SpringHingeAuthoring.TryGetHeldBallCentreGap( + hinge, proxy, magnet, out var initialGap), Is.True); + Assert.That(initialGap, Is.LessThan(-25f)); + Assert.That(SpringHingeAuthoring.TryGetFittedHeldBallCentreOffset( + hinge, proxy, magnet, out var fittedOffset), Is.True); + + magnet.HeldBallCentreOffset = fittedOffset; + Assert.That(fittedOffset.x, Is.EqualTo(25.1f).Within(0.01f)); + Assert.That(SpringHingeAuthoring.TryGetHeldBallCentreGap( + hinge, proxy, magnet, out var fittedGap), Is.True); + Assert.That(fittedGap, Is.EqualTo(0f).Within(0.001f)); + Assert.That(SpringHingeAuthoring.Validate(hinge, proxy), Is.Empty); + } finally { + Object.DestroyImmediate(root); + } + } + + [TestCase(false)] + [TestCase(true)] + public void HoldPointFitHandlesExteriorCorner(bool rotateProxy) + { + var root = new GameObject("Spring Hinge"); + var magnetObject = new GameObject("Owned Magnet"); + try { + var hinge = root.AddComponent(); + var proxy = root.AddComponent(); + proxy.LocalCentre = Vector3.zero; + proxy.LocalRotation = rotateProxy ? new Vector3(0f, 0f, 37f) : Vector3.zero; + proxy.HalfExtents = new Vector3(10f, 20f, 30f); + + magnetObject.transform.SetParent(root.transform, false); + var boxRotation = Quaternion.Euler(proxy.LocalRotation); + magnetObject.transform.localPosition = boxRotation + * new Vector3(15f, 25f, 35f) * Physics.ScaleInv; + var magnet = magnetObject.AddComponent(); + magnet.MagnetType = MagnetType.Spatial; + magnet.CoupleToParentHinge = true; + + Assert.That(SpringHingeAuthoring.TryGetFittedHeldBallCentreOffset( + hinge, proxy, magnet, out var fittedOffset), Is.True); + + magnet.HeldBallCentreOffset = fittedOffset; + Assert.That(SpringHingeAuthoring.TryGetHeldBallCentreGap( + hinge, proxy, magnet, out var fittedGap), Is.True); + Assert.That(fittedGap, Is.EqualTo(0f).Within(0.001f)); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void VisualBoundsFitMassAndProxyInVpxUnits() + { + var root = SpringHingeAuthoring.CreateBashToy(); + try { + var hinge = root.GetComponent(); + var proxy = root.GetComponent(); + hinge.CentreOfMass = Vector3.zero; + hinge.MassBoxHalfExtents = Vector3.one; + proxy.LocalCentre = Vector3.zero; + proxy.HalfExtents = Vector3.one; + + Assert.That(SpringHingeAuthoring.FitFromVisuals(hinge, proxy), Is.True); + + Assert.That(hinge.CentreOfMass.x, Is.EqualTo(0f).Within(0.01f)); + Assert.That(hinge.CentreOfMass.y, Is.EqualTo(-50f).Within(0.01f)); + Assert.That(hinge.CentreOfMass.z, Is.EqualTo(0f).Within(0.01f)); + Assert.That(hinge.MassBoxHalfExtents.x, Is.EqualTo(25f).Within(0.01f)); + Assert.That(hinge.MassBoxHalfExtents.y, Is.EqualTo(50f).Within(0.01f)); + Assert.That(hinge.MassBoxHalfExtents.z, Is.EqualTo(10f).Within(0.01f)); + Assert.That(proxy.LocalCentre, Is.EqualTo(hinge.CentreOfMass)); + Assert.That(proxy.HalfExtents, Is.EqualTo(hinge.MassBoxHalfExtents)); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void AddSetupMovesOnlySelectedVisualsAndPreservesWorldPose() + { + var parent = new GameObject("Assembly"); + var selected = GameObject.CreatePrimitive(PrimitiveType.Cube); + var bracket = GameObject.CreatePrimitive(PrimitiveType.Cube); + GameObject root = null; + try { + selected.name = "Moving Toy"; + selected.transform.SetParent(parent.transform, false); + selected.transform.SetPositionAndRotation(new Vector3(1f, 2f, 3f), + Quaternion.Euler(10f, 20f, 30f)); + bracket.name = "Fixed Bracket"; + bracket.transform.SetParent(parent.transform, false); + var worldPosition = selected.transform.position; + var worldRotation = selected.transform.rotation; + + root = SpringHingeAuthoring.AddSpringHinge( + new[] { selected.transform }, selected.transform); + + Assert.That(root, Is.SameAs(selected)); + Assert.That(root.transform.parent, Is.SameAs(parent.transform)); + Assert.That(selected.transform.position, Is.EqualTo(worldPosition)); + Assert.That(Quaternion.Angle(selected.transform.rotation, worldRotation), Is.LessThan(0.001f)); + Assert.That(selected.GetComponent().enabled, Is.False); + Assert.That(selected.GetComponent(), Is.Not.Null); + Assert.That(selected.GetComponent(), Is.Not.Null); + Assert.That(bracket.transform.parent, Is.SameAs(parent.transform)); + Assert.That(SpringHingeAuthoring.Validate(root.GetComponent(), + root.GetComponent()), Is.Empty); + } finally { + if (root) { + Object.DestroyImmediate(root); + } + Object.DestroyImmediate(bracket); + Object.DestroyImmediate(parent); + } + } + + [Test] + public void ColliderUsesHingeAxisAndCachesRestRotation() + { + var root = new GameObject("Spring Hinge"); + try { + var hinge = root.AddComponent(); + hinge.HingeAxis = Vector3.forward; + root.transform.localRotation = Quaternion.Euler(0f, 12f, 0f); + var proxy = root.AddComponent(); + proxy.CaptureInitialPose(); + + proxy.ApplyAngle(math.PI / 2f); + var expected = Quaternion.Euler(0f, 12f, 0f) + * Quaternion.AngleAxis(90f, Vector3.forward); + Assert.That(Quaternion.Angle(root.transform.localRotation, expected), + Is.LessThan(0.001f)); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void ColliderVisibilityRoundTripsThroughPackage() + { + var root = new GameObject("Spring Hinge"); + try { + root.AddComponent(); + var proxy = root.AddComponent(); + proxy.ShowColliderMesh = true; + + var bytes = proxy.Pack(); + proxy.ShowColliderMesh = false; + proxy.Unpack(bytes); + + Assert.That(proxy.ShowColliderMesh, Is.True); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void ValidationReportsUnsupportedAndDuplicateOwnedMagnets() + { + var root = SpringHingeAuthoring.CreateBashToy(); + var secondObject = new GameObject("Second Magnet"); + try { + secondObject.transform.SetParent(root.transform, false); + var second = secondObject.AddComponent(); + second.CoupleToParentHinge = true; + second.MagnetType = MagnetType.Playfield; + second.ForceProfile = MagnetForceProfile.VpxCompatible; + + var issues = SpringHingeAuthoring.Validate( + root.GetComponent(), + root.GetComponent()); + + Assert.That(issues.Any(issue => issue.Contains("Spatial")), Is.True); + Assert.That(issues.Any(issue => issue.Contains("Only one")), Is.True); + } finally { + Object.DestroyImmediate(root); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs.meta new file mode 100644 index 000000000..fa0f4ab0f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ce523ca697a344d9811dd66c74f4f684 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef index 0f2fb156b..1e3d55d15 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VisualPinball.Unity.Test.asmdef @@ -1,7 +1,8 @@ { "name": "VisualPinball.Unity.Test", - "references": [ - "UnityEngine.TestRunner", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", "Unity.Collections", "Unity.Entities", "Unity.Entities.Hybrid", diff --git a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs index 52d673487..af6fe7a8d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Common/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("VisualPinball.Unity.Test")] +[assembly: InternalsVisibleTo("VisualPinball.Unity.Editor")] diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/KinematicVelocityState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/KinematicVelocityState.cs index 50165997f..15bc6e60a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/KinematicVelocityState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/KinematicVelocityState.cs @@ -77,6 +77,22 @@ internal struct KinematicVelocityState /// internal ulong LastUpdateUsec; + /// + /// Simulation-clock time at which the simulation thread consumed the latest + /// transform sample. This is deliberately separate from + /// , whose Unity sample clock can drift from the + /// independently paced simulation clock. + /// + internal ulong LastAppliedUsec; + + /// + /// Linear and angular speeds used to pace collider pose catch-up. These are + /// kept separate from the actual step velocities so the catch-up factor is + /// applied once instead of feeding back and compounding every tick. + /// + internal float PaceSpeed; + internal float PaceAngularSpeed; + /// /// Instantaneous velocity of the actual pose step this tick (same unit /// as ), written by diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index 1cbca0bdd..da59eb003 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -32,10 +32,12 @@ public struct PhysicsCycle : IDisposable private static readonly ProfilerMarker PerfMarkerDisplacement = new("Displacement"); private static readonly ProfilerMarker PerfMarkerCollision = new("Collision"); private static readonly ProfilerMarker PerfMarkerContacts = new("Contacts"); + internal int DynamicBroadPhaseRefitCount { get; private set; } public PhysicsCycle(Allocator a) { _contacts = new NativeList(a); + DynamicBroadPhaseRefitCount = 0; } internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, float dTime) @@ -50,8 +52,11 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov while (dTime > 0) { var hitTime = dTime; // begin time search from now ... until delta ends + var mechanismStopTime = -1f; + var springHingeHitTime = -1f; - ApplyFlipperTime(ref hitTime, ref state); + ApplyFlipperTime(ref hitTime, ref mechanismStopTime, ref state); + ApplySpringHingeTime(ref hitTime, ref mechanismStopTime, ref state); // clear contacts _contacts.Clear(); @@ -73,6 +78,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders); PhysicsStaticNarrowPhase.FindNextCollision(ref state.KinematicColliders, ref ball, ref overlappingColliders, ref _contacts, ref state); + RecordSpringHingeHitTime(ref springHingeHitTime, in ball, ref state); // no negative time allowed if (ball.CollisionEvent.HitTime < 0) { @@ -86,6 +92,8 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov ApplyStaticTime(ref hitTime, ref staticCounts, in ball); } } + ClampToMechanismStop(ref hitTime, mechanismStopTime); + ClampToSpringHingeHit(ref hitTime, springHingeHitTime); #region Displacement PerfMarkerDisplacement.Begin(); @@ -129,6 +137,13 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov hitTime, ref state.EventQueue); } } + // spring hinges + using (var enumerator = state.SpringHingeStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var hingeState = ref enumerator.Current.Value; + SpringHingeDisplacementPhysics.UpdateDisplacement(ref hingeState, hitTime); + } + } PerfMarkerDisplacement.End(); #endregion @@ -170,12 +185,15 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov using (var enumerator = state.Balls.GetEnumerator()) { while (enumerator.MoveNext()) { - ref var ball = ref enumerator.Current.Value; - BallSpinHackPhysics.Update(ref ball); + ApplyBallSpinCorrection(ref enumerator.Current.Value); } } dTime -= hitTime; + if (PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref ballOctree, + ref state.Balls, dTime)) { + DynamicBroadPhaseRefitCount++; + } state.SwapBallCollisionHandling = !state.SwapBallCollisionHandling; } @@ -183,6 +201,18 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov PerfMarker.End(); } + internal void ResetDynamicBroadPhaseRefitCount() + { + DynamicBroadPhaseRefitCount = 0; + } + + internal static void ApplyBallSpinCorrection(ref BallState ball) + { + if (ball.AttachedMagnetId == 0) { + BallSpinHackPhysics.Update(ref ball); + } + } + private void PrepareContacts(ref PhysicsState state) { for (var i = 0; i < _contacts.Length; i++) { @@ -276,7 +306,7 @@ private static float3 GetContactNormalInPlayfield(in ContactBufferElement contac return math.normalizesafe(normal); } - private static void ApplyStaticTime(ref float hitTime, ref float staticCounts, in BallState ball) + internal static void ApplyStaticTime(ref float hitTime, ref float staticCounts, in BallState ball) { // for each collision event var collEvent = ball.CollisionEvent; @@ -291,7 +321,32 @@ private static void ApplyStaticTime(ref float hitTime, ref float staticCounts, i } } - private void ApplyFlipperTime(ref float hitTime, ref PhysicsState state) + internal static void RecordSpringHingeHitTime(ref float springHingeHitTime, + in BallState ball, ref PhysicsState state) + { + var collEvent = ball.CollisionEvent; + if (!collEvent.HasCollider() || collEvent.HitTime <= 0f) { + return; + } + ref var colliders = ref (collEvent.IsKinematic + ? ref state.KinematicColliders + : ref state.Colliders); + if (colliders.GetHeader(collEvent.ColliderId).Type != ColliderType.SpringHinge) { + return; + } + if (springHingeHitTime <= 0f || collEvent.HitTime < springHingeHitTime) { + springHingeHitTime = collEvent.HitTime; + } + } + + internal static void ClampToSpringHingeHit(ref float hitTime, float springHingeHitTime) + { + if (springHingeHitTime > 0f && hitTime > springHingeHitTime) { + hitTime = springHingeHitTime; + } + } + + private void ApplyFlipperTime(ref float hitTime, ref float mechanismStopTime, ref PhysicsState state) { // for each flipper using (var enumerator = state.FlipperStates.GetEnumerator()) { @@ -302,11 +357,33 @@ private void ApplyFlipperTime(ref float hitTime, ref PhysicsState state) // if flipper comes to a rest before the end of the cycle, advance to that time if (flipperHitTime > 0 && flipperHitTime < hitTime) { //!! >= 0.f causes infinite loop hitTime = flipperHitTime; + mechanismStopTime = flipperHitTime; + } + } + } + } + + private static void ApplySpringHingeTime(ref float hitTime, ref float mechanismStopTime, + ref PhysicsState state) + { + using (var enumerator = state.SpringHingeStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + var hingeHitTime = SpringHingeDisplacementPhysics.GetStopTime(enumerator.Current.Value); + if (hingeHitTime > 0f && hingeHitTime < hitTime) { + hitTime = hingeHitTime; + mechanismStopTime = hingeHitTime; } } } } + internal static void ClampToMechanismStop(ref float hitTime, float mechanismStopTime) + { + if (mechanismStopTime > 0f && hitTime > mechanismStopTime) { + hitTime = mechanismStopTime; + } + } + public void Dispose() { _contacts.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs index 814aea43a..e9a3fcaae 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs @@ -16,6 +16,7 @@ using NativeTrees; using Unity.Collections; +using Unity.Mathematics; using Unity.Profiling; using VisualPinball.Unity.Collections; @@ -23,6 +24,8 @@ namespace VisualPinball.Unity { public static class PhysicsDynamicBroadPhase { + private const float MotionBoundsMargin = 0.05f; + private const float ContainmentTolerance = 1e-5f; private static readonly ProfilerMarker PerfMarkerBallOctree = new("CreateBallOctree"); private static readonly ProfilerMarker PerfMarkerDynamicBroadPhase = new("DynamicBroadPhase"); @@ -33,11 +36,51 @@ internal static void RebuildOctree(ref NativeOctree octree, ref NativeParal using var enumerator = balls.GetEnumerator(); while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; - octree.Insert(ball.Id, ball.Aabb); + ball.DynamicBroadPhaseAabb = ball.Aabb; + octree.Insert(ball.Id, ball.DynamicBroadPhaseAabb); } PerfMarkerBallOctree.End(); } + internal static bool RebuildIfMotionEscapes(ref NativeOctree octree, + ref NativeParallelHashMap balls, float remainingTime) + { + if (remainingTime <= 0f || !RequiresRebuild(ref balls, remainingTime)) { + return false; + } + RebuildOctree(ref octree, ref balls); + return true; + } + + internal static bool RequiresRebuild(ref NativeParallelHashMap balls, + float remainingTime) + { + using var enumerator = balls.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var ball = ref enumerator.Current.Value; + if (!IsRemainingMotionContained(in ball, remainingTime)) { + return true; + } + } + return false; + } + + internal static bool IsRemainingMotionContained(in BallState ball, float remainingTime) + { + var end = ball.Position + ball.Velocity * math.max(0f, remainingTime); + var margin = ball.Radius + MotionBoundsMargin; + var min = math.min(ball.Position, end) - margin; + var max = math.max(ball.Position, end) + margin; + var inserted = ball.DynamicBroadPhaseAabb; + return math.all(math.isfinite(min)) && math.all(math.isfinite(max)) + && min.x >= inserted.Left - ContainmentTolerance + && max.x <= inserted.Right + ContainmentTolerance + && min.y >= inserted.Top - ContainmentTolerance + && max.y <= inserted.Bottom + ContainmentTolerance + && min.z >= inserted.ZLow - ContainmentTolerance + && max.z <= inserted.ZHigh + ContainmentTolerance; + } + internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingBalls, ref NativeParallelHashMap balls) { PerfMarkerDynamicBroadPhase.Begin(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 4ce1ea7cf..29487defe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -723,6 +723,11 @@ internal ref SpinnerState SpinnerState(int itemId) GuardLiveStateAccess(nameof(SpinnerState)); return ref _ctx.SpinnerStates.Ref.GetValueByRef(itemId); } + internal ref SpringHingeState SpringHingeState(int itemId) + { + GuardLiveStateAccess(nameof(SpringHingeState)); + return ref _ctx.SpringHingeStates.Ref.GetValueByRef(itemId); + } internal ref SurfaceState SurfaceState(int itemId) { GuardLiveStateAccess(nameof(SurfaceState)); @@ -777,6 +782,7 @@ internal void Register(T item) where T : MonoBehaviour _ctx.PlungerStates.Ref[itemId] = c.CreateState(); break; case SpinnerComponent c: _ctx.SpinnerStates.Ref[itemId] = c.CreateState(); break; + case SpringHingeComponent c: _ctx.SpringHingeStates.Ref[itemId] = c.CreateState(); break; case SurfaceComponent c: _ctx.SurfaceStates.Ref[itemId] = c.CreateState(); break; case TurntableComponent c: _ctx.TurntableStates.Ref[itemId] = c.CreateState(); break; case TriggerComponent c: _ctx.TriggerStates.Ref[itemId] = c.CreateState(); break; @@ -800,11 +806,33 @@ internal BallComponent UnregisterBall(int ballId) var b = _ctx.BallComponents[ballId]; _ctx.BallComponents.Remove(ballId); _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromKickers(ballId, _ctx.KickerStates.Ref); ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); return b; } + /// + /// Clears the active capture reference of every kicker that still points at + /// a ball being removed. A stale reference makes the kicker reject all later + /// captures because HasBall only checks for a non-zero ID. + /// + internal static void ReleaseDestroyedBallFromKickers(int ballId, + NativeParallelHashMap kickerStates) + { + if (!kickerStates.IsCreated) { + return; + } + + using var enumerator = kickerStates.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var kicker = ref enumerator.Current.Value; + if (kicker.Collision.BallId == ballId) { + kicker.Collision.BallId = 0; + } + } + } + /// /// Clears a destroyed ball from all magnet grab states and emits the release /// events. Magnet grab bitfields are keyed by InsideOfs bit indices, which get @@ -823,6 +851,10 @@ private void ReleaseDestroyedBallFromMagnets(int ballId) magnet.GrabbedBalls.SetBits(bitIndex, false); _ctx.EventQueue.Ref.Enqueue(new EventData(Engine.Game.EventId.MagnetEventsBallReleased, enumerator.Current.Key, ballId, true)); } + if (magnet.AttachedBallId == ballId) { + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + } magnet.ReleasedBalls.SetBits(bitIndex, false); } } @@ -859,6 +891,7 @@ internal BallComponent UnregisterRuntimeBall(int ballId) if (_ctx.BallStates.Ref.IsCreated) { _ctx.BallStates.Ref.Remove(ballId); } + ReleaseDestroyedBallFromKickers(ballId, _ctx.KickerStates.Ref); ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); } @@ -866,6 +899,7 @@ internal BallComponent UnregisterRuntimeBall(int ballId) } _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromKickers(ballId, _ctx.KickerStates.Ref); ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); @@ -888,6 +922,44 @@ internal void DisableCollider(int itemId) public bool TryGetBall(int itemId, out BallComponent ballComponent) => _ctx.BallComponents.TryGetValue(itemId, out ballComponent); + /// + /// Gets the currently captured ball while holding the physics lock in + /// external-timing mode. Invalid stale references are repaired in place. + /// + internal bool TryGetKickerBallId(int itemId, out int ballId) + { + if (_ctx.UseExternalTiming) { + lock (_ctx.PhysicsLock) { + return TryGetKickerBallIdUnsafe(itemId, out ballId); + } + } + + return TryGetKickerBallIdUnsafe(itemId, out ballId); + } + + private bool TryGetKickerBallIdUnsafe(int itemId, out int ballId) + { + ballId = 0; + if (!_ctx.KickerStates.Ref.IsCreated || + !_ctx.KickerStates.Ref.TryGetValue(itemId, out var kickerState)) { + return false; + } + + ballId = kickerState.Collision.BallId; + if (ballId == 0) { + return false; + } + + if (_ctx.BallStates.Ref.IsCreated && _ctx.BallStates.Ref.ContainsKey(ballId)) { + return true; + } + + ref var liveKickerState = ref _ctx.KickerStates.Ref.GetValueByRef(itemId); + liveKickerState.Collision.BallId = 0; + ballId = 0; + return false; + } + /// /// Returns the current velocity of a kinematic item, derived from its /// transform updates. Values are in VPX playfield space and per second diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index d54784ae0..fe229afdf 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -113,6 +113,7 @@ internal class PhysicsEngineContext : IDisposable public readonly LazyInit> MagnetStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> PlungerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SpinnerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); + public readonly LazyInit> SpringHingeStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> SurfaceStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> TurntableStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); public readonly LazyInit> TriggerStates = new(() => new NativeParallelHashMap(0, Allocator.Persistent)); @@ -300,6 +301,7 @@ internal PhysicsState CreateState() ref NonTransformableColliderTransforms.Ref, ref KinematicColliderLookups, ref events, ref InsideOfs, ref BallStates.Ref, ref BumperStates.Ref, ref DropTargetStates.Ref, ref FlipperStates.Ref, ref GateStates.Ref, ref HitTargetStates.Ref, ref KickerStates.Ref, ref MagnetStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref, + ref SpringHingeStates.Ref, ref SurfaceStates.Ref, ref TurntableStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling, ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref); } @@ -342,6 +344,7 @@ public void Dispose() PlungerStates.Ref.Dispose(); SpinnerStates.Ref.Dispose(); + SpringHingeStates.Ref.Dispose(); SurfaceStates.Ref.Dispose(); TurntableStates.Ref.Dispose(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index ecf245dc5..b1c09a9d4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -97,7 +97,9 @@ private struct HeldKinematicPose private readonly int[] _snapshotPlungerIds; private readonly int[] _snapshotSpinnerIds; private readonly int[] _snapshotTriggerIds; + private readonly int[] _snapshotSpringHingeIds; private bool _ballSnapshotOverflowWarningIssued; + private bool _ownedSnapshotSuppressionWarningIssued; private bool _floatSnapshotOverflowWarningIssued; private bool _float2SnapshotOverflowWarningIssued; @@ -127,9 +129,35 @@ internal PhysicsEngineThreading(PhysicsEngine physicsEngine, PhysicsEngineContex _snapshotPlungerIds = SnapshotIds(_ctx.PlungerStates.Ref); _snapshotSpinnerIds = SnapshotIds(_ctx.SpinnerStates.Ref); _snapshotTriggerIds = SnapshotIds(_ctx.TriggerStates.Ref, static state => state.AnimatedItemId != 0); + _snapshotSpringHingeIds = SnapshotIds(_ctx.SpringHingeStates.Ref); + ValidateOwnedSnapshotCapacity(_snapshotSpringHingeIds.Length, + _ctx.BallStates.Ref.Count(), FloatAnimationSourceCount()); _worldToPlayfield = worldToPlayfield; } + private int FloatAnimationSourceCount() + => _snapshotFlipperIds.Length + _snapshotBumperRingIds.Length + + _snapshotDropTargetIds.Length + _snapshotHitTargetIds.Length + + _snapshotGateIds.Length + _snapshotPlungerIds.Length + + _snapshotSpinnerIds.Length + _snapshotTriggerIds.Length + + _snapshotSpringHingeIds.Length; + + internal static void ValidateOwnedSnapshotCapacity(int springHingeCount, + int ballCount, int floatAnimationCount) + { + if (springHingeCount == 0) { + return; + } + if (ballCount > SimulationState.MaxBalls + || floatAnimationCount > SimulationState.MaxFloatAnimations) { + throw new InvalidOperationException( + $"Spring-hinge snapshots require coherent ball and owner output; configured sources ({ballCount} balls, {floatAnimationCount} float animations) exceed capacities ({SimulationState.MaxBalls}, {SimulationState.MaxFloatAnimations})."); + } + } + + internal static bool ShouldSuppressOwnedSnapshot(int springHingeCount, int ballCount) + => springHingeCount > 0 && ballCount > SimulationState.MaxBalls; + private static int[] SnapshotIds(global::Unity.Collections.NativeParallelHashMap map, Func predicate = null) where TState : unmanaged { @@ -322,7 +350,7 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec) if (_ctx.PendingKinematicStops.Count > 0) { foreach (var sample in _ctx.PendingKinematicStops) { - StopKinematicVelocity(sample.ItemId, sample.SampleTimeUsec); + StopKinematicVelocity(sample.ItemId, sample.SampleTimeUsec, currentTimeUsec); } _ctx.PendingKinematicStops.Clear(); } @@ -344,7 +372,7 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec) /// private void StageKinematicTarget(int itemId, in float4x4 matrix, ulong sampleTimeUsec, ulong holdTimeUsec) { - var isIsolated = DeriveKinematicVelocity(itemId, in matrix, sampleTimeUsec, out var prevMatrix); + var isIsolated = DeriveKinematicVelocity(itemId, in matrix, sampleTimeUsec, holdTimeUsec, out var prevMatrix); var wasHeld = _heldIsolatedPoses.Remove(itemId); // a follow-up resolves any hold if (isIsolated && !wasHeld) { @@ -421,7 +449,8 @@ private void ProcessHeldKinematicPoses(ulong nowUsec) /// target (the true motion timeline as staged), not the /// possibly-lagging stepped pose. /// - private bool DeriveKinematicVelocity(int itemId, in float4x4 currMatrix, ulong sampleTimeUsec, out float4x4 prevMatrix) + private bool DeriveKinematicVelocity(int itemId, in float4x4 currMatrix, ulong sampleTimeUsec, + ulong simulationTimeUsec, out float4x4 prevMatrix) { if (_heldIsolatedPoses.TryGetValue(itemId, out var held)) { prevMatrix = held.Pose; @@ -429,7 +458,10 @@ private bool DeriveKinematicVelocity(int itemId, in float4x4 currMatrix, ulong s prevMatrix = _ctx.KinematicTransforms.Ref[itemId]; } if (_ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var prevVelocity)) { - _ctx.KinematicVelocities.Ref[itemId] = PhysicsKinematics.DeriveVelocity(in prevVelocity, in prevMatrix, in currMatrix, sampleTimeUsec, out var isIsolated); + var velocity = PhysicsKinematics.DeriveVelocity(in prevVelocity, in prevMatrix, in currMatrix, + sampleTimeUsec, out var isIsolated); + velocity.LastAppliedUsec = simulationTimeUsec; + _ctx.KinematicVelocities.Ref[itemId] = velocity; return isIsolated; } @@ -437,6 +469,7 @@ private bool DeriveKinematicVelocity(int itemId, in float4x4 currMatrix, ulong s _ctx.KinematicVelocities.Ref[itemId] = new KinematicVelocityState { Pivot = currMatrix.c3.xyz, LastUpdateUsec = sampleTimeUsec, + LastAppliedUsec = simulationTimeUsec, }; return true; } @@ -446,27 +479,23 @@ private bool DeriveKinematicVelocity(int itemId, in float4x4 currMatrix, ulong s /// its pivot so a later update derives from a valid baseline. /// /// - /// The derived velocity is handed over to the step velocities instead of - /// just being cleared: the pose may still be catching up to its target, - /// and while it does, the collider really is still moving — the step - /// velocities keep the catch-up classified as continuous (no teleport - /// snap of the remaining gap) and carry its pace. This also covers the - /// case where the final transform update and the stop are drained - /// together, before ever - /// ran for that target — the step velocities would otherwise still be - /// zero. From here on, StepKinematics re-derives them from the actual - /// step each tick and zeroes them the tick the pose settles, before any - /// hit test runs; if the pose is already settled, the seeded values are - /// cleared the same way on the next tick. + /// The measured speeds remain as the catch-up pace while the derived surface + /// velocity is cleared. This covers a final transform and stop drained in the + /// same tick, before has moved + /// toward that target. StepKinematics records each actual step as surface + /// velocity and clears the pace when the pose settles. /// - private void StopKinematicVelocity(int itemId, ulong sampleTimeUsec) + private void StopKinematicVelocity(int itemId, ulong sampleTimeUsec, ulong simulationTimeUsec) { if (_ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var velocity)) { - velocity.StepVelocity = velocity.LinearVelocity; - velocity.StepAngularVelocity = velocity.AngularVelocity; + velocity.PaceSpeed = math.max(velocity.PaceSpeed, math.length(velocity.LinearVelocity)); + velocity.PaceAngularSpeed = math.max(velocity.PaceAngularSpeed, math.length(velocity.AngularVelocity)); + velocity.StepVelocity = float3.zero; + velocity.StepAngularVelocity = float3.zero; velocity.LinearVelocity = float3.zero; velocity.AngularVelocity = float3.zero; velocity.LastUpdateUsec = sampleTimeUsec; + velocity.LastAppliedUsec = simulationTimeUsec; _ctx.KinematicVelocities.Ref[itemId] = velocity; } } @@ -512,17 +541,23 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) ballCount++; } } - snapshot.BallCount = ballCount; + var suppressOwnedSnapshot = ShouldSuppressOwnedSnapshot( + _snapshotSpringHingeIds.Length, ballSourceCount); + snapshot.BallCount = suppressOwnedSnapshot ? 0 : ballCount; snapshot.BallSourceCount = ballSourceCount; snapshot.BallSnapshotsTruncated = ballSourceCount > SimulationState.MaxBalls ? (byte)1 : (byte)0; if (!_ballSnapshotOverflowWarningIssued && snapshot.BallSnapshotsTruncated != 0) { _ballSnapshotOverflowWarningIssued = true; Logger.Warn($"[PhysicsEngine] Ball snapshot capacity exceeded: {ballSourceCount} balls for max {SimulationState.MaxBalls}. Snapshot output is truncated."); } + if (!_ownedSnapshotSuppressionWarningIssued && suppressOwnedSnapshot) { + _ownedSnapshotSuppressionWarningIssued = true; + Logger.Warn("[PhysicsEngine] Ball and spring-hinge snapshot output is suppressed until the ball count returns within capacity, preserving one coherent published time."); + } // --- Float animations --- var floatCount = 0; - snapshot.FloatAnimationSourceCount = _snapshotFlipperIds.Length + _snapshotBumperRingIds.Length + _snapshotDropTargetIds.Length + _snapshotHitTargetIds.Length + _snapshotGateIds.Length + _snapshotPlungerIds.Length + _snapshotSpinnerIds.Length + _snapshotTriggerIds.Length; + snapshot.FloatAnimationSourceCount = FloatAnimationSourceCount(); // Flippers for (var i = 0; i < _snapshotFlipperIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) { @@ -596,6 +631,15 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot) }; } + // Spring hinges + for (var i = 0; !suppressOwnedSnapshot && i < _snapshotSpringHingeIds.Length && floatCount < SimulationState.MaxFloatAnimations; i++) { + var itemId = _snapshotSpringHingeIds[i]; + ref var s = ref _ctx.SpringHingeStates.Ref.GetValueByRef(itemId); + snapshot.FloatAnimations[floatCount++] = new SimulationState.FloatAnimation { + ItemId = itemId, Value = s.Movement.Angle + }; + } + snapshot.FloatAnimationCount = floatCount; snapshot.FloatAnimationsTruncated = snapshot.FloatAnimationSourceCount > SimulationState.MaxFloatAnimations ? (byte)1 : (byte)0; if (!_floatSnapshotOverflowWarningIssued && snapshot.FloatAnimationsTruncated != 0) { @@ -858,7 +902,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) if (lastTransformationMatrix.Equals(currTransformationMatrix)) { // unchanged — if it moved last frame, it just stopped, so zero its velocity if (_movedKinematicItems.Remove(item.ItemId)) { - StopKinematicVelocity(item.ItemId, currentTimeUsec); + StopKinematicVelocity(item.ItemId, currentTimeUsec, currentTimeUsec); } continue; } @@ -930,6 +974,7 @@ private void ApplyAllMovements(ref PhysicsState state) _physicsMovements.ApplyPlungerMovement(ref _ctx.PlungerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplySpinnerMovement(ref _ctx.SpinnerStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTriggerMovement(ref _ctx.TriggerStates.Ref, _ctx.FloatAnimatedComponents); + _physicsMovements.ApplySpringHingeMovement(ref _ctx.SpringHingeStates.Ref, _ctx.FloatAnimatedComponents); _physicsMovements.ApplyTurntableMovement(ref _ctx.TurntableStates.Ref, _ctx.Float2AnimatedComponents); _physicsEngine.ApplyVisualNudge(_ctx.PhysicsEnv.Nudge.CabinetOffset); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs index df22e4878..4728bb773 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs @@ -99,7 +99,7 @@ public static class PhysicsKinematics /// and re-transforms its colliders. Called once per tick; a no-op for items /// that have reached their target. /// - internal static void StepKinematics(ref PhysicsState state) + internal static void StepKinematics(ref PhysicsState state, ulong currentTimeUsec) { PerfMarkerTransform.Begin(); using var enumerator = state.KinematicTargetTransforms.GetEnumerator(); @@ -109,45 +109,51 @@ internal static void StepKinematics(ref PhysicsState state) ref var current = ref state.KinematicTransforms.GetValueByRef(itemId); var hasVelocity = state.KinematicVelocities.TryGetValue(itemId, out var velocity); + if (hasVelocity && ExpireStaleDerivedVelocity(ref velocity, currentTimeUsec)) { + state.KinematicVelocities[itemId] = velocity; + } if (current.Equals(target)) { - // pose settled: clear the step-velocity fallback - if (hasVelocity && (math.lengthsq(velocity.StepVelocity) > 0f || math.lengthsq(velocity.StepAngularVelocity) > 0f)) { + // pose settled: clear the step-velocity fallback and catch-up pace + if (hasVelocity && (math.lengthsq(velocity.StepVelocity) > 0f + || math.lengthsq(velocity.StepAngularVelocity) > 0f + || velocity.PaceSpeed > 0f + || velocity.PaceAngularSpeed > 0f)) { velocity.StepVelocity = float3.zero; velocity.StepAngularVelocity = float3.zero; + velocity.PaceSpeed = 0f; + velocity.PaceAngularSpeed = 0f; state.KinematicVelocities[itemId] = velocity; } continue; } - // pace the step at the item's own speed (per tick), so touching balls - // feel the true surface velocity; StepVelocity carries the pace across - // the final catch-up after the derived velocity was zeroed by a stop + // Pace from the measured speed, independent of the previous tick's actual + // step. Feeding the accelerated step back here compounds CatchUpFactor. var maxLinearStep = MaxLinearStepPerTick; var maxAngularStep = MaxAngularStepPerTick; + var hasLinearPace = false; + var hasAngularPace = false; if (hasVelocity) { - var paceLin = math.max(math.length(velocity.LinearVelocity), math.length(velocity.StepVelocity)) - * CatchUpFactor * PhysicsConstants.PhysFactor; - var paceAng = math.max(math.length(velocity.AngularVelocity), math.length(velocity.StepAngularVelocity)) - * CatchUpFactor * PhysicsConstants.PhysFactor; - if (paceLin > 1e-6f) { + var paceLin = velocity.PaceSpeed * CatchUpFactor * PhysicsConstants.PhysFactor; + var paceAng = velocity.PaceAngularSpeed * CatchUpFactor * PhysicsConstants.PhysFactor; + hasLinearPace = paceLin > 0f; + hasAngularPace = paceAng > 0f; + if (hasLinearPace) { maxLinearStep = math.min(paceLin, MaxLinearStepPerTick); } - if (paceAng > 1e-8f) { + if (hasAngularPace) { maxAngularStep = math.min(paceAng, MaxAngularStepPerTick); } } - // active step motion also counts as continuous: after a stop event, the - // derived velocity is already zeroed while the pose may still be - // catching up — the remaining gap must keep streaming (paced by - // StepVelocity), not get mistaken for an isolated warp and snapped - // through balls by the teleport branch of StepTowards - var continuous = hasVelocity && (velocity.IsMoving - || math.lengthsq(velocity.StepVelocity) > 0f - || math.lengthsq(velocity.StepAngularVelocity) > 0f); + // A measured pace keeps the remaining gap continuous after a stop has + // zeroed the derived surface velocity. Without a pace, the target is a + // teleport and must not sweep through balls. + var continuous = hasVelocity && (hasLinearPace || hasAngularPace); var before = current; - current = StepTowards(in current, in target, continuous, maxLinearStep, maxAngularStep, out var jumped); + current = StepTowards(in current, in target, continuous, hasLinearPace, hasAngularPace, + maxLinearStep, maxAngularStep, out var jumped); if (hasVelocity) { if (jumped) { @@ -168,8 +174,8 @@ internal static void StepKinematics(ref PhysicsState state) if (qd.value.w < 0f) { qd.value = -qd.value; } - var stepAngle = 2f * math.acos(math.clamp(qd.value.w, -1f, 1f)); var stepAxisLenSq = math.lengthsq(qd.value.xyz); + var stepAngle = 2f * math.atan2(math.sqrt(stepAxisLenSq), math.clamp(qd.value.w, -1f, 1f)); velocity.StepAngularVelocity = stepAngle > 1e-6f && stepAxisLenSq > 1e-12f ? qd.value.xyz * math.rsqrt(stepAxisLenSq) * (stepAngle / PhysicsConstants.PhysFactor) : float3.zero; @@ -186,19 +192,44 @@ internal static void StepKinematics(ref PhysicsState state) PerfMarkerTransform.End(); } + /// + /// Stops using a transform sample's derived velocity when the producer has + /// not refreshed it within the normal low-cadence update window. + /// + /// + /// In threaded mode the Unity main thread both samples transforms and + /// reports that an item stopped. If that thread stalls, the simulation + /// thread keeps running while the collider is already sitting at its last + /// target pose. Leaving the last derived velocity active in that state makes + /// a stationary collider behave like a moving surface and can push a ball or + /// make the narrow phase classify a falling ball as receding from its support. + /// + /// Step velocities remain intact because they describe pose movement that + /// actually happened on the simulation thread while catching up. + /// + private static bool ExpireStaleDerivedVelocity(ref KinematicVelocityState velocity, ulong currentTimeUsec) + { + if (currentTimeUsec < velocity.LastAppliedUsec || + currentTimeUsec - velocity.LastAppliedUsec < KinematicVelocityTimeoutUsec || + !velocity.IsMoving) { + return false; + } + + velocity.LinearVelocity = float3.zero; + velocity.AngularVelocity = float3.zero; + return true; + } + /// /// Returns the pose one tick-step closer to the target. Deltas within /// / - /// are applied as a single jump (the classic behavior, gentle embedded - /// carry); larger ones step at the given paced limits. An isolated - /// delta beyond the teleport thresholds snaps to the target directly (a warp - /// shouldn't sweep through balls); during continuous motion, large deltas - /// keep stepping, so fast drags stream instead of teleporting. Scale is - /// taken from the target (scale animation is unsupported, see - /// ). + /// are applied as a single jump. Larger continuous deltas step at the given + /// paced limits. A delta without a measured pace is an isolated warp and + /// snaps directly so it cannot sweep through balls. Scale is taken from the + /// target (scale animation is unsupported, see ). /// private static float4x4 StepTowards(in float4x4 current, in float4x4 target, bool continuous, - float maxLinearStep, float maxAngularStep, out bool jumped) + bool hasLinearPace, bool hasAngularPace, float maxLinearStep, float maxAngularStep, out bool jumped) { var pC = current.c3.xyz; var pT = target.c3.xyz; @@ -216,8 +247,12 @@ private static float4x4 StepTowards(in float4x4 current, in float4x4 target, boo return target; } - // an isolated teleport-sized warp snaps without imparting anything - if (!continuous && (dist > TeleportDistance || angle > TeleportAngle)) { + // Without a measured pace this is a teleport, not motion to sweep through + // balls. The same applies when only one motion axis has a pace and the + // other axis has more than a jump-sized unexplained gap. + if (!continuous + || (!hasLinearPace && dist > MaxLinearJumpPerTick) + || (!hasAngularPace && angle > MaxAngularJumpPerTick)) { jumped = true; return target; } @@ -275,6 +310,8 @@ internal static KinematicVelocityState DeriveVelocity(in KinematicVelocityState AngularVelocity = prev.AngularVelocity, Pivot = pivot, LastUpdateUsec = prev.LastUpdateUsec, + PaceSpeed = prev.PaceSpeed, + PaceAngularSpeed = prev.PaceAngularSpeed, }; } @@ -299,7 +336,8 @@ internal static KinematicVelocityState DeriveVelocity(in KinematicVelocityState if (qd.value.w < 0f) { // nearest-neighbor: q and -q are the same rotation qd.value = -qd.value; } - var angle = 2f * math.acos(math.clamp(qd.value.w, -1f, 1f)); + var axisLenSq = math.lengthsq(qd.value.xyz); + var angle = 2f * math.atan2(math.sqrt(axisLenSq), math.clamp(qd.value.w, -1f, 1f)); // teleport guard: a jump this large in a single update imparts no velocity if (math.lengthsq(deltaPos) > TeleportDistance * TeleportDistance || angle > TeleportAngle) { @@ -308,7 +346,6 @@ internal static KinematicVelocityState DeriveVelocity(in KinematicVelocityState } var angVel = float3.zero; - var axisLenSq = math.lengthsq(qd.value.xyz); if (angle > 1e-6f && axisLenSq > 1e-12f) { angVel = qd.value.xyz * math.rsqrt(axisLenSq) * (angle / dt); } @@ -318,6 +355,8 @@ internal static KinematicVelocityState DeriveVelocity(in KinematicVelocityState AngularVelocity = angVel, Pivot = pivot, LastUpdateUsec = sampleTimeUsec, + PaceSpeed = math.length(deltaPos / dt), + PaceAngularSpeed = math.length(angVel), }; } @@ -342,6 +381,14 @@ internal static KinematicVelocityState DeriveVelocity(in KinematicVelocityState /// internal const ulong IsolatedHoldTimeoutUsec = 120_000; + /// + /// Maximum age of a derived transform velocity. This still supports 10 Hz + /// transform producers, while bounding false surface motion during a Unity + /// main-thread stall to less than the time needed for a supported ball to + /// cross a typical collider thickness. + /// + internal const ulong KinematicVelocityTimeoutUsec = 120_000; + /// /// Returns whether an isolated update's delta is small enough to apply /// immediately (snap) instead of being held for disambiguation. diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs index 01d94a963..2bcc6d5a9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsMovements.cs @@ -143,6 +143,17 @@ internal void ApplyTriggerMovement(ref NativeParallelHashMap } } + internal void ApplySpringHingeMovement( + ref NativeParallelHashMap springHingeStates, + Dictionary> floatAnimatedComponent) + { + using var enumerator = springHingeStates.GetEnumerator(); + while (enumerator.MoveNext()) { + var component = floatAnimatedComponent[enumerator.Current.Key]; + component.UpdateAnimationValue(enumerator.Current.Value.Movement.Angle); + } + } + internal void ApplyTurntableMovement(ref NativeParallelHashMap turntableStates, Dictionary> float2AnimatedComponent) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 43776bb74..a5d2edc04 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -139,9 +139,10 @@ internal struct PhysicsState internal NativeParallelHashMap HitTargetStates; internal NativeParallelHashMap KickerStates; internal NativeParallelHashMap MagnetStates; - internal NativeParallelHashMap PlungerStates; - internal NativeParallelHashMap SpinnerStates; - internal NativeParallelHashMap SurfaceStates; + internal NativeParallelHashMap PlungerStates; + internal NativeParallelHashMap SpinnerStates; + internal NativeParallelHashMap SpringHingeStates; + internal NativeParallelHashMap SurfaceStates; internal NativeParallelHashMap TurntableStates; internal NativeParallelHashMap TriggerStates; internal NativeParallelHashSet DisabledCollisionItems; @@ -159,9 +160,10 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native ref NativeParallelHashMap bumperStates, ref NativeParallelHashMap dropTargetStates, ref NativeParallelHashMap flipperStates, ref NativeParallelHashMap gateStates, ref NativeParallelHashMap hitTargetStates, ref NativeParallelHashMap kickerStates, - ref NativeParallelHashMap magnetStates, - ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, - ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, + ref NativeParallelHashMap magnetStates, + ref NativeParallelHashMap plungerStates, ref NativeParallelHashMap spinnerStates, + ref NativeParallelHashMap springHingeStates, + ref NativeParallelHashMap surfaceStates, ref NativeParallelHashMap turntableStates, ref NativeParallelHashMap triggerStates, ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling, ref NativeParallelHashMap> elasticityOverVelocityLUTs, @@ -187,9 +189,10 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native HitTargetStates = hitTargetStates; KickerStates = kickerStates; MagnetStates = magnetStates; - PlungerStates = plungerStates; - SpinnerStates = spinnerStates; - SurfaceStates = surfaceStates; + PlungerStates = plungerStates; + SpinnerStates = spinnerStates; + SpringHingeStates = springHingeStates; + SurfaceStates = surfaceStates; TurntableStates = turntableStates; TriggerStates = triggerStates; DisabledCollisionItems = disabledCollisionItems; @@ -224,9 +227,12 @@ internal void DisableColliders(int itemId) { internal ref PlungerState GetPlungerState(int colliderId, ref NativeColliders colliders) => ref PlungerStates.GetValueByRef(colliders.GetItemId(colliderId)); - internal ref SpinnerState GetSpinnerState(int colliderId, ref NativeColliders colliders) => ref SpinnerStates.GetValueByRef(colliders.GetItemId(colliderId)); - - internal ref TriggerState GetTriggerState(int colliderId, ref NativeColliders colliders) => ref TriggerStates.GetValueByRef(colliders.GetItemId(colliderId)); + internal ref SpinnerState GetSpinnerState(int colliderId, ref NativeColliders colliders) => ref SpinnerStates.GetValueByRef(colliders.GetItemId(colliderId)); + + internal ref SpringHingeState GetSpringHingeState(int colliderId, ref NativeColliders colliders) + => ref SpringHingeStates.GetValueByRef(colliders.GetItemId(colliderId)); + + internal ref TriggerState GetTriggerState(int colliderId, ref NativeColliders colliders) => ref TriggerStates.GetValueByRef(colliders.GetItemId(colliderId)); internal ref KickerState GetKickerState(int colliderId, ref NativeColliders colliders) => ref KickerStates.GetValueByRef(colliders.GetItemId(colliderId)); @@ -443,11 +449,16 @@ internal float HitTest(ref NativeColliders colliders, int colliderId, ref BallSt return colliders.Circle(colliderId).HitTestBasicRadius(ref newCollEvent, ref InsideOfs, in ball, ball.CollisionEvent.HitTime, false, false, false); - case ColliderType.Flipper: + case ColliderType.Flipper: ref var flipperState = ref GetFlipperState(colliderId, ref colliders); ref var flipperCollider = ref colliders.Flipper(colliderId); - return flipperCollider.HitTest(ref newCollEvent, ref InsideOfs, ref flipperState.Hit, - in flipperState.Movement, in flipperState.Tricks, in flipperState.Static, in ball, ball.CollisionEvent.HitTime); + return flipperCollider.HitTest(ref newCollEvent, ref InsideOfs, ref flipperState.Hit, + in flipperState.Movement, in flipperState.Tricks, in flipperState.Static, in ball, ball.CollisionEvent.HitTime); + + case ColliderType.SpringHinge: + ref var springHingeState = ref GetSpringHingeState(colliderId, ref colliders); + return colliders.SpringHinge(colliderId).HitTest(ref newCollEvent, + in springHingeState, in ball, ball.CollisionEvent.HitTime); case ColliderType.Plunger: ref var plungerState = ref GetPlungerState(colliderId, ref colliders); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 375b9fe34..eece81766 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -105,20 +105,28 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r pointCollider.Collide(ref ball, ref state.EventQueue, in ball.CollisionEvent, ref state); break; - case ColliderType.Bumper: - ref var bumperState = ref state.GetBumperState(colliderId, ref colliders); + case ColliderType.Bumper: + ReleaseBeforeUnsupportedInteraction(ref ball, in collHeader, ref state); + ref var bumperState = ref state.GetBumperState(colliderId, ref colliders); BumperCollider.Collide(ref ball, ref state.EventQueue, ref ball.CollisionEvent, ref state, in collHeader, in bumperState.Static, ref state.InsideOfs, bumperState.IsSwitchWiredToCoil); break; - case ColliderType.Flipper: - ref var flipperState = ref state.GetFlipperState(colliderId, ref colliders); + case ColliderType.Flipper: + ReleaseBeforeUnsupportedInteraction(ref ball, in collHeader, ref state); + ref var flipperState = ref state.GetFlipperState(colliderId, ref colliders); ref var flipperCollider = ref colliders.Flipper(colliderId); flipperCollider.Collide(ref ball, ref ball.CollisionEvent, ref flipperState.Movement, ref state.EventQueue, in ball.Id, in flipperState.Tricks, in flipperState.Static, in flipperState.Velocity, in flipperState.Hit, state.Env.TimeMsec ); - break; + break; + + case ColliderType.SpringHinge: + ref var springHingeState = ref state.GetSpringHingeState(colliderId, ref colliders); + ref var springHingeCollider = ref colliders.SpringHinge(colliderId); + springHingeCollider.Collide(ref ball, ref springHingeState, in ball.CollisionEvent, ref state); + break; case ColliderType.Gate: ref var gateState = ref state.GetGateState(colliderId, ref colliders); @@ -126,29 +134,33 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r in collHeader, in gateState.Static); break; - case ColliderType.LineSlingShot: - ref var surfaceState = ref state.GetSurfaceState(colliderId, ref colliders); + case ColliderType.LineSlingShot: + ReleaseBeforeUnsupportedInteraction(ref ball, in collHeader, ref state); + ref var surfaceState = ref state.GetSurfaceState(colliderId, ref colliders); ref var surfaceCollider = ref colliders.LineSlingShot(colliderId); surfaceCollider.Collide(ref ball, ref state.EventQueue, in surfaceState.Slingshot, in ball.CollisionEvent, ref state); break; - case ColliderType.Plunger: - ref var plungerState = ref state.GetPlungerState(colliderId, ref colliders); + case ColliderType.Plunger: + ReleaseBeforeUnsupportedInteraction(ref ball, in collHeader, ref state); + 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.Spinner: ref var spinnerState = ref state.GetSpinnerState(colliderId, ref colliders); - SpinnerCollider.Collide(in ball, ref ball.CollisionEvent, ref spinnerState.Movement, in spinnerState.Static); + ref var spinnerCollider = ref colliders.Spinner(colliderId); + spinnerCollider.Collide(in ball, ref ball.CollisionEvent, ref spinnerState.Movement, in spinnerState.Static); break; case ColliderType.TriggerCircle: TriggerCollide(ref ball, ref state, in collHeader, ref colliders); break; - case ColliderType.KickerCircle: { - ref var kickerState = ref state.GetKickerState(colliderId, ref colliders); + case ColliderType.KickerCircle: { + ReleaseBeforeUnsupportedInteraction(ref ball, in collHeader, ref state); + ref var kickerState = ref state.GetKickerState(colliderId, ref colliders); ref var circleCollider = ref colliders.Circle(colliderId); KickerCollider.Collide(new float3(circleCollider.Center, circleCollider.ZLow), ref ball, ref state.EventQueue, ref state.InsideOfs, ref kickerState.Collision, @@ -162,9 +174,21 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r // remove trial hit object pointer ball.CollisionEvent.ClearCollider(); - } - - private static bool CollidesWithItem(ref NativeColliders colliders, ref ColliderHeader collHeader, ref BallState ball, ref PhysicsState state) + } + + private static void ReleaseBeforeUnsupportedInteraction(ref BallState ball, + in ColliderHeader collider, ref PhysicsState state) + { + MagnetPhysics.ReleaseOwnedAttachmentForUnsupportedInteraction(ref state, + ref ball, collider.ItemId); + } + + internal static bool IsUnsupportedActiveCollider(ColliderType colliderType) + => colliderType is ColliderType.Bumper or ColliderType.Flipper + or ColliderType.LineSlingShot or ColliderType.Plunger + or ColliderType.KickerCircle; + + private static bool CollidesWithItem(ref NativeColliders colliders, ref ColliderHeader collHeader, ref BallState ball, ref PhysicsState state) { // hit target var colliderId = ball.CollisionEvent.ColliderId; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index cd4e96723..7093a377a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -16,9 +16,10 @@ using System; using NativeTrees; using Unity.Burst; -using Unity.Collections; -using Unity.Collections.LowLevel.Unsafe; -using VisualPinball.Engine.Common; +using Unity.Collections; +using Unity.Collections.LowLevel.Unsafe; +using Unity.Mathematics; +using VisualPinball.Engine.Common; // ReSharper disable InconsistentNaming @@ -85,8 +86,9 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ // ref var env = ref UnsafeUtility.AsRef(envPtr.ToPointer()); // ref var overlappingColliders = ref UnsafeUtility.AsRef>(overlappingCollidersPtr.ToPointer()); - var subSteps = 0; - while (env.CurPhysicsFrameTime < initialTimeUsec) // loop here until current (real) time matches the physics (simulated) time + var subSteps = 0; + cycle.ResetDynamicBroadPhaseRefitCount(); + while (env.CurPhysicsFrameTime < initialTimeUsec) // loop here until current (real) time matches the physics (simulated) time { // Safety cap: if we've been catching up for too many iterations (e.g. after // a frame hitch), skip physics time forward to prevent cascading hitches. @@ -99,7 +101,7 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ // Step kinematic collider poses toward their target transforms, capped // per tick so fast movers can't skip past a ball. No-op when idle. - PhysicsKinematics.StepKinematics(ref state); + PhysicsKinematics.StepKinematics(ref state, env.CurPhysicsFrameTime); env.TimeMsec = (uint)((env.CurPhysicsFrameTime - env.StartTimeUsec) / 1000); var physicsDiffTime = (float)((env.NextPhysicsFrameTime - env.CurPhysicsFrameTime) * (1.0 / PhysicsConstants.DefaultStepTime)); @@ -147,13 +149,9 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); } } - // magnets - using (var enumerator = state.MagnetStates.GetEnumerator()) { - while (enumerator.MoveNext()) { - ref var magnetState = ref enumerator.Current.Value; - MagnetPhysics.Update(enumerator.Current.Key, ref magnetState, ref state, physicsDiffTime); - } - } + // spring hinges + UpdateSpringHingeVelocities(ref state, in env.Gravity, in cabinetAcceleration, physicsDiffTime); + UpdateMagnetsAndCommitSpringHinges(ref state, physicsDiffTime); // turntables using (var enumerator = state.TurntableStates.GetEnumerator()) { while (enumerator.MoveNext()) { @@ -183,6 +181,51 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } } + internal static void UpdateSpringHingeVelocities(ref PhysicsState state, in float3 gravity, + in float2 cabinetAcceleration, float step) + { + var effectiveGravity = gravity; + effectiveGravity.xy -= PhysicsConstants.Ms2ToVpuVpt2 * cabinetAcceleration; + using var enumerator = state.SpringHingeStates.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var hingeState = ref enumerator.Current.Value; + SpringHingeVelocityPhysics.PrepareVelocity(ref hingeState, in effectiveGravity, step); + } + } + + internal static void UpdateMagnetsAndCommitSpringHinges(ref PhysicsState state, float step) + { + // Advance every coil exactly once, then preserve legacy magnet behavior before + // the reciprocal owned pass arbitrates captures and commits hinge velocities. + using (var enumerator = state.MagnetStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + MagnetPhysics.AdvanceCoil(ref enumerator.Current.Value, step); + } + } + using (var enumerator = state.MagnetStates.GetEnumerator()) { + while (enumerator.MoveNext()) { + ref var magnetState = ref enumerator.Current.Value; + if (!magnetState.CoupleToHinge) { + MagnetPhysics.UpdateAfterCoil(enumerator.Current.Key, ref magnetState, + ref state, step); + } + } + } + OwnedMagnetPhysics.Update(ref state, step); + CommitSpringHingeVelocities(ref state); + } + + private static void CommitSpringHingeVelocities(ref PhysicsState state) + { + using var enumerator = state.SpringHingeStates.GetEnumerator(); + while (enumerator.MoveNext()) { + ref var hingeState = ref enumerator.Current.Value; + if (!hingeState.Movement.VelocityCommitted) { + SpringHingeVelocityPhysics.CommitFreeVelocity(ref hingeState); + } + } + } + /// /// Advances mechanical animations after physics substeps have completed. /// diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs index 64c74998d..5ab889842 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/Player.cs @@ -474,6 +474,10 @@ public void OnEvent(in EventData eventData) _magnets[eventData.ItemId].OnMagnetBallReleased(eventData.BallId); break; + case EventId.PhysicsDiagnosticsUnsupportedOwnedInteraction: + Logger.Warn($"Owned magnet {eventData.ItemId} released ball {eventData.BallId} before unsupported active interaction with item {eventData.IntParam}."); + break; + default: throw new InvalidOperationException($"Unknown event {eventData.EventId} for entity {eventData.ItemId}"); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs index 58409f1fd..5b1e49ad4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/Collider.cs @@ -77,8 +77,10 @@ public unsafe ColliderBounds Bounds() { return ((PlaneCollider*) collider)->Bounds; case ColliderType.Plunger: return ((PlungerCollider*) collider)->Bounds; - case ColliderType.Spinner: - return ((SpinnerCollider*) collider)->Bounds; + case ColliderType.Spinner: + return ((SpinnerCollider*) collider)->Bounds; + case ColliderType.SpringHinge: + return ((SpringHingeCollider*) 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..d719ba180 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 SpringHingeColliders; 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); + SpringHingeColliders = new NativeList(allocator); TriangleColliders = new NativeList(allocator); PlaneColliders = new NativeList(allocator); @@ -85,6 +87,7 @@ public void Dispose() PlungerColliders.Dispose(); PointColliders.Dispose(); SpinnerColliders.Dispose(); + SpringHingeColliders.Dispose(); TriangleColliders.Dispose(); PlaneColliders.Dispose(); using (var enumerator = _itemIdToColliderIds.GetEnumerator()) { @@ -169,6 +172,12 @@ public void TransformToIdentity(ref NativeParallelHashMap itemIdT spinnerCollider.TransformAabb(math.inverse(matrix)); break; + case ColliderType.SpringHinge: + #if UNITY_EDITOR + throw new InvalidOperationException("Spring-hinge colliders cannot be kinematic."); + #endif + break; + case ColliderType.Gate: ref var gateCollider = ref GateColliders.GetElementAsRef(lookup.Index); #if UNITY_EDITOR @@ -248,6 +257,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.SpringHinge: return SpringHingeColliders.GetElementAsRef(lookup.Index); case ColliderType.Triangle: return TriangleColliders.GetElementAsRef(lookup.Index); case ColliderType.Plane: return PlaneColliders.GetElementAsRef(lookup.Index); } @@ -494,6 +504,16 @@ internal int Add(TriangleCollider collider, float4x4 matrix) PlaneColliders.Add(collider); } + internal int Add(SpringHingeCollider collider) + { + collider.Header.IsTransformed = true; + collider.Id = Lookups.Length; + TrackReference(collider.Header.ItemId, collider.Header.Id); + Lookups.Add(new ColliderLookup(ColliderType.SpringHinge, SpringHingeColliders.Length)); + SpringHingeColliders.Add(collider); + return collider.Id; + } + #endregion // ReSharper disable once UnusedMember.Global @@ -533,6 +553,9 @@ public ICollider[] ToArray() case ColliderType.Spinner: array[i] = SpinnerColliders[lookup.Index]; break; + case ColliderType.SpringHinge: + array[i] = SpringHingeColliders[lookup.Index]; + break; case ColliderType.Triangle: array[i] = TriangleColliders[lookup.Index]; break; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs index 6e02c768a..ef1cd9b87 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ColliderType.cs @@ -31,8 +31,9 @@ public enum ColliderType Plane, Plunger, Point, - Spinner, - Triangle, - TriggerCircle, + Spinner, + Triangle, + TriggerCircle, + SpringHinge, } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index 1024ea69b..0c8b64738 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -74,7 +74,11 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball frictionAcceleration = matrixInv.MultiplyVector(frictionAcceleration); } - ref var collHeader = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId); + ref var collHeader = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId); + if (PhysicsStaticCollision.IsUnsupportedActiveCollider(collHeader.Type)) { + MagnetPhysics.ReleaseOwnedAttachmentForUnsupportedInteraction(ref state, + ref ball, collHeader.ItemId); + } if (collHeader.Type == ColliderType.Flipper) { ref var flipperCollider = ref colliders.Flipper(collEvent.ColliderId); ref var flipperState = ref state.GetFlipperState(collEvent.ColliderId, ref colliders); @@ -82,6 +86,12 @@ internal static void Update(ref ContactBufferElement contact, ref BallState ball flipperCollider.Contact(ref ball, ref flipperState.Movement, in collEvent, in flipperState.Static, in flipperState.Velocity, hitTime, in acceleration, in frictionAcceleration, in frictionVelocity, in frictionAngularMomentum); + } else if (collHeader.Type == ColliderType.SpringHinge) { + ref var hingeCollider = ref colliders.SpringHinge(collEvent.ColliderId); + ref var hingeState = ref state.GetSpringHingeState(collEvent.ColliderId, ref colliders); + var acceleration = gravity + ball.ExternalAcceleration; + hingeCollider.Contact(ref ball, ref hingeState, in collEvent, hitTime, in acceleration, + in frictionAcceleration, in frictionVelocity, in frictionAngularMomentum); } else { // surface velocity of the collider at the contact point (zero unless kinematic and moving) var colliderVelocity = state.GetKinematicSurfaceVelocity(in collEvent, ball.Position - ball.Radius * collEvent.HitNormal); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Event/EventData.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Event/EventData.cs index 12dcdaf58..bb841933d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Event/EventData.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Event/EventData.cs @@ -26,9 +26,10 @@ public readonly struct EventData { public readonly EventId EventId; public readonly int ItemId; - public readonly int BallId; - public readonly float FloatParam; - public readonly bool GroupEvent; + public readonly int BallId; + public readonly float FloatParam; + public readonly int IntParam; + public readonly bool GroupEvent; public EventData(EventId eventId, int itemId, int ballId, bool groupEvent = false) : this() { @@ -38,14 +39,25 @@ public EventData(EventId eventId, int itemId, int ballId, bool groupEvent = fals GroupEvent = groupEvent; } - public EventData(EventId eventId, int itemId, int ballId, float floatParam, bool groupEvent = false) : this() + public EventData(EventId eventId, int itemId, int ballId, float floatParam, bool groupEvent = false) : this() { EventId = eventId; ItemId = itemId; BallId = ballId; FloatParam = floatParam; - GroupEvent = groupEvent; - } + GroupEvent = groupEvent; + } + + public EventData(EventId eventId, int itemId, int ballId, float floatParam, int intParam, + bool groupEvent = false) : this() + { + EventId = eventId; + ItemId = itemId; + BallId = ballId; + FloatParam = floatParam; + IntParam = intParam; + GroupEvent = groupEvent; + } public EventData(EventId eventId, int itemId, bool groupEvent = false) : this() diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs index 85e969682..bd838c3f1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/NativeColliders.cs @@ -58,7 +58,8 @@ public unsafe struct NativeColliders : IDisposable [NativeDisableUnsafePtrRestriction] private void* m_LineZColliderBuffer; [NativeDisableUnsafePtrRestriction] private void* m_PlungerColliderBuffer; [NativeDisableUnsafePtrRestriction] private void* m_PointColliderBuffer; - [NativeDisableUnsafePtrRestriction] private void* m_SpinnerColliderBuffer; + [NativeDisableUnsafePtrRestriction] private void* m_SpinnerColliderBuffer; + [NativeDisableUnsafePtrRestriction] private void* m_SpringHingeColliderBuffer; [NativeDisableUnsafePtrRestriction] private void* m_TriangleColliderBuffer; [NativeDisableUnsafePtrRestriction] private void* m_PlaneColliderBuffer; @@ -133,9 +134,13 @@ public NativeColliders(ref ColliderReference colRef, Allocator allocator) m_PointColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator); UnsafeUtility.MemCpy(m_PointColliderBuffer, colRef.PointColliders.GetUnsafePtr(), size); - size = UnsafeUtility.SizeOf() * colRef.SpinnerColliders.Length; - m_SpinnerColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator); - UnsafeUtility.MemCpy(m_SpinnerColliderBuffer, colRef.SpinnerColliders.GetUnsafePtr(), size); + size = UnsafeUtility.SizeOf() * colRef.SpinnerColliders.Length; + m_SpinnerColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator); + UnsafeUtility.MemCpy(m_SpinnerColliderBuffer, colRef.SpinnerColliders.GetUnsafePtr(), size); + + size = UnsafeUtility.SizeOf() * colRef.SpringHingeColliders.Length; + m_SpringHingeColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator); + UnsafeUtility.MemCpy(m_SpringHingeColliderBuffer, colRef.SpringHingeColliders.GetUnsafePtr(), size); size = UnsafeUtility.SizeOf() * colRef.TriangleColliders.Length; m_TriangleColliderBuffer = UnsafeUtility.Malloc(size, UnsafeUtility.AlignOf(), allocator); @@ -282,7 +287,7 @@ internal ref SpinnerCollider Spinner(int colliderId) return ref UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index); } - internal ref TriangleCollider Triangle(int colliderId) + internal ref TriangleCollider Triangle(int colliderId) { ref var lookup = ref UnsafeUtility.ArrayElementAsRef(m_LookupBuffer, colliderId); #if ENABLE_UNITY_COLLECTIONS_CHECKS @@ -291,7 +296,18 @@ internal ref TriangleCollider Triangle(int colliderId) } #endif return ref UnsafeUtility.ArrayElementAsRef(m_TriangleColliderBuffer, lookup.Index); - } + } + + internal ref SpringHingeCollider SpringHinge(int colliderId) + { + ref var lookup = ref UnsafeUtility.ArrayElementAsRef(m_LookupBuffer, colliderId); +#if ENABLE_UNITY_COLLECTIONS_CHECKS + if (lookup.Type != ColliderType.SpringHinge) { + throw new ArgumentException($"Invalid collider type {lookup.Type} when looking up spring-hinge collider {colliderId}."); + } +#endif + return ref UnsafeUtility.ArrayElementAsRef(m_SpringHingeColliderBuffer, lookup.Index); + } #endregion @@ -328,7 +344,8 @@ public ICollider this[int index] case ColliderType.LineZ: return UnsafeUtility.ReadArrayElement(m_LineZColliderBuffer, lookup.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.Spinner: return UnsafeUtility.ReadArrayElement(m_SpinnerColliderBuffer, lookup.Index); + case ColliderType.SpringHinge: return UnsafeUtility.ReadArrayElement(m_SpringHingeColliderBuffer, lookup.Index); case ColliderType.Triangle: return UnsafeUtility.ReadArrayElement(m_TriangleColliderBuffer, lookup.Index); case ColliderType.Plane: return UnsafeUtility.ReadArrayElement(m_PlaneColliderBuffer, lookup.Index); } @@ -386,9 +403,12 @@ public ICollider this[int index] case ColliderType.Point: UnsafeUtility.WriteArrayElement(m_PointColliderBuffer, lookup.Index, (PointCollider)value); break; - case ColliderType.Spinner: - UnsafeUtility.WriteArrayElement(m_SpinnerColliderBuffer, lookup.Index, (SpinnerCollider)value); - break; + case ColliderType.Spinner: + UnsafeUtility.WriteArrayElement(m_SpinnerColliderBuffer, lookup.Index, (SpinnerCollider)value); + break; + case ColliderType.SpringHinge: + UnsafeUtility.WriteArrayElement(m_SpringHingeColliderBuffer, lookup.Index, (SpringHingeCollider)value); + break; case ColliderType.Triangle: UnsafeUtility.WriteArrayElement(m_TriangleColliderBuffer, lookup.Index, (TriangleCollider)value); break; @@ -418,7 +438,8 @@ public void Dispose() UnsafeUtility.Free(m_LineZColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_PlungerColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_PointColliderBuffer, m_AllocatorLabel); - UnsafeUtility.Free(m_SpinnerColliderBuffer, m_AllocatorLabel); + UnsafeUtility.Free(m_SpinnerColliderBuffer, m_AllocatorLabel); + UnsafeUtility.Free(m_SpringHingeColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_TriangleColliderBuffer, m_AllocatorLabel); UnsafeUtility.Free(m_PlaneColliderBuffer, m_AllocatorLabel); @@ -432,7 +453,8 @@ public void Dispose() m_LineZColliderBuffer = null; m_PlungerColliderBuffer = null; m_PointColliderBuffer = null; - m_SpinnerColliderBuffer = null; + m_SpinnerColliderBuffer = null; + m_SpringHingeColliderBuffer = null; m_TriangleColliderBuffer = null; m_PlaneColliderBuffer = null; m_Length = 0; @@ -473,7 +495,8 @@ public Aabb GetAabb(int index) case ColliderType.LineZ: return UnsafeUtility.ArrayElementAsRef(m_LineZColliderBuffer, lookup.Index).Bounds.Aabb; 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.Spinner: return UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index).Bounds.Aabb; + case ColliderType.SpringHinge: return UnsafeUtility.ArrayElementAsRef(m_SpringHingeColliderBuffer, 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: @@ -525,10 +548,12 @@ public Aabb GetTransformedAabb(int index, ref NativeParallelHashMap(m_PointColliderBuffer, lookup.Index); return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); } - case ColliderType.Spinner: { + case ColliderType.Spinner: { var collider = UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index); - return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); - } + return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); + } + case ColliderType.SpringHinge: + return UnsafeUtility.ArrayElementAsRef(m_SpringHingeColliderBuffer, lookup.Index).Bounds.Aabb; case ColliderType.Triangle: { var collider = UnsafeUtility.ArrayElementAsRef(m_TriangleColliderBuffer, lookup.Index); return collider.GetTransformedAabb(kinematicTransforms[collider.Header.ItemId]); @@ -564,7 +589,8 @@ public ref ColliderHeader GetHeader(int index) case ColliderType.LineZ: return ref UnsafeUtility.ArrayElementAsRef(m_LineZColliderBuffer, lookup.Index).Header; 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.Spinner: return ref UnsafeUtility.ArrayElementAsRef(m_SpinnerColliderBuffer, lookup.Index).Header; + case ColliderType.SpringHinge: return ref UnsafeUtility.ArrayElementAsRef(m_SpringHingeColliderBuffer, 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; } @@ -611,9 +637,12 @@ public ICollider[] ToArray() case ColliderType.Point: array[i] = UnsafeUtility.ReadArrayElement(m_PointColliderBuffer, lookup.Index); break; - case ColliderType.Spinner: - array[i] = UnsafeUtility.ReadArrayElement(m_SpinnerColliderBuffer, lookup.Index); - break; + case ColliderType.Spinner: + array[i] = UnsafeUtility.ReadArrayElement(m_SpinnerColliderBuffer, lookup.Index); + break; + case ColliderType.SpringHinge: + array[i] = UnsafeUtility.ReadArrayElement(m_SpringHingeColliderBuffer, lookup.Index); + break; case ColliderType.Triangle: array[i] = UnsafeUtility.ReadArrayElement(m_TriangleColliderBuffer, lookup.Index); break; @@ -651,4 +680,4 @@ public NativeCollidersDebugView(NativeColliders nativeColliders) } public ICollider[] Colliders => _nativeColliders.ToArray(); } -} \ No newline at end of file +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs index 7cc198695..9fdc4b7c0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs @@ -20,9 +20,10 @@ namespace VisualPinball.Unity { - internal static class BallCollider - { - private const float HardScatter = 0.0f; + internal static class BallCollider + { + private const float HardScatter = 0.0f; + private const float StaticContactPenetrationTolerance = 0.5f; public static void Collide3DWall(ref BallState ball, in PhysicsMaterialData material, in CollisionEventData collEvent, in float3 hitNormal, ref PhysicsState state) { @@ -157,9 +158,21 @@ internal static void HandleStaticContact(ref BallState ball, in CollisionEventDa // (relative to the surface, which may be moving if the collider is kinematic) var normVel = math.dot(ball.Velocity - colliderVelocity, collEvent.HitNormal); - // If some collision has changed the ball's velocity, we may not have to do anything. - if (normVel <= PhysicsConstants.ContactVel) { - + // If some collision has changed the ball's velocity, we may not have to do anything. + if (normVel <= PhysicsConstants.ContactVel) { + // Impacts correct penetration in Collide3DWall, but sustained contacts + // arrive through this path instead. A moving support can leave a ball + // slightly inside the next static mesh; without position recovery that + // error accumulates until a one-sided triangle rejects the ball as being + // behind it. Keep kinematic contacts unchanged so an intentionally moving + // support remains authoritative while it carries the ball. + if (collEvent.ColliderId >= 0 && !collEvent.IsKinematic && + collEvent.HitDistance < -StaticContactPenetrationTolerance) { + var correction = math.min(-PhysicsConstants.DispGain * collEvent.HitDistance, + PhysicsConstants.DispLimit); + ball.Position += collEvent.HitNormal * correction; + } + // Balance every continuous load already integrated this tick. Without the // additional acceleration, a magnet can press a resting ball through a wall // because the contact solver only compensates gravity. diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 26c6571b3..cdcd58668 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -57,8 +57,10 @@ public struct BallState public float3x3 BallOrientationForUnity; public float Radius; public float Mass; - public bool IsFrozen; - public int RingCounterOldPos; + public bool IsFrozen; + internal int AttachedMagnetId; + internal Aabb DynamicBroadPhaseAabb; + public int RingCounterOldPos; public bool ManualControl; public float2 ManualPosition; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs index c89513f41..0da4abb81 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs @@ -77,18 +77,31 @@ void IApi.OnDestroy() { } - public void CreateBall(GameObject ballPrefab = null, float radius = 25f, float mass = 1f) - { - var ballId = BallManager.CreateBall(MainComponent, radius, mass, ballPrefab); - - ref var ball = ref PhysicsEngine.BallState(ballId); - ref var kickerState = ref PhysicsEngine.KickerState(ItemId); - var events = PhysicsEngine.EventQueue; - ball.CollisionEvent.HitFlag = true; // HACK: avoid capture leaving kicker - - KickerCollider.Collide(new float3(kickerState.Static.Center, kickerState.Static.ZLow), ref ball, ref events, ref PhysicsEngine.InsideOfs, ref kickerState.Collision, - in kickerState.Static, in kickerState.CollisionMesh, in ball.CollisionEvent, ItemId, true); - } + public void CreateBall(GameObject ballPrefab = null, float radius = 25f, float mass = 1f) + { + var ballId = BallManager.CreateBall(MainComponent, radius, mass, ballPrefab); + var kickerId = ItemId; + + PhysicsEngine.MutateState((ref PhysicsState state) => { + if (!state.Balls.ContainsKey(ballId) || !state.KickerStates.ContainsKey(kickerId)) { + return; + } + + ref var ball = ref state.Balls.GetValueByRef(ballId); + ref var kickerState = ref state.KickerStates.GetValueByRef(kickerId); + if (kickerState.Collision.HasBall && + !state.Balls.ContainsKey(kickerState.Collision.BallId)) { + kickerState.Collision.BallId = 0; + } + + var events = state.EventQueue; + ball.CollisionEvent.HitFlag = true; // HACK: avoid capture leaving kicker + var collEvent = ball.CollisionEvent; + KickerCollider.Collide(new float3(kickerState.Static.Center, kickerState.Static.ZLow), + ref ball, ref events, ref state.InsideOfs, ref kickerState.Collision, + in kickerState.Static, in kickerState.CollisionMesh, in collEvent, kickerId, true); + }); + } public void CreateSizedBallWithMass(float radius, float mass) { @@ -107,23 +120,20 @@ public void Kick(float angle, float speed, float inclination = 0) /// /// If there is not ball in the kicker, this does nothing. /// - public void DestroyBall() - { - ref var kickerState = ref PhysicsEngine.KickerState(ItemId); - if (kickerState.Collision.HasBall) { - BallManager.DestroyBall(kickerState.Collision.BallId); - OnBallDestroyed(); - } + public void DestroyBall() + { + if (PhysicsEngine.TryGetKickerBallId(ItemId, out var ballId)) { + BallManager.DestroyBall(ballId); + } } /// /// Checks whether the kicker contains a ball. /// /// True if there is a ball in the kicker, false otherwise. - public bool HasBall() - { - ref var kickerState = ref PhysicsEngine.KickerState(ItemId); - return kickerState.Collision.HasBall; + public bool HasBall() + { + return PhysicsEngine.TryGetKickerBallId(ItemId, out _); } internal ref BallState GetBallData() @@ -132,12 +142,11 @@ internal ref BallState GetBallData() return ref PhysicsEngine.BallState(kickerState.Collision.BallId); } - internal int BallId { - get { - ref var kickerState = ref PhysicsEngine.KickerState(ItemId); - return kickerState.Collision.BallId; - } - } + internal int BallId { + get { + return PhysicsEngine.TryGetKickerBallId(ItemId, out var ballId) ? ballId : 0; + } + } #region Wiring @@ -159,15 +168,7 @@ private IApiCoil Coil(string deviceItem) throw new ArgumentException($"Unknown coil \"{deviceItem}\". Valid names are [ {string.Join(", ", _coils.Select(item => $"\"{item.Key}\""))} ]."); } - private void OnBallDestroyed() - { - ref var kickerState = ref PhysicsEngine.KickerState(ItemId); - if (kickerState.Collision.HasBall) { - kickerState.Collision.BallId = 0; - } - } - - #endregion + #endregion private void KickXYZ(float angle, float speed, float inclination, float x, float y, float z) { @@ -188,16 +189,20 @@ private void KickXYZ(float angle, float speed, float inclination, float x, float ); var rotQuaternion = new quaternion(rotMatrix); - PhysicsEngine.MutateState((ref PhysicsState state) => { + PhysicsEngine.MutateState((ref PhysicsState state) => { if (!state.KickerStates.ContainsKey(kickerId)) { return; } - ref var kickerState = ref state.KickerStates.GetValueByRef(kickerId); - var ballId = kickerState.Collision.BallId; - if (ballId == 0 || !state.Balls.ContainsKey(ballId)) { - return; - } + ref var kickerState = ref state.KickerStates.GetValueByRef(kickerId); + var ballId = kickerState.Collision.BallId; + if (ballId == 0) { + return; + } + if (!state.Balls.ContainsKey(ballId)) { + kickerState.Collision.BallId = 0; + return; + } var angleRad = math.radians(angle); // yaw angle, zero is along -Y axis diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 063785656..2b1839964 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -116,6 +116,24 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("If set, transforming this object during gameplay moves the magnetic field with it.")] public bool IsKinematic; + [Tooltip("Couple this Spatial magnet reciprocally to its nearest parent spring hinge.")] + public bool CoupleToParentHinge; + + [Tooltip("Held ball centre relative to the magnet transform, expressed in VPX units at the authored rest pose.")] + public Vector3 HeldBallCentreOffset; + + [Min(0f)] + [Tooltip("Translational stiffness of the owned ball hold, independent of influence distance.")] + public float HoldStiffness = 2f; + + [Min(0f)] + [Tooltip("Relative translational damping of the owned ball hold.")] + public float HoldDamping = 2f; + + [Min(0f)] + [Tooltip("Full-current force capacity of the owned ball hold.")] + public float MaxHoldForce = 10f; + [Tooltip("Draw play-mode force vectors and a green/red runtime coil-status gizmo.")] public bool DrawDebugForces; @@ -197,6 +215,9 @@ private void OnValidate() CylinderHeight = math.max(0f, CylinderHeight); CylindricalDamping = math.max(0f, CylindricalDamping); HitThreshold = math.max(0f, HitThreshold); + HoldStiffness = math.max(0f, HoldStiffness); + HoldDamping = math.max(0f, HoldDamping); + MaxHoldForce = math.max(0f, MaxHoldForce); SyncPhysicsState(); } @@ -205,6 +226,31 @@ internal MagnetState CreateState() var pos = GetPlayfieldPositionVpx(transform); var commandedPower = IsEnabledOnStart ? 1f : 0f; var usesPhysicalResponse = MagnetType != MagnetType.Playfield || ForceProfile == MagnetForceProfile.Physical; + var hinge = CoupleToParentHinge ? GetComponentInParent() : null; + var validOwnedMode = hinge && MagnetType == VisualPinball.Unity.MagnetType.Spatial; + if (CoupleToParentHinge && !validOwnedMode) { + Logger.Error($"Magnet {name} can couple only as a Spatial child of a spring hinge."); + } + if (validOwnedMode) { + var ownedMagnets = hinge.GetComponentsInChildren(true); + var ownedCount = 0; + for (var i = 0; i < ownedMagnets.Length; i++) { + if (ownedMagnets[i].CoupleToParentHinge + && ownedMagnets[i].MagnetType == VisualPinball.Unity.MagnetType.Spatial) { + ownedCount++; + } + } + if (ownedCount > 1) { + Logger.Error($"Spring hinge {hinge.name} has {ownedCount} owned magnets; only one reciprocal owner magnet is supported."); + } + } + var poleArm = float3.zero; + var heldCentreArm = float3.zero; + if (validOwnedMode) { + var pivot = hinge.ToPlayfieldVpx(hinge.transform.position); + poleArm = hinge.ToPlayfieldVpx(transform.position) - pivot; + heldCentreArm = hinge.ToPlayfieldVpx(GetHeldBallCentreWorldPosition()) - pivot; + } return new MagnetState { Position = pos.xy, Height = pos.z, @@ -226,9 +272,17 @@ internal MagnetState CreateState() IsEnabled = IsEnabledOnStart, IsKinematic = IsKinematic, // three-dimensional magnets dispatch on MagnetType and never read Profile - Profile = ForceProfile, + Profile = MagnetType == MagnetType.Playfield ? ForceProfile : MagnetForceProfile.Physical, HeightRange = HeightRange, MagnetType = MagnetType, + CoupleToHinge = validOwnedMode, + HingeOwnerId = validOwnedMode ? hinge.ItemId : 0, + LocalPoleArm = poleArm, + LocalHeldCentreArm = heldCentreArm, + HoldStiffness = HoldStiffness, + HoldDamping = HoldDamping, + MaxHoldForce = MaxHoldForce, + AttachedBallId = 0, GrabbedBalls = default, ReleasedBalls = default }; @@ -252,19 +306,34 @@ private void SyncPhysicsState() return; } ref var magnet = ref state.MagnetStates.GetValueByRef(itemId); + if (magnet.AttachedBallId != 0 && (!synced.CoupleToHinge + || !magnet.CoupleToHinge || synced.HingeOwnerId != magnet.HingeOwnerId)) { + MagnetPhysics.ReleaseGrabbedBalls(itemId, ref magnet, ref state, true); + } + if (synced.CoupleToHinge && magnet.CoupleToHinge + && synced.HingeOwnerId == magnet.HingeOwnerId) { + // These are baked geometry. Runtime visual rotation must not be sampled + // back into physics and then rotated by the hinge angle a second time. + synced.LocalPoleArm = magnet.LocalPoleArm; + synced.LocalHeldCentreArm = magnet.LocalHeldCentreArm; + } synced.IsEnabled = magnet.IsEnabled; synced.CommandedPower = magnet.CommandedPower; synced.EffectiveCurrent = magnet.EffectiveCurrent; synced.EffectiveStrength = magnet.EffectiveStrength; synced.GrabbedBalls = magnet.GrabbedBalls; synced.ReleasedBalls = magnet.ReleasedBalls; + synced.AttachedBallId = magnet.AttachedBallId; + synced.SaturationTicks = magnet.SaturationTicks; magnet = synced; }); } public int ItemId => UnityObjectId.Get(gameObject); - bool IKinematicTransformComponent.IsKinematic => IsKinematic; + bool IKinematicTransformComponent.IsKinematic => IsKinematic && !(CoupleToParentHinge + && MagnetType == VisualPinball.Unity.MagnetType.Spatial + && GetComponentInParent()); // The physics engine disables colliders by item ID when this returns false. // A magnet can share its GameObject (and therefore its item ID) with another @@ -360,6 +429,14 @@ internal static float3 GetPlayfieldPositionVpx(Transform transform) : (float3)transform.localPosition.TranslateToVpx(); } + /// + /// Returns the authored held-ball centre without applying transform scale. + /// Magnet dimensions are VPX distances, so render-hierarchy scale must not + /// change where the ball is held. + /// + public Vector3 GetHeldBallCentreWorldPosition() + => transform.position + transform.rotation * (HeldBallCentreOffset * Physics.ScaleInv); + private void OnDrawGizmos() { if (!Application.isPlaying || !DrawDebugForces) { @@ -417,6 +494,13 @@ private void OnDrawGizmosSelected() } } + if (CoupleToParentHinge) { + var heldCentre = GetHeldBallCentreWorldPosition(); + Gizmos.color = new Color(0.2f, 1f, 0.45f, 0.9f); + Gizmos.DrawLine(transform.position, heldCentre); + DrawVpxSphere(WorldToVpx(heldCentre), 25f); + } + if (MagnetType != VisualPinball.Unity.MagnetType.Cylindrical && (MagnetType == VisualPinball.Unity.MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical) && PoleRadius > 0f) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs index 8c3b75c27..24daa9407 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPackable.cs @@ -18,7 +18,7 @@ namespace VisualPinball.Unity { public struct MagnetPackable { - private const int CurrentVersion = 3; + private const int CurrentVersion = 4; public int Version; public float Radius; @@ -39,6 +39,11 @@ public struct MagnetPackable public bool IsKinematic; public bool DrawDebugForces; public float HitThreshold; + public bool CoupleToParentHinge; + public PackableFloat3 HeldBallCentreOffset; + public float HoldStiffness; + public float HoldDamping; + public float MaxHoldForce; public static byte[] Pack(MagnetComponent comp) { @@ -62,6 +67,11 @@ public static byte[] Pack(MagnetComponent comp) IsKinematic = comp.IsKinematic, DrawDebugForces = comp.DrawDebugForces, HitThreshold = comp.HitThreshold, + CoupleToParentHinge = comp.CoupleToParentHinge, + HeldBallCentreOffset = comp.HeldBallCentreOffset, + HoldStiffness = comp.HoldStiffness, + HoldDamping = comp.HoldDamping, + MaxHoldForce = comp.MaxHoldForce, }); } @@ -86,6 +96,13 @@ public static void Unpack(byte[] bytes, MagnetComponent comp) comp.IsKinematic = data.IsKinematic; comp.DrawDebugForces = data.DrawDebugForces; comp.HitThreshold = data.Version >= 3 ? data.HitThreshold : MagnetComponent.DefaultHitThreshold; + comp.CoupleToParentHinge = data.Version >= 4 && data.CoupleToParentHinge; + if (data.Version >= 4) { + comp.HeldBallCentreOffset = data.HeldBallCentreOffset; + comp.HoldStiffness = data.HoldStiffness; + comp.HoldDamping = data.HoldDamping; + comp.MaxHoldForce = data.MaxHoldForce; + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 86765b9b8..cb52c06a6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -34,6 +34,7 @@ internal static class MagnetPhysics // At full current, this converts the authored value to contact acceleration. private const float VpxStrengthScale = 1.5f / 56f; private const float PhysicalVelocityDamping = 0.02f; + private const float HeldBallSpinDamping = 0.5f; private const float MinEffectiveCurrent = 0.0001f; internal const float CylindricalContactTolerance = 1f; private const float CylindricalReleaseTolerance = 2f; @@ -42,6 +43,15 @@ internal static class MagnetPhysics internal static void Update(int itemId, ref MagnetState magnet, ref PhysicsState state, float physicsDiffTime) { AdvanceCoil(ref magnet, physicsDiffTime); + if (magnet.CoupleToHinge) { + return; + } + UpdateAfterCoil(itemId, ref magnet, ref state, physicsDiffTime); + } + + internal static void UpdateAfterCoil(int itemId, ref MagnetState magnet, ref PhysicsState state, + float physicsDiffTime) + { if (!HasActiveField(in magnet)) { ReleaseGrabbedBalls(itemId, ref magnet, ref state, false); if (!state.InsideOfs.IsEmpty(itemId)) { @@ -152,6 +162,7 @@ internal static void ApplyKinematicTransform(ref MagnetState magnet, in float4x4 internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref PhysicsState state, bool suppressRegrab) { + var attachedBallId = magnet.AttachedBallId; // the ball is a live physics object throughout the hold, so releasing it is // just dropping the hold force — it keeps whatever velocity it currently has if (magnet.GrabbedBalls.Value != 0UL) { @@ -168,6 +179,14 @@ internal static void ReleaseGrabbedBalls(int itemId, ref MagnetState magnet, ref } } } + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + if (attachedBallId != 0 && state.Balls.ContainsKey(attachedBallId)) { + ref var attachedBall = ref state.Balls.GetValueByRef(attachedBallId); + if (attachedBall.AttachedMagnetId == itemId) { + attachedBall.AttachedMagnetId = 0; + } + } if (!suppressRegrab) { magnet.ReleasedBalls = default; @@ -192,6 +211,9 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P if (state.InsideOfs.TryGetBallIdAtBitIndex(bitIndex, out var ballId)) { if (state.Balls.ContainsKey(ballId)) { ref var ball = ref state.Balls.GetValueByRef(ballId); + if (ball.AttachedMagnetId == itemId) { + ball.AttachedMagnetId = 0; + } if (magnet.MagnetType != MagnetType.Playfield) { ApplySpatialEject(ref ball, speed, angleDeg, verticalAngleDeg, carrierVelocity); } else { @@ -200,6 +222,10 @@ internal static void EjectGrabbedBalls(int itemId, ref MagnetState magnet, ref P } state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } + if (magnet.AttachedBallId == ballId) { + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + } magnet.GrabbedBalls.SetBits(bitIndex, false); magnet.ReleasedBalls.SetBits(bitIndex, true); } @@ -335,7 +361,7 @@ internal static void ApplySpatialPhysicalHold(ref BallState ball, in MagnetState var accelerationScale = ClampAccelerationScale(in acceleration, holdStrength); ball.Velocity += acceleration * accelerationScale * physicsDiffTime; - ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); + DampHeldBallSpin(ref ball, physicsDiffTime); RecordExternalAcceleration(ref ball, springAcceleration * accelerationScale); } @@ -364,10 +390,15 @@ internal static void ApplyPhysicalHold(ref BallState ball, in MagnetState magnet velocity += acceleration * accelerationScale * physicsDiffTime; ball.Velocity = new float3(velocity.x, velocity.y, ball.Velocity.z); - ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * 0.5f); + DampHeldBallSpin(ref ball, physicsDiffTime); RecordExternalAcceleration(ref ball, new float3(springAcceleration * accelerationScale, 0f)); } + internal static void DampHeldBallSpin(ref BallState ball, float physicsDiffTime) + { + ball.AngularMomentum *= 1f - math.saturate(physicsDiffTime * HeldBallSpinDamping); + } + private static float ClampAccelerationScale(in float3 acceleration, float maxAcceleration) { var accelerationLengthSq = math.lengthsq(acceleration); @@ -462,6 +493,10 @@ private static bool IsGrabbedBall(in MagnetState magnet, ref PhysicsState state, /// private static bool UpdateGrab(int itemId, ref MagnetState magnet, ref PhysicsState state, ref BallState ball, float physicsDiffTime, float3 magnetVelocity) { + if (ball.AttachedMagnetId != 0 && ball.AttachedMagnetId != itemId) { + ReleaseGrabbedBall(itemId, ref magnet, ref state, ball.Id); + return false; + } // plain attraction magnets never grab; skip the bookkeeping entirely if (magnet.GrabRadius <= 0f && magnet.GrabbedBalls.Value == 0UL && magnet.ReleasedBalls.Value == 0UL) { return false; @@ -709,7 +744,7 @@ private static float CompactSupport(float distanceSq, float radiusSq) private static bool UsesPhysicalResponse(in MagnetState magnet) => magnet.MagnetType != MagnetType.Playfield || magnet.Profile == MagnetForceProfile.Physical; - private static bool HasActiveField(in MagnetState magnet) + internal static bool HasActiveField(in MagnetState magnet) => UsesPhysicalResponse(in magnet) ? magnet.EffectiveCurrent > MinEffectiveCurrent && math.abs(magnet.Strength) > MinDistance : magnet.IsEnabled && magnet.CommandedPower > 0f; @@ -719,15 +754,26 @@ private static float3 GetKinematicVelocity(int itemId, in MagnetState magnet, re return state.GetKinematicVelocityAt(itemId, Center3D(in magnet)); } - private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, int ballId) + internal static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref PhysicsState state, + int ballId, bool suppressRegrab = false) { + if (magnet.AttachedBallId == ballId) { + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + if (state.Balls.ContainsKey(ballId)) { + ref var ball = ref state.Balls.GetValueByRef(ballId); + if (ball.AttachedMagnetId == itemId) { + ball.AttachedMagnetId = 0; + } + } + } if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { return; } - ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, false); + ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, ref state, suppressRegrab); } - private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) + internal static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) { if (!magnet.GrabbedBalls.IsSet(bitIndex)) { return; @@ -739,6 +785,62 @@ private static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int b state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } + internal static void ReleaseOwnedAttachmentForBall(ref PhysicsState state, ref BallState ball, + bool suppressRegrab = false) + { + var magnetId = ball.AttachedMagnetId; + if (magnetId == 0 || !state.MagnetStates.ContainsKey(magnetId)) { + ball.AttachedMagnetId = 0; + return; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(magnetId); + ReleaseGrabbedBall(magnetId, ref magnet, ref state, ball.Id, suppressRegrab); + } + + internal static bool ReleaseOwnedAttachmentForUnsupportedInteraction(ref PhysicsState state, + ref BallState ball, int interactionItemId) + { + var magnetId = ball.AttachedMagnetId; + if (magnetId == 0) { + return false; + } + if (!state.MagnetStates.ContainsKey(magnetId)) { + ball.AttachedMagnetId = 0; + return false; + } + ref var magnet = ref state.MagnetStates.GetValueByRef(magnetId); + if (!state.InsideOfs.TryGetBitIndex(ball.Id, out var bitIndex) + || !magnet.GrabbedBalls.IsSet(bitIndex)) { + if (magnet.AttachedBallId == ball.Id) { + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + } + ball.AttachedMagnetId = 0; + return false; + } + ReleaseGrabbedBall(magnetId, ref magnet, bitIndex, ball.Id, ref state, true); + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + ball.AttachedMagnetId = 0; + state.EventQueue.Enqueue(new EventData( + EventId.PhysicsDiagnosticsUnsupportedOwnedInteraction, + magnetId, ball.Id, 0f, interactionItemId)); + return true; + } + + internal static void ReleaseOwnedAttachmentsForHinge(int hingeId, ref PhysicsState state) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + if (!magnets.Current.Value.CoupleToHinge + || magnets.Current.Value.HingeOwnerId != hingeId) { + continue; + } + ReleaseGrabbedBalls(magnets.Current.Key, ref magnets.Current.Value, + ref state, true); + } + } + private static void ClearReleasedBall(ref MagnetState magnet, ref PhysicsState state, int ballId) { if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { @@ -756,7 +858,7 @@ private static void ReleaseMembership(int itemId, ref PhysicsState state) } } - private static void UpdateMembership(int itemId, int ballId, bool isInside, ref PhysicsState state) + internal static void UpdateMembership(int itemId, int ballId, bool isInside, ref PhysicsState state) { var wasInside = state.InsideOfs.IsInsideOf(itemId, ballId); if (isInside == wasInside) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs index 9af152815..c9afda8d1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetState.cs @@ -44,6 +44,18 @@ internal struct MagnetState internal MagnetForceProfile Profile; internal float HeightRange; internal MagnetType MagnetType; + [MarshalAs(UnmanagedType.U1)] + internal bool CoupleToHinge; + internal int HingeOwnerId; + internal float3 LocalPoleArm; + internal float3 LocalHeldCentreArm; + internal float HoldStiffness; + internal float HoldDamping; + internal float MaxHoldForce; + internal int AttachedBallId; + internal int CandidateBallId; + internal float CandidateDistanceSq; + internal byte SaturationTicks; internal BitField64 GrabbedBalls; internal BitField64 ReleasedBalls; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs new file mode 100644 index 000000000..19fd710a8 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs @@ -0,0 +1,520 @@ +// 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 VisualPinball.Engine.Common; +using VisualPinball.Engine.Game; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + /// + /// Once-per-tick reciprocal coupling between Spatial Physical magnets and + /// their spring-hinge owners. Candidate selection precedes every field or + /// hold impulse, and each hinge velocity is committed at most once. + /// + internal static class OwnedMagnetPhysics + { + private const float MinimumValue = 1e-6f; + private const float ReleaseGapMultiplier = 1.5f; + private const byte SaturationReleaseTicks = 3; + + internal static void Update(ref PhysicsState state, float step) + { + PrepareAttachmentsAndCandidates(ref state); + ResolveCandidateConflicts(ref state); + AcquireCandidates(ref state); + ApplyOwnedFields(ref state, step); + CommitOwnedHolds(ref state, step); + } + + private static void PrepareAttachmentsAndCandidates(ref PhysicsState state) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + var itemId = magnets.Current.Key; + ref var magnet = ref magnets.Current.Value; + magnet.CandidateBallId = 0; + magnet.CandidateDistanceSq = float.MaxValue; + if (!magnet.CoupleToHinge) { + continue; + } + if (!IsUsableOwner(in magnet, ref state) || !IsPrimaryForOwner(itemId, in magnet, ref state)) { + ReleaseAttachment(itemId, ref magnet, ref state, true); + magnet.ReleasedBalls = default; + ClearMemberships(itemId, ref state); + continue; + } + + ref var hinge = ref state.SpringHingeStates.GetValueByRef(magnet.HingeOwnerId); + GetPose(in magnet, in hinge, out var pole, out var target); + magnet.Position = pole.xy; + magnet.Height = pole.z; + + if (magnet.AttachedBallId != 0) { + if (!state.Balls.ContainsKey(magnet.AttachedBallId)) { + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + } else { + ref var attached = ref state.Balls.GetValueByRef(magnet.AttachedBallId); + var releaseDistance = math.max(magnet.GrabRadius * ReleaseGapMultiplier, + attached.Radius + PhysicsConstants.PhysTouch); + if (attached.AttachedMagnetId != itemId || attached.IsFrozen || attached.ManualControl + || !MagnetPhysics.HasActiveField(in magnet) + || math.distancesq(attached.Position, target) > releaseDistance * releaseDistance + || !HasValidProxyGap(in attached, in hinge, in target, ref state) + || !CanCaptureWithin(in attached, in magnet, in hinge, in pole, in target, + releaseDistance)) { + ReleaseAttachment(itemId, ref magnet, ref state, true); + } + } + } + if (!MagnetPhysics.HasActiveField(in magnet)) { + magnet.ReleasedBalls = default; + } + UpdateMemberships(itemId, ref magnet, in pole, in target, ref state); + + if (magnet.AttachedBallId != 0 || !MagnetPhysics.HasActiveField(in magnet) + || magnet.GrabRadius <= 0f || magnet.MaxHoldForce <= 0f) { + continue; + } + + using var balls = state.Balls.GetEnumerator(); + while (balls.MoveNext()) { + ref var ball = ref balls.Current.Value; + if (ball.IsFrozen || ball.ManualControl || ball.AttachedMagnetId != 0 + || IsLegacyGrabbed(ball.Id, ref state)) { + continue; + } + var distanceSq = math.distancesq(ball.Position, target); + if (state.InsideOfs.TryGetBitIndex(ball.Id, out var bitIndex) + && magnet.ReleasedBalls.IsSet(bitIndex)) { + if (distanceSq <= magnet.GrabRadius * magnet.GrabRadius) { + continue; + } + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + if (distanceSq > magnet.GrabRadius * magnet.GrabRadius + || !HasValidProxyGap(in ball, in hinge, in target, ref state) + || !CanCapture(in ball, in magnet, in hinge, in pole, in target)) { + continue; + } + if (distanceSq < magnet.CandidateDistanceSq + || distanceSq == magnet.CandidateDistanceSq && ball.Id < magnet.CandidateBallId) { + magnet.CandidateBallId = ball.Id; + magnet.CandidateDistanceSq = distanceSq; + } + } + } + } + + private static void ClearMemberships(int itemId, ref PhysicsState state) + { + using var balls = state.Balls.GetEnumerator(); + while (balls.MoveNext()) { + MagnetPhysics.UpdateMembership(itemId, balls.Current.Key, false, ref state); + } + } + + private static void UpdateMemberships(int itemId, ref MagnetState magnet, in float3 pole, + in float3 target, ref PhysicsState state) + { + var hasField = MagnetPhysics.HasActiveField(in magnet); + var radiusSq = magnet.Radius * magnet.Radius; + using var balls = state.Balls.GetEnumerator(); + while (balls.MoveNext()) { + ref var ball = ref balls.Current.Value; + if (state.InsideOfs.TryGetBitIndex(ball.Id, out var bitIndex) + && magnet.ReleasedBalls.IsSet(bitIndex) + && math.distancesq(ball.Position, target) > magnet.GrabRadius * magnet.GrabRadius) { + magnet.ReleasedBalls.SetBits(bitIndex, false); + } + var isInside = hasField && !ball.IsFrozen + && (ball.Id == magnet.AttachedBallId + || radiusSq > MinimumValue && math.distancesq(ball.Position, pole) < radiusSq); + MagnetPhysics.UpdateMembership(itemId, ball.Id, isInside, ref state); + } + } + + private static void ResolveCandidateConflicts(ref PhysicsState state) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + var itemId = magnets.Current.Key; + ref var magnet = ref magnets.Current.Value; + if (magnet.CandidateBallId == 0) { + continue; + } + using var competitors = state.MagnetStates.GetEnumerator(); + while (competitors.MoveNext()) { + if (competitors.Current.Key == itemId + || competitors.Current.Value.CandidateBallId != magnet.CandidateBallId) { + continue; + } + var other = competitors.Current.Value; + if (other.CandidateDistanceSq < magnet.CandidateDistanceSq + || other.CandidateDistanceSq == magnet.CandidateDistanceSq + && competitors.Current.Key < itemId) { + magnet.CandidateBallId = 0; + break; + } + } + } + } + + private static void AcquireCandidates(ref PhysicsState state) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + var itemId = magnets.Current.Key; + ref var magnet = ref magnets.Current.Value; + var ballId = magnet.CandidateBallId; + if (ballId == 0 || magnet.AttachedBallId != 0 || !state.Balls.ContainsKey(ballId)) { + continue; + } + ref var ball = ref state.Balls.GetValueByRef(ballId); + if (ball.AttachedMagnetId != 0) { + continue; + } + var bitIndex = state.InsideOfs.GetOrCreateBitIndex(ballId); + magnet.AttachedBallId = ballId; + magnet.GrabbedBalls.SetBits(bitIndex, true); + magnet.ReleasedBalls.SetBits(bitIndex, false); + magnet.SaturationTicks = 0; + ball.AttachedMagnetId = itemId; + MagnetPhysics.UpdateMembership(itemId, ballId, true, ref state); + state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallGrabbed, itemId, ballId, true)); + } + } + + private static void ApplyOwnedFields(ref PhysicsState state, float step) + { + if (step <= 0f) { + return; + } + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + var itemId = magnets.Current.Key; + ref var magnet = ref magnets.Current.Value; + if (!IsUsableOwner(in magnet, ref state) || !IsPrimaryForOwner(itemId, in magnet, ref state) + || !MagnetPhysics.HasActiveField(in magnet)) { + continue; + } + ref var hinge = ref state.SpringHingeStates.GetValueByRef(magnet.HingeOwnerId); + GetPose(in magnet, in hinge, out var pole, out _); + using var balls = state.Balls.GetEnumerator(); + while (balls.MoveNext()) { + ref var ball = ref balls.Current.Value; + if (ball.IsFrozen || ball.Id == magnet.AttachedBallId) { + continue; + } + var delta = ball.Position - pole; + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinimumValue || distanceSq >= magnet.Radius * magnet.Radius) { + continue; + } + var distance = math.sqrt(distanceSq); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + var accelerationMagnitude = MagnetPhysics.PhysicalForceMagnitude(distance, 0f, + cutoff, in magnet); + var acceleration = -delta / distance * accelerationMagnitude; + var impulse = acceleration * ball.Mass * step; + ball.Velocity += acceleration * step; + ball.ExternalAcceleration += acceleration; + var poleArm = pole - hinge.Static.Pivot; + hinge.Movement.PendingMagneticAngularImpulse += math.dot(hinge.Static.Axis, + math.cross(poleArm, -impulse)); + } + } + } + + private static void CommitOwnedHolds(ref PhysicsState state, float step) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + var itemId = magnets.Current.Key; + ref var magnet = ref magnets.Current.Value; + if (!IsUsableOwner(in magnet, ref state) || !IsPrimaryForOwner(itemId, in magnet, ref state)) { + continue; + } + ref var hinge = ref state.SpringHingeStates.GetValueByRef(magnet.HingeOwnerId); + if (magnet.AttachedBallId == 0 || !state.Balls.ContainsKey(magnet.AttachedBallId)) { + SpringHingeVelocityPhysics.CommitFreeVelocity(ref hinge); + continue; + } + ref var ball = ref state.Balls.GetValueByRef(magnet.AttachedBallId); + GetPose(in magnet, in hinge, out _, out var target); + if (!SolveHold(ref ball, ref hinge, in magnet, in target, step, out var saturated)) { + ReleaseAttachment(itemId, ref magnet, ref state, true); + SpringHingeVelocityPhysics.CommitFreeVelocity(ref hinge); + continue; + } + + var relativePosition = ball.Position - target; + var materialArm = math.cross(hinge.Static.Axis, ball.Position - hinge.Static.Pivot); + var relativeVelocity = ball.Velocity - materialArm * hinge.Movement.AngularVelocity; + var separating = math.dot(relativePosition, relativeVelocity) > 0f; + magnet.SaturationTicks = saturated && separating + ? (byte)math.min(byte.MaxValue, magnet.SaturationTicks + 1) + : (byte)0; + if (magnet.SaturationTicks >= SaturationReleaseTicks) { + ReleaseAttachment(itemId, ref magnet, ref state, true); + } + } + } + + internal static bool SolveHold(ref BallState ball, ref SpringHingeState hinge, + in MagnetState magnet, in float3 target, float step, out bool saturated) + { + saturated = false; + var mass = ball.Mass; + var inertia = hinge.Static.Inertia; + var stiffness = math.max(0f, magnet.HoldStiffness); + var damping = math.max(0f, magnet.HoldDamping); + var maxImpulse = math.max(0f, magnet.MaxHoldForce) + * magnet.EffectiveCurrent * magnet.EffectiveCurrent * step; + if (step <= 0f || mass <= 0f || inertia <= 0f || maxImpulse <= 0f + || stiffness <= 0f && damping <= 0f || !math.isfinite(step) + || !math.isfinite(mass) || !math.isfinite(inertia) + || !math.isfinite(stiffness) || !math.isfinite(damping) + || !math.isfinite(maxImpulse)) { + return false; + } + + var movement = hinge.Movement; + var u = math.cross(hinge.Static.Axis, ball.Position - hinge.Static.Pivot); + var error = ball.Position - target; + var beta = step * step * stiffness + step * damping; + var ballResponse = 1f + beta / mass; + var constantImpulse = -step * stiffness * (error + step * ball.Velocity) + - step * damping * ball.Velocity; + var denominator = inertia + step * hinge.Static.Damping + + step * step * hinge.Static.Stiffness; + if (denominator <= MinimumValue) { + return false; + } + var baseAngularMomentum = inertia * movement.TickStartAngularVelocity + + step * movement.GravityTorque + movement.PendingMagneticAngularImpulse + - step * hinge.Static.Stiffness * movement.TickStartAngleError; + var coupledDenominator = denominator + beta * math.lengthsq(u) / ballResponse; + var omega = (baseAngularMomentum - math.dot(u, constantImpulse) / ballResponse) + / coupledDenominator; + var impulse = (constantImpulse + beta * u * omega) / ballResponse; + if (!math.isfinite(omega) || !math.all(math.isfinite(impulse))) { + return false; + } + var impulseLength = math.length(impulse); + if (impulseLength > maxImpulse) { + impulse *= maxImpulse / impulseLength; + saturated = true; + omega = (baseAngularMomentum - math.dot(u, impulse)) / denominator; + } + + if (movement.ActiveStop != 0 && movement.ActiveStop * omega > 0f) { + var freeImpulse = impulse; + var freeOmega = omega; + var freeSaturated = saturated; + omega = 0f; + impulse = constantImpulse / ballResponse; + saturated = false; + impulseLength = math.length(impulse); + if (impulseLength > maxImpulse) { + impulse *= maxImpulse / impulseLength; + saturated = true; + } + var bearingImpulse = -inertia * movement.TickStartAngularVelocity + - step * movement.GravityTorque - movement.PendingMagneticAngularImpulse + + step * hinge.Static.Stiffness * movement.TickStartAngleError + + math.dot(u, impulse); + if (movement.ActiveStop * bearingImpulse > 0f) { + impulse = freeImpulse; + omega = freeOmega; + saturated = freeSaturated; + } + } + + ball.Velocity += impulse / mass; + ball.ExternalAcceleration += impulse / (mass * step); + MagnetPhysics.DampHeldBallSpin(ref ball, step); + hinge.Movement.AngularVelocity = omega; + hinge.Movement.CommittedMagneticTorque = + (hinge.Movement.PendingMagneticAngularImpulse - math.dot(u, impulse)) / step; + hinge.Movement.VelocityCommitted = true; + if (hinge.Movement.ActiveStop * omega < -MinimumValue) { + hinge.Movement.ActiveStop = 0; + } + SpringHingeVelocityPhysics.RefreshContinuousAcceleration(ref hinge); + return true; + } + + internal static bool CanCapture(in BallState ball, in MagnetState magnet, + in SpringHingeState hinge, in float3 pole, in float3 target) + => CanCaptureWithin(in ball, in magnet, in hinge, in pole, in target, + magnet.GrabRadius); + + internal static float EstimateStationaryHingeCaptureSpeed(in MagnetState magnet, + in float3 pole, in float3 target) + { + var availableWork = AvailableCaptureWork(in magnet, in pole, in target, + magnet.GrabRadius); + return availableWork > 0f && math.isfinite(availableWork) + ? math.sqrt(2f * availableWork) + : 0f; + } + + private static bool CanCaptureWithin(in BallState ball, in MagnetState magnet, + in SpringHingeState hinge, in float3 pole, in float3 target, float workRadius) + { + if (ball.Mass <= MinimumValue || hinge.Static.Inertia <= MinimumValue + || magnet.Radius <= MinimumValue || workRadius <= 0f + || !math.isfinite(ball.Mass) || !math.isfinite(hinge.Static.Inertia) + || !math.all(math.isfinite(ball.Position)) || !math.all(math.isfinite(ball.Velocity))) { + return false; + } + var availableWork = AvailableCaptureWork(in magnet, in pole, ball.Position, + target, workRadius, ball.Mass); + if (availableWork <= 0f) { + return false; + } + + var u = math.cross(hinge.Static.Axis, ball.Position - hinge.Static.Pivot); + var relativeVelocity = ball.Velocity - u * hinge.Movement.TickStartAngularVelocity; + var inverseInertia = 1f / hinge.Static.Inertia; + var response = float3x3.identity / ball.Mass + Outer(u) * inverseInertia; + var requiredImpulse = -math.mul(math.inverse(response), relativeVelocity); + if (hinge.Movement.ActiveStop != 0 + && hinge.Movement.ActiveStop * -math.dot(u, requiredImpulse) > 0f) { + response = float3x3.identity / ball.Mass; + requiredImpulse = -math.mul(math.inverse(response), relativeVelocity); + } + var requiredEnergy = -0.5f * math.dot(relativeVelocity, requiredImpulse); + return math.isfinite(requiredEnergy) && requiredEnergy <= availableWork; + } + + private static float AvailableCaptureWork(in MagnetState magnet, in float3 pole, + in float3 target, float workRadius) + => AvailableCaptureWork(in magnet, in pole, in target, in target, workRadius, 1f); + + private static float AvailableCaptureWork(in MagnetState magnet, in float3 pole, + in float3 ballPosition, in float3 target, float workRadius, float ballMass) + { + var delta = ballPosition - pole; + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinimumValue || distanceSq >= magnet.Radius * magnet.Radius) { + return 0f; + } + var distance = math.sqrt(distanceSq); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + var fieldForce = MagnetPhysics.PhysicalForceMagnitude(distance, 0f, cutoff, in magnet) + * ballMass; + var holdForce = math.max(0f, magnet.MaxHoldForce) + * magnet.EffectiveCurrent * magnet.EffectiveCurrent; + return math.min(fieldForce, holdForce) + * math.max(0f, workRadius - math.distance(ballPosition, target)); + } + + private static bool HasValidProxyGap(in BallState ball, in SpringHingeState hinge, + in float3 target, ref PhysicsState state) + { + for (var colliderId = 0; colliderId < state.Colliders.Length; colliderId++) { + if (state.Colliders.GetHeader(colliderId).Type != ColliderType.SpringHinge) { + continue; + } + ref var collider = ref state.Colliders.SpringHinge(colliderId); + if (collider.HingeOwnerId != hinge.Static.OwnerId) { + continue; + } + var targetGap = collider.Distance(in hinge, in target, ball.Radius).Separation; + var currentGap = collider.Distance(in hinge, ball.Position, ball.Radius).Separation; + if (math.abs(targetGap) <= PhysicsConstants.PhysTouch + && currentGap >= -PhysicsConstants.PhysTouch) { + return true; + } + } + return false; + } + + private static bool IsLegacyGrabbed(int ballId, ref PhysicsState state) + { + if (!state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + return false; + } + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + if (!magnets.Current.Value.CoupleToHinge + && magnets.Current.Value.GrabbedBalls.IsSet(bitIndex)) { + return true; + } + } + return false; + } + + private static bool IsUsableOwner(in MagnetState magnet, ref PhysicsState state) + => magnet.CoupleToHinge && magnet.HingeOwnerId != 0 + && state.SpringHingeStates.ContainsKey(magnet.HingeOwnerId) + && magnet.MagnetType == MagnetType.Spatial + && magnet.Profile == MagnetForceProfile.Physical; + + private static bool IsPrimaryForOwner(int itemId, in MagnetState magnet, ref PhysicsState state) + { + using var magnets = state.MagnetStates.GetEnumerator(); + while (magnets.MoveNext()) { + if (magnets.Current.Key < itemId && magnets.Current.Value.CoupleToHinge + && magnets.Current.Value.HingeOwnerId == magnet.HingeOwnerId) { + return false; + } + } + return true; + } + + private static void ReleaseAttachment(int itemId, ref MagnetState magnet, + ref PhysicsState state, bool suppressRegrab) + { + var ballId = magnet.AttachedBallId; + if (ballId == 0) { + return; + } + if (state.Balls.ContainsKey(ballId)) { + ref var ball = ref state.Balls.GetValueByRef(ballId); + if (ball.AttachedMagnetId == itemId) { + ball.AttachedMagnetId = 0; + } + } + if (state.InsideOfs.TryGetBitIndex(ballId, out var bitIndex)) { + MagnetPhysics.ReleaseGrabbedBall(itemId, ref magnet, bitIndex, ballId, + ref state, suppressRegrab); + } else { + magnet.AttachedBallId = 0; + } + magnet.AttachedBallId = 0; + magnet.SaturationTicks = 0; + } + + private static void GetPose(in MagnetState magnet, in SpringHingeState hinge, + out float3 pole, out float3 target) + { + var angle = hinge.Movement.Angle; + pole = hinge.Static.Pivot + SpringHingeVelocityPhysics.RotateAroundAxis( + magnet.LocalPoleArm, hinge.Static.Axis, angle); + target = hinge.Static.Pivot + SpringHingeVelocityPhysics.RotateAroundAxis( + magnet.LocalHeldCentreArm, hinge.Static.Axis, angle); + } + + private static float CompactSupport(float distanceSq, float radiusSq) + { + if (radiusSq <= MinimumValue || distanceSq >= radiusSq) { + return 0f; + } + var remaining = 1f - distanceSq / radiusSq; + return remaining * remaining; + } + + private static float3x3 Outer(in float3 value) + => new(value * value.x, value * value.y, value * value.z); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs.meta new file mode 100644 index 000000000..9273e3430 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43c77e694b1e4501a5d3e13ec557f83d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerCollider.cs index 1e852f913..b1d4a0d17 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerCollider.cs @@ -28,6 +28,13 @@ namespace VisualPinball.Unity /// internal struct SpinnerCollider : ICollider { + /// + /// Largest distance from the rotation axis in the built-in VPX spinner plate mesh at unit scale. + /// Used as the lower bound for the response lever so the plate edge cannot move faster than the ball. + /// This intentionally makes short spinners slower than VPX's height-based approximation. + /// + internal const float DefaultPlateRadius = 22.60827f; + public int Id { get => Header.Id; @@ -43,14 +50,18 @@ public int Id public LineCollider LineSeg0; public LineCollider LineSeg1; + private float _responseDivisor; public ColliderBounds Bounds { get; private set; } - public SpinnerCollider(in LineCollider lineSeg0, in LineCollider lineSeg1, ColliderInfo info) : this() + public SpinnerCollider(in LineCollider lineSeg0, in LineCollider lineSeg1, float height, ColliderInfo info) : this() { Header.Init(info, ColliderType.Spinner); LineSeg0 = lineSeg0; LineSeg1 = lineSeg1; + var h = height * 0.5f; + var vpxDivisor = math.abs(h) > 1.0f ? h : 1.0f; + _responseDivisor = math.sign(vpxDivisor) * math.max(math.abs(vpxDivisor), DefaultPlateRadius); Bounds = LineSeg0.Bounds; } @@ -80,7 +91,7 @@ public float HitTest(ref CollisionEventData collEvent, ref InsideOfs insideOfs, #region Collision - public static void Collide(in BallState ball, ref CollisionEventData collEvent, ref SpinnerMovementState movement, in SpinnerStaticState state) + public void Collide(in BallState ball, ref CollisionEventData collEvent, ref SpinnerMovementState movement, in SpinnerStaticState state) { var dot = math.dot(collEvent.HitNormal, ball.Velocity); @@ -89,7 +100,6 @@ public static void Collide(in BallState ball, ref CollisionEventData collEvent, return; } - var h = state.Height * 0.5f; // linear speed = ball speed // angular speed = linear/radius (height of hit) @@ -98,12 +108,7 @@ public static void Collide(in BallState ball, ref CollisionEventData collEvent, // h -coll.m_radius will be moving a at linear rate of // 'speed'. We can calculate the angular speed from that. - movement.AngleSpeed = math.abs(dot) * movement.InverseMass; // use this until a better value comes along - - if (math.abs(h) > 1.0f) { - // avoid divide by zero - movement.AngleSpeed /= h; - } + movement.AngleSpeed = math.abs(dot) / _responseDivisor; movement.AngleSpeed *= state.Damping; @@ -147,7 +152,8 @@ public void Transform(SpinnerCollider collider, float4x4 matrix) LineSeg0 = collider.LineSeg0.Transform(matrix); LineSeg1 = collider.LineSeg1.Transform(matrix); - Bounds = collider.LineSeg0.Bounds; + _responseDivisor = collider._responseDivisor * matrix.GetScale().x; + Bounds = LineSeg0.Bounds; } public Aabb GetTransformedAabb(float4x4 matrix) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs index b94af5850..b9e5d31a7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerColliderComponent.cs @@ -27,10 +27,6 @@ public class SpinnerColliderComponent : ColliderComponent(bytes); comp._isKinematic = data.IsMovable; - comp.Mass = data.Mass ?? comp.Mass; comp.Offset = data.Offset.HasValue ? (Vector3)data.Offset.Value : new Vector3(0f, 0f, data.ZPosition); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPlateAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPlateAnimationComponent.cs index 973c01133..d52c234a8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPlateAnimationComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerPlateAnimationComponent.cs @@ -34,7 +34,8 @@ private void Start() protected override void OnAnimationValueChanged(float value) { var axis = RotationVector.normalized; - var rotation = Quaternion.AngleAxis(math.degrees(value), axis); + // VPX applies MatrixRotateX(-angle) to the plate. Keep that sign when using a configurable Unity axis. + var rotation = Quaternion.AngleAxis(-math.degrees(value), axis); transform.localRotation = _initialRotation * rotation; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerStaticState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerStaticState.cs index c44a0e0c3..083690591 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerStaticState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Spinner/SpinnerStaticState.cs @@ -20,7 +20,6 @@ internal struct SpinnerStaticState { public float AngleMin; public float AngleMax; - public float Height; public float Damping; public float Elasticity; //public Entity PlateEntity; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge.meta new file mode 100644 index 000000000..038e53292 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a2da2ffd9674148aa69ae9e91663f23 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs new file mode 100644 index 000000000..9453a5b8d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs @@ -0,0 +1,148 @@ +// 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 VisualPinball.Engine.VPT; +using VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + public class SpringHingeApi : IApi, IApiColliderGenerator, IApiHittable, IApiSwitchDevice + { + private readonly SpringHingeComponent _component; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + private readonly SpringHingeColliderComponent _colliderComponent; + private readonly DeviceSwitch _angleSwitch; + private bool _angleSwitchClosed; + + public event EventHandler Init; + public event EventHandler Hit; + + internal SpringHingeApi(SpringHingeComponent component, PhysicsEngine physicsEngine) + { + _component = component; + _physicsEngine = physicsEngine; + _itemId = component.ItemId; + _colliderComponent = component.GetComponent(); + var player = component.GetComponentInParent(); + _angleSwitch = new DeviceSwitch(SpringHingeComponent.AngleSwitchItem, + false, SwitchDefault.NormallyOpen, player, physicsEngine); + } + + internal float Angle => _component.PublishedAngle; + + IApiSwitch IApiSwitchDevice.Switch(string deviceItem) + => deviceItem == SpringHingeComponent.AngleSwitchItem + ? _angleSwitch + : throw new ArgumentException($"Unknown spring-hinge switch \"{deviceItem}\". Valid name is \"{SpringHingeComponent.AngleSwitchItem}\"."); + + internal void OnAngleChanged(float angle) + { + if (!_component.EnableAngleSwitch) { + return; + } + var angleDegrees = math.degrees(angle); + if (!_angleSwitchClosed && angleDegrees >= _component.SwitchCloseAngle) { + _angleSwitchClosed = true; + _angleSwitch.SetSwitch(true); + } else if (_angleSwitchClosed && angleDegrees <= _component.SwitchOpenAngle) { + _angleSwitchClosed = false; + _angleSwitch.SetSwitch(false); + } + } + + public void Reset(float angle) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.SpringHingeStates.ContainsKey(_itemId)) { + return; + } + MagnetPhysics.ReleaseOwnedAttachmentsForHinge(_itemId, ref state); + ref var hinge = ref state.SpringHingeStates.GetValueByRef(_itemId); + hinge.Movement.Angle = math.clamp(math.radians(angle), + hinge.Static.MinimumAngle, hinge.Static.MaximumAngle); + hinge.Movement.AngularVelocity = 0f; + hinge.Movement.ActiveStop = 0; + }); + } + + void IApi.OnInit(BallManager ballManager) => Init?.Invoke(this, EventArgs.Empty); + + void IApi.OnDestroy() + { + } + + void IApiHittable.OnHit(int ballId, bool isUnHit) + { + if (!isUnHit) { + Hit?.Invoke(this, new HitEventArgs(ballId)); + } + } + + bool IApiColliderGenerator.IsColliderAvailable => _colliderComponent && _colliderComponent.IsCollidable; + + void IApiColliderGenerator.CreateColliders(ref ColliderReference colliders, + float4x4 translateWithinPlayfieldMatrix, float margin) + { + if (!_colliderComponent || !_colliderComponent.IsCollidable) { + return; + } + colliders.Add(SpringHingeColliderGenerator.Create(_component, _colliderComponent, + GetColliderInfo(ItemType.Invalid), margin)); + } + + ColliderInfo IApiColliderGenerator.GetColliderInfo() => GetColliderInfo(ItemType.Invalid); + ColliderInfo IApiColliderGenerator.GetColliderInfo(ItemType itemType) => GetColliderInfo(itemType); + + private ColliderInfo GetColliderInfo(ItemType itemType) + { + if (!_colliderComponent) { + return new ColliderInfo { ItemId = _itemId, ItemType = itemType }; + } + var material = !_colliderComponent.OverwritePhysics && _colliderComponent.PhysicsMaterial + ? new PhysicsMaterialData { + Elasticity = _colliderComponent.PhysicsMaterial.Elasticity, + ElasticityFalloff = _colliderComponent.PhysicsMaterial.ElasticityFalloff, + Friction = _colliderComponent.PhysicsMaterial.Friction, + ScatterAngleRad = 0f, + UseElasticityOverVelocity = _colliderComponent.PhysicsMaterial.UseElasticityOverVelocity, + UseFrictionOverVelocity = _colliderComponent.PhysicsMaterial.UseFrictionOverVelocity + } + : new PhysicsMaterialData { + Elasticity = _colliderComponent.Elasticity, + ElasticityFalloff = _colliderComponent.ElasticityFalloff, + Friction = _colliderComponent.Friction, + ScatterAngleRad = 0f + }; + if (_physicsEngine && !_colliderComponent.OverwritePhysics && _colliderComponent.PhysicsMaterial) { + if (material.UseElasticityOverVelocity + && !_physicsEngine.ElasticityOverVelocityLUTs.ContainsKey(_itemId)) { + _physicsEngine.ElasticityOverVelocityLUTs.Add(_itemId, + _colliderComponent.PhysicsMaterial.GetElasticityOverVelocityLUT()); + } + if (material.UseFrictionOverVelocity + && !_physicsEngine.FrictionOverVelocityLUTs.ContainsKey(_itemId)) { + _physicsEngine.FrictionOverVelocityLUTs.Add(_itemId, + _colliderComponent.PhysicsMaterial.GetFrictionOverVelocityLUT()); + } + } + return new ColliderInfo { + ItemId = _itemId, + ItemType = itemType, + FireEvents = _colliderComponent.HitEvent, + HitThreshold = _colliderComponent.HitThreshold, + Material = material + }; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs.meta new file mode 100644 index 000000000..9c7418d1c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 009ae5f3939242d590cb9800786c5ca0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs new file mode 100644 index 000000000..2cff9e0c9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs @@ -0,0 +1,397 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using Unity.Collections; +using Unity.Mathematics; +using VisualPinball.Engine.Common; + +namespace VisualPinball.Unity +{ + internal struct SpringHingeCollider : ICollider + { + private const int ConservativeIterations = 32; + private const int RefinementIterations = 14; + private const int FallbackSegments = 32; + private const float AdvanceSafety = 0.8f; + private const float TimeEpsilon = 1e-7f; + + public int Id + { + get => Header.Id; + set => Header.Id = value; + } + + public ColliderHeader Header; + public readonly int HingeOwnerId; + public readonly float3 CentreArm; + public readonly float3 HalfExtents; + public readonly float3 ReferenceAxisX; + public readonly float3 ReferenceAxisY; + public readonly float3 ReferenceAxisZ; + public readonly float MaxProxyRadius; + private readonly Aabb _fullTravelAabb; + + public ColliderBounds Bounds => new(Header.ItemId, Header.Id, _fullTravelAabb); + + internal SpringHingeCollider(int hingeOwnerId, in float3 pivot, in float3 centreArm, + in float3 halfExtents, in float3 referenceAxisX, in float3 referenceAxisY, + in float3 referenceAxisZ, ColliderInfo info) : this() + { + Header.Init(info, ColliderType.SpringHinge); + HingeOwnerId = hingeOwnerId; + CentreArm = centreArm; + HalfExtents = math.max(halfExtents, float3.zero); + ReferenceAxisX = math.normalizesafe(referenceAxisX, new float3(1f, 0f, 0f)); + ReferenceAxisY = math.normalizesafe(referenceAxisY, new float3(0f, 1f, 0f)); + ReferenceAxisZ = math.normalizesafe(referenceAxisZ, new float3(0f, 0f, 1f)); + MaxProxyRadius = CalculateMaxProxyRadius(); + var radius = new float3(MaxProxyRadius); + _fullTravelAabb = new Aabb(pivot - radius, pivot + radius); + } + + internal SpringHingeDistance Distance(in SpringHingeState hinge, in float3 sphereCentre, + float sphereRadius, float time = 0f) + { + var angle = math.clamp(hinge.Movement.Angle + hinge.Movement.AngularVelocity * time, + hinge.Static.MinimumAngle, hinge.Static.MaximumAngle); + math.sincos(angle, out var sine, out var cosine); + var centre = hinge.Static.Pivot + SpringHingeVelocityPhysics.RotateAroundAxis( + CentreArm, hinge.Static.Axis, sine, cosine); + var axisX = SpringHingeVelocityPhysics.RotateAroundAxis(ReferenceAxisX, hinge.Static.Axis, sine, cosine); + var axisY = SpringHingeVelocityPhysics.RotateAroundAxis(ReferenceAxisY, hinge.Static.Axis, sine, cosine); + var axisZ = SpringHingeVelocityPhysics.RotateAroundAxis(ReferenceAxisZ, hinge.Static.Axis, sine, cosine); + var relative = sphereCentre - centre; + var local = new float3(math.dot(relative, axisX), math.dot(relative, axisY), math.dot(relative, axisZ)); + var closest = math.clamp(local, -HalfExtents, HalfExtents); + var outside = local - closest; + var outsideLengthSq = math.lengthsq(outside); + + float3 normalLocal; + float signedPointDistance; + if (outsideLengthSq > 1e-12f) { + var outsideLength = math.sqrt(outsideLengthSq); + normalLocal = outside / outsideLength; + signedPointDistance = outsideLength; + } else { + var faceDistance = HalfExtents - math.abs(local); + var face = faceDistance.x <= faceDistance.y && faceDistance.x <= faceDistance.z ? 0 + : faceDistance.y <= faceDistance.z ? 1 : 2; + normalLocal = float3.zero; + normalLocal[face] = local[face] < 0f ? -1f : 1f; + closest = local; + closest[face] = normalLocal[face] * HalfExtents[face]; + signedPointDistance = -faceDistance[face]; + } + + var normal = math.normalizesafe(axisX * normalLocal.x + axisY * normalLocal.y + axisZ * normalLocal.z, + axisX); + var witness = centre + axisX * closest.x + axisY * closest.y + axisZ * closest.z; + return new SpringHingeDistance(signedPointDistance - sphereRadius, witness, normal); + } + + internal float HitTest(ref CollisionEventData collEvent, in SpringHingeState hinge, + in BallState ball, float dTime) + { + if (ball.IsFrozen || dTime < 0f) { + return -1f; + } + + var maxTime = dTime; + var stopTime = SpringHingeDisplacementPhysics.GetStopTime(hinge); + if (stopTime > 0f) { + maxTime = math.min(maxTime, stopTime); + } + var rateBound = math.length(ball.Velocity) + + math.abs(hinge.Movement.AngularVelocity) * MaxProxyRadius; + var time = 0f; + var distance = Distance(in hinge, ball.Position, ball.Radius); + + if (distance.Separation <= PhysicsConstants.PhysTouch) { + return PopulateHit(ref collEvent, in hinge, in ball, in distance, 0f); + } + if (rateBound <= math.EPSILON || maxTime <= 0f) { + return -1f; + } + + var needsFallback = false; + var iterations = 0; + for (; iterations < ConservativeIterations && time < maxTime; iterations++) { + var previousTime = time; + var advance = AdvanceSafety * distance.Separation / rateBound; + if (!math.isfinite(advance) || advance <= TimeEpsilon) { + needsFallback = true; + break; + } + time = math.min(maxTime, time + advance); + distance = Distance(in hinge, ball.Position + ball.Velocity * time, ball.Radius, time); + if (distance.Separation <= 0f) { + time = RefineHitTime(in hinge, in ball, previousTime, time); + distance = Distance(in hinge, ball.Position + ball.Velocity * time, ball.Radius, time); + return PopulateHit(ref collEvent, in hinge, in ball, in distance, time); + } + if (time <= previousTime) { + needsFallback = true; + break; + } + } + + if (!needsFallback && (time >= maxTime || iterations < ConservativeIterations)) { + return -1f; + } + return FallbackHitTest(ref collEvent, in hinge, in ball, time, maxTime); + } + + internal void Collide(ref BallState ball, ref SpringHingeState hinge, + in CollisionEventData collEvent, ref PhysicsState state) + { + var distance = Distance(in hinge, ball.Position, ball.Radius); + var normal = distance.Normal; + var correction = math.clamp(-PhysicsConstants.DispGain * distance.Separation, + 0f, PhysicsConstants.DispLimit); + if (correction > 1e-4f) { + ball.Position += correction * normal; + distance = Distance(in hinge, ball.Position, ball.Radius); + } + var arm = distance.Witness - hinge.Static.Pivot; + var surfaceVelocity = hinge.Movement.AngularVelocity * math.cross(hinge.Static.Axis, arm); + var normalVelocity = math.dot(ball.Velocity - surfaceVelocity, normal); + if (normalVelocity >= -PhysicsConstants.LowNormVel) { + return; + } + + var responseArm = math.dot(hinge.Static.Axis, math.cross(arm, normal)); + var hingeResponse = responseArm * responseArm / hinge.Static.Inertia; + if (PushesIntoActiveStop(in hinge.Movement, -responseArm)) { + hingeResponse = 0f; + } + var inverseEffectiveMass = ball.InvMass + hingeResponse; + if (inverseEffectiveMass <= math.EPSILON) { + return; + } + var elasticity = Math.ElasticityWithFalloff(Header.Material.Elasticity, + Header.Material.ElasticityFalloff, normalVelocity); + if (Header.Material.UseElasticityOverVelocity) { + var lut = state.ElasticityOverVelocityLUTs[Header.ItemId]; + elasticity = lut.InterpolateLUT(0, 127f, -normalVelocity); + } + var impulse = -(1f + elasticity) * normalVelocity / inverseEffectiveMass; + ball.Velocity += impulse * normal * ball.InvMass; + ApplyAngularImpulse(ref hinge, -impulse * responseArm); + + var ballArm = -ball.Radius * normal; + var relativeSurfaceVelocity = BallState.SurfaceVelocity(in ball, in ballArm) + - hinge.Movement.AngularVelocity * math.cross(hinge.Static.Axis, arm); + var tangentVelocity = relativeSurfaceVelocity + - normal * math.dot(relativeSurfaceVelocity, normal); + var tangentSpeed = math.length(tangentVelocity); + if (tangentSpeed > PhysicsConstants.Precision) { + var tangent = tangentVelocity / tangentSpeed; + var ballCross = math.cross(ballArm, tangent); + var hingeTangentArm = math.dot(hinge.Static.Axis, math.cross(arm, tangent)); + var tangentResponse = ball.InvMass + + math.dot(tangent, math.cross(ballCross / ball.Inertia, ballArm)); + if (!PushesIntoActiveStop(in hinge.Movement, hingeTangentArm)) { + tangentResponse += hingeTangentArm * hingeTangentArm / hinge.Static.Inertia; + } + var friction = GetFriction(ref state, normalVelocity); + var frictionImpulse = math.clamp(-tangentSpeed / tangentResponse, + -friction * impulse, friction * impulse); + ball.ApplySurfaceImpulse(frictionImpulse * ballCross, frictionImpulse * tangent); + ApplyAngularImpulse(ref hinge, -frictionImpulse * hingeTangentArm); + } + SpringHingeVelocityPhysics.RefreshContinuousAcceleration(ref hinge); + if (-normalVelocity >= Header.Threshold) { + Collider.FireHitEvent(ref ball, ref state.EventQueue, in Header); + } + } + + internal void Contact(ref BallState ball, ref SpringHingeState hinge, + in CollisionEventData collEvent, float dTime, in float3 acceleration, + in float3 frictionAcceleration, in float3 frictionVelocity, + in float3 frictionAngularMomentum) + { + var distance = Distance(in hinge, ball.Position, ball.Radius); + var normal = distance.Normal; + if (distance.Separation < -PhysicsConstants.Embedded) { + ball.Velocity += 0.1f * normal; + } + var ballArm = -ball.Radius * normal; + var hingeArm = distance.Witness - hinge.Static.Pivot; + var relativeVelocity = BallState.SurfaceVelocity(in ball, in ballArm) + - hinge.Movement.AngularVelocity * math.cross(hinge.Static.Axis, hingeArm); + var normalVelocity = math.dot(relativeVelocity, normal); + if (normalVelocity > PhysicsConstants.ContactVel) { + return; + } + + var angularVelocity = hinge.Movement.AngularVelocity * hinge.Static.Axis; + var hingeAcceleration = hinge.Movement.ContinuousAngularAcceleration + * math.cross(hinge.Static.Axis, hingeArm) + + math.cross(angularVelocity, math.cross(angularVelocity, hingeArm)); + var ballAcceleration = BallState.SurfaceAcceleration(in ball, in ballArm, in acceleration); + var frictionBall = ball; + frictionBall.Velocity = frictionVelocity; + frictionBall.AngularMomentum = frictionAngularMomentum; + var frictionBallAcceleration = BallState.SurfaceAcceleration(in frictionBall, in ballArm, + in frictionAcceleration); + var normalDerivative = math.cross(angularVelocity, normal); + var normalAcceleration = math.dot(ballAcceleration - hingeAcceleration, normal) + + 2f * math.dot(normalDerivative, relativeVelocity); + var responseArm = math.dot(hinge.Static.Axis, math.cross(hingeArm, normal)); + var hingeResponse = PushesIntoActiveStop(in hinge.Movement, -responseArm) + ? 0f : responseArm * responseArm / hinge.Static.Inertia; + var inverseEffectiveMass = ball.InvMass + hingeResponse; + if (inverseEffectiveMass <= math.EPSILON) { + return; + } + var supportForce = math.max(0f, -normalAcceleration / inverseEffectiveMass); + var normalImpulse = math.max(0f, + -normalVelocity / inverseEffectiveMass + supportForce * dTime); + var hingeAngularVelocityBeforeImpulse = hinge.Movement.AngularVelocity; + ball.Velocity += normalImpulse * normal * ball.InvMass; + ApplyAngularImpulse(ref hinge, -normalImpulse * responseArm); + + // Friction uses the contact-pass snapshots on both bodies. The normal solve above + // must not manufacture tangential slip by changing only the hinge side first. + var frictionRelative = BallState.SurfaceVelocity(in frictionBall, in ballArm) + - hingeAngularVelocityBeforeImpulse * math.cross(hinge.Static.Axis, hingeArm); + var frictionNormalAcceleration = math.dot(frictionBallAcceleration - hingeAcceleration, normal) + + 2f * math.dot(normalDerivative, frictionRelative); + var frictionSupportForce = math.max(0f, -frictionNormalAcceleration / inverseEffectiveMass); + var slip = frictionRelative - normal * math.dot(frictionRelative, normal); + var slipSpeed = math.length(slip); + if (slipSpeed > PhysicsConstants.Precision && frictionSupportForce > 0f) { + var tangent = slip / slipSpeed; + var ballCross = math.cross(ballArm, tangent); + var hingeTangentArm = math.dot(hinge.Static.Axis, math.cross(hingeArm, tangent)); + var tangentResponse = ball.InvMass + + math.dot(tangent, math.cross(ballCross / ball.Inertia, ballArm)); + if (!PushesIntoActiveStop(in hinge.Movement, hingeTangentArm)) { + tangentResponse += hingeTangentArm * hingeTangentArm / hinge.Static.Inertia; + } + var friction = Header.Material.Friction; + var impulse = math.clamp(-slipSpeed / tangentResponse, + -friction * frictionSupportForce * dTime, friction * frictionSupportForce * dTime); + ball.ApplySurfaceImpulse(impulse * ballCross, impulse * tangent); + ApplyAngularImpulse(ref hinge, -impulse * hingeTangentArm); + } + SpringHingeVelocityPhysics.RefreshContinuousAcceleration(ref hinge); + } + + private float FallbackHitTest(ref CollisionEventData collEvent, in SpringHingeState hinge, + in BallState ball, float startTime, float maxTime) + { + var previousTime = startTime; + for (var i = 1; i <= FallbackSegments; i++) { + var time = math.lerp(startTime, maxTime, (float)i / FallbackSegments); + var distance = Distance(in hinge, ball.Position + ball.Velocity * time, ball.Radius, time); + if (distance.Separation <= 0f) { + var refined = RefineHitTime(in hinge, in ball, previousTime, time); + var refinedDistance = Distance(in hinge, + ball.Position + ball.Velocity * refined, ball.Radius, refined); + return PopulateHit(ref collEvent, in hinge, in ball, in refinedDistance, refined); + } + previousTime = time; + } + return -1f; + } + + private float RefineHitTime(in SpringHingeState hinge, in BallState ball, float lower, float upper) + { + for (var i = 0; i < RefinementIterations; i++) { + var middle = (lower + upper) * 0.5f; + var distance = Distance(in hinge, ball.Position + ball.Velocity * middle, ball.Radius, middle); + if (distance.Separation > 0f) { + lower = middle; + } else { + upper = middle; + } + } + return upper; + } + + private float PopulateHit(ref CollisionEventData collEvent, in SpringHingeState hinge, + in BallState ball, in SpringHingeDistance distance, float time) + { + var angularVelocity = hinge.Movement.AngularVelocity; + var predictedAngle = hinge.Movement.Angle + angularVelocity * time; + if (predictedAngle <= hinge.Static.MinimumAngle && angularVelocity < 0f + || predictedAngle >= hinge.Static.MaximumAngle && angularVelocity > 0f) { + angularVelocity = 0f; + } + var surfaceVelocity = angularVelocity * math.cross(hinge.Static.Axis, + distance.Witness - hinge.Static.Pivot); + var normalVelocity = math.dot(ball.Velocity - surfaceVelocity, distance.Normal); + if (normalVelocity > PhysicsConstants.LowNormVel && distance.Separation > -PhysicsConstants.Embedded) { + return -1f; + } + collEvent.HitNormal = distance.Normal; + collEvent.HitDistance = distance.Separation; + collEvent.HitOrgNormalVelocity = normalVelocity; + collEvent.IsContact = math.abs(normalVelocity) <= PhysicsConstants.ContactVel + && distance.Separation <= PhysicsConstants.PhysTouch; + return time; + } + + private float CalculateMaxProxyRadius() + { + var maxRadiusSq = 0f; + for (var x = -1; x <= 1; x += 2) { + for (var y = -1; y <= 1; y += 2) { + for (var z = -1; z <= 1; z += 2) { + var corner = CentreArm + x * HalfExtents.x * ReferenceAxisX + + y * HalfExtents.y * ReferenceAxisY + z * HalfExtents.z * ReferenceAxisZ; + maxRadiusSq = math.max(maxRadiusSq, math.lengthsq(corner)); + } + } + } + return math.sqrt(maxRadiusSq); + } + + private static bool PushesIntoActiveStop(in SpringHingeMovementState movement, + float angularImpulseWithoutMagnitude) + => movement.ActiveStop != 0 && movement.ActiveStop * angularImpulseWithoutMagnitude > 0f; + + private static void ApplyAngularImpulse(ref SpringHingeState hinge, float angularImpulse) + { + if (hinge.Static.Inertia <= 0f || PushesIntoActiveStop(in hinge.Movement, angularImpulse)) { + return; + } + hinge.Movement.AngularVelocity += angularImpulse / hinge.Static.Inertia; + if (hinge.Movement.ActiveStop * hinge.Movement.AngularVelocity < -PhysicsConstants.Precision) { + hinge.Movement.ActiveStop = 0; + } + } + + private float GetFriction(ref PhysicsState state, float normalVelocity) + { + if (!Header.Material.UseFrictionOverVelocity) { + return Header.Material.Friction; + } + var lut = state.FrictionOverVelocityLUTs[Header.ItemId]; + return lut.InterpolateLUT(0, 127f, -normalVelocity); + } + + public override string ToString() + => $"SpringHingeCollider[{Header.ItemId}] owner {HingeOwnerId}"; + } + + internal readonly struct SpringHingeDistance + { + internal readonly float Separation; + internal readonly float3 Witness; + internal readonly float3 Normal; + + internal SpringHingeDistance(float separation, in float3 witness, in float3 normal) + { + Separation = separation; + Witness = witness; + Normal = normal; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs.meta new file mode 100644 index 000000000..f36f14463 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c3217c2b81ad4a94bfa5c2e0933a10a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs new file mode 100644 index 000000000..c834ffdde --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -0,0 +1,156 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(SpringHingeComponent))] + [PackAs("SpringHingeCollider")] + [AddComponentMenu("Pinball/Mechs/Spring Hinge Collider")] + public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent, IPackable + { + private SpringHingeComponent _hinge; + private Quaternion _initialLocalRotation; + private bool _poseCaptured; + + [Tooltip("Collision-box centre in VPX units along the hinge's local axes.")] + public Vector3 LocalCentre = new(0f, -50f, 0f); + + [Tooltip("Collision-box orientation in the hinge's local frame, in degrees.")] + public Vector3 LocalRotation; + + [Tooltip("Collision-box half-extents in its local frame, in VPX units.")] + public Vector3 HalfExtents = new(25f, 50f, 10f); + + [SerializeField] + [Tooltip("Show the analytic collision box in the Scene view.")] + public bool ShowColliderMesh; + + [Range(0f, 1f)] public float Elasticity = 0.1f; + [Min(0f)] public float ElasticityFalloff = 0.5f; + [Range(0f, 1f)] public float Friction = 0.3f; + [Tooltip("Emit a Hit event when the ball strikes the toy at a new position.")] + public bool HitEvent = true; + [Min(0f)] public float HitThreshold; + public bool OverwritePhysics = true; + public PhysicsMaterialAsset PhysicsMaterial; + + public byte[] Pack() => SpringHingeColliderPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => SpringHingeColliderReferencesPackable.PackReferences(this, files); + + public void Unpack(byte[] bytes) => SpringHingeColliderPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) + => SpringHingeColliderReferencesPackable.Unpack(data, this, files); + + private SpringHingeComponent Hinge + => _hinge ? _hinge : _hinge = GetComponent(); + + public int ItemId => Hinge.ItemId; + public bool IsKinematic => false; + public bool CollidersDirty { set { } } + internal bool IsCollidable => isActiveAndEnabled && math.all((float3)HalfExtents > 0f); + + public float PhysicsElasticity { get => Elasticity; set => Elasticity = value; } + public float PhysicsElasticityFalloff { get => ElasticityFalloff; set => ElasticityFalloff = value; } + public float PhysicsFriction { get => Friction; set => Friction = value; } + // Spring-hinge impacts deliberately exclude the legacy planar scatter heuristic. + public float PhysicsScatter { get => 0f; set { } } + public bool PhysicsOverwrite { get => OverwritePhysics; set => OverwritePhysics = value; } + public PhysicsMaterialAsset PhysicsMaterialReference { get => PhysicsMaterial; set => PhysicsMaterial = value; } + + private void Awake() + { + _hinge = GetComponent(); + CaptureInitialPose(); + } + + private void OnEnable() + { + if (Hinge) { + Hinge.OnAnimationValueChanged += ApplyAngle; + } + } + + private void OnDisable() + { + if (Hinge) { + Hinge.OnAnimationValueChanged -= ApplyAngle; + } + } + + private void OnValidate() + { + HalfExtents = Vector3.Max(HalfExtents, Vector3.zero); + } + + internal void CaptureInitialPose() + { + _initialLocalRotation = transform.localRotation; + _poseCaptured = true; + } + + internal void ApplyAngle(float angle) + { + if (!_poseCaptured) { + CaptureInitialPose(); + } + var axis = math.normalizesafe((float3)Hinge.HingeAxis, new float3(1f, 0f, 0f)); + transform.localRotation = _initialLocalRotation + * Quaternion.AngleAxis(math.degrees(angle), axis); + } + +#if UNITY_EDITOR + private void OnDrawGizmosSelected() + { + if (!ShowColliderMesh || !enabled) { + return; + } + var hinge = Hinge; + if (!hinge) { + return; + } + var angle = Application.isPlaying ? hinge.PublishedAngle : 0f; + var axis = math.normalizesafe((float3)hinge.HingeAxis, new float3(1f, 0f, 0f)); + var matrix = hinge.ReferenceLocalToWorldMatrix + * Matrix4x4.Rotate(Quaternion.AngleAxis(math.degrees(angle), axis)) + * Matrix4x4.TRS(LocalCentre * Physics.ScaleInv, + Quaternion.Euler(LocalRotation), Vector3.one); + var previousMatrix = Gizmos.matrix; + var previousColor = Gizmos.color; + Gizmos.matrix = matrix; + Gizmos.color = ColliderColor.TransformedColliderSelected; + Gizmos.DrawCube(Vector3.zero, HalfExtents * (2f * Physics.ScaleInv)); + Gizmos.color = new Color32(0, 255, 75, 230); + Gizmos.DrawWireCube(Vector3.zero, HalfExtents * (2f * Physics.ScaleInv)); + Gizmos.matrix = previousMatrix; + Gizmos.color = previousColor; + } +#endif + + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, + ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + { + if (!IsCollidable) { + return; + } + var hinge = Hinge; + var api = hinge.SpringHingeApi ?? new SpringHingeApi(hinge, physicsEngine); + ((IApiColliderGenerator)api).CreateColliders(ref colliders, float4x4.identity, margin); + } + + bool ICollidableComponent.IsCollidable => IsCollidable; + public float4x4 GetLocalToPlayfieldMatrixInVpx(float4x4 worldToPlayfield) => float4x4.identity; + public void OnTransformationChanged(float4x4 currTransformationMatrix) { } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs.meta new file mode 100644 index 000000000..f8189dca3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f55a88c40b194f8ba9df4aa4958a9b1a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs new file mode 100644 index 000000000..793b6d5ef --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs @@ -0,0 +1,55 @@ +// 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 +{ + internal static class SpringHingeColliderGenerator + { + private const float OrthogonalityTolerance = 1e-4f; + + internal static SpringHingeCollider Create(SpringHingeComponent hinge, + SpringHingeColliderComponent collider, ColliderInfo info, float margin) + { + var referenceMatrix = hinge.ReferenceLocalToWorldMatrix; + var pivot = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(Vector3.zero)); + var centre = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4( + collider.LocalCentre * Physics.ScaleInv)); + var localRotation = Quaternion.Euler(collider.LocalRotation); + var halfAxisX = hinge.ToPlayfieldVector(localRotation + * (Vector3.right * (collider.HalfExtents.x * Physics.ScaleInv))); + var halfAxisY = hinge.ToPlayfieldVector(localRotation + * (Vector3.up * (collider.HalfExtents.y * Physics.ScaleInv))); + var halfAxisZ = hinge.ToPlayfieldVector(localRotation + * (Vector3.forward * (collider.HalfExtents.z * Physics.ScaleInv))); + var lengthX = math.length(halfAxisX); + var lengthY = math.length(halfAxisY); + var lengthZ = math.length(halfAxisZ); + if (lengthX <= math.EPSILON || lengthY <= math.EPSILON || lengthZ <= math.EPSILON) { + throw new InvalidOperationException( + $"Spring hinge '{hinge.name}' collider has a degenerate transform or extent."); + } + var axisX = math.normalizesafe(halfAxisX, hinge.ToPlayfieldDirection(localRotation * Vector3.right)); + var axisY = math.normalizesafe(halfAxisY, hinge.ToPlayfieldDirection(localRotation * Vector3.up)); + var axisZ = math.normalizesafe(halfAxisZ, hinge.ToPlayfieldDirection(localRotation * Vector3.forward)); + if (math.abs(math.dot(axisX, axisY)) > OrthogonalityTolerance + || math.abs(math.dot(axisX, axisZ)) > OrthogonalityTolerance + || math.abs(math.dot(axisY, axisZ)) > OrthogonalityTolerance) { + throw new InvalidOperationException( + $"Spring hinge '{hinge.name}' collider transform is sheared. Use an orthogonal transform for the analytic box proxy."); + } + var halfExtents = new float3(lengthX, lengthY, lengthZ) + + math.max(0f, margin); + return new SpringHingeCollider(hinge.ItemId, in pivot, centre - pivot, in halfExtents, + in axisX, in axisY, in axisZ, info); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs.meta new file mode 100644 index 000000000..cb30a163e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0aaf1d482c194f8da83245e9cf113153 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs new file mode 100644 index 000000000..55c974f16 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.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 NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Engine.Game.Engines; +using VisualPinball.Unity.Collections; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [DisallowMultipleComponent] + [PackAs("SpringHinge")] + [AddComponentMenu("Pinball/Mechs/Spring Hinge")] + public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter, IPackable, ISwitchDeviceComponent + { + public const string AngleSwitchItem = "angle_switch"; + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Tooltip("Fixed hinge axis in this object's local frame.")] + public Vector3 HingeAxis = Vector3.right; + + [Tooltip("Unloaded toy centre of mass relative to the pivot, in VPX units along this object's local axes.")] + public Vector3 CentreOfMass = new(0f, -50f, 0f); + + [Min(0.001f)] + [Tooltip("Unloaded toy mass relative to VPE's standard ball mass.")] + public float ToyMass = 1f; + + [Tooltip("Use Manual Inertia instead of the box estimate.")] + public bool OverrideInertia = true; + + [Min(0.001f)] + [Tooltip("Moment of inertia about the hinge axis in ball-mass times VPX-unit squared.")] + public float ManualInertia = 2500f; + + [Tooltip("Half-extents of the box used to estimate unloaded toy inertia, in VPX units.")] + public Vector3 MassBoxHalfExtents = new(25f, 50f, 10f); + + [Min(0f)] + [Tooltip("Torsional spring stiffness in hinge simulation units.")] + public float SpringStiffness = 100f; + + [Min(0f)] + [Tooltip("Physical torsional damping coefficient in hinge simulation units.")] + public float SpringDamping = 5f; + + [Range(-180f, 180f)] + [Tooltip("Canonical spring equilibrium angle in degrees.")] + public float EquilibriumAngle; + + [Range(-180f, 180f)] + [Tooltip("Lower hard stop in degrees.")] + public float MinimumAngle = -30f; + + [Range(-180f, 180f)] + [Tooltip("Upper hard stop in degrees.")] + public float MaximumAngle = 30f; + + [Range(-180f, 180f)] + [Tooltip("Runtime angle at table start in degrees.")] + public float InitialAngle; + + [Tooltip("Expose a maintained switch that closes above Close Angle and opens below Open Angle.")] + public bool EnableAngleSwitch; + + [Range(-180f, 180f)] public float SwitchCloseAngle = 10f; + [Range(-180f, 180f)] public float SwitchOpenAngle = 5f; + + public SpringHingeApi SpringHingeApi { get; private set; } + public int ItemId => UnityObjectId.Get(gameObject); + internal float PublishedAngle => _animationValue; + + public event Action OnAnimationValueChanged; + + private PhysicsEngine _physicsEngine; + private float _animationValue; + private Matrix4x4 _referenceLocalMatrix; + private Quaternion _referenceLocalRotation; + private bool _referencePoseCaptured; + + public IEnumerable AvailableSwitches => EnableAngleSwitch + ? new[] { new GamelogicEngineSwitch(AngleSwitchItem) } + : Array.Empty(); + + public SwitchDefault SwitchDefault => SwitchDefault.NormallyOpen; + + IEnumerable IDeviceComponent.AvailableDeviceItems + => AvailableSwitches; + + public byte[] Pack() => SpringHingePackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => Array.Empty(); + + public void Unpack(byte[] bytes) => SpringHingePackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { } + + private void Awake() + { + CaptureReferencePose(); + var player = GetComponentInParent(); + if (!player) { + Logger.Error($"Cannot find player for spring hinge {name}."); + return; + } + + _physicsEngine = GetComponentInParent(); + SpringHingeApi = new SpringHingeApi(this, _physicsEngine); + player.Register(SpringHingeApi, this); + if (_physicsEngine) { + _physicsEngine.Register(this); + } else { + Logger.Error($"Cannot find physics engine for spring hinge {name}."); + } + } + + private void OnValidate() + { + if (math.lengthsq((float3)HingeAxis) < 1e-8f) { + HingeAxis = Vector3.right; + } + ToyMass = math.max(0.001f, ToyMass); + ManualInertia = math.max(0.001f, ManualInertia); + MassBoxHalfExtents = Vector3.Max(MassBoxHalfExtents, Vector3.zero); + SpringStiffness = math.max(0f, SpringStiffness); + SpringDamping = math.max(0f, SpringDamping); + if (MinimumAngle > MaximumAngle) { + (MinimumAngle, MaximumAngle) = (MaximumAngle, MinimumAngle); + } + if (SwitchOpenAngle > SwitchCloseAngle) { + (SwitchOpenAngle, SwitchCloseAngle) = (SwitchCloseAngle, SwitchOpenAngle); + } + InitialAngle = math.clamp(InitialAngle, MinimumAngle, MaximumAngle); + SyncPhysicsState(); + } + + internal SpringHingeState CreateState() + { + var pivot = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4(Vector3.zero)); + var axis = ToPlayfieldDirection(HingeAxis); + var centreOfMass = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4( + CentreOfMass * Physics.ScaleInv)); + var minimumAngle = math.radians(math.min(MinimumAngle, MaximumAngle)); + var maximumAngle = math.radians(math.max(MinimumAngle, MaximumAngle)); + var angle = math.clamp(math.radians(InitialAngle), minimumAngle, maximumAngle); + + var staticState = new SpringHingeStaticState { + OwnerId = ItemId, + Pivot = pivot, + Axis = axis, + CentreOfMassArm = centreOfMass - pivot, + Mass = ToyMass, + Inertia = OverrideInertia ? ManualInertia : EstimateInertia(axis), + EquilibriumAngle = math.radians(EquilibriumAngle), + Stiffness = SpringStiffness, + Damping = SpringDamping, + MinimumAngle = minimumAngle, + MaximumAngle = maximumAngle + }; + return new SpringHingeState(ItemId, staticState, new SpringHingeMovementState { + Angle = angle, + ActiveStop = angle <= minimumAngle ? (sbyte)-1 : angle >= maximumAngle ? (sbyte)1 : (sbyte)0 + }); + } + + public void UpdateAnimationValue(float angle) + { + if (math.abs(DeltaAngle(_animationValue, angle)) <= 0.0005f) { + return; + } + _animationValue = angle; + OnAnimationValueChanged?.Invoke(angle); + SpringHingeApi?.OnAngleChanged(angle); + } + + private static float DeltaAngle(float first, float second) + { + var delta = math.fmod(first - second + math.PI, math.TAU); + if (delta < 0f) { + delta += math.TAU; + } + return delta - math.PI; + } + + private float EstimateInertia(float3 axis) + { + var x = ToPlayfieldDirection(Vector3.right); + var y = ToPlayfieldDirection(Vector3.up); + var z = ToPlayfieldDirection(Vector3.forward); + var halfExtents = new float3( + MassBoxHalfExtents.x * math.abs(transform.lossyScale.x), + MassBoxHalfExtents.y * math.abs(transform.lossyScale.y), + MassBoxHalfExtents.z * math.abs(transform.lossyScale.z)); + var principal = ToyMass / 3f * new float3( + halfExtents.y * halfExtents.y + halfExtents.z * halfExtents.z, + halfExtents.x * halfExtents.x + halfExtents.z * halfExtents.z, + halfExtents.x * halfExtents.x + halfExtents.y * halfExtents.y); + var inertiaAtCentre = math.dot(principal, new float3( + math.pow(math.dot(axis, x), 2f), + math.pow(math.dot(axis, y), 2f), + math.pow(math.dot(axis, z), 2f))); + var referenceMatrix = ReferenceLocalToWorldMatrix; + var centreArm = ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(CentreOfMass * Physics.ScaleInv)) + - ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(Vector3.zero)); + var perpendicularArm = centreArm - axis * math.dot(axis, centreArm); + return math.max(0.001f, inertiaAtCentre + ToyMass * math.lengthsq(perpendicularArm)); + } + + internal float3 ToPlayfieldVpx(Vector3 worldPosition) + { + var playfield = GetComponentInParent(); + return playfield + ? (float3)worldPosition.TranslateToVpx(playfield.transform) + : (float3)worldPosition.TranslateToVpx(); + } + + internal float3 ToPlayfieldDirection(Vector3 localDirection) + { + var direction = ReferenceWorldRotation * localDirection.normalized; + var playfield = GetComponentInParent(); + if (playfield) { + direction = playfield.transform.InverseTransformDirection(direction); + } + return math.normalizesafe(Physics.WorldToVpx.MultiplyVector(direction), new float3(1f, 0f, 0f)); + } + + internal float3 ToPlayfieldVector(Vector3 localVector) + { + var vector = ReferenceLocalToWorldMatrix.MultiplyVector(localVector); + var playfield = GetComponentInParent(); + if (playfield) { + vector = playfield.transform.InverseTransformVector(vector); + } + return Physics.WorldToVpx.MultiplyVector(vector); + } + + internal Matrix4x4 ReferenceLocalToWorldMatrix { + get { + if (!Application.isPlaying || !_referencePoseCaptured) { + return transform.localToWorldMatrix; + } + var parentMatrix = transform.parent + ? transform.parent.localToWorldMatrix + : Matrix4x4.identity; + return parentMatrix * _referenceLocalMatrix; + } + } + + private Quaternion ReferenceWorldRotation { + get { + if (!Application.isPlaying || !_referencePoseCaptured) { + return transform.rotation; + } + return transform.parent + ? transform.parent.rotation * _referenceLocalRotation + : _referenceLocalRotation; + } + } + + private void CaptureReferencePose() + { + _referenceLocalMatrix = Matrix4x4.TRS(transform.localPosition, + transform.localRotation, transform.localScale); + _referenceLocalRotation = transform.localRotation; + _referencePoseCaptured = true; + } + + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = ItemId; + var synced = CreateState(); + var hasBakedCollider = GetComponent() != null; + var componentName = name; + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.SpringHingeStates.ContainsKey(itemId)) { + return; + } + ref var hinge = ref state.SpringHingeStates.GetValueByRef(itemId); + if (hasBakedCollider) { + var geometryChanged = math.distancesq(synced.Static.Pivot, hinge.Static.Pivot) > 1e-8f + || math.distancesq(synced.Static.Axis, hinge.Static.Axis) > 1e-8f; + if (geometryChanged) { + Logger.Warn($"Spring hinge {componentName} transform changes require a physics rebuild; keeping its baked collision frame for this play session."); + } + synced.Static.Pivot = hinge.Static.Pivot; + synced.Static.Axis = hinge.Static.Axis; + } + synced.Movement = hinge.Movement; + synced.Movement.Angle = math.clamp(synced.Movement.Angle, + synced.Static.MinimumAngle, synced.Static.MaximumAngle); + hinge = synced; + }); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs.meta new file mode 100644 index 000000000..818582303 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28897956128743c1ac7650f06fcae23a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs new file mode 100644 index 000000000..fe753469c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs @@ -0,0 +1,72 @@ +// 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; + +namespace VisualPinball.Unity +{ + internal static class SpringHingeDisplacementPhysics + { + private const float StopTolerance = 1e-6f; + + internal static float GetStopTime(in SpringHingeState state) + { + var angle = state.Movement.Angle; + var angularVelocity = state.Movement.AngularVelocity; + if (angularVelocity > 0f && angle < state.Static.MaximumAngle - StopTolerance) { + return (state.Static.MaximumAngle - angle) / angularVelocity; + } + if (angularVelocity < 0f && angle > state.Static.MinimumAngle + StopTolerance) { + return (state.Static.MinimumAngle - angle) / angularVelocity; + } + return -1f; + } + + internal static void UpdateDisplacement(ref SpringHingeState state, float step) + { + ref var movement = ref state.Movement; + movement.Angle += movement.AngularVelocity * step; + + if (movement.Angle > state.Static.MaximumAngle + || movement.AngularVelocity > 0f + && movement.Angle >= state.Static.MaximumAngle - StopTolerance) { + var movingOutward = movement.AngularVelocity > 0f; + movement.Angle = state.Static.MaximumAngle; + if (movingOutward) { + movement.AngularVelocity = 0f; + movement.ActiveStop = 1; + } else { + movement.ActiveStop = 0; + } + } else if (movement.Angle < state.Static.MinimumAngle + || movement.AngularVelocity < 0f + && movement.Angle <= state.Static.MinimumAngle + StopTolerance) { + var movingOutward = movement.AngularVelocity < 0f; + movement.Angle = state.Static.MinimumAngle; + if (movingOutward) { + movement.AngularVelocity = 0f; + movement.ActiveStop = -1; + } else { + movement.ActiveStop = 0; + } + } else if (movement.ActiveStop > 0 + && math.abs(movement.Angle - state.Static.MaximumAngle) <= StopTolerance + && math.abs(movement.AngularVelocity) <= StopTolerance) { + movement.Angle = state.Static.MaximumAngle; + } else if (movement.ActiveStop < 0 + && math.abs(movement.Angle - state.Static.MinimumAngle) <= StopTolerance + && math.abs(movement.AngularVelocity) <= StopTolerance) { + movement.Angle = state.Static.MinimumAngle; + } else { + movement.ActiveStop = 0; + } + + SpringHingeVelocityPhysics.RefreshContinuousAcceleration(ref state); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs.meta new file mode 100644 index 000000000..e6d745407 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0af0ffb3552944e9810884509a32855d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs new file mode 100644 index 000000000..37b131c45 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs @@ -0,0 +1,157 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity +{ + public struct SpringHingePackable + { + private const int CurrentVersion = 1; + + public int Version; + public PackableFloat3 HingeAxis; + public PackableFloat3 CentreOfMass; + public float ToyMass; + public bool OverrideInertia; + public float ManualInertia; + public PackableFloat3 MassBoxHalfExtents; + public float SpringStiffness; + public float SpringDamping; + public float EquilibriumAngle; + public float MinimumAngle; + public float MaximumAngle; + public float InitialAngle; + public bool EnableAngleSwitch; + public float SwitchCloseAngle; + public float SwitchOpenAngle; + + public static byte[] Pack(SpringHingeComponent comp) + { + return PackageApi.Packer.Pack(new SpringHingePackable { + Version = CurrentVersion, + HingeAxis = comp.HingeAxis, + CentreOfMass = comp.CentreOfMass, + ToyMass = comp.ToyMass, + OverrideInertia = comp.OverrideInertia, + ManualInertia = comp.ManualInertia, + MassBoxHalfExtents = comp.MassBoxHalfExtents, + SpringStiffness = comp.SpringStiffness, + SpringDamping = comp.SpringDamping, + EquilibriumAngle = comp.EquilibriumAngle, + MinimumAngle = comp.MinimumAngle, + MaximumAngle = comp.MaximumAngle, + InitialAngle = comp.InitialAngle, + EnableAngleSwitch = comp.EnableAngleSwitch, + SwitchCloseAngle = comp.SwitchCloseAngle, + SwitchOpenAngle = comp.SwitchOpenAngle + }); + } + + public static void Unpack(byte[] bytes, SpringHingeComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.HingeAxis = data.HingeAxis; + comp.CentreOfMass = data.CentreOfMass; + comp.ToyMass = data.ToyMass; + comp.OverrideInertia = data.OverrideInertia; + comp.ManualInertia = data.ManualInertia; + comp.MassBoxHalfExtents = data.MassBoxHalfExtents; + comp.SpringStiffness = data.SpringStiffness; + comp.SpringDamping = data.SpringDamping; + comp.EquilibriumAngle = data.EquilibriumAngle; + comp.MinimumAngle = data.MinimumAngle; + comp.MaximumAngle = data.MaximumAngle; + comp.InitialAngle = data.InitialAngle; + comp.EnableAngleSwitch = data.EnableAngleSwitch; + comp.SwitchCloseAngle = data.SwitchCloseAngle; + comp.SwitchOpenAngle = data.SwitchOpenAngle; + } + } + + public struct SpringHingeColliderPackable + { + private const int CurrentVersion = 2; + + public int Version; + public PackableFloat3 LocalCentre; + public PackableFloat3 LocalRotation; + public PackableFloat3 HalfExtents; + public bool ShowColliderMesh; + public float Elasticity; + public float ElasticityFalloff; + public float Friction; + public bool HitEvent; + public float HitThreshold; + public bool OverwritePhysics; + + public static byte[] Pack(SpringHingeColliderComponent comp) + { + return PackageApi.Packer.Pack(new SpringHingeColliderPackable { + Version = CurrentVersion, + LocalCentre = comp.LocalCentre, + LocalRotation = comp.LocalRotation, + HalfExtents = comp.HalfExtents, + ShowColliderMesh = comp.ShowColliderMesh, + Elasticity = comp.Elasticity, + ElasticityFalloff = comp.ElasticityFalloff, + Friction = comp.Friction, + HitEvent = comp.HitEvent, + HitThreshold = comp.HitThreshold, + OverwritePhysics = comp.OverwritePhysics + }); + } + + public static void Unpack(byte[] bytes, SpringHingeColliderComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.LocalCentre = data.LocalCentre; + comp.LocalRotation = data.LocalRotation; + comp.HalfExtents = data.HalfExtents; + comp.ShowColliderMesh = data.ShowColliderMesh; + comp.Elasticity = data.Elasticity; + comp.ElasticityFalloff = data.ElasticityFalloff; + comp.Friction = data.Friction; + comp.HitEvent = data.HitEvent; + comp.HitThreshold = data.HitThreshold; + comp.OverwritePhysics = data.OverwritePhysics; + } + } + + public struct SpringHingeColliderReferencesPackable + { + public PhysicalMaterialPackable PhysicalMaterial; + + public static byte[] PackReferences(SpringHingeColliderComponent comp, PackagedFiles files) + { + return PackageApi.Packer.Pack(new SpringHingeColliderReferencesPackable { + PhysicalMaterial = new PhysicalMaterialPackable { + Elasticity = comp.Elasticity, + ElasticityFalloff = comp.ElasticityFalloff, + Friction = comp.Friction, + Scatter = 0f, + Overwrite = comp.OverwritePhysics, + AssetRef = files.AddAsset(comp.PhysicsMaterial) + } + }); + } + + public static void Unpack(byte[] bytes, SpringHingeColliderComponent comp, + PackagedFiles files) + { + var data = PackageApi.Packer.Unpack(bytes); + var material = data.PhysicalMaterial; + comp.Elasticity = material.Elasticity; + comp.ElasticityFalloff = material.ElasticityFalloff; + comp.Friction = material.Friction; + comp.OverwritePhysics = material.Overwrite; + comp.PhysicsMaterial = files.GetAsset(material.AssetRef); + } + } + +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs.meta new file mode 100644 index 000000000..d1f896198 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36af43bc5fc74599b5c650d968531f40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs new file mode 100644 index 000000000..7812ccbf3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs @@ -0,0 +1,59 @@ +// 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; + +namespace VisualPinball.Unity +{ + internal struct SpringHingeState + { + internal readonly int AnimationItemId; + internal SpringHingeStaticState Static; + internal SpringHingeMovementState Movement; + + internal SpringHingeState(int animationItemId, SpringHingeStaticState @static, + SpringHingeMovementState movement) + { + AnimationItemId = animationItemId; + Static = @static; + Movement = movement; + } + } + + internal struct SpringHingeStaticState + { + internal int OwnerId; + internal float3 Pivot; + internal float3 Axis; + internal float3 CentreOfMassArm; + internal float Mass; + internal float Inertia; + internal float EquilibriumAngle; + internal float Stiffness; + internal float Damping; + internal float MinimumAngle; + internal float MaximumAngle; + } + + internal struct SpringHingeMovementState + { + internal float Angle; + internal float AngularVelocity; + internal float TickStartAngularVelocity; + internal float TickStartAngleError; + internal float3 EffectiveGravity; + internal float GravityTorque; + internal float PendingMagneticAngularImpulse; + internal float CommittedMagneticTorque; + internal float ContinuousAngularAcceleration; + internal float BlockedTorque; + internal float TickStep; + internal sbyte ActiveStop; + internal bool VelocityCommitted; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs.meta new file mode 100644 index 000000000..767957e5e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f323b93dad094cc4a4b6dd68fbe7ff98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs new file mode 100644 index 000000000..f3cc31e61 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.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 Unity.Mathematics; + +namespace VisualPinball.Unity +{ + internal static class SpringHingeVelocityPhysics + { + private const float StopTolerance = 1e-6f; + + internal static void UpdateVelocity(ref SpringHingeState state, in float3 effectiveGravity, + float step) + { + PrepareVelocity(ref state, in effectiveGravity, step); + CommitFreeVelocity(ref state); + } + + internal static void PrepareVelocity(ref SpringHingeState state, in float3 effectiveGravity, + float step) + { + ref var movement = ref state.Movement; + ref var data = ref state.Static; + + movement.TickStartAngularVelocity = movement.AngularVelocity; + movement.TickStartAngleError = movement.Angle - data.EquilibriumAngle; + movement.TickStep = step; + movement.PendingMagneticAngularImpulse = 0f; + movement.CommittedMagneticTorque = 0f; + movement.VelocityCommitted = false; + movement.EffectiveGravity = effectiveGravity; + movement.GravityTorque = CalculateGravityTorque(in data, movement.Angle, in effectiveGravity); + } + + internal static void CommitFreeVelocity(ref SpringHingeState state) + { + ref var movement = ref state.Movement; + ref var data = ref state.Static; + var step = movement.TickStep; + + var denominator = data.Inertia + step * data.Damping + step * step * data.Stiffness; + if (data.Inertia <= 0f || step <= 0f || denominator <= 0f || !math.isfinite(denominator)) { + // Degenerate authoring cannot convert a pending impulse into a finite torque. + // Drop it explicitly rather than leaving stale reaction state for diagnostics. + movement.PendingMagneticAngularImpulse = 0f; + movement.CommittedMagneticTorque = 0f; + ApplyStopConstraint(ref movement, in data); + movement.VelocityCommitted = true; + RefreshContinuousAcceleration(ref state); + return; + } + + var numerator = data.Inertia * movement.TickStartAngularVelocity + + step * movement.GravityTorque + + movement.PendingMagneticAngularImpulse + - step * data.Stiffness * movement.TickStartAngleError; + movement.AngularVelocity = numerator / denominator; + movement.CommittedMagneticTorque = step > 0f + ? movement.PendingMagneticAngularImpulse / step : 0f; + ApplyStopConstraint(ref movement, in data); + movement.VelocityCommitted = true; + RefreshContinuousAcceleration(ref state); + } + + internal static void RefreshContinuousAcceleration(ref SpringHingeState state) + { + ref var movement = ref state.Movement; + ref var data = ref state.Static; + if (data.Inertia <= 0f) { + movement.ContinuousAngularAcceleration = 0f; + movement.BlockedTorque = 0f; + return; + } + + var currentGravityTorque = CalculateGravityTorque(in data, movement.Angle, in movement.EffectiveGravity); + var torque = currentGravityTorque + movement.CommittedMagneticTorque + - data.Stiffness * (movement.Angle - data.EquilibriumAngle) + - data.Damping * movement.AngularVelocity; + if (movement.ActiveStop != 0 && movement.ActiveStop * torque > 0f + && math.abs(movement.AngularVelocity) <= StopTolerance) { + movement.BlockedTorque = torque; + movement.ContinuousAngularAcceleration = 0f; + } else { + movement.BlockedTorque = 0f; + movement.ContinuousAngularAcceleration = torque / data.Inertia; + } + } + + internal static float CalculateGravityTorque(in SpringHingeStaticState state, float angle, + in float3 effectiveGravity) + { + var arm = RotateAroundAxis(state.CentreOfMassArm, state.Axis, angle); + return math.dot(state.Axis, math.cross(arm, state.Mass * effectiveGravity)); + } + + internal static float3 RotateAroundAxis(in float3 vector, in float3 axis, float angle) + { + math.sincos(angle, out var sine, out var cosine); + return RotateAroundAxis(in vector, in axis, sine, cosine); + } + + internal static float3 RotateAroundAxis(in float3 vector, in float3 axis, float sine, float cosine) + { + return vector * cosine + math.cross(axis, vector) * sine + + axis * math.dot(axis, vector) * (1f - cosine); + } + + private static void ApplyStopConstraint(ref SpringHingeMovementState movement, + in SpringHingeStaticState data) + { + if (movement.Angle <= data.MinimumAngle + StopTolerance && movement.AngularVelocity < 0f) { + movement.Angle = data.MinimumAngle; + movement.AngularVelocity = 0f; + movement.ActiveStop = -1; + } else if (movement.Angle >= data.MaximumAngle - StopTolerance && movement.AngularVelocity > 0f) { + movement.Angle = data.MaximumAngle; + movement.AngularVelocity = 0f; + movement.ActiveStop = 1; + } else if (movement.ActiveStop < 0 && math.abs(movement.Angle - data.MinimumAngle) <= StopTolerance + && math.abs(movement.AngularVelocity) <= StopTolerance) { + movement.Angle = data.MinimumAngle; + } else if (movement.ActiveStop > 0 && math.abs(movement.Angle - data.MaximumAngle) <= StopTolerance + && math.abs(movement.AngularVelocity) <= StopTolerance) { + movement.Angle = data.MaximumAngle; + } else { + movement.ActiveStop = 0; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs.meta new file mode 100644 index 000000000..9d2789edb --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db92e480adab4ba58dd59ab385b80b67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs index 2e45727be..6517e8139 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Turntable/TurntablePhysics.cs @@ -45,6 +45,8 @@ internal static void Update(int itemId, ref TurntableState turntable, ref Physic if (ball.IsFrozen || !IsBallInRange(in ball, in turntable)) { continue; } + MagnetPhysics.ReleaseOwnedAttachmentForUnsupportedInteraction(ref state, + ref ball, itemId); ApplyVpxCompatibleForce(ref ball, in turntable, physicsDiffTime); } } diff --git a/package.json b/package.json index 12c59a0a6..3c50d179e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Main project of the Visual Pinball Engine.", "unity": "6000.5", "unityRelease": "0f1", - "dependencies": { + "dependencies": { "com.bartofzo.nativetrees": "0.1.11", "com.unity.cloud.draco": "5.1.8", "com.unity.cloud.gltfast": "6.10.1", @@ -17,9 +17,9 @@ "com.unity.nuget.newtonsoft-json": "3.2.1", "com.unity.splines": "2.8.2", "com.unity.ugui": "2.5.0", - "com.unity.test-framework": "1.7.0" - }, - "keywords": [ + "com.unity.test-framework": "1.7.0" + }, + "keywords": [ "Pinball", "Unity" ],