From 62b4b5f13c4727fa81272e4313688cc0c0f22216 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Thu, 25 Jun 2026 22:58:05 +0200 Subject: [PATCH] test(physics): characterise mass-ratio + no-CCD envelope (SG-MR3, SG-X5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the two TGS-Soft operating limits the W9 review flagged as undocumented and ungated. Both stay finite/stable — they are accuracy/coverage gaps, not bugs. - SG-MR3: at a ~100:1 mass ratio a crushed light box settles a small, bounded distance into the floor (~1.4px, measured here) and its centre never sinks below the surface. Past ~100:1 the velocity-capped soft push-out (maxBiasVelocity) lets penetration grow sharply (~6px at 500:1, fully through by ~5000:1). Pins the envelope edge so a regression into deep penetration at 100:1 is caught. - SG-X5: a body moving farther than the floor thickness in one frame tunnels through it (detection runs once per frame — no CCD) but stays finite. Pins the finiteness contract for fast bodies. Document the operating envelope (mass-ratio ceiling, no-CCD tunnelling, load-bearing subStepCount >= 2) on PhysicsWorld and tighten the subStepCount option doc. No solver behaviour change; physics is not in build-api so there is no api-mdx delta. Gates green: physics test (90 passed), typecheck, lint, format, docs:api:check. No version bump / tag / publish. --- packages/exojs-physics/src/PhysicsWorld.ts | 22 ++++++++- packages/exojs-physics/test/dynamics.test.ts | 48 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/exojs-physics/src/PhysicsWorld.ts b/packages/exojs-physics/src/PhysicsWorld.ts index 310ab065..2febb82d 100644 --- a/packages/exojs-physics/src/PhysicsWorld.ts +++ b/packages/exojs-physics/src/PhysicsWorld.ts @@ -24,7 +24,12 @@ export interface PhysicsWorldOptions { fixedDelta?: number; /** Maximum fixed steps per `step` call (spiral-of-death guard). Default `8`. */ maxSubSteps?: number; - /** TGS-Soft sub-steps per fixed step (the solver's stiffness scales with this, not iteration count). Default `4`. Must be ≥ 1. */ + /** + * TGS-Soft sub-steps per fixed step (the solver's stiffness scales with this, + * not iteration count). Default `4`. Must be ≥ 1. Values below `2` visibly + * degrade tall-stack stability (a 10-box tower jitters at `1`), so the default + * is load-bearing — do not lower it for performance. + */ subStepCount?: number; /** Soft-contact stiffness in Hz (the contact behaves as a damped spring at this frequency). Default `30`. */ contactHertz?: number; @@ -86,6 +91,21 @@ export interface AttachOptions { * stiffness from the iteration count keeps tall towers stable. The detection * backend sits behind an internal seam, so the solver is swappable without * touching this public surface. + * + * **Operating envelope.** The soft solver trades a little accuracy for + * robustness, so it has a few documented limits — each stays finite/stable and + * each is pinned by a gate in `dynamics.test.ts`: + * - **Mass ratio** — resting stacks are slop-accurate up to ~100:1. Beyond that + * the velocity-capped soft push-out (`maxBiasVelocity`) lets the lighter body + * settle progressively deeper (≈6px at 500:1, fully through a thin floor by + * ~5000:1) — always finite, never exploding (SG-MR3). + * - **No CCD** — detection runs once per fixed step with no swept test, so a + * body that travels farther than an obstacle's thickness in one step tunnels + * straight through it (it stays finite). Reliably stopping fast projectiles is + * a future bullet-mode feature (SG-X5). + * - **{@link PhysicsWorldOptions.subStepCount}** — the default `4` is + * load-bearing for tall-stack stability; lowering it below `2` visibly + * degrades stacking, so do not reduce it for performance. */ export class PhysicsWorld implements BodyOwner { /** Fires when two solid colliders begin touching. Argument is an immutable snapshot. */ diff --git a/packages/exojs-physics/test/dynamics.test.ts b/packages/exojs-physics/test/dynamics.test.ts index 9a2a93dc..cd3f8e33 100644 --- a/packages/exojs-physics/test/dynamics.test.ts +++ b/packages/exojs-physics/test/dynamics.test.ts @@ -451,6 +451,34 @@ describe('SG-MR — mass ratios', () => { expect(floorPenetration).toBeLessThanOrEqual(1); expect(light.y).toBeLessThan(floorTop); // light box centre still above the floor surface }); + + it('SG-MR3: characterises the ~100:1 envelope edge — a crushed light box stays shallowly bounded above the floor (no tunnelling)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const size = 32; + const floorTop = 300; + + addFloor(world, floorTop); + + const light = addBox(world, 0, floorTop - size / 2 - 1, size, size, { density: 1 }); + addBox(world, 0, floorTop - size - size / 2 - 2, size, size, { density: 100 }); + + advance(world, 5); + + expectAllFinite(world); + + // ~100:1 is the top of the supported resting envelope: the squeezed light box + // settles a small, bounded distance into the floor (~1.4px here — a few times + // the 0.25px slop, but far less than its 16px half-extent) and its centre + // never sinks below the surface. Past this ratio the velocity-capped soft + // push-out (`maxBiasVelocity`, ContactSolver.ts) lets penetration grow + // sharply (≈6px at 500:1, fully through by ~5000:1) — a documented + // soft-constraint tradeoff, not a defect (see the operating-envelope note on + // PhysicsWorld). This gate pins the edge so a regression into deep + // penetration at 100:1 is caught. + const floorPenetration = light.y + size / 2 - floorTop; + expect(floorPenetration).toBeLessThanOrEqual(2); + expect(light.y).toBeLessThan(floorTop); + }); }); // ── SG-K: kinematic interaction ─────────────────────────────────────────── @@ -574,6 +602,26 @@ describe('SG-X — failure safety', () => { expect(starts).toBe(1); expect(ends).toBe(0); }); + + it('SG-X5: a body faster than the floor thickness per frame tunnels but stays finite (no CCD)', () => { + const world = new PhysicsWorld({ gravity: { x: 0, y: GRAVITY } }); + const floorTop = 300; + + addFloor(world, floorTop); // 40px thick + + const bullet = addBox(world, 0, floorTop - 100, 16); + bullet.linearVelocityY = 1e6; // » floor thickness per frame at 1/60 s + + advance(world, 1); + + // Detection runs once per frame with no swept/continuous test (no CCD), so a + // body that moves farther than the floor thickness in a single frame passes + // straight through. The only contract under such speeds is finiteness — no + // NaN/Inf blow-up — which this gate pins. Reliably stopping fast projectiles + // is a v0.16 CCD / bullet-mode item (see the operating-envelope note). + expectAllFinite(world); + expect(bullet.y).toBeGreaterThan(floorTop); // tunnelled through (documented limitation) + }); }); // ── SG-D: deterministic replay ────────────────────────────────────────────