From 6fc548f7de294f11fb7adfc8b8e986bad54c1d03 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 1 May 2026 00:08:49 -0400 Subject: [PATCH 1/2] feat(physics): add local pointer field --- docs/physics-feel-contract.md | 4 ++-- src/core/BlobPhysics.ts | 21 +++++++++++++++++---- src/core/InteractionField.ts | 5 +++-- tests/unit/core.test.ts | 30 +++++++++++++++++++++++++----- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 316ed25..8732ab7 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -48,7 +48,7 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. - The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. -- Pointer IO currently updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; it does not apply a standalone pointer force yet. +- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; the first standalone route applies a small local pointer field only after real pointer input. - Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. ## Implementation Slices @@ -56,6 +56,6 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s 1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. 2. Add pure field helpers and unit tests without changing runtime feel. 3. Route gravity/device-orientation through the field helper while preserving ambient motion. -4. Route pointer and scroll values through field helpers one input at a time. +4. Route pointer and scroll values through field helpers one input at a time, starting with a low-strength local pointer field. 5. Add browser probes for directional bias, pointer locality, and scroll decay. 6. Revisit renderer stylability after interaction feel is stable. diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index 1f22e88..1e54972 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -60,8 +60,6 @@ export class BlobPhysics { private mouseY = 50; private mouseVelX = 0; private mouseVelY = 0; - private lastMouseX = 50; - private lastMouseY = 50; private gravity: GravityVector = { x: 0, y: 0 }; @@ -214,8 +212,6 @@ export class BlobPhysics { const previousMouseY = this.mouseY; this.mouseVelX = x - previousMouseX; this.mouseVelY = y - previousMouseY; - this.lastMouseX = previousMouseX; - this.lastMouseY = previousMouseY; this.mouseX = x; this.mouseY = y; } @@ -457,6 +453,9 @@ export class BlobPhysics { this.applyAccelerometerForces(blob); + + this.applyPointerField(blob); + this.updateMovementWithAccelerometer(blob, time); @@ -494,6 +493,20 @@ export class BlobPhysics { } } + private applyPointerField(blob: ConvexBlob): void { + if (this.mouseX === 50 && this.mouseY === 50) return; + + const dx = this.mouseX - blob.currentX; + const dy = this.mouseY - blob.currentY; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance === 0 || distance >= 34) return; + + const normalized = 1 - distance / 34; + const scale = (normalized * normalized * 0.0014) / distance; + blob.velocityX += dx * scale; + blob.velocityY += dy * scale; + } + private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { const neutralDriftX = (Math.random() - 0.5) * 0.001; diff --git a/src/core/InteractionField.ts b/src/core/InteractionField.ts index 62ec522..14bf8cd 100644 --- a/src/core/InteractionField.ts +++ b/src/core/InteractionField.ts @@ -78,9 +78,10 @@ export function pointAttractorField({ const dx = target.x - origin.x; const dy = target.y - origin.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance === 0) return { x: 0, y: 0 }; + if (distance === 0 || radius <= 0 || distance >= radius) return { x: 0, y: 0 }; - const falloff = smoothDistanceFalloff(distance, radius); + const normalized = 1 - distance / radius; + const falloff = normalized * normalized; const scale = (falloff * strength) / distance; return { x: dx * scale, diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index 5fa4b9d..726a96d 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -320,7 +320,7 @@ describe('BlobPhysics', () => { expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4); }); - it('tracks pointer position and velocity without applying standalone pointer force', () => { + it('tracks pointer position and velocity without applying distant pointer force', () => { const physics = new BlobPhysics(0); const blob = createTestConvexBlob(30, 50, 20); const internals = physics as unknown as { @@ -343,11 +343,33 @@ describe('BlobPhysics', () => { expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2)); }); + it('applies pointer influence as a local field after pointer input', () => { + const physics = new BlobPhysics(0); + const near = createTestConvexBlob(60, 50, 20); + const far = createTestConvexBlob(5, 50, 20); + const centered = createTestConvexBlob(60, 50, 20); + const internals = physics as unknown as { + applyPointerField(blob: ConvexBlob): void; + }; + + internals.applyPointerField(centered); + + expect(centered.velocityX).toBe(0); + expect(centered.velocityY).toBe(0); + + physics.updateMousePosition(75, 50); + internals.applyPointerField(near); + internals.applyPointerField(far); + + expect(near.velocityX).toBeGreaterThan(0); + expect(near.velocityY).toBe(0); + expect(far.velocityX).toBe(0); + expect(far.velocityY).toBe(0); + }); + it('computes pointer velocity from the previous pointer anchor', () => { const physics = new BlobPhysics(0); const internals = physics as unknown as { - lastMouseX: number; - lastMouseY: number; mouseVelX: number; mouseVelY: number; }; @@ -357,8 +379,6 @@ describe('BlobPhysics', () => { expect(internals.mouseVelX).toBe(5); expect(internals.mouseVelY).toBe(-5); - expect(internals.lastMouseX).toBe(75); - expect(internals.lastMouseY).toBe(25); }); }); From 5641f2836637e3aa0cbd32cdb517e067277feb7b Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 1 May 2026 11:24:33 -0400 Subject: [PATCH 2/2] test(browser): cover CDP pointer delivery --- README.md | 4 ++-- docs/physics-feel-contract.md | 4 ++-- scripts/probe-motion-cdp.mjs | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b8de5a..e08f56b 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only -- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, pointer delivery, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` - `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized @@ -115,7 +115,7 @@ The dev app includes a browser/device harness for interaction work: - Use the panel toggles to isolate pointer, scroll, and device-motion physics. - Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling. - On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device. -- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP reduced-motion media emulation and accelerometer override paths. +- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP pointer, reduced-motion media emulation, and accelerometer override paths. ## Release Truth diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md index 8732ab7..b7e7b7b 100644 --- a/docs/physics-feel-contract.md +++ b/docs/physics-feel-contract.md @@ -47,8 +47,8 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s ## Current Status - Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. -- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, and return to neutral on idle or reduced motion. -- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; the first standalone route applies a small local pointer field only after real pointer input. +- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, return to neutral on idle or reduced motion, and receive a real CDP pointer move while pointer physics is active. +- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; unit coverage verifies the first standalone route applies a small local pointer field only after real pointer input. - Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. ## Implementation Slices diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs index 4aaac0e..ab73e77 100644 --- a/scripts/probe-motion-cdp.mjs +++ b/scripts/probe-motion-cdp.mjs @@ -319,6 +319,14 @@ try { at: performance.now() }); }); + originalAddEventListener.call(window, 'pointermove', (event) => { + window.__tinyvectorsEvents.push({ + type: 'pointermove', + x: event.clientX, + y: event.clientY, + at: performance.now() + }); + }); })(); `, }); @@ -535,6 +543,37 @@ try { `Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`, ); + const beforePointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length + })`); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: 120, + y: 180, + button: 'none', + }); + await delay(500); + const afterPointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length, + lastPointerEvent: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').at(-1) + })`); + assert(beforePointerMove.firstBodyPath, 'Pointer probe could not read initial blob geometry.'); + assert(afterPointerMove.firstBodyPath, 'Pointer probe could not read updated blob geometry.'); + assert( + afterPointerMove.pointerEvents > beforePointerMove.pointerEvents, + 'CDP pointer move did not reach the page.', + ); + assert( + afterPointerMove.lastPointerEvent?.x === 120 && afterPointerMove.lastPointerEvent?.y === 180, + `CDP pointer move reached the page with unexpected coordinates ${JSON.stringify(afterPointerMove.lastPointerEvent)}.`, + ); + assert( + afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + 'Pointer probe did not observe animated blob geometry movement after pointer delivery.', + ); + await client.send('Runtime.evaluate', { expression: `document.getElementById('scroll-physics')?.click()`, awaitPromise: true, @@ -653,6 +692,11 @@ try { pathChanged: cdpAccelerometerChanged, note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.', }, + pointerDelivery: { + events: afterPointerMove.pointerEvents - beforePointerMove.pointerEvents, + pathChanged: afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + lastEvent: afterPointerMove.lastPointerEvent, + }, listenerLifecycle: { initial: listenerInitial.listeners, afterScrollOff: afterScrollOff.listeners,