PageScene: useFreeCam hook + delete free_cam.ts

The 446-line FreeCam class is gone. Its responsibilities split:

- Math (move/rotate vectors, pan/zoom from inputs, lookAt) lives in
  cameraMath.ts as pure functions, plus a freeCamFrameTick() that
  integrates the per-frame state.
- Listener attachment + drag/key state ownership moves into
  hooks/useFreeCam.ts. Listeners are scoped to the canvas (no
  window/document handlers); held-key state is reset on
  CAMERA_VIEW exit so a stale W keypress can't drift the camera.
- The shared FreeCamControlState struct flows through Editor via
  editor.setFreeCamState(state); the render loop reads it and
  calls freeCamFrameTick() each frame.

Side effects: keybinds_controls.ts now imports
isPointerLockSupported from browserChecks; helper.ts and
PageEditor's camera-select handler stop poking
.cameraViewControls and reset velocity via freeCamState instead.
PreviewEngineCamera's camera-view toggle now just flips the store
state — useFreeCam picks it up on its own (no more
enableFreeCamControls/disableFreeCamControls call site).
This commit is contained in:
Tanish Manku committed 2026-04-29 09:55:40 -07:00
1 parent 718b8261fd
commit efa9a54bee
9 files changed
+309 -496

No files matched your search

