From 0673f3d67513a5afad1f37aa80bfc0f630378f8c Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 12:15:05 +0200 Subject: [PATCH 01/16] physics: establish spring hinge numerical contract --- ...spring-hinge-magnet-implementation-plan.md | 322 ++++++++++++++++++ .../SpringHingeNumericalFixtureTests.cs | 310 +++++++++++++++++ .../SpringHingeNumericalFixtureTests.cs.meta | 11 + .../Physics/SpringHingeNumericalFixtures.cs | 275 +++++++++++++++ .../SpringHingeNumericalFixtures.cs.meta | 11 + 5 files changed, 929 insertions(+) create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtures.cs.meta 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..dae4ce0f4 --- /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 a dedicated Play Mode fixture/demo scene with shot markers, weak/medium/strong launch controls, magnet off/on, timed release, reset, and diagnostic traces. It runs through the real Player/PhysicsEngine and keeps hardware output disabled in the fixture. An isolated editor preview context does not exist today and is deferred; scene gizmos and the test scene provide the first authoring workflow. + +## 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. + +Phase 0 is implemented by the pure numerical fixtures alongside this plan. Phases 1–7 remain gated by their tests and pre-commit reviews. + +## 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/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs new file mode 100644 index 000000000..d610b7549 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs @@ -0,0 +1,310 @@ +// 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)); + } + + [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: From 6b2c5f57a437df10769ddd374bf6317447123763 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 13:02:04 +0200 Subject: [PATCH 02/16] physics: add spring hinge runtime skeleton --- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../Physics/MagnetPhysicsTests.cs | 12 + .../Physics/SpringHingePhysicsTests.cs | 208 ++++++++++++++++ .../Physics/SpringHingePhysicsTests.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsCycle.cs | 36 ++- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 6 + .../Game/PhysicsEngineContext.cs | 3 + .../VisualPinball.Unity/Game/PhysicsState.cs | 25 +- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 21 +- .../VisualPinball.Unity/VPT/SpringHinge.meta | 8 + .../VPT/SpringHinge/SpringHingeApi.cs | 55 +++++ .../VPT/SpringHinge/SpringHingeApi.cs.meta | 11 + .../SpringHingeColliderComponent.cs | 41 ++++ .../SpringHingeColliderComponent.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeComponent.cs | 222 ++++++++++++++++++ .../SpringHinge/SpringHingeComponent.cs.meta | 11 + .../SpringHingeDisplacementPhysics.cs | 72 ++++++ .../SpringHingeDisplacementPhysics.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeState.cs | 57 +++++ .../VPT/SpringHinge/SpringHingeState.cs.meta | 11 + .../SpringHinge/SpringHingeVelocityPhysics.cs | 106 +++++++++ .../SpringHingeVelocityPhysics.cs.meta | 11 + 22 files changed, 935 insertions(+), 16 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeDisplacementPhysics.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs.meta 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 index dae4ce0f4..683680b1c 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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. -Phase 0 is implemented by the pure numerical fixtures alongside this plan. Phases 1–7 remain gated by their tests and pre-commit reviews. +Phases 0 and 1 are implemented by the numerical fixtures and runtime spring-hinge skeleton alongside this plan. Phases 2–7 remain gated by their tests and pre-commit reviews. ## 13. Acceptance and regression matrix diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index fa0f350b5..5b36c5526 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -1896,6 +1896,7 @@ internal sealed class PhysicsStateHarness : IDisposable internal NativeParallelHashMap Balls; internal NativeParallelHashMap KinematicTransforms; internal NativeParallelHashMap KinematicVelocities; + internal NativeParallelHashMap SpringHingeStates; internal InsideOfs InsideOfs; internal NativeQueue EventQueue; @@ -1929,6 +1930,11 @@ internal PhysicsStateHarness() Balls = new NativeParallelHashMap(4, Allocator.Persistent); KinematicTransforms = new NativeParallelHashMap(4, Allocator.Persistent); KinematicVelocities = 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); } @@ -1941,6 +1947,7 @@ internal PhysicsState CreateState() 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 SpringHingeStates, ref _surfaceStates, ref _turntableStates, ref _triggerStates, ref _disabledCollisionItems, ref _swapBallCollisionHandling, ref _elasticityLuts, ref _frictionLuts, ref KinematicVelocities); } @@ -1950,6 +1957,11 @@ public void Dispose() Balls.Dispose(); KinematicTransforms.Dispose(); KinematicVelocities.Dispose(); + SpringHingeStates.Dispose(); + _flipperStates.Dispose(); + _gateStates.Dispose(); + _plungerStates.Dispose(); + _spinnerStates.Dispose(); InsideOfs.Dispose(); EventQueue.Dispose(); } 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..12b2148e5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs @@ -0,0 +1,208 @@ +// 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)); + } + + 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/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index 1cbca0bdd..c78fde1e4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -50,8 +50,10 @@ 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; - ApplyFlipperTime(ref hitTime, ref state); + ApplyFlipperTime(ref hitTime, ref mechanismStopTime, ref state); + ApplySpringHingeTime(ref hitTime, ref mechanismStopTime, ref state); // clear contacts _contacts.Clear(); @@ -86,6 +88,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov ApplyStaticTime(ref hitTime, ref staticCounts, in ball); } } + ClampToMechanismStop(ref hitTime, mechanismStopTime); #region Displacement PerfMarkerDisplacement.Begin(); @@ -129,6 +132,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 @@ -291,7 +301,7 @@ private static void ApplyStaticTime(ref float hitTime, ref float staticCounts, i } } - private void ApplyFlipperTime(ref float hitTime, ref PhysicsState state) + private void ApplyFlipperTime(ref float hitTime, ref float mechanismStopTime, ref PhysicsState state) { // for each flipper using (var enumerator = state.FlipperStates.GetEnumerator()) { @@ -302,11 +312,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/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 4ce1ea7cf..374204130 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; 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/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index 43776bb74..b359b257a 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,7 +227,9 @@ 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 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)); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index cd4e96723..0a9e07cdf 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 @@ -147,6 +148,8 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ SpinnerVelocityPhysics.UpdateVelocities(ref spinnerState.Movement, in spinnerState.Static); } } + // spring hinges + UpdateSpringHingeVelocities(ref state, in env.Gravity, in cabinetAcceleration, physicsDiffTime); // magnets using (var enumerator = state.MagnetStates.GetEnumerator()) { while (enumerator.MoveNext()) { @@ -183,6 +186,18 @@ 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.UpdateVelocity(ref hingeState, in effectiveGravity, step); + } + } + /// /// Advances mechanical animations after physics substeps have completed. /// 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..3ccb5238e --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.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 VisualPinball.Unity.Collections; + +namespace VisualPinball.Unity +{ + public class SpringHingeApi : IApi + { + private readonly SpringHingeComponent _component; + private readonly PhysicsEngine _physicsEngine; + private readonly int _itemId; + + public event EventHandler Init; + + internal SpringHingeApi(SpringHingeComponent component, PhysicsEngine physicsEngine) + { + _component = component; + _physicsEngine = physicsEngine; + _itemId = component.ItemId; + } + + internal float Angle => _component.PublishedAngle; + + public void Reset(float angle) + { + if (!_physicsEngine) { + return; + } + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.SpringHingeStates.ContainsKey(_itemId)) { + return; + } + 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() + { + } + } +} 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/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs new file mode 100644 index 000000000..4af9b1ad9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -0,0 +1,41 @@ +// 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 +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(SpringHingeComponent))] + [AddComponentMenu("Pinball/Mechs/Spring Hinge Collider")] + public class SpringHingeColliderComponent : MonoBehaviour + { + [Unit("mm")] + [Tooltip("Collision-box centre in the hinge's local frame.")] + public Vector3 LocalCentre = new(0f, -50f, 0f); + + [Tooltip("Collision-box orientation in the hinge's local frame, in degrees.")] + public Vector3 LocalRotation; + + [Unit("mm")] + [Tooltip("Collision-box half-extents in its local frame.")] + public Vector3 HalfExtents = new(25f, 50f, 10f); + + [Range(0f, 1f)] public float Elasticity = 0.1f; + [Min(0f)] public float ElasticityFalloff = 0.5f; + [Range(0f, 1f)] public float Friction = 0.3f; + [Range(-90f, 90f)] public float Scatter; + public bool OverwritePhysics = true; + public PhysicsMaterialAsset PhysicsMaterial; + + private void OnValidate() + { + HalfExtents = Vector3.Max(HalfExtents, Vector3.zero); + } + } +} 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/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs new file mode 100644 index 000000000..b50df14c0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -0,0 +1,222 @@ +// 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 NLog; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Unity.Collections; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + [DisallowMultipleComponent] + [AddComponentMenu("Pinball/Mechs/Spring Hinge")] + public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter + { + private const float MillimetersToWorld = 0.001f; + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + [Tooltip("Fixed hinge axis in this object's local frame.")] + public Vector3 HingeAxis = Vector3.right; + + [Unit("mm")] + [Tooltip("Unloaded toy centre of mass relative to the pivot, in this object's local frame.")] + 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; + + [Unit("mm")] + [Tooltip("Half-extents of the box used to estimate unloaded toy inertia.")] + 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; + + 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 void Awake() + { + 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); + } + InitialAngle = math.clamp(InitialAngle, MinimumAngle, MaximumAngle); + SyncPhysicsState(); + } + + internal SpringHingeState CreateState() + { + var pivot = ToPlayfieldVpx(transform.position); + var axis = ToPlayfieldDirection(HingeAxis); + var centreOfMass = ToPlayfieldVpx(transform.TransformPoint(CentreOfMass * MillimetersToWorld)); + 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); + } + + 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( + Physics.ScaleToVpx(MassBoxHalfExtents.x * MillimetersToWorld * math.abs(transform.lossyScale.x)), + Physics.ScaleToVpx(MassBoxHalfExtents.y * MillimetersToWorld * math.abs(transform.lossyScale.y)), + Physics.ScaleToVpx(MassBoxHalfExtents.z * MillimetersToWorld * 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 centreArm = ToPlayfieldVpx(transform.TransformPoint(CentreOfMass * MillimetersToWorld)) + - ToPlayfieldVpx(transform.position); + var perpendicularArm = centreArm - axis * math.dot(axis, centreArm); + return math.max(0.001f, inertiaAtCentre + ToyMass * math.lengthsq(perpendicularArm)); + } + + private float3 ToPlayfieldVpx(Vector3 worldPosition) + { + var playfield = GetComponentInParent(); + return playfield + ? (float3)worldPosition.TranslateToVpx(playfield.transform) + : (float3)worldPosition.TranslateToVpx(); + } + + private float3 ToPlayfieldDirection(Vector3 localDirection) + { + var direction = transform.TransformDirection(localDirection.normalized); + var playfield = GetComponentInParent(); + if (playfield) { + direction = playfield.transform.InverseTransformDirection(direction); + } + return math.normalizesafe(Physics.WorldToVpx.MultiplyVector(direction), new float3(1f, 0f, 0f)); + } + + private void SyncPhysicsState() + { + if (!Application.isPlaying || !_physicsEngine) { + return; + } + + var itemId = ItemId; + var synced = CreateState(); + _physicsEngine.MutateState((ref PhysicsState state) => { + if (!state.SpringHingeStates.ContainsKey(itemId)) { + return; + } + ref var hinge = ref state.SpringHingeStates.GetValueByRef(itemId); + 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/SpringHingeState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs new file mode 100644 index 000000000..efc76d7dc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs @@ -0,0 +1,57 @@ +// 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 CommittedMagneticTorque; + internal float ContinuousAngularAcceleration; + internal float BlockedTorque; + internal float TickStep; + internal sbyte ActiveStop; + } +} 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..67395587b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs @@ -0,0 +1,106 @@ +// 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) + { + 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.CommittedMagneticTorque = 0f; + movement.EffectiveGravity = effectiveGravity; + movement.GravityTorque = CalculateGravityTorque(in data, movement.Angle, in effectiveGravity); + + var denominator = data.Inertia + step * data.Damping + step * step * data.Stiffness; + if (data.Inertia <= 0f || step <= 0f || denominator <= 0f || !math.isfinite(denominator)) { + ApplyStopConstraint(ref movement, in data); + RefreshContinuousAcceleration(ref state); + return; + } + + var numerator = data.Inertia * movement.TickStartAngularVelocity + + step * movement.GravityTorque + - step * data.Stiffness * movement.TickStartAngleError; + movement.AngularVelocity = numerator / denominator; + ApplyStopConstraint(ref movement, in data); + 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) + { + var sine = math.sin(angle); + var cosine = math.cos(angle); + 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: From 65825c144542aa2160ce445d58de6600b9bb8dbf Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 14:47:44 +0200 Subject: [PATCH 03/16] physics: add spring hinge box collider --- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../Physics/MagnetPhysicsTests.cs | 13 + .../Physics/SpringHingeColliderTests.cs | 407 +++++++++++++++++ .../Physics/SpringHingeColliderTests.cs.meta | 11 + .../Physics/SpringHingePhysicsTests.cs | 40 ++ .../VisualPinball.Unity/Game/PhysicsCycle.cs | 30 +- .../VisualPinball.Unity/Game/PhysicsState.cs | 18 +- .../Game/PhysicsStaticCollision.cs | 10 +- .../Physics/Collider/Collider.cs | 6 +- .../Physics/Collider/ColliderReference.cs | 23 + .../Physics/Collision/ColliderType.cs | 7 +- .../Physics/Collision/ContactPhysics.cs | 6 + .../Physics/NativeColliders.cs | 71 ++- .../VPT/SpringHinge/SpringHingeApi.cs | 59 ++- .../VPT/SpringHinge/SpringHingeCollider.cs | 410 ++++++++++++++++++ .../SpringHinge/SpringHingeCollider.cs.meta | 11 + .../SpringHingeColliderComponent.cs | 32 +- .../SpringHingeColliderGenerator.cs | 55 +++ .../SpringHingeColliderGenerator.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeComponent.cs | 25 +- .../SpringHinge/SpringHingeVelocityPhysics.cs | 8 +- 21 files changed, 1212 insertions(+), 43 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs.meta 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 index 683680b1c..c18fde66c 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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 and 1 are implemented by the numerical fixtures and runtime spring-hinge skeleton alongside this plan. Phases 2–7 remain gated by their tests and pre-commit reviews. +Phases 0–2 are implemented by the numerical fixtures, runtime spring-hinge skeleton, and specialized analytic collider alongside this plan. Phases 3–7 remain gated by their tests and pre-commit reviews. ## 13. Acceptance and regression matrix diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 5b36c5526..44a8e9627 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -1903,6 +1903,7 @@ internal sealed class PhysicsStateHarness : IDisposable private PhysicsEnv _env; private NativeOctree _octree; private NativeColliders _colliders; + private bool _ownsColliders; private NativeColliders _kinematicColliders; private NativeColliders _kinematicCollidersAtIdentity; private NativeParallelHashMap _kinematicTargetTransforms; @@ -1952,6 +1953,15 @@ internal PhysicsState CreateState() 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(); @@ -1964,6 +1974,9 @@ public void Dispose() _spinnerStates.Dispose(); InsideOfs.Dispose(); EventQueue.Dispose(); + if (_ownsColliders) { + _colliders.Dispose(); + } } } } 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..710b9651a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs @@ -0,0 +1,407 @@ +// 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 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 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/SpringHingePhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs index 12b2148e5..c2a1b78e0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePhysicsTests.cs @@ -187,6 +187,46 @@ public void StaticProgressRuleCannotOverrunSelectedMechanismStop() 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 { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index c78fde1e4..f21bb3aa3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -51,6 +51,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov var hitTime = dTime; // begin time search from now ... until delta ends var mechanismStopTime = -1f; + var springHingeHitTime = -1f; ApplyFlipperTime(ref hitTime, ref mechanismStopTime, ref state); ApplySpringHingeTime(ref hitTime, ref mechanismStopTime, ref state); @@ -75,6 +76,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) { @@ -89,6 +91,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov } } ClampToMechanismStop(ref hitTime, mechanismStopTime); + ClampToSpringHingeHit(ref hitTime, springHingeHitTime); #region Displacement PerfMarkerDisplacement.Begin(); @@ -286,7 +289,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; @@ -301,6 +304,31 @@ private static void ApplyStaticTime(ref float hitTime, ref float staticCounts, i } } + 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 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs index b359b257a..a5d2edc04 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs @@ -229,9 +229,10 @@ internal void DisableColliders(int itemId) { 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 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)); @@ -448,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..8d25d765c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -111,14 +111,20 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r in collHeader, in bumperState.Static, ref state.InsideOfs, bumperState.IsSwitchWiredToCoil); break; - case ColliderType.Flipper: + case ColliderType.Flipper: 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); 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..58f013312 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs @@ -82,6 +82,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/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/SpringHinge/SpringHingeApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs index 3ccb5238e..941a1d60f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs @@ -8,15 +8,17 @@ using System; using Unity.Mathematics; +using VisualPinball.Engine.VPT; using VisualPinball.Unity.Collections; namespace VisualPinball.Unity { - public class SpringHingeApi : IApi + public class SpringHingeApi : IApi, IApiColliderGenerator { private readonly SpringHingeComponent _component; private readonly PhysicsEngine _physicsEngine; private readonly int _itemId; + private readonly SpringHingeColliderComponent _colliderComponent; public event EventHandler Init; @@ -25,6 +27,7 @@ internal SpringHingeApi(SpringHingeComponent component, PhysicsEngine physicsEng _component = component; _physicsEngine = physicsEngine; _itemId = component.ItemId; + _colliderComponent = component.GetComponent(); } internal float Angle => _component.PublishedAngle; @@ -51,5 +54,59 @@ public void Reset(float angle) void IApi.OnDestroy() { } + + 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, + Material = material + }; + } } } 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..a3416696f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs @@ -0,0 +1,410 @@ +// 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, in distance, + maxTime, rateBound); + } + + 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); + 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, in SpringHingeDistance startDistance, + float maxTime, float rateBound) + { + var previousTime = startTime; + var previous = startDistance; + 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); + } + var interval = time - previousTime; + if (math.min(previous.Separation, distance.Separation) <= rateBound * interval) { + var speculativeTime = math.min(maxTime, math.max(previousTime, TimeEpsilon)); + var speculativeDistance = Distance(in hinge, + ball.Position + ball.Velocity * speculativeTime, ball.Radius, speculativeTime); + var hit = PopulateHit(ref collEvent, in hinge, in ball, + in speculativeDistance, speculativeTime); + if (hit >= 0f) { + return hit; + } + } + previousTime = time; + previous = distance; + } + 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 index 4af9b1ad9..f2ab54ebc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -6,6 +6,7 @@ // 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 @@ -13,7 +14,7 @@ namespace VisualPinball.Unity [DisallowMultipleComponent] [RequireComponent(typeof(SpringHingeComponent))] [AddComponentMenu("Pinball/Mechs/Spring Hinge Collider")] - public class SpringHingeColliderComponent : MonoBehaviour + public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent { [Unit("mm")] [Tooltip("Collision-box centre in the hinge's local frame.")] @@ -29,13 +30,40 @@ public class SpringHingeColliderComponent : MonoBehaviour [Range(0f, 1f)] public float Elasticity = 0.1f; [Min(0f)] public float ElasticityFalloff = 0.5f; [Range(0f, 1f)] public float Friction = 0.3f; - [Range(-90f, 90f)] public float Scatter; public bool OverwritePhysics = true; public PhysicsMaterialAsset PhysicsMaterial; + public int ItemId => GetComponent().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 OnValidate() { HalfExtents = Vector3.Max(HalfExtents, Vector3.zero); } + + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, + ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) + { + if (!IsCollidable) { + return; + } + var hinge = GetComponent(); + 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/SpringHingeColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs new file mode 100644 index 000000000..a48bb6a32 --- /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 MillimetersToWorld = 0.001f; + private const float OrthogonalityTolerance = 1e-4f; + + internal static SpringHingeCollider Create(SpringHingeComponent hinge, + SpringHingeColliderComponent collider, ColliderInfo info, float margin) + { + var pivot = hinge.ToPlayfieldVpx(hinge.transform.position); + var centre = hinge.ToPlayfieldVpx(hinge.transform.TransformPoint( + collider.LocalCentre * MillimetersToWorld)); + var localRotation = Quaternion.Euler(collider.LocalRotation); + var halfAxisX = hinge.ToPlayfieldVector(localRotation + * (Vector3.right * (collider.HalfExtents.x * MillimetersToWorld))); + var halfAxisY = hinge.ToPlayfieldVector(localRotation + * (Vector3.up * (collider.HalfExtents.y * MillimetersToWorld))); + var halfAxisZ = hinge.ToPlayfieldVector(localRotation + * (Vector3.forward * (collider.HalfExtents.z * MillimetersToWorld))); + 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 index b50df14c0..c95c0bc5e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -181,7 +181,7 @@ private float EstimateInertia(float3 axis) return math.max(0.001f, inertiaAtCentre + ToyMass * math.lengthsq(perpendicularArm)); } - private float3 ToPlayfieldVpx(Vector3 worldPosition) + internal float3 ToPlayfieldVpx(Vector3 worldPosition) { var playfield = GetComponentInParent(); return playfield @@ -189,7 +189,7 @@ private float3 ToPlayfieldVpx(Vector3 worldPosition) : (float3)worldPosition.TranslateToVpx(); } - private float3 ToPlayfieldDirection(Vector3 localDirection) + internal float3 ToPlayfieldDirection(Vector3 localDirection) { var direction = transform.TransformDirection(localDirection.normalized); var playfield = GetComponentInParent(); @@ -199,6 +199,16 @@ private float3 ToPlayfieldDirection(Vector3 localDirection) return math.normalizesafe(Physics.WorldToVpx.MultiplyVector(direction), new float3(1f, 0f, 0f)); } + internal float3 ToPlayfieldVector(Vector3 localVector) + { + var vector = transform.TransformVector(localVector); + var playfield = GetComponentInParent(); + if (playfield) { + vector = playfield.transform.InverseTransformVector(vector); + } + return Physics.WorldToVpx.MultiplyVector(vector); + } + private void SyncPhysicsState() { if (!Application.isPlaying || !_physicsEngine) { @@ -207,11 +217,22 @@ private void SyncPhysicsState() 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs index 67395587b..b9171d728 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs @@ -75,8 +75,12 @@ internal static float CalculateGravityTorque(in SpringHingeStaticState state, fl internal static float3 RotateAroundAxis(in float3 vector, in float3 axis, float angle) { - var sine = math.sin(angle); - var cosine = math.cos(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); } From 32675607fc11db1b83ed755e1d5bbefad9637064 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 15:51:23 +0200 Subject: [PATCH 04/16] physics: add reciprocal hinge magnet --- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../Physics/MagnetPhysicsTests.cs | 6 +- .../Physics/OwnedMagnetPhysicsTests.cs | 508 ++++++++++++++++++ .../Physics/OwnedMagnetPhysicsTests.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsEngine.cs | 4 + .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 43 +- .../VisualPinball.Unity/VPT/Ball/BallState.cs | 3 +- .../VPT/Magnet/MagnetComponent.cs | 50 ++ .../VPT/Magnet/MagnetPhysics.cs | 71 ++- .../VPT/Magnet/MagnetState.cs | 12 + .../VPT/Magnet/OwnedMagnetPhysics.cs | 490 +++++++++++++++++ .../VPT/Magnet/OwnedMagnetPhysics.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeApi.cs | 1 + .../VPT/SpringHinge/SpringHingeState.cs | 2 + .../SpringHinge/SpringHingeVelocityPhysics.cs | 21 + 15 files changed, 1219 insertions(+), 16 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs.meta 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 index c18fde66c..d6ca23f8c 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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–2 are implemented by the numerical fixtures, runtime spring-hinge skeleton, and specialized analytic collider alongside this plan. Phases 3–7 remain gated by their tests and pre-commit reviews. +Phases 0–3 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, and reciprocal owned-magnet coupling alongside this plan. Phases 4–7 remain gated by their tests and pre-commit reviews. ## 13. Acceptance and regression matrix diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs index 44a8e9627..68a732cd6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs @@ -1896,6 +1896,7 @@ 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; @@ -1915,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; @@ -1931,6 +1931,7 @@ 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); @@ -1947,7 +1948,7 @@ 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); @@ -1967,6 +1968,7 @@ public void Dispose() Balls.Dispose(); KinematicTransforms.Dispose(); KinematicVelocities.Dispose(); + MagnetStates.Dispose(); SpringHingeStates.Dispose(); _flipperStates.Dispose(); _gateStates.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..039e130f5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs @@ -0,0 +1,508 @@ +// 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.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 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); + } + + [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(); + } + } + + 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 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/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 374204130..c2fe055e6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -829,6 +829,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); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index 0a9e07cdf..b338c91bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -150,13 +150,7 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ } // spring hinges UpdateSpringHingeVelocities(ref state, in env.Gravity, in cabinetAcceleration, physicsDiffTime); - // 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); - } - } + UpdateMagnetsAndCommitSpringHinges(ref state, physicsDiffTime); // turntables using (var enumerator = state.TurntableStates.GetEnumerator()) { while (enumerator.MoveNext()) { @@ -194,7 +188,40 @@ internal static void UpdateSpringHingeVelocities(ref PhysicsState state, in floa using var enumerator = state.SpringHingeStates.GetEnumerator(); while (enumerator.MoveNext()) { ref var hingeState = ref enumerator.Current.Value; - SpringHingeVelocityPhysics.UpdateVelocity(ref hingeState, in effectiveGravity, step); + 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); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 26c6571b3..86b0480d0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -57,7 +57,8 @@ public struct BallState public float3x3 BallOrientationForUnity; public float Radius; public float Mass; - public bool IsFrozen; + public bool IsFrozen; + internal int AttachedMagnetId; public int RingCounterOldPos; public bool ManualControl; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 063785656..c49cd5c34 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -116,6 +116,25 @@ 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 Physical magnet reciprocally to its nearest parent spring hinge.")] + public bool CoupleToParentHinge; + + [Unit("mm")] + [Tooltip("Held ball centre relative to the magnet transform, expressed in the hinge-local frame at rest.")] + 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 +216,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 +227,20 @@ 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 + && ForceProfile == MagnetForceProfile.Physical; + if (CoupleToParentHinge && !validOwnedMode) { + Logger.Error($"Magnet {name} can couple only as a Spatial Physical child of a spring hinge."); + } + 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(transform.TransformPoint( + HeldBallCentreOffset * 0.001f)) - pivot; + } return new MagnetState { Position = pos.xy, Height = pos.z, @@ -229,6 +265,14 @@ internal MagnetState CreateState() Profile = ForceProfile, 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,12 +296,18 @@ 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); + } 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; }); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 86765b9b8..8d5725a4e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -42,6 +42,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 +161,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 +178,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 +210,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 +221,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); } @@ -462,6 +487,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 +738,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 +748,25 @@ 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) { + 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); } - 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 +778,30 @@ 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) + { + 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); + } + + 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 +819,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..3d1328c23 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs @@ -0,0 +1,490 @@ +// 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) + || !CanCapture(in attached, in magnet, in hinge, in pole, in target)) { + 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); + 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) + { + if (ball.Mass <= MinimumValue || hinge.Static.Inertia <= MinimumValue + || magnet.Radius <= MinimumValue || magnet.GrabRadius <= 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 delta = ball.Position - pole; + var distanceSq = math.lengthsq(delta); + if (distanceSq <= MinimumValue || distanceSq >= magnet.Radius * magnet.Radius) { + return false; + } + var distance = math.sqrt(distanceSq); + var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); + var fieldForce = MagnetPhysics.PhysicalForceMagnitude(distance, 0f, cutoff, in magnet) + * ball.Mass; + var holdForce = math.max(0f, magnet.MaxHoldForce) + * magnet.EffectiveCurrent * magnet.EffectiveCurrent; + var availableWork = math.min(fieldForce, holdForce) + * math.max(0f, magnet.GrabRadius - math.distance(ball.Position, target)); + 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 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; + return math.abs(targetGap) <= PhysicsConstants.PhysTouch + && currentGap >= -PhysicsConstants.Embedded; + } + 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/SpringHinge/SpringHingeApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs index 941a1d60f..b1fcf40e9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs @@ -41,6 +41,7 @@ public void Reset(float angle) 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs index efc76d7dc..7812ccbf3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeState.cs @@ -48,10 +48,12 @@ internal struct SpringHingeMovementState 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/SpringHingeVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs index b9171d728..1a1fa8088 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs @@ -16,6 +16,13 @@ internal static class SpringHingeVelocityPhysics 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; @@ -23,22 +30,36 @@ internal static void UpdateVelocity(ref SpringHingeState state, in float3 effect 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)) { 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); } From 8595de7be9937283a3edfee0d38eb27ec14e6bf5 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 16:38:52 +0200 Subject: [PATCH 05/16] physics: qualify hinge magnet integration --- VisualPinball.Engine/Game/EventId.cs | 3 + ...spring-hinge-magnet-implementation-plan.md | 2 +- .../Physics/OwnedMagnetPhysicsTests.cs | 150 ++++++++++++++++++ .../Physics/SpringHingeIntegrationTests.cs | 145 +++++++++++++++++ .../SpringHingeIntegrationTests.cs.meta | 11 ++ .../SpringHingeNumericalFixtureTests.cs | 3 +- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 21 ++- .../Game/PhysicsDynamicBroadPhase.cs | 45 +++++- .../Game/PhysicsStaticCollision.cs | 41 +++-- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 5 +- .../VisualPinball.Unity/Game/Player.cs | 4 + .../Physics/Collision/ContactPhysics.cs | 6 +- .../Physics/Event/EventData.cs | 24 ++- .../VisualPinball.Unity/VPT/Ball/BallState.cs | 3 +- .../VPT/Magnet/MagnetComponent.cs | 21 +++ .../VPT/Magnet/MagnetPhysics.cs | 41 ++++- .../VPT/Magnet/OwnedMagnetPhysics.cs | 18 ++- .../VPT/SpringHinge/SpringHingeApi.cs | 12 +- .../VPT/SpringHinge/SpringHingeCollider.cs | 4 +- .../SpringHingeColliderComponent.cs | 3 + .../SpringHinge/SpringHingeVelocityPhysics.cs | 4 + .../VPT/Turntable/TurntablePhysics.cs | 2 + 22 files changed, 530 insertions(+), 38 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs.meta 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~/developer-guide/spring-hinge-magnet-implementation-plan.md b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md index d6ca23f8c..412117c54 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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–3 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, and reciprocal owned-magnet coupling alongside this plan. Phases 4–7 remain gated by their tests and pre-commit reviews. +Phases 0–4 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, and integration qualification alongside this plan. Phases 5–7 remain gated by their tests and pre-commit reviews. ## 13. Acceptance and regression matrix diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs index 039e130f5..86310e188 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using Unity.Collections; using Unity.Mathematics; +using VisualPinball.Engine.Common; using VisualPinball.Engine.Game; using VisualPinball.Unity.Collections; @@ -372,6 +373,124 @@ public void SustainedSeparatingCapSaturationReleasesOnce() } } + [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 { @@ -479,6 +598,37 @@ private static PhysicsStateHarness CreateAttachedHarness(out ColliderReference r 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; 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 index d610b7549..74c01c89c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeNumericalFixtureTests.cs @@ -122,7 +122,8 @@ public void ZeroHoldDoesNotDoubleCommitFreeHingeStep(float holdStiffness, float var result = SpringHingeNumericalFixtures.SolveHold(input); Assert.That(result.Impulse, Is.EqualTo(float3.zero)); - Assert.That(result.HingeAngularVelocity, Is.EqualTo(expected.AngularVelocity)); + Assert.That(result.HingeAngularVelocity, + Is.EqualTo(expected.AngularVelocity).Within(1e-6f)); } [Test] diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index f21bb3aa3..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) @@ -183,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; } @@ -196,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++) { 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/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 8d25d765c..0b53a4577 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -105,14 +105,16 @@ 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); + 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, @@ -132,15 +134,17 @@ 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; @@ -153,8 +157,9 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r 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, @@ -168,9 +173,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 b338c91bb..fc62815ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -86,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. 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/Collision/ContactPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collision/ContactPhysics.cs index 58f013312..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); 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/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs index 86b0480d0..cdcd58668 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs @@ -59,7 +59,8 @@ public struct BallState public float Mass; public bool IsFrozen; internal int AttachedMagnetId; - public int RingCounterOldPos; + internal Aabb DynamicBroadPhaseAabb; + public int RingCounterOldPos; public bool ManualControl; public float2 ManualPosition; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index c49cd5c34..8eba8d57d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -233,6 +233,20 @@ internal MagnetState CreateState() if (CoupleToParentHinge && !validOwnedMode) { Logger.Error($"Magnet {name} can couple only as a Spatial Physical 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 + && ownedMagnets[i].ForceProfile == MagnetForceProfile.Physical) { + 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) { @@ -300,6 +314,13 @@ private void SyncPhysicsState() || !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; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 8d5725a4e..551f34052 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs @@ -748,7 +748,8 @@ private static float3 GetKinematicVelocity(int itemId, in MagnetState magnet, re return state.GetKinematicVelocityAt(itemId, Center3D(in magnet)); } - internal 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; @@ -763,7 +764,7 @@ internal static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, ref 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); } internal static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int bitIndex, int ballId, ref PhysicsState state, bool suppressRegrab) @@ -778,7 +779,8 @@ internal static void ReleaseGrabbedBall(int itemId, ref MagnetState magnet, int state.EventQueue.Enqueue(new EventData(EventId.MagnetEventsBallReleased, itemId, ballId, true)); } - internal static void ReleaseOwnedAttachmentForBall(ref PhysicsState state, ref BallState ball) + internal static void ReleaseOwnedAttachmentForBall(ref PhysicsState state, ref BallState ball, + bool suppressRegrab = false) { var magnetId = ball.AttachedMagnetId; if (magnetId == 0 || !state.MagnetStates.ContainsKey(magnetId)) { @@ -786,7 +788,38 @@ internal static void ReleaseOwnedAttachmentForBall(ref PhysicsState state, ref B return; } ref var magnet = ref state.MagnetStates.GetValueByRef(magnetId); - ReleaseGrabbedBall(magnetId, ref magnet, ref state, ball.Id); + 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) diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs index 3d1328c23..9cd202360 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs @@ -68,7 +68,8 @@ private static void PrepareAttachmentsAndCandidates(ref PhysicsState state) || !MagnetPhysics.HasActiveField(in magnet) || math.distancesq(attached.Position, target) > releaseDistance * releaseDistance || !HasValidProxyGap(in attached, in hinge, in target, ref state) - || !CanCapture(in attached, in magnet, in hinge, in pole, in target)) { + || !CanCaptureWithin(in attached, in magnet, in hinge, in pole, in target, + releaseDistance)) { ReleaseAttachment(itemId, ref magnet, ref state, true); } } @@ -352,9 +353,14 @@ internal static bool SolveHold(ref BallState ball, ref SpringHingeState hinge, 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); + + 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 || magnet.GrabRadius <= 0f + || 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; @@ -371,7 +377,7 @@ internal static bool CanCapture(in BallState ball, in MagnetState magnet, var holdForce = math.max(0f, magnet.MaxHoldForce) * magnet.EffectiveCurrent * magnet.EffectiveCurrent; var availableWork = math.min(fieldForce, holdForce) - * math.max(0f, magnet.GrabRadius - math.distance(ball.Position, target)); + * math.max(0f, workRadius - math.distance(ball.Position, target)); if (availableWork <= 0f) { return false; } @@ -403,8 +409,10 @@ private static bool HasValidProxyGap(in BallState ball, in SpringHingeState hing } var targetGap = collider.Distance(in hinge, in target, ball.Radius).Separation; var currentGap = collider.Distance(in hinge, ball.Position, ball.Radius).Separation; - return math.abs(targetGap) <= PhysicsConstants.PhysTouch - && currentGap >= -PhysicsConstants.Embedded; + if (math.abs(targetGap) <= PhysicsConstants.PhysTouch + && currentGap >= -PhysicsConstants.Embedded) { + return true; + } } return false; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs index b1fcf40e9..39ad292e5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs @@ -13,7 +13,7 @@ namespace VisualPinball.Unity { - public class SpringHingeApi : IApi, IApiColliderGenerator + public class SpringHingeApi : IApi, IApiColliderGenerator, IApiHittable { private readonly SpringHingeComponent _component; private readonly PhysicsEngine _physicsEngine; @@ -21,6 +21,7 @@ public class SpringHingeApi : IApi, IApiColliderGenerator private readonly SpringHingeColliderComponent _colliderComponent; public event EventHandler Init; + public event EventHandler Hit; internal SpringHingeApi(SpringHingeComponent component, PhysicsEngine physicsEngine) { @@ -56,6 +57,13 @@ 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, @@ -106,6 +114,8 @@ private ColliderInfo GetColliderInfo(ItemType itemType) return new ColliderInfo { ItemId = _itemId, ItemType = itemType, + FireEvents = _colliderComponent.HitEvent, + HitThreshold = _colliderComponent.HitThreshold, Material = material }; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs index a3416696f..1d18aec17 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs @@ -206,7 +206,9 @@ internal void Collide(ref BallState ball, ref SpringHingeState hinge, ApplyAngularImpulse(ref hinge, -frictionImpulse * hingeTangentArm); } SpringHingeVelocityPhysics.RefreshContinuousAcceleration(ref hinge); - Collider.FireHitEvent(ref ball, ref state.EventQueue, in Header); + if (-normalVelocity >= Header.Threshold) { + Collider.FireHitEvent(ref ball, ref state.EventQueue, in Header); + } } internal void Contact(ref BallState ball, ref SpringHingeState hinge, diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs index f2ab54ebc..fe910b66e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -30,6 +30,9 @@ public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent [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; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs index 1a1fa8088..f3cc31e61 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeVelocityPhysics.cs @@ -45,6 +45,10 @@ internal static void CommitFreeVelocity(ref SpringHingeState state) 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); 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); } } From 4fee3d119562a3d813618cbe0326c27b65ebbe66 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 17:20:05 +0200 Subject: [PATCH 06/16] physics: persist and publish spring hinge state --- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../Physics/SpringHingePackagingTests.cs | 237 ++++++++++++++++++ .../Physics/SpringHingePackagingTests.cs.meta | 11 + .../Game/PhysicsEngineThreading.cs | 48 +++- .../Game/PhysicsMovements.cs | 11 + .../VPT/Magnet/MagnetComponent.cs | 5 +- .../VPT/Magnet/MagnetPackable.cs | 19 +- .../VPT/SpringHinge/SpringHingeApi.cs | 27 +- .../SpringHingeColliderComponent.cs | 13 +- .../VPT/SpringHinge/SpringHingeComponent.cs | 34 ++- .../VPT/SpringHinge/SpringHingePackable.cs | 151 +++++++++++ .../SpringHinge/SpringHingePackable.cs.meta | 11 + 12 files changed, 561 insertions(+), 8 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePackagingTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs.meta 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 index 412117c54..cfa8846e2 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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–4 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, and integration qualification alongside this plan. Phases 5–7 remain gated by their tests and pre-commit reviews. +Phases 0–5 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, integration qualification, and coherent render/package reconstruction alongside this plan. Phases 6–7 remain gated by their tests and pre-commit reviews. ## 13. Acceptance and regression matrix 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/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index ecf245dc5..0a9fa8d22 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 { @@ -512,17 +540,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 +630,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) { @@ -930,6 +973,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/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/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 8eba8d57d..fbe7efe4b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -335,7 +335,10 @@ private void SyncPhysicsState() public int ItemId => UnityObjectId.Get(gameObject); - bool IKinematicTransformComponent.IsKinematic => IsKinematic; + bool IKinematicTransformComponent.IsKinematic => IsKinematic && !(CoupleToParentHinge + && MagnetType == VisualPinball.Unity.MagnetType.Spatial + && ForceProfile == MagnetForceProfile.Physical + && 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 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/SpringHinge/SpringHingeApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs index 39ad292e5..9453a5b8d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeApi.cs @@ -13,12 +13,14 @@ namespace VisualPinball.Unity { - public class SpringHingeApi : IApi, IApiColliderGenerator, IApiHittable + 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; @@ -29,10 +31,33 @@ internal SpringHingeApi(SpringHingeComponent component, PhysicsEngine physicsEng _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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs index fe910b66e..a67e3472c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -13,8 +13,9 @@ namespace VisualPinball.Unity { [DisallowMultipleComponent] [RequireComponent(typeof(SpringHingeComponent))] + [PackAs("SpringHingeCollider")] [AddComponentMenu("Pinball/Mechs/Spring Hinge Collider")] - public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent + public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent, IPackable { [Unit("mm")] [Tooltip("Collision-box centre in the hinge's local frame.")] @@ -36,6 +37,16 @@ public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent 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); + public int ItemId => GetComponent().ItemId; public bool IsKinematic => false; public bool CollidersDirty { set { } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs index c95c0bc5e..a49470b3d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -7,19 +7,23 @@ // (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 + public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter, IPackable, ISwitchDeviceComponent { private const float MillimetersToWorld = 0.001f; + public const string AngleSwitchItem = "angle_switch"; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); [Tooltip("Fixed hinge axis in this object's local frame.")] @@ -68,6 +72,12 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter [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; @@ -77,6 +87,24 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter private PhysicsEngine _physicsEngine; private float _animationValue; + 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() { var player = GetComponentInParent(); @@ -108,6 +136,9 @@ private void OnValidate() if (MinimumAngle > MaximumAngle) { (MinimumAngle, MaximumAngle) = (MaximumAngle, MinimumAngle); } + if (SwitchOpenAngle > SwitchCloseAngle) { + (SwitchOpenAngle, SwitchCloseAngle) = (SwitchCloseAngle, SwitchOpenAngle); + } InitialAngle = math.clamp(InitialAngle, MinimumAngle, MaximumAngle); SyncPhysicsState(); } @@ -147,6 +178,7 @@ public void UpdateAnimationValue(float angle) } _animationValue = angle; OnAnimationValueChanged?.Invoke(angle); + SpringHingeApi?.OnAngleChanged(angle); } private static float DeltaAngle(float first, float second) 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..352492c7b --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs @@ -0,0 +1,151 @@ +// 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. + +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 = 1; + + public int Version; + public PackableFloat3 LocalCentre; + public PackableFloat3 LocalRotation; + public PackableFloat3 HalfExtents; + 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, + 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.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: From fc3407db518c81f63a6fa8a01acf5c52f5fe9c44 Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 17:58:43 +0200 Subject: [PATCH 07/16] editor: add spring hinge authoring workflow --- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../VPT/Magnet/MagnetInspector.cs | 55 +++ .../VPT/SpringHinge.meta | 8 + .../SpringHingeAnimationInspector.cs | 34 ++ .../SpringHingeAnimationInspector.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeAuthoring.cs | 329 ++++++++++++++++++ .../SpringHinge/SpringHingeAuthoring.cs.meta | 11 + .../SpringHingeColliderInspector.cs | 123 +++++++ .../SpringHingeColliderInspector.cs.meta | 11 + .../VPT/SpringHinge/SpringHingeInspector.cs | 163 +++++++++ .../SpringHinge/SpringHingeInspector.cs.meta | 11 + .../VPT/SpringHinge.meta | 8 + .../SpringHinge/SpringHingeAuthoringTests.cs | 173 +++++++++ .../SpringHingeAuthoringTests.cs.meta | 11 + .../VPT/Magnet/MagnetComponent.cs | 9 +- .../SpringHingeAnimationComponent.cs | 69 ++++ .../SpringHingeAnimationComponent.cs.meta | 11 + .../VPT/SpringHinge/SpringHingePackable.cs | 50 +++ 18 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta 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 index cfa8846e2..0b894510e 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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–5 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, integration qualification, and coherent render/package reconstruction alongside this plan. Phases 6–7 remain gated by their tests and pre-commit reviews. +Phases 0–6 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, integration qualification, coherent render/package reconstruction, and the authoring workflow alongside this plan. Phase 7 remains gated by its tests and pre-commit review. ## 13. Acceptance and regression matrix diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index f13f2cde7..24f9a2264 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -40,6 +40,11 @@ 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; protected override MonoBehaviour UndoTarget => target as MonoBehaviour; @@ -65,6 +70,11 @@ 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)); } public override void OnInspectorGUI() @@ -127,6 +137,20 @@ 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, + _forceProfileProperty.enumValueIndex == (int)MagnetForceProfile.Physical); + } + EditorGUILayout.Space(8f); PropertyField(_isEnabledOnStartProperty); // kinematic registration is fixed at startup; toggling during play would silently do nothing @@ -139,6 +163,37 @@ public override void OnInspectorGUI() EndEditing(); } + private void DrawOwnedModeValidation(bool isSpatial, bool usesOwnedPhysicalResponse) + { + 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 || !usesOwnedPhysicalResponse) { + EditorGUILayout.HelpBox("Owned mode requires Spatial type and Physical response.", MessageType.Error); + if (GUILayout.Button("Use Spatial Physical Mode")) { + _magnetTypeProperty.enumValueIndex = (int)MagnetType.Spatial; + _forceProfileProperty.enumValueIndex = (int)MagnetForceProfile.Physical; + } + } + 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); + } + } + } + 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/SpringHinge.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge.meta new file mode 100644 index 000000000..7437ca564 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 832288c006ce482ab27e28576e4ae8d8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs new file mode 100644 index 000000000..f2305e4a0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs @@ -0,0 +1,34 @@ +// 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; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(SpringHingeAnimationComponent)), CanEditMultipleObjects] + public class SpringHingeAnimationInspector : UnityEditor.Editor + { + private SerializedProperty _emitter; + private SerializedProperty _rotationAxis; + + private void OnEnable() + { + _emitter = serializedObject.FindProperty(nameof(SpringHingeAnimationComponent._emitter)); + _rotationAxis = serializedObject.FindProperty(nameof(SpringHingeAnimationComponent.RotationAxis)); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + EditorGUILayout.PropertyField(_emitter); + EditorGUILayout.PropertyField(_rotationAxis); + serializedObject.ApplyModifiedProperties(); + EditorGUILayout.HelpBox("Keep this moving transform below the fixed spring-hinge pivot. Put the visual toy and any owned magnet below this transform.", MessageType.Info); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta new file mode 100644 index 000000000..e5f7f3584 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d17bcc4c402479b8eadba0fdc0b2499 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs new file mode 100644 index 000000000..13354d98f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs @@ -0,0 +1,329 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + public static class SpringHingeAuthoring + { + private const float WorldToMillimeters = 1000f; + private const float StandardBallRadiusMillimeters = 25f; + + [MenuItem("GameObject/Pinball/Add Spring Hinge", false, 12)] + private static void AddSpringHingeMenu(MenuCommand command) + { + var selected = Selection.transforms; + var root = AddSpringHinge(selected, Selection.activeTransform); + Selection.activeGameObject = root; + } + + [MenuItem("GameObject/Pinball/Add Spring Hinge", true)] + private static bool ValidateAddSpringHingeMenu() + => 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 movingPart = new GameObject("Moving Part"); + movingPart.transform.SetParent(root.transform, false); + var animation = Undo.AddComponent(movingPart); + animation._emitter = hinge; + animation.RotationAxis = hinge.HingeAxis; + + var visual = GameObject.CreatePrimitive(PrimitiveType.Cube); + visual.name = "Toy Visual"; + visual.transform.SetParent(movingPart.transform, false); + visual.transform.localPosition = new Vector3(0f, -0.05f, 0f); + visual.transform.localScale = new Vector3(0.05f, 0.1f, 0.02f); + var unityCollider = visual.GetComponent(); + if (unityCollider) { + UnityEngine.Object.DestroyImmediate(unityCollider); + } + + var magnetObject = new GameObject("Owned Magnet"); + magnetObject.transform.SetParent(movingPart.transform, false); + magnetObject.transform.localPosition = new Vector3(0f, -0.1f, 0f); + 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 root = new GameObject("Spring Hinge"); + Undo.RegisterCreatedObjectUndo(root, "Add Spring Hinge"); + var parent = activeVisual.parent; + if (parent) { + root.transform.SetParent(parent, false); + } + root.transform.SetPositionAndRotation(activeVisual.position, activeVisual.rotation); + + var hinge = Undo.AddComponent(root); + var proxy = Undo.AddComponent(root); + var movingPart = new GameObject("Moving Part"); + movingPart.transform.SetParent(root.transform, false); + var animation = Undo.AddComponent(movingPart); + animation._emitter = hinge; + animation.RotationAxis = hinge.HingeAxis; + + foreach (var visualPart in visualParts) { + if (!visualPart || visualPart == root.transform || IsAncestorSelected(visualPart, visualParts)) { + continue; + } + Undo.SetTransformParent(visualPart, movingPart.transform, "Add Visual To Spring Hinge"); + DisableIndependentColliders(visualPart); + } + + var ownedMagnets = movingPart.GetComponentsInChildren(true); + var magnet = ownedMagnets.Length == 1 ? ownedMagnets[0] : null; + ApplyBashPreset(hinge, proxy, magnet); + FitFromVisuals(hinge, proxy); + EditorUtility.SetDirty(root); + return root; + } + + 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.PoleRadius = MagnetComponent.DefaultPoleRadius; + magnet.GrabBall = true; + magnet.GrabRadius = MagnetComponent.DefaultGrabRadius; + magnet.CoupleToParentHinge = true; + magnet.HeldBallCentreOffset = Vector3.down * StandardBallRadiusMillimeters; + magnet.HoldStiffness = 2f; + magnet.HoldDamping = 2f; + magnet.MaxHoldForce = 10f; + magnet.IsKinematic = false; + } + + public static bool TryGetVisualBounds(SpringHingeComponent hinge, + out Vector3 centreMillimeters, out Vector3 halfExtentsMillimeters) + { + centreMillimeters = Vector3.zero; + halfExtentsMillimeters = 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; + } + centreMillimeters = (minimum + maximum) * (0.5f * WorldToMillimeters); + halfExtentsMillimeters = (maximum - minimum) * (0.5f * WorldToMillimeters); + 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."); + } + + var drivers = hinge.GetComponentsInChildren(true); + var driverCount = 0; + foreach (var driver in drivers) { + if (driver._emitter == hinge) { + driverCount++; + } + } + if (driverCount != 1) { + issues.Add("The visual hierarchy must have exactly one Spring Hinge Transform driven by this hinge."); + } + 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 || magnet.ForceProfile != MagnetForceProfile.Physical) { + issues.Add($"Owned magnet '{magnet.name}' must use Spatial type and Physical response."); + } + if (proxy && IsHeldCentreInsideProxy(hinge, proxy, magnet)) { + issues.Add($"Owned magnet '{magnet.name}' has its held ball centre inside the analytic box proxy."); + } + } + if (ownedCount > 1) { + issues.Add("Only one owned magnet is supported per spring hinge."); + } + return issues; + } + + private static bool IsHeldCentreInsideProxy(SpringHingeComponent hinge, + SpringHingeColliderComponent proxy, MagnetComponent magnet) + { + var world = magnet.transform.TransformPoint(magnet.HeldBallCentreOffset * 0.001f); + var hingeLocalMillimeters = hinge.transform.InverseTransformPoint(world) * WorldToMillimeters; + var boxLocal = Quaternion.Inverse(Quaternion.Euler(proxy.LocalRotation)) + * (hingeLocalMillimeters - proxy.LocalCentre); + return Mathf.Abs(boxLocal.x) < proxy.HalfExtents.x + && Mathf.Abs(boxLocal.y) < proxy.HalfExtents.y + && Mathf.Abs(boxLocal.z) < proxy.HalfExtents.z; + } + + 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..e2fdf9367 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs @@ -0,0 +1,123 @@ +// 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 _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)); + _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() + { + BeginEditing(); + EditorGUILayout.LabelField("Analytic Box", EditorStyles.boldLabel); + PropertyField(_localCentre, updateColliders: true); + PropertyField(_localRotation, updateColliders: true); + PropertyField(_halfExtents, updateColliders: true); + 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 (targets.Length != 1 || target is not SpringHingeColliderComponent proxy) { + return; + } + var hinge = proxy.GetComponent(); + if (!hinge) { + return; + } + + var centre = hinge.transform.TransformPoint(proxy.LocalCentre * 0.001f); + var rotation = hinge.transform.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 * 0.001f, centre, rotation, handleSize); + if (EditorGUI.EndChangeCheck()) { + Undo.RecordObject(proxy, "Edit Spring Hinge Proxy"); + proxy.LocalCentre = hinge.transform.InverseTransformPoint(movedCentre) * 1000f; + proxy.HalfExtents = Vector3.Max(resized * 1000f, Vector3.one * 0.001f); + proxy.CollidersDirty = true; + EditorUtility.SetDirty(proxy); + } + + var matrix = hinge.transform.localToWorldMatrix + * Matrix4x4.TRS(proxy.LocalCentre * 0.001f, + Quaternion.Euler(proxy.LocalRotation), Vector3.one); + using (new Handles.DrawingScope(new Color(0f, 1f, 1f, 0.8f), matrix)) { + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * 0.002f); + } + 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 * 0.001f, + Quaternion.Euler(proxy.LocalRotation), Vector3.one); + using (new Handles.DrawingScope(color, matrix)) { + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * 0.002f); + } + } + } +} 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..e04e7a6dd --- /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 Child 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 child 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 (targets.Length != 1 || 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 * 0.001f); + 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) * 1000f; + 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/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..00703c67f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -0,0 +1,173 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Unity.Mathematics; +using UnityEngine; +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Test +{ + public class SpringHingeAuthoringTests + { + [Test] + public void BashSetupCreatesCompleteOwnedVisualHierarchy() + { + var root = SpringHingeAuthoring.CreateBashToy(); + try { + var hinge = root.GetComponent(); + var proxy = root.GetComponent(); + var animation = root.GetComponentInChildren(); + var magnet = root.GetComponentInChildren(); + + Assert.That(hinge, Is.Not.Null); + Assert.That(proxy, Is.Not.Null); + Assert.That(animation, Is.Not.Null); + Assert.That(animation._emitter, Is.SameAs(hinge)); + 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.GetComponentInParent(), Is.SameAs(animation)); + Assert.That(root.GetComponentInChildren(), Is.Null); + Assert.That(SpringHingeAuthoring.Validate(hinge, proxy), Is.Empty); + } finally { + Object.DestroyImmediate(root); + } + } + + [Test] + public void VisualBoundsFitMassAndProxyInMillimeters() + { + 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.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.GetComponentInParent(), 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 TransformFollowerAppliesRadiansAndRoundTripsReferences() + { + var root = new GameObject("Spring Hinge"); + var moving = new GameObject("Moving Part"); + try { + moving.transform.SetParent(root.transform, false); + var hinge = root.AddComponent(); + moving.transform.localRotation = Quaternion.Euler(0f, 12f, 0f); + var animation = moving.AddComponent(); + animation._emitter = hinge; + animation.RotationAxis = Vector3.forward; + animation.CaptureInitialPose(); + + animation.ApplyAngle(math.PI / 2f); + var expected = Quaternion.Euler(0f, 12f, 0f) + * Quaternion.AngleAxis(90f, Vector3.forward); + Assert.That(Quaternion.Angle(moving.transform.localRotation, expected), + Is.LessThan(0.001f)); + + var refs = new PackagedRefs(root.transform); + refs.SetNodeIdsForWrite(new Dictionary { + { root.transform, "hinge" }, { moving.transform, "moving" } + }); + var values = animation.Pack(); + var references = animation.PackReferences(root.transform, refs, null); + animation.RotationAxis = Vector3.right; + animation._emitter = null; + animation.Unpack(values); + refs.SetNodeIdsForRead(new Dictionary { + { "hinge", root.transform }, { "moving", moving.transform } + }); + animation.UnpackReferences(references, root.transform, refs, null); + + Assert.That(animation.RotationAxis, Is.EqualTo(Vector3.forward)); + Assert.That(animation._emitter, Is.SameAs(hinge)); + } 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/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index fbe7efe4b..59a075e9e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -120,7 +120,7 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic public bool CoupleToParentHinge; [Unit("mm")] - [Tooltip("Held ball centre relative to the magnet transform, expressed in the hinge-local frame at rest.")] + [Tooltip("Held ball centre relative to the magnet transform, expressed in millimeters at the authored rest pose.")] public Vector3 HeldBallCentreOffset; [Min(0f)] @@ -491,6 +491,13 @@ private void OnDrawGizmosSelected() } } + if (CoupleToParentHinge) { + var heldCentre = transform.TransformPoint(HeldBallCentreOffset * 0.001f); + Gizmos.color = new Color(0.2f, 1f, 0.45f, 0.9f); + Gizmos.DrawLine(transform.position, heldCentre); + Gizmos.DrawWireSphere(heldCentre, 0.006f); + } + if (MagnetType != VisualPinball.Unity.MagnetType.Cylindrical && (MagnetType == VisualPinball.Unity.MagnetType.Spatial || ForceProfile == MagnetForceProfile.Physical) && PoleRadius > 0f) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs new file mode 100644 index 000000000..d7b8652f2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs @@ -0,0 +1,69 @@ +// 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] + [PackAs("SpringHingeAnimation")] + [AddComponentMenu("Pinball/Animation/Spring Hinge Transform")] + public class SpringHingeAnimationComponent : AnimationComponent, IPackable + { + [Tooltip("Rotation axis in this moving transform's local frame.")] + public Vector3 RotationAxis = Vector3.right; + + private Quaternion _initialLocalRotation; + private bool _poseCaptured; + + public byte[] Pack() => SpringHingeAnimationPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) + => SpringHingeAnimationReferencesPackable.Pack(this, refs); + + public void Unpack(byte[] bytes) => SpringHingeAnimationPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) + => SpringHingeAnimationReferencesPackable.Unpack(bytes, this, refs); + + protected override void Awake() + { + CaptureInitialPose(); + base.Awake(); + } + + protected override void OnAnimationValueChanged(float angle) => ApplyAngle(angle); + + internal void CaptureInitialPose() + { + _initialLocalRotation = transform.localRotation; + _poseCaptured = true; + } + + internal void ApplyAngle(float angle) + { + if (!_poseCaptured) { + CaptureInitialPose(); + } + var axis = math.normalizesafe((float3)RotationAxis, new float3(1f, 0f, 0f)); + transform.localRotation = _initialLocalRotation + * Quaternion.AngleAxis(math.degrees(angle), axis); + } + +#if UNITY_EDITOR + protected override void OnValidate() + { + base.OnValidate(); + if (math.lengthsq((float3)RotationAxis) < 1e-8f) { + RotationAxis = Vector3.right; + } + } +#endif + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta new file mode 100644 index 000000000..73b319806 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 750ff807a0f248119bd2784ff516ff38 +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 index 352492c7b..564583a1d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs @@ -6,6 +6,8 @@ // 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 @@ -148,4 +150,52 @@ public static void Unpack(byte[] bytes, SpringHingeColliderComponent comp, comp.PhysicsMaterial = files.GetAsset(material.AssetRef); } } + + public struct SpringHingeAnimationPackable + { + private const int CurrentVersion = 1; + + public int Version; + public PackableFloat3 RotationAxis; + + public static byte[] Pack(SpringHingeAnimationComponent comp) + { + return PackageApi.Packer.Pack(new SpringHingeAnimationPackable { + Version = CurrentVersion, + RotationAxis = comp.RotationAxis + }); + } + + public static void Unpack(byte[] bytes, SpringHingeAnimationComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.RotationAxis = data.RotationAxis; + } + } + + public struct SpringHingeAnimationReferencesPackable + { + public ReferencePackable EmitterRef; + + public static byte[] Pack(SpringHingeAnimationComponent comp, PackagedRefs refs) + { + var emitterRef = new ReferencePackable(null, null); + if (comp._emitter != null) { + if (refs.HasType(comp._emitter.GetType())) { + emitterRef = refs.PackReference(comp._emitter); + } else { + Debug.LogWarning($"Cannot package spring-hinge animation emitter {comp._emitter.GetType().FullName} on '{comp.name}' because it has no PackAs attribute; writing a null reference.", comp); + } + } + return PackageApi.Packer.Pack(new SpringHingeAnimationReferencesPackable { + EmitterRef = emitterRef + }); + } + + public static void Unpack(byte[] bytes, SpringHingeAnimationComponent comp, PackagedRefs refs) + { + var data = PackageApi.Packer.Unpack(bytes); + comp._emitter = refs.Resolve>(data.EmitterRef); + } + } } From 64b11c82aec73212c29e2e21137d962912ce54fa Mon Sep 17 00:00:00 2001 From: freezy Date: Mon, 7 Sep 2026 18:56:32 +0200 Subject: [PATCH 08/16] docs: qualify spring hinge bash toy --- CHANGELOG.md | 1 - Samples~/SpringHingeBashToy.meta | 8 + Samples~/SpringHingeBashToy/README.md | 12 + Samples~/SpringHingeBashToy/README.md.meta | 7 + Samples~/SpringHingeBashToy/Scripts.meta | 8 + .../Scripts/SpringHingeBashToyController.cs | 233 ++++++++++++++++++ .../SpringHingeBashToyController.cs.meta | 11 + ...ll.Unity.Samples.SpringHingeBashToy.asmdef | 18 ++ ...ity.Samples.SpringHingeBashToy.asmdef.meta | 7 + .../manual/mechanisms/spring-hinges.md | 54 ++++ .../Documentation~/creators-guide/toc.yml | 6 +- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../spring-hinge-qualification.md | 26 ++ .../Documentation~/developer-guide/toc.yml | 2 + .../SpringHingePlayModeFixtureTests.cs | 145 +++++++++++ .../SpringHingePlayModeFixtureTests.cs.meta | 11 + .../Physics/SpringHingeQualificationTests.cs | 204 +++++++++++++++ .../SpringHingeQualificationTests.cs.meta | 11 + .../VisualPinball.Unity.Test.asmdef | 5 +- package.json | 15 +- 20 files changed, 776 insertions(+), 10 deletions(-) create mode 100644 Samples~/SpringHingeBashToy.meta create mode 100644 Samples~/SpringHingeBashToy/README.md create mode 100644 Samples~/SpringHingeBashToy/README.md.meta create mode 100644 Samples~/SpringHingeBashToy/Scripts.meta create mode 100644 Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs create mode 100644 Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta create mode 100644 Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef create mode 100644 Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta create mode 100644 VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeQualificationTests.cs.meta 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/Samples~/SpringHingeBashToy.meta b/Samples~/SpringHingeBashToy.meta new file mode 100644 index 000000000..320bbf349 --- /dev/null +++ b/Samples~/SpringHingeBashToy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f377832f786242aba085f2dba20b30fc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/README.md b/Samples~/SpringHingeBashToy/README.md new file mode 100644 index 000000000..5b464d282 --- /dev/null +++ b/Samples~/SpringHingeBashToy/README.md @@ -0,0 +1,12 @@ +# Spring Hinge Bash Toy + +This sample adds Play Mode shot controls and diagnostic traces to a spring-hinge magnet toy in a real VPE table. It drives the local `Player`, `PhysicsEngine`, `SpringHingeApi`, and `MagnetApi`; it does not create or send hardware outputs. + +1. Import **Spring Hinge Bash Toy** from Package Manager. +2. In a table scene, create the toy with **GameObject > Pinball > Spring Hinge Bash Toy**, then position its pivot and use the scene handles to fit the visual and analytic box. +3. Add `SpringHingeBashToyController` beneath the table, assign its Player, Spring Hinge, Owned Magnet, and a shot marker. Point the marker's forward axis toward the toy. Assigning a ball prefab is optional. +4. Enter Play Mode. Use **1**, **2**, and **3** for weak, medium, and strong shots; **M** to toggle the magnet; **T** to schedule a release; and **R** to release the ball, reset the hinge, and remove balls created by the sample. The optional on-screen panel exposes the same controls. + +Start with the preset and move the magnet to the intended collision face. The held-ball-centre marker should sit one ball radius outside the analytic box. Raise magnetic strength and holding capacity together only when a strong shot should capture; a large influence radius does not make the attachment stiffer. + +The diagnostic trace reports shot creation, hinge angle, impact, capture, release, and reset events. Use it with the Physics diagnostics in the editor to distinguish a magnetic release from the release-before-legacy-active-mechanism fallback. diff --git a/Samples~/SpringHingeBashToy/README.md.meta b/Samples~/SpringHingeBashToy/README.md.meta new file mode 100644 index 000000000..d7d6a2a43 --- /dev/null +++ b/Samples~/SpringHingeBashToy/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f0179e617b914bf6b59f07ff27a2bdc0 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts.meta b/Samples~/SpringHingeBashToy/Scripts.meta new file mode 100644 index 000000000..254352e1e --- /dev/null +++ b/Samples~/SpringHingeBashToy/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6826df62fa44dcda1d6668d27f781e7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs new file mode 100644 index 000000000..565fae397 --- /dev/null +++ b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs @@ -0,0 +1,233 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +using System; +using System.Collections; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace VisualPinball.Unity.Samples.SpringHingeBashToy +{ + [DisallowMultipleComponent] + [AddComponentMenu("Pinball/Samples/Spring Hinge Bash Toy Controller")] + public sealed class SpringHingeBashToyController : MonoBehaviour + { + [Header("Fixture")] + public Player Player; + public SpringHingeComponent SpringHinge; + public MagnetComponent OwnedMagnet; + [Tooltip("Place on the playfield and point its forward axis toward the toy.")] + public Transform ShotMarker; + public GameObject BallPrefab; + + [Header("Shots")] + [Min(0f)] public float WeakShotSpeed = 8f; + [Min(0f)] public float MediumShotSpeed = 18f; + [Min(0f)] public float StrongShotSpeed = 30f; + [Min(0f)] public float ReleaseDelaySeconds = 1.5f; + + [Header("Diagnostics")] + public bool ShowControls = true; + public bool TraceEvents = true; + + private readonly List _spawnedBalls = new(); + private Coroutine _timedRelease; + private string _lastTrace = "Ready"; + private bool _subscribed; + + private void Awake() + { + Player = Player ? Player : GetComponentInParent(); + SpringHinge = SpringHinge ? SpringHinge : GetComponentInChildren(true); + OwnedMagnet = OwnedMagnet ? OwnedMagnet : GetComponentInChildren(true); + } + + private void Start() + { + if (!IsReady) { + Debug.LogError("Spring Hinge Bash Toy sample needs a Player, Spring Hinge, Owned Magnet, and Shot Marker.", this); + return; + } + Player.OnBallCreated += OnBallCreated; + Player.OnBallDestroyed += OnBallDestroyed; + SpringHinge.OnAnimationValueChanged += OnAngleChanged; + SpringHinge.SpringHingeApi.Hit += OnHingeHit; + OwnedMagnet.MagnetApi.BallGrabbed += OnBallGrabbed; + OwnedMagnet.MagnetApi.BallReleased += OnBallReleased; + _subscribed = true; + Trace("fixture started; hardware output is disabled"); + } + + private void OnDestroy() + { + if (!_subscribed) { + return; + } + if (Player) { + Player.OnBallCreated -= OnBallCreated; + Player.OnBallDestroyed -= OnBallDestroyed; + } + if (SpringHinge) { + SpringHinge.OnAnimationValueChanged -= OnAngleChanged; + if (SpringHinge.SpringHingeApi != null) { + SpringHinge.SpringHingeApi.Hit -= OnHingeHit; + } + } + if (OwnedMagnet && OwnedMagnet.MagnetApi != null) { + OwnedMagnet.MagnetApi.BallGrabbed -= OnBallGrabbed; + OwnedMagnet.MagnetApi.BallReleased -= OnBallReleased; + } + _subscribed = false; + } + + private bool IsReady => Player && SpringHinge && OwnedMagnet && ShotMarker + && Player.BallManager != null + && SpringHinge.SpringHingeApi != null + && OwnedMagnet.MagnetApi != null; + + private void Update() + { + var keyboard = Keyboard.current; + if (!IsReady || keyboard == null) { + return; + } + if (keyboard.digit1Key.wasPressedThisFrame) LaunchWeak(); + if (keyboard.digit2Key.wasPressedThisFrame) LaunchMedium(); + if (keyboard.digit3Key.wasPressedThisFrame) LaunchStrong(); + if (keyboard.mKey.wasPressedThisFrame) ToggleMagnet(); + if (keyboard.tKey.wasPressedThisFrame) ReleaseAfterDelay(); + if (keyboard.rKey.wasPressedThisFrame) ResetFixture(); + } + + private void OnGUI() + { + if (!ShowControls || !IsReady) { + return; + } + GUILayout.BeginArea(new Rect(12f, 12f, 230f, 235f), GUI.skin.box); + GUILayout.Label("Spring Hinge Bash Toy"); + if (GUILayout.Button("Weak Shot [1]")) LaunchWeak(); + if (GUILayout.Button("Medium Shot [2]")) LaunchMedium(); + if (GUILayout.Button("Strong Shot [3]")) LaunchStrong(); + if (GUILayout.Button("Toggle Magnet [M]")) ToggleMagnet(); + if (GUILayout.Button("Timed Release [T]")) ReleaseAfterDelay(); + if (GUILayout.Button("Reset [R]")) ResetFixture(); + GUILayout.Label(_lastTrace); + GUILayout.EndArea(); + } + + [ContextMenu("Launch Weak Shot")] + public void LaunchWeak() => Launch(WeakShotSpeed); + + [ContextMenu("Launch Medium Shot")] + public void LaunchMedium() => Launch(MediumShotSpeed); + + [ContextMenu("Launch Strong Shot")] + public void LaunchStrong() => Launch(StrongShotSpeed); + + public int Launch(float speed) + { + if (!IsReady || speed <= 0f) { + return 0; + } + var playfield = Player.Playfield.transform; + var start = (float3)ShotMarker.position.TranslateToVpx(playfield); + var ahead = (float3)(ShotMarker.position + ShotMarker.forward).TranslateToVpx(playfield); + var direction = math.normalizesafe((ahead - start).xy, new float2(0f, -1f)); + var angle = math.degrees(math.atan2(direction.x, -direction.y)); + var ballId = Player.BallManager.CreateBall(new DebugBallCreator( + start.x, start.y, start.z, angle, speed), 25f, 1f, BallPrefab); + Trace($"shot {ballId}: {speed:0.##} VPU / normalized time"); + return ballId; + } + + [ContextMenu("Toggle Magnet")] + public void ToggleMagnet() + { + if (!IsReady) { + return; + } + OwnedMagnet.MagnetApi.IsEnabled = !OwnedMagnet.MagnetApi.IsEnabled; + Trace(OwnedMagnet.MagnetApi.IsEnabled ? "magnet on" : "magnet off"); + } + + [ContextMenu("Release After Delay")] + public void ReleaseAfterDelay() + { + if (!IsReady) { + return; + } + if (_timedRelease != null) { + StopCoroutine(_timedRelease); + } + _timedRelease = StartCoroutine(ReleaseAfterDelayRoutine()); + Trace($"release scheduled in {ReleaseDelaySeconds:0.##} s"); + } + + private IEnumerator ReleaseAfterDelayRoutine() + { + yield return new WaitForSeconds(ReleaseDelaySeconds); + _timedRelease = null; + ReleaseNow(); + } + + public void ReleaseNow() + { + if (!IsReady) { + return; + } + OwnedMagnet.MagnetApi.ReleaseBall(); + OwnedMagnet.MagnetApi.IsEnabled = false; + Trace("magnetic hold released"); + } + + [ContextMenu("Reset Fixture")] + public void ResetFixture() + { + if (!IsReady) { + return; + } + if (_timedRelease != null) { + StopCoroutine(_timedRelease); + _timedRelease = null; + } + ReleaseNow(); + SpringHinge.SpringHingeApi.Reset(SpringHinge.InitialAngle); + for (var i = _spawnedBalls.Count - 1; i >= 0; i--) { + var ball = _spawnedBalls[i]; + if (ball && ball.TryGetComponent(out var component)) { + Player.BallManager.DestroyBall(component.Id); + } + } + _spawnedBalls.Clear(); + Trace("fixture reset"); + } + + private void OnBallCreated(object sender, BallEvent args) => _spawnedBalls.Add(args.Ball); + + private void OnBallDestroyed(object sender, BallEvent args) => _spawnedBalls.Remove(args.Ball); + + private void OnAngleChanged(float radians) => Trace($"hinge angle {math.degrees(radians):0.##}°"); + + private void OnHingeHit(object sender, HitEventArgs args) => Trace($"hinge impact by ball {args.BallId}"); + + private void OnBallGrabbed(object sender, HitEventArgs args) => Trace($"ball {args.BallId} captured"); + + private void OnBallReleased(object sender, HitEventArgs args) => Trace($"ball {args.BallId} released"); + + private void Trace(string message) + { + _lastTrace = message; + if (TraceEvents) { + Debug.Log($"[SpringHingeBashToy] {message}", this); + } + } + } +} diff --git a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta new file mode 100644 index 000000000..1f0b7fac2 --- /dev/null +++ b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97114fa8f337464c9cda50518259ec18 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef new file mode 100644 index 000000000..91879ab2c --- /dev/null +++ b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef @@ -0,0 +1,18 @@ +{ + "name": "VisualPinball.Unity.Samples.SpringHingeBashToy", + "rootNamespace": "VisualPinball.Unity.Samples.SpringHingeBashToy", + "references": [ + "Unity.InputSystem", + "Unity.Mathematics", + "VisualPinball.Unity" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta new file mode 100644 index 000000000..19fffda03 --- /dev/null +++ b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4b86b7992d45418f9eea4d5c6d531ff4 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: 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..ef09ed44f --- /dev/null +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -0,0 +1,54 @@ +--- +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, select only the parts that should move and choose **GameObject > Pinball > Add Spring Hinge**. VPE creates a dedicated pivot root and a Moving Part child, preserves the selected world poses, and disables their independent colliders. Keep fixed brackets out of the selection. Move the new root to the real bearing centre, 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 hierarchy with a cube visual, analytic box, transform follower, and owned magnet. The bash preset starts at 0 degrees and stops at 20 degrees with a low-elasticity box. + +The Moving Part's Spring Hinge Transform component applies the physics angle to the visual hierarchy. Leave the fixed pivot and brackets outside it. The simulation owns this angle; do not animate or move the pivot from another behavior during play. + +## Mass, Spring, and Stops + +**Toy Mass** is relative to VPE's standard ball mass. A value of 1 means one standard ball mass; it is not kilograms. **Fit From Child Renderers** fills the centre of mass, inertia-estimate box, and collision proxy from the selected visuals in millimeters. This is a conservative bounds fit, not mesh-volume integration, so move the centre marker and mass box when the toy is hollow or uneven. Enable **Override Inertia** for a measured or separately calculated moment of inertia. + +**Spring Stiffness** and **Spring Damping** are torsional values in VPE's normalized simulation units. **Equilibrium Angle** is the one canonical rest/preload setting and may lie beyond a stop to hold the toy against it. Stops have zero restitution: the toy may leave a stop immediately when an inward impulse or spring torque acts. + +The optional angle switch closes at **Switch Close Angle** and opens at **Switch Open Angle**. Use different thresholds to avoid chatter. + +## Analytic Collision Box + +Version one supports one oriented box attached to the hinge. Edit its local centre, rotation, and half-extents independently of the mass box. The scene view shows its current pose and both travel limits. Remove or disable mesh and static colliders on the moving visual so a ball cannot contact two representations. + +The box is continuous-collision tested against the ball in 3-D, including its faces, edges, corners, and return travel. The qualified envelope uses the 1 ms physics tick, a loaded period of at least 0.314 seconds, `hold frequency × tick <= 0.2`, positive proxy half-extents, and ordinary pinball shot speeds represented by the sample's 8, 18, and 30 VPU-per-normalized-time controls. Conservative advancement is bounded to 32 steps, followed by at most 32 local fallback segments and 14 refinements. A table relying on sustained force-cap saturation, a stiffer/faster mechanism, or penetration above 0.5% of ball radius needs a narrower proxy, a softer configuration, or further qualification. + +## Couple a Magnet + +Place a Magnet below Moving Part, select **Spatial** and **Physical**, and enable **Couple To Parent Hinge**. Only one owned magnet and one attached ball are supported per hinge. The inspector displays the resolved owner and rejects unsupported types or duplicates. + +The magnet transform is the moving pole. **Held Ball Centre Offset** is a separate target in millimeters at the authored rest pose. Place it outside the box at the intended collision face; for a standard 25-unit-radius ball, begin one radius beyond the face. Adjust it for a different ball radius. The green scene marker shows the target. + +**Hold Stiffness** and **Hold Damping** control attachment compliance. **Max Hold Force** is the current-dependent capacity. These settings are independent of Influence Radius. Tune the field to attract the ball, then tune capacity and compliance so the intended shot captures without living at the force cap. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. + +## Validate in Play Mode + +Import **Spring Hinge Bash Toy** from Package Manager to add `SpringHingeBashToyController`. Assign a table Player, hinge, magnet, and a shot marker pointing toward the toy. In Play Mode, use **1/2/3** for weak/medium/strong shots, **M** for magnet off/on, **T** for timed release, and **R** to reset. The controller uses local VPE APIs and leaves hardware output disabled. + +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. Watch the diagnostic trace for one impact/capture/release event per transition. + +## 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 Physical 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 index 0b894510e..ca41e8943 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -257,7 +257,7 @@ The table asset must not persist captured-ball IDs, warm solver state, or runtim 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–6 are implemented by the numerical fixtures, runtime spring-hinge skeleton, specialized analytic collider, reciprocal owned-magnet coupling, integration qualification, coherent render/package reconstruction, and the authoring workflow alongside this plan. Phase 7 remains gated by its tests and pre-commit review. +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 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..596893219 --- /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, millimeter 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.Test/Physics/SpringHingePlayModeFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs new file mode 100644 index 000000000..24df226dc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.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 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 moving = new GameObject("Moving Part"); + moving.transform.SetParent(hingeObject.transform, false); + var animation = moving.AddComponent(); + animation._emitter = hinge; + animation.RotationAxis = Vector3.forward; + + var magnetObject = new GameObject("Owned Magnet"); + magnetObject.transform.SetParent(moving.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/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/package.json b/package.json index 12c59a0a6..b0450981d 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,16 @@ "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" + }, + "samples": [ + { + "displayName": "Spring Hinge Bash Toy", + "description": "Play Mode shot controls and diagnostics for a reciprocal spring-hinge magnet toy.", + "path": "Samples~/SpringHingeBashToy" + } + ], + "keywords": [ "Pinball", "Unity" ], From 1b179578ea77853bd399ca02c5249025a935ea32 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 8 Sep 2026 21:34:17 +0200 Subject: [PATCH 09/16] spring-hinge: simplify rotating toy setup --- Samples~/SpringHingeBashToy.meta | 8 - Samples~/SpringHingeBashToy/README.md | 12 - Samples~/SpringHingeBashToy/README.md.meta | 7 - Samples~/SpringHingeBashToy/Scripts.meta | 8 - .../Scripts/SpringHingeBashToyController.cs | 233 ------------------ .../SpringHingeBashToyController.cs.meta | 11 - ...ll.Unity.Samples.SpringHingeBashToy.asmdef | 18 -- ...ity.Samples.SpringHingeBashToy.asmdef.meta | 7 - .../manual/mechanisms/spring-hinges.md | 18 +- ...spring-hinge-magnet-implementation-plan.md | 2 +- .../VPT/SpringHinge/SpringHingeAuthoring.cs | 55 ++--- .../SpringHingeColliderInspector.cs | 30 ++- .../VPT/SpringHinge/SpringHingeInspector.cs | 6 +- .../SpringHingePlayModeFixtureTests.cs | 6 +- .../SpringHinge/SpringHingeAuthoringTests.cs | 43 +++- .../SpringHingeAnimationComponent.cs | 2 +- .../SpringHingeColliderComponent.cs | 32 +++ .../SpringHingeColliderGenerator.cs | 5 +- .../VPT/SpringHinge/SpringHingeComponent.cs | 49 +++- .../VPT/SpringHinge/SpringHingePackable.cs | 5 +- package.json | 7 - 21 files changed, 177 insertions(+), 387 deletions(-) delete mode 100644 Samples~/SpringHingeBashToy.meta delete mode 100644 Samples~/SpringHingeBashToy/README.md delete mode 100644 Samples~/SpringHingeBashToy/README.md.meta delete mode 100644 Samples~/SpringHingeBashToy/Scripts.meta delete mode 100644 Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs delete mode 100644 Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta delete mode 100644 Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef delete mode 100644 Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta diff --git a/Samples~/SpringHingeBashToy.meta b/Samples~/SpringHingeBashToy.meta deleted file mode 100644 index 320bbf349..000000000 --- a/Samples~/SpringHingeBashToy.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: f377832f786242aba085f2dba20b30fc -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/README.md b/Samples~/SpringHingeBashToy/README.md deleted file mode 100644 index 5b464d282..000000000 --- a/Samples~/SpringHingeBashToy/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Spring Hinge Bash Toy - -This sample adds Play Mode shot controls and diagnostic traces to a spring-hinge magnet toy in a real VPE table. It drives the local `Player`, `PhysicsEngine`, `SpringHingeApi`, and `MagnetApi`; it does not create or send hardware outputs. - -1. Import **Spring Hinge Bash Toy** from Package Manager. -2. In a table scene, create the toy with **GameObject > Pinball > Spring Hinge Bash Toy**, then position its pivot and use the scene handles to fit the visual and analytic box. -3. Add `SpringHingeBashToyController` beneath the table, assign its Player, Spring Hinge, Owned Magnet, and a shot marker. Point the marker's forward axis toward the toy. Assigning a ball prefab is optional. -4. Enter Play Mode. Use **1**, **2**, and **3** for weak, medium, and strong shots; **M** to toggle the magnet; **T** to schedule a release; and **R** to release the ball, reset the hinge, and remove balls created by the sample. The optional on-screen panel exposes the same controls. - -Start with the preset and move the magnet to the intended collision face. The held-ball-centre marker should sit one ball radius outside the analytic box. Raise magnetic strength and holding capacity together only when a strong shot should capture; a large influence radius does not make the attachment stiffer. - -The diagnostic trace reports shot creation, hinge angle, impact, capture, release, and reset events. Use it with the Physics diagnostics in the editor to distinguish a magnetic release from the release-before-legacy-active-mechanism fallback. diff --git a/Samples~/SpringHingeBashToy/README.md.meta b/Samples~/SpringHingeBashToy/README.md.meta deleted file mode 100644 index d7d6a2a43..000000000 --- a/Samples~/SpringHingeBashToy/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: f0179e617b914bf6b59f07ff27a2bdc0 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts.meta b/Samples~/SpringHingeBashToy/Scripts.meta deleted file mode 100644 index 254352e1e..000000000 --- a/Samples~/SpringHingeBashToy/Scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: e6826df62fa44dcda1d6668d27f781e7 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs deleted file mode 100644 index 565fae397..000000000 --- a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs +++ /dev/null @@ -1,233 +0,0 @@ -// Visual Pinball Engine -// Copyright (C) 2026 freezy and VPE Team -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. - -using System; -using System.Collections; -using System.Collections.Generic; -using Unity.Mathematics; -using UnityEngine; -using UnityEngine.InputSystem; - -namespace VisualPinball.Unity.Samples.SpringHingeBashToy -{ - [DisallowMultipleComponent] - [AddComponentMenu("Pinball/Samples/Spring Hinge Bash Toy Controller")] - public sealed class SpringHingeBashToyController : MonoBehaviour - { - [Header("Fixture")] - public Player Player; - public SpringHingeComponent SpringHinge; - public MagnetComponent OwnedMagnet; - [Tooltip("Place on the playfield and point its forward axis toward the toy.")] - public Transform ShotMarker; - public GameObject BallPrefab; - - [Header("Shots")] - [Min(0f)] public float WeakShotSpeed = 8f; - [Min(0f)] public float MediumShotSpeed = 18f; - [Min(0f)] public float StrongShotSpeed = 30f; - [Min(0f)] public float ReleaseDelaySeconds = 1.5f; - - [Header("Diagnostics")] - public bool ShowControls = true; - public bool TraceEvents = true; - - private readonly List _spawnedBalls = new(); - private Coroutine _timedRelease; - private string _lastTrace = "Ready"; - private bool _subscribed; - - private void Awake() - { - Player = Player ? Player : GetComponentInParent(); - SpringHinge = SpringHinge ? SpringHinge : GetComponentInChildren(true); - OwnedMagnet = OwnedMagnet ? OwnedMagnet : GetComponentInChildren(true); - } - - private void Start() - { - if (!IsReady) { - Debug.LogError("Spring Hinge Bash Toy sample needs a Player, Spring Hinge, Owned Magnet, and Shot Marker.", this); - return; - } - Player.OnBallCreated += OnBallCreated; - Player.OnBallDestroyed += OnBallDestroyed; - SpringHinge.OnAnimationValueChanged += OnAngleChanged; - SpringHinge.SpringHingeApi.Hit += OnHingeHit; - OwnedMagnet.MagnetApi.BallGrabbed += OnBallGrabbed; - OwnedMagnet.MagnetApi.BallReleased += OnBallReleased; - _subscribed = true; - Trace("fixture started; hardware output is disabled"); - } - - private void OnDestroy() - { - if (!_subscribed) { - return; - } - if (Player) { - Player.OnBallCreated -= OnBallCreated; - Player.OnBallDestroyed -= OnBallDestroyed; - } - if (SpringHinge) { - SpringHinge.OnAnimationValueChanged -= OnAngleChanged; - if (SpringHinge.SpringHingeApi != null) { - SpringHinge.SpringHingeApi.Hit -= OnHingeHit; - } - } - if (OwnedMagnet && OwnedMagnet.MagnetApi != null) { - OwnedMagnet.MagnetApi.BallGrabbed -= OnBallGrabbed; - OwnedMagnet.MagnetApi.BallReleased -= OnBallReleased; - } - _subscribed = false; - } - - private bool IsReady => Player && SpringHinge && OwnedMagnet && ShotMarker - && Player.BallManager != null - && SpringHinge.SpringHingeApi != null - && OwnedMagnet.MagnetApi != null; - - private void Update() - { - var keyboard = Keyboard.current; - if (!IsReady || keyboard == null) { - return; - } - if (keyboard.digit1Key.wasPressedThisFrame) LaunchWeak(); - if (keyboard.digit2Key.wasPressedThisFrame) LaunchMedium(); - if (keyboard.digit3Key.wasPressedThisFrame) LaunchStrong(); - if (keyboard.mKey.wasPressedThisFrame) ToggleMagnet(); - if (keyboard.tKey.wasPressedThisFrame) ReleaseAfterDelay(); - if (keyboard.rKey.wasPressedThisFrame) ResetFixture(); - } - - private void OnGUI() - { - if (!ShowControls || !IsReady) { - return; - } - GUILayout.BeginArea(new Rect(12f, 12f, 230f, 235f), GUI.skin.box); - GUILayout.Label("Spring Hinge Bash Toy"); - if (GUILayout.Button("Weak Shot [1]")) LaunchWeak(); - if (GUILayout.Button("Medium Shot [2]")) LaunchMedium(); - if (GUILayout.Button("Strong Shot [3]")) LaunchStrong(); - if (GUILayout.Button("Toggle Magnet [M]")) ToggleMagnet(); - if (GUILayout.Button("Timed Release [T]")) ReleaseAfterDelay(); - if (GUILayout.Button("Reset [R]")) ResetFixture(); - GUILayout.Label(_lastTrace); - GUILayout.EndArea(); - } - - [ContextMenu("Launch Weak Shot")] - public void LaunchWeak() => Launch(WeakShotSpeed); - - [ContextMenu("Launch Medium Shot")] - public void LaunchMedium() => Launch(MediumShotSpeed); - - [ContextMenu("Launch Strong Shot")] - public void LaunchStrong() => Launch(StrongShotSpeed); - - public int Launch(float speed) - { - if (!IsReady || speed <= 0f) { - return 0; - } - var playfield = Player.Playfield.transform; - var start = (float3)ShotMarker.position.TranslateToVpx(playfield); - var ahead = (float3)(ShotMarker.position + ShotMarker.forward).TranslateToVpx(playfield); - var direction = math.normalizesafe((ahead - start).xy, new float2(0f, -1f)); - var angle = math.degrees(math.atan2(direction.x, -direction.y)); - var ballId = Player.BallManager.CreateBall(new DebugBallCreator( - start.x, start.y, start.z, angle, speed), 25f, 1f, BallPrefab); - Trace($"shot {ballId}: {speed:0.##} VPU / normalized time"); - return ballId; - } - - [ContextMenu("Toggle Magnet")] - public void ToggleMagnet() - { - if (!IsReady) { - return; - } - OwnedMagnet.MagnetApi.IsEnabled = !OwnedMagnet.MagnetApi.IsEnabled; - Trace(OwnedMagnet.MagnetApi.IsEnabled ? "magnet on" : "magnet off"); - } - - [ContextMenu("Release After Delay")] - public void ReleaseAfterDelay() - { - if (!IsReady) { - return; - } - if (_timedRelease != null) { - StopCoroutine(_timedRelease); - } - _timedRelease = StartCoroutine(ReleaseAfterDelayRoutine()); - Trace($"release scheduled in {ReleaseDelaySeconds:0.##} s"); - } - - private IEnumerator ReleaseAfterDelayRoutine() - { - yield return new WaitForSeconds(ReleaseDelaySeconds); - _timedRelease = null; - ReleaseNow(); - } - - public void ReleaseNow() - { - if (!IsReady) { - return; - } - OwnedMagnet.MagnetApi.ReleaseBall(); - OwnedMagnet.MagnetApi.IsEnabled = false; - Trace("magnetic hold released"); - } - - [ContextMenu("Reset Fixture")] - public void ResetFixture() - { - if (!IsReady) { - return; - } - if (_timedRelease != null) { - StopCoroutine(_timedRelease); - _timedRelease = null; - } - ReleaseNow(); - SpringHinge.SpringHingeApi.Reset(SpringHinge.InitialAngle); - for (var i = _spawnedBalls.Count - 1; i >= 0; i--) { - var ball = _spawnedBalls[i]; - if (ball && ball.TryGetComponent(out var component)) { - Player.BallManager.DestroyBall(component.Id); - } - } - _spawnedBalls.Clear(); - Trace("fixture reset"); - } - - private void OnBallCreated(object sender, BallEvent args) => _spawnedBalls.Add(args.Ball); - - private void OnBallDestroyed(object sender, BallEvent args) => _spawnedBalls.Remove(args.Ball); - - private void OnAngleChanged(float radians) => Trace($"hinge angle {math.degrees(radians):0.##}°"); - - private void OnHingeHit(object sender, HitEventArgs args) => Trace($"hinge impact by ball {args.BallId}"); - - private void OnBallGrabbed(object sender, HitEventArgs args) => Trace($"ball {args.BallId} captured"); - - private void OnBallReleased(object sender, HitEventArgs args) => Trace($"ball {args.BallId} released"); - - private void Trace(string message) - { - _lastTrace = message; - if (TraceEvents) { - Debug.Log($"[SpringHingeBashToy] {message}", this); - } - } - } -} diff --git a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta b/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta deleted file mode 100644 index 1f0b7fac2..000000000 --- a/Samples~/SpringHingeBashToy/Scripts/SpringHingeBashToyController.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 97114fa8f337464c9cda50518259ec18 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef deleted file mode 100644 index 91879ab2c..000000000 --- a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "VisualPinball.Unity.Samples.SpringHingeBashToy", - "rootNamespace": "VisualPinball.Unity.Samples.SpringHingeBashToy", - "references": [ - "Unity.InputSystem", - "Unity.Mathematics", - "VisualPinball.Unity" - ], - "includePlatforms": [], - "excludePlatforms": [], - "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], - "autoReferenced": true, - "defineConstraints": [], - "versionDefines": [], - "noEngineReferences": false -} diff --git a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta b/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta deleted file mode 100644 index 19fffda03..000000000 --- a/Samples~/SpringHingeBashToy/Scripts/VisualPinball.Unity.Samples.SpringHingeBashToy.asmdef.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 4b86b7992d45418f9eea4d5c6d531ff4 -AssemblyDefinitionImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md index ef09ed44f..58e7b009c 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -10,15 +10,15 @@ A Spring Hinge simulates a rigid toy rotating about one fixed axis. Balls push i ## Create a Toy -For an existing model, select only the parts that should move and choose **GameObject > Pinball > Add Spring Hinge**. VPE creates a dedicated pivot root and a Moving Part child, preserves the selected world poses, and disables their independent colliders. Keep fixed brackets out of the selection. Move the new root to the real bearing centre, then use the scene handles to set the axis, centre of mass, analytic box, and travel limits. +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, Spring Hinge Collider, and Spring Hinge Transform 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 hierarchy with a cube visual, analytic box, transform follower, and owned magnet. The bash preset starts at 0 degrees and stops at 20 degrees with a low-elasticity box. +Choose **GameObject > Pinball > Spring Hinge Bash Toy** to create a complete example object with a cube visual, analytic box, transform driver, and owned magnet. The bash preset starts at 0 degrees and stops at 20 degrees with a low-elasticity box. -The Moving Part's Spring Hinge Transform component applies the physics angle to the visual hierarchy. Leave the fixed pivot and brackets outside it. The simulation owns this angle; do not animate or move the pivot from another behavior during play. +Spring Hinge Transform applies the physics angle directly to the rotating object around its local origin. The simulation caches the authored rest pose and owns this angle; do not animate or move the object from another behavior during play. ## Mass, Spring, and Stops -**Toy Mass** is relative to VPE's standard ball mass. A value of 1 means one standard ball mass; it is not kilograms. **Fit From Child Renderers** fills the centre of mass, inertia-estimate box, and collision proxy from the selected visuals in millimeters. This is a conservative bounds fit, not mesh-volume integration, so move the centre marker and mass box when the toy is hollow or uneven. Enable **Override Inertia** for a measured or separately calculated moment of inertia. +**Toy Mass** is relative to VPE's standard ball mass. A value of 1 means one standard ball mass; it is not kilograms. **Fit From Renderers** fills the centre of mass, inertia-estimate box, and collision proxy from the rotating object's renderers and their children in millimeters. This is a conservative bounds fit, not mesh-volume integration, so move the centre marker and mass box when the toy is hollow or uneven. Enable **Override Inertia** for a measured or separately calculated moment of inertia. **Spring Stiffness** and **Spring Damping** are torsional values in VPE's normalized simulation units. **Equilibrium Angle** is the one canonical rest/preload setting and may lie beyond a stop to hold the toy against it. Stops have zero restitution: the toy may leave a stop immediately when an inward impulse or spring torque acts. @@ -28,11 +28,13 @@ The optional angle switch closes at **Switch Close Angle** and opens at **Switch Version one supports one oriented box attached to the hinge. Edit its local centre, rotation, and half-extents independently of the mass box. The scene view shows its current pose and both travel limits. Remove or disable mesh and static colliders on the moving visual so a ball cannot contact two representations. -The box is continuous-collision tested against the ball in 3-D, including its faces, edges, corners, and return travel. The qualified envelope uses the 1 ms physics tick, a loaded period of at least 0.314 seconds, `hold frequency × tick <= 0.2`, positive proxy half-extents, and ordinary pinball shot speeds represented by the sample's 8, 18, and 30 VPU-per-normalized-time controls. Conservative advancement is bounded to 32 steps, followed by at most 32 local fallback segments and 14 refinements. A table relying on sustained force-cap saturation, a stiffer/faster mechanism, or penetration above 0.5% of ball radius needs a narrower proxy, a softer configuration, or further qualification. +Enable **Show Collider** on Spring Hinge Collider to display the current analytic box in green in the Scene view. This follows the simulated hinge angle during Play Mode. + +The box is continuous-collision tested against the ball in 3-D, including its faces, edges, corners, and return travel. The qualified envelope uses the 1 ms physics tick, a loaded period of at least 0.314 seconds, `hold frequency × tick <= 0.2`, positive proxy half-extents, and ordinary pinball shot speeds from 8 to 30 VPU per normalized time. Conservative advancement is bounded to 32 steps, followed by at most 32 local fallback segments and 14 refinements. A table relying on sustained force-cap saturation, a stiffer/faster mechanism, or penetration above 0.5% of ball radius needs a narrower proxy, a softer configuration, or further qualification. ## Couple a Magnet -Place a Magnet below Moving Part, select **Spatial** and **Physical**, and enable **Couple To Parent Hinge**. Only one owned magnet and one attached ball are supported per hinge. The inspector displays the resolved owner and rejects unsupported types or duplicates. +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** and **Physical**, then enable **Couple To Parent Hinge**. 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 millimeters at the authored rest pose. Place it outside the box at the intended collision face; for a standard 25-unit-radius ball, begin one radius beyond the face. Adjust it for a different ball radius. The green scene marker shows the target. @@ -40,9 +42,7 @@ The magnet transform is the moving pole. **Held Ball Centre Offset** is a separa ## Validate in Play Mode -Import **Spring Hinge Bash Toy** from Package Manager to add `SpringHingeBashToyController`. Assign a table Player, hinge, magnet, and a shot marker pointing toward the toy. In Play Mode, use **1/2/3** for weak/medium/strong shots, **M** for magnet off/on, **T** for timed release, and **R** to reset. The controller uses local VPE APIs and leaves hardware output disabled. - -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. Watch the diagnostic trace for one impact/capture/release event per transition. +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 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 index ca41e8943..ddbff3d76 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-magnet-implementation-plan.md @@ -86,7 +86,7 @@ Add the existing magnet as a child of the hinge and select Spatial physical beha 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 a dedicated Play Mode fixture/demo scene with shot markers, weak/medium/strong launch controls, magnet off/on, timed release, reset, and diagnostic traces. It runs through the real Player/PhysicsEngine and keeps hardware output disabled in the fixture. An isolated editor preview context does not exist today and is deferred; scene gizmos and the test scene provide the first authoring workflow. +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 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs index 13354d98f..d47c49518 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs @@ -47,16 +47,13 @@ public static GameObject CreateBashToy(Transform parent = null) var hinge = Undo.AddComponent(root); var proxy = Undo.AddComponent(root); - - var movingPart = new GameObject("Moving Part"); - movingPart.transform.SetParent(root.transform, false); - var animation = Undo.AddComponent(movingPart); + var animation = Undo.AddComponent(root); animation._emitter = hinge; animation.RotationAxis = hinge.HingeAxis; var visual = GameObject.CreatePrimitive(PrimitiveType.Cube); visual.name = "Toy Visual"; - visual.transform.SetParent(movingPart.transform, false); + visual.transform.SetParent(root.transform, false); visual.transform.localPosition = new Vector3(0f, -0.05f, 0f); visual.transform.localScale = new Vector3(0.05f, 0.1f, 0.02f); var unityCollider = visual.GetComponent(); @@ -65,7 +62,7 @@ public static GameObject CreateBashToy(Transform parent = null) } var magnetObject = new GameObject("Owned Magnet"); - magnetObject.transform.SetParent(movingPart.transform, false); + magnetObject.transform.SetParent(root.transform, false); magnetObject.transform.localPosition = new Vector3(0f, -0.1f, 0f); var magnet = Undo.AddComponent(magnetObject); @@ -82,36 +79,35 @@ public static GameObject AddSpringHinge(IReadOnlyList visualParts, } activeVisual = activeVisual ? activeVisual : visualParts[0]; - var root = new GameObject("Spring Hinge"); - Undo.RegisterCreatedObjectUndo(root, "Add Spring Hinge"); - var parent = activeVisual.parent; - if (parent) { - root.transform.SetParent(parent, false); - } - root.transform.SetPositionAndRotation(activeVisual.position, activeVisual.rotation); - - var hinge = Undo.AddComponent(root); - var proxy = Undo.AddComponent(root); - var movingPart = new GameObject("Moving Part"); - movingPart.transform.SetParent(root.transform, false); - var animation = Undo.AddComponent(movingPart); + var rotatingObject = activeVisual.gameObject; + var hinge = rotatingObject.GetComponent() + ?? Undo.AddComponent(rotatingObject); + var proxy = rotatingObject.GetComponent() + ?? Undo.AddComponent(rotatingObject); + var animation = rotatingObject.GetComponent() + ?? Undo.AddComponent(rotatingObject); animation._emitter = hinge; animation.RotationAxis = hinge.HingeAxis; foreach (var visualPart in visualParts) { - if (!visualPart || visualPart == root.transform || IsAncestorSelected(visualPart, 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, movingPart.transform, "Add Visual To Spring Hinge"); - DisableIndependentColliders(visualPart); + Undo.SetTransformParent(visualPart, activeVisual, "Add Visual To Spring Hinge"); } + DisableIndependentColliders(activeVisual); - var ownedMagnets = movingPart.GetComponentsInChildren(true); + var ownedMagnets = rotatingObject.GetComponentsInChildren(true); var magnet = ownedMagnets.Length == 1 ? ownedMagnets[0] : null; ApplyBashPreset(hinge, proxy, magnet); FitFromVisuals(hinge, proxy); - EditorUtility.SetDirty(root); - return root; + EditorUtility.SetDirty(rotatingObject); + return rotatingObject; } private static bool IsAncestorSelected(Transform candidate, @@ -263,15 +259,16 @@ public static IReadOnlyList Validate(SpringHingeComponent hinge, issues.Add("The analytic box proxy needs three positive half-extents."); } + var localDriver = hinge.GetComponent(); var drivers = hinge.GetComponentsInChildren(true); var driverCount = 0; - foreach (var driver in drivers) { - if (driver._emitter == hinge) { + foreach (var candidate in drivers) { + if (candidate._emitter == hinge) { driverCount++; } } - if (driverCount != 1) { - issues.Add("The visual hierarchy must have exactly one Spring Hinge Transform driven by this hinge."); + if (!localDriver || localDriver._emitter != hinge || driverCount != 1) { + issues.Add("The rotating object must have exactly one Spring Hinge Transform on the same GameObject, driven by this hinge."); } if (hinge.GetComponentInChildren(true)) { issues.Add("Remove hit-target animation from spring-hinge visuals; the hinge is their only animation driver."); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs index e2fdf9367..72ebb6cce 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs @@ -17,6 +17,7 @@ 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; @@ -34,6 +35,7 @@ protected override void 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)); @@ -50,6 +52,11 @@ public override void OnInspectorGUI() 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); @@ -72,7 +79,7 @@ public override void OnInspectorGUI() private void OnSceneGUI() { - if (targets.Length != 1 || target is not SpringHingeColliderComponent proxy) { + if (target is not SpringHingeColliderComponent proxy) { return; } var hinge = proxy.GetComponent(); @@ -80,28 +87,33 @@ private void OnSceneGUI() return; } - var centre = hinge.transform.TransformPoint(proxy.LocalCentre * 0.001f); - var rotation = hinge.transform.rotation * Quaternion.Euler(proxy.LocalRotation); + var hingePose = hinge.transform.localToWorldMatrix; + var centre = hingePose.MultiplyPoint3x4(proxy.LocalCentre * 0.001f); + 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 * 0.001f, centre, rotation, handleSize); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(proxy, "Edit Spring Hinge Proxy"); - proxy.LocalCentre = hinge.transform.InverseTransformPoint(movedCentre) * 1000f; + proxy.LocalCentre = hingePose.inverse.MultiplyPoint3x4(movedCentre) * 1000f; proxy.HalfExtents = Vector3.Max(resized * 1000f, Vector3.one * 0.001f); proxy.CollidersDirty = true; EditorUtility.SetDirty(proxy); } - var matrix = hinge.transform.localToWorldMatrix + var matrix = hingePose * Matrix4x4.TRS(proxy.LocalCentre * 0.001f, Quaternion.Euler(proxy.LocalRotation), Vector3.one); - using (new Handles.DrawingScope(new Color(0f, 1f, 1f, 0.8f), matrix)) { - Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * 0.002f); + if (!proxy.ShowColliderMesh) { + using (new Handles.DrawingScope(new Color(0f, 1f, 1f, 0.8f), matrix)) { + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * 0.002f); + } + } + 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)); } - 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, diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs index e04e7a6dd..8d6647a20 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs @@ -101,10 +101,10 @@ private static void DrawSetupActions(SpringHingeComponent hinge) } using (new EditorGUILayout.HorizontalScope()) { - if (GUILayout.Button("Fit From Child Renderers")) { + 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 child renderers to fit.", hinge); + Debug.LogWarning($"Spring hinge '{hinge.name}' has no renderers to fit.", hinge); } EditorUtility.SetDirty(hinge); EditorUtility.SetDirty(proxy); @@ -127,7 +127,7 @@ private static void DrawSetupActions(SpringHingeComponent hinge) private void OnSceneGUI() { - if (targets.Length != 1 || target is not SpringHingeComponent hinge) { + if (target is not SpringHingeComponent hinge) { return; } var pivot = hinge.transform.position; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs index 24df226dc..e59ba8f68 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs @@ -91,14 +91,12 @@ private static Fixture CreateFixture() proxy.LocalCentre = new Vector3(0f, 50f, 0f); proxy.HalfExtents = new Vector3(25f, 50f, 10f); - var moving = new GameObject("Moving Part"); - moving.transform.SetParent(hingeObject.transform, false); - var animation = moving.AddComponent(); + var animation = hingeObject.AddComponent(); animation._emitter = hinge; animation.RotationAxis = Vector3.forward; var magnetObject = new GameObject("Owned Magnet"); - magnetObject.transform.SetParent(moving.transform, false); + magnetObject.transform.SetParent(hingeObject.transform, false); magnetObject.transform.localPosition = new Vector3(0f, 0.05f, 0f); var magnet = magnetObject.AddComponent(); magnet.MagnetType = MagnetType.Spatial; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs index 00703c67f..ddb6fc0d2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -18,18 +18,19 @@ namespace VisualPinball.Unity.Test public class SpringHingeAuthoringTests { [Test] - public void BashSetupCreatesCompleteOwnedVisualHierarchy() + public void BashSetupCreatesCompleteOwnedRotatingObject() { var root = SpringHingeAuthoring.CreateBashToy(); try { var hinge = root.GetComponent(); var proxy = root.GetComponent(); - var animation = root.GetComponentInChildren(); + var animation = root.GetComponent(); var magnet = root.GetComponentInChildren(); Assert.That(hinge, Is.Not.Null); Assert.That(proxy, Is.Not.Null); Assert.That(animation, Is.Not.Null); + Assert.That(animation.gameObject, Is.SameAs(root)); Assert.That(animation._emitter, Is.SameAs(hinge)); Assert.That(magnet.CoupleToParentHinge, Is.True); Assert.That(magnet.MagnetType, Is.EqualTo(MagnetType.Spatial)); @@ -89,11 +90,14 @@ public void AddSetupMovesOnlySelectedVisualsAndPreservesWorldPose() 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.GetComponentInParent(), Is.Not.Null); + Assert.That(selected.GetComponent(), Is.Not.Null); + 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); @@ -107,15 +111,13 @@ public void AddSetupMovesOnlySelectedVisualsAndPreservesWorldPose() } [Test] - public void TransformFollowerAppliesRadiansAndRoundTripsReferences() + public void SameObjectTransformDriverCachesRestRotationAndRoundTripsReferences() { var root = new GameObject("Spring Hinge"); - var moving = new GameObject("Moving Part"); try { - moving.transform.SetParent(root.transform, false); var hinge = root.AddComponent(); - moving.transform.localRotation = Quaternion.Euler(0f, 12f, 0f); - var animation = moving.AddComponent(); + root.transform.localRotation = Quaternion.Euler(0f, 12f, 0f); + var animation = root.AddComponent(); animation._emitter = hinge; animation.RotationAxis = Vector3.forward; animation.CaptureInitialPose(); @@ -123,12 +125,12 @@ public void TransformFollowerAppliesRadiansAndRoundTripsReferences() animation.ApplyAngle(math.PI / 2f); var expected = Quaternion.Euler(0f, 12f, 0f) * Quaternion.AngleAxis(90f, Vector3.forward); - Assert.That(Quaternion.Angle(moving.transform.localRotation, expected), + Assert.That(Quaternion.Angle(root.transform.localRotation, expected), Is.LessThan(0.001f)); var refs = new PackagedRefs(root.transform); refs.SetNodeIdsForWrite(new Dictionary { - { root.transform, "hinge" }, { moving.transform, "moving" } + { root.transform, "hinge" } }); var values = animation.Pack(); var references = animation.PackReferences(root.transform, refs, null); @@ -136,7 +138,7 @@ public void TransformFollowerAppliesRadiansAndRoundTripsReferences() animation._emitter = null; animation.Unpack(values); refs.SetNodeIdsForRead(new Dictionary { - { "hinge", root.transform }, { "moving", moving.transform } + { "hinge", root.transform } }); animation.UnpackReferences(references, root.transform, refs, null); @@ -147,6 +149,25 @@ public void TransformFollowerAppliesRadiansAndRoundTripsReferences() } } + [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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs index d7b8652f2..7849f0020 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs @@ -34,8 +34,8 @@ public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, Pa protected override void Awake() { - CaptureInitialPose(); base.Awake(); + CaptureInitialPose(); } protected override void OnAnimationValueChanged(float angle) => ApplyAngle(angle); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs index a67e3472c..54281b296 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -28,6 +28,10 @@ public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent, [Tooltip("Collision-box half-extents in its local frame.")] 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; @@ -65,6 +69,34 @@ private void OnValidate() HalfExtents = Vector3.Max(HalfExtents, Vector3.zero); } +#if UNITY_EDITOR + private void OnDrawGizmosSelected() + { + if (!ShowColliderMesh || !enabled) { + return; + } + var hinge = GetComponent(); + 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 * 0.001f, + 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 * 0.002f); + Gizmos.color = new Color32(0, 255, 75, 230); + Gizmos.DrawWireCube(Vector3.zero, HalfExtents * 0.002f); + Gizmos.matrix = previousMatrix; + Gizmos.color = previousColor; + } +#endif + void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngine, ref ColliderReference colliders, float4x4 translateWithinPlayfieldMatrix, float margin) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs index a48bb6a32..134e6394d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs @@ -20,8 +20,9 @@ internal static class SpringHingeColliderGenerator internal static SpringHingeCollider Create(SpringHingeComponent hinge, SpringHingeColliderComponent collider, ColliderInfo info, float margin) { - var pivot = hinge.ToPlayfieldVpx(hinge.transform.position); - var centre = hinge.ToPlayfieldVpx(hinge.transform.TransformPoint( + var referenceMatrix = hinge.ReferenceLocalToWorldMatrix; + var pivot = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(Vector3.zero)); + var centre = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4( collider.LocalCentre * MillimetersToWorld)); var localRotation = Quaternion.Euler(collider.LocalRotation); var halfAxisX = hinge.ToPlayfieldVector(localRotation diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs index a49470b3d..c3a2b284e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -86,6 +86,9 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter private PhysicsEngine _physicsEngine; private float _animationValue; + private Matrix4x4 _referenceLocalMatrix; + private Quaternion _referenceLocalRotation; + private bool _referencePoseCaptured; public IEnumerable AvailableSwitches => EnableAngleSwitch ? new[] { new GamelogicEngineSwitch(AngleSwitchItem) } @@ -107,6 +110,7 @@ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, Pac private void Awake() { + CaptureReferencePose(); var player = GetComponentInParent(); if (!player) { Logger.Error($"Cannot find player for spring hinge {name}."); @@ -145,9 +149,10 @@ private void OnValidate() internal SpringHingeState CreateState() { - var pivot = ToPlayfieldVpx(transform.position); + var pivot = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4(Vector3.zero)); var axis = ToPlayfieldDirection(HingeAxis); - var centreOfMass = ToPlayfieldVpx(transform.TransformPoint(CentreOfMass * MillimetersToWorld)); + var centreOfMass = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4( + CentreOfMass * MillimetersToWorld)); 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); @@ -207,8 +212,9 @@ private float EstimateInertia(float3 axis) math.pow(math.dot(axis, x), 2f), math.pow(math.dot(axis, y), 2f), math.pow(math.dot(axis, z), 2f))); - var centreArm = ToPlayfieldVpx(transform.TransformPoint(CentreOfMass * MillimetersToWorld)) - - ToPlayfieldVpx(transform.position); + var referenceMatrix = ReferenceLocalToWorldMatrix; + var centreArm = ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(CentreOfMass * MillimetersToWorld)) + - ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(Vector3.zero)); var perpendicularArm = centreArm - axis * math.dot(axis, centreArm); return math.max(0.001f, inertiaAtCentre + ToyMass * math.lengthsq(perpendicularArm)); } @@ -223,7 +229,7 @@ internal float3 ToPlayfieldVpx(Vector3 worldPosition) internal float3 ToPlayfieldDirection(Vector3 localDirection) { - var direction = transform.TransformDirection(localDirection.normalized); + var direction = ReferenceWorldRotation * localDirection.normalized; var playfield = GetComponentInParent(); if (playfield) { direction = playfield.transform.InverseTransformDirection(direction); @@ -233,7 +239,7 @@ internal float3 ToPlayfieldDirection(Vector3 localDirection) internal float3 ToPlayfieldVector(Vector3 localVector) { - var vector = transform.TransformVector(localVector); + var vector = ReferenceLocalToWorldMatrix.MultiplyVector(localVector); var playfield = GetComponentInParent(); if (playfield) { vector = playfield.transform.InverseTransformVector(vector); @@ -241,6 +247,37 @@ internal float3 ToPlayfieldVector(Vector3 localVector) 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) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs index 564583a1d..dd469f0cc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs @@ -76,12 +76,13 @@ public static void Unpack(byte[] bytes, SpringHingeComponent comp) public struct SpringHingeColliderPackable { - private const int CurrentVersion = 1; + 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; @@ -96,6 +97,7 @@ public static byte[] Pack(SpringHingeColliderComponent comp) LocalCentre = comp.LocalCentre, LocalRotation = comp.LocalRotation, HalfExtents = comp.HalfExtents, + ShowColliderMesh = comp.ShowColliderMesh, Elasticity = comp.Elasticity, ElasticityFalloff = comp.ElasticityFalloff, Friction = comp.Friction, @@ -111,6 +113,7 @@ public static void Unpack(byte[] bytes, SpringHingeColliderComponent comp) 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; diff --git a/package.json b/package.json index b0450981d..3c50d179e 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,6 @@ "com.unity.ugui": "2.5.0", "com.unity.test-framework": "1.7.0" }, - "samples": [ - { - "displayName": "Spring Hinge Bash Toy", - "description": "Play Mode shot controls and diagnostics for a reciprocal spring-hinge magnet toy.", - "path": "Samples~/SpringHingeBashToy" - } - ], "keywords": [ "Pinball", "Unity" From e0a7dfb15fb2f1b76a6b835396dfd0dccb2a7eaf Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 11:40:28 +0200 Subject: [PATCH 10/16] spring-hinge: simplify bash toy authoring --- .../manual/mechanisms/spring-hinges.md | 87 ++++++++++++++++--- .../spring-hinge-qualification.md | 2 +- .../SpringHingeAnimationInspector.cs | 34 -------- .../SpringHingeAnimationInspector.cs.meta | 11 --- .../VPT/SpringHinge/SpringHingeAuthoring.cs | 47 +++------- .../SpringHingeColliderInspector.cs | 17 ++-- .../VPT/SpringHinge/SpringHingeInspector.cs | 4 +- .../Physics/SpringHingeColliderTests.cs | 37 ++++++++ .../SpringHingePlayModeFixtureTests.cs | 4 - .../SpringHinge/SpringHingeAuthoringTests.cs | 38 ++------ .../VPT/Magnet/MagnetComponent.cs | 8 +- .../SpringHingeAnimationComponent.cs | 69 --------------- .../SpringHingeAnimationComponent.cs.meta | 11 --- .../SpringHingeColliderComponent.cs | 63 +++++++++++--- .../SpringHingeColliderGenerator.cs | 9 +- .../VPT/SpringHinge/SpringHingeComponent.cs | 19 ++-- .../VPT/SpringHinge/SpringHingePackable.cs | 47 ---------- 17 files changed, 215 insertions(+), 292 deletions(-) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md index 58e7b009c..bd3df8c95 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -10,33 +10,96 @@ A Spring Hinge simulates a rigid toy rotating about one fixed axis. Balls push i ## 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, Spring Hinge Collider, and Spring Hinge Transform 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. +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, transform driver, and owned magnet. The bash preset starts at 0 degrees and stops at 20 degrees with a low-elasticity box. +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 Transform applies the physics angle directly to the rotating object around its local origin. The simulation caches the authored rest pose and owns this angle; do not animate or move the object from another behavior during play. +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. -## Mass, Spring, and Stops +## Understand the Angles -**Toy Mass** is relative to VPE's standard ball mass. A value of 1 means one standard ball mass; it is not kilograms. **Fit From Renderers** fills the centre of mass, inertia-estimate box, and collision proxy from the rotating object's renderers and their children in millimeters. This is a conservative bounds fit, not mesh-volume integration, so move the centre marker and mass box when the toy is hollow or uneven. Enable **Override Inertia** for a measured or separately calculated moment of inertia. +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. -**Spring Stiffness** and **Spring Damping** are torsional values in VPE's normalized simulation units. **Equilibrium Angle** is the one canonical rest/preload setting and may lie beyond a stop to hold the toy against it. Stops have zero restitution: the toy may leave a stop immediately when an inward impulse or spring torque acts. +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. -The optional angle switch closes at **Switch Close Angle** and opens at **Switch Open Angle**. Use different thresholds to avoid chatter. +## Spring Hinge Inspector -## Analytic Collision Box +### Pivot and Mass -Version one supports one oriented box attached to the hinge. Edit its local centre, rotation, and half-extents independently of the mass box. The scene view shows its current pose and both travel limits. Remove or disable mesh and static colliders on the moving visual so a ball cannot contact two representations. +| 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. | -Enable **Show Collider** on Spring Hinge Collider to display the current analytic box in green in the Scene view. This follows the simulated hinge angle during Play Mode. +### Spring and Stops -The box is continuous-collision tested against the ball in 3-D, including its faces, edges, corners, and return travel. The qualified envelope uses the 1 ms physics tick, a loaded period of at least 0.314 seconds, `hold frequency × tick <= 0.2`, positive proxy half-extents, and ordinary pinball shot speeds from 8 to 30 VPU per normalized time. Conservative advancement is bounded to 32 steps, followed by at most 32 local fallback segments and 14 refinements. A table relying on sustained force-cap saturation, a stiffer/faster mechanism, or penetration above 0.5% of ball radius needs a narrower proxy, a softer configuration, or further qualification. +| 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** and **Physical**, then enable **Couple To Parent Hinge**. 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 millimeters at the authored rest pose. Place it outside the box at the intended collision face; for a standard 25-unit-radius ball, begin one radius beyond the face. Adjust it for a different ball radius. The green scene marker shows the target. +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. Place it outside the box at the intended collision face; for a standard 25-unit-radius ball, begin one radius beyond the face. Adjust it for a different ball radius. The green scene marker shows the target. **Hold Stiffness** and **Hold Damping** control attachment compliance. **Max Hold Force** is the current-dependent capacity. These settings are independent of Influence Radius. Tune the field to attract the ball, then tune capacity and compliance so the intended shot captures without living at the force cap. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md index 596893219..a1d45a1bb 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/spring-hinge-qualification.md @@ -11,7 +11,7 @@ The spring-hinge and owned-magnet implementation is qualified in the Unity 6000. | 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, millimeter fit, validation, follower/reference round-trip, real `Player`/`PhysicsEngine` startup, shot creation, magnet control, release, reset, and teardown 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. diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs deleted file mode 100644 index f2305e4a0..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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; - -namespace VisualPinball.Unity.Editor -{ - [CustomEditor(typeof(SpringHingeAnimationComponent)), CanEditMultipleObjects] - public class SpringHingeAnimationInspector : UnityEditor.Editor - { - private SerializedProperty _emitter; - private SerializedProperty _rotationAxis; - - private void OnEnable() - { - _emitter = serializedObject.FindProperty(nameof(SpringHingeAnimationComponent._emitter)); - _rotationAxis = serializedObject.FindProperty(nameof(SpringHingeAnimationComponent.RotationAxis)); - } - - public override void OnInspectorGUI() - { - serializedObject.Update(); - EditorGUILayout.PropertyField(_emitter); - EditorGUILayout.PropertyField(_rotationAxis); - serializedObject.ApplyModifiedProperties(); - EditorGUILayout.HelpBox("Keep this moving transform below the fixed spring-hinge pivot. Put the visual toy and any owned magnet below this transform.", MessageType.Info); - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta deleted file mode 100644 index e5f7f3584..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAnimationInspector.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5d17bcc4c402479b8eadba0fdc0b2499 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs index d47c49518..7baa6621c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs @@ -14,8 +14,7 @@ namespace VisualPinball.Unity.Editor { public static class SpringHingeAuthoring { - private const float WorldToMillimeters = 1000f; - private const float StandardBallRadiusMillimeters = 25f; + private const float StandardBallRadiusVpx = 25f; [MenuItem("GameObject/Pinball/Add Spring Hinge", false, 12)] private static void AddSpringHingeMenu(MenuCommand command) @@ -47,15 +46,13 @@ public static GameObject CreateBashToy(Transform parent = null) var hinge = Undo.AddComponent(root); var proxy = Undo.AddComponent(root); - var animation = Undo.AddComponent(root); - animation._emitter = hinge; - animation.RotationAxis = hinge.HingeAxis; var visual = GameObject.CreatePrimitive(PrimitiveType.Cube); visual.name = "Toy Visual"; visual.transform.SetParent(root.transform, false); - visual.transform.localPosition = new Vector3(0f, -0.05f, 0f); - visual.transform.localScale = new Vector3(0.05f, 0.1f, 0.02f); + 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); @@ -63,7 +60,7 @@ public static GameObject CreateBashToy(Transform parent = null) var magnetObject = new GameObject("Owned Magnet"); magnetObject.transform.SetParent(root.transform, false); - magnetObject.transform.localPosition = new Vector3(0f, -0.1f, 0f); + magnetObject.transform.localPosition = Vector3.down * Physics.ScaleToWorld(100f); var magnet = Undo.AddComponent(magnetObject); ApplyBashPreset(hinge, proxy, magnet); @@ -84,11 +81,6 @@ public static GameObject AddSpringHinge(IReadOnlyList visualParts, ?? Undo.AddComponent(rotatingObject); var proxy = rotatingObject.GetComponent() ?? Undo.AddComponent(rotatingObject); - var animation = rotatingObject.GetComponent() - ?? Undo.AddComponent(rotatingObject); - animation._emitter = hinge; - animation.RotationAxis = hinge.HingeAxis; - foreach (var visualPart in visualParts) { if (!visualPart || visualPart == activeVisual || visualPart.IsChildOf(activeVisual) || IsAncestorSelected(visualPart, visualParts)) { @@ -174,7 +166,7 @@ public static void ApplyBashPreset(SpringHingeComponent hinge, magnet.GrabBall = true; magnet.GrabRadius = MagnetComponent.DefaultGrabRadius; magnet.CoupleToParentHinge = true; - magnet.HeldBallCentreOffset = Vector3.down * StandardBallRadiusMillimeters; + magnet.HeldBallCentreOffset = Vector3.down * StandardBallRadiusVpx; magnet.HoldStiffness = 2f; magnet.HoldDamping = 2f; magnet.MaxHoldForce = 10f; @@ -182,10 +174,10 @@ public static void ApplyBashPreset(SpringHingeComponent hinge, } public static bool TryGetVisualBounds(SpringHingeComponent hinge, - out Vector3 centreMillimeters, out Vector3 halfExtentsMillimeters) + out Vector3 centreVpx, out Vector3 halfExtentsVpx) { - centreMillimeters = Vector3.zero; - halfExtentsMillimeters = Vector3.zero; + centreVpx = Vector3.zero; + halfExtentsVpx = Vector3.zero; if (!hinge) { return false; } @@ -213,8 +205,8 @@ public static bool TryGetVisualBounds(SpringHingeComponent hinge, if (!found) { return false; } - centreMillimeters = (minimum + maximum) * (0.5f * WorldToMillimeters); - halfExtentsMillimeters = (maximum - minimum) * (0.5f * WorldToMillimeters); + centreVpx = (minimum + maximum) * (0.5f / Physics.ScaleInv); + halfExtentsVpx = (maximum - minimum) * (0.5f / Physics.ScaleInv); return true; } @@ -259,17 +251,6 @@ public static IReadOnlyList Validate(SpringHingeComponent hinge, issues.Add("The analytic box proxy needs three positive half-extents."); } - var localDriver = hinge.GetComponent(); - var drivers = hinge.GetComponentsInChildren(true); - var driverCount = 0; - foreach (var candidate in drivers) { - if (candidate._emitter == hinge) { - driverCount++; - } - } - if (!localDriver || localDriver._emitter != hinge || driverCount != 1) { - issues.Add("The rotating object must have exactly one Spring Hinge Transform on the same GameObject, driven by this hinge."); - } if (hinge.GetComponentInChildren(true)) { issues.Add("Remove hit-target animation from spring-hinge visuals; the hinge is their only animation driver."); } @@ -297,10 +278,10 @@ public static IReadOnlyList Validate(SpringHingeComponent hinge, private static bool IsHeldCentreInsideProxy(SpringHingeComponent hinge, SpringHingeColliderComponent proxy, MagnetComponent magnet) { - var world = magnet.transform.TransformPoint(magnet.HeldBallCentreOffset * 0.001f); - var hingeLocalMillimeters = hinge.transform.InverseTransformPoint(world) * WorldToMillimeters; + var world = magnet.transform.TransformPoint(magnet.HeldBallCentreOffset * Physics.ScaleInv); + var hingeLocalVpx = hinge.transform.InverseTransformPoint(world) / Physics.ScaleInv; var boxLocal = Quaternion.Inverse(Quaternion.Euler(proxy.LocalRotation)) - * (hingeLocalMillimeters - proxy.LocalCentre); + * (hingeLocalVpx - proxy.LocalCentre); return Mathf.Abs(boxLocal.x) < proxy.HalfExtents.x && Mathf.Abs(boxLocal.y) < proxy.HalfExtents.y && Mathf.Abs(boxLocal.z) < proxy.HalfExtents.z; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs index 72ebb6cce..cf1c201fc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeColliderInspector.cs @@ -47,6 +47,7 @@ protected override void OnEnable() 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); @@ -88,26 +89,26 @@ private void OnSceneGUI() } var hingePose = hinge.transform.localToWorldMatrix; - var centre = hingePose.MultiplyPoint3x4(proxy.LocalCentre * 0.001f); + 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 * 0.001f, centre, rotation, handleSize); + 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) * 1000f; - proxy.HalfExtents = Vector3.Max(resized * 1000f, Vector3.one * 0.001f); + 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 * 0.001f, + * 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 * 0.002f); + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * (2f * Physics.ScaleInv)); } } if (!Application.isPlaying) { @@ -125,10 +126,10 @@ private static void DrawSweep(SpringHingeComponent hinge, var rotation = Quaternion.AngleAxis(angle, axis); var matrix = hinge.transform.localToWorldMatrix * Matrix4x4.Rotate(rotation) - * Matrix4x4.TRS(proxy.LocalCentre * 0.001f, + * Matrix4x4.TRS(proxy.LocalCentre * Physics.ScaleInv, Quaternion.Euler(proxy.LocalRotation), Vector3.one); using (new Handles.DrawingScope(color, matrix)) { - Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * 0.002f); + Handles.DrawWireCube(Vector3.zero, proxy.HalfExtents * (2f * Physics.ScaleInv)); } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs index 8d6647a20..aac16b508 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeInspector.cs @@ -148,12 +148,12 @@ private void OnSceneGUI() Handles.DrawWireArc(pivot, worldAxis, start, hinge.MaximumAngle - hinge.MinimumAngle, radius); - var centreWorld = hinge.transform.TransformPoint(hinge.CentreOfMass * 0.001f); + 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) * 1000f; + hinge.CentreOfMass = hinge.transform.InverseTransformPoint(movedCentre) / Physics.ScaleInv; EditorUtility.SetDirty(hinge); } Handles.color = Color.yellow; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs index 710b9651a..d3de46683 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs @@ -184,6 +184,43 @@ public void FullTravelBoundContainsAllRotatedCorners() } } + [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 GeneratorRejectsShearedBoxFrame() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs index e59ba8f68..f42ebd50a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingePlayModeFixtureTests.cs @@ -91,10 +91,6 @@ private static Fixture CreateFixture() proxy.LocalCentre = new Vector3(0f, 50f, 0f); proxy.HalfExtents = new Vector3(25f, 50f, 10f); - var animation = hingeObject.AddComponent(); - animation._emitter = hinge; - animation.RotationAxis = Vector3.forward; - var magnetObject = new GameObject("Owned Magnet"); magnetObject.transform.SetParent(hingeObject.transform, false); magnetObject.transform.localPosition = new Vector3(0f, 0.05f, 0f); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs index ddb6fc0d2..9e32efa08 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -6,7 +6,6 @@ // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using Unity.Mathematics; @@ -24,18 +23,14 @@ public void BashSetupCreatesCompleteOwnedRotatingObject() try { var hinge = root.GetComponent(); var proxy = root.GetComponent(); - var animation = root.GetComponent(); var magnet = root.GetComponentInChildren(); Assert.That(hinge, Is.Not.Null); Assert.That(proxy, Is.Not.Null); - Assert.That(animation, Is.Not.Null); - Assert.That(animation.gameObject, Is.SameAs(root)); - Assert.That(animation._emitter, Is.SameAs(hinge)); 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.GetComponentInParent(), Is.SameAs(animation)); + Assert.That(magnet.GetComponentInParent(), Is.SameAs(proxy)); Assert.That(root.GetComponentInChildren(), Is.Null); Assert.That(SpringHingeAuthoring.Validate(hinge, proxy), Is.Empty); } finally { @@ -44,7 +39,7 @@ public void BashSetupCreatesCompleteOwnedRotatingObject() } [Test] - public void VisualBoundsFitMassAndProxyInMillimeters() + public void VisualBoundsFitMassAndProxyInVpxUnits() { var root = SpringHingeAuthoring.CreateBashToy(); try { @@ -97,7 +92,6 @@ public void AddSetupMovesOnlySelectedVisualsAndPreservesWorldPose() Assert.That(selected.GetComponent().enabled, Is.False); Assert.That(selected.GetComponent(), Is.Not.Null); 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); @@ -111,39 +105,21 @@ public void AddSetupMovesOnlySelectedVisualsAndPreservesWorldPose() } [Test] - public void SameObjectTransformDriverCachesRestRotationAndRoundTripsReferences() + 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 animation = root.AddComponent(); - animation._emitter = hinge; - animation.RotationAxis = Vector3.forward; - animation.CaptureInitialPose(); + var proxy = root.AddComponent(); + proxy.CaptureInitialPose(); - animation.ApplyAngle(math.PI / 2f); + 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)); - - var refs = new PackagedRefs(root.transform); - refs.SetNodeIdsForWrite(new Dictionary { - { root.transform, "hinge" } - }); - var values = animation.Pack(); - var references = animation.PackReferences(root.transform, refs, null); - animation.RotationAxis = Vector3.right; - animation._emitter = null; - animation.Unpack(values); - refs.SetNodeIdsForRead(new Dictionary { - { "hinge", root.transform } - }); - animation.UnpackReferences(references, root.transform, refs, null); - - Assert.That(animation.RotationAxis, Is.EqualTo(Vector3.forward)); - Assert.That(animation._emitter, Is.SameAs(hinge)); } finally { Object.DestroyImmediate(root); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 59a075e9e..16b9ce9b2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -119,8 +119,8 @@ public class MagnetComponent : MonoBehaviour, ICoilDeviceComponent, ISwitchDevic [Tooltip("Couple this Spatial Physical magnet reciprocally to its nearest parent spring hinge.")] public bool CoupleToParentHinge; - [Unit("mm")] - [Tooltip("Held ball centre relative to the magnet transform, expressed in millimeters at the authored rest pose.")] + [Unit("VPX")] + [Tooltip("Held ball centre relative to the magnet transform, expressed in VPX units at the authored rest pose.")] public Vector3 HeldBallCentreOffset; [Min(0f)] @@ -253,7 +253,7 @@ internal MagnetState CreateState() var pivot = hinge.ToPlayfieldVpx(hinge.transform.position); poleArm = hinge.ToPlayfieldVpx(transform.position) - pivot; heldCentreArm = hinge.ToPlayfieldVpx(transform.TransformPoint( - HeldBallCentreOffset * 0.001f)) - pivot; + HeldBallCentreOffset * Physics.ScaleInv)) - pivot; } return new MagnetState { Position = pos.xy, @@ -492,7 +492,7 @@ private void OnDrawGizmosSelected() } if (CoupleToParentHinge) { - var heldCentre = transform.TransformPoint(HeldBallCentreOffset * 0.001f); + var heldCentre = transform.TransformPoint(HeldBallCentreOffset * Physics.ScaleInv); Gizmos.color = new Color(0.2f, 1f, 0.45f, 0.9f); Gizmos.DrawLine(transform.position, heldCentre); Gizmos.DrawWireSphere(heldCentre, 0.006f); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs deleted file mode 100644 index 7849f0020..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs +++ /dev/null @@ -1,69 +0,0 @@ -// 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] - [PackAs("SpringHingeAnimation")] - [AddComponentMenu("Pinball/Animation/Spring Hinge Transform")] - public class SpringHingeAnimationComponent : AnimationComponent, IPackable - { - [Tooltip("Rotation axis in this moving transform's local frame.")] - public Vector3 RotationAxis = Vector3.right; - - private Quaternion _initialLocalRotation; - private bool _poseCaptured; - - public byte[] Pack() => SpringHingeAnimationPackable.Pack(this); - - public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) - => SpringHingeAnimationReferencesPackable.Pack(this, refs); - - public void Unpack(byte[] bytes) => SpringHingeAnimationPackable.Unpack(bytes, this); - - public void UnpackReferences(byte[] bytes, Transform root, PackagedRefs refs, PackagedFiles files) - => SpringHingeAnimationReferencesPackable.Unpack(bytes, this, refs); - - protected override void Awake() - { - base.Awake(); - CaptureInitialPose(); - } - - protected override void OnAnimationValueChanged(float angle) => ApplyAngle(angle); - - internal void CaptureInitialPose() - { - _initialLocalRotation = transform.localRotation; - _poseCaptured = true; - } - - internal void ApplyAngle(float angle) - { - if (!_poseCaptured) { - CaptureInitialPose(); - } - var axis = math.normalizesafe((float3)RotationAxis, new float3(1f, 0f, 0f)); - transform.localRotation = _initialLocalRotation - * Quaternion.AngleAxis(math.degrees(angle), axis); - } - -#if UNITY_EDITOR - protected override void OnValidate() - { - base.OnValidate(); - if (math.lengthsq((float3)RotationAxis) < 1e-8f) { - RotationAxis = Vector3.right; - } - } -#endif - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta deleted file mode 100644 index 73b319806..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeAnimationComponent.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 750ff807a0f248119bd2784ff516ff38 -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 index 54281b296..0201a11dd 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -17,15 +17,19 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mechs/Spring Hinge Collider")] public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent, IPackable { - [Unit("mm")] - [Tooltip("Collision-box centre in the hinge's local frame.")] + private SpringHingeComponent _hinge; + private Quaternion _initialLocalRotation; + private bool _poseCaptured; + + [Unit("VPX")] + [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; - [Unit("mm")] - [Tooltip("Collision-box half-extents in its local frame.")] + [Unit("VPX")] + [Tooltip("Collision-box half-extents in its local frame, in VPX units.")] public Vector3 HalfExtents = new(25f, 50f, 10f); [SerializeField] @@ -51,7 +55,10 @@ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles fi public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) => SpringHingeColliderReferencesPackable.Unpack(data, this, files); - public int ItemId => GetComponent().ItemId; + 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); @@ -64,18 +71,54 @@ public bool CollidersDirty { 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 = GetComponent(); + var hinge = Hinge; if (!hinge) { return; } @@ -83,15 +126,15 @@ private void OnDrawGizmosSelected() 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 * 0.001f, + * 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 * 0.002f); + Gizmos.DrawCube(Vector3.zero, HalfExtents * (2f * Physics.ScaleInv)); Gizmos.color = new Color32(0, 255, 75, 230); - Gizmos.DrawWireCube(Vector3.zero, HalfExtents * 0.002f); + Gizmos.DrawWireCube(Vector3.zero, HalfExtents * (2f * Physics.ScaleInv)); Gizmos.matrix = previousMatrix; Gizmos.color = previousColor; } @@ -103,7 +146,7 @@ void ICollidableComponent.GetColliders(Player player, PhysicsEngine physicsEngin if (!IsCollidable) { return; } - var hinge = GetComponent(); + var hinge = Hinge; var api = hinge.SpringHingeApi ?? new SpringHingeApi(hinge, physicsEngine); ((IApiColliderGenerator)api).CreateColliders(ref colliders, float4x4.identity, margin); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs index 134e6394d..793b6d5ef 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderGenerator.cs @@ -14,7 +14,6 @@ namespace VisualPinball.Unity { internal static class SpringHingeColliderGenerator { - private const float MillimetersToWorld = 0.001f; private const float OrthogonalityTolerance = 1e-4f; internal static SpringHingeCollider Create(SpringHingeComponent hinge, @@ -23,14 +22,14 @@ internal static SpringHingeCollider Create(SpringHingeComponent hinge, var referenceMatrix = hinge.ReferenceLocalToWorldMatrix; var pivot = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(Vector3.zero)); var centre = hinge.ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4( - collider.LocalCentre * MillimetersToWorld)); + collider.LocalCentre * Physics.ScaleInv)); var localRotation = Quaternion.Euler(collider.LocalRotation); var halfAxisX = hinge.ToPlayfieldVector(localRotation - * (Vector3.right * (collider.HalfExtents.x * MillimetersToWorld))); + * (Vector3.right * (collider.HalfExtents.x * Physics.ScaleInv))); var halfAxisY = hinge.ToPlayfieldVector(localRotation - * (Vector3.up * (collider.HalfExtents.y * MillimetersToWorld))); + * (Vector3.up * (collider.HalfExtents.y * Physics.ScaleInv))); var halfAxisZ = hinge.ToPlayfieldVector(localRotation - * (Vector3.forward * (collider.HalfExtents.z * MillimetersToWorld))); + * (Vector3.forward * (collider.HalfExtents.z * Physics.ScaleInv))); var lengthX = math.length(halfAxisX); var lengthY = math.length(halfAxisY); var lengthZ = math.length(halfAxisZ); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs index c3a2b284e..d87b18914 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -22,15 +22,14 @@ namespace VisualPinball.Unity [AddComponentMenu("Pinball/Mechs/Spring Hinge")] public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter, IPackable, ISwitchDeviceComponent { - private const float MillimetersToWorld = 0.001f; 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; - [Unit("mm")] - [Tooltip("Unloaded toy centre of mass relative to the pivot, in this object's local frame.")] + [Unit("VPX")] + [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)] @@ -44,8 +43,8 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter [Tooltip("Moment of inertia about the hinge axis in ball-mass times VPX-unit squared.")] public float ManualInertia = 2500f; - [Unit("mm")] - [Tooltip("Half-extents of the box used to estimate unloaded toy inertia.")] + [Unit("VPX")] + [Tooltip("Half-extents of the box used to estimate unloaded toy inertia, in VPX units.")] public Vector3 MassBoxHalfExtents = new(25f, 50f, 10f); [Min(0f)] @@ -152,7 +151,7 @@ internal SpringHingeState CreateState() var pivot = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4(Vector3.zero)); var axis = ToPlayfieldDirection(HingeAxis); var centreOfMass = ToPlayfieldVpx(ReferenceLocalToWorldMatrix.MultiplyPoint3x4( - CentreOfMass * MillimetersToWorld)); + 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); @@ -201,9 +200,9 @@ private float EstimateInertia(float3 axis) var y = ToPlayfieldDirection(Vector3.up); var z = ToPlayfieldDirection(Vector3.forward); var halfExtents = new float3( - Physics.ScaleToVpx(MassBoxHalfExtents.x * MillimetersToWorld * math.abs(transform.lossyScale.x)), - Physics.ScaleToVpx(MassBoxHalfExtents.y * MillimetersToWorld * math.abs(transform.lossyScale.y)), - Physics.ScaleToVpx(MassBoxHalfExtents.z * MillimetersToWorld * math.abs(transform.lossyScale.z))); + 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, @@ -213,7 +212,7 @@ private float EstimateInertia(float3 axis) math.pow(math.dot(axis, y), 2f), math.pow(math.dot(axis, z), 2f))); var referenceMatrix = ReferenceLocalToWorldMatrix; - var centreArm = ToPlayfieldVpx(referenceMatrix.MultiplyPoint3x4(CentreOfMass * MillimetersToWorld)) + 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)); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs index dd469f0cc..37b131c45 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingePackable.cs @@ -154,51 +154,4 @@ public static void Unpack(byte[] bytes, SpringHingeColliderComponent comp, } } - public struct SpringHingeAnimationPackable - { - private const int CurrentVersion = 1; - - public int Version; - public PackableFloat3 RotationAxis; - - public static byte[] Pack(SpringHingeAnimationComponent comp) - { - return PackageApi.Packer.Pack(new SpringHingeAnimationPackable { - Version = CurrentVersion, - RotationAxis = comp.RotationAxis - }); - } - - public static void Unpack(byte[] bytes, SpringHingeAnimationComponent comp) - { - var data = PackageApi.Packer.Unpack(bytes); - comp.RotationAxis = data.RotationAxis; - } - } - - public struct SpringHingeAnimationReferencesPackable - { - public ReferencePackable EmitterRef; - - public static byte[] Pack(SpringHingeAnimationComponent comp, PackagedRefs refs) - { - var emitterRef = new ReferencePackable(null, null); - if (comp._emitter != null) { - if (refs.HasType(comp._emitter.GetType())) { - emitterRef = refs.PackReference(comp._emitter); - } else { - Debug.LogWarning($"Cannot package spring-hinge animation emitter {comp._emitter.GetType().FullName} on '{comp.name}' because it has no PackAs attribute; writing a null reference.", comp); - } - } - return PackageApi.Packer.Pack(new SpringHingeAnimationReferencesPackable { - EmitterRef = emitterRef - }); - } - - public static void Unpack(byte[] bytes, SpringHingeAnimationComponent comp, PackagedRefs refs) - { - var data = PackageApi.Packer.Unpack(bytes); - comp._emitter = refs.Resolve>(data.EmitterRef); - } - } } From b2121283c99cc2339b663177f2fda166b57d6a30 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 14:40:30 +0200 Subject: [PATCH 11/16] spinner: Fix collision speed and direction --- .../VPT/Spinner/SpinnerColliderInspector.cs | 3 - .../VPT/SpinnerTests.cs | 117 ++++++++++++++++++ .../Game/PhysicsStaticCollision.cs | 3 +- .../VPT/Spinner/SpinnerCollider.cs | 26 ++-- .../VPT/Spinner/SpinnerColliderComponent.cs | 4 - .../VPT/Spinner/SpinnerColliderGenerator.cs | 2 +- .../VPT/Spinner/SpinnerComponent.cs | 6 +- .../VPT/Spinner/SpinnerMovementState.cs | 1 - .../VPT/Spinner/SpinnerPackable.cs | 3 - .../Spinner/SpinnerPlateAnimationComponent.cs | 3 +- .../VPT/Spinner/SpinnerStaticState.cs | 1 - 11 files changed, 140 insertions(+), 29 deletions(-) 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(); + 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/Game/PhysicsStaticCollision.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs index 0b53a4577..eece81766 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticCollision.cs @@ -150,7 +150,8 @@ private static void Collide(ref NativeColliders colliders, ref BallState ball, r 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: 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; From dd259a3a83d628c77adf93f7c08d594345b55b56 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 15:19:58 +0200 Subject: [PATCH 12/16] magnet: fix owned spring hinge authoring --- .../manual/mechanisms/spring-hinges.md | 8 +- .../VPT/Magnet/MagnetInspector.cs | 80 +++++++++++++++-- .../VPT/SpringHinge/SpringHingeAuthoring.cs | 80 ++++++++++++++--- .../Physics/SpringHingeColliderTests.cs | 29 ++++++ .../SpringHinge/SpringHingeAuthoringTests.cs | 90 +++++++++++++++++++ .../Common/AssemblyInfo.cs | 1 + .../VPT/Magnet/MagnetComponent.cs | 29 +++--- .../SpringHingeColliderComponent.cs | 2 - .../VPT/SpringHinge/SpringHingeComponent.cs | 2 - 9 files changed, 279 insertions(+), 42 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md index bd3df8c95..029b7abba 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -97,9 +97,11 @@ The box is tested continuously against the ball, including its faces, edges, cor ## 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** and **Physical**, then enable **Couple To Parent Hinge**. 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. +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. Place it outside the box at the intended collision face; for a standard 25-unit-radius ball, begin one radius beyond the face. Adjust it for a different ball radius. The green scene marker shows the target. +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. Tune the field to attract the ball, then tune capacity and compliance so the intended shot captures without living at the force cap. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. @@ -111,7 +113,7 @@ Test both magnet-off impacts and magnet-on capture, a timed release in each trav - 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 Physical magnet may own one ball. Other balls remain free and can strike the toy or held ball. +- 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/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index 24f9a2264..09133c1f7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -16,6 +16,7 @@ using UnityEditor; using UnityEngine; +using VisualPinball.Engine.Common; namespace VisualPinball.Unity.Editor { @@ -45,6 +46,8 @@ public class MagnetInspector : ItemInspector private SerializedProperty _holdStiffnessProperty; private SerializedProperty _holdDampingProperty; private SerializedProperty _maxHoldForceProperty; + private IApiCoil _runtimeCoil; + private bool? _lastRuntimeCoilStatus; protected override MonoBehaviour UndoTarget => target as MonoBehaviour; @@ -77,8 +80,15 @@ protected override void OnEnable() _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) { @@ -93,7 +103,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); @@ -147,8 +156,7 @@ public override void OnInspectorGUI() PropertyField(_holdStiffnessProperty); PropertyField(_holdDampingProperty); PropertyField(_maxHoldForceProperty); - DrawOwnedModeValidation(isSpatial, - _forceProfileProperty.enumValueIndex == (int)MagnetForceProfile.Physical); + DrawOwnedModeValidation(isSpatial); } EditorGUILayout.Space(8f); @@ -163,7 +171,43 @@ public override void OnInspectorGUI() EndEditing(); } - private void DrawOwnedModeValidation(bool isSpatial, bool usesOwnedPhysicalResponse) + 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; @@ -174,11 +218,10 @@ private void DrawOwnedModeValidation(bool isSpatial, bool usesOwnedPhysicalRespo if (!owner) { EditorGUILayout.HelpBox("Owned mode requires a parent Spring Hinge.", MessageType.Error); } - if (!isSpatial || !usesOwnedPhysicalResponse) { - EditorGUILayout.HelpBox("Owned mode requires Spatial type and Physical response.", MessageType.Error); - if (GUILayout.Button("Use Spatial Physical Mode")) { + if (!isSpatial) { + EditorGUILayout.HelpBox("Owned mode requires a Spatial magnet.", MessageType.Error); + if (GUILayout.Button("Use Spatial Mode")) { _magnetTypeProperty.enumValueIndex = (int)MagnetType.Spatial; - _forceProfileProperty.enumValueIndex = (int)MagnetForceProfile.Physical; } } if (owner) { @@ -191,6 +234,27 @@ private void DrawOwnedModeValidation(bool isSpatial, bool usesOwnedPhysicalRespo if (ownedCount > 1) { EditorGUILayout.HelpBox("Only one owned magnet is supported per spring hinge.", MessageType.Error); } + + 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; + } + } + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs index 7baa6621c..d507b1fe0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs @@ -6,7 +6,9 @@ // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. +using System; using System.Collections.Generic; +using Unity.Mathematics; using UnityEditor; using UnityEngine; @@ -262,11 +264,8 @@ public static IReadOnlyList Validate(SpringHingeComponent hinge, continue; } ownedCount++; - if (magnet.MagnetType != MagnetType.Spatial || magnet.ForceProfile != MagnetForceProfile.Physical) { - issues.Add($"Owned magnet '{magnet.name}' must use Spatial type and Physical response."); - } - if (proxy && IsHeldCentreInsideProxy(hinge, proxy, magnet)) { - issues.Add($"Owned magnet '{magnet.name}' has its held ball centre inside the analytic box proxy."); + if (magnet.MagnetType != MagnetType.Spatial) { + issues.Add($"Owned magnet '{magnet.name}' must use Spatial type."); } } if (ownedCount > 1) { @@ -275,16 +274,69 @@ public static IReadOnlyList Validate(SpringHingeComponent hinge, return issues; } - private static bool IsHeldCentreInsideProxy(SpringHingeComponent hinge, - SpringHingeColliderComponent proxy, MagnetComponent magnet) + 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 world = magnet.transform.TransformPoint(magnet.HeldBallCentreOffset * Physics.ScaleInv); - var hingeLocalVpx = hinge.transform.InverseTransformPoint(world) / Physics.ScaleInv; - var boxLocal = Quaternion.Inverse(Quaternion.Euler(proxy.LocalRotation)) - * (hingeLocalVpx - proxy.LocalCentre); - return Mathf.Abs(boxLocal.x) < proxy.HalfExtents.x - && Mathf.Abs(boxLocal.y) < proxy.HalfExtents.y - && Mathf.Abs(boxLocal.z) < proxy.HalfExtents.z; + var playfield = hinge.GetComponentInParent(); + return playfield ? point.TranslateToWorld(playfield.transform) : point.TranslateToWorld(); } private static bool HasRigidFrame(Transform transform) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs index d3de46683..37e263e6b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs @@ -221,6 +221,35 @@ public void ComponentGeometryUsesAuthoredVpxUnits() } } + [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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs index 9e32efa08..eaabeadb4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -38,6 +38,96 @@ public void BashSetupCreatesCompleteOwnedRotatingObject() } } + [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() { 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/VPT/Magnet/MagnetComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs index 16b9ce9b2..2b1839964 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetComponent.cs @@ -116,10 +116,9 @@ 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 Physical magnet reciprocally to its nearest parent spring hinge.")] + [Tooltip("Couple this Spatial magnet reciprocally to its nearest parent spring hinge.")] public bool CoupleToParentHinge; - [Unit("VPX")] [Tooltip("Held ball centre relative to the magnet transform, expressed in VPX units at the authored rest pose.")] public Vector3 HeldBallCentreOffset; @@ -228,18 +227,16 @@ internal MagnetState CreateState() 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 - && ForceProfile == MagnetForceProfile.Physical; + var validOwnedMode = hinge && MagnetType == VisualPinball.Unity.MagnetType.Spatial; if (CoupleToParentHinge && !validOwnedMode) { - Logger.Error($"Magnet {name} can couple only as a Spatial Physical child of a spring hinge."); + 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 - && ownedMagnets[i].ForceProfile == MagnetForceProfile.Physical) { + && ownedMagnets[i].MagnetType == VisualPinball.Unity.MagnetType.Spatial) { ownedCount++; } } @@ -252,8 +249,7 @@ internal MagnetState CreateState() if (validOwnedMode) { var pivot = hinge.ToPlayfieldVpx(hinge.transform.position); poleArm = hinge.ToPlayfieldVpx(transform.position) - pivot; - heldCentreArm = hinge.ToPlayfieldVpx(transform.TransformPoint( - HeldBallCentreOffset * Physics.ScaleInv)) - pivot; + heldCentreArm = hinge.ToPlayfieldVpx(GetHeldBallCentreWorldPosition()) - pivot; } return new MagnetState { Position = pos.xy, @@ -276,7 +272,7 @@ 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, @@ -337,7 +333,6 @@ private void SyncPhysicsState() bool IKinematicTransformComponent.IsKinematic => IsKinematic && !(CoupleToParentHinge && MagnetType == VisualPinball.Unity.MagnetType.Spatial - && ForceProfile == MagnetForceProfile.Physical && GetComponentInParent()); // The physics engine disables colliders by item ID when this returns false. @@ -434,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) { @@ -492,10 +495,10 @@ private void OnDrawGizmosSelected() } if (CoupleToParentHinge) { - var heldCentre = transform.TransformPoint(HeldBallCentreOffset * Physics.ScaleInv); + var heldCentre = GetHeldBallCentreWorldPosition(); Gizmos.color = new Color(0.2f, 1f, 0.45f, 0.9f); Gizmos.DrawLine(transform.position, heldCentre); - Gizmos.DrawWireSphere(heldCentre, 0.006f); + DrawVpxSphere(WorldToVpx(heldCentre), 25f); } if (MagnetType != VisualPinball.Unity.MagnetType.Cylindrical && diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs index 0201a11dd..c834ffdde 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeColliderComponent.cs @@ -21,14 +21,12 @@ public class SpringHingeColliderComponent : MonoBehaviour, ICollidableComponent, private Quaternion _initialLocalRotation; private bool _poseCaptured; - [Unit("VPX")] [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; - [Unit("VPX")] [Tooltip("Collision-box half-extents in its local frame, in VPX units.")] public Vector3 HalfExtents = new(25f, 50f, 10f); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs index d87b18914..55c974f16 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeComponent.cs @@ -28,7 +28,6 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter [Tooltip("Fixed hinge axis in this object's local frame.")] public Vector3 HingeAxis = Vector3.right; - [Unit("VPX")] [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); @@ -43,7 +42,6 @@ public class SpringHingeComponent : MonoBehaviour, IAnimationValueEmitter [Tooltip("Moment of inertia about the hinge axis in ball-mass times VPX-unit squared.")] public float ManualInertia = 2500f; - [Unit("VPX")] [Tooltip("Half-extents of the box used to estimate unloaded toy inertia, in VPX units.")] public Vector3 MassBoxHalfExtents = new(25f, 50f, 10f); From 01a60491c7395391b3539ed67861564a1183c348 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 15:44:43 +0200 Subject: [PATCH 13/16] magnet: fix spring hinge ball capture --- .../manual/mechanisms/spring-hinges.md | 2 +- .../VPT/Magnet/MagnetInspector.cs | 34 ++++++ .../VPT/SpringHinge/SpringHingeAuthoring.cs | 11 +- .../Physics/OwnedMagnetPhysicsTests.cs | 103 ++++++++++++++++++ .../SpringHinge/SpringHingeAuthoringTests.cs | 4 + .../VPT/Magnet/OwnedMagnetPhysics.cs | 49 ++++++--- 6 files changed, 185 insertions(+), 18 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md index 029b7abba..be8ff0402 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -103,7 +103,7 @@ The magnet transform is the moving pole. **Held Ball Centre Offset** is a separa 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. Tune the field to attract the ball, then tune capacity and compliance so the intended shot captures without living at the force cap. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. +**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. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. ## Validate in Play Mode diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs index 09133c1f7..c57955e2c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Magnet/MagnetInspector.cs @@ -16,6 +16,7 @@ using UnityEditor; using UnityEngine; +using float3 = global::Unity.Mathematics.float3; using VisualPinball.Engine.Common; namespace VisualPinball.Unity.Editor @@ -23,6 +24,8 @@ 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; @@ -233,6 +236,8 @@ private void DrawOwnedModeValidation(bool isSpatial) } if (ownedCount > 1) { EditorGUILayout.HelpBox("Only one owned magnet is supported per spring hinge.", MessageType.Error); + } else if (isSpatial) { + DrawCaptureEstimate(); } var proxy = owner.GetComponent(); @@ -258,6 +263,35 @@ private void DrawOwnedModeValidation(bool isSpatial) } } + 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/SpringHinge/SpringHingeAuthoring.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs index d507b1fe0..142f2a94e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/SpringHinge/SpringHingeAuthoring.cs @@ -17,6 +17,10 @@ namespace VisualPinball.Unity.Editor public static class SpringHingeAuthoring { private const float StandardBallRadiusVpx = 25f; + private const float BashMagnetStrength = 40000f; + private const float BashMagnetHoldStiffness = 4f; + private const float BashMagnetHoldDamping = 4f; + private const float BashMagnetMaxHoldForce = 50f; [MenuItem("GameObject/Pinball/Add Spring Hinge", false, 12)] private static void AddSpringHingeMenu(MenuCommand command) @@ -164,14 +168,15 @@ public static void ApplyBashPreset(SpringHingeComponent hinge, 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 = 2f; - magnet.HoldDamping = 2f; - magnet.MaxHoldForce = 10f; + magnet.HoldStiffness = BashMagnetHoldStiffness; + magnet.HoldDamping = BashMagnetHoldDamping; + magnet.MaxHoldForce = BashMagnetMaxHoldForce; magnet.IsKinematic = false; } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs index 86310e188..774c81be8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs @@ -228,6 +228,109 @@ public void CaptureRequiresAvailableWorkEvenAtZeroRelativeSpeed() 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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs index eaabeadb4..3782336f8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/SpringHinge/SpringHingeAuthoringTests.cs @@ -30,6 +30,10 @@ public void BashSetupCreatesCompleteOwnedRotatingObject() 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs index 9cd202360..11cd503f1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs @@ -356,6 +356,16 @@ internal static bool CanCapture(in BallState ball, in MagnetState magnet, => 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) { @@ -365,19 +375,8 @@ private static bool CanCaptureWithin(in BallState ball, in MagnetState magnet, || !math.all(math.isfinite(ball.Position)) || !math.all(math.isfinite(ball.Velocity))) { return false; } - var delta = ball.Position - pole; - var distanceSq = math.lengthsq(delta); - if (distanceSq <= MinimumValue || distanceSq >= magnet.Radius * magnet.Radius) { - return false; - } - var distance = math.sqrt(distanceSq); - var cutoff = CompactSupport(distanceSq, magnet.Radius * magnet.Radius); - var fieldForce = MagnetPhysics.PhysicalForceMagnitude(distance, 0f, cutoff, in magnet) - * ball.Mass; - var holdForce = math.max(0f, magnet.MaxHoldForce) - * magnet.EffectiveCurrent * magnet.EffectiveCurrent; - var availableWork = math.min(fieldForce, holdForce) - * math.max(0f, workRadius - math.distance(ball.Position, target)); + var availableWork = AvailableCaptureWork(in magnet, in pole, ball.Position, + target, workRadius, ball.Mass); if (availableWork <= 0f) { return false; } @@ -396,6 +395,28 @@ private static bool CanCaptureWithin(in BallState ball, in MagnetState magnet, 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) { @@ -410,7 +431,7 @@ private static bool HasValidProxyGap(in BallState ball, in SpringHingeState hing 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.Embedded) { + && currentGap >= -PhysicsConstants.PhysTouch) { return true; } } From bc2acfe3f980c129351a82c0d1897989f8f81aec Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 16:09:34 +0200 Subject: [PATCH 14/16] magnet: damp spin while held by spring hinge --- .../manual/mechanisms/spring-hinges.md | 2 +- .../Physics/OwnedMagnetPhysicsTests.cs | 20 +++++++++++++++++++ .../VPT/Magnet/MagnetPhysics.cs | 10 ++++++++-- .../VPT/Magnet/OwnedMagnetPhysics.cs | 1 + 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md index be8ff0402..03d819ad7 100644 --- a/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md +++ b/VisualPinball.Unity/Documentation~/creators-guide/manual/mechanisms/spring-hinges.md @@ -103,7 +103,7 @@ The magnet transform is the moving pole. **Held Ball Centre Offset** is a separa 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. Turning the coil off honors coil decay before release. Release preserves ball and hinge velocity. +**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 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs index 774c81be8..2f0bbaf5f 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/OwnedMagnetPhysicsTests.cs @@ -83,6 +83,26 @@ public void HoldCapIsAProjectedVectorImpulse() 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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/MagnetPhysics.cs index 551f34052..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; @@ -360,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); } @@ -389,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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs index 11cd503f1..19fd710a8 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Magnet/OwnedMagnetPhysics.cs @@ -340,6 +340,7 @@ internal static bool SolveHold(ref BallState ball, ref SpringHingeState hinge, 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; From 5bb8201d319e973d3e56fd160b4a48534e176016 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 12 Sep 2026 21:34:42 +0200 Subject: [PATCH 15/16] physics: stabilize kinematic ball handoffs --- .../Physics/PhysicsKinematicsTests.cs | 237 ++++++++++++++++++ .../Physics/PhysicsKinematicsTests.cs.meta | 2 + .../Physics/PhysicsRegressionTests.cs | 85 +++++++ .../Game/KinematicVelocityState.cs | 16 ++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 62 +++++ .../Game/PhysicsEngineThreading.cs | 41 +-- .../Game/PhysicsKinematics.cs | 115 ++++++--- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 2 +- .../VPT/Ball/BallCollider.cs | 25 +- .../VPT/Kicker/KickerApi.cs | 93 +++---- 10 files changed, 573 insertions(+), 105 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/PhysicsKinematicsTests.cs.meta 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/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/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index c2fe055e6..29487defe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -806,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 @@ -869,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); } @@ -876,6 +899,7 @@ internal BallComponent UnregisterRuntimeBall(int ballId) } _ctx.BallStates.Ref.Remove(ballId); + ReleaseDestroyedBallFromKickers(ballId, _ctx.KickerStates.Ref); ReleaseDestroyedBallFromMagnets(ballId); _ctx.InsideOfs.SetOutsideOfAll(ballId); @@ -898,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/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 0a9fa8d22..b1c09a9d4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -350,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(); } @@ -372,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) { @@ -449,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; @@ -457,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; } @@ -465,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; } @@ -474,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; } } @@ -901,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; } 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/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs index fc62815ac..7093a377a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs @@ -101,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)); 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/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 From 2a4e8f227b73765475c1fbccdfd2dcd3561fcae1 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 13 Sep 2026 00:40:34 +0200 Subject: [PATCH 16/16] spring-hinge: reject speculative fallback impacts --- .../Physics/SpringHingeColliderTests.cs | 13 +++++++++++++ .../VPT/SpringHinge/SpringHingeCollider.cs | 19 ++----------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs index 37e263e6b..7a11f942a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeColliderTests.cs @@ -80,6 +80,19 @@ public void HighSpeedSweepUsesConservativeFallbackWithoutZeroTimeImpact() 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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs index 1d18aec17..2cff9e0c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/SpringHinge/SpringHingeCollider.cs @@ -143,8 +143,7 @@ internal float HitTest(ref CollisionEventData collEvent, in SpringHingeState hin if (!needsFallback && (time >= maxTime || iterations < ConservativeIterations)) { return -1f; } - return FallbackHitTest(ref collEvent, in hinge, in ball, time, in distance, - maxTime, rateBound); + return FallbackHitTest(ref collEvent, in hinge, in ball, time, maxTime); } internal void Collide(ref BallState ball, ref SpringHingeState hinge, @@ -285,11 +284,9 @@ internal void Contact(ref BallState ball, ref SpringHingeState hinge, } private float FallbackHitTest(ref CollisionEventData collEvent, in SpringHingeState hinge, - in BallState ball, float startTime, in SpringHingeDistance startDistance, - float maxTime, float rateBound) + in BallState ball, float startTime, float maxTime) { var previousTime = startTime; - var previous = startDistance; 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); @@ -299,19 +296,7 @@ private float FallbackHitTest(ref CollisionEventData collEvent, in SpringHingeSt ball.Position + ball.Velocity * refined, ball.Radius, refined); return PopulateHit(ref collEvent, in hinge, in ball, in refinedDistance, refined); } - var interval = time - previousTime; - if (math.min(previous.Separation, distance.Separation) <= rateBound * interval) { - var speculativeTime = math.min(maxTime, math.max(previousTime, TimeEpsilon)); - var speculativeDistance = Distance(in hinge, - ball.Position + ball.Velocity * speculativeTime, ball.Radius, speculativeTime); - var hit = PopulateHit(ref collEvent, in hinge, in ball, - in speculativeDistance, speculativeTime); - if (hit >= 0f) { - return hit; - } - } previousTime = time; - previous = distance; } return -1f; }