From 1351262470b24ce25cbfdbda6496fd053422d9ad Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 18:14:49 +0200 Subject: [PATCH 01/26] feat: add InputAxis.setValue --- src/core/input/InputAxis.ts | 3 ++ src/core/input/axis.ts | 9 ++++++ test/core/input/InputAxis.test.ts | 50 +++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) 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/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/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 }); From cd2d66aad4366388c6e9bffe91d2106a216abf06 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 18:22:52 +0200 Subject: [PATCH 02/26] refactor: move InputAxisOwner type to core --- src/core/aim/PanTiltAim.ts | 4 +++- src/core/input/InputAxisOwner.ts | 6 ++++++ src/core/input/index.ts | 1 + src/react/index.ts | 3 ++- src/react/input/InputAxisOwnerContext.ts | 7 +------ src/react/input/InputController.tsx | 7 ++++--- test/react/input/InputController.test.tsx | 3 ++- 7 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 src/core/input/InputAxisOwner.ts 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/InputAxisOwner.ts b/src/core/input/InputAxisOwner.ts new file mode 100644 index 00000000..20699eaf --- /dev/null +++ b/src/core/input/InputAxisOwner.ts @@ -0,0 +1,6 @@ +import type { InputAxisData } from './axis'; + +/** A camera piece exposing named axes for input to drive. */ +export type InputAxisOwner = { + readonly inputAxes: Record; +}; diff --git a/src/core/input/index.ts b/src/core/input/index.ts index abf20775..e9b4025e 100644 --- a/src/core/input/index.ts +++ b/src/core/input/index.ts @@ -1,4 +1,5 @@ export { InputAxis } from './InputAxis'; +export type { InputAxisOwner } from './InputAxisOwner'; export * as inputAxis from './axis'; export type { InputAxisData, InputAxisParams, InputAxisRecentering } from './axis'; diff --git a/src/react/index.ts b/src/react/index.ts index 3e8efd1a..584d100c 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -9,7 +9,8 @@ export { } from './VirtualCamera'; export { useIsActiveVirtualCamera, useIsLiveVirtualCamera, useVirtualCamera } from './VirtualCameraContext'; -export { InputAxisOwnerContext, type InputAxisOwner } from './input/InputAxisOwnerContext'; +export type { InputAxisOwner } from '../core/input/InputAxisOwner'; +export { InputAxisOwnerContext } from './input/InputAxisOwnerContext'; export { InputController, type InputControllerProps, type InputSourceConfig } from './input/InputController'; export { Body } from './body/Body'; diff --git a/src/react/input/InputAxisOwnerContext.ts b/src/react/input/InputAxisOwnerContext.ts index 02d8001d..a31e3ee3 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'; - -/** Anything a `` can drive - any Body/Aim/Extension exposing named `InputAxis` instances */ -export type InputAxisOwner = { - readonly inputAxes: Record; -}; +import type { InputAxisOwner } from '../../core/input/InputAxisOwner'; /** 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..49ef25b6 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -1,7 +1,8 @@ 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 { InputAxisController, type InputAxisControllerConfig, type InputInvert } from '../../dom/InputAxisController'; import type { InteractiveArea } from '../../dom/InputSystem'; @@ -9,7 +10,7 @@ import type { InteractiveArea } from '../../dom/InputSystem'; import { useKlipp } from '../KlippContext'; import { useIsActiveVirtualCamera, useIsLiveVirtualCamera } from '../VirtualCameraContext'; -import { InputAxisOwnerContext, type InputAxisOwner } from './InputAxisOwnerContext'; +import { InputAxisOwnerContext } from './InputAxisOwnerContext'; export type InputSourceConfig = { /** Axis names to drive for each source. */ @@ -45,7 +46,7 @@ export type InputControllerProps = { 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.`); diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index b06cb263..6bfb6be0 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -3,10 +3,11 @@ 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 { 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'; From 7d963d416397ff70e4b1423b5bd39ad7afc1692e Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 18:43:00 +0200 Subject: [PATCH 03/26] refactor!: name input classes by layer (InputController, InputControllerDom) --- docs/aim/pan-tilt.mdx | 20 +++---- docs/input/input-controller.mdx | 60 +++++++++---------- docs/input/introduction.mdx | 24 ++++---- examples/src/scene/usePointerLock.ts | 4 +- examples/src/scenes/aim/PanTilt.tsx | 4 +- .../src/scenes/aim/PanTiltRecentering.tsx | 4 +- .../src/scenes/aim/PanTiltReferenceFrame.tsx | 4 +- .../src/scenes/aim/PanTiltRestrictedLook.tsx | 4 +- src/core/input/InputController.ts | 27 +++++++++ src/core/input/index.ts | 3 +- src/core/input/inputMapping.ts | 4 +- src/dom/InputAxisController.ts | 41 ------------- src/dom/InputControllerDom.ts | 21 +++++++ src/dom/index.ts | 8 +-- src/react/input/InputController.tsx | 11 ++-- test/core/input/InputController.test.ts | 42 +++++++++++++ ...ler.test.ts => InputControllerDom.test.ts} | 17 +++--- test/react/input/InputController.test.tsx | 20 +++---- 18 files changed, 182 insertions(+), 136 deletions(-) create mode 100644 src/core/input/InputController.ts delete mode 100644 src/dom/InputAxisController.ts create mode 100644 src/dom/InputControllerDom.ts create mode 100644 test/core/input/InputController.test.ts rename test/dom/{InputAxisController.test.ts => InputControllerDom.test.ts} (92%) diff --git a/docs/aim/pan-tilt.mdx b/docs/aim/pan-tilt.mdx index 09528b47..e19eadc9 100644 --- a/docs/aim/pan-tilt.mdx +++ b/docs/aim/pan-tilt.mdx @@ -34,21 +34,21 @@ 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({ +const controller = new InputControllerDom({ 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 }, }); 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)`. @@ -65,21 +65,21 @@ aim.tilt.value; // current tilt ```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({ +const controller = new InputControllerDom({ 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 }, }); 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)`. diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index bc91c63c..88604030 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -4,7 +4,7 @@ description: Maps mouse and touch drags 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 and finger counts 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. ## Settings @@ -37,7 +37,7 @@ For mouse look without holding a button, request pointer lock on the controller' ## 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`, `touchPinchDelta` or `leftDx`, if you want to handle something yourself. ## Usage @@ -50,36 +50,36 @@ 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({ +const controller = new InputControllerDom({ 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 }, }); -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. +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. `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(); + zoom += controller.input.wheelDeltaY * 0.01; }); ``` @@ -87,36 +87,36 @@ klipp.registerUpdate(() => { ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ +const controller = new InputControllerDom({ 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 }, }); -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. +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. `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(); + zoom += controller.input.wheelDeltaY * 0.01; }); ``` @@ -133,25 +133,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. `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; + zoom += controller.current.input.wheelDeltaY * 0.01; }); ``` diff --git a/docs/input/introduction.mdx b/docs/input/introduction.mdx index a5273748..ef6ae5c5 100644 --- a/docs/input/introduction.mdx +++ b/docs/input/introduction.mdx @@ -18,20 +18,20 @@ 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 and touch, and takes the axes themselves: ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ +const controller = new InputControllerDom({ 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 }, }); 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)`. @@ -39,20 +39,20 @@ Without a DOM, feed the axes from any source with `axis.applyDelta(delta)`. -`InputAxisController` from `@kvvasuu/klipp/dom` reads the mouse and touch, and takes the axes themselves: +`InputControllerDom` from `@kvvasuu/klipp/dom` reads the mouse and touch, and takes the axes themselves: ```ts -import { InputAxisController } from '@kvvasuu/klipp/dom'; +import { InputControllerDom } from '@kvvasuu/klipp/dom'; -const input = new InputAxisController({ +const controller = new InputControllerDom({ 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 }, }); 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/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/src/core/input/InputController.ts b/src/core/input/InputController.ts new file mode 100644 index 00000000..cc1c9f12 --- /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 named axis pairs. 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 axis pair. */ + 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/index.ts b/src/core/input/index.ts index e9b4025e..719b0261 100644 --- a/src/core/input/index.ts +++ b/src/core/input/index.ts @@ -6,5 +6,6 @@ export type { InputAxisData, InputAxisParams, InputAxisRecentering } from './axi 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 { InputControllerConfig, InputAxisPair, InputInvert, InputSourceMapping } from './inputMapping'; diff --git a/src/core/input/inputMapping.ts b/src/core/input/inputMapping.ts index 0530b1d1..a4426f20 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -18,7 +18,7 @@ export type InputSourceMapping = { invert?: InputInvert; }; -export type InputAxisControllerConfig = { +export type InputControllerConfig = { mouseButtons: { left: InputSourceMapping | null; right: InputSourceMapping | null; @@ -54,7 +54,7 @@ function applyHeld(mapping: InputSourceMapping | null, held: boolean): void { } /** Feeds every configured source's shaped delta and hold state into its axis pair. */ -export function feedAxes(config: InputAxisControllerConfig, input: ConsumedInput, enabled: boolean): void { +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); 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..0db5bc25 --- /dev/null +++ b/src/dom/InputControllerDom.ts @@ -0,0 +1,21 @@ +import type { ConsumedInput } from '../core/input/consumedInput'; +import { InputController } from '../core/input/InputController'; + +import { InputSystem } from './InputSystem'; + +/** `InputController` reading mouse, touch and wheel input from a DOM element. */ +export class InputControllerDom extends InputController { + readonly inputSystem = new InputSystem(); + + connect = (element: HTMLElement): void => { + this.inputSystem.connect(element); + }; + + disconnect = (): void => { + this.inputSystem.disconnect(); + }; + + protected override readInput(): ConsumedInput { + return this.inputSystem.consume(this.input); + } +} 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/react/input/InputController.tsx b/src/react/input/InputController.tsx index 49ef25b6..c8e2e846 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -3,8 +3,9 @@ import { use, useEffect, useImperativeHandle, useState, type Ref, type RefObject 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'; @@ -43,7 +44,7 @@ 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): InputAxisData | null { @@ -63,7 +64,7 @@ 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 buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputControllerConfig { return { mouseButtons: { left: resolveSource(owner, props.mouseButtons?.left), @@ -78,7 +79,7 @@ function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputA }; } -const emptyConfig: InputAxisControllerConfig = { +const emptyConfig: InputControllerConfig = { mouseButtons: { left: null, right: null, middle: null }, touches: { one: null, two: null, three: null }, }; @@ -102,7 +103,7 @@ 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(emptyConfig)); useImperativeHandle(ref, () => controller, [controller]); controller.enabled = enabled; diff --git a/test/core/input/InputController.test.ts b/test/core/input/InputController.test.ts new file mode 100644 index 00000000..4118e45a --- /dev/null +++ b/test/core/input/InputController.test.ts @@ -0,0 +1,42 @@ +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); + }); +}); diff --git a/test/dom/InputAxisController.test.ts b/test/dom/InputControllerDom.test.ts similarity index 92% rename from test/dom/InputAxisController.test.ts rename to test/dom/InputControllerDom.test.ts index 2a9186c7..929bbfa6 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 { 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,7 +156,7 @@ describe('InputAxisController', () => { }); it("feeds buttonless movement under Pointer Lock through mouseButtons.left's mapping", () => { - const lockedMove = (left: InputAxisControllerConfig['mouseButtons']['left']) => { + const lockedMove = (left: InputControllerConfig['mouseButtons']['left']) => { const el = setup({ ...emptyConfig(), mouseButtons: { left, right: null, middle: null } }); stubPointerLock(el); controller.inputSystem.requestPointerLock(); diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index 6bfb6be0..83d71987 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -5,7 +5,7 @@ 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 { InputControllerDom } from '../../../src/dom/InputControllerDom'; import { HardLockToTarget } from '../../../src/react/body/HardLockToTarget'; import { InputAxisOwnerContext } from '../../../src/react/input/InputAxisOwnerContext'; import { InputController } from '../../../src/react/input/InputController'; @@ -26,7 +26,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) => ( @@ -56,7 +56,7 @@ describe('InputController (React wrapper)', () => { 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 = ( @@ -82,7 +82,7 @@ describe('InputController (React wrapper)', () => { 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 = ( @@ -108,7 +108,7 @@ describe('InputController (React wrapper)', () => { }); it('disconnects when unmounted', async () => { - let controller: InputAxisController | null = null; + let controller: InputControllerDom | null = null; const scene = (mounted: boolean) => ( @@ -136,7 +136,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) => ( @@ -170,7 +170,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) => ( @@ -207,7 +207,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 = ( @@ -253,8 +253,8 @@ 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 }); }); From d2b42e62c5b3b81dc2e8984d8add7df2b182720e Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 18:48:27 +0200 Subject: [PATCH 04/26] feat: expose a virtual camera's input axes --- src/core/VirtualCamera.ts | 41 ++++++++++++++++++++++---- src/core/input/InputAxisOwner.ts | 3 ++ src/core/input/index.ts | 2 +- test/core/VirtualCamera.test.ts | 50 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) 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/input/InputAxisOwner.ts b/src/core/input/InputAxisOwner.ts index 20699eaf..d7f1080d 100644 --- a/src/core/input/InputAxisOwner.ts +++ b/src/core/input/InputAxisOwner.ts @@ -4,3 +4,6 @@ import type { InputAxisData } from './axis'; 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/index.ts b/src/core/input/index.ts index 719b0261..2eb937cd 100644 --- a/src/core/input/index.ts +++ b/src/core/input/index.ts @@ -1,5 +1,5 @@ export { InputAxis } from './InputAxis'; -export type { InputAxisOwner } from './InputAxisOwner'; +export { isInputAxisOwner, type InputAxisOwner } from './InputAxisOwner'; export * as inputAxis from './axis'; export type { InputAxisData, InputAxisParams, InputAxisRecentering } from './axis'; 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(); + }); +}); From 9157a26244fa98a0e0877b6fd2ef569e27957360 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 19:09:18 +0200 Subject: [PATCH 05/26] feat: let InputController drive the camera's own axes --- docs/input/input-controller.mdx | 4 +-- src/react/input/InputController.tsx | 37 +++++++++++++++++++---- test/react/input/InputController.test.tsx | 36 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index 88604030..05cf801b 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -4,7 +4,7 @@ description: Maps mouse and touch drags 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 `InputControllerDom` 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 and finger counts 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 @@ -17,7 +17,7 @@ Maps mouse buttons and finger counts onto axes. On the core and in three.js it's | `interactiveArea` | Only start drags 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 diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index c8e2e846..ced90fcc 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -9,7 +9,7 @@ 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 } from './InputAxisOwnerContext'; @@ -22,7 +22,7 @@ export type InputSourceConfig = { }; 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; @@ -79,6 +79,15 @@ function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputC }; } +/** What the config was last built from. */ +type Binding = { + props: InputControllerProps; + contextOwner: InputAxisOwner | null; + owner: InputAxisOwner | null; + axes: InputAxisOwner['inputAxes'] | null; + stale: boolean; +}; + const emptyConfig: InputControllerConfig = { mouseButtons: { left: null, right: null, middle: null }, touches: { one: null, two: null, three: null }, @@ -87,7 +96,6 @@ const emptyConfig: InputControllerConfig = { /** Connects DOM input sources to named axes on a camera component. */ export function InputController(props: InputControllerProps) { const { - target, waitForBlend = true, enabled = true, suppressContextMenu = false, @@ -96,6 +104,7 @@ export function InputController(props: InputControllerProps) { ref, } = props; const contextOwner = use(InputAxisOwnerContext); + const camera = useVirtualCamera(); const klipp = useKlipp(); const isActive = useIsActiveVirtualCamera(); const isLive = useIsLiveVirtualCamera(); @@ -104,6 +113,7 @@ export function InputController(props: InputControllerProps) { const invalidate = useThree((state) => state.invalidate); const [controller] = useState(() => new InputControllerDom(emptyConfig)); + const [binding] = useState(() => ({ props, contextOwner, owner: null, axes: null, stale: true })); useImperativeHandle(ref, () => controller, [controller]); controller.enabled = enabled; @@ -112,11 +122,26 @@ 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; diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index 83d71987..e9740bb6 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -5,7 +5,10 @@ import { describe, expect, it, vi } from 'vitest'; import { InputAxis } from '../../../src/core/input/InputAxis'; import type { InputAxisOwner } from '../../../src/core/input/InputAxisOwner'; +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 } from '../../../src/react/input/InputAxisOwnerContext'; import { InputController } from '../../../src/react/input/InputController'; @@ -257,4 +260,37 @@ describe('InputController (React wrapper)', () => { 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); + }); }); From b23086f6ed2b4acc07e915d2935b5dbecfc07f80 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 19:20:38 +0200 Subject: [PATCH 06/26] feat: add buttonInput source state --- .oxlintrc.json | 25 ++++++++++++ src/dom/sources/buttonInput.ts | 36 +++++++++++++++++ test/dom/sources/buttonInput.test.ts | 58 ++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 src/dom/sources/buttonInput.ts create mode 100644 test/dom/sources/buttonInput.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 38445421..e3a4b48f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -83,6 +83,31 @@ ] } }, + { + "files": ["src/dom/sources/**"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": [ + "../**", + "three", + "three/*", + "react", + "react/*", + "react-dom", + "@react-three/*", + "camera-controls" + ], + "message": "sources import only each other and math." + } + ] + } + ] + } + }, { "files": ["test/**"], "rules": { diff --git a/src/dom/sources/buttonInput.ts b/src/dom/sources/buttonInput.ts new file mode 100644 index 00000000..04610c6b --- /dev/null +++ b/src/dom/sources/buttonInput.ts @@ -0,0 +1,36 @@ +/** 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 { + 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 { + state.justPressed.clear(); + state.justReleased.clear(); +} diff --git a/test/dom/sources/buttonInput.test.ts b/test/dom/sources/buttonInput.test.ts new file mode 100644 index 00000000..a1b541ea --- /dev/null +++ b/test/dom/sources/buttonInput.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import * as buttonInput from '../../../src/dom/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']); + }); +}); From eb90954a929be5d93e4dd3d052133707ab0f29ce Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 19:34:37 +0200 Subject: [PATCH 07/26] feat: add suppressNativeGestures with style restore --- src/dom/sources/suppressNativeGestures.ts | 29 ++++++++++++ .../sources/suppressNativeGestures.test.ts | 46 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/dom/sources/suppressNativeGestures.ts create mode 100644 test/dom/sources/suppressNativeGestures.test.ts diff --git a/src/dom/sources/suppressNativeGestures.ts b/src/dom/sources/suppressNativeGestures.ts new file mode 100644 index 00000000..1add7a6e --- /dev/null +++ b/src/dom/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/test/dom/sources/suppressNativeGestures.test.ts b/test/dom/sources/suppressNativeGestures.test.ts new file mode 100644 index 00000000..a37ee6ba --- /dev/null +++ b/test/dom/sources/suppressNativeGestures.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { suppressNativeGestures } from '../../../src/dom/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'); + }); +}); From d7b8c28f3410885bc1fbba96b5fdcbd78efc29f1 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 19:57:35 +0200 Subject: [PATCH 08/26] feat: add wheel source with deltaMode normalization --- src/dom/InputSystem.ts | 12 +-- src/dom/sources/isInsideInteractiveArea.ts | 21 +++++ src/dom/sources/wheel.ts | 70 ++++++++++++++ test/dom/sources/wheel.test.ts | 103 +++++++++++++++++++++ 4 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 src/dom/sources/isInsideInteractiveArea.ts create mode 100644 src/dom/sources/wheel.ts create mode 100644 test/dom/sources/wheel.test.ts diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 3bd32c0b..fd7ae407 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -2,6 +2,10 @@ import { degreesToRadians, deltaAngle, radiansToDegrees } from 'math'; import type { ConsumedInput } from '../core/input/consumedInput'; +import type { InteractiveArea } from './sources/isInsideInteractiveArea'; + +export type { InteractiveArea } from './sources/isInsideInteractiveArea'; + export const MouseButton = { left: 1, right: 2, @@ -35,14 +39,6 @@ function angleBetween(a: ActivePointer, b: ActivePointer): number { const SCALE_ANGLE_RATIO_INTENT_DEG = 30; -/** Normalized input region within the element. */ -export type InteractiveArea = { - x: number; - y: number; - width: number; - height: number; -}; - /** Buffers raw pointer and wheel input from a DOM element. */ export class InputSystem { /** Suppress the native right-click menu. */ diff --git a/src/dom/sources/isInsideInteractiveArea.ts b/src/dom/sources/isInsideInteractiveArea.ts new file mode 100644 index 00000000..3f4bac86 --- /dev/null +++ b/src/dom/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, since 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/dom/sources/wheel.ts b/src/dom/sources/wheel.ts new file mode 100644 index 00000000..0801b4ec --- /dev/null +++ b/src/dom/sources/wheel.ts @@ -0,0 +1,70 @@ +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`, reported by browsers as a wheel with `ctrlKey`. */ + zoomDelta: number; + /** Only scrolls that start inside this normalized region count. */ + interactiveArea: InteractiveArea | null; + /** 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; + pendingZoom: number; +}; + +export const create = (): WheelState => ({ + deltaX: 0, + deltaY: 0, + zoomDelta: 0, + interactiveArea: null, + pixelsPerLine: 33, + pixelsPerPage: 800, + pendingX: 0, + pendingY: 0, + pendingZoom: 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 and keeps the page from scrolling under it. 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; + event.preventDefault(); + const scale = pixelsPerUnit(state, event.deltaMode); + if (event.ctrlKey) { + state.pendingZoom += event.deltaY * scale; + } 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.zoomDelta = state.pendingZoom; + state.pendingX = 0; + state.pendingY = 0; + state.pendingZoom = 0; +} diff --git a/test/dom/sources/wheel.test.ts b/test/dom/sources/wheel.test.ts new file mode 100644 index 00000000..fe7326bd --- /dev/null +++ b/test/dom/sources/wheel.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as wheel from '../../../src/dom/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 zoomDelta', () => { + const { state, scroll } = setup(); + scroll({ deltaX: 5, deltaY: 50, ctrlKey: true }); + wheel.update(state); + expect(state.zoomDelta).toBe(50); + 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); + }); +}); From be408c9bc31046b209972157cfac62361195e06e Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:28:50 +0200 Subject: [PATCH 09/26] feat: add mouse source --- src/dom/sources/mouse.ts | 213 +++++++++++++++++++++++++++ test/dom/sources/mouse.test.ts | 256 +++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/dom/sources/mouse.ts create mode 100644 test/dom/sources/mouse.test.ts diff --git a/src/dom/sources/mouse.ts b/src/dom/sources/mouse.ts new file mode 100644 index 00000000..a03fc533 --- /dev/null +++ b/src/dom/sources/mouse.ts @@ -0,0 +1,213 @@ +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'; + +export type MouseButton = 'left' | 'middle' | 'right'; + +/** 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 `bit | (down ? DOWN : 0)`. */ + 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 BITS = [1, 2, 4] as const; +const bits: Record = { 1: 'left', 2: 'right', 4: '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 (const bit of BITS) { + if ((pending.held & bit) === (held & bit)) continue; + pending.changes[pending.changeCount++] = bit | (held & bit ? DOWN : 0); + } + pending.held = held; +} + +function addDrag(pending: MousePending, buttons: number, dx: number, dy: number): void { + for (const bit of BITS) { + if ((buttons & bit) === 0) continue; + const drag = pending.drag[bits[bit]]; + 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 = bits[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/test/dom/sources/mouse.test.ts b/test/dom/sources/mouse.test.ts new file mode 100644 index 00000000..291f3759 --- /dev/null +++ b/test/dom/sources/mouse.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as mouse from '../../../src/dom/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(); + }); + }); +}); From 892b346d404a63a3c2446fd8abe86ed9c0241cc8 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:28:54 +0200 Subject: [PATCH 10/26] refactor: read mouse and wheel through sources in InputSystem --- src/dom/InputSystem.ts | 407 +++++++++++++++-------------------------- 1 file changed, 145 insertions(+), 262 deletions(-) diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index fd7ae407..29fb0deb 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -2,7 +2,9 @@ import { degreesToRadians, deltaAngle, radiansToDegrees } from 'math'; import type { ConsumedInput } from '../core/input/consumedInput'; -import type { InteractiveArea } from './sources/isInsideInteractiveArea'; +import * as mouse from './sources/mouse'; +import * as wheel from './sources/wheel'; +import { isInsideInteractiveArea, type InteractiveArea } from './sources/isInsideInteractiveArea'; export type { InteractiveArea } from './sources/isInsideInteractiveArea'; @@ -41,17 +43,15 @@ const SCALE_ANGLE_RATIO_INTENT_DEG = 30; /** Buffers raw pointer and wheel input from a DOM element. */ export class InputSystem { - /** Suppress the native right-click menu. */ - suppressContextMenu = false; - - /** Restrict gesture starts to a normalized region. */ - interactiveArea: InteractiveArea | null = null; - /** Lock diagonal two-finger input to pinch or rotation. */ lockTouchAxis = false; + readonly mouse = mouse.create(); + readonly wheel = wheel.create(); + + private _interactiveArea: InteractiveArea | null = null; private element: HTMLElement | null = null; - private activePointer: ActivePointer | null = null; + private disconnectSources: (() => void) | null = null; private touchPointer: ActivePointer | null = null; private touchPointer2: ActivePointer | null = null; private touchPointer3: ActivePointer | null = null; @@ -67,12 +67,6 @@ export class InputSystem { private gestureRotateTotalDeg = 0; private gestureAxisLock: 'pinch' | 'rotate' | null = null; - 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; @@ -82,65 +76,66 @@ export class InputSystem { 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; + + /** Suppress the native right-click menu. */ + get suppressContextMenu(): boolean { + return this.mouse.suppressContextMenu; + } + + set suppressContextMenu(suppress: boolean) { + this.mouse.suppressContextMenu = suppress; + } + + /** Restrict gesture starts to a normalized region. */ + get interactiveArea(): InteractiveArea | null { + return this._interactiveArea; + } + + set interactiveArea(area: InteractiveArea | null) { + this._interactiveArea = area; + this.mouse.interactiveArea = area; + this.wheel.interactiveArea = area; + } /** Attaches listeners to `element`. Safe to call again with a new element - disconnects the old one first. */ connect = (element: HTMLElement): 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'; + const disconnectMouse = mouse.connect(this.mouse, element); + const disconnectWheel = wheel.connect(this.wheel, element); + this.disconnectSources = () => { + disconnectMouse(); + disconnectWheel(); + }; 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; }; disconnect = (): void => { if (!this.element) return; - this.element.style.touchAction = ''; - this.element.style.userSelect = ''; + this.disconnectSources?.(); + this.disconnectSources = null; 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.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 => { @@ -152,12 +147,23 @@ export class InputSystem { }; 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; + mouse.update(this.mouse); + wheel.update(this.wheel); + 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'); + out.wheelDeltaX = this.wheel.deltaX; + out.wheelDeltaY = this.wheel.deltaY; + out.wheelZoomDelta = this.wheel.zoomDelta; out.touchOneDx = this.touchOneDx; out.touchOneDy = this.touchOneDy; out.touchTwoDx = this.touchTwoDx; @@ -167,23 +173,9 @@ export class InputSystem { 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; @@ -193,24 +185,14 @@ export class InputSystem { this.touchPinchDelta = 0; this.touchRotateDelta = 0; this.gestureZoomDelta = 0; - this.wheelDeltaX = 0; - this.wheelDeltaY = 0; - this.wheelZoomDelta = 0; - this.lockedDx = 0; - this.lockedDy = 0; 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". */ 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 - @@ -225,39 +207,23 @@ export class InputSystem { } private onPointerDown = (event: PointerEvent): void => { + if (event.pointerType !== 'touch') return; 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; + 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 { - return; // pen: not handled yet + // a fourth finger, or a duplicate pointerId ignored + return; } try { @@ -266,183 +232,100 @@ export class InputSystem { }; 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 + if (event.pointerType !== 'touch') return; + 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; - - // 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; + 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 (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; + 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'; } - 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; + if (this.touchAxisLock !== 'rotate') this.touchPinchDelta += pinchStepDelta; + if (this.touchAxisLock !== 'pinch') this.touchRotateDelta += rotateStepDelta; 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; - } + 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; }; 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; + if (event.pointerType !== 'touch') return; + 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(); } - 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; } }; From d7cdb7c3aebde27d54288a322ad3a11794eb6442 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:34:24 +0200 Subject: [PATCH 11/26] feat: add touch source --- src/dom/sources/touch.ts | 211 +++++++++++++++++++++++++++++++++ test/dom/sources/touch.test.ts | 176 +++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/dom/sources/touch.ts create mode 100644 test/dom/sources/touch.test.ts diff --git a/src/dom/sources/touch.ts b/src/dom/sources/touch.ts new file mode 100644 index 00000000..b86b994a --- /dev/null +++ b/src/dom/sources/touch.ts @@ -0,0 +1,211 @@ +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; + /** Change in the distance between two fingers this frame, in pixels. */ + 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. */ + fingers: [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; + +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, + fingers: [ + { 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.fingers[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.fingers; + 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.fingers; + const distance = Math.hypot(b.x - a.x, b.y - a.y); + const pinchStep = distance - pending.distance; + 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) { + const scaleFraction = distance / pending.startDistance - 1; + const intent = + Math.abs(scaleFraction) * SCALE_ANGLE_RATIO_INTENT_DEG - Math.abs(radiansToDegrees(pending.twistTotal)); + if (intent < 0) pending.axisLock = 'twist'; + else if (intent > 0) pending.axisLock = 'pinch'; + } + + 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.fingers[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.fingers[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.fingers[index]; + for (let i = index; i < pending.count - 1; i++) pending.fingers[i] = pending.fingers[i + 1]; + pending.fingers[--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/test/dom/sources/touch.test.ts b/test/dom/sources/touch.test.ts new file mode 100644 index 00000000..d07d8b7e --- /dev/null +++ b/test/dom/sources/touch.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as touch from '../../../src/dom/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(-10, 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.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.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(50, 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(''); + }); +}); From c691f255e8be3e351f5a9d4f587767eac0190874 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:34:27 +0200 Subject: [PATCH 12/26] refactor: read touch through a source in InputSystem --- src/dom/InputSystem.ts | 231 ++++++----------------------------------- 1 file changed, 32 insertions(+), 199 deletions(-) diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 29fb0deb..1a3e8661 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -1,8 +1,9 @@ -import { degreesToRadians, deltaAngle, radiansToDegrees } from 'math'; +import { degreesToRadians } from 'math'; import type { ConsumedInput } from '../core/input/consumedInput'; import * as mouse from './sources/mouse'; +import * as touch from './sources/touch'; import * as wheel from './sources/wheel'; import { isInsideInteractiveArea, type InteractiveArea } from './sources/isInsideInteractiveArea'; @@ -14,12 +15,6 @@ export const MouseButton = { 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; @@ -29,53 +24,25 @@ type WebKitGestureEvent = Event & { 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); -} - -function angleBetween(a: ActivePointer, b: ActivePointer): number { - return Math.atan2(b.y - a.y, b.x - a.x); -} - const SCALE_ANGLE_RATIO_INTENT_DEG = 30; /** Buffers raw pointer and wheel input from a DOM element. */ export class InputSystem { - /** Lock diagonal two-finger input to pinch or rotation. */ - lockTouchAxis = false; - readonly mouse = mouse.create(); + readonly touch = touch.create(); readonly wheel = wheel.create(); private _interactiveArea: InteractiveArea | null = null; private element: HTMLElement | null = null; private disconnectSources: (() => void) | 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; private gestureActive = false; private lastGestureScale = 1; private lastGestureRotationDeg = 0; private gestureRotateTotalDeg = 0; private gestureAxisLock: 'pinch' | 'rotate' | null = null; - - 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 gestureRotateDelta = 0; /** Suppress the native right-click menu. */ get suppressContextMenu(): boolean { @@ -94,23 +61,31 @@ export class InputSystem { set interactiveArea(area: InteractiveArea | null) { this._interactiveArea = area; this.mouse.interactiveArea = area; + this.touch.interactiveArea = area; this.wheel.interactiveArea = area; } + /** Lock diagonal two-finger input to pinch or rotation. */ + get lockTouchAxis(): boolean { + return this.touch.lockTouchAxis; + } + + set lockTouchAxis(lock: boolean) { + this.touch.lockTouchAxis = lock; + } + /** Attaches listeners to `element`. Safe to call again with a new element - disconnects the old one first. */ connect = (element: HTMLElement): void => { this.disconnect(); this.element = element; const disconnectMouse = mouse.connect(this.mouse, element); + const disconnectTouch = touch.connect(this.touch, element); const disconnectWheel = wheel.connect(this.wheel, element); this.disconnectSources = () => { disconnectMouse(); + disconnectTouch(); disconnectWheel(); }; - element.addEventListener('pointerdown', this.onPointerDown); - element.addEventListener('pointermove', this.onPointerMove); - element.addEventListener('pointerup', this.onPointerUp); - element.addEventListener('pointercancel', this.onPointerUp); element.addEventListener('gesturestart', this.onGestureStart as EventListener); element.addEventListener('gesturechange', this.onGestureChange as EventListener); element.addEventListener('gestureend', this.onGestureEnd); @@ -121,10 +96,6 @@ export class InputSystem { if (!this.element) return; this.disconnectSources?.(); this.disconnectSources = null; - 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('gesturestart', this.onGestureStart as EventListener); this.element.removeEventListener('gesturechange', this.onGestureChange as EventListener); this.element.removeEventListener('gestureend', this.onGestureEnd); @@ -132,9 +103,6 @@ export class InputSystem { // 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.element = null; - this.touchPointer = null; - this.touchPointer2 = null; - this.touchPointer3 = null; this.gestureActive = false; }; @@ -148,6 +116,7 @@ export class InputSystem { consume = (out: ConsumedInput): ConsumedInput => { mouse.update(this.mouse); + touch.update(this.touch); wheel.update(this.wheel); const { drag, buttons, lockedMovement } = this.mouse; out.leftDx = drag.left[0]; @@ -161,30 +130,24 @@ export class InputSystem { 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.touchPinchDelta = touchState.pinchDelta; + out.touchRotateDelta = touchState.twistDelta + this.gestureRotateDelta; + 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.wheelZoomDelta = this.wheel.zoomDelta; - 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.touchOneHeld = this.touchPointer !== null && this.touchPointer2 === null; - out.touchTwoHeld = this.touchPointer2 !== null && this.touchPointer3 === null; - out.touchThreeHeld = this.touchPointer3 !== null; - 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.gestureRotateDelta = 0; return out; }; @@ -195,138 +158,8 @@ export class InputSystem { 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 (event.pointerType !== 'touch') return; - if (!this.isInsideInteractiveArea(event.clientX, event.clientY)) return; - 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; - } - - try { - (event.target as HTMLElement).setPointerCapture(event.pointerId); - } catch {} - }; - - private onPointerMove = (event: PointerEvent): void => { - if (event.pointerType !== 'touch') return; - 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; - }; - - private onPointerUp = (event: PointerEvent): void => { - if (event.pointerType !== 'touch') return; - 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(); - } - }; - private onVisibilityChange = (): void => { - if (document.hidden) { - this.touchPointer = null; - this.touchPointer2 = null; - this.touchPointer3 = null; - this.gestureActive = false; - } + if (document.hidden) this.gestureActive = false; }; private onGestureStart = (event: WebKitGestureEvent): void => { @@ -358,7 +191,7 @@ export class InputSystem { } if (this.gestureAxisLock !== 'rotate') this.gestureZoomDelta += zoomStepDelta; - if (this.gestureAxisLock !== 'pinch') this.touchRotateDelta += degreesToRadians(rotateStepDeg); + if (this.gestureAxisLock !== 'pinch') this.gestureRotateDelta += degreesToRadians(rotateStepDeg); }; private onGestureEnd = (): void => { From c24b6501eb018fe3b9f31cf56b5e6c3c7dd0db77 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:44:49 +0200 Subject: [PATCH 13/26] feat: add Safari gesture source --- src/dom/sources/safariGesture.ts | 110 +++++++++++++++++++++++++ src/dom/sources/touch.ts | 12 +-- test/dom/sources/safariGesture.test.ts | 100 ++++++++++++++++++++++ 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 src/dom/sources/safariGesture.ts create mode 100644 test/dom/sources/safariGesture.test.ts diff --git a/src/dom/sources/safariGesture.ts b/src/dom/sources/safariGesture.ts new file mode 100644 index 00000000..22db8d9b --- /dev/null +++ b/src/dom/sources/safariGesture.ts @@ -0,0 +1,110 @@ +import { degreesToRadians } from 'math'; + +import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; +import { dominantGesture } from './touch'; + +/** Safari's trackpad pinch and rotate, summed per frame. */ +export type SafariGestureState = { + /** Change in pinch scale this frame, where `1` is the scale at the start of the gesture. */ + scaleDelta: 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; + /** Internal. */ + pending: { + scaleDelta: number; + twistDelta: number; + active: boolean; + scale: number; + rotationDegrees: number; + twistTotalDegrees: number; + axisLock: 'pinch' | 'twist' | null; + }; +}; + +// Not part of the standard DOM types. +type GestureEvent = Event & { scale: number; rotation: number; clientX: number; clientY: number }; + +export const create = (): SafariGestureState => ({ + scaleDelta: 0, + twistDelta: 0, + interactiveArea: null, + lockTouchAxis: false, + pending: { + scaleDelta: 0, + twistDelta: 0, + active: false, + scale: 1, + rotationDegrees: 0, + twistTotalDegrees: 0, + axisLock: null, + }, +}); + +/** 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 onGestureStart = (event: Event): void => { + const gesture = event as GestureEvent; + if (!isInsideInteractiveArea(element, state.interactiveArea, gesture.clientX, gesture.clientY)) return; + if (event.cancelable) event.preventDefault(); + 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) event.preventDefault(); + const scaleStep = 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.scaleDelta += scaleStep; + if (pending.axisLock !== 'pinch') pending.twistDelta += degreesToRadians(rotationStep); + onInput?.(); + }; + + const end = (): void => { + pending.active = false; + }; + const onVisibilityChange = (): void => { + if (document.hidden) end(); + }; + + element.addEventListener('gesturestart', onGestureStart); + element.addEventListener('gesturechange', onGestureChange); + element.addEventListener('gestureend', end); + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', end); + + return () => { + element.removeEventListener('gesturestart', onGestureStart); + element.removeEventListener('gesturechange', onGestureChange); + element.removeEventListener('gestureend', end); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', end); + end(); + }; +} + +/** Starts a new frame: the input gathered since the last call becomes this frame's. */ +export function update(state: SafariGestureState): void { + state.scaleDelta = state.pending.scaleDelta; + state.twistDelta = state.pending.twistDelta; + state.pending.scaleDelta = 0; + state.pending.twistDelta = 0; +} diff --git a/src/dom/sources/touch.ts b/src/dom/sources/touch.ts index b86b994a..fbc133d2 100644 --- a/src/dom/sources/touch.ts +++ b/src/dom/sources/touch.ts @@ -43,6 +43,12 @@ type TouchPending = { /** 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] }, @@ -107,11 +113,7 @@ function movePair(state: TouchState, moving: Finger, x: number, y: number): void // Decided once per gesture, so a later move leaning the other way doesn't flip it. if (state.lockTouchAxis && !pending.axisLock) { - const scaleFraction = distance / pending.startDistance - 1; - const intent = - Math.abs(scaleFraction) * SCALE_ANGLE_RATIO_INTENT_DEG - Math.abs(radiansToDegrees(pending.twistTotal)); - if (intent < 0) pending.axisLock = 'twist'; - else if (intent > 0) pending.axisLock = 'pinch'; + pending.axisLock = dominantGesture(distance / pending.startDistance - 1, radiansToDegrees(pending.twistTotal)); } if (pending.axisLock !== 'twist') pending.pinchDelta += pinchStep; diff --git a/test/dom/sources/safariGesture.test.ts b/test/dom/sources/safariGesture.test.ts new file mode 100644 index 00000000..ef3c448c --- /dev/null +++ b/test/dom/sources/safariGesture.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as safariGesture from '../../../src/dom/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 { state, onInput, send, disconnect }; +} + +afterEach(() => { + for (const disconnect of connections.splice(0)) disconnect(); + document.body.innerHTML = ''; +}); + +describe('safariGesture', () => { + it('sums scale and rotation steps on update, rotation in radians, 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.scaleDelta).toBeCloseTo(0.15, 5); + expect(state.twistDelta).toBeCloseTo((10 * Math.PI) / 180, 5); + + safariGesture.update(state); + expect(state.scaleDelta).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.scaleDelta).toBeCloseTo(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.scaleDelta).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.scaleDelta).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.scaleDelta).toBeCloseTo(0.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.scaleDelta).toBe(0); + }); +}); From 7def3ae7d1864c73627a43dd6bb34de2344b78f1 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:44:52 +0200 Subject: [PATCH 14/26] refactor: make InputSystem a facade over the input sources --- src/dom/InputSystem.ts | 93 ++++++------------------------------------ 1 file changed, 13 insertions(+), 80 deletions(-) diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 1a3e8661..3dd5f99f 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -1,8 +1,7 @@ -import { degreesToRadians } from 'math'; - import type { ConsumedInput } from '../core/input/consumedInput'; import * as mouse from './sources/mouse'; +import * as safariGesture from './sources/safariGesture'; import * as touch from './sources/touch'; import * as wheel from './sources/wheel'; import { isInsideInteractiveArea, type InteractiveArea } from './sources/isInsideInteractiveArea'; @@ -15,35 +14,17 @@ export const MouseButton = { middle: 4, } as const; -// 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; -}; - -const SCALE_ANGLE_RATIO_INTENT_DEG = 30; - -/** Buffers raw pointer and wheel input from a DOM element. */ +/** 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(); private _interactiveArea: InteractiveArea | null = null; private element: HTMLElement | null = null; private disconnectSources: (() => void) | null = null; - private gestureActive = false; - private lastGestureScale = 1; - private lastGestureRotationDeg = 0; - private gestureRotateTotalDeg = 0; - private gestureAxisLock: 'pinch' | 'rotate' | null = null; - private gestureZoomDelta = 0; - private gestureRotateDelta = 0; - /** Suppress the native right-click menu. */ get suppressContextMenu(): boolean { return this.mouse.suppressContextMenu; @@ -63,6 +44,7 @@ export class InputSystem { this.mouse.interactiveArea = area; this.touch.interactiveArea = area; this.wheel.interactiveArea = area; + this.safariGesture.interactiveArea = area; } /** Lock diagonal two-finger input to pinch or rotation. */ @@ -72,6 +54,7 @@ export class InputSystem { 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. */ @@ -81,29 +64,20 @@ export class InputSystem { const disconnectMouse = mouse.connect(this.mouse, element); const disconnectTouch = touch.connect(this.touch, element); const disconnectWheel = wheel.connect(this.wheel, element); + const disconnectSafariGesture = safariGesture.connect(this.safariGesture, element); this.disconnectSources = () => { disconnectMouse(); disconnectTouch(); disconnectWheel(); + disconnectSafariGesture(); }; - element.addEventListener('gesturestart', this.onGestureStart as EventListener); - element.addEventListener('gesturechange', this.onGestureChange as EventListener); - element.addEventListener('gestureend', this.onGestureEnd); - document.addEventListener('visibilitychange', this.onVisibilityChange); }; + /** Detaches every listener. A pointer lock stays: it belongs to the document, release it with `exitPointerLock`. */ disconnect = (): void => { - if (!this.element) return; this.disconnectSources?.(); this.disconnectSources = null; - this.element.removeEventListener('gesturestart', this.onGestureStart as EventListener); - this.element.removeEventListener('gesturechange', this.onGestureChange as EventListener); - this.element.removeEventListener('gestureend', this.onGestureEnd); - 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.element = null; - this.gestureActive = false; }; requestPointerLock = (): void => { @@ -114,10 +88,12 @@ 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 => { 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]; @@ -138,63 +114,20 @@ export class InputSystem { out.touchThreeDx = touchState.drag.three[0]; out.touchThreeDy = touchState.drag.three[1]; out.touchPinchDelta = touchState.pinchDelta; - out.touchRotateDelta = touchState.twistDelta + this.gestureRotateDelta; + 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.wheelZoomDelta = this.wheel.zoomDelta; - out.gestureZoomDelta = this.gestureZoomDelta; - this.gestureZoomDelta = 0; - this.gestureRotateDelta = 0; + out.gestureZoomDelta = this.safariGesture.scaleDelta; 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, since its position is frozen. */ isInsideInteractiveArea(clientX: number, clientY: number): boolean { if (!this.element) return true; return isInsideInteractiveArea(this.element, this._interactiveArea, clientX, clientY); } - - private onVisibilityChange = (): void => { - if (document.hidden) this.gestureActive = false; - }; - - 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.gestureRotateDelta += degreesToRadians(rotateStepDeg); - }; - - private onGestureEnd = (): void => { - this.gestureActive = false; - }; } From bd0bbc31a41cccee2b54fbad1cfe66142e5e744d Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 20:55:50 +0200 Subject: [PATCH 15/26] fix: wake demand rendering from every input source --- docs/advanced/on-demand-rendering.mdx | 2 +- docs/input/input-controller.mdx | 2 +- src/dom/InputControllerDom.ts | 5 +++-- src/dom/InputSystem.ts | 15 +++++++++------ src/react/input/InputController.tsx | 19 ++++--------------- test/dom/InputControllerDom.test.ts | 21 ++++++++++++++++++++- 6 files changed, 38 insertions(+), 26 deletions(-) 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/input/input-controller.mdx b/docs/input/input-controller.mdx index 05cf801b..39f20197 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -133,7 +133,7 @@ klipp.registerUpdate(() => { ``` -Input is only read while its camera is on screen. `ref` gives you the `InputControllerDom`. +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** diff --git a/src/dom/InputControllerDom.ts b/src/dom/InputControllerDom.ts index 0db5bc25..f69fcf21 100644 --- a/src/dom/InputControllerDom.ts +++ b/src/dom/InputControllerDom.ts @@ -7,8 +7,9 @@ import { InputSystem } from './InputSystem'; export class InputControllerDom extends InputController { readonly inputSystem = new InputSystem(); - connect = (element: HTMLElement): void => { - this.inputSystem.connect(element); + /** Listens to `element`, calling `onInput` for every event that changes the input. */ + connect = (element: HTMLElement, onInput?: () => void): void => { + this.inputSystem.connect(element, onInput); }; disconnect = (): void => { diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 3dd5f99f..747ed98a 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -57,14 +57,17 @@ export class InputSystem { 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; - const disconnectMouse = mouse.connect(this.mouse, element); - const disconnectTouch = touch.connect(this.touch, element); - const disconnectWheel = wheel.connect(this.wheel, element); - const disconnectSafariGesture = safariGesture.connect(this.safariGesture, 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(); diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index ced90fcc..6ea82d01 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -36,7 +36,7 @@ export type InputControllerProps = { }; /** 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; @@ -145,22 +145,11 @@ export function InputController(props: InputControllerProps) { 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/dom/InputControllerDom.test.ts b/test/dom/InputControllerDom.test.ts index 929bbfa6..df5a9b08 100644 --- a/test/dom/InputControllerDom.test.ts +++ b/test/dom/InputControllerDom.test.ts @@ -1,4 +1,4 @@ -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'; @@ -222,4 +222,23 @@ describe('InputControllerDom', () => { 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(); + }); }); From fe3d6fde03d1fe763cc8af8cfc6582fcc922263f Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 21:49:42 +0200 Subject: [PATCH 16/26] feat: map wheel and pinch onto single axes, ignore iOS touch pinches as Safari gestures --- docs/input/input-controller.mdx | 17 ++++++ src/core/input/consumedInput.ts | 6 +- src/core/input/index.ts | 8 ++- src/core/input/inputMapping.ts | 25 +++++++++ src/dom/InputSystem.ts | 2 +- src/dom/sources/safariGesture.ts | 67 ++++++++++++++++++----- src/dom/sources/wheel.ts | 2 +- src/react/index.ts | 7 ++- src/react/input/InputController.tsx | 23 ++++++++ test/core/input/InputController.test.ts | 51 +++++++++++++++++ test/dom/InputSystem.test.ts | 10 ++-- test/dom/sources/safariGesture.test.ts | 41 +++++++++++--- test/react/input/InputController.test.tsx | 26 +++++++++ 13 files changed, 249 insertions(+), 36 deletions(-) diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index 39f20197..db1d48ae 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -12,6 +12,9 @@ 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. | | +| `touchPinch` | What a two-finger pinch on a touch screen drives, in pixels. Spreading is positive. | | +| `trackpadPinch` | What a trackpad pinch drives, 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 | @@ -31,6 +34,20 @@ Each button or finger count takes the same config: } ``` +## Wheel and pinch + +`wheel`, `touchPinch` and `trackpadPinch` each drive a single axis: + +```ts +{ + axis: 'radial', // a name in React, the axis itself elsewhere + gain: 0.002, + invert: true, +} +``` + +They come in different units, so each has its own `gain`. A trackpad pinch adds up to the same amount however far you are zoomed in, which suits a zoom stored as `ln` of its scale. + ## 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`. diff --git a/src/core/input/consumedInput.ts b/src/core/input/consumedInput.ts index 2aa5555d..a3cf97cb 100644 --- a/src/core/input/consumedInput.ts +++ b/src/core/input/consumedInput.ts @@ -12,18 +12,18 @@ export type ConsumedInput = { /** Two-finger centroid movement. */ touchTwoDx: number; touchTwoDy: number; - /** Two-finger distance change. */ + /** Two-finger distance change, in pixels. Spreading is positive. */ touchPinchDelta: number; /** Three-finger centroid movement. */ touchThreeDx: number; touchThreeDy: number; /** Two-finger twist in radians. */ touchRotateDelta: number; - /** Safari/WebKit trackpad pinch amount. */ + /** Safari trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ gestureZoomDelta: number; wheelDeltaX: number; wheelDeltaY: number; - /** Trackpad pinch amount reported through `wheel`. */ + /** Trackpad pinch reported as a `ctrlKey` wheel, whose `deltaY` is `-100 * ln(scale)`. */ wheelZoomDelta: number; /** Raw mouse movement while Pointer Lock is active and no button is held. */ lockedDx: number; diff --git a/src/core/input/index.ts b/src/core/input/index.ts index 2eb937cd..12064560 100644 --- a/src/core/input/index.ts +++ b/src/core/input/index.ts @@ -8,4 +8,10 @@ export type { ConsumedInput } from './consumedInput'; export { InputController } from './InputController'; export * as inputMapping from './inputMapping'; -export type { InputControllerConfig, 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 a4426f20..a90ae043 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -18,6 +18,14 @@ export type InputSourceMapping = { invert?: InputInvert; }; +/** One input feeding one axis. */ +export type InputAxisMapping = { + axis: InputAxisData; + /** Multiplies the raw delta before it reaches the axis. */ + gain?: number; + invert?: boolean; +}; + export type InputControllerConfig = { mouseButtons: { left: InputSourceMapping | null; @@ -29,6 +37,12 @@ export type InputControllerConfig = { two: InputSourceMapping | null; three: InputSourceMapping | null; }; + /** Vertical wheel scrolling, in pixels. Scrolling up is positive, like spreading a pinch. */ + wheel?: InputAxisMapping | null; + /** Change in the distance between two fingers, in pixels. Spreading is positive. */ + touchPinch?: InputAxisMapping | null; + /** Trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ + trackpadPinch?: InputAxisMapping | null; }; const isInverted = (invert: InputInvert | undefined, axis: 'x' | 'y'): boolean => @@ -41,6 +55,11 @@ function applySource(mapping: InputSourceMapping | null, dx: number, dy: number) inputAxis.applyDelta(mapping.axes.y, dy * gain * (isInverted(mapping.invert, 'y') ? -1 : 1)); } +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: InputSourceMapping | null): void { if (!mapping) return; mapping.axes.x.held = false; @@ -63,6 +82,7 @@ export function feedAxes(config: InputControllerConfig, input: ConsumedInput, en resetHeld(touches.one); resetHeld(touches.two); resetHeld(touches.three); + if (config.touchPinch) config.touchPinch.axis.held = false; if (!enabled) return; applySource(mouseButtons.left, input.leftDx, input.leftDy); applySource(mouseButtons.right, input.rightDx, input.rightDy); @@ -77,4 +97,9 @@ export function feedAxes(config: InputControllerConfig, input: ConsumedInput, en applyHeld(touches.one, input.touchOneHeld); applyHeld(touches.two, input.touchTwoHeld); applyHeld(touches.three, input.touchThreeHeld); + applyAxis(config.wheel, -input.wheelDeltaY); + applyAxis(config.touchPinch, input.touchPinchDelta); + // Both arrive as ln(scale): Safari's gesture directly, other browsers as a ctrlKey wheel's -100 * ln(scale). + applyAxis(config.trackpadPinch, input.gestureZoomDelta - input.wheelZoomDelta / 100); + if (config.touchPinch && input.touchTwoHeld) config.touchPinch.axis.held = true; } diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 747ed98a..939bddc8 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -124,7 +124,7 @@ export class InputSystem { out.wheelDeltaX = this.wheel.deltaX; out.wheelDeltaY = this.wheel.deltaY; out.wheelZoomDelta = this.wheel.zoomDelta; - out.gestureZoomDelta = this.safariGesture.scaleDelta; + out.gestureZoomDelta = this.safariGesture.logScaleDelta; return out; }; diff --git a/src/dom/sources/safariGesture.ts b/src/dom/sources/safariGesture.ts index 22db8d9b..680917dc 100644 --- a/src/dom/sources/safariGesture.ts +++ b/src/dom/sources/safariGesture.ts @@ -3,10 +3,10 @@ import { degreesToRadians } from 'math'; import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; import { dominantGesture } from './touch'; -/** Safari's trackpad pinch and rotate, summed per frame. */ +/** Safari's trackpad pinch and rotate, summed per frame. Gestures made with fingers on a touch screen are left to `touch`. */ export type SafariGestureState = { - /** Change in pinch scale this frame, where `1` is the scale at the start of the gesture. */ - scaleDelta: number; + /** Pinch this frame, as the change in `ln(scale)`: spreading is positive, and steps add up the same at any zoom. */ + logScaleDelta: number; /** Rotation this frame, in radians. */ twistDelta: number; /** Only gestures that start inside this normalized region count. */ @@ -15,13 +15,16 @@ export type SafariGestureState = { lockTouchAxis: boolean; /** Internal. */ pending: { - scaleDelta: number; + logScaleDelta: 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; }; }; @@ -29,18 +32,20 @@ export type SafariGestureState = { type GestureEvent = Event & { scale: number; rotation: number; clientX: number; clientY: number }; export const create = (): SafariGestureState => ({ - scaleDelta: 0, + logScaleDelta: 0, twistDelta: 0, interactiveArea: null, lockTouchAxis: false, pending: { - scaleDelta: 0, + logScaleDelta: 0, twistDelta: 0, active: false, scale: 1, rotationDegrees: 0, twistTotalDegrees: 0, axisLock: null, + touchIds: [], + touchCount: 0, }, }); @@ -48,10 +53,24 @@ export const create = (): SafariGestureState => ({ export function connect(state: SafariGestureState, element: HTMLElement, onInput?: () => void): () => void { const pending = state.pending; + 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) event.preventDefault(); + if (pending.touchCount > 0) return; pending.active = true; pending.scale = gesture.scale; pending.rotationDegrees = gesture.rotation; @@ -63,7 +82,12 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput if (!pending.active) return; const gesture = event as GestureEvent; if (event.cancelable) event.preventDefault(); - const scaleStep = gesture.scale - pending.scale; + // A finger may land after the gesture started, depending on event order. + if (pending.touchCount > 0) { + end(); + return; + } + const logScaleStep = Math.log(gesture.scale / pending.scale); const rotationStep = gesture.rotation - pending.rotationDegrees; pending.scale = gesture.scale; pending.rotationDegrees = gesture.rotation; @@ -73,38 +97,51 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput if (state.lockTouchAxis && !pending.axisLock) { pending.axisLock = dominantGesture(gesture.scale - 1, pending.twistTotalDegrees); } - if (pending.axisLock !== 'twist') pending.scaleDelta += scaleStep; + if (pending.axisLock !== 'twist') pending.logScaleDelta += logScaleStep; if (pending.axisLock !== 'pinch') pending.twistDelta += degreesToRadians(rotationStep); onInput?.(); }; - const end = (): void => { + function end(): void { pending.active = false; + } + const liftAll = (): void => { + end(); + pending.touchIds.fill(-1); + pending.touchCount = 0; }; const onVisibilityChange = (): void => { - if (document.hidden) end(); + 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', end); + 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', end); - end(); + 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.scaleDelta = state.pending.scaleDelta; + state.logScaleDelta = state.pending.logScaleDelta; state.twistDelta = state.pending.twistDelta; - state.pending.scaleDelta = 0; + state.pending.logScaleDelta = 0; state.pending.twistDelta = 0; } diff --git a/src/dom/sources/wheel.ts b/src/dom/sources/wheel.ts index 0801b4ec..ec0d9cb7 100644 --- a/src/dom/sources/wheel.ts +++ b/src/dom/sources/wheel.ts @@ -5,7 +5,7 @@ export type WheelState = { /** Scroll since the last `update`. */ deltaX: number; deltaY: number; - /** Trackpad pinch since the last `update`, reported by browsers as a wheel with `ctrlKey`. */ + /** Trackpad pinch since the last `update`, which browsers send as a `ctrlKey` wheel with `deltaY = -100 * ln(scale)`. */ zoomDelta: number; /** Only scrolls that start inside this normalized region count. */ interactiveArea: InteractiveArea | null; diff --git a/src/react/index.ts b/src/react/index.ts index 584d100c..06be11f9 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -11,7 +11,12 @@ export { useIsActiveVirtualCamera, useIsLiveVirtualCamera, useVirtualCamera } fr export type { InputAxisOwner } from '../core/input/InputAxisOwner'; export { InputAxisOwnerContext } from './input/InputAxisOwnerContext'; -export { InputController, type InputControllerProps, type InputSourceConfig } from './input/InputController'; +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/InputController.tsx b/src/react/input/InputController.tsx index 6ea82d01..26180919 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -21,6 +21,14 @@ 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 owner context when omitted, or else the virtual camera itself. */ target?: RefObject; @@ -34,6 +42,12 @@ 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; + /** Change in the distance between two fingers, in pixels. Spreading is positive. */ + touchPinch?: InputAxisSourceConfig | null; + /** Trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ + trackpadPinch?: InputAxisSourceConfig | null; /** Wait until this camera is live before listening to input. */ waitForBlend?: boolean; /** Whether input reaches the axes. Unmount the component to stop listening altogether. */ @@ -64,6 +78,12 @@ function resolveSource(owner: InputAxisOwner, source: InputSourceConfig | null | return { axes: { x, y }, gain: source.gain, invert: source.invert }; } +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: { @@ -76,6 +96,9 @@ function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputC two: resolveSource(owner, props.touches?.two), three: resolveSource(owner, props.touches?.three), }, + wheel: resolveAxisSource(owner, props.wheel), + touchPinch: resolveAxisSource(owner, props.touchPinch), + trackpadPinch: resolveAxisSource(owner, props.trackpadPinch), }; } diff --git a/test/core/input/InputController.test.ts b/test/core/input/InputController.test.ts index 4118e45a..4d23cf1f 100644 --- a/test/core/input/InputController.test.ts +++ b/test/core/input/InputController.test.ts @@ -40,3 +40,54 @@ describe('InputController', () => { 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 touch pinch and holds its axis while two fingers are down', () => { + const { axis, controller, step } = single(); + controller.config.touchPinch = { axis }; + controller.input.touchPinchDelta = 30; + controller.input.touchTwoHeld = true; + step(); + expect(axis.value).toBe(30); + expect(axis.held).toBe(true); + + controller.input.touchPinchDelta = 0; + controller.input.touchTwoHeld = false; + step(); + expect(axis.held).toBe(false); + }); + + it("feeds a trackpad pinch from Safari's gestures and other browsers' ctrl+wheel in the same ln(scale) units", () => { + const { axis, controller, step } = single(); + controller.config.trackpadPinch = { axis }; + controller.input.gestureZoomDelta = Math.log(2); + controller.input.wheelZoomDelta = -100 * Math.log(1.5); + step(); + expect(axis.value).toBeCloseTo(Math.log(3), 10); + }); +}); diff --git a/test/dom/InputSystem.test.ts b/test/dom/InputSystem.test.ts index b4c7090c..5454fb4e 100644 --- a/test/dom/InputSystem.test.ts +++ b/test/dom/InputSystem.test.ts @@ -730,7 +730,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 gestureZoomDelta and rotation into touchRotateDelta, in radians', () => { const el = setup(); gesture(el, 'gesturestart', 1, 0); gesture(el, 'gesturechange', 1.1, 0); @@ -739,7 +739,7 @@ describe('InputSystem', () => { const out = emptyInput(); system.consume(out); - expect(out.gestureZoomDelta).toBeCloseTo(0.15, 5); + expect(out.gestureZoomDelta).toBeCloseTo(Math.log(1.15), 5); expect(out.touchRotateDelta).toBeCloseTo((10 * Math.PI) / 180, 5); }); @@ -769,7 +769,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.gestureZoomDelta).toBeCloseTo(Math.log(1.1), 5); // a stale baseline of 2 would give ln(0.55) }); describe('lockTouchAxis', () => { @@ -786,7 +786,7 @@ describe('InputSystem', () => { }; const zoom = gestureWith(1.5, 5); - expect(zoom.gestureZoomDelta).toBeCloseTo(0.5, 5); + expect(zoom.gestureZoomDelta).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); @@ -808,7 +808,7 @@ describe('InputSystem', () => { 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.gestureZoomDelta).toBeCloseTo(Math.log(1.5), 5); expect(out.touchRotateDelta).toBe(0); }); }); diff --git a/test/dom/sources/safariGesture.test.ts b/test/dom/sources/safariGesture.test.ts index ef3c448c..ca578c1a 100644 --- a/test/dom/sources/safariGesture.test.ts +++ b/test/dom/sources/safariGesture.test.ts @@ -23,7 +23,7 @@ function setup() { element.dispatchEvent(event); return event; }; - return { state, onInput, send, disconnect }; + return { element, state, onInput, send, disconnect }; } afterEach(() => { @@ -32,7 +32,7 @@ afterEach(() => { }); describe('safariGesture', () => { - it('sums scale and rotation steps on update, rotation in radians, and keeps the page from zooming', () => { + 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); @@ -40,11 +40,11 @@ describe('safariGesture', () => { expect(onInput).toHaveBeenCalledTimes(2); safariGesture.update(state); - expect(state.scaleDelta).toBeCloseTo(0.15, 5); + expect(state.logScaleDelta).toBeCloseTo(Math.log(1.15), 5); expect(state.twistDelta).toBeCloseTo((10 * Math.PI) / 180, 5); safariGesture.update(state); - expect(state.scaleDelta).toBe(0); + expect(state.logScaleDelta).toBe(0); }); it('measures each gesture from its own start, since Safari resets scale and rotation per gesture', () => { @@ -55,7 +55,7 @@ describe('safariGesture', () => { send('gesturestart', 1, 0); send('gesturechange', 1.1, 0); safariGesture.update(state); - expect(state.scaleDelta).toBeCloseTo(1.1, 5); + expect(state.logScaleDelta).toBeCloseTo(Math.log(2) + Math.log(1.1), 5); }); it('ignores a gesture that starts outside interactiveArea', () => { @@ -64,7 +64,7 @@ describe('safariGesture', () => { send('gesturestart', 1, 0, 10, 10); send('gesturechange', 1.1, 0, 10, 10); safariGesture.update(state); - expect(state.scaleDelta).toBe(0); + expect(state.logScaleDelta).toBe(0); }); it('lockTouchAxis keeps the stronger of scale and rotation, decided once per gesture', () => { @@ -74,14 +74,14 @@ describe('safariGesture', () => { send('gesturechange', 1.01, 20); send('gesturechange', 1.5, 20); safariGesture.update(state); - expect(state.scaleDelta).toBe(0); + expect(state.logScaleDelta).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.scaleDelta).toBeCloseTo(0.5, 5); + expect(state.logScaleDelta).toBeCloseTo(Math.log(1.5), 5); expect(state.twistDelta).toBe(0); }); @@ -95,6 +95,29 @@ describe('safariGesture', () => { disconnect(); send('gesturechange', 1.5, 0); safariGesture.update(state); - expect(state.scaleDelta).toBe(0); + expect(state.logScaleDelta).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.logScaleDelta).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.logScaleDelta).toBeCloseTo(Math.log(1.2), 5); }); }); diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index e9740bb6..dc8048b8 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -293,4 +293,30 @@ describe('InputController (React wrapper)', () => { 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(); + const roll = new InputAxis(); + let controller: InputControllerDom | null = null; + const renderer = await create( + + + { + controller = c; + }} + target={owner({ radial, roll })} + wheel={{ axis: 'radial', gain: 0.01 }} + touchPinch={{ axis: 'radial', invert: true }} + trackpadPinch={{ axis: 'roll' }} + /> + + , + ); + await renderer.advanceFrames(1, 0.05); + + expect(controller!.config.wheel).toEqual({ axis: radial, gain: 0.01, invert: undefined }); + expect(controller!.config.touchPinch?.axis).toBe(radial); + expect(controller!.config.trackpadPinch?.axis).toBe(roll); + }); }); From 108a52353b1d6602b616c92a62f9870bad1f3108 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 21:56:58 +0200 Subject: [PATCH 17/26] docs: add input from data page --- docs/input/input-axis.mdx | 3 + docs/input/input-from-data.mdx | 150 +++++++++++++++++++++++++++++++++ docs/input/introduction.mdx | 4 +- 3 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/input/input-from-data.mdx 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-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 ef6ae5c5..c8c50cb2 100644 --- a/docs/input/introduction.mdx +++ b/docs/input/introduction.mdx @@ -6,7 +6,7 @@ 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. -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 @@ -34,7 +34,7 @@ klipp.whileOnScreen(camera, () => { 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). From 0faec86e089e66ef6c49da84b039c4180d47cea5 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:09:07 +0200 Subject: [PATCH 18/26] docs: add input from data example --- examples/src/registry/examples.ts | 17 ++ examples/src/scenes/input/InputFromData.tsx | 192 ++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 examples/src/scenes/input/InputFromData.tsx 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/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' && ( + + )} + + + + + + + ); +} From 0b1e638686846e42371934ae7bfe0b3fb86a0fb8 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:17:12 +0200 Subject: [PATCH 19/26] refactor: tidy input sources and docs after review --- docs/aim/pan-tilt.mdx | 9 +++++++ docs/input/input-controller.mdx | 6 ++--- docs/input/introduction.mdx | 8 +++--- src/core/input/InputController.ts | 4 +-- src/core/input/consumedInput.ts | 1 + src/core/input/inputMapping.ts | 2 +- src/dom/InputControllerDom.ts | 2 +- src/dom/InputSystem.ts | 9 ++----- src/dom/sources/isInsideInteractiveArea.ts | 2 +- src/dom/sources/mouse.ts | 29 ++++++++++++---------- src/dom/sources/safariGesture.ts | 21 ++++++++-------- src/dom/sources/touch.ts | 20 +++++++-------- src/dom/sources/wheel.ts | 2 +- src/react/input/InputAxisOwnerContext.ts | 2 +- 14 files changed, 63 insertions(+), 54 deletions(-) diff --git a/docs/aim/pan-tilt.mdx b/docs/aim/pan-tilt.mdx index e19eadc9..a615ed57 100644 --- a/docs/aim/pan-tilt.mdx +++ b/docs/aim/pan-tilt.mdx @@ -57,9 +57,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). + @@ -88,9 +91,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 +124,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-controller.mdx b/docs/input/input-controller.mdx index db1d48ae..34ea3f06 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 `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. +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 @@ -17,7 +17,7 @@ Maps mouse buttons and finger counts onto axes. On the core and in three.js it's | `trackpadPinch` | What a trackpad pinch drives, 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 piece, or the camera | diff --git a/docs/input/introduction.mdx b/docs/input/introduction.mdx index c8c50cb2..56b1e645 100644 --- a/docs/input/introduction.mdx +++ b/docs/input/introduction.mdx @@ -1,10 +1,10 @@ --- 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, see [Input from data](/input/input-from-data). @@ -18,7 +18,7 @@ Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, -`InputControllerDom` 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 { InputControllerDom } from '@kvvasuu/klipp/dom'; @@ -39,7 +39,7 @@ Without a DOM, feed the axes from any source, see [Input from data](/input/input -`InputControllerDom` 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 { InputControllerDom } from '@kvvasuu/klipp/dom'; diff --git a/src/core/input/InputController.ts b/src/core/input/InputController.ts index cc1c9f12..8fd0685f 100644 --- a/src/core/input/InputController.ts +++ b/src/core/input/InputController.ts @@ -3,7 +3,7 @@ import * as inputMapping from './inputMapping'; import type { ConsumedInput } from './consumedInput'; import type { InputControllerConfig } from './inputMapping'; -/** Maps per-frame input onto named axis pairs. Layers override `readInput`. */ +/** Maps per-frame input onto axes. Layers override `readInput`. */ export class InputController { config: InputControllerConfig; /** Whether input deltas are applied to the configured axes. */ @@ -15,7 +15,7 @@ export class InputController { this.config = config; } - /** Feeds every configured source's shaped delta and hold state into its axis pair. */ + /** Feeds every configured source's shaped delta and hold state into its axes. */ update = (): void => { inputMapping.feedAxes(this.config, this.readInput(), this.enabled); }; diff --git a/src/core/input/consumedInput.ts b/src/core/input/consumedInput.ts index a3cf97cb..02014da7 100644 --- a/src/core/input/consumedInput.ts +++ b/src/core/input/consumedInput.ts @@ -21,6 +21,7 @@ export type ConsumedInput = { touchRotateDelta: number; /** Safari trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ gestureZoomDelta: number; + /** Wheel scrolling in pixels, as the browser reports it: right and down are positive. */ wheelDeltaX: number; wheelDeltaY: number; /** Trackpad pinch reported as a `ctrlKey` wheel, whose `deltaY` is `-100 * ln(scale)`. */ diff --git a/src/core/input/inputMapping.ts b/src/core/input/inputMapping.ts index a90ae043..dbc43f69 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -72,7 +72,7 @@ function applyHeld(mapping: InputSourceMapping | null, held: boolean): void { mapping.axes.y.held = true; } -/** Feeds every configured source's shaped delta and hold state into its axis pair. */ +/** 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. diff --git a/src/dom/InputControllerDom.ts b/src/dom/InputControllerDom.ts index f69fcf21..5de5968a 100644 --- a/src/dom/InputControllerDom.ts +++ b/src/dom/InputControllerDom.ts @@ -3,7 +3,7 @@ import { InputController } from '../core/input/InputController'; import { InputSystem } from './InputSystem'; -/** `InputController` reading mouse, touch and wheel input from a DOM element. */ +/** `InputController` reading mouse, touch, wheel and gesture input from a DOM element. */ export class InputControllerDom extends InputController { readonly inputSystem = new InputSystem(); diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index 939bddc8..a40e13c5 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -7,12 +7,7 @@ import * as wheel from './sources/wheel'; import { isInsideInteractiveArea, type InteractiveArea } from './sources/isInsideInteractiveArea'; export type { InteractiveArea } from './sources/isInsideInteractiveArea'; - -export const MouseButton = { - left: 1, - right: 2, - middle: 4, -} as const; +export { MouseButton } from './sources/mouse'; /** Mouse, touch, wheel and Safari gesture input from a DOM element, read once per frame. */ export class InputSystem { @@ -128,7 +123,7 @@ export class InputSystem { return out; }; - /** Whether `clientX/Y` falls within `interactiveArea`. A locked pointer counts as inside, since its position is frozen. */ + /** Whether `clientX/Y` falls within `interactiveArea`. A locked pointer counts as inside: its position is frozen. */ isInsideInteractiveArea(clientX: number, clientY: number): boolean { if (!this.element) return true; return isInsideInteractiveArea(this.element, this._interactiveArea, clientX, clientY); diff --git a/src/dom/sources/isInsideInteractiveArea.ts b/src/dom/sources/isInsideInteractiveArea.ts index 3f4bac86..e4ce3a37 100644 --- a/src/dom/sources/isInsideInteractiveArea.ts +++ b/src/dom/sources/isInsideInteractiveArea.ts @@ -6,7 +6,7 @@ export type InteractiveArea = { height: number; }; -/** Whether `clientX/Y` falls within `area` of `element`. A locked pointer counts as inside, since its position is frozen. */ +/** 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, diff --git a/src/dom/sources/mouse.ts b/src/dom/sources/mouse.ts index a03fc533..5709bca7 100644 --- a/src/dom/sources/mouse.ts +++ b/src/dom/sources/mouse.ts @@ -5,14 +5,17 @@ import type { ButtonInput } from './buttonInput'; import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; import { suppressNativeGestures } from './suppressNativeGestures'; -export type MouseButton = 'left' | 'middle' | 'right'; +/** `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; + readonly buttons: ButtonInput; /** Movement this frame while each button was held, in pixels. */ - readonly drag: Record; + 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. */ @@ -26,9 +29,9 @@ export type MouseState = { }; type MousePending = { - drag: Record; + drag: Record; lockedMovement: Vec2; - /** Button changes since the last update, in order, as `bit | (down ? DOWN : 0)`. */ + /** 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. */ @@ -41,8 +44,7 @@ type MousePending = { }; const DOWN = 8; -const BITS = [1, 2, 4] as const; -const bits: Record = { 1: 'left', 2: 'right', 4: 'middle' }; +const buttonNames: readonly MouseButtonName[] = ['left', 'right', 'middle']; export const create = (): MouseState => ({ buttons: buttonInput.create(), @@ -66,17 +68,18 @@ export const create = (): MouseState => ({ /** Queues a press or release for every button that differs between the last event and `held`. */ function setHeld(pending: MousePending, held: number): void { - for (const bit of BITS) { + for (let i = 0; i < buttonNames.length; i++) { + const bit = MouseButton[buttonNames[i]]; if ((pending.held & bit) === (held & bit)) continue; - pending.changes[pending.changeCount++] = bit | (held & bit ? DOWN : 0); + 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 bit of BITS) { - if ((buttons & bit) === 0) continue; - const drag = pending.drag[bits[bit]]; + for (const name of buttonNames) { + if ((buttons & MouseButton[name]) === 0) continue; + const drag = pending.drag[name]; drag[0] += dx; drag[1] += dy; } @@ -201,7 +204,7 @@ export function update(state: MouseState): void { buttonInput.clear(state.buttons); for (let i = 0; i < pending.changeCount; i++) { const change = pending.changes[i]; - const button = bits[change & ~DOWN]; + const button = buttonNames[change & ~DOWN]; if (change & DOWN) buttonInput.press(state.buttons, button); else buttonInput.release(state.buttons, button); } diff --git a/src/dom/sources/safariGesture.ts b/src/dom/sources/safariGesture.ts index 680917dc..b3d6bb68 100644 --- a/src/dom/sources/safariGesture.ts +++ b/src/dom/sources/safariGesture.ts @@ -3,9 +3,9 @@ import { degreesToRadians } from 'math'; import { isInsideInteractiveArea, type InteractiveArea } from './isInsideInteractiveArea'; import { dominantGesture } from './touch'; -/** Safari's trackpad pinch and rotate, summed per frame. Gestures made with fingers on a touch screen are left to `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, and steps add up the same at any zoom. */ + /** Pinch this frame, as the change in `ln(scale)`. Spreading is positive. */ logScaleDelta: number; /** Rotation this frame, in radians. */ twistDelta: number; @@ -53,6 +53,15 @@ export const create = (): SafariGestureState => ({ 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; @@ -102,14 +111,6 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput onInput?.(); }; - function end(): void { - pending.active = false; - } - const liftAll = (): void => { - end(); - pending.touchIds.fill(-1); - pending.touchCount = 0; - }; const onVisibilityChange = (): void => { if (document.hidden) liftAll(); }; diff --git a/src/dom/sources/touch.ts b/src/dom/sources/touch.ts index fbc133d2..c73bea35 100644 --- a/src/dom/sources/touch.ts +++ b/src/dom/sources/touch.ts @@ -30,7 +30,7 @@ type TouchPending = { pinchDelta: number; twistDelta: number; /** The first `count` are down, in the order they touched. */ - fingers: [Finger, Finger, Finger]; + slots: [Finger, Finger, Finger]; count: number; /** Two-finger gesture, measured from when the current pair formed. */ distance: number; @@ -60,7 +60,7 @@ export const create = (): TouchState => ({ drag: { one: [0, 0], two: [0, 0], three: [0, 0] }, pinchDelta: 0, twistDelta: 0, - fingers: [ + slots: [ { id: 0, x: 0, y: 0 }, { id: 0, x: 0, y: 0 }, { id: 0, x: 0, y: 0 }, @@ -75,14 +75,14 @@ export const create = (): TouchState => ({ }); function indexOf(pending: TouchPending, id: number): number { - for (let i = 0; i < pending.count; i++) if (pending.fingers[i].id === id) return i; + 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.fingers; + 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; @@ -101,7 +101,7 @@ 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.fingers; + const [a, b] = pending.slots; const distance = Math.hypot(b.x - a.x, b.y - a.y); const pinchStep = distance - pending.distance; pending.distance = distance; @@ -129,7 +129,7 @@ export function connect(state: TouchState, element: HTMLElement, onInput?: () => 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.fingers[pending.count++]; + const finger = pending.slots[pending.count++]; finger.id = event.pointerId; finger.x = event.clientX; finger.y = event.clientY; @@ -144,7 +144,7 @@ export function connect(state: TouchState, element: HTMLElement, onInput?: () => if (event.pointerType !== 'touch') return; const index = indexOf(pending, event.pointerId); if (index === -1) return; - const moving = pending.fingers[index]; + 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); @@ -156,9 +156,9 @@ export function connect(state: TouchState, element: HTMLElement, onInput?: () => 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.fingers[index]; - for (let i = index; i < pending.count - 1; i++) pending.fingers[i] = pending.fingers[i + 1]; - pending.fingers[--pending.count] = lifted; + 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?.(); }; diff --git a/src/dom/sources/wheel.ts b/src/dom/sources/wheel.ts index ec0d9cb7..d2a9a9a5 100644 --- a/src/dom/sources/wheel.ts +++ b/src/dom/sources/wheel.ts @@ -5,7 +5,7 @@ export type WheelState = { /** Scroll since the last `update`. */ deltaX: number; deltaY: number; - /** Trackpad pinch since the last `update`, which browsers send as a `ctrlKey` wheel with `deltaY = -100 * ln(scale)`. */ + /** Trackpad pinch since the last `update`. Browsers send it as a `ctrlKey` wheel with `deltaY = -100 * ln(scale)`. */ zoomDelta: number; /** Only scrolls that start inside this normalized region count. */ interactiveArea: InteractiveArea | null; diff --git a/src/react/input/InputAxisOwnerContext.ts b/src/react/input/InputAxisOwnerContext.ts index a31e3ee3..2c10a09e 100644 --- a/src/react/input/InputAxisOwnerContext.ts +++ b/src/react/input/InputAxisOwnerContext.ts @@ -2,5 +2,5 @@ import { createContext } from 'react'; import type { InputAxisOwner } from '../../core/input/InputAxisOwner'; -/** 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); From 8b3a9fed8050b685e34474543f506a9804cf1f85 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:20:14 +0200 Subject: [PATCH 20/26] fix: leave page scroll and zoom alone unless wheel or trackpad pinch is mapped --- docs/input/input-controller.mdx | 8 +++++--- src/dom/InputControllerDom.ts | 15 ++++++++++++++- src/dom/sources/safariGesture.ts | 7 +++++-- src/dom/sources/wheel.ts | 10 ++++++++-- test/dom/InputControllerDom.test.ts | 23 +++++++++++++++++++++++ test/dom/sources/safariGesture.test.ts | 9 +++++++++ test/dom/sources/wheel.test.ts | 13 +++++++++++++ 7 files changed, 77 insertions(+), 8 deletions(-) diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index 34ea3f06..f887ddb7 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -48,6 +48,8 @@ Each button or finger count takes the same config: They come in different units, so each has its own `gain`. A trackpad pinch adds up to the same amount however far you are zoomed in, which suits a zoom stored as `ln` of its scale. +The page keeps scrolling over the canvas until `wheel` is mapped, and a trackpad pinch keeps zooming the page until `trackpadPinch` 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`. @@ -96,7 +98,7 @@ controller.inputSystem.exitPointerLock(); ```ts klipp.registerUpdate(() => { controller.update(); - zoom += controller.input.wheelDeltaY * 0.01; + roll += controller.input.touchRotateDelta; }); ``` @@ -133,7 +135,7 @@ controller.inputSystem.exitPointerLock(); ```ts klipp.registerUpdate(() => { controller.update(); - zoom += controller.input.wheelDeltaY * 0.01; + roll += controller.input.touchRotateDelta; }); ``` @@ -168,7 +170,7 @@ controller.current.inputSystem.exitPointerLock(); ```tsx useFrame(() => { - zoom += controller.current.input.wheelDeltaY * 0.01; + roll += controller.current.input.touchRotateDelta; }); ``` diff --git a/src/dom/InputControllerDom.ts b/src/dom/InputControllerDom.ts index 5de5968a..fd76e879 100644 --- a/src/dom/InputControllerDom.ts +++ b/src/dom/InputControllerDom.ts @@ -3,12 +3,16 @@ import { InputController } from '../core/input/InputController'; import { InputSystem } from './InputSystem'; -/** `InputController` reading mouse, touch, wheel and gesture input from a DOM element. */ +/** + * `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 `trackpadPinch` 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); }; @@ -17,6 +21,15 @@ export class InputControllerDom extends InputController { }; protected override readInput(): ConsumedInput { + this.claimPageGestures(); return this.inputSystem.consume(this.input); } + + private claimPageGestures(): void { + const { wheel, safariGesture } = this.inputSystem; + const zoom = !!this.config.trackpadPinch; + wheel.preventPageScroll = !!this.config.wheel; + wheel.preventPageZoom = zoom; + safariGesture.preventPageZoom = zoom; + } } diff --git a/src/dom/sources/safariGesture.ts b/src/dom/sources/safariGesture.ts index b3d6bb68..0e9b5924 100644 --- a/src/dom/sources/safariGesture.ts +++ b/src/dom/sources/safariGesture.ts @@ -13,6 +13,8 @@ export type SafariGestureState = { 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: { logScaleDelta: number; @@ -36,6 +38,7 @@ export const create = (): SafariGestureState => ({ twistDelta: 0, interactiveArea: null, lockTouchAxis: false, + preventPageZoom: true, pending: { logScaleDelta: 0, twistDelta: 0, @@ -78,7 +81,7 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput const onGestureStart = (event: Event): void => { const gesture = event as GestureEvent; if (!isInsideInteractiveArea(element, state.interactiveArea, gesture.clientX, gesture.clientY)) return; - if (event.cancelable) event.preventDefault(); + if (event.cancelable && state.preventPageZoom) event.preventDefault(); if (pending.touchCount > 0) return; pending.active = true; pending.scale = gesture.scale; @@ -90,7 +93,7 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput const onGestureChange = (event: Event): void => { if (!pending.active) return; const gesture = event as GestureEvent; - if (event.cancelable) event.preventDefault(); + if (event.cancelable && state.preventPageZoom) event.preventDefault(); // A finger may land after the gesture started, depending on event order. if (pending.touchCount > 0) { end(); diff --git a/src/dom/sources/wheel.ts b/src/dom/sources/wheel.ts index d2a9a9a5..293ac1e7 100644 --- a/src/dom/sources/wheel.ts +++ b/src/dom/sources/wheel.ts @@ -9,6 +9,10 @@ export type WheelState = { zoomDelta: 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. */ @@ -24,6 +28,8 @@ export const create = (): WheelState => ({ deltaY: 0, zoomDelta: 0, interactiveArea: null, + preventPageScroll: true, + preventPageZoom: true, pixelsPerLine: 33, pixelsPerPage: 800, pendingX: 0, @@ -38,11 +44,11 @@ const pixelsPerUnit = (state: WheelState, deltaMode: number): number => ? state.pixelsPerPage : 1; -/** Listens to `element`'s wheel and keeps the page from scrolling under it. Returns a function that stops. */ +/** 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; - event.preventDefault(); + if (event.ctrlKey ? state.preventPageZoom : state.preventPageScroll) event.preventDefault(); const scale = pixelsPerUnit(state, event.deltaMode); if (event.ctrlKey) { state.pendingZoom += event.deltaY * scale; diff --git a/test/dom/InputControllerDom.test.ts b/test/dom/InputControllerDom.test.ts index df5a9b08..924f6cfe 100644 --- a/test/dom/InputControllerDom.test.ts +++ b/test/dom/InputControllerDom.test.ts @@ -241,4 +241,27 @@ describe('InputControllerDom', () => { expect(onInput).toHaveBeenCalledTimes(4); el.remove(); }); + + it('leaves scrolling to the page unless wheel is mapped, and pinch zoom unless trackpadPinch 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.trackpadPinch = { axis: new InputAxis() }; + controller.update(); + expect([blocked({}), blocked({ ctrlKey: true })]).toEqual([true, true]); + el.remove(); + }); }); diff --git a/test/dom/sources/safariGesture.test.ts b/test/dom/sources/safariGesture.test.ts index ca578c1a..b975d0b1 100644 --- a/test/dom/sources/safariGesture.test.ts +++ b/test/dom/sources/safariGesture.test.ts @@ -120,4 +120,13 @@ describe('safariGesture', () => { safariGesture.update(state); expect(state.logScaleDelta).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.logScaleDelta).toBeCloseTo(Math.log(1.5), 5); + }); }); diff --git a/test/dom/sources/wheel.test.ts b/test/dom/sources/wheel.test.ts index fe7326bd..21ab033c 100644 --- a/test/dom/sources/wheel.test.ts +++ b/test/dom/sources/wheel.test.ts @@ -100,4 +100,17 @@ describe('wheel', () => { 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.zoomDelta).toBe(10); + }); }); From d1f95c95b58a3254e1113bf7f99a363fb32225bd Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:28:14 +0200 Subject: [PATCH 21/26] refactor: move input sources into their own folder --- .oxlintrc.json | 33 ++++++++++++++++--- src/dom/InputSystem.ts | 16 ++++----- .../sources => input-sources}/buttonInput.ts | 0 .../isInsideInteractiveArea.ts | 0 src/{dom/sources => input-sources}/mouse.ts | 0 .../safariGesture.ts | 0 .../suppressNativeGestures.ts | 0 src/{dom/sources => input-sources}/touch.ts | 0 src/{dom/sources => input-sources}/wheel.ts | 0 .../buttonInput.test.ts | 2 +- .../sources => input-sources}/mouse.test.ts | 2 +- .../safariGesture.test.ts | 2 +- .../suppressNativeGestures.test.ts | 2 +- .../sources => input-sources}/touch.test.ts | 2 +- .../sources => input-sources}/wheel.test.ts | 2 +- 15 files changed, 43 insertions(+), 18 deletions(-) rename src/{dom/sources => input-sources}/buttonInput.ts (100%) rename src/{dom/sources => input-sources}/isInsideInteractiveArea.ts (100%) rename src/{dom/sources => input-sources}/mouse.ts (100%) rename src/{dom/sources => input-sources}/safariGesture.ts (100%) rename src/{dom/sources => input-sources}/suppressNativeGestures.ts (100%) rename src/{dom/sources => input-sources}/touch.ts (100%) rename src/{dom/sources => input-sources}/wheel.ts (100%) rename test/{dom/sources => input-sources}/buttonInput.test.ts (96%) rename test/{dom/sources => input-sources}/mouse.test.ts (99%) rename test/{dom/sources => input-sources}/safariGesture.test.ts (98%) rename test/{dom/sources => input-sources}/suppressNativeGestures.test.ts (93%) rename test/{dom/sources => input-sources}/touch.test.ts (99%) rename test/{dom/sources => input-sources}/wheel.test.ts (98%) diff --git a/.oxlintrc.json b/.oxlintrc.json index e3a4b48f..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": { @@ -84,7 +109,7 @@ } }, { - "files": ["src/dom/sources/**"], + "files": ["src/input-sources/**"], "rules": { "no-restricted-imports": [ "error", @@ -101,7 +126,7 @@ "@react-three/*", "camera-controls" ], - "message": "sources import only each other and math." + "message": "input-sources import only each other and math." } ] } diff --git a/src/dom/InputSystem.ts b/src/dom/InputSystem.ts index a40e13c5..ff219a05 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -1,13 +1,13 @@ import type { ConsumedInput } from '../core/input/consumedInput'; -import * as mouse from './sources/mouse'; -import * as safariGesture from './sources/safariGesture'; -import * as touch from './sources/touch'; -import * as wheel from './sources/wheel'; -import { isInsideInteractiveArea, type InteractiveArea } from './sources/isInsideInteractiveArea'; - -export type { InteractiveArea } from './sources/isInsideInteractiveArea'; -export { MouseButton } from './sources/mouse'; +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'; + +export type { InteractiveArea } from '../input-sources/isInsideInteractiveArea'; +export { MouseButton } from '../input-sources/mouse'; /** Mouse, touch, wheel and Safari gesture input from a DOM element, read once per frame. */ export class InputSystem { diff --git a/src/dom/sources/buttonInput.ts b/src/input-sources/buttonInput.ts similarity index 100% rename from src/dom/sources/buttonInput.ts rename to src/input-sources/buttonInput.ts diff --git a/src/dom/sources/isInsideInteractiveArea.ts b/src/input-sources/isInsideInteractiveArea.ts similarity index 100% rename from src/dom/sources/isInsideInteractiveArea.ts rename to src/input-sources/isInsideInteractiveArea.ts diff --git a/src/dom/sources/mouse.ts b/src/input-sources/mouse.ts similarity index 100% rename from src/dom/sources/mouse.ts rename to src/input-sources/mouse.ts diff --git a/src/dom/sources/safariGesture.ts b/src/input-sources/safariGesture.ts similarity index 100% rename from src/dom/sources/safariGesture.ts rename to src/input-sources/safariGesture.ts diff --git a/src/dom/sources/suppressNativeGestures.ts b/src/input-sources/suppressNativeGestures.ts similarity index 100% rename from src/dom/sources/suppressNativeGestures.ts rename to src/input-sources/suppressNativeGestures.ts diff --git a/src/dom/sources/touch.ts b/src/input-sources/touch.ts similarity index 100% rename from src/dom/sources/touch.ts rename to src/input-sources/touch.ts diff --git a/src/dom/sources/wheel.ts b/src/input-sources/wheel.ts similarity index 100% rename from src/dom/sources/wheel.ts rename to src/input-sources/wheel.ts diff --git a/test/dom/sources/buttonInput.test.ts b/test/input-sources/buttonInput.test.ts similarity index 96% rename from test/dom/sources/buttonInput.test.ts rename to test/input-sources/buttonInput.test.ts index a1b541ea..6d38d10a 100644 --- a/test/dom/sources/buttonInput.test.ts +++ b/test/input-sources/buttonInput.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import * as buttonInput from '../../../src/dom/sources/buttonInput'; +import * as buttonInput from '../../src/input-sources/buttonInput'; const sorted = (set: Set): T[] => [...set].sort(); diff --git a/test/dom/sources/mouse.test.ts b/test/input-sources/mouse.test.ts similarity index 99% rename from test/dom/sources/mouse.test.ts rename to test/input-sources/mouse.test.ts index 291f3759..6f8d8ee5 100644 --- a/test/dom/sources/mouse.test.ts +++ b/test/input-sources/mouse.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as mouse from '../../../src/dom/sources/mouse'; +import * as mouse from '../../src/input-sources/mouse'; const LEFT = 1; const RIGHT = 2; diff --git a/test/dom/sources/safariGesture.test.ts b/test/input-sources/safariGesture.test.ts similarity index 98% rename from test/dom/sources/safariGesture.test.ts rename to test/input-sources/safariGesture.test.ts index b975d0b1..e44324c3 100644 --- a/test/dom/sources/safariGesture.test.ts +++ b/test/input-sources/safariGesture.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as safariGesture from '../../../src/dom/sources/safariGesture'; +import * as safariGesture from '../../src/input-sources/safariGesture'; const connections: (() => void)[] = []; diff --git a/test/dom/sources/suppressNativeGestures.test.ts b/test/input-sources/suppressNativeGestures.test.ts similarity index 93% rename from test/dom/sources/suppressNativeGestures.test.ts rename to test/input-sources/suppressNativeGestures.test.ts index a37ee6ba..f0125641 100644 --- a/test/dom/sources/suppressNativeGestures.test.ts +++ b/test/input-sources/suppressNativeGestures.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { suppressNativeGestures } from '../../../src/dom/sources/suppressNativeGestures'; +import { suppressNativeGestures } from '../../src/input-sources/suppressNativeGestures'; function element(touchAction = '', userSelect = ''): HTMLElement { const el = document.createElement('div'); diff --git a/test/dom/sources/touch.test.ts b/test/input-sources/touch.test.ts similarity index 99% rename from test/dom/sources/touch.test.ts rename to test/input-sources/touch.test.ts index d07d8b7e..e6e333e2 100644 --- a/test/dom/sources/touch.test.ts +++ b/test/input-sources/touch.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as touch from '../../../src/dom/sources/touch'; +import * as touch from '../../src/input-sources/touch'; const connections: (() => void)[] = []; diff --git a/test/dom/sources/wheel.test.ts b/test/input-sources/wheel.test.ts similarity index 98% rename from test/dom/sources/wheel.test.ts rename to test/input-sources/wheel.test.ts index 21ab033c..6cc2f4c1 100644 --- a/test/dom/sources/wheel.test.ts +++ b/test/input-sources/wheel.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as wheel from '../../../src/dom/sources/wheel'; +import * as wheel from '../../src/input-sources/wheel'; function setup() { const element = document.createElement('div'); From c3425ff57a16a597204b914542b22634ae7d08c4 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:43:14 +0200 Subject: [PATCH 22/26] fix: skip clearing empty button sets, which allocated every frame --- src/input-sources/buttonInput.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/input-sources/buttonInput.ts b/src/input-sources/buttonInput.ts index 04610c6b..fc81d7fa 100644 --- a/src/input-sources/buttonInput.ts +++ b/src/input-sources/buttonInput.ts @@ -25,12 +25,14 @@ export function release(state: ButtonInput, button: T): void { /** 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 { - state.justPressed.clear(); - state.justReleased.clear(); + // 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(); } From d8563c54e0030b416a3f0bc8f11542b0a524d0ef Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 22:43:18 +0200 Subject: [PATCH 23/26] test: rewrite input benches around a fake DOM --- benches/README.md | 7 +- benches/dom/input.bench.ts | 136 ++++++++++--------------- benches/fakeDom.ts | 49 +++++++++ benches/input-sources/sources.bench.ts | 93 +++++++++++++++++ benches/warm.ts | 8 ++ 5 files changed, 209 insertions(+), 84 deletions(-) create mode 100644 benches/fakeDom.ts create mode 100644 benches/input-sources/sources.bench.ts create mode 100644 benches/warm.ts 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/dom/input.bench.ts b/benches/dom/input.bench.ts index 821c5b63..cc5fc0ae 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, + trackpadPinch: 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 trackpad 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..5699cc5d --- /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.logScaleDelta; + }); + }); +}); 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; +} From 61bea994ed3b67c3bf3c6b7e2bf5e329cc033754 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 23:09:52 +0200 Subject: [PATCH 24/26] test: warm up every bench before labs decides on batching --- benches/core/aim.bench.ts | 17 +++++++++-------- benches/core/blend.bench.ts | 8 +++++--- benches/core/body.bench.ts | 29 +++++++++++++++-------------- benches/core/impulse.bench.ts | 26 ++++++++++++++------------ benches/core/klipp.bench.ts | 15 ++++++++------- benches/core/noise.bench.ts | 10 ++++++---- benches/scenes/cameras.bench.ts | 18 ++++++++++-------- benches/three/targets.bench.ts | 5 +++-- 8 files changed, 70 insertions(+), 58 deletions(-) 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/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', () => { From cd82622887e4ccc7a69136048465ba2b70ae65dc Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 23:43:16 +0200 Subject: [PATCH 25/26] feat: let input configs list only the sources they use --- docs/aim/pan-tilt.mdx | 10 ++-- docs/input/input-controller.mdx | 16 +++--- docs/input/introduction.mdx | 10 ++-- src/core/input/inputMapping.ts | 65 ++++++++++++----------- src/react/input/InputController.tsx | 7 +-- test/dom/InputControllerDom.test.ts | 19 +++++-- test/react/input/InputController.test.tsx | 16 +++--- 7 files changed, 80 insertions(+), 63 deletions(-) diff --git a/docs/aim/pan-tilt.mdx b/docs/aim/pan-tilt.mdx index a615ed57..7af8936e 100644 --- a/docs/aim/pan-tilt.mdx +++ b/docs/aim/pan-tilt.mdx @@ -40,9 +40,10 @@ const aim = new PanTiltAim(); aim.tilt.range = [-60, 60]; camera.aim = aim; +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { left: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { controller.connect(canvas); @@ -74,9 +75,10 @@ const aim = new PanTiltAimThree(); aim.tilt.range = [-60, 60]; camera.aim = aim; +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { left: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { controller.connect(renderer.domElement); diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index f887ddb7..8e07d7f2 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -24,7 +24,7 @@ Maps mouse buttons, finger counts, the wheel and pinches onto axes. On the core ## 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 { @@ -71,9 +71,10 @@ For mouse look without holding a button, request pointer lock on the controller' ```ts import { InputControllerDom } from '@kvvasuu/klipp/dom'; +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { right: look }, + touches: { one: look }, }); controller.inputSystem.suppressContextMenu = true; klipp.whileOnScreen(camera, () => { @@ -83,7 +84,7 @@ klipp.whileOnScreen(camera, () => { 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. `controller.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** @@ -108,9 +109,10 @@ klipp.registerUpdate(() => { ```ts import { InputControllerDom } from '@kvvasuu/klipp/dom'; +const look = { axes: { x: aim.pan, y: aim.tilt }, gain: 0.15 }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { right: look }, + touches: { one: look }, }); controller.inputSystem.suppressContextMenu = true; klipp.whileOnScreen(camera, () => { @@ -120,7 +122,7 @@ klipp.whileOnScreen(camera, () => { 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. `controller.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** diff --git a/docs/input/introduction.mdx b/docs/input/introduction.mdx index 56b1e645..061d0c54 100644 --- a/docs/input/introduction.mdx +++ b/docs/input/introduction.mdx @@ -23,9 +23,10 @@ Each axis is an [`InputAxis`](/input/input-axis), a value with limits, wrapping, ```ts import { InputControllerDom } from '@kvvasuu/klipp/dom'; +const look = { axes: { x: aim.pan, y: aim.tilt } }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { right: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { controller.connect(canvas); @@ -44,9 +45,10 @@ Without a DOM, feed the axes from any source, see [Input from data](/input/input ```ts import { InputControllerDom } from '@kvvasuu/klipp/dom'; +const look = { axes: { x: aim.pan, y: aim.tilt } }; const controller = new InputControllerDom({ - 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 }, + mouseButtons: { right: look }, + touches: { one: look }, }); klipp.whileOnScreen(camera, () => { controller.connect(renderer.domElement); diff --git a/src/core/input/inputMapping.ts b/src/core/input/inputMapping.ts index dbc43f69..90a61f26 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -26,16 +26,19 @@ export type InputAxisMapping = { invert?: boolean; }; +/** Which axes each input drives. Leave a source out, or set it to `null`, to ignore it. */ export type InputControllerConfig = { - mouseButtons: { - left: InputSourceMapping | null; - right: InputSourceMapping | null; - middle: InputSourceMapping | null; + /** 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; @@ -48,7 +51,9 @@ export type InputControllerConfig = { 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)); @@ -60,13 +65,13 @@ function applyAxis(mapping: InputAxisMapping | null | undefined, delta: number): inputAxis.applyDelta(mapping.axis, delta * (mapping.gain ?? 1) * (mapping.invert ? -1 : 1)); } -function resetHeld(mapping: InputSourceMapping | null): void { +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; @@ -76,27 +81,27 @@ function applyHeld(mapping: InputSourceMapping | null, held: boolean): void { 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.touchPinch) config.touchPinch.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.touchPinch, input.touchPinchDelta); // Both arrive as ln(scale): Safari's gesture directly, other browsers as a ctrlKey wheel's -100 * ln(scale). diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index 26180919..91a94fac 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -111,11 +111,6 @@ type Binding = { stale: boolean; }; -const emptyConfig: InputControllerConfig = { - mouseButtons: { left: null, right: null, middle: null }, - touches: { one: null, two: null, three: null }, -}; - /** Connects DOM input sources to named axes on a camera component. */ export function InputController(props: InputControllerProps) { const { @@ -135,7 +130,7 @@ export function InputController(props: InputControllerProps) { const domElement = useThree((state) => state.gl.domElement); const invalidate = useThree((state) => state.invalidate); - const [controller] = useState(() => new InputControllerDom(emptyConfig)); + const [controller] = useState(() => new InputControllerDom({})); const [binding] = useState(() => ({ props, contextOwner, owner: null, axes: null, stale: true })); useImperativeHandle(ref, () => controller, [controller]); diff --git a/test/dom/InputControllerDom.test.ts b/test/dom/InputControllerDom.test.ts index 924f6cfe..15ebb630 100644 --- a/test/dom/InputControllerDom.test.ts +++ b/test/dom/InputControllerDom.test.ts @@ -156,8 +156,8 @@ describe('InputControllerDom', () => { }); it("feeds buttonless movement under Pointer Lock through mouseButtons.left's mapping", () => { - const lockedMove = (left: InputControllerConfig['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( @@ -176,11 +176,22 @@ describe('InputControllerDom', () => { 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', () => { diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index dc8048b8..5e03241c 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -48,12 +48,12 @@ 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 () => { @@ -79,8 +79,8 @@ 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 () => { @@ -105,7 +105,7 @@ 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(); }); @@ -285,13 +285,13 @@ describe('InputController (React wrapper)', () => { 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 }); + 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); + expect(controller!.config.mouseButtons?.left?.axes.x).toBe(aim!.pan); }); it('resolves the single-axis sources by name too', async () => { From 503bac6d417e59e323484e4ecc3d6454309fea92 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Wed, 7 Oct 2026 23:43:24 +0200 Subject: [PATCH 26/26] feat!: merge touch and trackpad pinch into one pinch source --- benches/dom/input.bench.ts | 4 +- benches/input-sources/sources.bench.ts | 2 +- docs/input/input-controller.mdx | 11 ++--- src/core/input/consumedInput.ts | 12 ++--- src/core/input/inputMapping.ts | 14 ++---- src/dom/InputControllerDom.ts | 4 +- src/dom/InputSystem.ts | 4 +- src/input-sources/safariGesture.ts | 16 +++---- src/input-sources/touch.ts | 4 +- src/input-sources/wheel.ts | 17 +++---- src/react/input/InputController.tsx | 9 ++-- test/core/input/InputController.test.ts | 19 ++------ test/dom/InputControllerDom.test.ts | 4 +- test/dom/InputSystem.test.ts | 58 ++++++++++++++--------- test/input-sources/safariGesture.test.ts | 20 ++++---- test/input-sources/touch.test.ts | 8 ++-- test/input-sources/wheel.test.ts | 6 +-- test/react/input/InputController.test.tsx | 9 ++-- 18 files changed, 103 insertions(+), 118 deletions(-) diff --git a/benches/dom/input.bench.ts b/benches/dom/input.bench.ts index cc5fc0ae..988b6003 100644 --- a/benches/dom/input.bench.ts +++ b/benches/dom/input.bench.ts @@ -18,7 +18,7 @@ function setup({ mapZoom = false } = {}) { mouseButtons: { left: look, right: null, middle: null }, touches: { one: look, two: null, three: null }, wheel: mapZoom ? { axis: zoom, gain: 0.001 } : null, - trackpadPinch: mapZoom ? { axis: zoom } : null, + pinch: mapZoom ? { axis: zoom } : null, }); controller.connect(element); const frame = () => { @@ -49,7 +49,7 @@ group('InputControllerDom frame @input', () => { }); }); - bench('drag, wheel and trackpad pinch mapped', function* () { + 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 }; diff --git a/benches/input-sources/sources.bench.ts b/benches/input-sources/sources.bench.ts index 5699cc5d..967b8417 100644 --- a/benches/input-sources/sources.bench.ts +++ b/benches/input-sources/sources.bench.ts @@ -87,7 +87,7 @@ group('Input sources @input', () => { event.scale = 1 + Math.sin(t) * 0.5; element.dispatch('gesturechange', event); safariGesture.update(state); - return state.logScaleDelta; + return state.pinchDelta; }); }); }); diff --git a/docs/input/input-controller.mdx b/docs/input/input-controller.mdx index 8e07d7f2..e92a8604 100644 --- a/docs/input/input-controller.mdx +++ b/docs/input/input-controller.mdx @@ -13,8 +13,7 @@ Maps mouse buttons, finger counts, the wheel and pinches onto axes. On the core | `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. | | -| `touchPinch` | What a two-finger pinch on a touch screen drives, in pixels. Spreading is positive. | | -| `trackpadPinch` | What a trackpad pinch drives, as the change in `ln(scale)`. Spreading is positive. | | +| `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 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 | @@ -36,7 +35,7 @@ List only the sources you use. Each button and finger count takes the same confi ## Wheel and pinch -`wheel`, `touchPinch` and `trackpadPinch` each drive a single axis: +`wheel` and `pinch` each drive a single axis: ```ts { @@ -46,9 +45,9 @@ List only the sources you use. Each button and finger count takes the same confi } ``` -They come in different units, so each has its own `gain`. A trackpad pinch adds up to the same amount however far you are zoomed in, which suits a zoom stored as `ln` of its scale. +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 `trackpadPinch` is mapped. +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 @@ -56,7 +55,7 @@ For mouse look without holding a button, request pointer lock on the controller' ## Raw input -`input` 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 diff --git a/src/core/input/consumedInput.ts b/src/core/input/consumedInput.ts index 02014da7..ba693732 100644 --- a/src/core/input/consumedInput.ts +++ b/src/core/input/consumedInput.ts @@ -12,20 +12,16 @@ export type ConsumedInput = { /** Two-finger centroid movement. */ touchTwoDx: number; touchTwoDy: number; - /** Two-finger distance change, in pixels. Spreading is positive. */ - touchPinchDelta: number; /** Three-finger centroid movement. */ touchThreeDx: number; touchThreeDy: number; /** Two-finger twist in radians. */ touchRotateDelta: number; - /** Safari trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ - 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 reported as a `ctrlKey` wheel, whose `deltaY` is `-100 * ln(scale)`. */ - wheelZoomDelta: number; /** Raw mouse movement while Pointer Lock is active and no button is held. */ lockedDx: number; lockedDy: number; @@ -53,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/inputMapping.ts b/src/core/input/inputMapping.ts index 90a61f26..58f12bb5 100644 --- a/src/core/input/inputMapping.ts +++ b/src/core/input/inputMapping.ts @@ -42,10 +42,8 @@ export type InputControllerConfig = { }; /** Vertical wheel scrolling, in pixels. Scrolling up is positive, like spreading a pinch. */ wheel?: InputAxisMapping | null; - /** Change in the distance between two fingers, in pixels. Spreading is positive. */ - touchPinch?: InputAxisMapping | null; - /** Trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ - trackpadPinch?: 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 => @@ -87,7 +85,7 @@ export function feedAxes(config: InputControllerConfig, input: ConsumedInput, en resetHeld(touches?.one); resetHeld(touches?.two); resetHeld(touches?.three); - if (config.touchPinch) config.touchPinch.axis.held = false; + 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); @@ -103,8 +101,6 @@ export function feedAxes(config: InputControllerConfig, input: ConsumedInput, en applyHeld(touches?.two, input.touchTwoHeld); applyHeld(touches?.three, input.touchThreeHeld); applyAxis(config.wheel, -input.wheelDeltaY); - applyAxis(config.touchPinch, input.touchPinchDelta); - // Both arrive as ln(scale): Safari's gesture directly, other browsers as a ctrlKey wheel's -100 * ln(scale). - applyAxis(config.trackpadPinch, input.gestureZoomDelta - input.wheelZoomDelta / 100); - if (config.touchPinch && input.touchTwoHeld) config.touchPinch.axis.held = true; + applyAxis(config.pinch, input.pinchDelta); + if (config.pinch && input.touchTwoHeld) config.pinch.axis.held = true; } diff --git a/src/dom/InputControllerDom.ts b/src/dom/InputControllerDom.ts index fd76e879..94392992 100644 --- a/src/dom/InputControllerDom.ts +++ b/src/dom/InputControllerDom.ts @@ -5,7 +5,7 @@ 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 `trackpadPinch` is mapped. + * scrolling and pinch zoom over the element unless `wheel` or `pinch` is mapped. */ export class InputControllerDom extends InputController { readonly inputSystem = new InputSystem(); @@ -27,7 +27,7 @@ export class InputControllerDom extends InputController { private claimPageGestures(): void { const { wheel, safariGesture } = this.inputSystem; - const zoom = !!this.config.trackpadPinch; + 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 ff219a05..dde2f611 100644 --- a/src/dom/InputSystem.ts +++ b/src/dom/InputSystem.ts @@ -111,15 +111,13 @@ export class InputSystem { out.touchTwoDy = touchState.drag.two[1]; out.touchThreeDx = touchState.drag.three[0]; out.touchThreeDy = touchState.drag.three[1]; - out.touchPinchDelta = touchState.pinchDelta; 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.wheelZoomDelta = this.wheel.zoomDelta; - out.gestureZoomDelta = this.safariGesture.logScaleDelta; + out.pinchDelta = touchState.pinchDelta + this.wheel.pinchDelta + this.safariGesture.pinchDelta; return out; }; diff --git a/src/input-sources/safariGesture.ts b/src/input-sources/safariGesture.ts index 0e9b5924..d479dba8 100644 --- a/src/input-sources/safariGesture.ts +++ b/src/input-sources/safariGesture.ts @@ -6,7 +6,7 @@ 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. */ - logScaleDelta: number; + pinchDelta: number; /** Rotation this frame, in radians. */ twistDelta: number; /** Only gestures that start inside this normalized region count. */ @@ -17,7 +17,7 @@ export type SafariGestureState = { preventPageZoom: boolean; /** Internal. */ pending: { - logScaleDelta: number; + pinchDelta: number; twistDelta: number; active: boolean; scale: number; @@ -34,13 +34,13 @@ export type SafariGestureState = { type GestureEvent = Event & { scale: number; rotation: number; clientX: number; clientY: number }; export const create = (): SafariGestureState => ({ - logScaleDelta: 0, + pinchDelta: 0, twistDelta: 0, interactiveArea: null, lockTouchAxis: false, preventPageZoom: true, pending: { - logScaleDelta: 0, + pinchDelta: 0, twistDelta: 0, active: false, scale: 1, @@ -99,7 +99,7 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput end(); return; } - const logScaleStep = Math.log(gesture.scale / pending.scale); + const pinchStep = Math.log(gesture.scale / pending.scale); const rotationStep = gesture.rotation - pending.rotationDegrees; pending.scale = gesture.scale; pending.rotationDegrees = gesture.rotation; @@ -109,7 +109,7 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput if (state.lockTouchAxis && !pending.axisLock) { pending.axisLock = dominantGesture(gesture.scale - 1, pending.twistTotalDegrees); } - if (pending.axisLock !== 'twist') pending.logScaleDelta += logScaleStep; + if (pending.axisLock !== 'twist') pending.pinchDelta += pinchStep; if (pending.axisLock !== 'pinch') pending.twistDelta += degreesToRadians(rotationStep); onInput?.(); }; @@ -144,8 +144,8 @@ export function connect(state: SafariGestureState, element: HTMLElement, onInput /** Starts a new frame: the input gathered since the last call becomes this frame's. */ export function update(state: SafariGestureState): void { - state.logScaleDelta = state.pending.logScaleDelta; + state.pinchDelta = state.pending.pinchDelta; state.twistDelta = state.pending.twistDelta; - state.pending.logScaleDelta = 0; + state.pending.pinchDelta = 0; state.pending.twistDelta = 0; } diff --git a/src/input-sources/touch.ts b/src/input-sources/touch.ts index c73bea35..1c7ac1ea 100644 --- a/src/input-sources/touch.ts +++ b/src/input-sources/touch.ts @@ -11,7 +11,7 @@ export type TouchState = { fingers: number; /** Movement this frame of the fingers' center, by how many were down at the time, in pixels. */ readonly drag: Record; - /** Change in the distance between two fingers this frame, in pixels. */ + /** 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; @@ -103,7 +103,7 @@ function movePair(state: TouchState, moving: Finger, x: number, y: number): void const [a, b] = pending.slots; const distance = Math.hypot(b.x - a.x, b.y - a.y); - const pinchStep = distance - pending.distance; + 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); diff --git a/src/input-sources/wheel.ts b/src/input-sources/wheel.ts index 293ac1e7..d61ada4e 100644 --- a/src/input-sources/wheel.ts +++ b/src/input-sources/wheel.ts @@ -5,8 +5,8 @@ export type WheelState = { /** Scroll since the last `update`. */ deltaX: number; deltaY: number; - /** Trackpad pinch since the last `update`. Browsers send it as a `ctrlKey` wheel with `deltaY = -100 * ln(scale)`. */ - zoomDelta: 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. */ @@ -20,13 +20,13 @@ export type WheelState = { /** Internal. */ pendingX: number; pendingY: number; - pendingZoom: number; + pendingPinch: number; }; export const create = (): WheelState => ({ deltaX: 0, deltaY: 0, - zoomDelta: 0, + pinchDelta: 0, interactiveArea: null, preventPageScroll: true, preventPageZoom: true, @@ -34,7 +34,7 @@ export const create = (): WheelState => ({ pixelsPerPage: 800, pendingX: 0, pendingY: 0, - pendingZoom: 0, + pendingPinch: 0, }); const pixelsPerUnit = (state: WheelState, deltaMode: number): number => @@ -51,7 +51,8 @@ export function connect(state: WheelState, element: HTMLElement, onInput?: () => if (event.ctrlKey ? state.preventPageZoom : state.preventPageScroll) event.preventDefault(); const scale = pixelsPerUnit(state, event.deltaMode); if (event.ctrlKey) { - state.pendingZoom += event.deltaY * scale; + // 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; @@ -69,8 +70,8 @@ export function connect(state: WheelState, element: HTMLElement, onInput?: () => export function update(state: WheelState): void { state.deltaX = state.pendingX; state.deltaY = state.pendingY; - state.zoomDelta = state.pendingZoom; + state.pinchDelta = state.pendingPinch; state.pendingX = 0; state.pendingY = 0; - state.pendingZoom = 0; + state.pendingPinch = 0; } diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index 91a94fac..b3f61e60 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -44,10 +44,8 @@ export type InputControllerProps = { }; /** Vertical wheel scrolling, in pixels. Scrolling up is positive, like spreading a pinch. */ wheel?: InputAxisSourceConfig | null; - /** Change in the distance between two fingers, in pixels. Spreading is positive. */ - touchPinch?: InputAxisSourceConfig | null; - /** Trackpad pinch, as the change in `ln(scale)`. Spreading is positive. */ - trackpadPinch?: 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 reaches the axes. Unmount the component to stop listening altogether. */ @@ -97,8 +95,7 @@ function buildConfig(owner: InputAxisOwner, props: InputControllerProps): InputC three: resolveSource(owner, props.touches?.three), }, wheel: resolveAxisSource(owner, props.wheel), - touchPinch: resolveAxisSource(owner, props.touchPinch), - trackpadPinch: resolveAxisSource(owner, props.trackpadPinch), + pinch: resolveAxisSource(owner, props.pinch), }; } diff --git a/test/core/input/InputController.test.ts b/test/core/input/InputController.test.ts index 4d23cf1f..653543dd 100644 --- a/test/core/input/InputController.test.ts +++ b/test/core/input/InputController.test.ts @@ -67,27 +67,18 @@ describe('InputController — single-axis sources', () => { expect(axis.value).toBeCloseTo(0, 10); }); - it('feeds a touch pinch and holds its axis while two fingers are down', () => { + it('feeds a pinch and holds its axis while two fingers are down', () => { const { axis, controller, step } = single(); - controller.config.touchPinch = { axis }; - controller.input.touchPinchDelta = 30; + controller.config.pinch = { axis, gain: 2 }; + controller.input.pinchDelta = Math.log(1.5); controller.input.touchTwoHeld = true; step(); - expect(axis.value).toBe(30); + expect(axis.value).toBeCloseTo(2 * Math.log(1.5), 10); expect(axis.held).toBe(true); - controller.input.touchPinchDelta = 0; + controller.input.pinchDelta = 0; controller.input.touchTwoHeld = false; step(); expect(axis.held).toBe(false); }); - - it("feeds a trackpad pinch from Safari's gestures and other browsers' ctrl+wheel in the same ln(scale) units", () => { - const { axis, controller, step } = single(); - controller.config.trackpadPinch = { axis }; - controller.input.gestureZoomDelta = Math.log(2); - controller.input.wheelZoomDelta = -100 * Math.log(1.5); - step(); - expect(axis.value).toBeCloseTo(Math.log(3), 10); - }); }); diff --git a/test/dom/InputControllerDom.test.ts b/test/dom/InputControllerDom.test.ts index 15ebb630..50ebb17a 100644 --- a/test/dom/InputControllerDom.test.ts +++ b/test/dom/InputControllerDom.test.ts @@ -253,7 +253,7 @@ describe('InputControllerDom', () => { el.remove(); }); - it('leaves scrolling to the page unless wheel is mapped, and pinch zoom unless trackpadPinch is', () => { + 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()); @@ -270,7 +270,7 @@ describe('InputControllerDom', () => { controller.update(); expect([blocked({}), blocked({ ctrlKey: true })]).toEqual([true, false]); - controller.config.trackpadPinch = { axis: new InputAxis() }; + 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 5454fb4e..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 pinch as ln(scale) into gestureZoomDelta and rotation 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(Math.log(1.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(Math.log(1.1), 5); // a stale baseline of 2 would give ln(0.55) + 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(Math.log(1.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(Math.log(1.5), 5); + expect(out.pinchDelta).toBeCloseTo(Math.log(1.5), 5); expect(out.touchRotateDelta).toBe(0); }); }); diff --git a/test/input-sources/safariGesture.test.ts b/test/input-sources/safariGesture.test.ts index e44324c3..822e6002 100644 --- a/test/input-sources/safariGesture.test.ts +++ b/test/input-sources/safariGesture.test.ts @@ -40,11 +40,11 @@ describe('safariGesture', () => { expect(onInput).toHaveBeenCalledTimes(2); safariGesture.update(state); - expect(state.logScaleDelta).toBeCloseTo(Math.log(1.15), 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.15), 5); expect(state.twistDelta).toBeCloseTo((10 * Math.PI) / 180, 5); safariGesture.update(state); - expect(state.logScaleDelta).toBe(0); + expect(state.pinchDelta).toBe(0); }); it('measures each gesture from its own start, since Safari resets scale and rotation per gesture', () => { @@ -55,7 +55,7 @@ describe('safariGesture', () => { send('gesturestart', 1, 0); send('gesturechange', 1.1, 0); safariGesture.update(state); - expect(state.logScaleDelta).toBeCloseTo(Math.log(2) + Math.log(1.1), 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(2) + Math.log(1.1), 5); }); it('ignores a gesture that starts outside interactiveArea', () => { @@ -64,7 +64,7 @@ describe('safariGesture', () => { send('gesturestart', 1, 0, 10, 10); send('gesturechange', 1.1, 0, 10, 10); safariGesture.update(state); - expect(state.logScaleDelta).toBe(0); + expect(state.pinchDelta).toBe(0); }); it('lockTouchAxis keeps the stronger of scale and rotation, decided once per gesture', () => { @@ -74,14 +74,14 @@ describe('safariGesture', () => { send('gesturechange', 1.01, 20); send('gesturechange', 1.5, 20); safariGesture.update(state); - expect(state.logScaleDelta).toBe(0); + 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.logScaleDelta).toBeCloseTo(Math.log(1.5), 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.5), 5); expect(state.twistDelta).toBe(0); }); @@ -95,7 +95,7 @@ describe('safariGesture', () => { disconnect(); send('gesturechange', 1.5, 0); safariGesture.update(state); - expect(state.logScaleDelta).toBe(0); + 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)', () => { @@ -108,7 +108,7 @@ describe('safariGesture', () => { send('gesturestart', 1, 0); send('gesturechange', 1.5, 10); safariGesture.update(state); - expect(state.logScaleDelta).toBe(0); + expect(state.pinchDelta).toBe(0); expect(state.twistDelta).toBe(0); finger('pointerup', 1); @@ -118,7 +118,7 @@ describe('safariGesture', () => { finger('pointerdown', 3); send('gesturechange', 1.5, 0); safariGesture.update(state); - expect(state.logScaleDelta).toBeCloseTo(Math.log(1.2), 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.2), 5); }); it('can leave pinch zoom to the page, while still reading it', () => { @@ -127,6 +127,6 @@ describe('safariGesture', () => { expect(send('gesturestart', 1, 0).defaultPrevented).toBe(false); expect(send('gesturechange', 1.5, 0).defaultPrevented).toBe(false); safariGesture.update(state); - expect(state.logScaleDelta).toBeCloseTo(Math.log(1.5), 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(1.5), 5); }); }); diff --git a/test/input-sources/touch.test.ts b/test/input-sources/touch.test.ts index e6e333e2..2c928d50 100644 --- a/test/input-sources/touch.test.ts +++ b/test/input-sources/touch.test.ts @@ -55,7 +55,7 @@ describe('touch', () => { expect(state.fingers).toBe(3); expect(state.drag.one).toEqual([10, 0]); expect(state.drag.two[0]).toBeCloseTo(5, 5); - expect(state.pinchDelta).toBeCloseTo(-10, 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(80 / 90), 5); expect(state.drag.three[0]).toBeCloseTo(10 / 3, 5); }); @@ -65,7 +65,7 @@ describe('touch', () => { send('pointerdown', 50, 0, 2); send('pointermove', 0, 50, 2); touch.update(state); - expect(state.pinchDelta).toBeCloseTo(Math.hypot(50, 50) - 100, 5); + 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); @@ -91,7 +91,7 @@ describe('touch', () => { send('pointerdown', 100, 0, 2); send('pointermove', 150, 5, 2); touch.update(state); - expect(state.pinchDelta).toBeCloseTo(Math.hypot(150, 5) - 100, 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(Math.hypot(150, 5) / 100), 5); expect(state.twistDelta).toBe(0); }); @@ -118,7 +118,7 @@ describe('touch', () => { send('pointermove', 300, 0, 3); touch.update(state); expect(state.fingers).toBe(2); - expect(state.pinchDelta).toBeCloseTo(50, 5); + expect(state.pinchDelta).toBeCloseTo(Math.log(200 / 150), 5); send('pointerup', 300, 0, 3); send('pointermove', 110, 0, 2); diff --git a/test/input-sources/wheel.test.ts b/test/input-sources/wheel.test.ts index 6cc2f4c1..72ada6a4 100644 --- a/test/input-sources/wheel.test.ts +++ b/test/input-sources/wheel.test.ts @@ -49,11 +49,11 @@ describe('wheel', () => { expect(state.deltaY).toBe(state.pixelsPerPage); }); - it('routes a ctrlKey wheel (trackpad pinch) to zoomDelta', () => { + 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.zoomDelta).toBe(50); + expect(state.pinchDelta).toBeCloseTo(-0.5, 10); expect([state.deltaX, state.deltaY]).toEqual([0, 0]); }); @@ -111,6 +111,6 @@ describe('wheel', () => { expect(scroll({ deltaY: 5, ctrlKey: true }).defaultPrevented).toBe(false); wheel.update(state); expect(state.deltaY).toBe(100); - expect(state.zoomDelta).toBe(10); + expect(state.pinchDelta).toBeCloseTo(-0.1, 10); }); }); diff --git a/test/react/input/InputController.test.tsx b/test/react/input/InputController.test.tsx index 5e03241c..641fd66a 100644 --- a/test/react/input/InputController.test.tsx +++ b/test/react/input/InputController.test.tsx @@ -296,7 +296,6 @@ describe('InputController (React wrapper)', () => { it('resolves the single-axis sources by name too', async () => { const radial = new InputAxis(); - const roll = new InputAxis(); let controller: InputControllerDom | null = null; const renderer = await create( @@ -305,10 +304,9 @@ describe('InputController (React wrapper)', () => { ref={(c) => { controller = c; }} - target={owner({ radial, roll })} + target={owner({ radial })} wheel={{ axis: 'radial', gain: 0.01 }} - touchPinch={{ axis: 'radial', invert: true }} - trackpadPinch={{ axis: 'roll' }} + pinch={{ axis: 'radial', invert: true }} /> , @@ -316,7 +314,6 @@ describe('InputController (React wrapper)', () => { await renderer.advanceFrames(1, 0.05); expect(controller!.config.wheel).toEqual({ axis: radial, gain: 0.01, invert: undefined }); - expect(controller!.config.touchPinch?.axis).toBe(radial); - expect(controller!.config.trackpadPinch?.axis).toBe(roll); + expect(controller!.config.pinch).toEqual({ axis: radial, gain: undefined, invert: true }); }); });