@@ -5,7 +5,6 @@ import { Controls3D } from "./comps/Controls3D";
import { ControlsTopButtons } from "./comps/ControlsTopButtons";
import { ControlPanelSceneObject } from "./comps/ControlPanelSceneObject";
import { PreviewEngineCamera } from "./comps/PreviewEngineCamera";
import { PreviewFrameImage } from "./comps/PreviewFrameImage";
import { authentication, pageHeight, pageWidth } from "~/signals";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import ImageToVideo from "../PageVideo/ImageToVideo";
@@ -31,6 +30,8 @@ import { DomLevels } from "~/pages/PageScene/PageSceneStore";
import { setCameraAspect } from "~/pages/PageScene/actions";
import { EditorCanvas } from "./comps/EngineCanvases";
import { SceneContainer } from "./comps/SceneContainer";
import { useEditorCanvas } from "./hooks/useEditorCanvas";
import { useFreeCam } from "./hooks/useFreeCam";
import { Outliner } from "./comps/Outliner";
import { CameraAspectRatio } from "./enums";
import { PromptBox3D } from "@storyteller/ui-promptbox";
@@ -156,6 +157,8 @@ export const PageEditor = () => {
// These are callbacks required by promptbox
const editorEngine = useContext(EngineContext);
const editorCanvas = useEditorCanvas();
useFreeCam(editorCanvas, editorEngine);
const handleCameraSelect = (selectedItem: PopoverItem) => {
const selectedCamera = cameras.value.find(
(cam) => cam.label === selectedItem.label,
@@ -196,10 +199,10 @@ export const PageEditor = () => {
);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
// Reset free-cam motion so a switch doesn't carry over a
// half-applied drag from the previous camera.
if (editorEngine.freeCamState) {
editorEngine.freeCamState.velocity.set(0, 0, 0);
}
// Force a render to update the view
@@ -259,10 +262,10 @@ export const PageEditor = () => {
editorEngine.camera.fov = editorEngine.focalLengthToFov(24);
editorEngine.camera.updateProjectionMatrix();
// Reset and update camera controls
if (editorEngine.cameraViewControls) {
editorEngine.cameraViewControls.reset();
editorEngine.cameraViewControls.update(0);
// Reset free-cam motion so the camera doesn't drift after
// teleport.
if (editorEngine.freeCamState) {
editorEngine.freeCamState.velocity.set(0, 0, 0);
}
// Force a render to update the view
@@ -464,7 +467,6 @@ export const PageEditor = () => {
<div className="relative w-full overflow-hidden bg-transparent">
<SceneContainer>
<EditorCanvas />
<PreviewFrameImage />
</SceneContainer>
{/* Focal Length Display */}
@@ -17,7 +17,6 @@ import { CameraViewCanvas } from "~/pages/PageScene/comps/EngineCanvases";
import { ButtonIcon } from "@storyteller/ui-button-icon";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { getActiveEditor } from "~/pages/PageScene/contexts/EngineContext/EngineContext";
export const PreviewEngineCamera = () => {
useSignals();
@@ -33,15 +32,13 @@ export const PreviewEngineCamera = () => {
const handleButtonCameraView = () => {
const { editorState, setEditorState } = usePageSceneStore.getState();
const next =
setEditorState(
editorState === EditorStates.CAMERA_VIEW
? EditorStates.EDIT
: EditorStates.CAMERA_VIEW;
setEditorState(next);
const editor = getActiveEditor();
if (!editor) return;
if (next === EditorStates.CAMERA_VIEW) editor.enableFreeCamControls();
else editor.disableFreeCamControls();
: EditorStates.CAMERA_VIEW,
);
// useFreeCam picks up the editorState change from the store and
// (de)activates its listeners + clears held keys automatically.
};
const getLargeScreenHeightClass = () => {
@@ -107,3 +107,86 @@ export const lerpVelocity = (
velocity.z = MathUtils.lerp(velocity.z, target.z, smoothing);
return velocity;
};
// Per-frame state owned by useFreeCam. The editor's render loop
// reads this struct on every tick and applies the resulting motion.
// Mutated in place by event handlers (keyboard) so the React
// reference stays stable.
export interface FreeCamControlState {
enabled: boolean;
moveKeys: HeldMoveKeys;
rotateKeys: HeldRotateKeys;
velocity: Vector3;
movementSpeed: number;
rollSpeed: number;
smoothing: number;
}
export const createFreeCamControlState = (): FreeCamControlState => ({
enabled: false,
moveKeys: emptyMoveKeys(),
rotateKeys: emptyRotateKeys(),
velocity: new Vector3(),
movementSpeed: 1.15,
rollSpeed: Math.PI / 180,
smoothing: 0.2,
});
// Translate a code from a KeyboardEvent into the move-state slot it
// drives, if any. Returns the property name on HeldMoveKeys, or null.
export const moveSlotForKeyCode = (
code: string,
): keyof HeldMoveKeys | null => {
switch (code) {
case "KeyW": return "forward";
case "KeyS": return "back";
case "KeyA": return "left";
case "KeyD": return "right";
case "KeyQ": return "down";
case "KeyE": return "up";
default: return null;
}
};
export const rotateSlotForKeyCode = (
code: string,
): keyof HeldRotateKeys | null => {
switch (code) {
case "ArrowUp": return "pitchUp";
case "ArrowDown": return "pitchDown";
case "ArrowLeft": return "yawLeft";
case "ArrowRight": return "yawRight";
default: return null;
}
};
// Per-frame integration step. Reads `state` (held keys + velocity),
// applies the resulting movement / rotation to `camera`, and returns
// `true` if anything changed. The caller is responsible for any
// downstream sync (e.g. lookAt updates) when this returns true.
export const freeCamFrameTick = (
camera: PerspectiveCamera,
state: FreeCamControlState,
delta: number,
): boolean => {
if (!state.enabled) return false;
const moveVec = moveVectorFromKeys(state.moveKeys);
const rotVec = rotationVectorFromKeys(state.rotateKeys, state.rollSpeed);
const stationary = moveVec.lengthSq() === 0;
const noRotation = rotVec.lengthSq() === 0;
const velocityIdle = state.velocity.lengthSq() < 0.0001;
if (stationary && noRotation && velocityIdle) return false;
const target = moveVec.multiplyScalar(delta * state.movementSpeed);
lerpVelocity(state.velocity, target, state.smoothing);
camera.translateX(state.velocity.x);
camera.translateY(state.velocity.y);
camera.translateZ(state.velocity.z);
camera.rotateX(rotVec.x);
camera.rotateY(rotVec.y);
camera.rotateZ(rotVec.z);
return true;
};
@@ -1,7 +1,11 @@
import * as THREE from "three";
import { signal } from "@preact/signals-react";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { FreeCam } from "./free_cam";
import {
freeCamFrameTick,
lookAtFromCamera,
type FreeCamControlState,
} from "./cameraMath";
import { TransformControls } from "./TransformControls.js";
import Scene from "./scene.js";
import { APIManager, ArtStyle } from "./api_manager.js";
@@ -100,7 +104,10 @@ class Editor {
transform_interaction = false;
rendering: boolean;
api_manager: APIManager;
cameraViewControls: FreeCam | undefined;
freeCamState: FreeCamControlState | null = null;
setFreeCamState(state: FreeCamControlState | null) {
this.freeCamState = state;
}
orbitControls: OrbitControls | undefined;
locked: boolean;
@@ -509,16 +516,8 @@ class Editor {
this.camera,
this.renderer.domElement,
);
this.cameraViewControls = new FreeCam(
this.camera,
this.renderer.domElement,
);
this.cameraViewControls.movementSpeed = 1.15;
this.cameraViewControls.domElement = this.renderer.domElement;
this.cameraViewControls.rollSpeed = Math.PI / 180;
this.cameraViewControls.autoForward = false;
this.cameraViewControls.dragToLook = true;
this.cameraViewControls.enabled = true;
// FreeCam math + listeners now live in hooks/useFreeCam.ts; the
// editor reads `freeCamState` (set by that hook) on every render.
this.control = new TransformControls(this.camera, this.renderer.domElement);
this.control.space = "world"; // Default to world space for translate mode
@@ -561,7 +560,7 @@ class Editor {
this.mouse_controls = new MouseControls(
this.camera,
this.get_camera_person_mode.bind(this),
this.cameraViewControls,
this.freeCamState,
this.lockControls,
this.camera_last_pos,
this.selectedCanvas,
@@ -656,22 +655,16 @@ class Editor {
document.body.appendChild(this.stats.dom);
}
// Track whether the cursor is currently over the viewport canvas.
// useFreeCam owns the actual input listeners, but a few sites
// (e.g. keybinds_controls.ts) still consult `selectedCanvas`.
private mouseOverEventHandler(event: MouseEvent) {
if (this.cameraViewControls) {
if (
event.target instanceof HTMLCanvasElement ||
(event.target as HTMLElement).id == "letterbox"
) {
this.cameraViewControls.enabled = true;
this.selectedCanvas = true;
this.focused = true;
} else {
this.cameraViewControls.reset();
this.focused = false;
this.cameraViewControls.enabled = false;
this.selectedCanvas = false;
}
}
const target = event.target;
const onCanvas =
target instanceof HTMLCanvasElement ||
(target instanceof HTMLElement && target.id === "letterbox");
this.selectedCanvas = onCanvas;
this.focused = onCanvas;
}
public enableFreeCamControls() {
@@ -1172,12 +1165,32 @@ class Editor {
}
}
this.cameraViewControls?.update(5 * delta_time);
if (this.freeCamState && this.camera) {
const moved = freeCamFrameTick(this.camera, this.freeCamState, 5 * delta_time);
// Mirror the active camera's transform back into the cameras
// signal so PromptBox3D and the camera-list UI stay in sync.
// (Bridge signal: retire when PromptBox3D takes store callbacks.)
if (moved && selectedCameraId.value) {
const lookAt = lookAtFromCamera(this.camera);
const pos = this.camera.position;
const rot = this.camera.rotation;
cameras.value = cameras.value.map((cam) =>
cam.id === selectedCameraId.value
? {
...cam,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
lookAt: { x: lookAt.x, y: lookAt.y, z: lookAt.z },
}
: cam,
);
}
}
this.activeScene.shader_objects.forEach((shader) => {
shader.material.uniforms["time"].value += 0.5 * delta_time;
});
if (this.cameraViewControls && this.camera_person_mode) {
if (this.camera_person_mode) {
if (this.cam_obj && this.camera) {
// Without a timeline scrubber, edits in camera-person mode write
// back into cam_obj rather than copying out of it.
@@ -1268,7 +1281,6 @@ class Editor {
this.startRenderLoop();
this.sceneManager?.attachEventListeners();
this.enableFreeCamControls();
this.cameraViewControls?.attachEventListeners();
console.log("3D Editor Engine remounted");
}
@@ -1277,7 +1289,6 @@ class Editor {
this.stopRenderLoop();
this.sceneManager?.detachEventListeners();
this.disableFreeCamControls();
this.cameraViewControls?.detachEventListeners();
// Fix: dispose 3D contexts
this.renderer?.dispose();
@@ -1,432 +0,0 @@
import {
EventDispatcher,
Quaternion,
Vector3,
Vector2,
MathUtils,
} from "three";
import * as THREE from "three";
import { cameras, selectedCameraId } from "~/pages/PageScene/signals/camera";
import { isPromptBoxFocused } from "~/pages/PageScene/signals/promptBox";
// 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;
domElement: HTMLElement | Document;
enabled = true;
movementSpeed = 1.0;
rollSpeed = 0.005;
dragToLook = false;
autoForward = false;
dragging = false;
xOffset = 0;
yOffset = 0;
// Smoothing properties
smoothingFactor = 0.2;
currentVelocity = new Vector3();
lastPosition = new Vector3();
lastMousePosition = new Vector2();
mouseVelocity = new Vector2();
tmpQuaternion = new Quaternion();
status = 0;
moveState = {
up: 0,
down: 0,
left: 0,
right: 0,
forward: 0,
back: 0,
pitchUp: 0,
pitchDown: 0,
yawLeft: 0,
yawRight: 0,
rollLeft: 0,
rollRight: 0,
};
moveVector = new Vector3(0, 0, 0);
rotationVector = new Vector3(0, 0, 0);
constructor(
object: THREE.PerspectiveCamera,
domElement: HTMLElement | Document,
) {
super();
this.object = object;
this.domElement = domElement;
this.lastPosition.copy(object.position);
this.keydown = this.keydown.bind(this);
this.keyup = this.keyup.bind(this);
this.mousedown = this.mousedown.bind(this);
this.mouseup = this.mouseup.bind(this);
this.mousemove = this.mousemove.bind(this);
this.mousewheel = this.mousewheel.bind(this);
this.reset = this.reset.bind(this);
this.update = this.update.bind(this);
this.updateMovementVector = this.updateMovementVector.bind(this);
this.updateRotationVector = this.updateRotationVector.bind(this);
this.getContainerDimensions = this.getContainerDimensions.bind(this);
this.dispose = this.dispose.bind(this);
this.domElement.addEventListener("contextmenu", function (event) {
event.preventDefault();
});
this.updateMovementVector();
this.updateRotationVector();
}
attachEventListeners() {
window.addEventListener("keydown", this.keydown);
window.addEventListener("keyup", this.keyup);
window.addEventListener("mousedown", this.mousedown);
window.addEventListener("mouseup", this.mouseup);
window.addEventListener("mousemove", this.mousemove);
window.addEventListener("wheel", this.mousewheel);
}
detachEventListeners() {
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
window.removeEventListener("mousedown", this.mousedown);
window.removeEventListener("mouseup", this.mouseup);
window.removeEventListener("mousemove", this.mousemove);
window.removeEventListener("wheel", this.mousewheel);
}
keydown(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 1;
break;
case "KeyS":
this.moveState.back = 1;
break;
case "KeyA":
this.moveState.left = 1;
break;
case "KeyD":
this.moveState.right = 1;
break;
case "KeyQ":
this.moveState.down = 1;
break;
case "KeyE":
this.moveState.up = 1;
break;
case "ArrowUp":
this.moveState.pitchUp = 1;
break;
case "ArrowDown":
this.moveState.pitchDown = 1;
break;
case "ArrowLeft":
this.moveState.yawLeft = 1;
break;
case "ArrowRight":
this.moveState.yawRight = 1;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
keyup(event: KeyboardEvent) {
if (!this.enabled || isPromptBoxFocused.value) {
return;
}
switch (event.code) {
case "KeyW":
this.moveState.forward = 0;
break;
case "KeyS":
this.moveState.back = 0;
break;
case "KeyA":
this.moveState.left = 0;
break;
case "KeyD":
this.moveState.right = 0;
break;
case "ArrowUp":
this.moveState.pitchUp = 0;
break;
case "ArrowDown":
this.moveState.pitchDown = 0;
break;
case "ArrowLeft":
this.moveState.yawLeft = 0;
break;
case "ArrowRight":
this.moveState.yawRight = 0;
break;
case "KeyQ":
this.moveState.down = 0;
break;
case "KeyE":
this.moveState.up = 0;
break;
}
this.updateMovementVector();
this.updateRotationVector();
}
mousedown(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.dragging = true;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
}
mouseup(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (event.button === 2) {
this.xOffset = event.clientX;
this.yOffset = event.clientY;
this.resetVelocity();
}
this.dragging = false;
}
mousemove(event: MouseEvent) {
if (!this.enabled) {
return;
}
if (this.dragging) {
const mouseX = event.clientX - this.xOffset;
const mouseY = event.clientY - this.yOffset;
this.xOffset = event.clientX;
this.yOffset = event.clientY;
if (Math.abs(mouseX + mouseY) > 0) {
// Store last position before movement
this.lastPosition.copy(this.object.position);
// Apply movement with smoothing
const translationX = -mouseX * (this.movementSpeed * 0.01);
const translationY = mouseY * (this.movementSpeed * 0.01);
this.currentVelocity.x = MathUtils.lerp(
this.currentVelocity.x,
translationX,
this.smoothingFactor,
);
this.currentVelocity.y = MathUtils.lerp(
this.currentVelocity.y,
translationY,
this.smoothingFactor,
);
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
}
}
}
mousewheel(event: WheelEvent) {
if (!this.enabled) {
return;
}
// Store last position before zoom
this.lastPosition.copy(this.object.position);
const zoomDelta = event.deltaY / 120;
this.currentVelocity.z = MathUtils.lerp(
this.currentVelocity.z,
zoomDelta,
this.smoothingFactor,
);
this.object.translateZ(this.currentVelocity.z);
}
resetVelocity() {
this.currentVelocity.set(0, 0, 0);
this.lastPosition.copy(this.object.position);
}
reset() {
this.moveState.forward = 0;
this.moveState.back = 0;
this.moveState.left = 0;
this.moveState.right = 0;
this.moveState.pitchUp = 0;
this.moveState.pitchDown = 0;
this.moveState.yawLeft = 0;
this.moveState.yawRight = 0;
this.moveState.down = 0;
this.moveState.up = 0;
this.resetVelocity();
this.updateMovementVector();
this.updateRotationVector();
}
isStationary() {
return (
this.moveVector.distanceTo(new Vector3(0, 0, 0)) === 0 &&
this.currentVelocity.distanceTo(new Vector3(0, 0, 0)) < 0.0001
);
}
isRotating() {
return (
this.moveState.pitchUp ||
this.moveState.pitchDown ||
this.moveState.yawLeft ||
this.moveState.yawRight
);
}
update(delta: number) {
if (!this.enabled) {
return;
}
// Skip update if there's no movement, velocity, or rotation
if (this.isStationary() && !this.isRotating()) {
return;
}
const moveMulti = delta * this.movementSpeed;
// Apply WASD movement with smoothing
const targetMovement = new Vector3(
this.moveVector.x * moveMulti,
this.moveVector.y * moveMulti,
this.moveVector.z * moveMulti,
);
// Smooth out the movement
this.currentVelocity.lerp(targetMovement, this.smoothingFactor);
// Apply the smoothed movement
this.object.translateX(this.currentVelocity.x);
this.object.translateY(this.currentVelocity.y);
this.object.translateZ(this.currentVelocity.z);
// Store last position
this.lastPosition.copy(this.object.position);
// Done with positions
// Now handle rotations
this.object.rotateX(this.rotationVector.x);
this.object.rotateY(this.rotationVector.y);
this.object.rotateZ(this.rotationVector.z);
// Update camera signals
if (selectedCameraId.value) {
const pos = this.object.position;
const rot = this.object.rotation;
// Calculate lookAt point based on camera's forward direction
const lookAtPoint = new Vector3(0, 0, -1);
lookAtPoint.applyQuaternion(this.object.quaternion);
lookAtPoint.add(pos);
cameras.value = cameras.value.map((cam) =>
cam.id === selectedCameraId.value
? {
...cam,
position: { x: pos.x, y: pos.y, z: pos.z },
rotation: { x: rot.x, y: rot.y, z: rot.z },
lookAt: { x: lookAtPoint.x, y: lookAtPoint.y, z: lookAtPoint.z },
}
: cam,
);
}
}
updateMovementVector() {
const forward =
this.moveState.forward || (this.autoForward && !this.moveState.back)
? 1
: 0;
this.moveVector.x = -this.moveState.left + this.moveState.right;
this.moveVector.y = -this.moveState.down + this.moveState.up;
this.moveVector.z = -forward + this.moveState.back;
}
updateRotationVector() {
this.rotationVector.x = -this.moveState.pitchDown + this.moveState.pitchUp;
this.rotationVector.y = -this.moveState.yawRight + this.moveState.yawLeft;
this.rotationVector.z = -this.moveState.rollRight + this.moveState.rollLeft;
this.rotationVector.multiplyScalar(this.rollSpeed);
}
getContainerDimensions() {
if (this.domElement !== document) {
return {
size: [
(this.domElement as HTMLElement).offsetWidth,
(this.domElement as HTMLElement).offsetHeight,
],
offset: [
(this.domElement as HTMLElement).offsetLeft,
(this.domElement as HTMLElement).offsetTop,
],
};
} else {
return {
size: [window.innerWidth, window.innerHeight],
offset: [0, 0],
};
}
}
dispose() {
// window.addEventListener('contextmenu', function(event) {
// event.preventDefault();
// });
// this.domElement.removeEventListener("contextmenu", this.contextmenu);
// this.domElement.removeEventListener("pointerdown", this.pointerdown);
// this.domElement.removeEventListener("pointermove", this.pointermove);
// this.domElement.removeEventListener("pointerup", this.pointerup);
// this.domElement.removeEventListener("pointercancel", this.pointercancel);
//
window.removeEventListener("keydown", this.keydown);
window.removeEventListener("keyup", this.keyup);
}
}
export { FreeCam };
@@ -117,7 +117,9 @@ export class SceneUtils {
switchCameraView() {
this.editor.camera_person_mode = !this.editor.camera_person_mode;
this.editor.cameraViewControls?.reset();
if (this.editor.freeCamState) {
this.editor.freeCamState.velocity.set(0, 0, 0);
}
if (this.editor.cam_obj) {
if (this.editor.camera_person_mode && this.editor.camera) {
this.editor.last_cam_pos.copy(this.editor.camera.position);
@@ -131,9 +133,8 @@ export class SceneUtils {
this.editor.lockControls.getObject(),
);
}
if (this.editor.cameraViewControls) {
this.editor.cameraViewControls.enabled = true;
}
// useFreeCam reads editorState from the store and enables
// itself when CAMERA_VIEW; nothing to do here.
this.editor.cam_obj.scale.set(0, 0, 0);
this.removeTransformControls();
@@ -12,7 +12,8 @@ import {
} from "three/examples/jsm/Addons.js";
import { TransformControls } from "./TransformControls";
import { SceneManager, SceneObject } from "./scene_manager_api";
import { FreeCam, isPointerLockSupported } from "./free_cam";
import { isPointerLockSupported } from "./browserChecks";
import type { FreeCamControlState } from "./cameraMath";
import { FKHelper } from "./KinHelpers/FKHelper";
import { Euler } from "three";
@@ -81,7 +82,7 @@ export class MouseControls {
setSelected: Function;
sceneManager: SceneManager | undefined;
private isProcessing: boolean = false;
private cameraViewControls: FreeCam;
private cameraViewControls: FreeCamControlState | null;
private isMouseClicked: boolean = false;
private isMovable: Function;
enable_stats: Function;
@@ -95,7 +96,7 @@ export class MouseControls {
constructor(
camera: THREE.PerspectiveCamera,
camera_person_mode: boolean,
cameraViewControls: FreeCam,
cameraViewControls: FreeCamControlState | null,
lockControls: PointerLockControls | undefined,
camera_last_pos: THREE.Vector3,
selectedCanvas: boolean,
@@ -437,12 +438,14 @@ export class MouseControls {
event.stopPropagation();
}
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 3;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 0.75;
if (this.cameraViewControls) {
if (event.shiftKey) {
this.cameraViewControls.movementSpeed = 3;
} else if (event.altKey) {
this.cameraViewControls.movementSpeed = 0.1;
} else {
this.cameraViewControls.movementSpeed = 0.75;
}
}
if (event.key === "Escape") {
@@ -0,0 +1,148 @@
import { useEffect, useRef } from "react";
import { MathUtils } from "three";
import {
createFreeCamControlState,
emptyMoveKeys,
emptyRotateKeys,
moveSlotForKeyCode,
panFromDrag,
rotateSlotForKeyCode,
zoomFromWheel,
type FreeCamControlState,
} from "~/pages/PageScene/engine/cameraMath";
import { isPromptBoxFocused } from "~/pages/PageScene/signals/promptBox";
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
import { EditorStates } from "~/pages/PageScene/enums";
import type Editor from "~/pages/PageScene/engine/editor";
// Drives the 3D viewport's camera while the editor is in CAMERA_VIEW.
// Owns:
// - Listener attachment to the canvas (no window/document handlers).
// - Drag state + held-key state in refs.
// - The shared FreeCamControlState handed to the editor's render
// loop via setFreeCamState.
//
// The editor reads this state on every frame (see editor.ts render
// loop) and integrates it via freeCamFrameTick. There is no FreeCam
// class instance — the math lives in cameraMath.ts.
export const useFreeCam = (
canvas: HTMLCanvasElement | null,
editor: Editor | null,
) => {
const stateRef = useRef<FreeCamControlState>(createFreeCamControlState());
const dragRef = useRef<{ x: number; y: number; pointerId: number } | null>(
null,
);
const enabled = usePageSceneStore(
(s) => s.editorState === EditorStates.CAMERA_VIEW,
);
// Hand the state to the editor so its render loop can integrate it.
useEffect(() => {
if (!editor) return;
editor.setFreeCamState(stateRef.current);
return () => editor.setFreeCamState(null);
}, [editor]);
// Toggle enabled and clear motion when leaving CAMERA_VIEW so the
// camera doesn't drift on a stale held key.
useEffect(() => {
const state = stateRef.current;
state.enabled = enabled;
if (!enabled) {
state.moveKeys = emptyMoveKeys();
state.rotateKeys = emptyRotateKeys();
state.velocity.set(0, 0, 0);
dragRef.current = null;
}
}, [enabled]);
// Attach listeners to the canvas. Canvas-scoped means no listener
// leaks beyond the viewport, and no need for mouseover gating.
useEffect(() => {
if (!canvas || !enabled || !editor) return;
const state = stateRef.current;
const onKeyDown = (e: KeyboardEvent) => {
if (isPromptBoxFocused.value) return;
const moveSlot = moveSlotForKeyCode(e.code);
if (moveSlot) state.moveKeys[moveSlot] = 1;
const rotateSlot = rotateSlotForKeyCode(e.code);
if (rotateSlot) state.rotateKeys[rotateSlot] = 1;
};
const onKeyUp = (e: KeyboardEvent) => {
if (isPromptBoxFocused.value) return;
const moveSlot = moveSlotForKeyCode(e.code);
if (moveSlot) state.moveKeys[moveSlot] = 0;
const rotateSlot = rotateSlotForKeyCode(e.code);
if (rotateSlot) state.rotateKeys[rotateSlot] = 0;
};
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 2) return;
dragRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
state.velocity.set(0, 0, 0);
try {
canvas.setPointerCapture(e.pointerId);
} catch {
// pointerCapture can throw if pointer is already captured elsewhere
}
};
const onPointerUp = (e: PointerEvent) => {
if (!dragRef.current || dragRef.current.pointerId !== e.pointerId) return;
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
// ignore: capture may already be released
}
dragRef.current = null;
state.velocity.set(0, 0, 0);
};
const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current;
if (!drag || !editor.camera) return;
const dx = e.clientX - drag.x;
const dy = e.clientY - drag.y;
drag.x = e.clientX;
drag.y = e.clientY;
if (Math.abs(dx) + Math.abs(dy) === 0) return;
const pan = panFromDrag(dx, dy, state.movementSpeed);
state.velocity.x = MathUtils.lerp(state.velocity.x, pan.x, state.smoothing);
state.velocity.y = MathUtils.lerp(state.velocity.y, pan.y, state.smoothing);
editor.camera.translateX(state.velocity.x);
editor.camera.translateY(state.velocity.y);
};
const onWheel = (e: WheelEvent) => {
if (!editor.camera) return;
const z = zoomFromWheel(e.deltaY);
state.velocity.z = MathUtils.lerp(state.velocity.z, z, state.smoothing);
editor.camera.translateZ(state.velocity.z);
};
const onContextMenu = (e: Event) => e.preventDefault();
canvas.addEventListener("keydown", onKeyDown);
canvas.addEventListener("keyup", onKeyUp);
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("wheel", onWheel, { passive: true });
canvas.addEventListener("contextmenu", onContextMenu);
return () => {
canvas.removeEventListener("keydown", onKeyDown);
canvas.removeEventListener("keyup", onKeyUp);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("contextmenu", onContextMenu);
};
}, [canvas, editor, enabled]);
};
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated