diff --git a/.oxlintrc.json b/.oxlintrc.json index 38445421..b497865b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -33,7 +33,8 @@ "camera-controls", "**/three/**", "**/react/**", - "**/dom/**" + "**/dom/**", + "**/input-sources/**" ], "message": "core imports only math." } @@ -50,7 +51,15 @@ { "patterns": [ { - "group": ["react", "react/*", "react-dom", "@react-three/*", "**/react/**", "**/dom/**"], + "group": [ + "react", + "react/*", + "react-dom", + "@react-three/*", + "**/react/**", + "**/dom/**", + "**/input-sources/**" + ], "message": "three must not depend on React or the DOM layer." } ] @@ -58,6 +67,22 @@ ] } }, + { + "files": ["src/react/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["**/input-sources/**"], + "message": "React reads input through the dom layer." + } + ] + } + ] + } + }, { "files": ["src/dom/**"], "rules": { @@ -83,6 +108,31 @@ ] } }, + { + "files": ["src/input-sources/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "../**", + "three", + "three/*", + "react", + "react/*", + "react-dom", + "@react-three/*", + "camera-controls" + ], + "message": "input-sources import only each other and math." + } + ] + } + ] + } + }, { "files": ["test/**"], "rules": { diff --git a/benches/README.md b/benches/README.md index f309a0cf..d44558ea 100644 --- a/benches/README.md +++ b/benches/README.md @@ -20,7 +20,8 @@ The folders follow `src`: - `core/`: one piece or core step at a time (`@body`, `@aim`, `@noise`, `@impulse`, `@controller`, `@core`, `@blend`) - `three/`: target reads through the registry (`@targets`) -- `dom/`: input event handlers (`@input`) +- `input-sources/`: one input source at a time, an event and the frame after it (`@input`) +- `dom/`: a whole `InputControllerDom` frame, from events to axes (`@input`) - `scenes/`: whole frames of `KlippThree` with 1 to 50 cameras and a blend (`@scene`) The scene benches take long enough per iteration to stay above the noise of a busy machine. Use them to spot a regression, then the smaller benches to find where it comes from. @@ -33,4 +34,6 @@ Results are saved to `.labs/` (gitignored). A comparison only reports a change w ## Writing a bench -Code before `yield` is setup, and the yielded function is measured. Return a value from the measured function that depends on the work, so it isn't optimized away. Shared moving targets live in `targets.ts`. +Code before `yield` is setup, and the yielded function is measured. Return a value from the measured function that depends on the work, so it isn't optimized away. Shared moving targets live in `targets.ts`, and a fake DOM for input benches in `fakeDom.ts`. + +labs times the first calls to decide whether to batch. When they run through a lot of cold code, it times every call alone and mostly measures its own timer, which shows up as microseconds and kilobytes for sub-microsecond work. Wrap such a function in `warm` from `warm.ts` before yielding it. diff --git a/benches/core/aim.bench.ts b/benches/core/aim.bench.ts index 410bd80b..004a0aa3 100644 --- a/benches/core/aim.bench.ts +++ b/benches/core/aim.bench.ts @@ -8,6 +8,7 @@ import { HardLookAtAimThree } from '../../src/three/aim/HardLookAtAimThree'; import { RotationComposerAimThree } from '../../src/three/aim/RotationComposerAimThree'; import { makeMovingTarget } from '../targets'; +import { warm } from '../warm'; group('Aim.update @aim', () => { bench('HardLookAt', function* () { @@ -15,11 +16,11 @@ group('Aim.update @aim', () => { const aim = new HardLookAtAimThree(object); const out = cameraState.create(); vec3.set(out.position, 0, 2, 15); - yield () => { + yield warm(() => { step(); aim.update(out); return out.quaternion[0]; - }; + }); }); bench('RotationComposer (deadZone + hardLimit)', function* () { @@ -33,11 +34,11 @@ group('Aim.update @aim', () => { }); const out = cameraState.create(); vec3.set(out.position, 0, 2, 15); - yield () => { + yield warm(() => { step(); aim.update(out, 0.016, false); return out.quaternion[0]; - }; + }); }); bench('RotationComposer (deadZone + hardLimit + lookahead)', function* () { @@ -54,11 +55,11 @@ group('Aim.update @aim', () => { }); const out = cameraState.create(); vec3.set(out.position, 0, 2, 15); - yield () => { + yield warm(() => { step(); aim.update(out, 0.016, false); return out.quaternion[0]; - }; + }); }); // Where a live camera spends most frames: both dampers return early. @@ -72,9 +73,9 @@ group('Aim.update @aim', () => { const out = cameraState.create(); vec3.set(out.position, 0, 2, 15); aim.update(out, 0.016, true); - yield () => { + yield warm(() => { aim.update(out, 0.016, false); return out.quaternion[0]; - }; + }); }); }); diff --git a/benches/core/blend.bench.ts b/benches/core/blend.bench.ts index 266bbba6..8874a8bb 100644 --- a/benches/core/blend.bench.ts +++ b/benches/core/blend.bench.ts @@ -5,6 +5,8 @@ import { Matrix4, Quaternion, Vector3 } from 'three'; import * as cameraState from '../../src/core/CameraState'; import { BlendHints } from '../../src/core/blend/BlendHints'; +import { warm } from '../warm'; + /** Runs every frame of a blend, and never once it settles. */ group('cameraState.lerp @blend', () => { function makeOrbitingState(position: Vector3, lookAtTarget: Vector3) { @@ -27,20 +29,20 @@ group('cameraState.lerp @blend', () => { vec3.set(b.position, 0, 0, 5); new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2).toArray(b.quaternion); const out = cameraState.create(); - yield () => cameraState.lerp(out, a, b, 0.5).position[0]; + yield warm(() => cameraState.lerp(out, a, b, 0.5).position[0]); }); bench('lookAtTarget-driven rotation', function* () { const a = makeOrbitingState(new Vector3(5, 5, 5), new Vector3(0, 0, 0)); const b = makeOrbitingState(new Vector3(0, 0, 5), new Vector3(0, 0, 0)); const out = cameraState.create(); - yield () => cameraState.lerp(out, a, b, 0.5).position[0]; + yield warm(() => cameraState.lerp(out, a, b, 0.5).position[0]); }); bench('lookAtTarget-driven rotation + sphericalPosition hint', function* () { const a = makeOrbitingState(new Vector3(5, 5, 5), new Vector3(0, 0, 0)); const b = makeOrbitingState(new Vector3(0, 0, 5), new Vector3(0, 0, 0)); const out = cameraState.create(); - yield () => cameraState.lerp(out, a, b, 0.5, BlendHints.sphericalPosition).position[0]; + yield warm(() => cameraState.lerp(out, a, b, 0.5, BlendHints.sphericalPosition).position[0]); }); }); diff --git a/benches/core/body.bench.ts b/benches/core/body.bench.ts index a7649958..0266813b 100644 --- a/benches/core/body.bench.ts +++ b/benches/core/body.bench.ts @@ -7,28 +7,29 @@ import { HardLockToTargetBodyThree } from '../../src/three/body/HardLockToTarget import { PositionComposerBodyThree } from '../../src/three/body/PositionComposerBodyThree'; import { makeMovingMeshTarget, makeMovingTarget } from '../targets'; +import { warm } from '../warm'; group('Body.update @body', () => { bench('HardLockToTarget', function* () { const { object, step } = makeMovingTarget(); const body = new HardLockToTargetBodyThree(object, { damping: 0.5 }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('Follow (lockToTarget binding)', function* () { const { object, step } = makeMovingTarget(); const body = new FollowBodyThree(object, { offset: [0, 3, 8], damping: 0.5 }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('PositionComposer (deadZone + hardLimit)', function* () { @@ -42,11 +43,11 @@ group('Body.update @body', () => { hardLimit: [0.4, 0.4], }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('PositionComposer (deadZone + hardLimit + lookahead)', function* () { @@ -63,11 +64,11 @@ group('Body.update @body', () => { lookaheadSmoothing: 1, }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('PositionComposer (radius extent)', function* () { @@ -82,11 +83,11 @@ group('Body.update @body', () => { radius: 1.5, }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('PositionComposer (explicit size extent, rotating box)', function* () { @@ -101,11 +102,11 @@ group('Body.update @body', () => { size: [2, 2, 2], }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('PositionComposer (auto-detected Mesh size extent, rotating box)', function* () { @@ -119,10 +120,10 @@ group('Body.update @body', () => { hardLimit: [0.4, 0.4], }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); body.update(out, 0.016, false); return out.position[0]; - }; + }); }); }); diff --git a/benches/core/impulse.bench.ts b/benches/core/impulse.bench.ts index b530a8d8..4ab0a6aa 100644 --- a/benches/core/impulse.bench.ts +++ b/benches/core/impulse.bench.ts @@ -6,6 +6,8 @@ import { ImpulseField } from '../../src/core/impulse/ImpulseField'; import { ImpulseListenerNoise } from '../../src/core/impulse/ImpulseListenerNoise'; import { BasicMultiChannelPerlinNoise } from '../../src/core/noise/BasicMultiChannelPerlinNoise'; +import { warm } from '../warm'; + const always = () => 1; group('ImpulseField.sampleAt @impulse', () => { @@ -22,11 +24,11 @@ group('ImpulseField.sampleAt @impulse', () => { const out: Vec3 = [0, 0, 0]; const samplePosition: Vec3 = [0, 0, 0]; let now = 0; - yield () => { + yield warm(() => { now += 0.016; field.sampleAt(out, samplePosition, 1, 1, now); return out[0]; - }; + }); }); bench('10 concurrent events', function* () { @@ -34,11 +36,11 @@ group('ImpulseField.sampleAt @impulse', () => { const out: Vec3 = [0, 0, 0]; const samplePosition: Vec3 = [0, 0, 0]; let now = 0; - yield () => { + yield warm(() => { now += 0.016; field.sampleAt(out, samplePosition, 1, 1, now); return out[0]; - }; + }); }); bench('50 concurrent events', function* () { @@ -46,11 +48,11 @@ group('ImpulseField.sampleAt @impulse', () => { const out: Vec3 = [0, 0, 0]; const samplePosition: Vec3 = [0, 0, 0]; let now = 0; - yield () => { + yield warm(() => { now += 0.016; field.sampleAt(out, samplePosition, 1, 1, now); return out[0]; - }; + }); }); }); @@ -61,11 +63,11 @@ group('ImpulseListenerNoise.update @impulse', () => { const listener = new ImpulseListenerNoise({ field }); const out = cameraState.create(); let now = 0; - yield () => { + yield warm(() => { now += 0.016; listener.update(out, 0.016, false, now); return out.position[0]; - }; + }); }); bench('kick + shake', function* () { @@ -78,11 +80,11 @@ group('ImpulseListenerNoise.update @impulse', () => { const listener = new ImpulseListenerNoise({ field, channelMask: 1, gain: 1, shake }); const out = cameraState.create(); let now = 0; - yield () => { + yield warm(() => { now += 0.016; listener.update(out, 0.016, false, now); return out.position[0]; - }; + }); }); bench('kick + cameraSpace', function* () { @@ -91,10 +93,10 @@ group('ImpulseListenerNoise.update @impulse', () => { const listener = new ImpulseListenerNoise({ field, channelMask: 1, gain: 1, cameraSpace: true }); const out = cameraState.create(); let now = 0; - yield () => { + yield warm(() => { now += 0.016; listener.update(out, 0.016, false, now); return out.position[0]; - }; + }); }); }); diff --git a/benches/core/klipp.bench.ts b/benches/core/klipp.bench.ts index af4bf130..c9cfdf34 100644 --- a/benches/core/klipp.bench.ts +++ b/benches/core/klipp.bench.ts @@ -15,6 +15,7 @@ import { GroupFramingExtensionThree } from '../../src/three/extension/GroupFrami import { TargetGroup } from '../../src/three/extension/TargetGroup'; import { makeMovingTarget } from '../targets'; +import { warm } from '../warm'; group('VirtualCamera.update @controller', () => { bench('minimal: HardLockToTarget + HardLookAt', function* () { @@ -23,11 +24,11 @@ group('VirtualCamera.update @controller', () => { controller.setBody(new HardLockToTargetBodyThree(object, { damping: 0.5 })); controller.setAim(new HardLookAtAimThree(object)); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); controller.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('full: Follow + RotationComposer + GroupFraming + Perlin (like FocusReproScene)', function* () { @@ -64,11 +65,11 @@ group('VirtualCamera.update @controller', () => { }).update, }); const out = cameraState.create(); - yield () => { + yield warm(() => { step(); controller.update(out, 0.016, false); return out.position[0]; - }; + }); }); }); @@ -86,16 +87,16 @@ group('Klipp.tick @core', () => { bench('1 registered camera', function* () { const core = makeCoreWithCameras(1); - yield () => core[advance](0.016).position[0]; + yield warm(() => core[advance](0.016).position[0]); }); bench('10 registered cameras', function* () { const core = makeCoreWithCameras(10); - yield () => core[advance](0.016).position[0]; + yield warm(() => core[advance](0.016).position[0]); }); bench('50 registered cameras', function* () { const core = makeCoreWithCameras(50); - yield () => core[advance](0.016).position[0]; + yield warm(() => core[advance](0.016).position[0]); }); }); diff --git a/benches/core/noise.bench.ts b/benches/core/noise.bench.ts index eff47d9c..a77d0807 100644 --- a/benches/core/noise.bench.ts +++ b/benches/core/noise.bench.ts @@ -7,6 +7,8 @@ import { BasicMultiChannelPerlinNoise } from '../../src/core/noise/BasicMultiCha import { GroupFramingExtensionThree } from '../../src/three/extension/GroupFramingExtensionThree'; import { TargetGroup } from '../../src/three/extension/TargetGroup'; +import { warm } from '../warm'; + group('Noise/Extension.update @noise', () => { bench('BasicMultiChannelPerlin', function* () { const perlin = new BasicMultiChannelPerlinNoise({ @@ -20,10 +22,10 @@ group('Noise/Extension.update @noise', () => { amplitudeDamping: 0.5, }); const out = cameraState.create(); - yield () => { + yield warm(() => { perlin.update(out, 0.016, false); return out.position[0]; - }; + }); }); bench('GroupFraming (single member)', function* () { @@ -35,9 +37,9 @@ group('Noise/Extension.update @noise', () => { damping: 0.5, }); const out = cameraState.create(); - yield () => { + yield warm(() => { groupFraming.update(out, 0.016, false); return out.position[2]; - }; + }); }); }); diff --git a/benches/dom/input.bench.ts b/benches/dom/input.bench.ts index 821c5b63..988b6003 100644 --- a/benches/dom/input.bench.ts +++ b/benches/dom/input.bench.ts @@ -1,94 +1,66 @@ import { bench, group } from '@pmndrs/labs'; -import * as consumedInput from '../../src/core/input/consumedInput'; -import type { ConsumedInput } from '../../src/core/input/consumedInput'; +import { InputAxis } from '../../src/core/input/InputAxis'; -import { InputSystem, MouseButton } from '../../src/dom/InputSystem'; +import { InputControllerDom } from '../../src/dom/InputControllerDom'; +import { MouseButton } from '../../src/dom/InputSystem'; -// Node has no DOM, so these call the handlers directly instead of dispatching events. -group('InputSystem event handlers @input', () => { - type Handlers = Pick & { - onPointerDown: (event: unknown) => void; - onPointerMove: (event: unknown) => void; - onWheel: (event: unknown) => void; - }; - - function makeConnectedInputSystem(): { system: Handlers; element: object } { - const fakeDocument = { - pointerLockElement: null as object | null, - addEventListener: () => {}, - removeEventListener: () => {}, - exitPointerLock: () => {}, - }; - (globalThis as unknown as { document: unknown }).document = fakeDocument; - const element = { - style: {} as Record, - addEventListener: () => {}, - removeEventListener: () => {}, - setPointerCapture: () => {}, - releasePointerCapture: () => {}, - }; - const system = new InputSystem() as unknown as Handlers; - system.connect(element as unknown as HTMLElement); - return { system, element }; - } +import { makeFakeDom } from '../fakeDom'; +import { warm } from '../warm'; - function emptyInput(): ConsumedInput { - return consumedInput.create(); - } +function setup({ mapZoom = false } = {}) { + const { element } = makeFakeDom(); + const pan = new InputAxis({ range: [-180, 180], wrap: true }); + const tilt = new InputAxis({ range: [-90, 90] }); + const zoom = new InputAxis({ range: [-1, 1] }); + const look = { axes: { x: pan, y: tilt }, gain: 0.15 }; + const controller = new InputControllerDom({ + mouseButtons: { left: look, right: null, middle: null }, + touches: { one: look, two: null, three: null }, + wheel: mapZoom ? { axis: zoom, gain: 0.001 } : null, + pinch: mapZoom ? { axis: zoom } : null, + }); + controller.connect(element); + const frame = () => { + controller.update(); + pan.update(0.016); + tilt.update(0.016); + zoom.update(0.016); + return pan.value + zoom.value; + }; + return { element, frame }; +} - bench('onPointerMove (button held, unlocked drag)', function* () { - const { system, element } = makeConnectedInputSystem(); - system.onPointerDown({ pointerType: 'mouse', pointerId: 1, clientX: 0, clientY: 0, target: element }); - const moveEvent = { pointerType: 'mouse', pointerId: 1, clientX: 0, clientY: 0, buttons: MouseButton.left }; - const out = emptyInput(); - let x = 0; - yield () => { - x += 1; - moveEvent.clientX = x; - system.onPointerMove(moveEvent); - system.consume(out); - return out.leftDx; - }; +// One measured call is a whole frame: the events since the last frame, then every source, the mapping and the axes. +group('InputControllerDom frame @input', () => { + bench('idle', function* () { + const { frame } = setup(); + yield warm(frame); }); - bench('onPointerMove (buttonless, Pointer Lock active)', function* () { - const { system, element } = makeConnectedInputSystem(); - // Pretend the browser granted Pointer Lock. - (globalThis as unknown as { document: { pointerLockElement: unknown } }).document.pointerLockElement = element; - const moveEvent = { - pointerType: 'mouse', - pointerId: 1, - clientX: 0, - clientY: 0, - buttons: 0, - movementX: 1, - movementY: 0, - }; - const out = emptyInput(); - yield () => { - system.onPointerMove(moveEvent); - system.consume(out); - return out.lockedDx; - }; + bench('one drag event', function* () { + const { element, frame } = setup(); + const drag = { pointerType: 'mouse', pointerId: 1, clientX: 0, clientY: 0, buttons: MouseButton.left }; + element.dispatch('pointerdown', drag); + yield warm(() => { + drag.clientX++; + element.dispatch('pointermove', drag); + return frame(); + }); }); - bench('onWheel', function* () { - const { system } = makeConnectedInputSystem(); - const wheelEvent = { - clientX: 0, - clientY: 0, - deltaX: 0, - deltaY: 1, - ctrlKey: false, - shiftKey: false, - preventDefault: () => {}, - }; - const out = emptyInput(); - yield () => { - system.onWheel(wheelEvent); - system.consume(out); - return out.wheelDeltaY; - }; + bench('drag, wheel and pinch mapped', function* () { + const { element, frame } = setup({ mapZoom: true }); + const drag = { pointerType: 'mouse', pointerId: 1, clientX: 0, clientY: 0, buttons: MouseButton.left }; + const scroll = { clientX: 0, clientY: 0, deltaX: 0, deltaY: 10, deltaMode: 0, ctrlKey: false, shiftKey: false }; + const event = Object.assign(scroll, { preventDefault: () => {} }); + element.dispatch('pointerdown', drag); + yield warm(() => { + drag.clientX++; + event.ctrlKey = !event.ctrlKey; + element.dispatch('pointermove', drag); + element.dispatch('wheel', event); + return frame(); + }); }); }); diff --git a/benches/fakeDom.ts b/benches/fakeDom.ts new file mode 100644 index 00000000..0836fce8 --- /dev/null +++ b/benches/fakeDom.ts @@ -0,0 +1,49 @@ +type Listener = (event: unknown) => void; + +/** Something that keeps its listeners, so a bench can call them without a real DOM. */ +class FakeTarget { + readonly listeners = new Map(); + + addEventListener = (type: string, listener: Listener): void => { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + }; + + removeEventListener = (type: string, listener: Listener): void => { + const list = this.listeners.get(type); + if (list) + this.listeners.set( + type, + list.filter((other) => other !== listener), + ); + }; + + /** Calls every listener of `type` with `event`, like a dispatch without bubbling. */ + dispatch = (type: string, event: unknown): void => { + const list = this.listeners.get(type); + if (list) for (const listener of list) listener(event); + }; +} + +/** + * Installs a fake `document`, `window` and `WheelEvent` on `globalThis` and returns an element for sources to + * connect to. Node has no DOM, so this is how input benches feed events. + */ +export function makeFakeDom(): { element: FakeTarget & HTMLElement; document: FakeTarget & Document } { + const document = Object.assign(new FakeTarget(), { pointerLockElement: null, hidden: false }); + const window = new FakeTarget(); + const element = Object.assign(new FakeTarget(), { + style: { touchAction: '', userSelect: '' }, + setPointerCapture: () => {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }), + }); + const globals = globalThis as unknown as Record; + globals.document = document; + globals.window = window; + globals.WheelEvent ??= { DOM_DELTA_PIXEL: 0, DOM_DELTA_LINE: 1, DOM_DELTA_PAGE: 2 }; + return { + element: element as unknown as FakeTarget & HTMLElement, + document: document as unknown as FakeTarget & Document, + }; +} diff --git a/benches/input-sources/sources.bench.ts b/benches/input-sources/sources.bench.ts new file mode 100644 index 00000000..967b8417 --- /dev/null +++ b/benches/input-sources/sources.bench.ts @@ -0,0 +1,93 @@ +import { bench, group } from '@pmndrs/labs'; + +import { makeFakeDom } from '../fakeDom'; +import { warm } from '../warm'; + +import * as mouse from '../../src/input-sources/mouse'; +import * as safariGesture from '../../src/input-sources/safariGesture'; +import * as touch from '../../src/input-sources/touch'; +import * as wheel from '../../src/input-sources/wheel'; + +// Each measured call is one frame with one event: the handler, then the source's update. +group('Input sources @input', () => { + bench('mouse: drag', function* () { + const { element } = makeFakeDom(); + const state = mouse.create(); + mouse.connect(state, element); + const event = { pointerType: 'mouse', pointerId: 1, clientX: 0, clientY: 0, buttons: mouse.MouseButton.left }; + element.dispatch('pointerdown', event); + yield warm(() => { + event.clientX++; + element.dispatch('pointermove', event); + mouse.update(state); + return state.drag.left[0]; + }); + }); + + bench('mouse: pointer lock, no button', function* () { + const { element, document } = makeFakeDom(); + const state = mouse.create(); + (document as unknown as { pointerLockElement: unknown }).pointerLockElement = element; + mouse.connect(state, element); + const event = { pointerType: 'mouse', pointerId: 1, buttons: 0, movementX: 1, movementY: 0 }; + yield warm(() => { + element.dispatch('pointermove', event); + mouse.update(state); + return state.lockedMovement[0]; + }); + }); + + bench('touch: two-finger pinch', function* () { + const { element } = makeFakeDom(); + const state = touch.create(); + touch.connect(state, element); + element.dispatch('pointerdown', { pointerType: 'touch', pointerId: 1, clientX: 0, clientY: 0 }); + element.dispatch('pointerdown', { pointerType: 'touch', pointerId: 2, clientX: 100, clientY: 0 }); + const event = { pointerType: 'touch', pointerId: 2, clientX: 100, clientY: 0 }; + let t = 0; + yield warm(() => { + t += 0.01; + event.clientX = 100 + Math.sin(t) * 20; + element.dispatch('pointermove', event); + touch.update(state); + return state.pinchDelta; + }); + }); + + bench('wheel', function* () { + const { element } = makeFakeDom(); + const state = wheel.create(); + wheel.connect(state, element); + const event = { + clientX: 0, + clientY: 0, + deltaX: 0, + deltaY: 100, + deltaMode: 0, + ctrlKey: false, + shiftKey: false, + preventDefault: () => {}, + }; + yield warm(() => { + element.dispatch('wheel', event); + wheel.update(state); + return state.deltaY; + }); + }); + + bench('safariGesture: pinch', function* () { + const { element } = makeFakeDom(); + const state = safariGesture.create(); + safariGesture.connect(state, element); + const event = { scale: 1, rotation: 0, clientX: 0, clientY: 0, cancelable: true, preventDefault: () => {} }; + element.dispatch('gesturestart', event); + let t = 0; + yield warm(() => { + t += 0.01; + event.scale = 1 + Math.sin(t) * 0.5; + element.dispatch('gesturechange', event); + safariGesture.update(state); + return state.pinchDelta; + }); + }); +}); diff --git a/benches/scenes/cameras.bench.ts b/benches/scenes/cameras.bench.ts index c4983a28..74ad8133 100644 --- a/benches/scenes/cameras.bench.ts +++ b/benches/scenes/cameras.bench.ts @@ -9,6 +9,8 @@ import { FollowBodyThree } from '../../src/three/body/FollowBodyThree'; import { KlippThree } from '../../src/three/KlippThree'; import { BasicMultiChannelPerlinNoiseThree } from '../../src/three/noise/BasicMultiChannelPerlinNoiseThree'; +import { warm } from '../warm'; + type SceneOptions = { cameras: number; standbyUpdate?: StandbyUpdate; sharedTarget?: boolean }; /** A whole frame of cameras, each Follow + RotationComposer + Perlin on its own moving target. */ @@ -39,27 +41,27 @@ function scene({ cameras, standbyUpdate = 'roundRobin', sharedTarget = false }: group('KlippThree.update, whole frames @scene', () => { bench('1 camera', function* () { - yield scene({ cameras: 1 }); + yield warm(scene({ cameras: 1 })); }); bench('10 cameras, roundRobin', function* () { - yield scene({ cameras: 10 }); + yield warm(scene({ cameras: 10 })); }); bench('50 cameras, roundRobin', function* () { - yield scene({ cameras: 50 }); + yield warm(scene({ cameras: 50 })); }); bench('50 cameras, one shared target, roundRobin', function* () { - yield scene({ cameras: 50, sharedTarget: true }); + yield warm(scene({ cameras: 50, sharedTarget: true })); }); bench('50 cameras, always', function* () { - yield scene({ cameras: 50, standbyUpdate: 'always' }); + yield warm(scene({ cameras: 50, standbyUpdate: 'always' })); }); bench('50 cameras, never', function* () { - yield scene({ cameras: 50, standbyUpdate: 'never' }); + yield warm(scene({ cameras: 50, standbyUpdate: 'never' })); }); // A blend that never ends, so every frame interpolates between two moving cameras. @@ -78,12 +80,12 @@ group('KlippThree.update, whole frames @scene', () => { to.aim = new RotationComposerAimThree(target, { damping: 0.4 }); let time = 0; - yield () => { + yield warm(() => { time += 0.016; target.position.set(Math.sin(time) * 10, 2, Math.cos(time) * 10); target.updateMatrixWorld(); klipp.update(0.016); return klipp.shot.position[0]; - }; + }); }); }); diff --git a/benches/three/targets.bench.ts b/benches/three/targets.bench.ts index 89a8b08f..a68e65e5 100644 --- a/benches/three/targets.bench.ts +++ b/benches/three/targets.bench.ts @@ -8,6 +8,7 @@ import { FollowBodyThree } from '../../src/three/body/FollowBodyThree'; import { TargetRegistry } from '../../src/three/resolve/TargetRegistry'; import { makeNestedTarget } from '../targets'; +import { warm } from '../warm'; /** Two cameras, each Follow + HardLookAt on the same target, with or without registry slots. */ function* twoCamerasOnSharedTarget(depth: number, withRegistry: boolean) { @@ -19,7 +20,7 @@ function* twoCamerasOnSharedTarget(depth: number, withRegistry: boolean) { if (withRegistry) follow.targetSlot = look.targetSlot = registry.acquire(object); return { follow, look, out: cameraState.create() }; }); - yield () => { + yield warm(() => { step(); if (withRegistry) registry.refresh(); for (const { follow, look, out } of cameras) { @@ -27,7 +28,7 @@ function* twoCamerasOnSharedTarget(depth: number, withRegistry: boolean) { look.update(out); } return cameras[0].out.position[0]; - }; + }); } group('Target reads, two cameras on one target @targets', () => { diff --git a/benches/warm.ts b/benches/warm.ts new file mode 100644 index 00000000..d0b8cd17 --- /dev/null +++ b/benches/warm.ts @@ -0,0 +1,8 @@ +/** + * Runs `fn` a few hundred times before it is measured. labs times the first calls to decide whether to batch, + * and cold code there makes it time every call alone, which mostly measures its own timer. + */ +export function warm(fn: () => T, times = 500): () => T { + for (let i = 0; i < times; i++) fn(); + return fn; +} diff --git a/docs/advanced/on-demand-rendering.mdx b/docs/advanced/on-demand-rendering.mdx index e6e45194..150c96ce 100644 --- a/docs/advanced/on-demand-rendering.mdx +++ b/docs/advanced/on-demand-rendering.mdx @@ -41,7 +41,7 @@ renderer.setAnimationLoop(() => { klipp works with ``. It keeps requesting frames on its own while anything is still moving. -Once the camera has settled, nothing renders until something asks for a frame. Mounting a camera, changing its `active` or `priority`, and changing `Lens` props do that for you. For anything else, call `invalidate()` yourself, for example after: +Once the camera has settled, nothing renders until something asks for a frame. Mounting a camera, changing its `active` or `priority`, changing `Lens` props or ``, and input read by `` do that for you. For anything else, call `invalidate()` yourself, for example after: - changing a Body or Aim prop, like a new `target` or `offset`, - moving a target imperatively, for example `target.position.set(...)` in a click handler, diff --git a/docs/aim/pan-tilt.mdx b/docs/aim/pan-tilt.mdx index 09528b47..7af8936e 100644 --- a/docs/aim/pan-tilt.mdx +++ b/docs/aim/pan-tilt.mdx @@ -34,21 +34,22 @@ On the core and in three.js, these are set on the axes: `aim.pan` and `aim.tilt` ```ts import { PanTiltAim } from '@kvvasuu/klipp'; -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; const aim = new PanTiltAim(); aim.tilt.range = [-60, 60]; camera.aim = aim; -const input = new InputAxisController({ - mouseButtons: { left: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, right: null, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; +const controller = new InputControllerDom({ + mouseButtons: { left: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { - input.connect(canvas); - return () => input.disconnect(); + controller.connect(canvas); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); // reads input before the cameras run +klipp.registerUpdate(() => controller.update()); // reads input before the cameras run ``` Pass a target to make `pan` and `tilt` relative to its rotation: `new PanTiltAim(vehicle)`. @@ -57,29 +58,33 @@ Pass a target to make `pan` and `tilt` relative to its rotation: `new PanTiltAim ```ts aim.pan.applyDelta(5); // turn right by 5° +aim.pan.setValue(0); // face forward at once aim.tilt.value; // current tilt ``` +More in [Input from data](/input/input-from-data). + ```ts import { PanTiltAimThree } from '@kvvasuu/klipp/three'; -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; const aim = new PanTiltAimThree(); aim.tilt.range = [-60, 60]; camera.aim = aim; -const input = new InputAxisController({ - mouseButtons: { left: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, right: null, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; +const controller = new InputControllerDom({ + mouseButtons: { left: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { - input.connect(renderer.domElement); - return () => input.disconnect(); + controller.connect(renderer.domElement); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); // reads input before the cameras run +klipp.registerUpdate(() => controller.update()); // reads input before the cameras run ``` Pass a target to make `pan` and `tilt` relative to its rotation: `new PanTiltAimThree(vehicle)`. @@ -88,9 +93,12 @@ Pass a target to make `pan` and `tilt` relative to its rotation: `new PanTiltAim ```ts aim.pan.applyDelta(5); // turn right by 5° +aim.pan.setValue(0); // face forward at once aim.tilt.value; // current tilt ``` +More in [Input from data](/input/input-from-data). + @@ -118,8 +126,11 @@ import { Aim, Body, InputController } from '@kvvasuu/klipp/react'; const aim = useRef(null); aim.current.pan.applyDelta(5); // turn right by 5° +aim.current.pan.setValue(0); // face forward at once aim.current.tilt.value; // current tilt ``` +More in [Input from data](/input/input-from-data). + diff --git a/docs/input/input-axis.mdx b/docs/input/input-axis.mdx index c6c410fd..4d9be8b2 100644 --- a/docs/input/input-axis.mdx +++ b/docs/input/input-axis.mdx @@ -41,6 +41,7 @@ The axes are fields of the piece: ```ts aim.pan.applyDelta(5); // turn by 5° +aim.pan.setValue(0); // jump straight to 0° aim.pan.value; // read the current value ``` @@ -63,6 +64,7 @@ The axes are fields of the piece: ```ts aim.pan.applyDelta(5); // turn by 5° +aim.pan.setValue(0); // jump straight to 0° aim.pan.value; // read the current value ``` @@ -87,6 +89,7 @@ The axes are on the piece's `ref`: const aim = useRef(null); aim.current.pan.applyDelta(5); // turn by 5° +aim.current.pan.setValue(0); // jump straight to 0° aim.current.pan.value; // read the current value ``` diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index bc91c63c..e92a8604 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -1,10 +1,10 @@ --- title: InputController -description: Maps mouse and touch drags onto a piece's axes. +description: Maps mouse, touch, wheel and pinch input onto a piece's axes. nav: 61 --- -Maps mouse buttons and finger counts onto axes. On the core and in three.js it's an `InputAxisController` from `@kvvasuu/klipp/dom` that takes the axes themselves. In React, `` finds them by name in the piece it's placed in. +Maps mouse buttons, finger counts, the wheel and pinches onto axes. On the core and in three.js it's an `InputControllerDom` from `@kvvasuu/klipp/dom` that takes the axes themselves. In React, `` finds them by name in the piece it's placed in. Placed directly in a ``, it finds them in any of the camera's pieces. ## Settings @@ -12,16 +12,18 @@ Maps mouse buttons and finger counts onto axes. On the core and in three.js it's | ------- | ----------- | ------- | | `mouseButtons` | What each mouse button drives: `{ left, right, middle }`. | | | `touches` | What each finger count drives: `{ one, two, three }`. | | +| `wheel` | What the mouse wheel drives. Scrolling up is positive, like spreading a pinch. | | +| `pinch` | What a pinch drives, with two fingers or on a trackpad, as the change in `ln(scale)`. Spreading is positive. | | | `enabled` | Turns input on or off. | `true` | | `suppressContextMenu` | Disable the right-click menu on the canvas. On `inputSystem` on the core and in three.js. | `false` | -| `interactiveArea` | Only start drags inside part of the canvas, as a normalized rectangle. On `inputSystem` on the core and in three.js. | whole canvas | +| `interactiveArea` | Only take drags, scrolls and pinches that start inside part of the canvas, as a normalized rectangle. On `inputSystem` on the core and in three.js. | whole canvas | | `lockTouchAxis` | For two-finger gestures, pick either pinch or rotate instead of both. On `inputSystem` on the core and in three.js. | `false` | | `waitForBlend` | Ignore input until the blend into this camera has finished. An option of `klipp.whileOnScreen` on the core and in three.js. | `true` | -| `target` | React only. A ref to something else with axes to control (for example a `PanTiltAimThree`), instead of the piece it's placed in. | parent | +| `target` | React only. A ref to something else with axes to control (for example a `PanTiltAimThree`), instead of the piece it's placed in. | parent piece, or the camera | ## Mapping a source -Each button or finger count takes the same config: +List only the sources you use. Each button and finger count takes the same config: ```ts { @@ -31,13 +33,29 @@ Each button or finger count takes the same config: } ``` +## Wheel and pinch + +`wheel` and `pinch` each drive a single axis: + +```ts +{ + axis: 'radial', // a name in React, the axis itself elsewhere + gain: 0.002, + invert: true, +} +``` + +A pinch feels the same on a touch screen and on a trackpad, and adds up to the same amount however far you are zoomed in. That suits a zoom stored as `ln` of its scale. The wheel moves in pixels, so it needs its own `gain`. + +The page keeps scrolling over the canvas until `wheel` is mapped, and a trackpad pinch keeps zooming the page until `pinch` is mapped. + ## Pointer lock For mouse look without holding a button, request pointer lock on the controller's `inputSystem`. While locked, mouse movement goes to `mouseButtons.left`. ## Raw input -`lastInput` holds this frame's raw deltas, for example `wheelDeltaY`, `touchPinchDelta` or `leftDx`, if you want to handle something yourself. +`input` holds this frame's raw deltas, for example `wheelDeltaY`, `pinchDelta` or `leftDx`, if you want to handle something yourself. ## Usage @@ -50,36 +68,37 @@ For mouse look without holding a button, request pointer lock on the controller' ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ - mouseButtons: { left: null, right: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; +const controller = new InputControllerDom({ + mouseButtons: { right: look }, + touches: { one: look }, }); -input.inputSystem.suppressContextMenu = true; +controller.inputSystem.suppressContextMenu = true; klipp.whileOnScreen(camera, () => { - input.connect(canvas); - return () => input.disconnect(); + controller.connect(canvas); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); +klipp.registerUpdate(() => controller.update()); ``` -Every source has to be listed, `null` for unused ones. `whileOnScreen` connects it only while its camera is on screen, so several cameras don't react to the same drag. `input.enabled = false` keeps it connected but ignores input. +`whileOnScreen` connects it only while its camera is on screen, so several cameras don't react to the same drag. `controller.enabled = false` keeps it connected but ignores input. **Pointer lock** ```ts // in a click handler -input.inputSystem.requestPointerLock(); -input.inputSystem.exitPointerLock(); +controller.inputSystem.requestPointerLock(); +controller.inputSystem.exitPointerLock(); ``` **Raw input** ```ts klipp.registerUpdate(() => { - input.update(); - zoom += input.lastInput.wheelDeltaY * 0.01; + controller.update(); + roll += controller.input.touchRotateDelta; }); ``` @@ -87,36 +106,37 @@ klipp.registerUpdate(() => { ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ - mouseButtons: { left: null, right: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; +const controller = new InputControllerDom({ + mouseButtons: { right: look }, + touches: { one: look }, }); -input.inputSystem.suppressContextMenu = true; +controller.inputSystem.suppressContextMenu = true; klipp.whileOnScreen(camera, () => { - input.connect(renderer.domElement); - return () => input.disconnect(); + controller.connect(renderer.domElement); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); +klipp.registerUpdate(() => controller.update()); ``` -Every source has to be listed, `null` for unused ones. `whileOnScreen` connects it only while its camera is on screen, so several cameras don't react to the same drag. `input.enabled = false` keeps it connected but ignores input. +`whileOnScreen` connects it only while its camera is on screen, so several cameras don't react to the same drag. `controller.enabled = false` keeps it connected but ignores input. **Pointer lock** ```ts // in a click handler -input.inputSystem.requestPointerLock(); -input.inputSystem.exitPointerLock(); +controller.inputSystem.requestPointerLock(); +controller.inputSystem.exitPointerLock(); ``` **Raw input** ```ts klipp.registerUpdate(() => { - input.update(); - zoom += input.lastInput.wheelDeltaY * 0.01; + controller.update(); + roll += controller.input.touchRotateDelta; }); ``` @@ -133,25 +153,25 @@ klipp.registerUpdate(() => { ``` -Input is only read while its camera is on screen. `ref` gives you the `InputAxisController`. +Input is only read while its camera is on screen. `enabled={false}` keeps it listening but ignores input; unmount it to stop listening, for example to let the page scroll over the canvas. `ref` gives you the `InputControllerDom`. **Pointer lock** ```tsx -const input = useRef(null); +const controller = useRef(null); -; +; // in a click handler -input.current.inputSystem.requestPointerLock(); -input.current.inputSystem.exitPointerLock(); +controller.current.inputSystem.requestPointerLock(); +controller.current.inputSystem.exitPointerLock(); ``` **Raw input** ```tsx useFrame(() => { - zoom += input.current.lastInput.wheelDeltaY * 0.01; + roll += controller.current.input.touchRotateDelta; }); ``` diff --git a/docs/input/input-from-data.mdx b/docs/input/input-from-data.mdx new file mode 100644 index 00000000..d59d4e13 --- /dev/null +++ b/docs/input/input-from-data.mdx @@ -0,0 +1,150 @@ +--- +title: Input from data +description: Steer a camera from your own code, the network or a recording. +nav: 63 +--- + +An axis is plain data, so anything that produces numbers can steer a camera: your own code, an AI, another player over the network, or a recording of earlier input. None of it needs a mouse or a DOM. + +## Changing an axis + +| Call | Use it for | Recentering | +| ---- | ---------- | ----------- | +| `applyDelta(delta)` | Moving by an amount, the way a drag does. Eases with the axis's `damping`. | Waits, since the axis is in use. | +| `held = true` | Holding still, like a finger resting on the screen. | Waits while held. | +| `setValue(value)` | Jumping straight to a value, for a teleport or a reset. | Carries on. A jump isn't input. | + +Use `setValue` for jumps. Writing `value` directly doesn't last: the axis eases back to where its input left it. + +## In the same frame + +Feed the axes from a function passed to `registerUpdate`. It runs every frame before the cameras, so the camera answers in that same frame. While your data keeps changing on its own, return `true` from it, so on-demand rendering keeps asking for frames. + +## Usage + + + + Core + three.js + React Three Fiber + + + +```ts +const remote = { pan: 0, tilt: 0 }; // filled by your network code + +klipp.registerUpdate(() => { + aim.pan.applyDelta(remote.pan); + aim.tilt.applyDelta(remote.tilt); + remote.pan = 0; + remote.tilt = 0; +}); + +aim.pan.setValue(0); // face forward again +``` + +**Recording and replay** + +An `InputController` from `@kvvasuu/klipp` reads its input from its `input` field. Copy what a live controller read each frame, then play it back into another controller with the same mapping. Here `live` is an `InputControllerDom` set up as usual, updated in the recording function: + +```ts +import { InputController, type ConsumedInput } from '@kvvasuu/klipp'; + +const frames: ConsumedInput[] = []; +klipp.registerUpdate(() => { + live.update(); + frames.push({ ...live.input }); +}); + +// later +const replay = new InputController(live.config); +let frame = 0; +klipp.registerUpdate(() => { + if (frame >= frames.length) return false; + Object.assign(replay.input, frames[frame++]); + replay.update(); + return true; +}); +``` + +The replay plays one recorded frame per frame. + + + + +```ts +const remote = { pan: 0, tilt: 0 }; // filled by your network code + +klipp.registerUpdate(() => { + aim.pan.applyDelta(remote.pan); + aim.tilt.applyDelta(remote.tilt); + remote.pan = 0; + remote.tilt = 0; +}); + +aim.pan.setValue(0); // face forward again +``` + +**Recording and replay** + +An `InputController` from `@kvvasuu/klipp` reads its input from its `input` field. Copy what a live controller read each frame, then play it back into another controller with the same mapping. Here `live` is an `InputControllerDom` set up as usual, updated in the recording function: + +```ts +import { InputController, type ConsumedInput } from '@kvvasuu/klipp'; + +const frames: ConsumedInput[] = []; +klipp.registerUpdate(() => { + live.update(); + frames.push({ ...live.input }); +}); + +// later +const replay = new InputController(live.config); +let frame = 0; +klipp.registerUpdate(() => { + if (frame >= frames.length) return false; + Object.assign(replay.input, frames[frame++]); + replay.update(); + return true; +}); +``` + +The replay plays one recorded frame per frame. + + + + +Register the update from a component inside ``, and reach the axes through the piece's `ref`: + +```tsx +function RemoteLook({ aim, remote }) { + const klipp = useKlipp(); + + useEffect( + () => + klipp.registerUpdate(() => { + aim.current?.pan.applyDelta(remote.pan); + aim.current?.tilt.applyDelta(remote.tilt); + remote.pan = 0; + remote.tilt = 0; + }), + [klipp, aim, remote], + ); + + return null; +} +``` + +With `frameloop="demand"`, data that arrives between frames, for example in a WebSocket message, needs a nudge: call `invalidate()` after storing it. + +```tsx +socket.onmessage = (message) => { + remote.pan += message.pan; + invalidate(); +}; +``` + +Jumps work the same way: `aim.current.pan.setValue(0)`. + + + diff --git a/docs/input/introduction.mdx b/docs/input/introduction.mdx index a5273748..061d0c54 100644 --- a/docs/input/introduction.mdx +++ b/docs/input/introduction.mdx @@ -1,12 +1,12 @@ --- title: Input -description: Mouse, touch and pointer lock input for cameras. +description: Mouse, touch, wheel and pinch input for cameras, or your own data. nav: 60 --- -Some pieces can be controlled by the user, like [`PanTilt`](/aim/pan-tilt). They expose **axes** (`pan`, `tilt`), and an [input controller](/input/input-controller) maps mouse and touch drags onto those axes. +Some pieces can be controlled by the user, like [`PanTilt`](/aim/pan-tilt). They expose **axes** (`pan`, `tilt`), and an [input controller](/input/input-controller) maps mouse and touch drags, the wheel and pinches onto those axes. -Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, damping and optional recentering. You can also drive an axis from your own code, for example in a cutscene. +Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, damping and optional recentering. You can also drive an axis from your own code, for example in a cutscene, see [Input from data](/input/input-from-data). ## Usage @@ -18,41 +18,43 @@ Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, -`InputAxisController` from `@kvvasuu/klipp/dom` reads the mouse and touch, and takes the axes themselves: +`InputControllerDom` from `@kvvasuu/klipp/dom` reads the mouse, touch and wheel, and takes the axes themselves: ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ - mouseButtons: { left: null, right: { axes: { x: aim.pan, y: aim.tilt } }, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt } }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt } }; +const controller = new InputControllerDom({ + mouseButtons: { right: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { - input.connect(canvas); - return () => input.disconnect(); + controller.connect(canvas); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); // reads input before the cameras run +klipp.registerUpdate(() => controller.update()); // reads input before the cameras run ``` -Without a DOM, feed the axes from any source with `axis.applyDelta(delta)`. +Without a DOM, feed the axes from any source, see [Input from data](/input/input-from-data). -`InputAxisController` from `@kvvasuu/klipp/dom` reads the mouse and touch, and takes the axes themselves: +`InputControllerDom` from `@kvvasuu/klipp/dom` reads the mouse, touch and wheel, and takes the axes themselves: ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ - mouseButtons: { left: null, right: { axes: { x: aim.pan, y: aim.tilt } }, middle: null }, - touches: { one: { axes: { x: aim.pan, y: aim.tilt } }, two: null, three: null }, +const look = { axes: { x: aim.pan, y: aim.tilt } }; +const controller = new InputControllerDom({ + mouseButtons: { right: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { - input.connect(renderer.domElement); - return () => input.disconnect(); + controller.connect(renderer.domElement); + return () => controller.disconnect(); }); -klipp.registerUpdate(() => input.update()); // reads input before the cameras run +klipp.registerUpdate(() => controller.update()); // reads input before the cameras run ``` diff --git a/examples/src/registry/examples.ts b/examples/src/registry/examples.ts index ad7c01ae..682d8ef1 100644 --- a/examples/src/registry/examples.ts +++ b/examples/src/registry/examples.ts @@ -19,6 +19,7 @@ import { PositionComposerLookahead } from '../scenes/body/PositionComposerLookah import { GroupFraming } from '../scenes/extension/GroupFraming'; import { Lens } from '../scenes/extension/Lens'; import { Impulse } from '../scenes/impulse/Impulse'; +import { InputFromData } from '../scenes/input/InputFromData'; import { BasicMultiChannelPerlin } from '../scenes/noise/BasicMultiChannelPerlin'; import { Placeholder } from '../scenes/Placeholder'; import { StandbyUpdate } from '../scenes/virtual-camera/StandbyUpdate'; @@ -187,6 +188,22 @@ export const categories: ExampleCategory[] = [ }, ], }, + { + slug: 'input', + title: 'Input', + examples: [ + { + slug: 'input-from-data', + title: 'Input from data', + Scene: InputFromData, + description: + 'One camera, three sources of input. Pick a `source` and only its settings show up. With the mouse you can record what you do, `scripted` lets code look around with `applyDelta`, and `replay` plays the recording back without any mouse.', + spectatorPosition: [8, 5, 8], + spectatorTarget: [0, 2, 0], + ready: true, + }, + ], + }, { slug: 'noise', title: 'Noise', diff --git a/examples/src/scene/usePointerLock.ts b/examples/src/scene/usePointerLock.ts index c4e084fc..409760c3 100644 --- a/examples/src/scene/usePointerLock.ts +++ b/examples/src/scene/usePointerLock.ts @@ -1,10 +1,10 @@ -import type { InputAxisController } from '@kvvasuu/klipp/dom'; +import type { InputControllerDom } from '@kvvasuu/klipp/dom'; import { MouseButton } from '@kvvasuu/klipp/dom'; import { useThree } from '@react-three/fiber'; import { useEffect, useEffectEvent, useState, type RefObject } from 'react'; /** Left-click toggles Pointer Lock while `enabled`. Returns whether the lock is currently engaged. */ -export function usePointerLock(controllerRef: RefObject, enabled: boolean): boolean { +export function usePointerLock(controllerRef: RefObject, enabled: boolean): boolean { const gl = useThree((state) => state.gl); const [locked, setLocked] = useState(false); diff --git a/examples/src/scenes/aim/PanTilt.tsx b/examples/src/scenes/aim/PanTilt.tsx index 7b2e12be..3ec4eb0e 100644 --- a/examples/src/scenes/aim/PanTilt.tsx +++ b/examples/src/scenes/aim/PanTilt.tsx @@ -1,4 +1,4 @@ -import type { InputAxisController } from '@kvvasuu/klipp/dom'; +import type { InputControllerDom } from '@kvvasuu/klipp/dom'; import { Aim, InputController, Klipp, VirtualCamera } from '@kvvasuu/klipp/react'; import type { PanTiltAimThree } from '@kvvasuu/klipp/three'; import { useFrame } from '@react-three/fiber'; @@ -15,7 +15,7 @@ const degreesPerPixel = 0.15; const lookSource = { axes: { x: 'pan', y: 'tilt' }, gain: degreesPerPixel }; export function PanTilt() { - const controllerRef = useRef(null); + const controllerRef = useRef(null); const aimRef = useRef(null); const panValueRef = useRef(null); diff --git a/examples/src/scenes/aim/PanTiltRecentering.tsx b/examples/src/scenes/aim/PanTiltRecentering.tsx index 54180438..b9de87e0 100644 --- a/examples/src/scenes/aim/PanTiltRecentering.tsx +++ b/examples/src/scenes/aim/PanTiltRecentering.tsx @@ -1,4 +1,4 @@ -import type { InputAxisController } from '@kvvasuu/klipp/dom'; +import type { InputControllerDom } from '@kvvasuu/klipp/dom'; import { Aim, InputController, Klipp, VirtualCamera } from '@kvvasuu/klipp/react'; import { useControls } from 'leva'; import { useRef } from 'react'; @@ -14,7 +14,7 @@ const lookSource = { axes: { x: 'pan', y: 'tilt' }, gain: degreesPerPixel }; const centerMarkerPosition: [number, number, number] = [0, 2, -10]; export function PanTiltRecentering() { - const controllerRef = useRef(null); + const controllerRef = useRef(null); const { enabled, wait, time, lockPointer } = useControls('PanTilt: Recentering', { enabled: true, diff --git a/examples/src/scenes/aim/PanTiltReferenceFrame.tsx b/examples/src/scenes/aim/PanTiltReferenceFrame.tsx index ca30927f..c0c977b5 100644 --- a/examples/src/scenes/aim/PanTiltReferenceFrame.tsx +++ b/examples/src/scenes/aim/PanTiltReferenceFrame.tsx @@ -1,5 +1,5 @@ import { BindingModes } from '@kvvasuu/klipp'; -import { type InputAxisController } from '@kvvasuu/klipp/dom'; +import { type InputControllerDom } from '@kvvasuu/klipp/dom'; import { Aim, Body, InputController, Klipp, VirtualCamera } from '@kvvasuu/klipp/react'; import { type PanTiltAimThree } from '@kvvasuu/klipp/three'; import { useControls } from 'leva'; @@ -21,7 +21,7 @@ const seatOffset: [number, number, number] = [0, 0.8, 1]; export function PanTiltReferenceFrame() { const planeRef = useRef(null); - const controllerRef = useRef(null); + const controllerRef = useRef(null); const aimRef = useRef(null); const { rigidMount, lockPointer } = useControls('PanTilt: Reference Frame', { diff --git a/examples/src/scenes/aim/PanTiltRestrictedLook.tsx b/examples/src/scenes/aim/PanTiltRestrictedLook.tsx index 27cec8cd..1835529f 100644 --- a/examples/src/scenes/aim/PanTiltRestrictedLook.tsx +++ b/examples/src/scenes/aim/PanTiltRestrictedLook.tsx @@ -1,4 +1,4 @@ -import type { InputAxisController } from '@kvvasuu/klipp/dom'; +import type { InputControllerDom } from '@kvvasuu/klipp/dom'; import { Aim, InputController, Klipp, VirtualCamera } from '@kvvasuu/klipp/react'; import { useControls } from 'leva'; import { useRef } from 'react'; @@ -31,7 +31,7 @@ function RangeMarker({ angleDeg }: { angleDeg: number }) { } export function PanTiltRestrictedLook() { - const controllerRef = useRef(null); + const controllerRef = useRef(null); const { panMin, panMax, tiltMin, tiltMax, lockPointer } = useControls('PanTilt: Restricted Look', { panMin: { value: -30, min: -180, max: 0, step: 5 }, diff --git a/examples/src/scenes/input/InputFromData.tsx b/examples/src/scenes/input/InputFromData.tsx new file mode 100644 index 00000000..e1533231 --- /dev/null +++ b/examples/src/scenes/input/InputFromData.tsx @@ -0,0 +1,192 @@ +import { InputController as InputReplay, type ConsumedInput, type InputControllerConfig } from '@kvvasuu/klipp'; +import type { InputControllerDom } from '@kvvasuu/klipp/dom'; +import { Aim, InputController, Klipp, VirtualCamera, useKlipp } from '@kvvasuu/klipp/react'; +import type { PanTiltAimThree } from '@kvvasuu/klipp/three'; +import { useFrame } from '@react-three/fiber'; +import { button, folder, useControls } from 'leva'; +import { useEffect, useRef, useState, type RefObject } from 'react'; + +import { CanvasOverlay } from '../../scene/CanvasOverlay'; +import { GroundClutter } from '../../scene/GroundClutter'; +import { SpectatorFrustum } from '../../scene/SpectatorFrustum'; + +const lookSource = { axes: { x: 'pan', y: 'tilt' }, gain: 0.15 }; + +type Source = 'mouse' | 'scripted' | 'replay'; + +const sourceOptions: Record = { + 'mouse (can record)': 'mouse', + 'scripted by code': 'scripted', + 'replay the recording': 'replay', +}; +const shownFor = (source: Source) => (get: (path: string) => unknown) => get('Input from data.source') === source; + +type Recording = { frames: ConsumedInput[]; config: InputControllerConfig | null }; + +/** Feeds the axes from code, a scripted look-around or a replay of recorded mouse input, and shows how far it got. */ +function DataDriver({ + source, + recording, + panSpeed, + tiltSpeed, + loop, + aim, + controller, +}: { + source: Source; + recording: boolean; + panSpeed: number; + tiltSpeed: number; + loop: boolean; + aim: RefObject; + controller: RefObject; +}) { + const klipp = useKlipp(); + const tape = useRef({ frames: [], config: null }); + const replayed = useRef(0); + const liveRef = useRef(null); + const [recordedFrames, setRecordedFrames] = useState(0); + + useEffect(() => { + const recorded = tape.current; + + if (source === 'mouse') { + if (!recording) return; + recorded.frames = []; + const stop = klipp.registerUpdate(() => { + const live = controller.current; + if (!live) return; + recorded.config = live.config; + recorded.frames.push({ ...live.input }); + }); + return () => { + stop(); + setRecordedFrames(recorded.frames.length); + }; + } + + if (source === 'scripted') { + let time = 0; + return klipp.registerUpdate((dt) => { + time += dt; + aim.current?.pan.applyDelta(Math.cos(time * 0.6) * panSpeed * dt); + aim.current?.tilt.applyDelta(Math.cos(time * 1.3) * tiltSpeed * dt); + return true; + }); + } + + if (!recorded.config || recorded.frames.length === 0) return; + const replay = new InputReplay(recorded.config); + // A disabled update lets go of axes the recording left held. + const release = () => { + replay.enabled = false; + replay.update(); + }; + replayed.current = 0; + const stop = klipp.registerUpdate(() => { + if (replayed.current === recorded.frames.length) { + if (!loop) return false; + replayed.current = 0; + } + Object.assign(replay.input, recorded.frames[replayed.current++]); + replay.update(); + if (!loop && replayed.current === recorded.frames.length) release(); + return true; + }); + return () => { + stop(); + release(); + }; + }, [klipp, source, recording, panSpeed, tiltSpeed, loop, aim, controller]); + + useFrame(() => { + if (!liveRef.current || !aim.current) return; + const { pan, tilt } = aim.current; + const angles = `pan ${pan.value.toFixed(1)}° tilt ${tilt.value.toFixed(1)}°`; + const frames = tape.current.frames.length; + const progress = + source === 'replay' + ? frames > 0 + ? `frame ${replayed.current} / ${frames}` + : '' + : source === 'mouse' + ? recording + ? `● recording · ${frames} frames` + : `${frames} frames recorded` + : ''; + liveRef.current.textContent = progress ? `${angles} · ${progress}` : angles; + }); + + const hint = + source === 'mouse' + ? recording + ? 'drag to look around, every frame is being recorded' + : 'drag to look around, turn on recording to capture it' + : source === 'scripted' + ? 'code looks around on its own' + : recordedFrames > 0 + ? 'replaying the recording, no mouse involved' + : 'record something with the mouse first'; + + return ( + +
+ {hint} +
+
+ + ); +} + +export function InputFromData() { + const aimRef = useRef(null); + const controllerRef = useRef(null); + const [faceForward, setFaceForward] = useState(0); + + const controls = useControls('Input from data', { + source: { value: 'mouse', options: sourceOptions }, + Mouse: folder({ recording: false }, { render: shownFor('mouse') }), + Scripted: folder( + { + panSpeed: { value: 40, min: 0, max: 120, step: 5, label: 'pan °/s' }, + tiltSpeed: { value: 10, min: 0, max: 60, step: 5, label: 'tilt °/s' }, + }, + { render: shownFor('scripted') }, + ), + Replay: folder({ loop: true }, { render: shownFor('replay') }), + }); + useControls('Camera', { 'face forward': button(() => setFaceForward((count) => count + 1)) }); + const source = controls.source as Source; + + useEffect(() => { + if (faceForward === 0) return; + aimRef.current?.pan.setValue(0); + aimRef.current?.tilt.setValue(0); + }, [faceForward]); + + return ( + <> + + + + + + {source === 'mouse' && ( + + )} + + + + + + + ); +} diff --git a/src/core/VirtualCamera.ts b/src/core/VirtualCamera.ts index 67478dce..b450550d 100644 --- a/src/core/VirtualCamera.ts +++ b/src/core/VirtualCamera.ts @@ -7,6 +7,8 @@ import { attachTo, checkName, prepare, register, run, setHints, setPriority, ski import type { CameraTransitionEventMap, Klipp } from './Klipp'; import { BlendHints } from './blend/BlendHints'; +import type { InputAxisData } from './input/axis'; +import { isInputAxisOwner, type InputAxisOwner } from './input/InputAxisOwner'; /** Writes `out` for one frame. Return `true` when more work remains for a later frame. */ export type CameraStateWriter = (out: CameraState, dt: number, justActivated: boolean) => boolean | void; @@ -43,7 +45,7 @@ function warnDoubleRegistration(slot: 'Body' | 'Aim', name: string): void { const noop = (): void => {}; /** One shot: its state, the pieces that write it, and its place in a `Klipp`'s arbitration. */ -export class VirtualCamera extends EventDispatcher { +export class VirtualCamera extends EventDispatcher implements InputAxisOwner { /** This camera's own state, written by its pieces every frame. */ readonly state: CameraState; /** How this camera updates while another one is on screen. */ @@ -70,6 +72,7 @@ export class VirtualCamera extends EventDispatcher { private removeAim: () => void = noop; private readonly extensions = new Set(); private readonly noises = new Set(); + private _inputAxes: Record | null = null; constructor(name: string, options: VirtualCameraOptions = {}) { super(); @@ -131,6 +134,11 @@ export class VirtualCamera extends EventDispatcher { this.syncRegistration(); } + /** Every input axis of this camera's pieces, by name. A new object whenever the pieces change. */ + get inputAxes(): Record { + return (this._inputAxes ??= this.collectInputAxes()); + } + get body(): CameraPiece | null { return this._body; } @@ -159,7 +167,7 @@ export class VirtualCamera extends EventDispatcher { } this._body = body; if (!body) return (this.removeBody = noop); - this.pieces.add(body); + this.addPiece(body); // initialState only shapes the first activation. if (!this.hasRun && this.initialState?.position && 'primeFrom' in body) { (body as PositionPrimed).primeFrom(this.state.position); @@ -180,7 +188,7 @@ export class VirtualCamera extends EventDispatcher { } this._aim = aim; if (!aim) return (this.removeAim = noop); - this.pieces.add(aim); + this.addPiece(aim); if (!this.hasRun && this.initialState?.quaternion && 'primeFrom' in aim) { (aim as RotationPrimed).primeFrom(this.state.quaternion, this.state.referenceUp); } @@ -194,14 +202,14 @@ export class VirtualCamera extends EventDispatcher { /** Add an Extension. They run in the order added. Returns a function that removes it. */ addExtension(extension: CameraPiece): () => void { - this.pieces.add(extension); + this.addPiece(extension); this.extensions.add(extension); return () => this.removePiece(extension); } /** Add a Noise. They run in the order added, after every Extension. Returns a function that removes it. */ addNoise(noise: CameraPiece): () => void { - this.pieces.add(noise); + this.addPiece(noise); this.noises.add(noise); return () => this.removePiece(noise); } @@ -254,6 +262,29 @@ export class VirtualCamera extends EventDispatcher { this.pieces.delete(piece); this.extensions.delete(piece); this.noises.delete(piece); + this._inputAxes = null; + } + + private addPiece(piece: CameraPiece): void { + this.pieces.add(piece); + this._inputAxes = null; + } + + private collectInputAxes(): Record { + const axes: Record = {}; + for (const piece of this.pieces) { + if (!isInputAxisOwner(piece)) continue; + for (const name in piece.inputAxes) { + if (name in axes) { + console.warn( + `Virtual camera "${this._name}": two pieces have an input axis named "${name}". Using the first.`, + ); + } else { + axes[name] = piece.inputAxes[name]; + } + } + } + return axes; } private registration() { diff --git a/src/core/aim/PanTiltAim.ts b/src/core/aim/PanTiltAim.ts index ae9fa0ea..34ab5b49 100644 --- a/src/core/aim/PanTiltAim.ts +++ b/src/core/aim/PanTiltAim.ts @@ -3,10 +3,12 @@ import type { Quat, Vec3 } from 'math'; import type { CameraState } from '../CameraState'; import type { TargetPose } from '../TargetPose'; +import type { InputAxisOwner } from '../input/InputAxisOwner'; + import * as panTilt from './panTilt'; /** Rotation from two `InputAxis`: `pan` (yaw) and `tilt` (pitch), relative to an optional target's rotation. */ -export class PanTiltAim { +export class PanTiltAim implements InputAxisOwner { readonly state = panTilt.createState(); readonly pan = this.state.pan; readonly tilt = this.state.tilt; diff --git a/src/core/input/InputAxis.ts b/src/core/input/InputAxis.ts index ab4c5e92..69eb30f9 100644 --- a/src/core/input/InputAxis.ts +++ b/src/core/input/InputAxis.ts @@ -30,6 +30,9 @@ export class InputAxis implements InputAxisData { applyDelta = (delta: number): void => inputAxis.applyDelta(this, delta); + /** Jump straight to `value`, without easing and without counting as input. */ + setValue = (value: number): void => inputAxis.setValue(this, value); + /** Advance the axis and apply damping or recentering. Returns `true` while it still has to move. */ update = (dt: number): boolean => inputAxis.update(this, dt); diff --git a/src/core/input/InputAxisOwner.ts b/src/core/input/InputAxisOwner.ts new file mode 100644 index 00000000..d7f1080d --- /dev/null +++ b/src/core/input/InputAxisOwner.ts @@ -0,0 +1,9 @@ +import type { InputAxisData } from './axis'; + +/** A camera piece exposing named axes for input to drive. */ +export type InputAxisOwner = { + readonly inputAxes: Record; +}; + +export const isInputAxisOwner = (value: object): value is InputAxisOwner => + 'inputAxes' in value && typeof value.inputAxes === 'object' && value.inputAxes !== null; diff --git a/src/core/input/InputController.ts b/src/core/input/InputController.ts new file mode 100644 index 00000000..8fd0685f --- /dev/null +++ b/src/core/input/InputController.ts @@ -0,0 +1,27 @@ +import * as consumedInput from './consumedInput'; +import * as inputMapping from './inputMapping'; +import type { ConsumedInput } from './consumedInput'; +import type { InputControllerConfig } from './inputMapping'; + +/** Maps per-frame input onto axes. Layers override `readInput`. */ +export class InputController { + config: InputControllerConfig; + /** Whether input deltas are applied to the configured axes. */ + enabled = true; + /** This frame's input. */ + readonly input: ConsumedInput = consumedInput.create(); + + constructor(config: InputControllerConfig) { + this.config = config; + } + + /** Feeds every configured source's shaped delta and hold state into its axes. */ + update = (): void => { + inputMapping.feedAxes(this.config, this.readInput(), this.enabled); + }; + + /** This frame's input, read from wherever the layer gets it. */ + protected readInput(): ConsumedInput { + return this.input; + } +} diff --git a/src/core/input/axis.ts b/src/core/input/axis.ts index abbde505..66bd1f06 100644 --- a/src/core/input/axis.ts +++ b/src/core/input/axis.ts @@ -76,6 +76,15 @@ export function applyDelta(axis: InputAxisData, delta: number): void { axis.hadDelta = true; } +/** Jump straight to `value`, without easing and without counting as input. */ +export function setValue(axis: InputAxisData, value: number): void { + const next = axis.wrap ? value : clampToRange(axis, value); + axis.value = next; + axis.rawValue = next; + axis.damper.velocity = 0; + axis.damper.previousDistance = 0; +} + /** Wrap `value` and `rawValue` back into `range`. */ export function normalize(axis: InputAxisData): void { if (!axis.wrap || !axis.range) return; diff --git a/src/core/input/consumedInput.ts b/src/core/input/consumedInput.ts index 2aa5555d..ba693732 100644 --- a/src/core/input/consumedInput.ts +++ b/src/core/input/consumedInput.ts @@ -12,19 +12,16 @@ export type ConsumedInput = { /** Two-finger centroid movement. */ touchTwoDx: number; touchTwoDy: number; - /** Two-finger distance change. */ - touchPinchDelta: number; /** Three-finger centroid movement. */ touchThreeDx: number; touchThreeDy: number; /** Two-finger twist in radians. */ touchRotateDelta: number; - /** Safari/WebKit trackpad pinch amount. */ - gestureZoomDelta: number; + /** Pinch with two fingers or on a trackpad, as the change in `ln(scale)`. Spreading is positive. */ + pinchDelta: number; + /** Wheel scrolling in pixels, as the browser reports it: right and down are positive. */ wheelDeltaX: number; wheelDeltaY: number; - /** Trackpad pinch amount reported through `wheel`. */ - wheelZoomDelta: number; /** Raw mouse movement while Pointer Lock is active and no button is held. */ lockedDx: number; lockedDy: number; @@ -52,12 +49,10 @@ export function create(): ConsumedInput { touchTwoDy: 0, touchThreeDx: 0, touchThreeDy: 0, - touchPinchDelta: 0, touchRotateDelta: 0, - gestureZoomDelta: 0, + pinchDelta: 0, wheelDeltaX: 0, wheelDeltaY: 0, - wheelZoomDelta: 0, lockedDx: 0, lockedDy: 0, leftHeld: false, diff --git a/src/core/input/index.ts b/src/core/input/index.ts index abf20775..12064560 100644 --- a/src/core/input/index.ts +++ b/src/core/input/index.ts @@ -1,9 +1,17 @@ export { InputAxis } from './InputAxis'; +export { isInputAxisOwner, type InputAxisOwner } from './InputAxisOwner'; export * as inputAxis from './axis'; export type { InputAxisData, InputAxisParams, InputAxisRecentering } from './axis'; export * as consumedInput from './consumedInput'; export type { ConsumedInput } from './consumedInput'; +export { InputController } from './InputController'; export * as inputMapping from './inputMapping'; -export type { InputAxisControllerConfig, InputAxisPair, InputInvert, InputSourceMapping } from './inputMapping'; +export type { + InputAxisMapping, + InputAxisPair, + InputControllerConfig, + InputInvert, + InputSourceMapping, +} from './inputMapping'; diff --git a/src/core/input/inputMapping.ts b/src/core/input/inputMapping.ts index 0530b1d1..58f12bb5 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -18,63 +18,89 @@ export type InputSourceMapping = { invert?: InputInvert; }; -export type InputAxisControllerConfig = { - mouseButtons: { - left: InputSourceMapping | null; - right: InputSourceMapping | null; - middle: InputSourceMapping | null; +/** One input feeding one axis. */ +export type InputAxisMapping = { + axis: InputAxisData; + /** Multiplies the raw delta before it reaches the axis. */ + gain?: number; + invert?: boolean; +}; + +/** Which axes each input drives. Leave a source out, or set it to `null`, to ignore it. */ +export type InputControllerConfig = { + /** Dragging with each mouse button held. */ + mouseButtons?: { + left?: InputSourceMapping | null; + right?: InputSourceMapping | null; + middle?: InputSourceMapping | null; }; - touches: { - one: InputSourceMapping | null; - two: InputSourceMapping | null; - three: InputSourceMapping | null; + /** Dragging with one, two or three fingers. */ + touches?: { + one?: InputSourceMapping | null; + two?: InputSourceMapping | null; + three?: InputSourceMapping | null; }; + /** Vertical wheel scrolling, in pixels. Scrolling up is positive, like spreading a pinch. */ + wheel?: InputAxisMapping | null; + /** Pinch with two fingers or on a trackpad, as the change in `ln(scale)`. Spreading is positive. */ + pinch?: InputAxisMapping | null; }; const isInverted = (invert: InputInvert | undefined, axis: 'x' | 'y'): boolean => invert === true || (typeof invert === 'object' && !!invert[axis]); -function applySource(mapping: InputSourceMapping | null, dx: number, dy: number): void { +type Mapping = InputSourceMapping | null | undefined; + +function applySource(mapping: Mapping, dx: number, dy: number): void { if (!mapping || (dx === 0 && dy === 0)) return; const gain = mapping.gain ?? 1; inputAxis.applyDelta(mapping.axes.x, dx * gain * (isInverted(mapping.invert, 'x') ? -1 : 1)); inputAxis.applyDelta(mapping.axes.y, dy * gain * (isInverted(mapping.invert, 'y') ? -1 : 1)); } -function resetHeld(mapping: InputSourceMapping | null): void { +function applyAxis(mapping: InputAxisMapping | null | undefined, delta: number): void { + if (!mapping || delta === 0) return; + inputAxis.applyDelta(mapping.axis, delta * (mapping.gain ?? 1) * (mapping.invert ? -1 : 1)); +} + +function resetHeld(mapping: Mapping): void { if (!mapping) return; mapping.axes.x.held = false; mapping.axes.y.held = false; } -function applyHeld(mapping: InputSourceMapping | null, held: boolean): void { +function applyHeld(mapping: Mapping, held: boolean): void { if (!mapping || !held) return; mapping.axes.x.held = true; mapping.axes.y.held = true; } -/** Feeds every configured source's shaped delta and hold state into its axis pair. */ -export function feedAxes(config: InputAxisControllerConfig, input: ConsumedInput, enabled: boolean): void { +/** Feeds every configured source's shaped delta and hold state into its axes. */ +export function feedAxes(config: InputControllerConfig, input: ConsumedInput, enabled: boolean): void { const { mouseButtons, touches } = config; // Reset held state first so shared mappings can combine multiple sources. - resetHeld(mouseButtons.left); - resetHeld(mouseButtons.right); - resetHeld(mouseButtons.middle); - resetHeld(touches.one); - resetHeld(touches.two); - resetHeld(touches.three); + resetHeld(mouseButtons?.left); + resetHeld(mouseButtons?.right); + resetHeld(mouseButtons?.middle); + resetHeld(touches?.one); + resetHeld(touches?.two); + resetHeld(touches?.three); + if (config.pinch) config.pinch.axis.held = false; if (!enabled) return; - applySource(mouseButtons.left, input.leftDx, input.leftDy); - applySource(mouseButtons.right, input.rightDx, input.rightDy); - applySource(mouseButtons.middle, input.middleDx, input.middleDy); - applySource(touches.one, input.touchOneDx, input.touchOneDy); - applySource(touches.two, input.touchTwoDx, input.touchTwoDy); - applySource(touches.three, input.touchThreeDx, input.touchThreeDy); - applySource(mouseButtons.left, input.lockedDx, input.lockedDy); - applyHeld(mouseButtons.left, input.leftHeld); - applyHeld(mouseButtons.right, input.rightHeld); - applyHeld(mouseButtons.middle, input.middleHeld); - applyHeld(touches.one, input.touchOneHeld); - applyHeld(touches.two, input.touchTwoHeld); - applyHeld(touches.three, input.touchThreeHeld); + applySource(mouseButtons?.left, input.leftDx, input.leftDy); + applySource(mouseButtons?.right, input.rightDx, input.rightDy); + applySource(mouseButtons?.middle, input.middleDx, input.middleDy); + applySource(touches?.one, input.touchOneDx, input.touchOneDy); + applySource(touches?.two, input.touchTwoDx, input.touchTwoDy); + applySource(touches?.three, input.touchThreeDx, input.touchThreeDy); + applySource(mouseButtons?.left, input.lockedDx, input.lockedDy); + applyHeld(mouseButtons?.left, input.leftHeld); + applyHeld(mouseButtons?.right, input.rightHeld); + applyHeld(mouseButtons?.middle, input.middleHeld); + applyHeld(touches?.one, input.touchOneHeld); + applyHeld(touches?.two, input.touchTwoHeld); + applyHeld(touches?.three, input.touchThreeHeld); + applyAxis(config.wheel, -input.wheelDeltaY); + applyAxis(config.pinch, input.pinchDelta); + if (config.pinch && input.touchTwoHeld) config.pinch.axis.held = true; } diff --git a/src/dom/InputAxisController.ts b/src/dom/InputAxisController.ts deleted file mode 100644 index 9e711917..00000000 --- a/src/dom/InputAxisController.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as consumedInput from '../core/input/consumedInput'; -import * as inputMapping from '../core/input/inputMapping'; -import type { ConsumedInput } from '../core/input/consumedInput'; -import type { InputAxisControllerConfig } from '../core/input/inputMapping'; - -import { InputSystem } from './InputSystem'; - -export type { - InputAxisControllerConfig, - InputAxisPair, - InputInvert, - InputSourceMapping, -} from '../core/input/inputMapping'; - -/** Maps DOM input onto named axis pairs. */ -export class InputAxisController { - readonly inputSystem = new InputSystem(); - config: InputAxisControllerConfig; - /** Whether input deltas are applied to the configured axes. */ - enabled = true; - - /* Reused values drained from the input system each frame. */ - readonly lastInput: ConsumedInput = consumedInput.create(); - - constructor(config: InputAxisControllerConfig) { - this.config = config; - } - - connect = (element: HTMLElement): void => { - this.inputSystem.connect(element); - }; - - disconnect = (): void => { - this.inputSystem.disconnect(); - }; - - /** Drains `InputSystem` and feeds every configured source's shaped delta into its axis pair. */ - update = (): void => { - inputMapping.feedAxes(this.config, this.inputSystem.consume(this.lastInput), this.enabled); - }; -} diff --git a/src/dom/InputControllerDom.ts b/src/dom/InputControllerDom.ts new file mode 100644 index 00000000..94392992 --- /dev/null +++ b/src/dom/InputControllerDom.ts @@ -0,0 +1,35 @@ +import type { ConsumedInput } from '../core/input/consumedInput'; +import { InputController } from '../core/input/InputController'; + +import { InputSystem } from './InputSystem'; + +/** + * `InputController` reading mouse, touch, wheel and gesture input from a DOM element. The page keeps its own + * scrolling and pinch zoom over the element unless `wheel` or `pinch` is mapped. + */ +export class InputControllerDom extends InputController { + readonly inputSystem = new InputSystem(); + + /** Listens to `element`, calling `onInput` for every event that changes the input. */ + connect = (element: HTMLElement, onInput?: () => void): void => { + this.claimPageGestures(); + this.inputSystem.connect(element, onInput); + }; + + disconnect = (): void => { + this.inputSystem.disconnect(); + }; + + protected override readInput(): ConsumedInput { + this.claimPageGestures(); + return this.inputSystem.consume(this.input); + } + + private claimPageGestures(): void { + const { wheel, safariGesture } = this.inputSystem; + const zoom = !!this.config.pinch; + wheel.preventPageScroll = !!this.config.wheel; + wheel.preventPageZoom = zoom; + safariGesture.preventPageZoom = zoom; + } +} diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 3bd32c0b..dde2f611 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -1,150 +1,81 @@ -import { degreesToRadians, deltaAngle, radiansToDegrees } from 'math'; - import type { ConsumedInput } from '../core/input/consumedInput'; -export const MouseButton = { - left: 1, - right: 2, - middle: 4, -} as const; - -type ActivePointer = { - pointerId: number; - x: number; - y: number; -}; - -// Safari/WebKit's non-standard trackpad gesture event is not included in DOM types. -type WebKitGestureEvent = Event & { - scale: number; - rotation: number; - clientX: number; - clientY: number; - cancelable: boolean; -}; - -function distanceBetween(a: ActivePointer, b: ActivePointer): number { - const dx = a.x - b.x; - const dy = a.y - b.y; - return Math.sqrt(dx * dx + dy * dy); -} +import * as mouse from '../input-sources/mouse'; +import * as safariGesture from '../input-sources/safariGesture'; +import * as touch from '../input-sources/touch'; +import * as wheel from '../input-sources/wheel'; +import { isInsideInteractiveArea, type InteractiveArea } from '../input-sources/isInsideInteractiveArea'; -function angleBetween(a: ActivePointer, b: ActivePointer): number { - return Math.atan2(b.y - a.y, b.x - a.x); -} +export type { InteractiveArea } from '../input-sources/isInsideInteractiveArea'; +export { MouseButton } from '../input-sources/mouse'; -const SCALE_ANGLE_RATIO_INTENT_DEG = 30; +/** Mouse, touch, wheel and Safari gesture input from a DOM element, read once per frame. */ +export class InputSystem { + readonly mouse = mouse.create(); + readonly touch = touch.create(); + readonly wheel = wheel.create(); + readonly safariGesture = safariGesture.create(); -/** Normalized input region within the element. */ -export type InteractiveArea = { - x: number; - y: number; - width: number; - height: number; -}; + private _interactiveArea: InteractiveArea | null = null; + private element: HTMLElement | null = null; + private disconnectSources: (() => void) | null = null; -/** Buffers raw pointer and wheel input from a DOM element. */ -export class InputSystem { /** Suppress the native right-click menu. */ - suppressContextMenu = false; + get suppressContextMenu(): boolean { + return this.mouse.suppressContextMenu; + } - /** Restrict gesture starts to a normalized region. */ - interactiveArea: InteractiveArea | null = null; + set suppressContextMenu(suppress: boolean) { + this.mouse.suppressContextMenu = suppress; + } - /** Lock diagonal two-finger input to pinch or rotation. */ - lockTouchAxis = false; + /** Restrict gesture starts to a normalized region. */ + get interactiveArea(): InteractiveArea | null { + return this._interactiveArea; + } - private element: HTMLElement | null = null; - private activePointer: ActivePointer | null = null; - private touchPointer: ActivePointer | null = null; - private touchPointer2: ActivePointer | null = null; - private touchPointer3: ActivePointer | null = null; - private touchPinchDistance = 0; - private touchAngle = 0; - private touchGestureStartDistance = 0; - private touchGestureRotateTotal = 0; - private touchAxisLock: 'pinch' | 'rotate' | null = null; + set interactiveArea(area: InteractiveArea | null) { + this._interactiveArea = area; + this.mouse.interactiveArea = area; + this.touch.interactiveArea = area; + this.wheel.interactiveArea = area; + this.safariGesture.interactiveArea = area; + } - private gestureActive = false; - private lastGestureScale = 1; - private lastGestureRotationDeg = 0; - private gestureRotateTotalDeg = 0; - private gestureAxisLock: 'pinch' | 'rotate' | null = null; + /** Lock diagonal two-finger input to pinch or rotation. */ + get lockTouchAxis(): boolean { + return this.touch.lockTouchAxis; + } - private leftDx = 0; - private leftDy = 0; - private middleDx = 0; - private middleDy = 0; - private rightDx = 0; - private rightDy = 0; - private touchOneDx = 0; - private touchOneDy = 0; - private touchTwoDx = 0; - private touchTwoDy = 0; - private touchThreeDx = 0; - private touchThreeDy = 0; - private touchPinchDelta = 0; - private touchRotateDelta = 0; - private gestureZoomDelta = 0; - private wheelDeltaX = 0; - private wheelDeltaY = 0; - private wheelZoomDelta = 0; - private lockedDx = 0; - private lockedDy = 0; - private locked = false; - // event.buttons as of the last mouse pointerdown/move/up - only one mouse pointer is ever tracked - private heldButtons = 0; + set lockTouchAxis(lock: boolean) { + this.touch.lockTouchAxis = lock; + this.safariGesture.lockTouchAxis = lock; + } - /** Attaches listeners to `element`. Safe to call again with a new element - disconnects the old one first. */ - connect = (element: HTMLElement): void => { + /** + * Attaches listeners to `element`, calling `onInput` for every event that changes the input. Safe to call + * again with a new element - disconnects the old one first. + */ + connect = (element: HTMLElement, onInput?: () => void): void => { this.disconnect(); this.element = element; - // native touch-scroll/selection would otherwise fight a drag on the same element - element.style.touchAction = 'none'; - element.style.userSelect = 'none'; - element.addEventListener('pointerdown', this.onPointerDown); - element.addEventListener('pointermove', this.onPointerMove); - element.addEventListener('pointerup', this.onPointerUp); - element.addEventListener('pointercancel', this.onPointerUp); - element.addEventListener('wheel', this.onWheel, { passive: false }); - element.addEventListener('contextmenu', this.onContextMenu); - element.addEventListener('gesturestart', this.onGestureStart as EventListener); - element.addEventListener('gesturechange', this.onGestureChange as EventListener); - element.addEventListener('gestureend', this.onGestureEnd); - document.addEventListener('pointerlockchange', this.onPointerLockChange); - document.addEventListener('pointerlockerror', this.onPointerLockError); - document.addEventListener('visibilitychange', this.onVisibilityChange); - // picks up a lock that outlived a previous disconnect() (see disconnect()'s own comment) - no - // 'pointerlockchange' event fires here to do this for us, since the OS-level state never changed - this.locked = document.pointerLockElement === element; - }; - + const disconnectMouse = mouse.connect(this.mouse, element, onInput); + const disconnectTouch = touch.connect(this.touch, element, onInput); + const disconnectWheel = wheel.connect(this.wheel, element, onInput); + const disconnectSafariGesture = safariGesture.connect(this.safariGesture, element, onInput); + this.disconnectSources = () => { + disconnectMouse(); + disconnectTouch(); + disconnectWheel(); + disconnectSafariGesture(); + }; + }; + + /** Detaches every listener. A pointer lock stays: it belongs to the document, release it with `exitPointerLock`. */ disconnect = (): void => { - if (!this.element) return; - this.element.style.touchAction = ''; - this.element.style.userSelect = ''; - this.element.removeEventListener('pointerdown', this.onPointerDown); - this.element.removeEventListener('pointermove', this.onPointerMove); - this.element.removeEventListener('pointerup', this.onPointerUp); - this.element.removeEventListener('pointercancel', this.onPointerUp); - this.element.removeEventListener('wheel', this.onWheel); - this.element.removeEventListener('contextmenu', this.onContextMenu); - this.element.removeEventListener('gesturestart', this.onGestureStart as EventListener); - this.element.removeEventListener('gesturechange', this.onGestureChange as EventListener); - this.element.removeEventListener('gestureend', this.onGestureEnd); - document.removeEventListener('pointerlockchange', this.onPointerLockChange); - document.removeEventListener('pointerlockerror', this.onPointerLockError); - document.removeEventListener('visibilitychange', this.onVisibilityChange); - // Keep the pointer lock: it belongs to the document and can outlive this connection, for example across a - // hand-off to another virtual camera. Release it with exitPointerLock(). + this.disconnectSources?.(); + this.disconnectSources = null; this.element = null; - this.activePointer = null; - this.touchPointer = null; - this.touchPointer2 = null; - this.touchPointer3 = null; - this.gestureActive = false; - this.locked = false; - this.heldButtons = 0; }; requestPointerLock = (): void => { @@ -155,334 +86,44 @@ export class InputSystem { if (document.pointerLockElement === this.element) document.exitPointerLock(); }; + /** Starts a new frame in every source and writes it to `out`. */ consume = (out: ConsumedInput): ConsumedInput => { - out.leftDx = this.leftDx; - out.leftDy = this.leftDy; - out.middleDx = this.middleDx; - out.middleDy = this.middleDy; - out.rightDx = this.rightDx; - out.rightDy = this.rightDy; - out.touchOneDx = this.touchOneDx; - out.touchOneDy = this.touchOneDy; - out.touchTwoDx = this.touchTwoDx; - out.touchTwoDy = this.touchTwoDy; - out.touchThreeDx = this.touchThreeDx; - out.touchThreeDy = this.touchThreeDy; - out.touchPinchDelta = this.touchPinchDelta; - out.touchRotateDelta = this.touchRotateDelta; - out.gestureZoomDelta = this.gestureZoomDelta; - out.wheelDeltaX = this.wheelDeltaX; - out.wheelDeltaY = this.wheelDeltaY; - out.wheelZoomDelta = this.wheelZoomDelta; - out.lockedDx = this.lockedDx; - out.lockedDy = this.lockedDy; - out.leftHeld = (this.heldButtons & MouseButton.left) === MouseButton.left; - out.middleHeld = (this.heldButtons & MouseButton.middle) === MouseButton.middle; - out.rightHeld = (this.heldButtons & MouseButton.right) === MouseButton.right; - out.touchOneHeld = this.touchPointer !== null && this.touchPointer2 === null; - out.touchTwoHeld = this.touchPointer2 !== null && this.touchPointer3 === null; - out.touchThreeHeld = this.touchPointer3 !== null; - this.leftDx = 0; - this.leftDy = 0; - this.middleDx = 0; - this.middleDy = 0; - this.rightDx = 0; - this.rightDy = 0; - this.touchOneDx = 0; - this.touchOneDy = 0; - this.touchTwoDx = 0; - this.touchTwoDy = 0; - this.touchThreeDx = 0; - this.touchThreeDy = 0; - this.touchPinchDelta = 0; - this.touchRotateDelta = 0; - this.gestureZoomDelta = 0; - this.wheelDeltaX = 0; - this.wheelDeltaY = 0; - this.wheelZoomDelta = 0; - this.lockedDx = 0; - this.lockedDy = 0; + mouse.update(this.mouse); + touch.update(this.touch); + wheel.update(this.wheel); + safariGesture.update(this.safariGesture); + const { drag, buttons, lockedMovement } = this.mouse; + out.leftDx = drag.left[0]; + out.leftDy = drag.left[1]; + out.middleDx = drag.middle[0]; + out.middleDy = drag.middle[1]; + out.rightDx = drag.right[0]; + out.rightDy = drag.right[1]; + out.lockedDx = lockedMovement[0]; + out.lockedDy = lockedMovement[1]; + out.leftHeld = buttons.pressed.has('left'); + out.middleHeld = buttons.pressed.has('middle'); + out.rightHeld = buttons.pressed.has('right'); + const touchState = this.touch; + out.touchOneDx = touchState.drag.one[0]; + out.touchOneDy = touchState.drag.one[1]; + out.touchTwoDx = touchState.drag.two[0]; + out.touchTwoDy = touchState.drag.two[1]; + out.touchThreeDx = touchState.drag.three[0]; + out.touchThreeDy = touchState.drag.three[1]; + out.touchRotateDelta = touchState.twistDelta + this.safariGesture.twistDelta; + out.touchOneHeld = touchState.fingers === 1; + out.touchTwoHeld = touchState.fingers === 2; + out.touchThreeHeld = touchState.fingers === 3; + out.wheelDeltaX = this.wheel.deltaX; + out.wheelDeltaY = this.wheel.deltaY; + out.pinchDelta = touchState.pinchDelta + this.wheel.pinchDelta + this.safariGesture.pinchDelta; return out; }; - /** Whether `clientX/Y` falls within `interactiveArea` - always `true` once Pointer Lock is active on - * this element, since `clientX/Y` then freezes at the lock-engage position, meaningless as a "where". */ + /** Whether `clientX/Y` falls within `interactiveArea`. A locked pointer counts as inside: its position is frozen. */ isInsideInteractiveArea(clientX: number, clientY: number): boolean { - if (!this.interactiveArea || !this.element) return true; - if (document.pointerLockElement === this.element) return true; - const rect = this.element.getBoundingClientRect(); - const x = (clientX - rect.left) / rect.width; - const y = (clientY - rect.top) / rect.height; - const area = this.interactiveArea; - return x >= area.x && x <= area.x + area.width && y >= area.y && y <= area.y + area.height; + if (!this.element) return true; + return isInsideInteractiveArea(this.element, this._interactiveArea, clientX, clientY); } - - // re-baselines touchPinchDelta/touchRotateDelta/lockTouchAxis for a fresh two-finger gesture - - // called both when the second finger joins and when a third finger lifts back down to two - private resetTwoFingerGestureState(): void { - if (!this.touchPointer || !this.touchPointer2) return; - this.touchPinchDistance = distanceBetween(this.touchPointer, this.touchPointer2); - this.touchAngle = angleBetween(this.touchPointer, this.touchPointer2); - this.touchGestureStartDistance = this.touchPinchDistance; - this.touchGestureRotateTotal = 0; - this.touchAxisLock = null; - } - - private onPointerDown = (event: PointerEvent): void => { - if (!this.isInsideInteractiveArea(event.clientX, event.clientY)) return; - if (event.pointerType === 'touch') { - if (!this.touchPointer) { - this.touchPointer = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; - } else if (!this.touchPointer2 && event.pointerId !== this.touchPointer.pointerId) { - this.touchPointer2 = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; - this.resetTwoFingerGestureState(); - } else if ( - !this.touchPointer3 && - this.touchPointer2 && - event.pointerId !== this.touchPointer.pointerId && - event.pointerId !== this.touchPointer2.pointerId - ) { - this.touchPointer3 = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; - } else { - // a fourth finger, or a duplicate pointerId ignored - return; - } - } else if (event.pointerType === 'mouse') { - if (this.activePointer && this.activePointer.pointerId !== event.pointerId) { - // a second mouse pointerId while one is already tracked is ignored entirely - return; - } - // idempotent: a pointerdown for an already-tracked pointerId just re-anchors it - if (this.activePointer) { - this.activePointer.x = event.clientX; - this.activePointer.y = event.clientY; - } else { - this.activePointer = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; - } - this.heldButtons = event.buttons; - } else { - return; // pen: not handled yet - } - - try { - (event.target as HTMLElement).setPointerCapture(event.pointerId); - } catch {} - }; - - private onPointerMove = (event: PointerEvent): void => { - if (event.pointerType === 'touch') { - if (!this.touchPointer) return; - if (this.touchPointer3) { - if (!this.touchPointer2) return; - const moving = - event.pointerId === this.touchPointer.pointerId - ? this.touchPointer - : event.pointerId === this.touchPointer2.pointerId - ? this.touchPointer2 - : event.pointerId === this.touchPointer3.pointerId - ? this.touchPointer3 - : null; - if (!moving) return; // a fourth finger's stray move - ignored - - const oldCentroidX = (this.touchPointer.x + this.touchPointer2.x + this.touchPointer3.x) / 3; - const oldCentroidY = (this.touchPointer.y + this.touchPointer2.y + this.touchPointer3.y) / 3; - moving.x = event.clientX; - moving.y = event.clientY; - this.touchThreeDx += (this.touchPointer.x + this.touchPointer2.x + this.touchPointer3.x) / 3 - oldCentroidX; - this.touchThreeDy += (this.touchPointer.y + this.touchPointer2.y + this.touchPointer3.y) / 3 - oldCentroidY; - return; - } - if (this.touchPointer2) { - const moving = - event.pointerId === this.touchPointer.pointerId - ? this.touchPointer - : event.pointerId === this.touchPointer2.pointerId - ? this.touchPointer2 - : null; - if (!moving) return; - - // centroid (pan), pinch distance (dolly), and twist angle (rotate) all come from the same two - // points, recomputed together on every move - const oldCentroidX = (this.touchPointer.x + this.touchPointer2.x) / 2; - const oldCentroidY = (this.touchPointer.y + this.touchPointer2.y) / 2; - moving.x = event.clientX; - moving.y = event.clientY; - this.touchTwoDx += (this.touchPointer.x + this.touchPointer2.x) / 2 - oldCentroidX; - this.touchTwoDy += (this.touchPointer.y + this.touchPointer2.y) / 2 - oldCentroidY; - - const distance = distanceBetween(this.touchPointer, this.touchPointer2); - const pinchStepDelta = distance - this.touchPinchDistance; - this.touchPinchDistance = distance; - - const angle = angleBetween(this.touchPointer, this.touchPointer2); - const rotateStepDelta = deltaAngle(this.touchAngle, angle); - this.touchAngle = angle; - this.touchGestureRotateTotal += rotateStepDelta; - - // decided once per gesture - a later move dominated by the other axis doesn't re-decide - if (this.lockTouchAxis && !this.touchAxisLock) { - const scaleFraction = distance / this.touchGestureStartDistance - 1; - const rotateDegrees = radiansToDegrees(this.touchGestureRotateTotal); - const intent = Math.abs(scaleFraction) * SCALE_ANGLE_RATIO_INTENT_DEG - Math.abs(rotateDegrees); - if (intent < 0) this.touchAxisLock = 'rotate'; - else if (intent > 0) this.touchAxisLock = 'pinch'; - } - - if (this.touchAxisLock !== 'rotate') this.touchPinchDelta += pinchStepDelta; - if (this.touchAxisLock !== 'pinch') this.touchRotateDelta += rotateStepDelta; - return; - } - if (event.pointerId !== this.touchPointer.pointerId) return; - this.touchOneDx += event.clientX - this.touchPointer.x; - this.touchOneDy += event.clientY - this.touchPointer.y; - this.touchPointer.x = event.clientX; - this.touchPointer.y = event.clientY; - return; - } - if (event.pointerType !== 'mouse') return; - - const pointer = this.activePointer; - if (!pointer || pointer.pointerId !== event.pointerId) { - const locked = this.element != null && document.pointerLockElement === this.element; - if (locked) { - this.lockedDx += event.movementX; - this.lockedDy += event.movementY; - } - return; - } - this.heldButtons = event.buttons; - - // marked NaN by onPointerLockChange when the lock just ended - clientX/Y were frozen at the - // pre-lock position while locked, so this move re-anchors instead of computing a jump from it - if (Number.isNaN(pointer.x)) { - pointer.x = event.clientX; - pointer.y = event.clientY; - return; - } - - // clientX/Y freeze at 0 under Pointer Lock - movementX/Y is the only source of truth there - const locked = this.element != null && document.pointerLockElement === this.element; - const dx = locked ? event.movementX : event.clientX - pointer.x; - const dy = locked ? event.movementY : event.clientY - pointer.y; - pointer.x = event.clientX; - pointer.y = event.clientY; - - if ((event.buttons & MouseButton.left) === MouseButton.left) { - this.leftDx += dx; - this.leftDy += dy; - } - if ((event.buttons & MouseButton.middle) === MouseButton.middle) { - this.middleDx += dx; - this.middleDy += dy; - } - if ((event.buttons & MouseButton.right) === MouseButton.right) { - this.rightDx += dx; - this.rightDy += dy; - } - }; - - private onPointerUp = (event: PointerEvent): void => { - if (event.pointerType === 'touch') { - if (this.touchPointer3?.pointerId === event.pointerId) { - this.touchPointer3 = null; - this.resetTwoFingerGestureState(); - } else if (this.touchPointer2?.pointerId === event.pointerId) { - this.touchPointer2 = this.touchPointer3; - this.touchPointer3 = null; - this.resetTwoFingerGestureState(); - } else if (this.touchPointer?.pointerId === event.pointerId) { - // promote the remaining finger(s) down a slot - no jump, their positions are already - // current, a fresh one/two-finger gesture just continues from wherever they already are - this.touchPointer = this.touchPointer2; - this.touchPointer2 = this.touchPointer3; - this.touchPointer3 = null; - this.resetTwoFingerGestureState(); - } - return; - } - if (event.pointerType !== 'mouse') return; - if (this.activePointer?.pointerId === event.pointerId) { - this.activePointer = null; - this.heldButtons = 0; - } - }; - - private onWheel = (event: WheelEvent): void => { - if (!this.isInsideInteractiveArea(event.clientX, event.clientY)) return; - event.preventDefault(); - if (event.ctrlKey) { - // trackpad pinch, not a real scroll - deltaX is meaningless here, only deltaY carries the gesture - this.wheelZoomDelta += event.deltaY; - } else if (event.shiftKey && event.deltaX === 0) { - // a single-axis wheel (most mice) reports shift+scroll through deltaY, not deltaX - a real - // horizontal trackpad swipe already arrives as deltaX on its own and is untouched by this - this.wheelDeltaX += event.deltaY; - } else { - this.wheelDeltaX += event.deltaX; - this.wheelDeltaY += event.deltaY; - } - }; - - private onContextMenu = (event: MouseEvent): void => { - if (this.suppressContextMenu) event.preventDefault(); - }; - - private onPointerLockChange = (): void => { - const isLocked = this.element != null && document.pointerLockElement === this.element; - if (this.locked && !isLocked && this.activePointer) { - this.activePointer.x = NaN; - } - this.locked = isLocked; - }; - - private onPointerLockError = (): void => { - console.warn('InputSystem: requestPointerLock() failed - the browser rejected the request.'); - }; - - private onVisibilityChange = (): void => { - if (document.hidden) { - this.activePointer = null; - this.touchPointer = null; - this.touchPointer2 = null; - this.touchPointer3 = null; - this.gestureActive = false; - this.heldButtons = 0; - } - }; - - private onGestureStart = (event: WebKitGestureEvent): void => { - if (!this.isInsideInteractiveArea(event.clientX, event.clientY)) return; - if (event.cancelable) event.preventDefault(); - this.gestureActive = true; - this.lastGestureScale = event.scale; - this.lastGestureRotationDeg = event.rotation; - this.gestureRotateTotalDeg = 0; - this.gestureAxisLock = null; - }; - - private onGestureChange = (event: WebKitGestureEvent): void => { - if (!this.gestureActive) return; - if (event.cancelable) event.preventDefault(); - const zoomStepDelta = event.scale - this.lastGestureScale; - const rotateStepDeg = event.rotation - this.lastGestureRotationDeg; - this.lastGestureScale = event.scale; - this.lastGestureRotationDeg = event.rotation; - this.gestureRotateTotalDeg += rotateStepDeg; - - // same formula as touch's axis-intent lock, but scaleFraction is already relative to - // gesture start (WebKit's own `scale`) and rotation never needs unwrapping (WebKit tracks it continuously) - if (this.lockTouchAxis && !this.gestureAxisLock) { - const scaleFraction = event.scale - 1; - const intent = Math.abs(scaleFraction) * SCALE_ANGLE_RATIO_INTENT_DEG - Math.abs(this.gestureRotateTotalDeg); - if (intent < 0) this.gestureAxisLock = 'rotate'; - else if (intent > 0) this.gestureAxisLock = 'pinch'; - } - - if (this.gestureAxisLock !== 'rotate') this.gestureZoomDelta += zoomStepDelta; - if (this.gestureAxisLock !== 'pinch') this.touchRotateDelta += degreesToRadians(rotateStepDeg); - }; - - private onGestureEnd = (): void => { - this.gestureActive = false; - }; } diff --git a/src/dom/index.ts b/src/dom/index.ts index 0b6bcfce..28dc3523 100644 --- a/src/dom/index.ts +++ b/src/dom/index.ts @@ -1,9 +1,3 @@ export { DebugOverlay, type DebugZone } from './DebugOverlay'; -export { - InputAxisController, - type InputAxisControllerConfig, - type InputAxisPair, - type InputInvert, - type InputSourceMapping, -} from './InputAxisController'; +export { InputControllerDom } from './InputControllerDom'; export { InputSystem, MouseButton, type InteractiveArea } from './InputSystem'; diff --git a/src/input-sources/buttonInput.ts b/src/input-sources/buttonInput.ts new file mode 100644 index 00000000..fc81d7fa --- /dev/null +++ b/src/input-sources/buttonInput.ts @@ -0,0 +1,38 @@ +/** Which buttons are held, and which went down or up since the last `clear`. */ +export type ButtonInput = { + readonly pressed: Set; + readonly justPressed: Set; + readonly justReleased: Set; +}; + +export const create = (): ButtonInput => ({ + pressed: new Set(), + justPressed: new Set(), + justReleased: new Set(), +}); + +/** Marks `button` as held. Pressing a held button again is not a new press. */ +export function press(state: ButtonInput, button: T): void { + if (state.pressed.has(button)) return; + state.pressed.add(button); + state.justPressed.add(button); +} + +/** Releases `button` if it was held. */ +export function release(state: ButtonInput, button: T): void { + if (state.pressed.delete(button)) state.justReleased.add(button); +} + +/** Releases every held button, for when the page loses focus mid-press. */ +export function releaseAll(state: ButtonInput): void { + if (state.pressed.size === 0) return; + for (const button of state.pressed) state.justReleased.add(button); + state.pressed.clear(); +} + +/** Starts a new frame: forgets which buttons just went down or up, keeps what is held. */ +export function clear(state: ButtonInput): void { + // Clearing a Set allocates a new table even when it is empty. + if (state.justPressed.size > 0) state.justPressed.clear(); + if (state.justReleased.size > 0) state.justReleased.clear(); +} diff --git a/src/input-sources/isInsideInteractiveArea.ts b/src/input-sources/isInsideInteractiveArea.ts new file mode 100644 index 00000000..e4ce3a37 --- /dev/null +++ b/src/input-sources/isInsideInteractiveArea.ts @@ -0,0 +1,21 @@ +/** Normalized input region within the element. */ +export type InteractiveArea = { + x: number; + y: number; + width: number; + height: number; +}; + +/** Whether `clientX/Y` falls within `area` of `element`. A locked pointer counts as inside: its position is frozen. */ +export function isInsideInteractiveArea( + element: HTMLElement, + area: InteractiveArea | null, + clientX: number, + clientY: number, +): boolean { + if (!area || document.pointerLockElement === element) return true; + const rect = element.getBoundingClientRect(); + const x = (clientX - rect.left) / rect.width; + const y = (clientY - rect.top) / rect.height; + return x >= area.x && x <= area.x + area.width && y >= area.y && y <= area.y + area.height; +} diff --git a/src/input-sources/mouse.ts b/src/input-sources/mouse.ts new file mode 100644 index 00000000..5709bca7 --- /dev/null +++ b/src/input-sources/mouse.ts @@ -0,0 +1,216 @@ +import type { Vec2 } from 'math'; + +import * as buttonInput from './buttonInput'; +import type { ButtonInput } from './buttonInput'; +import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; +import { suppressNativeGestures } from './suppressNativeGestures'; + +/** `PointerEvent.buttons` bit of each mouse button. */ +export const MouseButton = { left: 1, right: 2, middle: 4 } as const; + +export type MouseButtonName = keyof typeof MouseButton; + +/** Mouse buttons and movement, summed per frame. */ +export type MouseState = { + /** Which buttons are held, and which went down or up this frame. */ + readonly buttons: ButtonInput; + /** Movement this frame while each button was held, in pixels. */ + readonly drag: Record; + /** Movement this frame while the pointer was locked with no button held. */ + readonly lockedMovement: Vec2; + /** Whether the pointer is locked to the element. */ + locked: boolean; + /** Only presses that start inside this normalized region count. */ + interactiveArea: InteractiveArea | null; + /** Suppress the native right-click menu. */ + suppressContextMenu: boolean; + /** Internal. */ + pending: MousePending; +}; + +type MousePending = { + drag: Record; + lockedMovement: Vec2; + /** Button changes since the last update, in order, as an index into `buttonNames` plus `DOWN` for a press. */ + changes: number[]; + changeCount: number; + /** `event.buttons` as of the last event. */ + held: number; + pointerId: number | null; + x: number; + y: number; + /** The next move sets the position instead of measuring from it, since it went stale. */ + reanchor: boolean; +}; + +const DOWN = 8; +const buttonNames: readonly MouseButtonName[] = ['left', 'right', 'middle']; + +export const create = (): MouseState => ({ + buttons: buttonInput.create(), + drag: { left: [0, 0], middle: [0, 0], right: [0, 0] }, + lockedMovement: [0, 0], + locked: false, + interactiveArea: null, + suppressContextMenu: false, + pending: { + drag: { left: [0, 0], middle: [0, 0], right: [0, 0] }, + lockedMovement: [0, 0], + changes: [], + changeCount: 0, + held: 0, + pointerId: null, + x: 0, + y: 0, + reanchor: false, + }, +}); + +/** Queues a press or release for every button that differs between the last event and `held`. */ +function setHeld(pending: MousePending, held: number): void { + for (let i = 0; i < buttonNames.length; i++) { + const bit = MouseButton[buttonNames[i]]; + if ((pending.held & bit) === (held & bit)) continue; + pending.changes[pending.changeCount++] = i | (held & bit ? DOWN : 0); + } + pending.held = held; +} + +function addDrag(pending: MousePending, buttons: number, dx: number, dy: number): void { + for (const name of buttonNames) { + if ((buttons & MouseButton[name]) === 0) continue; + const drag = pending.drag[name]; + drag[0] += dx; + drag[1] += dy; + } +} + +/** Listens to `element`'s mouse. Returns a function that stops; a pointer lock it holds stays. */ +export function connect(state: MouseState, element: HTMLElement, onInput?: () => void): () => void { + const pending = state.pending; + const isLocked = () => document.pointerLockElement === element; + + const release = (): void => { + pending.pointerId = null; + setHeld(pending, 0); + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse') return; + if (!isInsideInteractiveArea(element, state.interactiveArea, event.clientX, event.clientY)) return; + if (pending.pointerId !== null && pending.pointerId !== event.pointerId) return; + pending.pointerId = event.pointerId; + pending.x = event.clientX; + pending.y = event.clientY; + setHeld(pending, event.buttons); + try { + element.setPointerCapture(event.pointerId); + } catch {} + onInput?.(); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse') return; + if (pending.pointerId !== event.pointerId) { + if (!isLocked()) return; + pending.lockedMovement[0] += event.movementX; + pending.lockedMovement[1] += event.movementY; + onInput?.(); + return; + } + setHeld(pending, event.buttons); + if (pending.reanchor) { + pending.reanchor = false; + } else if (isLocked()) { + // clientX/Y stay frozen while locked. + addDrag(pending, event.buttons, event.movementX, event.movementY); + } else { + addDrag(pending, event.buttons, event.clientX - pending.x, event.clientY - pending.y); + } + pending.x = event.clientX; + pending.y = event.clientY; + onInput?.(); + }; + + const onPointerUp = (event: PointerEvent): void => { + if (event.pointerType !== 'mouse' || pending.pointerId !== event.pointerId) return; + release(); + onInput?.(); + }; + + const onContextMenu = (event: MouseEvent): void => { + if (state.suppressContextMenu) event.preventDefault(); + }; + + const onPointerLockChange = (): void => { + const locked = isLocked(); + // The position froze while locked, so the first move after it would jump. + if (state.locked && !locked && pending.pointerId !== null) pending.reanchor = true; + state.locked = locked; + }; + + const onPointerLockError = (): void => { + console.warn('requestPointerLock() failed - the browser rejected the request.'); + }; + + // Without focus, the release may never arrive. + const onBlur = (): void => release(); + const onVisibilityChange = (): void => { + if (document.hidden) release(); + }; + + const restoreStyles = suppressNativeGestures(element); + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointermove', onPointerMove); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointercancel', onPointerUp); + element.addEventListener('lostpointercapture', onPointerUp); + element.addEventListener('contextmenu', onContextMenu); + document.addEventListener('pointerlockchange', onPointerLockChange); + document.addEventListener('pointerlockerror', onPointerLockError); + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', onBlur); + // A lock can outlive a previous connection, and no event reports it again. + state.locked = isLocked(); + + return () => { + restoreStyles(); + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointercancel', onPointerUp); + element.removeEventListener('lostpointercapture', onPointerUp); + element.removeEventListener('contextmenu', onContextMenu); + document.removeEventListener('pointerlockchange', onPointerLockChange); + document.removeEventListener('pointerlockerror', onPointerLockError); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', onBlur); + release(); + pending.reanchor = false; + state.locked = false; + }; +} + +const copyAndZero = (out: Vec2, source: Vec2): void => { + out[0] = source[0]; + out[1] = source[1]; + source[0] = 0; + source[1] = 0; +}; + +/** Starts a new frame: the input gathered since the last call becomes this frame's. */ +export function update(state: MouseState): void { + const pending = state.pending; + buttonInput.clear(state.buttons); + for (let i = 0; i < pending.changeCount; i++) { + const change = pending.changes[i]; + const button = buttonNames[change & ~DOWN]; + if (change & DOWN) buttonInput.press(state.buttons, button); + else buttonInput.release(state.buttons, button); + } + pending.changeCount = 0; + copyAndZero(state.drag.left, pending.drag.left); + copyAndZero(state.drag.middle, pending.drag.middle); + copyAndZero(state.drag.right, pending.drag.right); + copyAndZero(state.lockedMovement, pending.lockedMovement); +} diff --git a/src/input-sources/safariGesture.ts b/src/input-sources/safariGesture.ts new file mode 100644 index 00000000..d479dba8 --- /dev/null +++ b/src/input-sources/safariGesture.ts @@ -0,0 +1,151 @@ +import { degreesToRadians } from 'math'; + +import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; +import { dominantGesture } from './touch'; + +/** Safari's trackpad pinch and rotate, summed per frame. Pinches on a touch screen are left to `touch`. */ +export type SafariGestureState = { + /** Pinch this frame, as the change in `ln(scale)`. Spreading is positive. */ + pinchDelta: number; + /** Rotation this frame, in radians. */ + twistDelta: number; + /** Only gestures that start inside this normalized region count. */ + interactiveArea: InteractiveArea | null; + /** Each gesture keeps only the stronger of pinch and twist. */ + lockTouchAxis: boolean; + /** Keep the gesture from zooming the page. */ + preventPageZoom: boolean; + /** Internal. */ + pending: { + pinchDelta: number; + twistDelta: number; + active: boolean; + scale: number; + rotationDegrees: number; + twistTotalDegrees: number; + axisLock: 'pinch' | 'twist' | null; + /** Touch pointers down on the element, which iOS also reports as gestures. */ + touchIds: number[]; + touchCount: number; + }; +}; + +// Not part of the standard DOM types. +type GestureEvent = Event & { scale: number; rotation: number; clientX: number; clientY: number }; + +export const create = (): SafariGestureState => ({ + pinchDelta: 0, + twistDelta: 0, + interactiveArea: null, + lockTouchAxis: false, + preventPageZoom: true, + pending: { + pinchDelta: 0, + twistDelta: 0, + active: false, + scale: 1, + rotationDegrees: 0, + twistTotalDegrees: 0, + axisLock: null, + touchIds: [], + touchCount: 0, + }, +}); + +/** Listens to `element`'s Safari gesture events. Returns a function that stops. */ +export function connect(state: SafariGestureState, element: HTMLElement, onInput?: () => void): () => void { + const pending = state.pending; + + const end = (): void => { + pending.active = false; + }; + const liftAll = (): void => { + end(); + pending.touchIds.fill(-1); + pending.touchCount = 0; + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.pointerType !== 'touch' || pending.touchIds.indexOf(event.pointerId) !== -1) return; + pending.touchIds[pending.touchCount++] = event.pointerId; + }; + + const onPointerUp = (event: PointerEvent): void => { + if (event.pointerType !== 'touch') return; + const index = pending.touchIds.indexOf(event.pointerId); + if (index === -1 || index >= pending.touchCount) return; + pending.touchIds[index] = pending.touchIds[--pending.touchCount]; + pending.touchIds[pending.touchCount] = -1; + }; + + const onGestureStart = (event: Event): void => { + const gesture = event as GestureEvent; + if (!isInsideInteractiveArea(element, state.interactiveArea, gesture.clientX, gesture.clientY)) return; + if (event.cancelable && state.preventPageZoom) event.preventDefault(); + if (pending.touchCount > 0) return; + pending.active = true; + pending.scale = gesture.scale; + pending.rotationDegrees = gesture.rotation; + pending.twistTotalDegrees = 0; + pending.axisLock = null; + }; + + const onGestureChange = (event: Event): void => { + if (!pending.active) return; + const gesture = event as GestureEvent; + if (event.cancelable && state.preventPageZoom) event.preventDefault(); + // A finger may land after the gesture started, depending on event order. + if (pending.touchCount > 0) { + end(); + return; + } + const pinchStep = Math.log(gesture.scale / pending.scale); + const rotationStep = gesture.rotation - pending.rotationDegrees; + pending.scale = gesture.scale; + pending.rotationDegrees = gesture.rotation; + pending.twistTotalDegrees += rotationStep; + + // `scale` already counts from the start of the gesture. + if (state.lockTouchAxis && !pending.axisLock) { + pending.axisLock = dominantGesture(gesture.scale - 1, pending.twistTotalDegrees); + } + if (pending.axisLock !== 'twist') pending.pinchDelta += pinchStep; + if (pending.axisLock !== 'pinch') pending.twistDelta += degreesToRadians(rotationStep); + onInput?.(); + }; + + const onVisibilityChange = (): void => { + if (document.hidden) liftAll(); + }; + + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointercancel', onPointerUp); + element.addEventListener('lostpointercapture', onPointerUp); + element.addEventListener('gesturestart', onGestureStart); + element.addEventListener('gesturechange', onGestureChange); + element.addEventListener('gestureend', end); + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', liftAll); + + return () => { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointercancel', onPointerUp); + element.removeEventListener('lostpointercapture', onPointerUp); + element.removeEventListener('gesturestart', onGestureStart); + element.removeEventListener('gesturechange', onGestureChange); + element.removeEventListener('gestureend', end); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', liftAll); + liftAll(); + }; +} + +/** Starts a new frame: the input gathered since the last call becomes this frame's. */ +export function update(state: SafariGestureState): void { + state.pinchDelta = state.pending.pinchDelta; + state.twistDelta = state.pending.twistDelta; + state.pending.pinchDelta = 0; + state.pending.twistDelta = 0; +} diff --git a/src/input-sources/suppressNativeGestures.ts b/src/input-sources/suppressNativeGestures.ts new file mode 100644 index 00000000..1add7a6e --- /dev/null +++ b/src/input-sources/suppressNativeGestures.ts @@ -0,0 +1,29 @@ +type Suppression = { count: number; touchAction: string; userSelect: string }; + +const suppressed = new WeakMap(); + +/** + * Turns off native touch scrolling and text selection on `element`, which would fight a drag. Returns a + * function that restores the inline styles from before the first of any overlapping calls. + */ +export function suppressNativeGestures(element: HTMLElement): () => void { + let entry = suppressed.get(element); + if (!entry) { + entry = { count: 0, touchAction: element.style.touchAction, userSelect: element.style.userSelect }; + suppressed.set(element, entry); + element.style.touchAction = 'none'; + element.style.userSelect = 'none'; + } + entry.count++; + + let restored = false; + return () => { + if (restored) return; + restored = true; + const current = suppressed.get(element)!; + if (--current.count > 0) return; + element.style.touchAction = current.touchAction; + element.style.userSelect = current.userSelect; + suppressed.delete(element); + }; +} diff --git a/src/input-sources/touch.ts b/src/input-sources/touch.ts new file mode 100644 index 00000000..1c7ac1ea --- /dev/null +++ b/src/input-sources/touch.ts @@ -0,0 +1,213 @@ +import { deltaAngle, radiansToDegrees, type Vec2 } from 'math'; + +import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; +import { suppressNativeGestures } from './suppressNativeGestures'; + +export type FingerCount = 'one' | 'two' | 'three'; + +/** Up to three fingers: their movement per finger count, and two-finger pinch and twist, summed per frame. */ +export type TouchState = { + /** Fingers down, up to three. */ + fingers: number; + /** Movement this frame of the fingers' center, by how many were down at the time, in pixels. */ + readonly drag: Record; + /** Two-finger pinch this frame, as the change in `ln(scale)`. Spreading is positive. */ + pinchDelta: number; + /** Two-finger rotation this frame, in radians. */ + twistDelta: number; + /** Only touches that start inside this normalized region count. */ + interactiveArea: InteractiveArea | null; + /** Each two-finger gesture keeps only the stronger of pinch and twist. */ + lockTouchAxis: boolean; + /** Internal. */ + pending: TouchPending; +}; + +type Finger = { id: number; x: number; y: number }; + +type TouchPending = { + drag: Record; + pinchDelta: number; + twistDelta: number; + /** The first `count` are down, in the order they touched. */ + slots: [Finger, Finger, Finger]; + count: number; + /** Two-finger gesture, measured from when the current pair formed. */ + distance: number; + angle: number; + startDistance: number; + twistTotal: number; + axisLock: 'pinch' | 'twist' | null; +}; + +/** How many degrees of twist weigh as much as doubling the pinch distance, when choosing one. */ +const SCALE_ANGLE_RATIO_INTENT_DEG = 30; + +/** Which of pinch and twist a two-finger gesture mostly is so far, or `null` while it is even. */ +export function dominantGesture(scaleFraction: number, twistDegrees: number): 'pinch' | 'twist' | null { + const intent = Math.abs(scaleFraction) * SCALE_ANGLE_RATIO_INTENT_DEG - Math.abs(twistDegrees); + return intent < 0 ? 'twist' : intent > 0 ? 'pinch' : null; +} + +export const create = (): TouchState => ({ + fingers: 0, + drag: { one: [0, 0], two: [0, 0], three: [0, 0] }, + pinchDelta: 0, + twistDelta: 0, + interactiveArea: null, + lockTouchAxis: false, + pending: { + drag: { one: [0, 0], two: [0, 0], three: [0, 0] }, + pinchDelta: 0, + twistDelta: 0, + slots: [ + { id: 0, x: 0, y: 0 }, + { id: 0, x: 0, y: 0 }, + { id: 0, x: 0, y: 0 }, + ], + count: 0, + distance: 0, + angle: 0, + startDistance: 0, + twistTotal: 0, + axisLock: null, + }, +}); + +function indexOf(pending: TouchPending, id: number): number { + for (let i = 0; i < pending.count; i++) if (pending.slots[i].id === id) return i; + return -1; +} + +/** Starts a fresh two-finger gesture from where the first two fingers are now. */ +function resetPair(pending: TouchPending): void { + if (pending.count < 2) return; + const [a, b] = pending.slots; + pending.distance = Math.hypot(b.x - a.x, b.y - a.y); + pending.angle = Math.atan2(b.y - a.y, b.x - a.x); + pending.startDistance = pending.distance; + pending.twistTotal = 0; + pending.axisLock = null; +} + +function moveCenter(drag: Vec2, count: number, moving: Finger, x: number, y: number): void { + drag[0] += (x - moving.x) / count; + drag[1] += (y - moving.y) / count; + moving.x = x; + moving.y = y; +} + +function movePair(state: TouchState, moving: Finger, x: number, y: number): void { + const pending = state.pending; + moveCenter(pending.drag.two, 2, moving, x, y); + + const [a, b] = pending.slots; + const distance = Math.hypot(b.x - a.x, b.y - a.y); + const pinchStep = distance > 0 && pending.distance > 0 ? Math.log(distance / pending.distance) : 0; + pending.distance = distance; + + const angle = Math.atan2(b.y - a.y, b.x - a.x); + const twistStep = deltaAngle(pending.angle, angle); + pending.angle = angle; + pending.twistTotal += twistStep; + + // Decided once per gesture, so a later move leaning the other way doesn't flip it. + if (state.lockTouchAxis && !pending.axisLock) { + pending.axisLock = dominantGesture(distance / pending.startDistance - 1, radiansToDegrees(pending.twistTotal)); + } + + if (pending.axisLock !== 'twist') pending.pinchDelta += pinchStep; + if (pending.axisLock !== 'pinch') pending.twistDelta += twistStep; +} + +/** Listens to `element`'s touches. Returns a function that stops. */ +export function connect(state: TouchState, element: HTMLElement, onInput?: () => void): () => void { + const pending = state.pending; + + const onPointerDown = (event: PointerEvent): void => { + if (event.pointerType !== 'touch') return; + if (!isInsideInteractiveArea(element, state.interactiveArea, event.clientX, event.clientY)) return; + // A fourth finger, or a repeat of one already down, is ignored. + if (pending.count === 3 || indexOf(pending, event.pointerId) !== -1) return; + const finger = pending.slots[pending.count++]; + finger.id = event.pointerId; + finger.x = event.clientX; + finger.y = event.clientY; + if (pending.count === 2) resetPair(pending); + try { + element.setPointerCapture(event.pointerId); + } catch {} + onInput?.(); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (event.pointerType !== 'touch') return; + const index = indexOf(pending, event.pointerId); + if (index === -1) return; + const moving = pending.slots[index]; + if (pending.count === 3) moveCenter(pending.drag.three, 3, moving, event.clientX, event.clientY); + else if (pending.count === 2) movePair(state, moving, event.clientX, event.clientY); + else moveCenter(pending.drag.one, 1, moving, event.clientX, event.clientY); + onInput?.(); + }; + + const onPointerUp = (event: PointerEvent): void => { + if (event.pointerType !== 'touch') return; + const index = indexOf(pending, event.pointerId); + if (index === -1) return; + // The remaining fingers move down a slot and carry on from where they are, so nothing jumps. + const lifted = pending.slots[index]; + for (let i = index; i < pending.count - 1; i++) pending.slots[i] = pending.slots[i + 1]; + pending.slots[--pending.count] = lifted; + resetPair(pending); + onInput?.(); + }; + + const liftAll = (): void => { + pending.count = 0; + }; + const onVisibilityChange = (): void => { + if (document.hidden) liftAll(); + }; + + const restoreStyles = suppressNativeGestures(element); + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointermove', onPointerMove); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointercancel', onPointerUp); + element.addEventListener('lostpointercapture', onPointerUp); + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', liftAll); + + return () => { + restoreStyles(); + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointermove', onPointerMove); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointercancel', onPointerUp); + element.removeEventListener('lostpointercapture', onPointerUp); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', liftAll); + liftAll(); + }; +} + +const copyAndZero = (out: Vec2, source: Vec2): void => { + out[0] = source[0]; + out[1] = source[1]; + source[0] = 0; + source[1] = 0; +}; + +/** Starts a new frame: the input gathered since the last call becomes this frame's. */ +export function update(state: TouchState): void { + const pending = state.pending; + state.fingers = pending.count; + copyAndZero(state.drag.one, pending.drag.one); + copyAndZero(state.drag.two, pending.drag.two); + copyAndZero(state.drag.three, pending.drag.three); + state.pinchDelta = pending.pinchDelta; + state.twistDelta = pending.twistDelta; + pending.pinchDelta = 0; + pending.twistDelta = 0; +} diff --git a/src/input-sources/wheel.ts b/src/input-sources/wheel.ts new file mode 100644 index 00000000..d61ada4e --- /dev/null +++ b/src/input-sources/wheel.ts @@ -0,0 +1,77 @@ +import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; + +/** Wheel and trackpad scrolling, in pixels, summed per frame. */ +export type WheelState = { + /** Scroll since the last `update`. */ + deltaX: number; + deltaY: number; + /** Trackpad pinch since the last `update`, as the change in `ln(scale)`. Spreading is positive. */ + pinchDelta: number; + /** Only scrolls that start inside this normalized region count. */ + interactiveArea: InteractiveArea | null; + /** Keep the page from scrolling under the element. */ + preventPageScroll: boolean; + /** Keep a trackpad pinch from zooming the page. */ + preventPageZoom: boolean; + /** Pixels per line, for wheels that report lines. */ + pixelsPerLine: number; + /** Pixels per page, for wheels that report pages. */ + pixelsPerPage: number; + /** Internal. */ + pendingX: number; + pendingY: number; + pendingPinch: number; +}; + +export const create = (): WheelState => ({ + deltaX: 0, + deltaY: 0, + pinchDelta: 0, + interactiveArea: null, + preventPageScroll: true, + preventPageZoom: true, + pixelsPerLine: 33, + pixelsPerPage: 800, + pendingX: 0, + pendingY: 0, + pendingPinch: 0, +}); + +const pixelsPerUnit = (state: WheelState, deltaMode: number): number => + deltaMode === WheelEvent.DOM_DELTA_LINE + ? state.pixelsPerLine + : deltaMode === WheelEvent.DOM_DELTA_PAGE + ? state.pixelsPerPage + : 1; + +/** Listens to `element`'s wheel. Returns a function that stops. */ +export function connect(state: WheelState, element: HTMLElement, onInput?: () => void): () => void { + const onWheel = (event: WheelEvent): void => { + if (!isInsideInteractiveArea(element, state.interactiveArea, event.clientX, event.clientY)) return; + if (event.ctrlKey ? state.preventPageZoom : state.preventPageScroll) event.preventDefault(); + const scale = pixelsPerUnit(state, event.deltaMode); + if (event.ctrlKey) { + // Browsers send a trackpad pinch as a ctrlKey wheel with deltaY = -100 * ln(scale). + state.pendingPinch -= (event.deltaY * scale) / 100; + } else if (event.shiftKey && event.deltaX === 0) { + // Most mice have one wheel and report shift+scroll on deltaY. + state.pendingX += event.deltaY * scale; + } else { + state.pendingX += event.deltaX * scale; + state.pendingY += event.deltaY * scale; + } + onInput?.(); + }; + element.addEventListener('wheel', onWheel, { passive: false }); + return () => element.removeEventListener('wheel', onWheel); +} + +/** Starts a new frame: the scroll gathered since the last call becomes this frame's. */ +export function update(state: WheelState): void { + state.deltaX = state.pendingX; + state.deltaY = state.pendingY; + state.pinchDelta = state.pendingPinch; + state.pendingX = 0; + state.pendingY = 0; + state.pendingPinch = 0; +} diff --git a/src/react/index.ts b/src/react/index.ts index 3e8efd1a..06be11f9 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -9,8 +9,14 @@ export { } from './VirtualCamera'; export { useIsActiveVirtualCamera, useIsLiveVirtualCamera, useVirtualCamera } from './VirtualCameraContext'; -export { InputAxisOwnerContext, type InputAxisOwner } from './input/InputAxisOwnerContext'; -export { InputController, type InputControllerProps, type InputSourceConfig } from './input/InputController'; +export type { InputAxisOwner } from '../core/input/InputAxisOwner'; +export { InputAxisOwnerContext } from './input/InputAxisOwnerContext'; +export { + InputController, + type InputAxisSourceConfig, + type InputControllerProps, + type InputSourceConfig, +} from './input/InputController'; export { Body } from './body/Body'; export { Follow, type FollowProps } from './body/Follow'; diff --git a/src/react/input/InputAxisOwnerContext.ts b/src/react/input/InputAxisOwnerContext.ts index 02d8001d..2c10a09e 100644 --- a/src/react/input/InputAxisOwnerContext.ts +++ b/src/react/input/InputAxisOwnerContext.ts @@ -1,11 +1,6 @@ import { createContext } from 'react'; -import type { InputAxis } from '../../core/input/InputAxis'; +import type { InputAxisOwner } from '../../core/input/InputAxisOwner'; -/** Anything a `` can drive - any Body/Aim/Extension exposing named `InputAxis` instances */ -export type InputAxisOwner = { - readonly inputAxes: Record; -}; - -/** Set by a Body/Aim/Extension around its `children`, so a nested `` finds its `inputAxes` */ +/** Set by a Body/Aim/Extension around its `children`, so a nested `` finds its `inputAxes`. */ export const InputAxisOwnerContext = createContext(null); diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index 3cc18b11..b3f61e60 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -1,15 +1,17 @@ import { useThree } from '@react-three/fiber'; import { use, useEffect, useImperativeHandle, useState, type Ref, type RefObject } from 'react'; -import type { InputAxis } from '../../core/input/InputAxis'; +import type { InputAxisData } from '../../core/input/axis'; +import type { InputAxisOwner } from '../../core/input/InputAxisOwner'; +import type { InputControllerConfig, InputInvert } from '../../core/input/inputMapping'; -import { InputAxisController, type InputAxisControllerConfig, type InputInvert } from '../../dom/InputAxisController'; +import { InputControllerDom } from '../../dom/InputControllerDom'; import type { InteractiveArea } from '../../dom/InputSystem'; import { useKlipp } from '../KlippContext'; -import { useIsActiveVirtualCamera, useIsLiveVirtualCamera } from '../VirtualCameraContext'; +import { useIsActiveVirtualCamera, useIsLiveVirtualCamera, useVirtualCamera } from '../VirtualCameraContext'; -import { InputAxisOwnerContext, type InputAxisOwner } from './InputAxisOwnerContext'; +import { InputAxisOwnerContext } from './InputAxisOwnerContext'; export type InputSourceConfig = { /** Axis names to drive for each source. */ @@ -19,8 +21,16 @@ export type InputSourceConfig = { invert?: InputInvert; }; +export type InputAxisSourceConfig = { + /** Axis name to drive. */ + axis: string; + /** Multiplies the raw delta before it reaches the axis. */ + gain?: number; + invert?: boolean; +}; + export type InputControllerProps = { - /** Axis owner to drive. Uses the nearest context when omitted. */ + /** Axis owner to drive. Uses the nearest owner context when omitted, or else the virtual camera itself. */ target?: RefObject; mouseButtons?: { left?: InputSourceConfig | null; @@ -32,9 +42,13 @@ export type InputControllerProps = { two?: InputSourceConfig | null; three?: InputSourceConfig | null; }; + /** Vertical wheel scrolling, in pixels. Scrolling up is positive, like spreading a pinch. */ + wheel?: InputAxisSourceConfig | null; + /** Pinch with two fingers or on a trackpad, as the change in `ln(scale)`. Spreading is positive. */ + pinch?: InputAxisSourceConfig | null; /** Wait until this camera is live before listening to input. */ waitForBlend?: boolean; - /** Whether input processing is enabled. */ + /** Whether input reaches the axes. Unmount the component to stop listening altogether. */ enabled?: boolean; /** Suppresses the native right-click context menu. */ suppressContextMenu?: boolean; @@ -42,10 +56,10 @@ export type InputControllerProps = { interactiveArea?: InteractiveArea | null; /** Lock diagonal two-finger input to pinch or rotation. */ lockTouchAxis?: boolean; - ref?: Ref; + ref?: Ref; }; -function resolveAxis(owner: InputAxisOwner, name: string): InputAxis | null { +function resolveAxis(owner: InputAxisOwner, name: string): InputAxisData | null { const axis = owner.inputAxes[name]; if (!axis) { console.warn(`: no axis named "${name}" on target's inputAxes.`); @@ -62,7 +76,13 @@ function resolveSource(owner: InputAxisOwner, source: InputSourceConfig | null | return { axes: { x, y }, gain: source.gain, invert: source.invert }; } -function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputAxisControllerConfig { +function resolveAxisSource(owner: InputAxisOwner, source: InputAxisSourceConfig | null | undefined) { + if (!source) return null; + const axis = resolveAxis(owner, source.axis); + return axis && { axis, gain: source.gain, invert: source.invert }; +} + +function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputControllerConfig { return { mouseButtons: { left: resolveSource(owner, props.mouseButtons?.left), @@ -74,18 +94,23 @@ function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputA two: resolveSource(owner, props.touches?.two), three: resolveSource(owner, props.touches?.three), }, + wheel: resolveAxisSource(owner, props.wheel), + pinch: resolveAxisSource(owner, props.pinch), }; } -const emptyConfig: InputAxisControllerConfig = { - mouseButtons: { left: null, right: null, middle: null }, - touches: { one: null, two: null, three: null }, +/** What the config was last built from. */ +type Binding = { + props: InputControllerProps; + contextOwner: InputAxisOwner | null; + owner: InputAxisOwner | null; + axes: InputAxisOwner['inputAxes'] | null; + stale: boolean; }; /** Connects DOM input sources to named axes on a camera component. */ export function InputController(props: InputControllerProps) { const { - target, waitForBlend = true, enabled = true, suppressContextMenu = false, @@ -94,6 +119,7 @@ export function InputController(props: InputControllerProps) { ref, } = props; const contextOwner = use(InputAxisOwnerContext); + const camera = useVirtualCamera(); const klipp = useKlipp(); const isActive = useIsActiveVirtualCamera(); const isLive = useIsLiveVirtualCamera(); @@ -101,7 +127,8 @@ export function InputController(props: InputControllerProps) { const domElement = useThree((state) => state.gl.domElement); const invalidate = useThree((state) => state.invalidate); - const [controller] = useState(() => new InputAxisController(emptyConfig)); + const [controller] = useState(() => new InputControllerDom({})); + const [binding] = useState(() => ({ props, contextOwner, owner: null, axes: null, stale: true })); useImperativeHandle(ref, () => controller, [controller]); controller.enabled = enabled; @@ -110,30 +137,34 @@ export function InputController(props: InputControllerProps) { controller.inputSystem.lockTouchAxis = lockTouchAxis; useEffect(() => { - const owner = target?.current ?? contextOwner; - if (owner) controller.config = buildConfig(owner, props); + binding.props = props; + binding.contextOwner = contextOwner; + binding.stale = true; }); - useEffect(() => klipp.registerUpdate(() => controller.update()), [klipp, controller]); + useEffect( + () => + klipp.registerUpdate(() => { + // Resolved per frame, since the camera's pieces can register after this or change later. + const owner = binding.props.target?.current ?? binding.contextOwner ?? camera; + if (binding.stale || owner !== binding.owner || owner.inputAxes !== binding.axes) { + controller.config = buildConfig(owner, binding.props); + binding.owner = owner; + binding.axes = owner.inputAxes; + binding.stale = false; + } + controller.update(); + }), + [klipp, controller, binding, camera], + ); useEffect(() => { if (!shouldConnect) return; - controller.connect(domElement); - // frameloop="demand" needs a frame for each event that feeds the axes, not for plain hovering. - const onPointer = (event: PointerEvent) => { - const dragging = event.buttons !== 0 || event.pointerType === 'touch'; - if (event.type !== 'pointermove' || dragging || document.pointerLockElement === domElement) invalidate(); - }; - domElement.addEventListener('pointerdown', onPointer); - domElement.addEventListener('pointermove', onPointer); - domElement.addEventListener('pointerup', onPointer); - domElement.addEventListener('pointercancel', onPointer); + controller.connect(domElement, () => invalidate()); return () => { controller.disconnect(); - domElement.removeEventListener('pointerdown', onPointer); - domElement.removeEventListener('pointermove', onPointer); - domElement.removeEventListener('pointerup', onPointer); - domElement.removeEventListener('pointercancel', onPointer); + // One more frame lets the axes see the buttons released by disconnecting. + invalidate(); }; }, [controller, domElement, shouldConnect, invalidate]); diff --git a/test/core/VirtualCamera.test.ts b/test/core/VirtualCamera.test.ts index 2a2b7320..a1414b9e 100644 --- a/test/core/VirtualCamera.test.ts +++ b/test/core/VirtualCamera.test.ts @@ -5,6 +5,8 @@ import * as cameraState from '../../src/core/CameraState'; import { BlendCurves } from '../../src/core/blend/BlendCurves'; import { BlendHints } from '../../src/core/blend/BlendHints'; import type { CameraState } from '../../src/core/CameraState'; +import { InputAxis } from '../../src/core/input/InputAxis'; +import type { InputAxisOwner } from '../../src/core/input/InputAxisOwner'; import { register } from '../../src/core/internal'; import { Klipp } from '../../src/core/Klipp'; import { VirtualCamera, type CameraPiece, type CameraStateWriter } from '../../src/core/VirtualCamera'; @@ -353,3 +355,51 @@ describe('VirtualCamera — pieces and registration', () => { expect([framing.viewportWidth, framing.viewportHeight]).toEqual([800, 400]); }); }); + +describe('VirtualCamera — inputAxes', () => { + const owner = (axes: Record): CameraPiece & InputAxisOwner => ({ + update: () => {}, + inputAxes: axes, + }); + + it('collects the axes of every piece by name and skips pieces without any', () => { + const camera = new VirtualCamera('a'); + const radial = new InputAxis(); + const pan = new InputAxis(); + const zoom = new InputAxis(); + camera.setBody(owner({ radial })); + camera.setAim(owner({ pan })); + camera.addExtension(owner({ zoom })); + camera.addNoise({ update: () => {} }); + + expect(camera.inputAxes).toEqual({ radial, pan, zoom }); + }); + + it('keeps the same object until the pieces change, then builds a new one', () => { + const camera = new VirtualCamera('a'); + const pan = new InputAxis(); + const removeAim = camera.setAim(owner({ pan })); + const first = camera.inputAxes; + expect(camera.inputAxes).toBe(first); + + removeAim(); + expect(camera.inputAxes).not.toBe(first); + expect(camera.inputAxes).toEqual({}); + + const tilt = new InputAxis(); + camera.aim = owner({ tilt }); + expect(camera.inputAxes).toEqual({ tilt }); + }); + + it('warns about a duplicate name and keeps the first axis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const camera = new VirtualCamera('shot'); + const first = new InputAxis(); + camera.setBody(owner({ pan: first })); + camera.setAim(owner({ pan: new InputAxis() })); + + expect(camera.inputAxes.pan).toBe(first); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('"pan"')); + warn.mockRestore(); + }); +}); diff --git a/test/core/input/InputAxis.test.ts b/test/core/input/InputAxis.test.ts index 68c8aa44..fff8de6c 100644 --- a/test/core/input/InputAxis.test.ts +++ b/test/core/input/InputAxis.test.ts @@ -79,6 +79,56 @@ describe('InputAxis', () => { }); }); + describe('setValue', () => { + it('jumps mid-ease and stays there, where assigning value would ease back', () => { + const axis = new InputAxis({ value: 0 }); + axis.damping = 0.3; + axis.applyDelta(50); + settle(axis, 5); + + axis.setValue(-20); + expect(axis.value).toBe(-20); + settle(axis, 100); + expect(axis.value).toBe(-20); + }); + + it('eases the next delta from rest, like a fresh axis', () => { + const moved = new InputAxis({ value: 0 }); + moved.damping = 0.3; + moved.applyDelta(50); + settle(moved, 5); + moved.setValue(10); + + const fresh = new InputAxis({ value: 10 }); + fresh.damping = 0.3; + settle(fresh); + + moved.applyDelta(5); + fresh.applyDelta(5); + moved.update(0.016); + fresh.update(0.016); + expect(moved.value).toBe(fresh.value); + }); + + it('clamps to the range unless the axis wraps', () => { + const clamped = new InputAxis({ range: [-10, 10] }); + clamped.setValue(50); + expect(clamped.value).toBe(10); + + const wrapped = new InputAxis({ range: [-180, 180], wrap: true }); + wrapped.setValue(190); + expect(wrapped.value).toBe(190); + }); + + it('does not count as input, so a running recentering carries on', () => { + const axis = new InputAxis({ value: 10, center: 0, recentering: recentering(0, 0.5) }); + axis.update(0.1); + axis.setValue(20); + axis.update(0.1); + expect(axis.value).toBeLessThan(20); + }); + }); + describe('wrap', () => { it('lets a drag run past the seam, and normalize() folds it back to the same angle', () => { const axis = new InputAxis({ value: 170, center: 0, range: [-180, 180], wrap: true }); diff --git a/test/core/input/InputController.test.ts b/test/core/input/InputController.test.ts new file mode 100644 index 00000000..653543dd --- /dev/null +++ b/test/core/input/InputController.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { InputAxis } from '../../../src/core/input/InputAxis'; +import { InputController } from '../../../src/core/input/InputController'; + +function setup() { + const x = new InputAxis(); + const y = new InputAxis(); + const controller = new InputController({ + mouseButtons: { left: { axes: { x, y }, gain: 2 }, right: null, middle: null }, + touches: { one: null, two: null, three: null }, + }); + return { x, y, controller }; +} + +describe('InputController', () => { + it('feeds input written by the caller into the mapped axes, without any device', () => { + const { x, y, controller } = setup(); + controller.input.leftDx = 3; + controller.input.leftDy = -1; + controller.input.leftHeld = true; + controller.update(); + x.update(0.016); + y.update(0.016); + + expect(x.value).toBe(6); + expect(y.value).toBe(-2); + expect(x.held).toBe(true); + }); + + it('applies nothing while disabled, and releases held axes', () => { + const { x, controller } = setup(); + controller.enabled = false; + controller.input.leftDx = 3; + controller.input.leftHeld = true; + controller.update(); + x.update(0.016); + + expect(x.value).toBe(0); + expect(x.held).toBe(false); + }); +}); + +describe('InputController — single-axis sources', () => { + function single() { + const axis = new InputAxis(); + const controller = new InputController({ + mouseButtons: { left: null, right: null, middle: null }, + touches: { one: null, two: null, three: null }, + }); + const step = () => { + controller.update(); + axis.update(0.016); + }; + return { axis, controller, step }; + } + + it('feeds wheel scrolling with up as positive, scaled and optionally inverted', () => { + const { axis, controller, step } = single(); + controller.config.wheel = { axis, gain: 0.01 }; + controller.input.wheelDeltaY = -100; + step(); + expect(axis.value).toBeCloseTo(1, 10); + + controller.config.wheel.invert = true; + step(); + expect(axis.value).toBeCloseTo(0, 10); + }); + + it('feeds a pinch and holds its axis while two fingers are down', () => { + const { axis, controller, step } = single(); + controller.config.pinch = { axis, gain: 2 }; + controller.input.pinchDelta = Math.log(1.5); + controller.input.touchTwoHeld = true; + step(); + expect(axis.value).toBeCloseTo(2 * Math.log(1.5), 10); + expect(axis.held).toBe(true); + + controller.input.pinchDelta = 0; + controller.input.touchTwoHeld = false; + step(); + expect(axis.held).toBe(false); + }); +}); diff --git a/test/dom/InputAxisController.test.ts b/test/dom/InputControllerDom.test.ts similarity index 72% rename from test/dom/InputAxisController.test.ts rename to test/dom/InputControllerDom.test.ts index 2a9186c7..50ebb17a 100644 --- a/test/dom/InputAxisController.test.ts +++ b/test/dom/InputControllerDom.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { InputAxis } from '../../src/core/input/InputAxis'; +import type { InputControllerConfig } from '../../src/core/input/inputMapping'; -import { InputAxisController, type InputAxisControllerConfig } from '../../src/dom/InputAxisController'; +import { InputControllerDom } from '../../src/dom/InputControllerDom'; function pointer(el: HTMLElement, type: string, x: number, y: number, buttons: number, pointerId = 1): void { el.dispatchEvent( @@ -22,14 +23,14 @@ function touch(el: HTMLElement, type: string, x: number, y: number, pointerId = el.dispatchEvent(new PointerEvent(type, { pointerId, clientX: x, clientY: y, bubbles: true, pointerType: 'touch' })); } -// InputAxisController.update() only calls applyDelta() on the mapped axes - it's up to the axis owner +// InputControllerDom.update() only calls applyDelta() on the mapped axes - it's up to the axis owner // (e.g. PanTiltAim) to call axis.update(dt) afterward. damping=0 (default) converges near-instantly but // still needs a couple of real update() ticks to settle exactly, same as InputAxis's own tests. function settle(...axes: InputAxis[]): void { for (let i = 0; i < 3; i++) for (const axis of axes) axis.update(0.016); } -function emptyConfig(): InputAxisControllerConfig { +function emptyConfig(): InputControllerConfig { return { mouseButtons: { left: null, right: null, middle: null }, touches: { one: null, two: null, three: null }, @@ -48,21 +49,21 @@ function stubPointerLock(el: HTMLElement): void { }; } -describe('InputAxisController', () => { +describe('InputControllerDom', () => { let element: HTMLElement; - let controller: InputAxisController; + let controller: InputControllerDom; afterEach(() => { controller?.disconnect(); element?.remove(); }); - function setup(config: InputAxisControllerConfig): HTMLElement { + function setup(config: InputControllerConfig): HTMLElement { element = document.createElement('div'); document.body.appendChild(element); element.setPointerCapture = () => {}; element.releasePointerCapture = () => {}; - controller = new InputAxisController(config); + controller = new InputControllerDom(config); controller.connect(element); return element; } @@ -155,8 +156,8 @@ describe('InputAxisController', () => { }); it("feeds buttonless movement under Pointer Lock through mouseButtons.left's mapping", () => { - const lockedMove = (left: InputAxisControllerConfig['mouseButtons']['left']) => { - const el = setup({ ...emptyConfig(), mouseButtons: { left, right: null, middle: null } }); + const lockedMove = (config: InputControllerConfig) => { + const el = setup(config); stubPointerLock(el); controller.inputSystem.requestPointerLock(); el.dispatchEvent( @@ -175,11 +176,22 @@ describe('InputAxisController', () => { const x = new InputAxis(); const y = new InputAxis(); - lockedMove({ axes: { x, y }, gain: 2, invert: true }); + lockedMove({ mouseButtons: { left: { axes: { x, y }, gain: 2, invert: true } } }); settle(x, y); expect([x.value, y.value]).toEqual([-20, -8]); - expect(() => lockedMove(null)).not.toThrow(); + expect(() => lockedMove({})).not.toThrow(); + }); + + it('takes a config that lists only the sources it uses', () => { + const x = new InputAxis(); + const y = new InputAxis(); + const el = setup({ touches: { one: { axes: { x, y } } } }); + touch(el, 'pointerdown', 0, 0); + touch(el, 'pointermove', 7, 3); + controller.update(); + settle(x, y); + expect([x.value, y.value]).toEqual([7, 3]); }); describe('held propagation', () => { @@ -221,4 +233,46 @@ describe('InputAxisController', () => { expect(y.held).toBe(true); }); }); + + it('calls onInput for every event that feeds the input, which is what wakes an on-demand render loop', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + el.setPointerCapture = () => {}; + const onInput = vi.fn(); + controller = new InputControllerDom(emptyConfig()); + controller.connect(el, onInput); + + pointer(el, 'pointermove', 5, 5, 0); + expect(onInput).not.toHaveBeenCalled(); + + el.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true })); + pointer(el, 'pointerdown', 0, 0, 1); + pointer(el, 'pointermove', 10, 0, 1); + touch(el, 'pointerdown', 0, 0, 2); + expect(onInput).toHaveBeenCalledTimes(4); + el.remove(); + }); + + it('leaves scrolling to the page unless wheel is mapped, and pinch zoom unless pinch is', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + controller = new InputControllerDom(emptyConfig()); + controller.connect(el); + const blocked = (init: WheelEventInit) => { + const event = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true, ...init }); + el.dispatchEvent(event); + return event.defaultPrevented; + }; + + expect([blocked({}), blocked({ ctrlKey: true })]).toEqual([false, false]); + + controller.config.wheel = { axis: new InputAxis() }; + controller.update(); + expect([blocked({}), blocked({ ctrlKey: true })]).toEqual([true, false]); + + controller.config.pinch = { axis: new InputAxis() }; + controller.update(); + expect([blocked({}), blocked({ ctrlKey: true })]).toEqual([true, true]); + el.remove(); + }); }); diff --git a/test/dom/InputSystem.test.ts b/test/dom/InputSystem.test.ts index b4c7090c..55b34ff9 100644 --- a/test/dom/InputSystem.test.ts +++ b/test/dom/InputSystem.test.ts @@ -130,13 +130,25 @@ describe('InputSystem', () => { expect(wheel({ deltaX: 15, deltaY: 40, shiftKey: true })).toEqual([15, 40]); // a trackpad's own deltaX wins }); - it('a ctrlKey wheel event (trackpad pinch) routes into wheelZoomDelta, not wheelDeltaX/Y', () => { + it('adds touch and trackpad pinches into one pinchDelta, in the same ln(scale) units', () => { + const el = setup(); + touch(el, 'pointerdown', 0, 0, 1); + touch(el, 'pointerdown', 100, 0, 2); + touch(el, 'pointermove', 200, 0, 2); + el.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 * Math.log(1.5), ctrlKey: true, bubbles: true })); + + const out = emptyInput(); + system.consume(out); + expect(out.pinchDelta).toBeCloseTo(Math.log(3), 5); + }); + + it('a ctrlKey wheel event (trackpad pinch) routes into pinchDelta as ln(scale), not wheelDeltaX/Y', () => { const el = setup(); el.dispatchEvent(new WheelEvent('wheel', { deltaX: 5, deltaY: 50, ctrlKey: true, bubbles: true })); const out = emptyInput(); system.consume(out); - expect(out.wheelZoomDelta).toBeCloseTo(50, 5); + expect(out.pinchDelta).toBeCloseTo(-0.5, 5); expect(out.wheelDeltaX).toBe(0); expect(out.wheelDeltaY).toBe(0); }); @@ -222,7 +234,7 @@ describe('InputSystem', () => { expect(out.leftDx).toBe(0); }); - it('a second finger joining stops touchOneDx/Dy and starts driving touchTwoDx/Dy + touchPinchDelta', () => { + it('a second finger joining stops touchOneDx/Dy and starts driving touchTwoDx/Dy + pinchDelta', () => { const el = setup(); touch(el, 'pointerdown', 0, 0, 1); @@ -235,7 +247,7 @@ describe('InputSystem', () => { expect(out.touchOneDx).toBeCloseTo(10, 5); // unchanged since the second finger joined expect(out.touchTwoDx).toBeCloseTo(5, 5); // centroid: (10+100)/2=55 -> (20+100)/2=60 - expect(out.touchPinchDelta).toBeCloseTo(-10, 5); // distance: 90 -> 80, fingers came closer + expect(out.pinchDelta).toBeCloseTo(Math.log(80 / 90), 5); // distance: 90 -> 80, fingers came closer }); it('pinch grows as fingers spread, and stays 0 when both move together as a pan', () => { @@ -245,13 +257,13 @@ describe('InputSystem', () => { touch(el, 'pointermove', -50, 0, 1); const out = emptyInput(); system.consume(out); - expect(out.touchPinchDelta).toBeCloseTo(50, 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(150 / 100), 5); touch(el, 'pointermove', -40, 0, 1); touch(el, 'pointermove', 110, 0, 2); system.consume(out); expect(out.touchTwoDx).toBeCloseTo(10, 5); - expect(out.touchPinchDelta).toBeCloseTo(0, 5); + expect(out.pinchDelta).toBeCloseTo(0, 5); }); it('rotate: a twist produces a signed touchRotateDelta', () => { @@ -291,7 +303,7 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - expect(out.touchPinchDelta).toBeCloseTo(Math.sqrt(50 * 50 + 50 * 50) - 100, 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(Math.hypot(50, 50) / 100), 5); expect(out.touchRotateDelta).toBeCloseTo(Math.PI / 4, 5); }); @@ -309,11 +321,11 @@ describe('InputSystem', () => { }; const pinch = gesture(150, 5); - expect(pinch.touchPinchDelta).toBeCloseTo(Math.hypot(150, 5) - 100, 5); + expect(pinch.pinchDelta).toBeCloseTo(Math.log(Math.hypot(150, 5) / 100), 5); expect(pinch.touchRotateDelta).toBe(0); const rotate = gesture(95, 34); expect(rotate.touchRotateDelta).toBeCloseTo(Math.atan2(34, 95), 5); - expect(rotate.touchPinchDelta).toBe(0); + expect(rotate.pinchDelta).toBe(0); }); it('lockTouchAxis decides once per gesture and decides again for the next one', () => { @@ -326,7 +338,7 @@ describe('InputSystem', () => { touch(el, 'pointermove', 95, 34, 2); // twist: locks to rotate touch(el, 'pointermove', 190, 68, 2); // pure spread from here on system.consume(out); - expect(out.touchPinchDelta).toBe(0); + expect(out.pinchDelta).toBe(0); touch(el, 'pointerup', 190, 68, 2); touch(el, 'pointerup', 0, 0, 1); @@ -334,7 +346,7 @@ describe('InputSystem', () => { touch(el, 'pointerdown', 100, 0, 2); touch(el, 'pointermove', 150, 5, 2); // spread: locks to pinch this time system.consume(out); - expect(out.touchPinchDelta).toBeCloseTo(Math.hypot(150, 5) - 100, 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(Math.hypot(150, 5) / 100), 5); expect(out.touchRotateDelta).toBe(0); }); @@ -383,8 +395,8 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - // fresh baseline: 100 - 70 = 30. A stale baseline (the original 100) would wrongly give ~0. - expect(out.touchPinchDelta).toBeCloseTo(30, 5); + // fresh baseline: 70 -> 100. A stale baseline (the original 100) would wrongly give ~0. + expect(out.pinchDelta).toBeCloseTo(Math.log(100 / 70), 5); }); it('lifting the first finger while three are tracked promotes the other two, fresh pinch baseline', () => { @@ -399,8 +411,8 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - // fresh baseline: 200 - 150 = 50. A stale baseline (from the original 1<->2 pair, 100) would be wrong. - expect(out.touchPinchDelta).toBeCloseTo(50, 5); + // fresh baseline: 150 -> 200. A stale baseline (from the original 1<->2 pair, 100) would be wrong. + expect(out.pinchDelta).toBeCloseTo(Math.log(200 / 150), 5); }); it('lifting the first finger while the second is down promotes it - a fresh one-finger drag, no jump', () => { @@ -730,7 +742,7 @@ describe('InputSystem', () => { }); describe('Safari gesture events', () => { - it('buffers scale steps into gestureZoomDelta and rotation steps into touchRotateDelta, in radians', () => { + it('buffers pinch as ln(scale) into pinchDelta and rotation into touchRotateDelta, in radians', () => { const el = setup(); gesture(el, 'gesturestart', 1, 0); gesture(el, 'gesturechange', 1.1, 0); @@ -739,7 +751,7 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - expect(out.gestureZoomDelta).toBeCloseTo(0.15, 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(1.15), 5); expect(out.touchRotateDelta).toBeCloseTo((10 * Math.PI) / 180, 5); }); @@ -754,7 +766,7 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - expect(out.gestureZoomDelta).toBe(0); + expect(out.pinchDelta).toBe(0); }); it('a fresh gesturestart resets the scale baseline - no stale jump from the previous gesture', () => { @@ -769,7 +781,7 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - expect(out.gestureZoomDelta).toBeCloseTo(0.1, 5); // not -0.9, which a stale lastGestureScale=2 would give + expect(out.pinchDelta).toBeCloseTo(Math.log(1.1), 5); // a stale baseline of 2 would give ln(0.55) }); describe('lockTouchAxis', () => { @@ -786,11 +798,11 @@ describe('InputSystem', () => { }; const zoom = gestureWith(1.5, 5); - expect(zoom.gestureZoomDelta).toBeCloseTo(0.5, 5); + expect(zoom.pinchDelta).toBeCloseTo(Math.log(1.5), 5); expect(zoom.touchRotateDelta).toBe(0); const turn = gestureWith(1.01, 20); expect(turn.touchRotateDelta).toBeCloseTo((20 * Math.PI) / 180, 5); - expect(turn.gestureZoomDelta).toBe(0); + expect(turn.pinchDelta).toBe(0); }); it('decides once per gesture and decides again for the next one', () => { @@ -802,13 +814,13 @@ describe('InputSystem', () => { gesture(el, 'gesturechange', 1.01, 20); // locks to rotate gesture(el, 'gesturechange', 1.5, 20); system.consume(out); - expect(out.gestureZoomDelta).toBe(0); + expect(out.pinchDelta).toBe(0); gesture(el, 'gestureend', 1.5, 20); gesture(el, 'gesturestart', 1, 0); gesture(el, 'gesturechange', 1.5, 5); // locks to pinch this time system.consume(out); - expect(out.gestureZoomDelta).toBeCloseTo(0.5, 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(1.5), 5); expect(out.touchRotateDelta).toBe(0); }); }); diff --git a/test/input-sources/buttonInput.test.ts b/test/input-sources/buttonInput.test.ts new file mode 100644 index 00000000..6d38d10a --- /dev/null +++ b/test/input-sources/buttonInput.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import * as buttonInput from '../../src/input-sources/buttonInput'; + +const sorted = (set: Set): T[] => [...set].sort(); + +describe('buttonInput', () => { + it('a press is held until released, and "just" only in the frame it happened', () => { + const buttons = buttonInput.create(); + buttonInput.press(buttons, 'a'); + expect(buttons.pressed.has('a')).toBe(true); + expect(buttons.justPressed.has('a')).toBe(true); + + buttonInput.clear(buttons); + expect(buttons.pressed.has('a')).toBe(true); + expect(buttons.justPressed.has('a')).toBe(false); + + buttonInput.release(buttons, 'a'); + expect(buttons.pressed.has('a')).toBe(false); + expect(buttons.justReleased.has('a')).toBe(true); + + buttonInput.clear(buttons); + expect(buttons.justReleased.has('a')).toBe(false); + }); + + it('pressing a held button again is not a new press, so key repeat needs no filtering', () => { + const buttons = buttonInput.create(); + buttonInput.press(buttons, 'a'); + buttonInput.clear(buttons); + buttonInput.press(buttons, 'a'); + expect(buttons.justPressed.has('a')).toBe(false); + }); + + it('releasing a button that is not held does nothing', () => { + const buttons = buttonInput.create(); + buttonInput.release(buttons, 'a'); + expect(buttons.justReleased.size).toBe(0); + }); + + it('a press and release within one frame reports both, with nothing left held', () => { + const buttons = buttonInput.create(); + buttonInput.press(buttons, 'a'); + buttonInput.release(buttons, 'a'); + expect(buttons.pressed.has('a')).toBe(false); + expect(buttons.justPressed.has('a')).toBe(true); + expect(buttons.justReleased.has('a')).toBe(true); + }); + + it('releaseAll releases every held button and only those', () => { + const buttons = buttonInput.create(); + buttonInput.press(buttons, 'a'); + buttonInput.press(buttons, 'b'); + buttonInput.clear(buttons); + buttonInput.releaseAll(buttons); + expect(buttons.pressed.size).toBe(0); + expect(sorted(buttons.justReleased)).toEqual(['a', 'b']); + }); +}); diff --git a/test/input-sources/mouse.test.ts b/test/input-sources/mouse.test.ts new file mode 100644 index 00000000..6f8d8ee5 --- /dev/null +++ b/test/input-sources/mouse.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as mouse from '../../src/input-sources/mouse'; + +const LEFT = 1; +const RIGHT = 2; + +type Move = { x?: number; y?: number; buttons?: number; id?: number; movementX?: number; movementY?: number }; + +const connections: (() => void)[] = []; + +function setup() { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }) as DOMRect; + element.setPointerCapture = () => {}; + document.body.appendChild(element); + const state = mouse.create(); + const onInput = vi.fn(); + let disconnect = mouse.connect(state, element, onInput); + const send = (type: string, { x = 0, y = 0, buttons = 0, id = 1, movementX = 0, movementY = 0 }: Move = {}) => + element.dispatchEvent( + new PointerEvent(type, { + pointerId: id, + clientX: x, + clientY: y, + buttons, + movementX, + movementY, + bubbles: true, + cancelable: true, + pointerType: 'mouse', + }), + ); + const lock = (locked: boolean) => { + Object.defineProperty(document, 'pointerLockElement', { value: locked ? element : null, configurable: true }); + document.dispatchEvent(new Event('pointerlockchange')); + }; + const reconnect = () => { + disconnect(); + disconnect = mouse.connect(state, element, onInput); + }; + connections.push(() => disconnect()); + return { element, state, onInput, send, lock, reconnect, disconnect: () => disconnect() }; +} + +afterEach(() => { + for (const disconnect of connections.splice(0)) disconnect(); + document.body.innerHTML = ''; + Object.defineProperty(document, 'pointerLockElement', { value: null, configurable: true }); + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); +}); + +describe('mouse', () => { + it('sums a frame of dragging on update, and reading it does not clear it', () => { + const { state, send } = setup(); + send('pointerdown', { buttons: LEFT }); + send('pointermove', { x: 10, y: 4, buttons: LEFT }); + send('pointermove', { x: 25, y: 10, buttons: LEFT }); + expect(state.drag.left).toEqual([0, 0]); + + mouse.update(state); + expect(state.drag.left).toEqual([25, 10]); + expect(state.drag.left).toEqual([25, 10]); + + mouse.update(state); + expect(state.drag.left).toEqual([0, 0]); + }); + + it('gives each move to the buttons held at that moment', () => { + const { state, send } = setup(); + send('pointerdown', { buttons: LEFT | RIGHT }); + send('pointermove', { x: 10, buttons: LEFT | RIGHT }); + send('pointerup'); + send('pointerdown', { buttons: RIGHT }); + send('pointermove', { x: 5, y: 5, buttons: RIGHT }); + mouse.update(state); + + expect(state.drag.left).toEqual([10, 0]); + expect(state.drag.right).toEqual([15, 5]); + }); + + it('reports held buttons across frames, and a click inside one frame as pressed and released', () => { + const { state, send } = setup(); + send('pointerdown', { buttons: LEFT }); + mouse.update(state); + expect(state.buttons.justPressed.has('left')).toBe(true); + mouse.update(state); + expect(state.buttons.pressed.has('left')).toBe(true); + expect(state.buttons.justPressed.has('left')).toBe(false); + + send('pointerup'); + mouse.update(state); + expect(state.buttons.justReleased.has('left')).toBe(true); + expect(state.buttons.pressed.size).toBe(0); + + send('pointerdown', { buttons: RIGHT }); + send('pointerup'); + mouse.update(state); + expect(state.buttons.justPressed.has('right')).toBe(true); + expect(state.buttons.justReleased.has('right')).toBe(true); + expect(state.buttons.pressed.has('right')).toBe(false); + }); + + it('a repeated pointerdown re-anchors, and a second mouse is ignored until the first lifts', () => { + const { state, send } = setup(); + send('pointerdown', { buttons: LEFT }); + send('pointerdown', { x: 20, y: 20, buttons: LEFT }); + send('pointerdown', { x: 100, y: 100, buttons: LEFT, id: 2 }); + send('pointermove', { x: 25, y: 20, buttons: LEFT }); + send('pointermove', { x: 999, y: 999, buttons: LEFT, id: 2 }); + mouse.update(state); + expect(state.drag.left).toEqual([5, 0]); + + send('pointerup'); + send('pointerdown', { x: 50, y: 50, buttons: LEFT, id: 2 }); + send('pointermove', { x: 60, y: 55, buttons: LEFT, id: 2 }); + mouse.update(state); + expect(state.drag.left).toEqual([10, 5]); + }); + + it('releases on a hidden tab, lost focus or lost capture, where the pointerup may never come', () => { + const { state, send } = setup(); + const releasedBy = (lose: () => void) => { + send('pointerdown', { buttons: LEFT }); + mouse.update(state); + lose(); + mouse.update(state); + const released = state.buttons.justReleased.has('left') && !state.buttons.pressed.has('left'); + send('pointerdown', { x: 200, y: 200, buttons: LEFT }); + send('pointermove', { x: 210, y: 205, buttons: LEFT }); + send('pointerup'); + mouse.update(state); + return released && state.drag.left[0] === 10 && state.drag.left[1] === 5; + }; + + expect( + releasedBy(() => { + Object.defineProperty(document, 'hidden', { value: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + }), + ).toBe(true); + expect(releasedBy(() => window.dispatchEvent(new Event('blur')))).toBe(true); + expect(releasedBy(() => send('lostpointercapture'))).toBe(true); + }); + + it('only starts drags inside interactiveArea, and keeps a started drag going outside it', () => { + const { state, send } = setup(); + state.interactiveArea = { x: 0.5, y: 0, width: 0.5, height: 1 }; + const drag = (fromX: number, toX: number) => { + send('pointerdown', { x: fromX, y: 10, buttons: LEFT }); + send('pointermove', { x: toX, y: 10, buttons: LEFT }); + send('pointerup', { x: toX, y: 10 }); + mouse.update(state); + return state.drag.left[0]; + }; + + expect(drag(10, 20)).toBe(0); + expect(drag(60, 70)).toBe(10); + expect(drag(60, 0)).toBe(-60); + }); + + it('only calls onInput for input it keeps, not for hovering', () => { + const { send, onInput } = setup(); + send('pointermove', { x: 10 }); + expect(onInput).not.toHaveBeenCalled(); + send('pointerdown', { buttons: LEFT }); + send('pointermove', { x: 20, buttons: LEFT }); + expect(onInput).toHaveBeenCalledTimes(2); + }); + + it('suppresses the context menu only when asked', () => { + const { element, state } = setup(); + const menu = () => { + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }); + element.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(menu()).toBe(false); + state.suppressContextMenu = true; + expect(menu()).toBe(true); + }); + + it('disconnecting stops listening, releases held buttons and restores the element styles', () => { + const { element, state, send, disconnect } = setup(); + element.style.touchAction = ''; + send('pointerdown', { buttons: LEFT }); + disconnect(); + send('pointermove', { x: 50, buttons: LEFT }); + mouse.update(state); + + expect(state.drag.left).toEqual([0, 0]); + expect(state.buttons.pressed.size).toBe(0); + expect(element.style.userSelect).toBe(''); + }); + + describe('pointer lock', () => { + it('measures drags with movementX/Y, since the position freezes', () => { + const { state, send, lock } = setup(); + send('pointerdown', { buttons: LEFT }); + lock(true); + send('pointermove', { buttons: LEFT, movementX: 7, movementY: -3 }); + mouse.update(state); + expect(state.drag.left).toEqual([7, -3]); + expect(state.lockedMovement).toEqual([0, 0]); + }); + + it('takes movement with no button held only while locked', () => { + const { state, send, lock } = setup(); + send('pointermove', { movementX: 12, movementY: 4 }); + mouse.update(state); + expect(state.lockedMovement).toEqual([0, 0]); + + lock(true); + expect(state.locked).toBe(true); + send('pointermove', { movementX: 12, movementY: 4 }); + mouse.update(state); + expect(state.lockedMovement).toEqual([12, 4]); + }); + + it('a lock lost mid-drag makes the next move re-anchor instead of jumping', () => { + const { state, send, lock } = setup(); + send('pointerdown', { buttons: LEFT }); + lock(true); + send('pointermove', { buttons: LEFT, movementX: 5, movementY: 5 }); + lock(false); + send('pointermove', { x: 500, y: 500, buttons: LEFT }); + mouse.update(state); + expect(state.drag.left).toEqual([5, 5]); + }); + + it('a lock that outlives a reconnect is picked up again, and leaving it still re-anchors', () => { + const { state, send, lock, reconnect } = setup(); + lock(true); + reconnect(); + expect(state.locked).toBe(true); + send('pointermove', { movementX: 8, movementY: 3 }); + + send('pointerdown', { buttons: LEFT }); + lock(false); + send('pointermove', { x: 500, y: 500, buttons: LEFT }); + mouse.update(state); + expect(state.lockedMovement).toEqual([8, 3]); + expect(state.drag.left).toEqual([0, 0]); + }); + + it('warns when the browser rejects the lock', () => { + setup(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + document.dispatchEvent(new Event('pointerlockerror')); + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); + }); + }); +}); diff --git a/test/input-sources/safariGesture.test.ts b/test/input-sources/safariGesture.test.ts new file mode 100644 index 00000000..822e6002 --- /dev/null +++ b/test/input-sources/safariGesture.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as safariGesture from '../../src/input-sources/safariGesture'; + +const connections: (() => void)[] = []; + +function setup() { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }) as DOMRect; + document.body.appendChild(element); + const state = safariGesture.create(); + const onInput = vi.fn(); + const disconnect = safariGesture.connect(state, element, onInput); + connections.push(disconnect); + const send = (type: string, scale: number, rotation: number, x = 0, y = 0) => { + const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), { + scale, + rotation, + clientX: x, + clientY: y, + }); + element.dispatchEvent(event); + return event; + }; + return { element, state, onInput, send, disconnect }; +} + +afterEach(() => { + for (const disconnect of connections.splice(0)) disconnect(); + document.body.innerHTML = ''; +}); + +describe('safariGesture', () => { + it('sums pinch as ln(scale) and rotation in radians on update, and keeps the page from zooming', () => { + const { state, send, onInput } = setup(); + expect(send('gesturestart', 1, 0).defaultPrevented).toBe(true); + send('gesturechange', 1.1, 0); + send('gesturechange', 1.15, 10); + expect(onInput).toHaveBeenCalledTimes(2); + + safariGesture.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.15), 5); + expect(state.twistDelta).toBeCloseTo((10 * Math.PI) / 180, 5); + + safariGesture.update(state); + expect(state.pinchDelta).toBe(0); + }); + + it('measures each gesture from its own start, since Safari resets scale and rotation per gesture', () => { + const { state, send } = setup(); + send('gesturestart', 1, 0); + send('gesturechange', 2, 0); + send('gestureend', 2, 0); + send('gesturestart', 1, 0); + send('gesturechange', 1.1, 0); + safariGesture.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(2) + Math.log(1.1), 5); + }); + + it('ignores a gesture that starts outside interactiveArea', () => { + const { state, send } = setup(); + state.interactiveArea = { x: 0.5, y: 0, width: 0.5, height: 1 }; + send('gesturestart', 1, 0, 10, 10); + send('gesturechange', 1.1, 0, 10, 10); + safariGesture.update(state); + expect(state.pinchDelta).toBe(0); + }); + + it('lockTouchAxis keeps the stronger of scale and rotation, decided once per gesture', () => { + const { state, send } = setup(); + state.lockTouchAxis = true; + send('gesturestart', 1, 0); + send('gesturechange', 1.01, 20); + send('gesturechange', 1.5, 20); + safariGesture.update(state); + expect(state.pinchDelta).toBe(0); + expect(state.twistDelta).toBeCloseTo((20 * Math.PI) / 180, 5); + + send('gestureend', 1.5, 20); + send('gesturestart', 1, 0); + send('gesturechange', 1.5, 5); + safariGesture.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.5), 5); + expect(state.twistDelta).toBe(0); + }); + + it('ends the gesture on lost focus or disconnect, so a late change does nothing', () => { + const { state, send, disconnect } = setup(); + send('gesturestart', 1, 0); + window.dispatchEvent(new Event('blur')); + send('gesturechange', 1.5, 0); + + send('gesturestart', 1, 0); + disconnect(); + send('gesturechange', 1.5, 0); + safariGesture.update(state); + expect(state.pinchDelta).toBe(0); + }); + + it('leaves a pinch made with fingers on a touch screen to the touch source (real bug: iOS counted it twice)', () => { + const { state, send, element } = setup(); + const finger = (type: string, id: number) => + element.dispatchEvent(new PointerEvent(type, { pointerId: id, bubbles: true, pointerType: 'touch' })); + + finger('pointerdown', 1); + finger('pointerdown', 2); + send('gesturestart', 1, 0); + send('gesturechange', 1.5, 10); + safariGesture.update(state); + expect(state.pinchDelta).toBe(0); + expect(state.twistDelta).toBe(0); + + finger('pointerup', 1); + finger('pointerup', 2); + send('gesturestart', 1, 0); + send('gesturechange', 1.2, 0); + finger('pointerdown', 3); + send('gesturechange', 1.5, 0); + safariGesture.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.2), 5); + }); + + it('can leave pinch zoom to the page, while still reading it', () => { + const { state, send } = setup(); + state.preventPageZoom = false; + expect(send('gesturestart', 1, 0).defaultPrevented).toBe(false); + expect(send('gesturechange', 1.5, 0).defaultPrevented).toBe(false); + safariGesture.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.5), 5); + }); +}); diff --git a/test/input-sources/suppressNativeGestures.test.ts b/test/input-sources/suppressNativeGestures.test.ts new file mode 100644 index 00000000..f0125641 --- /dev/null +++ b/test/input-sources/suppressNativeGestures.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { suppressNativeGestures } from '../../src/input-sources/suppressNativeGestures'; + +function element(touchAction = '', userSelect = ''): HTMLElement { + const el = document.createElement('div'); + el.style.touchAction = touchAction; + el.style.userSelect = userSelect; + return el; +} + +describe('suppressNativeGestures', () => { + it('turns off touch scrolling and selection, then restores the inline styles it found (real bug: they were cleared)', () => { + const el = element('pan-y', 'text'); + const restore = suppressNativeGestures(el); + expect(el.style.touchAction).toBe('none'); + expect(el.style.userSelect).toBe('none'); + + restore(); + expect(el.style.touchAction).toBe('pan-y'); + expect(el.style.userSelect).toBe('text'); + }); + + it('keeps them off until the last of overlapping calls restores', () => { + const el = element('pan-y'); + const first = suppressNativeGestures(el); + const second = suppressNativeGestures(el); + + first(); + expect(el.style.touchAction).toBe('none'); + second(); + expect(el.style.touchAction).toBe('pan-y'); + }); + + it('restoring twice counts once', () => { + const el = element('pan-y'); + const first = suppressNativeGestures(el); + const second = suppressNativeGestures(el); + + first(); + first(); + expect(el.style.touchAction).toBe('none'); + second(); + expect(el.style.touchAction).toBe('pan-y'); + }); +}); diff --git a/test/input-sources/touch.test.ts b/test/input-sources/touch.test.ts new file mode 100644 index 00000000..2c928d50 --- /dev/null +++ b/test/input-sources/touch.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as touch from '../../src/input-sources/touch'; + +const connections: (() => void)[] = []; + +function setup() { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }) as DOMRect; + element.setPointerCapture = () => {}; + document.body.appendChild(element); + const state = touch.create(); + const onInput = vi.fn(); + const disconnect = touch.connect(state, element, onInput); + connections.push(disconnect); + const send = (type: string, x: number, y: number, id: number, pointerType = 'touch') => + element.dispatchEvent( + new PointerEvent(type, { pointerId: id, clientX: x, clientY: y, bubbles: true, pointerType }), + ); + return { element, state, onInput, send, disconnect }; +} + +afterEach(() => { + for (const disconnect of connections.splice(0)) disconnect(); + document.body.innerHTML = ''; + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); +}); + +describe('touch', () => { + it('sums a frame of one-finger dragging on update, and reading it does not clear it', () => { + const { state, send } = setup(); + send('pointerdown', 0, 0, 1); + send('pointermove', 10, 4, 1); + send('pointermove', 25, 10, 1); + touch.update(state); + expect(state.fingers).toBe(1); + expect(state.drag.one).toEqual([25, 10]); + expect(state.drag.one).toEqual([25, 10]); + + touch.update(state); + expect(state.drag.one).toEqual([0, 0]); + }); + + it('gives each move to the finger count at that moment, even within one frame', () => { + const { state, send } = setup(); + send('pointerdown', 0, 0, 1); + send('pointermove', 10, 0, 1); + send('pointerdown', 100, 0, 2); + send('pointermove', 20, 0, 1); + send('pointerdown', 50, 50, 3); + send('pointermove', 60, 50, 3); + touch.update(state); + + expect(state.fingers).toBe(3); + expect(state.drag.one).toEqual([10, 0]); + expect(state.drag.two[0]).toBeCloseTo(5, 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(80 / 90), 5); + expect(state.drag.three[0]).toBeCloseTo(10 / 3, 5); + }); + + it('measures pinch and twist from the same two-finger move, crossing the ±180° seam the short way', () => { + const { state, send } = setup(); + send('pointerdown', -50, 0, 1); + send('pointerdown', 50, 0, 2); + send('pointermove', 0, 50, 2); + touch.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(Math.hypot(50, 50) / 100), 5); + expect(state.twistDelta).toBeCloseTo(Math.PI / 4, 5); + + send('pointerup', 0, 50, 2); + send('pointerdown', -100, 1, 3); + send('pointermove', -100, -1, 3); + touch.update(state); + expect(state.twistDelta).toBeGreaterThan(0); + expect(state.twistDelta).toBeLessThan(0.1); + }); + + it('lockTouchAxis keeps the stronger of pinch and twist, decided once per gesture', () => { + const { state, send } = setup(); + state.lockTouchAxis = true; + send('pointerdown', 0, 0, 1); + send('pointerdown', 100, 0, 2); + send('pointermove', 95, 34, 2); + send('pointermove', 190, 68, 2); + touch.update(state); + expect(state.pinchDelta).toBe(0); + expect(state.twistDelta).toBeCloseTo(Math.atan2(34, 95), 5); + + send('pointerup', 190, 68, 2); + send('pointerdown', 100, 0, 2); + send('pointermove', 150, 5, 2); + touch.update(state); + expect(state.pinchDelta).toBeCloseTo(Math.log(Math.hypot(150, 5) / 100), 5); + expect(state.twistDelta).toBe(0); + }); + + it('ignores a fourth finger and mouse or pen pointers', () => { + const { state, send } = setup(); + send('pointerdown', 0, 0, 1); + send('pointerdown', 100, 0, 2); + send('pointerdown', 50, 50, 3); + send('pointerdown', 999, 999, 4); + send('pointermove', 0, 0, 4); + send('pointerdown', 0, 0, 5, 'mouse'); + send('pointerdown', 0, 0, 6, 'pen'); + touch.update(state); + expect(state.fingers).toBe(3); + expect(state.drag.three).toEqual([0, 0]); + }); + + it('lifting a finger moves the rest down a slot with a fresh pinch baseline, so nothing jumps', () => { + const { state, send } = setup(); + send('pointerdown', 0, 0, 1); + send('pointerdown', 100, 0, 2); + send('pointerdown', 250, 0, 3); + send('pointerup', 0, 0, 1); + send('pointermove', 300, 0, 3); + touch.update(state); + expect(state.fingers).toBe(2); + expect(state.pinchDelta).toBeCloseTo(Math.log(200 / 150), 5); + + send('pointerup', 300, 0, 3); + send('pointermove', 110, 0, 2); + touch.update(state); + expect(state.fingers).toBe(1); + expect(state.drag.one).toEqual([10, 0]); + expect(state.drag.two).toEqual([0, 0]); + }); + + it('lifts every finger on a hidden tab, lost focus or lost capture, where the pointerup may never come', () => { + const { state, send } = setup(); + const liftedBy = (lose: () => void) => { + send('pointerdown', 0, 0, 1); + touch.update(state); + lose(); + touch.update(state); + return state.fingers === 0; + }; + + expect( + liftedBy(() => { + Object.defineProperty(document, 'hidden', { value: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + }), + ).toBe(true); + expect(liftedBy(() => window.dispatchEvent(new Event('blur')))).toBe(true); + expect(liftedBy(() => send('lostpointercapture', 0, 0, 1))).toBe(true); + }); + + it('only starts touches inside interactiveArea', () => { + const { state, send } = setup(); + state.interactiveArea = { x: 0.5, y: 0, width: 0.5, height: 1 }; + send('pointerdown', 10, 10, 1); + send('pointerdown', 60, 10, 2); + send('pointermove', 70, 10, 2); + touch.update(state); + expect(state.fingers).toBe(1); + expect(state.drag.one).toEqual([10, 0]); + }); + + it('disconnecting lifts every finger, stops listening, restores the styles and reports input until then', () => { + const { element, state, send, onInput, disconnect } = setup(); + send('pointerdown', 0, 0, 1); + expect(onInput).toHaveBeenCalledOnce(); + expect(element.style.touchAction).toBe('none'); + + disconnect(); + send('pointermove', 50, 0, 1); + touch.update(state); + expect(state.fingers).toBe(0); + expect(state.drag.one).toEqual([0, 0]); + expect(element.style.touchAction).toBe(''); + }); +}); diff --git a/test/input-sources/wheel.test.ts b/test/input-sources/wheel.test.ts new file mode 100644 index 00000000..72ada6a4 --- /dev/null +++ b/test/input-sources/wheel.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as wheel from '../../src/input-sources/wheel'; + +function setup() { + const element = document.createElement('div'); + element.getBoundingClientRect = () => + ({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }) as DOMRect; + document.body.appendChild(element); + const state = wheel.create(); + const onInput = vi.fn(); + const disconnect = wheel.connect(state, element, onInput); + const scroll = (init: WheelEventInit) => { + const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init }); + element.dispatchEvent(event); + return event; + }; + return { element, state, onInput, disconnect, scroll }; +} + +afterEach(() => { + document.body.innerHTML = ''; + Object.defineProperty(document, 'pointerLockElement', { value: null, configurable: true }); +}); + +describe('wheel', () => { + it('sums a frame of scrolling on update, and reading it does not clear it', () => { + const { state, scroll } = setup(); + scroll({ deltaX: 5, deltaY: 30 }); + scroll({ deltaY: 70 }); + expect(state.deltaY).toBe(0); + + wheel.update(state); + expect([state.deltaX, state.deltaY]).toEqual([5, 100]); + expect([state.deltaX, state.deltaY]).toEqual([5, 100]); + + wheel.update(state); + expect([state.deltaX, state.deltaY]).toEqual([0, 0]); + }); + + it('converts lines and pages to pixels (real bug: line-based wheels moved a fraction as far)', () => { + const { state, scroll } = setup(); + scroll({ deltaY: 3, deltaMode: WheelEvent.DOM_DELTA_LINE }); + wheel.update(state); + expect(state.deltaY).toBe(3 * state.pixelsPerLine); + + scroll({ deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_PAGE }); + wheel.update(state); + expect(state.deltaY).toBe(state.pixelsPerPage); + }); + + it('routes a ctrlKey wheel (trackpad pinch) to pinchDelta, as ln(scale)', () => { + const { state, scroll } = setup(); + scroll({ deltaX: 5, deltaY: 50, ctrlKey: true }); + wheel.update(state); + expect(state.pinchDelta).toBeCloseTo(-0.5, 10); + expect([state.deltaX, state.deltaY]).toEqual([0, 0]); + }); + + it('turns a shifted single-axis wheel sideways, but keeps the deltaX a trackpad sends', () => { + const { state, scroll } = setup(); + scroll({ deltaY: 40, shiftKey: true }); + wheel.update(state); + expect([state.deltaX, state.deltaY]).toEqual([40, 0]); + + scroll({ deltaX: 15, deltaY: 40, shiftKey: true }); + wheel.update(state); + expect([state.deltaX, state.deltaY]).toEqual([15, 40]); + }); + + it('only takes scrolls inside interactiveArea, and keeps the page scrolling outside it', () => { + const { state, scroll, onInput } = setup(); + state.interactiveArea = { x: 0.5, y: 0, width: 0.5, height: 1 }; + + const outside = scroll({ deltaY: 100, clientX: 10, clientY: 10 }); + expect(outside.defaultPrevented).toBe(false); + expect(onInput).not.toHaveBeenCalled(); + + const inside = scroll({ deltaY: 100, clientX: 60, clientY: 10 }); + expect(inside.defaultPrevented).toBe(true); + expect(onInput).toHaveBeenCalledOnce(); + wheel.update(state); + expect(state.deltaY).toBe(100); + }); + + it('counts a locked pointer as inside interactiveArea, since its position is frozen', () => { + const { element, state, scroll } = setup(); + state.interactiveArea = { x: 0.5, y: 0, width: 0.5, height: 1 }; + Object.defineProperty(document, 'pointerLockElement', { value: element, configurable: true }); + scroll({ deltaY: 100, clientX: 0, clientY: 0 }); + wheel.update(state); + expect(state.deltaY).toBe(100); + }); + + it('stops listening and leaves the page scroll alone once disconnected', () => { + const { state, scroll, disconnect } = setup(); + disconnect(); + const event = scroll({ deltaY: 100 }); + wheel.update(state); + expect(state.deltaY).toBe(0); + expect(event.defaultPrevented).toBe(false); + }); + + it('can leave scrolling and pinch zoom to the page, while still reading them', () => { + const { state, scroll } = setup(); + state.preventPageScroll = false; + expect(scroll({ deltaY: 100 }).defaultPrevented).toBe(false); + expect(scroll({ deltaY: 5, ctrlKey: true }).defaultPrevented).toBe(true); + + state.preventPageZoom = false; + expect(scroll({ deltaY: 5, ctrlKey: true }).defaultPrevented).toBe(false); + wheel.update(state); + expect(state.deltaY).toBe(100); + expect(state.pinchDelta).toBeCloseTo(-0.1, 10); + }); +}); diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index b06cb263..641fd66a 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -3,10 +3,14 @@ import { create } from '@react-three/test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { InputAxis } from '../../../src/core/input/InputAxis'; +import type { InputAxisOwner } from '../../../src/core/input/InputAxisOwner'; -import type { InputAxisController } from '../../../src/dom/InputAxisController'; +import type { PanTiltAimThree } from '../../../src/three/aim/PanTiltAimThree'; + +import type { InputControllerDom } from '../../../src/dom/InputControllerDom'; +import { PanTilt } from '../../../src/react/aim/PanTilt'; import { HardLockToTarget } from '../../../src/react/body/HardLockToTarget'; -import { InputAxisOwnerContext, type InputAxisOwner } from '../../../src/react/input/InputAxisOwnerContext'; +import { InputAxisOwnerContext } from '../../../src/react/input/InputAxisOwnerContext'; import { InputController } from '../../../src/react/input/InputController'; import { Klipp } from '../../../src/react/Klipp'; import { VirtualCamera } from '../../../src/react/VirtualCamera'; @@ -25,7 +29,7 @@ describe('InputController (React wrapper)', () => { const pan = new InputAxis(); const tilt = new InputAxis(); const radial = new InputAxis(); - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const scene = (y: string) => ( @@ -44,18 +48,18 @@ describe('InputController (React wrapper)', () => { const renderer = await create(scene('tilt')); await renderer.advanceFrames(1, 0.05); - expect(controller!.config.mouseButtons.right?.axes).toEqual({ x: pan, y: tilt }); - expect(controller!.config.mouseButtons.left).toBeNull(); + expect(controller!.config.mouseButtons?.right?.axes).toEqual({ x: pan, y: tilt }); + expect(controller!.config.mouseButtons?.left).toBeNull(); await renderer.update(scene('radial')); await renderer.advanceFrames(1, 0.05); - expect(controller!.config.mouseButtons.right?.axes.y).toBe(radial); + expect(controller!.config.mouseButtons?.right?.axes.y).toBe(radial); }); it('with no target prop, resolves inputAxes from the nearest InputAxisOwnerContext instead', async () => { const pan = new InputAxis(); const tilt = new InputAxis(); - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const scene = ( @@ -75,13 +79,13 @@ describe('InputController (React wrapper)', () => { const renderer = await create(scene); await renderer.advanceFrames(1, 0.05); - expect(controller!.config.mouseButtons.right?.axes.x).toBe(pan); - expect(controller!.config.mouseButtons.right?.axes.y).toBe(tilt); + expect(controller!.config.mouseButtons?.right?.axes.x).toBe(pan); + expect(controller!.config.mouseButtons?.right?.axes.y).toBe(tilt); }); it('a source with no axis name of that kind on the target resolves to null, with a dev warning', async () => { const target = owner({ pan: new InputAxis(), tilt: new InputAxis() }); - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const scene = ( @@ -101,13 +105,13 @@ describe('InputController (React wrapper)', () => { const renderer = await create(scene); await renderer.advanceFrames(1, 0.05); - expect(controller!.config.mouseButtons.right).toBeNull(); + expect(controller!.config.mouseButtons?.right).toBeNull(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent')); warnSpy.mockRestore(); }); it('disconnects when unmounted', async () => { - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const scene = (mounted: boolean) => ( @@ -135,7 +139,7 @@ describe('InputController (React wrapper)', () => { it('connects once the blend into its camera finishes, or right away with waitForBlend off', async () => { const connectsAt = async (waitForBlend?: boolean) => { - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const scene = (priority: number) => ( @@ -169,7 +173,7 @@ describe('InputController (React wrapper)', () => { }); it('passes its options to the controller and its InputSystem, enabled by default', async () => { - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const area = { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }; const scene = (enabled?: boolean) => ( @@ -206,7 +210,7 @@ describe('InputController (React wrapper)', () => { const pan = new InputAxis(); const tilt = new InputAxis(); const target = owner({ pan, tilt }); - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; let domElement: HTMLElement | undefined; const scene = ( @@ -252,8 +256,64 @@ describe('InputController (React wrapper)', () => { await renderer.advanceFrames(1, 0.05); // proves InputSystem actually received/buffered the event (connect() ran) - if it hadn't connected - // at all, lastInput would show 0 too, same as pan.value, and this test wouldn't tell them apart - expect(controller!.lastInput.leftDx).toBeCloseTo(20, 5); + // at all, input would show 0 too, same as pan.value, and this test wouldn't tell them apart + expect(controller!.input.leftDx).toBeCloseTo(20, 5); expect(pan.value).toBe(0); // ...but enabled=false kept it from ever reaching the axis }); + + it("with no owner around it, drives the camera's own axes, even from before the Aim and across an Aim swap", async () => { + let controller: InputControllerDom | null = null; + let aim: PanTiltAimThree | null = null; + const scene = (key: string) => ( + + + { + controller = c; + }} + mouseButtons={{ left: { axes: { x: 'pan', y: 'tilt' } }, right: null, middle: null }} + /> + { + aim = a; + }} + /> + + + ); + + const renderer = await create(scene('first')); + await renderer.advanceFrames(1, 0.05); + expect(controller!.config.mouseButtons?.left?.axes).toEqual({ x: aim!.pan, y: aim!.tilt }); + + const first = aim!; + await renderer.update(scene('second')); + await renderer.advanceFrames(1, 0.05); + expect(aim).not.toBe(first); + expect(controller!.config.mouseButtons?.left?.axes.x).toBe(aim!.pan); + }); + + it('resolves the single-axis sources by name too', async () => { + const radial = new InputAxis(); + let controller: InputControllerDom | null = null; + const renderer = await create( + + + { + controller = c; + }} + target={owner({ radial })} + wheel={{ axis: 'radial', gain: 0.01 }} + pinch={{ axis: 'radial', invert: true }} + /> + + , + ); + await renderer.advanceFrames(1, 0.05); + + expect(controller!.config.wheel).toEqual({ axis: radial, gain: 0.01, invert: undefined }); + expect(controller!.config.pinch).toEqual({ axis: radial, gain: undefined, invert: true }); + }); });