diff --git a/src/core/CameraState.ts b/src/core/CameraState.ts index ed4d4ef4..ff034c78 100644 --- a/src/core/CameraState.ts +++ b/src/core/CameraState.ts @@ -85,4 +85,5 @@ export const lensEquals = (a: CameraState, b: CameraState): boolean => a.viewOffset[1] === b.viewOffset[1]; export { lerp } from './blend/lerpCameraState'; -export { mix } from './groups/mixCameraStates'; +// Groups are not public until they are finished and tested. +// export { mix } from './groups/mixCameraStates'; diff --git a/src/core/Klipp.ts b/src/core/Klipp.ts index 49fcea71..8665b0b1 100644 --- a/src/core/Klipp.ts +++ b/src/core/Klipp.ts @@ -32,7 +32,6 @@ export class Klipp extends EventDispatcher { readonly state = klippState.create(); /** The pose and lens new virtual cameras start from. */ readonly initialCameraState: CameraState; - mode: KlippMode; protected readonly cameras = new Set(); private readonly updates = new Set(); @@ -45,6 +44,7 @@ export class Klipp extends EventDispatcher { private readonly liveIdListeners = new Set<() => void>(); private draining = false; private roundRobinTurn = 0; + private _mode: KlippMode = 'enabled'; constructor(options: KlippOptions = {}) { super(); @@ -56,6 +56,16 @@ export class Klipp extends EventDispatcher { }; } + get mode(): KlippMode { + return this._mode; + } + + /** Switching back to `'enabled'` writes the next shot even if it did not change, to take the camera back. */ + set mode(mode: KlippMode) { + if (mode === 'enabled' && this._mode !== 'enabled') this.settled = false; + this._mode = mode; + } + setDefaultBlend(defaultBlend?: BlendDefinition): void { this.params.defaultBlend = defaultBlend ?? DEFAULT_BLEND; } @@ -300,13 +310,19 @@ export class Klipp extends EventDispatcher { if (this.draining) return; this.draining = true; const events = this.state.events; - for (let i = 0; i < events.length; i++) { - const event = events[i]; - if (event.type === 'activeIdChanged') for (const listener of this.activeIdListeners) listener(); - else if (event.type === 'liveIdChanged') for (const listener of this.liveIdListeners) listener(); - else this.dispatchEvent(event); + let i = 0; + try { + for (; i < events.length; i++) { + const event = events[i]; + if (event.type === 'activeIdChanged') for (const listener of this.activeIdListeners) listener(); + else if (event.type === 'liveIdChanged') for (const listener of this.liveIdListeners) listener(); + else this.dispatchEvent(event); + } + } finally { + // A throwing listener drops only the events delivered so far, the rest wait for the next drain. + if (i >= events.length) events.length = 0; + else events.splice(0, i + 1); + this.draining = false; } - events.length = 0; - this.draining = false; } } diff --git a/src/core/aim/PanTiltAim.ts b/src/core/aim/PanTiltAim.ts index 9459419a..ae9fa0ea 100644 --- a/src/core/aim/PanTiltAim.ts +++ b/src/core/aim/PanTiltAim.ts @@ -19,9 +19,7 @@ export class PanTiltAim { this.target = target; } - update = (out: CameraState, dt: number): void => { - panTilt.update(out, this.state, this.targetRotation(), dt); - }; + update = (out: CameraState, dt: number): boolean => panTilt.update(out, this.state, this.targetRotation(), dt); /** Seeds `pan`/`tilt` from `rotation`'s forward direction, relative to the current reference frame. */ setFromRotation = (rotation: Quat, referenceUp: Vec3): void => { diff --git a/src/core/aim/panTilt.ts b/src/core/aim/panTilt.ts index 7e2da19a..f29fd9ab 100644 --- a/src/core/aim/panTilt.ts +++ b/src/core/aim/panTilt.ts @@ -37,15 +37,16 @@ function projectOnPlane(out: Vec3, v: Vec3, normal: Vec3): Vec3 { return vec3.scaleAndAdd(out, v, normal, -vec3.dot(normal, v) / lengthSq); } -/** Advances both axes and writes the resulting rotation to `out`. */ -export function update(out: CameraState, state: PanTiltState, targetRotation: Quat | null, dt: number): void { - state.pan.update(dt); - state.tilt.update(dt); +/** Advances both axes and writes the resulting rotation to `out`. Returns `true` while an axis still has to move. */ +export function update(out: CameraState, state: PanTiltState, targetRotation: Quat | null, dt: number): boolean { + const panMoving = state.pan.update(dt); + const tiltMoving = state.tilt.update(dt); referenceFrame(scratchFrame, targetRotation, out.referenceUp); scratchEuler[0] = -degreesToRadians(state.tilt.value); scratchEuler[1] = -degreesToRadians(state.pan.value); quat.multiply(out.quaternion, scratchFrame, quat.fromEuler(scratchRotation, scratchEuler)); + return panMoving || tiltMoving; } /** diff --git a/src/core/blend/BlendHints.ts b/src/core/blend/BlendHints.ts index 11a47b5a..f35cbda4 100644 --- a/src/core/blend/BlendHints.ts +++ b/src/core/blend/BlendHints.ts @@ -3,9 +3,7 @@ export const BlendHints = { none: 0, cylindricalPosition: 1 << 0, sphericalPosition: 1 << 1, - screenSpaceAimWhenTargetsDiffer: 1 << 2, ignoreTarget: 1 << 3, - inheritPosition: 1 << 4, } as const; export type BlendHints = number; diff --git a/src/core/extension/groupFraming.ts b/src/core/extension/groupFraming.ts index ae2e3210..3e9c540f 100644 --- a/src/core/extension/groupFraming.ts +++ b/src/core/extension/groupFraming.ts @@ -186,7 +186,7 @@ export function update( damping.reset(state.screenPositionYDamper); } const boundsRadius = computeBounds(scratchGroupPosition, members, positionMode); - if (boundsRadius <= 0) return false; + if (boundsRadius < 0 || (boundsRadius === 0 && params.padding <= 0)) return false; const verticalHalfFov = degreesToRadians(out.fov) / 2; const aspect = params.viewportWidth / params.viewportHeight; diff --git a/src/core/groups/MixingCamera.ts b/src/core/groups/MixingCamera.ts index b3cef694..ac0db15c 100644 --- a/src/core/groups/MixingCamera.ts +++ b/src/core/groups/MixingCamera.ts @@ -1,7 +1,7 @@ import * as cameraState from '../CameraState'; import type { CameraState } from '../CameraState'; -import type { MixingCameraSlot } from './mixCameraStates'; +import { mix, type MixingCameraSlot } from './mixCameraStates'; const MAX_SLOTS = 8; @@ -28,6 +28,6 @@ export class MixingCamera { /** Recomputes the weighted mix from the slots' current weights and returns it - same scratch instance * every call. If every weight is zero (or negative), returns the previous output unchanged. */ tick(): CameraState { - return cameraState.mix(this.output, this.slots); + return mix(this.output, this.slots); } } diff --git a/src/core/index.ts b/src/core/index.ts index aa134f8d..e34fb874 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -30,7 +30,8 @@ export * from './body/index'; export * from './damping/index'; export * from './debug/index'; export * from './extension/index'; -export * from './groups/index'; +// Groups are not public until they are finished and tested. +// export * from './groups/index'; export * from './impulse/index'; export * from './input/index'; export * from './noise/index'; diff --git a/src/core/input/InputAxis.ts b/src/core/input/InputAxis.ts index 81517481..ab4c5e92 100644 --- a/src/core/input/InputAxis.ts +++ b/src/core/input/InputAxis.ts @@ -30,8 +30,8 @@ export class InputAxis implements InputAxisData { applyDelta = (delta: number): void => inputAxis.applyDelta(this, delta); - /** Advance the axis and apply damping or recentering. */ - update = (dt: number): void => inputAxis.update(this, dt); + /** Advance the axis and apply damping or recentering. Returns `true` while it still has to move. */ + update = (dt: number): boolean => inputAxis.update(this, dt); /** Reset damping so the next update snaps to the raw value. */ reset = (): void => inputAxis.reset(this); diff --git a/src/core/input/axis.ts b/src/core/input/axis.ts index 913de99b..abbde505 100644 --- a/src/core/input/axis.ts +++ b/src/core/input/axis.ts @@ -85,8 +85,17 @@ export function normalize(axis: InputAxisData): void { axis.rawValue = min + repeat(axis.rawValue - min, span); } -/** Advance the axis and apply damping or recentering. */ -export function update(axis: InputAxisData, dt: number): void { +/** Whether the axis still has to move: easing toward its target, or a recentering yet to finish. */ +function isMoving(axis: InputAxisData): boolean { + if (axis.value !== axis.rawValue) return true; + if (axis.held || !axis.recentering.enabled) return false; + const offCenter = + axis.wrap && axis.range ? shortestWrappedDelta(axis.value, axis.center, axis.range) : axis.center - axis.value; + return Math.abs(offCenter) > 1e-9; +} + +/** Advance the axis and apply damping or recentering. Returns `true` while it still has to move. */ +export function update(axis: InputAxisData, dt: number): boolean { const active = axis.held || axis.hadDelta; axis.hadDelta = false; if (active) axis.idleTime = 0; @@ -112,6 +121,7 @@ export function update(axis: InputAxisData, dt: number): void { if (isRecentering) axis.rawValue = axis.value; if (axis.autoNormalize && axis.value === axis.rawValue) normalize(axis); + return isMoving(axis); } /** Reset damping so the next update snaps to the raw value. */ diff --git a/src/react/Klipp.tsx b/src/react/Klipp.tsx index fad39eea..aa11113d 100644 --- a/src/react/Klipp.tsx +++ b/src/react/Klipp.tsx @@ -25,6 +25,7 @@ export type KlippProps = Pick & { export function Klipp({ children, defaultBlend, customBlends, camera: cameraProp, mode = 'enabled' }: KlippProps) { const defaultCamera = useThree((state) => state.camera); const size = useThree((state) => state.size); + const invalidate = useThree((state) => state.invalidate); const camera = cameraProp ?? defaultCamera; const [klipp] = useState(() => new KlippThree(camera, { defaultBlend, customBlends })); klipp.camera = camera; @@ -33,6 +34,7 @@ export function Klipp({ children, defaultBlend, customBlends, camera: cameraProp useEffect(() => klipp.setDefaultBlend(defaultBlend), [klipp, defaultBlend]); useEffect(() => klipp.setCustomBlends(customBlends), [klipp, customBlends]); + useEffect(() => invalidate(), [mode, invalidate]); useFrame((state, rawDelta) => { const delta = state.frameloop === 'demand' ? Math.min(rawDelta, DEMAND_MODE_MAX_DELTA) : rawDelta; diff --git a/src/react/input/InputController.tsx b/src/react/input/InputController.tsx index 5da7fbaf..3cc18b11 100644 --- a/src/react/input/InputController.tsx +++ b/src/react/input/InputController.tsx @@ -99,6 +99,7 @@ export function InputController(props: InputControllerProps) { const isLive = useIsLiveVirtualCamera(); const shouldConnect = isActive && (waitForBlend ? isLive : true); const domElement = useThree((state) => state.gl.domElement); + const invalidate = useThree((state) => state.invalidate); const [controller] = useState(() => new InputAxisController(emptyConfig)); useImperativeHandle(ref, () => controller, [controller]); @@ -118,8 +119,23 @@ export function InputController(props: InputControllerProps) { useEffect(() => { if (!shouldConnect) return; controller.connect(domElement); - return () => controller.disconnect(); - }, [controller, domElement, shouldConnect]); + // 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); + return () => { + controller.disconnect(); + domElement.removeEventListener('pointerdown', onPointer); + domElement.removeEventListener('pointermove', onPointer); + domElement.removeEventListener('pointerup', onPointer); + domElement.removeEventListener('pointercancel', onPointer); + }; + }, [controller, domElement, shouldConnect, invalidate]); return null; } diff --git a/test/core/Klipp.test.ts b/test/core/Klipp.test.ts index 657e3229..bd0804f4 100644 --- a/test/core/Klipp.test.ts +++ b/test/core/Klipp.test.ts @@ -687,6 +687,35 @@ describe('Klipp — update(dt): the frame loop', () => { } }); + it('takes the camera back when switched to enabled again, even with an unchanged shot (real bug: it stayed where standby left it)', () => { + for (const mode of ['standby', 'disabled'] as const) { + const { klipp } = twoShots(); + klipp.update(0.1); + klipp.mode = mode; + klipp.update(0.1); + klipp.mode = 'enabled'; + klipp.update(0.1); + expect(klipp.writes.map((w) => w.position[0])).toEqual([-10, -10]); + } + }); + + it('keeps delivering events after a listener throws (real bug: one throw stopped every later event)', () => { + const klipp = new Klipp(); + const thrower = () => { + throw new Error('listener'); + }; + const activated: string[] = []; + klipp.addEventListener('activated', thrower); + klipp.addEventListener('activated', (event) => activated.push(event.incoming)); + + expect(() => klipp.addCamera('a', { priority: 1 })).toThrow('listener'); + klipp.removeEventListener('activated', thrower); + klipp.addCamera('b', { priority: 2 }); + + expect(activated).toEqual(['b']); + expect(klipp.state.events).toEqual([]); + }); + it('runs registered updates before the cameras, and stops them once removed', () => { const { klipp, left } = twoShots(); const order: string[] = []; diff --git a/test/core/blend/BlendHints.test.ts b/test/core/blend/BlendHints.test.ts index 0e8d2af8..0ec079e2 100644 --- a/test/core/blend/BlendHints.test.ts +++ b/test/core/blend/BlendHints.test.ts @@ -4,13 +4,7 @@ import { BlendHints, hasBlendHint } from '../../../src/core/blend/BlendHints'; describe('BlendHints', () => { it('each flag is a distinct bit (no accidental overlap)', () => { - const flags = [ - BlendHints.cylindricalPosition, - BlendHints.sphericalPosition, - BlendHints.screenSpaceAimWhenTargetsDiffer, - BlendHints.ignoreTarget, - BlendHints.inheritPosition, - ]; + const flags = [BlendHints.cylindricalPosition, BlendHints.sphericalPosition, BlendHints.ignoreTarget]; const seen = new Set(flags); expect(seen.size).toBe(flags.length); }); @@ -26,6 +20,5 @@ describe('BlendHints', () => { expect(hasBlendHint(combined, BlendHints.cylindricalPosition)).toBe(true); expect(hasBlendHint(combined, BlendHints.ignoreTarget)).toBe(true); expect(hasBlendHint(combined, BlendHints.sphericalPosition)).toBe(false); - expect(hasBlendHint(combined, BlendHints.inheritPosition)).toBe(false); }); }); diff --git a/test/core/input/InputAxis.test.ts b/test/core/input/InputAxis.test.ts index 471873ae..68c8aa44 100644 --- a/test/core/input/InputAxis.test.ts +++ b/test/core/input/InputAxis.test.ts @@ -199,5 +199,21 @@ describe('InputAxis', () => { for (let i = 0; i < 50; i++) axis.update(0.1); expect(axis.value).toBeCloseTo(0, 1); }); + + it('update returns true through the wait and the recentering (real bug: an idle demand loop never started it)', () => { + const axis = new InputAxis({ value: 10, center: 0, range: null, wrap: false, recentering: recentering(1, 0.5) }); + expect(axis.update(0.1)).toBe(true); + expect(axis.value).toBe(10); + + let frames = 0; + while (axis.update(0.1) && frames < 1000) frames++; + expect(frames).toBeLessThan(1000); + expect(axis.value).toBeCloseTo(0, 6); + + axis.held = true; + axis.applyDelta(5); + settle(axis); + expect(axis.update(0.1)).toBe(false); + }); }); }); diff --git a/test/three/extension/GroupFramingExtensionThree.test.ts b/test/three/extension/GroupFramingExtensionThree.test.ts index ee8cde96..7c1248d9 100644 --- a/test/three/extension/GroupFramingExtensionThree.test.ts +++ b/test/three/extension/GroupFramingExtensionThree.test.ts @@ -40,6 +40,16 @@ describe('GroupFramingExtensionThree', () => { } }); + it('frames a single point member by its padding (real bug: a zero-size group was always skipped)', () => { + const out = camera(); + new GroupFramingExtensionThree(new TargetGroup([{ target: new Vector3() }]), { + padding: 1, + viewportWidth: 100, + viewportHeight: 100, + }).update(out, 0.1, false); + expect(out.position[2]).toBeCloseTo(sphereFit(1), 10); + }); + it("backs away along the camera's own view axis until the group fits", () => { const out = camera(); new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2).toArray(out.quaternion);