PageScene: extract cameraMath + browserChecks modules

Pure-function helpers carved out of FreeCam in preparation for the
hook rewrite:

- engine/cameraMath.ts: HeldMoveKeys / HeldRotateKeys types,
  moveVectorFromKeys, rotationVectorFromKeys, panFromDrag,
  zoomFromWheel, lookAtFromCamera, lerpVelocity. No DOM access, no
  signals.
- engine/browserChecks.ts: isSafariOrWebKit, hasApplePay, isSafari,
  isPointerLockSupported. Unrelated to camera math; were just
  hitchhiking in free_cam.ts.

free_cam.ts re-exports the browser checks for back-compat with
keybinds_controls.ts; both consumers will switch to the direct
imports when free_cam.ts is deleted in the next commit.
This commit is contained in:
Tanish Manku committed 2026-04-29 09:50:16 -07:00
1 parent 61c4936c29
commit 718b8261fd
3 files changed
+138 -22

No files matched your search

@@ -0,0 +1,20 @@
// Browser feature detection helpers used by the input subsystem.
// Pointer Lock behaves differently on Safari/WebKit, so the input
// hooks fall back to drag-based control there.
export const isSafariOrWebKit = (): boolean => {
const ua = navigator.userAgent;
const isWebKit =
/AppleWebKit/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS|FxiOS/i.test(ua);
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
return isSafari || isWebKit;
};
export const hasApplePay = (): boolean => {
// @ts-expect-error Apple Pay is not defined in TypeScript
return !!window.ApplePaySession;
};
export const isSafari = (): boolean => isSafariOrWebKit() || hasApplePay();
export const isPointerLockSupported = (): boolean => !isSafari();
@@ -0,0 +1,109 @@
import { MathUtils, Vector3 } from "three";
import type { PerspectiveCamera } from "three";
// Pure math used by the free-cam viewport controls.
// No DOM access, no event listeners, no signals — every function
// takes inputs and returns values. The input hooks own the listener
// lifecycle and the per-frame loop; this module owns the formulas.
// Held movement keys (WASD + QE). Stored as 0/1 so update steps can
// just sum them in opposite directions.
export interface HeldMoveKeys {
forward: 0 | 1;
back: 0 | 1;
left: 0 | 1;
right: 0 | 1;
up: 0 | 1;
down: 0 | 1;
}
// Held rotation keys (arrow keys + roll). Same encoding.
export interface HeldRotateKeys {
pitchUp: 0 | 1;
pitchDown: 0 | 1;
yawLeft: 0 | 1;
yawRight: 0 | 1;
rollLeft: 0 | 1;
rollRight: 0 | 1;
}
export const emptyMoveKeys = (): HeldMoveKeys => ({
forward: 0,
back: 0,
left: 0,
right: 0,
up: 0,
down: 0,
});
export const emptyRotateKeys = (): HeldRotateKeys => ({
pitchUp: 0,
pitchDown: 0,
yawLeft: 0,
yawRight: 0,
rollLeft: 0,
rollRight: 0,
});
// Per-frame movement direction (in camera-local space). Multiply by
// (delta * movementSpeed) before applying.
export const moveVectorFromKeys = (
keys: HeldMoveKeys,
autoForward = false,
): Vector3 => {
const forward = keys.forward || (autoForward && !keys.back) ? 1 : 0;
return new Vector3(
-keys.left + keys.right,
-keys.down + keys.up,
-forward + keys.back,
);
};
// Per-frame rotation deltas. Already pre-scaled by rollSpeed.
export const rotationVectorFromKeys = (
keys: HeldRotateKeys,
rollSpeed: number,
): Vector3 =>
new Vector3(
-keys.pitchDown + keys.pitchUp,
-keys.yawRight + keys.yawLeft,
-keys.rollRight + keys.rollLeft,
).multiplyScalar(rollSpeed);
// Mouse-drag pan amounts (in camera-local x/y, before applying).
// dx/dy are pointer deltas in pixels; speed scales them.
export const panFromDrag = (
dx: number,
dy: number,
speed: number,
): { x: number; y: number } => ({
x: -dx * (speed * 0.01),
y: dy * (speed * 0.01),
});
// Wheel-event zoom amount along camera-local z.
export const zoomFromWheel = (deltaY: number): number => deltaY / 120;
// World-space lookAt point one unit ahead of the camera along its
// current orientation. Used to update camera entries in the store
// after a free-cam move so the camera entry's "lookAt" stays in
// sync with the rendered orientation.
export const lookAtFromCamera = (camera: PerspectiveCamera): Vector3 => {
const lookAt = new Vector3(0, 0, -1);
lookAt.applyQuaternion(camera.quaternion);
lookAt.add(camera.position);
return lookAt;
};
// Smooth a velocity vector toward a target with a per-component lerp.
// Returns the same `velocity` instance for chaining.
export const lerpVelocity = (
velocity: Vector3,
target: Vector3,
smoothing: number,
): Vector3 => {
velocity.x = MathUtils.lerp(velocity.x, target.x, smoothing);
velocity.y = MathUtils.lerp(velocity.y, target.y, smoothing);
velocity.z = MathUtils.lerp(velocity.z, target.z, smoothing);
return velocity;
};
@@ -9,28 +9,15 @@ import * as THREE from "three";
import { cameras, selectedCameraId } from "~/pages/PageScene/signals/camera";
import { isPromptBoxFocused } from "~/pages/PageScene/signals/promptBox";
export const isSafariOrWebKit = () => {
const ua = navigator.userAgent;
const isWebKit = /AppleWebKit/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS|FxiOS/i.test(ua);
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
return isSafari || isWebKit;
};
export const hasApplePay = () => {
// @ts-expect-error Apple Pay is not defined in TypeScript
return !!window.ApplePaySession;
}
export const isSafari = () => {
return isSafariOrWebKit() || hasApplePay();
}
export const isPointerLockSupported = () => {
return !isSafari();
}
// Re-exported here for back-compat with keybinds_controls.ts; the
// definitions live in browserChecks.ts and will be imported directly
// once Phase 6 deletes this file.
export {
isSafariOrWebKit,
hasApplePay,
isSafari,
isPointerLockSupported,
} from "./browserChecks";
class FreeCam extends EventDispatcher {
object: THREE.PerspectiveCamera;