From 3bfb405e8e5ec73a86898facedb223b12936f71d Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 1 May 2026 04:06:08 -0700 Subject: [PATCH] 3D SaveManager cleanup and History Manager architecture (#1466) --- .../pages/PageScene/DragAndDrop/DndAsset.ts | 18 +- .../app/src/pages/PageScene/PageEditor.tsx | 70 ++-- .../pages/PageScene/actions/addCharacter.ts | 3 + .../src/pages/PageScene/actions/addObject.ts | 3 + .../src/pages/PageScene/actions/addShape.ts | 12 +- .../app/src/pages/PageScene/actions/index.ts | 4 + .../pages/PageScene/actions/setObjectColor.ts | 22 ++ .../PageScene/actions/toggleObjectLock.ts | 18 + .../actions/toggleObjectVisibility.ts | 21 ++ .../PageScene/actions/transformObject.ts | 39 +++ .../ControlPanelSceneObject.tsx | 56 +++- .../PageScene/comps/Outliner/Outliner.tsx | 10 +- .../src/pages/PageScene/engine/Commands.ts | 169 ---------- .../app/src/pages/PageScene/engine/editor.ts | 78 +++-- .../engine/editor/CameraController.ts | 11 + .../PageScene/engine/editor/HistoryManager.ts | 105 ++++++ .../engine/editor/SelectionBridge.ts | 30 +- .../engine/editor/actions/ColorAction.ts | 24 ++ .../engine/editor/actions/CreateAction.ts | 36 ++ .../engine/editor/actions/DeleteAction.ts | 35 ++ .../engine/editor/actions/LockAction.ts | 29 ++ .../engine/editor/actions/TransformAction.ts | 58 ++++ .../engine/editor/actions/VisibilityAction.ts | 38 +++ .../engine/editor/actions/snapshots.ts | 108 ++++++ .../app/src/pages/PageScene/engine/helper.ts | 7 + .../app/src/pages/PageScene/engine/keymap.ts | 14 +- .../PageScene/engine/pickDropPosition.ts | 41 +++ .../pages/PageScene/engine/save_manager.ts | 273 ++++++--------- .../app/src/pages/PageScene/engine/scene.ts | 5 + .../PageScene/engine/scene_manager_api.ts | 314 +----------------- 30 files changed, 906 insertions(+), 745 deletions(-) create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectLock.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectVisibility.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/actions/transformObject.ts delete mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/Commands.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/HistoryManager.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/ColorAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/CreateAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/DeleteAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/LockAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/TransformAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/VisibilityAction.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/snapshots.ts create mode 100644 frontend/apps/artcraft/app/src/pages/PageScene/engine/pickDropPosition.ts diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/DragAndDrop/DndAsset.ts b/frontend/apps/artcraft/app/src/pages/PageScene/DragAndDrop/DndAsset.ts index 92db953d7e..466873216d 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/DragAndDrop/DndAsset.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/DragAndDrop/DndAsset.ts @@ -4,6 +4,7 @@ import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; import { pageHeight, pageWidth } from "~/signals"; import { AssetType } from "~/enums"; import type Editor from "~/pages/PageScene/engine/editor"; +import { pickDropPosition } from "~/pages/PageScene/engine/pickDropPosition"; import { addCharacter, addObject, @@ -88,16 +89,27 @@ class DndAsset { const positionX = event.pageX; const positionY = event.pageY; if (this.overCanvas(positionX, positionY)) { + const worldPosition = pickDropPosition( + { + getCamera: () => editor.cameraController.camera, + getCanvas: () => editor.renderer?.domElement, + getRaycastTargets: () => editor.activeScene.scene.children, + removeTransformControls: () => + editor.utils.removeTransformControls(true), + }, + positionX, + positionY, + ); if (mediaItem.type === AssetType.CHARACTER) { - void addCharacter(editor, mediaItem); + void addCharacter(editor, mediaItem, worldPosition); } else if ( mediaItem.type === AssetType.OBJECT || mediaItem.type === AssetType.SPLAT || mediaItem.type === AssetType.SKYBOX ) { - void addObject(editor, mediaItem); + void addObject(editor, mediaItem, worldPosition); } else if (mediaItem.type === AssetType.SHAPE) { - void addShape(editor, mediaItem); + void addShape(editor, mediaItem, worldPosition); } } } diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx index 5fad357d7d..aca263e1f4 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/PageEditor.tsx @@ -50,7 +50,7 @@ import { AssetType, AUTH_STATUS } from "~/enums"; import { v4 as uuidv4 } from "uuid"; import { MediaItem } from "~/pages/PageScene/models"; import { UploaderState } from "~/models"; -import * as THREE from "three"; +import { pickDropPosition } from "./engine/pickDropPosition"; import { GalleryItem, onImageDrop, @@ -186,32 +186,30 @@ export const PageEditor = () => { }, 1500); // Update the main camera to match the selected camera's properties - if (editorEngine.camera) { - // First update position and lookAt - editorEngine.camera.position.set( + const cam = editorEngine.cameraController.camera; + if (cam) { + cam.position.set( selectedCamera.position.x, selectedCamera.position.y, selectedCamera.position.z, ); - editorEngine.camera.lookAt( + cam.lookAt( selectedCamera.lookAt.x, selectedCamera.lookAt.y, selectedCamera.lookAt.z, ); - // Update FOV - editorEngine.camera.fov = editorEngine.focalLengthToFov( + cam.fov = editorEngine.cameraController.focalLengthToFov( selectedCamera.focalLength, ); - editorEngine.camera.updateProjectionMatrix(); + cam.updateProjectionMatrix(); // 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); + if (editorEngine.cameraController.freeCamState) { + editorEngine.cameraController.freeCamState.velocity.set(0, 0, 0); } - // Force a render to update the view editorEngine.renderScene(); } @@ -262,19 +260,19 @@ export const PageEditor = () => { setSelectedCameraId(newId); // Update the engine camera to match the new camera's properties - if (editorEngine && editorEngine.camera) { - editorEngine.camera.position.set(randomX, randomY, randomZ); - editorEngine.camera.lookAt(0, 0, 0); - editorEngine.camera.fov = editorEngine.focalLengthToFov(24); - editorEngine.camera.updateProjectionMatrix(); + const cam = editorEngine?.cameraController.camera; + if (editorEngine && cam) { + cam.position.set(randomX, randomY, randomZ); + cam.lookAt(0, 0, 0); + cam.fov = editorEngine.cameraController.focalLengthToFov(24); + cam.updateProjectionMatrix(); // Reset free-cam motion so the camera doesn't drift after // teleport. - if (editorEngine.freeCamState) { - editorEngine.freeCamState.velocity.set(0, 0, 0); + if (editorEngine.cameraController.freeCamState) { + editorEngine.cameraController.freeCamState.velocity.set(0, 0, 0); } - // Force a render to update the view editorEngine.renderScene(); } }; @@ -303,33 +301,23 @@ export const PageEditor = () => { if (tabStore.activeTabId === "3D") { handler = onImageDrop( (item: GalleryItem, position: { x: number; y: number }) => { - console.log("3D Drop debug (event):", { - editorEngine, - camera: editorEngine?.camera, - renderer: editorEngine?.renderer, - position, - }); - - // Calculate world position from cursor immediately on drop - let worldPosition = undefined; - if (editorEngine?.camera && editorEngine?.renderer && position) { - const rect = - editorEngine.renderer.domElement.getBoundingClientRect(); - // Convert client coordinates to canvas coordinates - const canvasX = position.x - rect.left; - const canvasY = position.y - rect.top; - const ndcX = (canvasX / rect.width) * 2 - 1; - const ndcY = -(canvasY / rect.height) * 2 + 1; - const vector = new THREE.Vector3(ndcX, ndcY, 0.5); - vector.unproject(editorEngine.camera); - worldPosition = vector; - } - (async () => { if (!editorEngine) { console.warn("Cannot drop asset: editor engine not ready"); return; } + const worldPosition = pickDropPosition( + { + getCamera: () => editorEngine.cameraController.camera, + getCanvas: () => editorEngine.renderer?.domElement, + getRaycastTargets: () => + editorEngine.activeScene.scene.children, + removeTransformControls: () => + editorEngine.utils.removeTransformControls(true), + }, + position.x, + position.y, + ); try { if (item.mediaClass === "dimensional") { const isCharacter = item.assetType === "character"; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addCharacter.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addCharacter.ts index a91f04c177..4b2561cbcd 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addCharacter.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addCharacter.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { usePageSceneStore } from "../PageSceneStore"; +import { CreateAction } from "../engine/editor/actions/CreateAction"; export async function addCharacter( editor: Editor, @@ -17,6 +18,8 @@ export async function addCharacter( obj.userData.isCharacter = true; + editor.history.record(new CreateAction(editor, obj)); + usePageSceneStore.getState().addCharacter({ id: obj.uuid, kind: "character", diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addObject.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addObject.ts index af0e476d82..813a76cc7c 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addObject.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addObject.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { usePageSceneStore } from "../PageSceneStore"; +import { CreateAction } from "../engine/editor/actions/CreateAction"; export async function addObject( editor: Editor, @@ -15,6 +16,8 @@ export async function addObject( ); if (!obj) return undefined; + editor.history.record(new CreateAction(editor, obj)); + usePageSceneStore.getState().addObject({ id: obj.uuid, kind: "object", diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addShape.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addShape.ts index 65f6bff292..07b770d86b 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/actions/addShape.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/addShape.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import type Editor from "../engine/editor"; import { MediaItem } from "../models"; import { usePageSceneStore } from "../PageSceneStore"; +import { CreateAction } from "../engine/editor/actions/CreateAction"; export async function addShape( editor: Editor, @@ -9,16 +10,23 @@ export async function addShape( position?: THREE.Vector3, ): Promise { const obj = await editor.sceneManager?.create( + "Parim", item.media_id, - item.name ?? "shape", position ?? new THREE.Vector3(), ); if (!obj) return undefined; + // Stash the geometry key (Box / Sphere / PointLight / ...) so undo/redo + // can re-route through scene.instantiate's name switch. obj.name gets + // overridden below with the display label ("Cube", "Point Light"). + obj.userData.shapeKey = item.media_id; + obj.name = item.name ?? "shape"; + + editor.history.record(new CreateAction(editor, obj)); usePageSceneStore.getState().addShape({ id: obj.uuid, kind: "shape", - name: obj.name || (item.name ?? "shape"), + name: obj.name, mediaId: item.media_id, }); editor.selection.refreshOutliner(); diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/index.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/index.ts index cf2a381356..4a50d297fc 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/actions/index.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/index.ts @@ -5,3 +5,7 @@ export * from "./deleteObject"; export * from "./selectObject"; export * from "./setTransformMode"; export * from "./setCameraAspect"; +export * from "./setObjectColor"; +export * from "./toggleObjectLock"; +export * from "./toggleObjectVisibility"; +export * from "./transformObject"; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts new file mode 100644 index 0000000000..7ab53ec968 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/setObjectColor.ts @@ -0,0 +1,22 @@ +import type Editor from "../engine/editor"; +import { ColorAction } from "../engine/editor/actions/ColorAction"; + +// Apply a color change to an object: writes the new hex through +// activeScene.setColor (which handles material/Chroma plumbing), and +// records a ColorAction on the undo stack with the before-state read +// from the live object so the helper is self-contained. +// +// View callers only need to provide editor + uuid + new hex; the +// helper handles engine + history orchestration. +export function setObjectColor( + editor: Editor, + uuid: string, + color: string, +): void { + const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); + if (!obj) return; + const before = (obj.userData.color as string) ?? "#ffffff"; + if (before === color) return; + editor.activeScene.setColor(uuid, color); + editor.history.record(new ColorAction(editor, uuid, before, color)); +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectLock.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectLock.ts new file mode 100644 index 0000000000..63b66a0a47 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectLock.ts @@ -0,0 +1,18 @@ +import type Editor from "../engine/editor"; +import { LockAction } from "../engine/editor/actions/LockAction"; +import { usePageSceneStore } from "../PageSceneStore"; + +// Toggle the locked state of an object: flips userData.locked, runs +// the gizmo attach/detach side effect, syncs the outliner row icon in +// the Zustand store, and records the toggle on the undo stack. +// +// View callers (Outliner) only need to provide editor + uuid; the +// action handles the engine + store + history orchestration. +export function toggleObjectLock(editor: Editor, uuid: string): void { + const before = editor.selection.isObjectLocked(uuid); + usePageSceneStore.getState().toggleOutlinerLock(uuid); + editor.selection.lockUnlockObject(uuid); + editor.history.record( + new LockAction(editor.selection, uuid, before, !before), + ); +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectVisibility.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectVisibility.ts new file mode 100644 index 0000000000..c51ca1c0a2 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/toggleObjectVisibility.ts @@ -0,0 +1,21 @@ +import type Editor from "../engine/editor"; +import { VisibilityAction } from "../engine/editor/actions/VisibilityAction"; +import { usePageSceneStore } from "../PageSceneStore"; + +// Toggle the visibility of an object: flips obj.visible + +// userData.visible, syncs the outliner row icon in the Zustand store, +// and records the toggle on the undo stack. +// +// View callers (Outliner) only need to provide editor + uuid; the +// helper reads the before-state from the live engine object so the +// view doesn't have to plumb it. +export function toggleObjectVisibility(editor: Editor, uuid: string): void { + const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); + if (!obj) return; + const before = obj.visible; + usePageSceneStore.getState().toggleOutlinerVisibility(uuid); + editor.sceneManager?.hideObject(uuid); + editor.history.record( + new VisibilityAction(editor, uuid, before, !before), + ); +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/actions/transformObject.ts b/frontend/apps/artcraft/app/src/pages/PageScene/actions/transformObject.ts new file mode 100644 index 0000000000..4cdf449645 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/actions/transformObject.ts @@ -0,0 +1,39 @@ +import type Editor from "../engine/editor"; +import { TransformAction } from "../engine/editor/actions/TransformAction"; + +// A transform "session" — opened on the first edit of a multi-input +// editing flow (e.g. Object Panel position/rotation/scale text fields), +// committed at the end of the session (selection change, panel +// unmount, focus moving away from the panel). +// +// The session captures the before-state at construction. On commit it +// captures the after-state, pushes a single TransformAction onto the +// undo stack only if the transform actually changed, and re-syncs the +// Zustand `objectPanel` slice from the engine so the panel's view of +// the selection matches the engine state. +// +// Engine-level concerns (capture / apply / revert) stay in +// `engine/editor/actions/TransformAction.ts`; this file is the +// orchestration wrapper that the action layer (UI code) uses so the +// view never has to construct an UndoableAction directly. +export type TransformSession = { + commit(): void; +}; + +export function beginTransformSession( + editor: Editor, + uuid: string, +): TransformSession { + const action = new TransformAction(editor, uuid); + return { + commit() { + if (action.commit()) { + editor.history.record(action); + } + // Push the engine's post-edit transform back into the Zustand + // objectPanel slice so the panel's currentSceneObject vectors + // are in sync with what the user just committed. Idempotent. + editor.selection.updateSelectedUI(); + }, + }; +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx index 8dbf2b2823..9e7f0759dd 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx @@ -1,4 +1,11 @@ -import { ChangeEvent, useContext, useEffect, useId, useState } from "react"; +import { + ChangeEvent, + useContext, + useEffect, + useId, + useRef, + useState, +} from "react"; import { Transition } from "@headlessui/react"; import { faChevronDown, @@ -18,6 +25,11 @@ import { objectMismatch } from "~/pages/PageScene/comps/ControlPanelSceneObject/ import { XYZ } from "~/pages/PageScene/datastructures/common"; import { pageHeight } from "~/signals"; import { DraggablePrecisionMutator } from "./DraggablePrecisionMutator"; +import { setObjectColor } from "~/pages/PageScene/actions/setObjectColor"; +import { + beginTransformSession, + TransformSession, +} from "~/pages/PageScene/actions/transformObject"; // TODO this will be useful later to fix the bug on leading zeros // const formatNumber = (input: string): number => { @@ -57,6 +69,26 @@ export const ControlPanelSceneObject = () => { const [color, setColor] = useState("#ffffff"); + // Pending transform session — opened on first panel edit, committed + // on selection change / unmount. The action layer (transformObject) + // owns recording + Zustand sync; this view just opens and closes the + // session boundary. + const transformSessionRef = useRef(null); + + const beginPanelTransform = () => { + if (transformSessionRef.current) return; + if (!editorEngine || !currentSceneObject) return; + transformSessionRef.current = beginTransformSession( + editorEngine, + currentSceneObject.object_uuid, + ); + }; + + const commitPanelTransform = () => { + transformSessionRef.current?.commit(); + transformSessionRef.current = null; + }; + const colorInputId = useId(); const toggleCollapse = () => { @@ -105,6 +137,11 @@ export const ControlPanelSceneObject = () => { setLocked(editorEngine.selection.isObjectLocked(editorEngine?.selected?.uuid || "")); setColor(editorEngine?.selected?.userData.color); + + // On selection change / unmount, commit any in-flight panel + // transform from the previous selection. The action references + // its captured uuid, so commit still targets the right object. + return () => commitPanelTransform(); }, [currentSceneObject, editorEngine]); if (!currentSceneObject) { @@ -126,6 +163,7 @@ export const ControlPanelSceneObject = () => { } const cleanXyz = sanitize(xyz); if (objectMismatch(localPosition, cleanXyz)) { + beginPanelTransform(); setInputsUpdated(true); } setLocalPosition(xyz); @@ -138,6 +176,7 @@ export const ControlPanelSceneObject = () => { } const cleanXyz = sanitize(xyz); if (objectMismatch(localRotation, cleanXyz)) { + beginPanelTransform(); setInputsUpdated(true); } setLocalRotation(xyz); @@ -159,6 +198,7 @@ export const ControlPanelSceneObject = () => { } const cleanXyz = sanitize(xyz); if (objectMismatch(localScale, cleanXyz)) { + beginPanelTransform(); setInputsUpdated(true); } setLocalScale(xyz); @@ -220,11 +260,15 @@ export const ControlPanelSceneObject = () => { className="h-0 w-0 cursor-pointer opacity-0" id={colorInputId} onChange={(e: ChangeEvent) => { - editorEngine?.activeScene.setColor( - editorEngine?.selected?.uuid || "", - e.target.value, - ); - setColor(e.target.value); + // fires onChange once per committed + // pick (when the picker closes), so the action helper + // gets exactly one record per user-visible color change. + const uuid = editorEngine?.selected?.uuid; + const after = e.target.value; + if (uuid && editorEngine) { + setObjectColor(editorEngine, uuid, after); + } + setColor(after); }} type="color" value={color} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/comps/Outliner/Outliner.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/comps/Outliner/Outliner.tsx index 066406f3f5..481b7c32c5 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/comps/Outliner/Outliner.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/comps/Outliner/Outliner.tsx @@ -19,6 +19,8 @@ import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore"; import { pageHeight, pageWidth } from "~/signals"; import { CameraAspectRatio } from "../../enums"; import { effect } from "@preact/signals-react"; +import { toggleObjectLock } from "../../actions/toggleObjectLock"; +import { toggleObjectVisibility } from "../../actions/toggleObjectVisibility"; const OutlinerRow = ({ item }: { item: OutlinerItem }) => { const [hovered, setHovered] = useState(false); @@ -47,14 +49,14 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => { const handleToggleLock = (e: React.MouseEvent) => { e.stopPropagation(); - usePageSceneStore.getState().toggleOutlinerLock(item.id); - editorEngine?.selection.lockUnlockObject(item.id); + if (!editorEngine) return; + toggleObjectLock(editorEngine, item.id); }; const handleToggleVisibility = (e: React.MouseEvent) => { e.stopPropagation(); - usePageSceneStore.getState().toggleOutlinerVisibility(item.id); - editorEngine?.sceneManager?.hideObject(item.id); + if (!editorEngine) return; + toggleObjectVisibility(editorEngine, item.id); }; return ( diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/Commands.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/Commands.ts deleted file mode 100644 index 50bbe60715..0000000000 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/Commands.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { SceneManager } from "./scene_manager_api"; -import * as THREE from "three"; - -export interface ICommand { - undo(): Promise; - redo(): Promise; -} - -export interface TransformData { - object_uuid: string; - position: THREE.Vector3; - rotation: THREE.Euler; - scale: THREE.Vector3; -} - -export interface UserData { - object_uuid: string; - color: string; - visible: boolean; - locked: boolean; -} - -export interface CreationData { - object_uuid: string; - name: string; - position: THREE.Vector3; - userdata: Record; -} - -export interface DeletionData { - object_uuid: string; - name: string; - position: THREE.Vector3; - userdata: Record; -} - -export interface SceneState { - scene_objects: string[]; - scene_names: string[]; - scene_userdata: string[]; - scene_positions: string[]; - scene_rotations: string[]; - scene_scales: string[]; -} - -export type CommandInputTypes = TransformData | CreationData | DeletionData; - -export class CreationSceneItem implements ICommand { - private component: SceneManager; - private creationData: CreationData; - - public constructor(sceneManager: SceneManager, creationData: CreationData) { - this.component = sceneManager; - this.creationData = creationData; - } - - public async undo(): Promise { - this.component.delete(this.creationData.object_uuid); - } - - public async redo(): Promise { - const object = await this.component.create( - this.creationData.userdata["media_id"], - this.creationData.name, - this.creationData.position, - ); - object.position.copy(this.creationData.position); - object.uuid = this.creationData.object_uuid; - if (this.creationData.userdata["visible"] !== undefined) { - object.visible = this.creationData.userdata["visible"]; - } - this.component.scene.setColor( - object.uuid, - this.creationData.userdata["color"], - ); - } -} - -export class DeletionSceneItem implements ICommand { - private component: SceneManager; - private deletionData: DeletionData; - - public constructor(sceneManager: SceneManager, creationData: DeletionData) { - this.component = sceneManager; - this.deletionData = creationData; - } - - public async undo(): Promise { - const object = await this.component.create( - this.deletionData.userdata["media_id"], - this.deletionData.name, - this.deletionData.position, - ); - object.uuid = this.deletionData.object_uuid; - object.visible = this.deletionData.userdata["visible"]; - this.component.scene.setColor( - object.uuid, - this.deletionData.userdata["color"], - ); - } - - public async redo(): Promise { - this.component.delete(this.deletionData.object_uuid); - } -} - -export class TransformSceneItem implements ICommand { - private component: SceneManager; - private transformDataStart: TransformData; - private transformDataEnd: TransformData; - - public constructor( - sceneManager: SceneManager, - transformDataStart: TransformData, - transformDataEnd: TransformData, - ) { - this.component = sceneManager; - this.transformDataStart = transformDataStart; - this.transformDataEnd = transformDataEnd; - } - - public async undo(): Promise { - this.component.update( - this.transformDataStart.object_uuid, - this.transformDataStart.position, - this.transformDataStart.rotation, - this.transformDataStart.scale, - ); - } - - public async redo(): Promise { - this.component.update( - this.transformDataEnd.object_uuid, - this.transformDataEnd.position, - this.transformDataEnd.rotation, - this.transformDataEnd.scale, - ); - } -} - -export class UserDataSceneItem implements ICommand { - private component: SceneManager; - private startUserData: UserData; - private endUserData: UserData; - - public constructor( - sceneManager: SceneManager, - startUserData: UserData, - endUserData: UserData, - ) { - this.component = sceneManager; - this.startUserData = startUserData; - this.endUserData = endUserData; - } - - public async undo(): Promise { - this.component.scene.setColor( - this.startUserData.object_uuid, - this.startUserData.color, - ); - } - - public async redo(): Promise { - this.component.scene.setColor( - this.endUserData.object_uuid, - this.endUserData.color, - ); - } -} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts index 9911a8efa7..debfce771d 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts @@ -22,6 +22,9 @@ import { PostProcessingPipeline } from "./editor/PostProcessingPipeline"; import { GizmoController } from "./editor/GizmoController"; import { SelectionBridge } from "./editor/SelectionBridge"; import { CameraController } from "./editor/CameraController"; +import { HistoryManager } from "./editor/HistoryManager"; +import { DeleteAction } from "./editor/actions/DeleteAction"; +import { TransformAction } from "./editor/actions/TransformAction"; import Stats from "three/examples/jsm/libs/stats.module.js"; import { SparkRenderer } from "@sparkjsdev/spark"; @@ -64,13 +67,9 @@ class Editor { frames: number; lastFrameTime: number; - current_scene_media_token: string | null; - current_scene_glb_media_token: string | null; - can_initialize: boolean; positive_prompt: string; - generating_preview: boolean = false; // Owns canvas/container DOM refs and the resize cascade. viewport: ViewportController; @@ -82,6 +81,14 @@ class Editor { selection: SelectionBridge; // Owns the camera state, FreeCam plumbing, and the per-frame camera tick. cameraController: CameraController; + // Owns the undo/redo stack. Mutation sites push UndoableAction + // instances via editor.history.record(...); each action class under + // engine/editor/actions/ encapsulates its own apply/revert. + history: HistoryManager; + + // Holds the in-flight transform action between gizmo dragstart and + // dragend. Null whenever no drag is in progress. + private activeTransform: TransformAction | null = null; // Forwarding getter — ControlPanelSceneObject reads `editor.selected`. get selected(): THREE.Object3D | undefined { @@ -158,6 +165,8 @@ class Editor { cameraName: this.cameraController.camera_name, version: this.version, toggleObjectLocked: (uuid) => this.utils.toggleObjectLocked(uuid), + setObjectLocked: (uuid, locked) => + this.utils.setObjectLocked(uuid, locked), isObjectLocked: (uuid) => this.utils.isObjectLocked(uuid), removeTransformControls: () => this.utils.removeTransformControls(false), @@ -187,7 +196,25 @@ class Editor { this.shouldRender = false; this.utils = new SceneUtils(this, this.activeScene); - this.save_manager = new SaveManager(this); + this.save_manager = new SaveManager({ + getVersion: () => this.version, + setVersion: (v) => { + this.version = v; + }, + getActiveScene: () => this.activeScene, + getRenderer: () => this.renderer, + removeTransformControls: () => this.utils.removeTransformControls(), + getCamera: () => this.cameraController.camera, + refreshCamObj: () => + this.cameraController.refreshCamObj(this.activeScene.scene), + changeRenderCameraAspectRatio: (ratio) => + this.cameraController.changeRenderCameraAspectRatio(ratio), + setPositivePrompt: (prompt) => { + this.positive_prompt = prompt; + }, + saveSceneState: (args) => this.api_manager.saveSceneState(args), + loadSceneState: (token) => this.api_manager.loadSceneState(token), + }); this.viewport = new ViewportController({ getCamera: () => this.cameraController.camera, getRenderCamera: () => this.cameraController.render_camera, @@ -197,9 +224,9 @@ class Editor { resizePostProcessing: (w, h) => this.postProcessing.resize(w, h), }); - // Scene State - this.current_scene_media_token = null; - this.current_scene_glb_media_token = null; + // Action classes under engine/editor/actions/ encapsulate their own + // apply/revert + dependencies. HistoryManager just stores them. + this.history = new HistoryManager({ capacity: 64 }); this.positive_prompt = "((masterpiece, best quality, 8K, detailed)), colorful, epic, fantasy, (fox, red fox:1.2), no humans, 1other, ((koi pond)), outdoors, pond, rocks, stones, koi fish, ((watercolor))), lilypad, fish swimming around."; @@ -348,6 +375,20 @@ class Editor { new THREE.Vector3(-99999, -99999, -99999), ); this.focused = !dragging; + // Gizmo drag boundary → TransformAction. Begin captures the + // pre-drag transform in the constructor; end commits the diff + // (or drops a no-op move). + if (dragging) { + const target = this.sceneManager?.selected_objects?.[0]; + if (target) { + this.activeTransform = new TransformAction(this, target.uuid); + } + } else if (this.activeTransform) { + if (this.activeTransform.commit()) { + this.history.record(this.activeTransform); + } + this.activeTransform = null; + } }, }, ); @@ -363,13 +404,7 @@ class Editor { this.viewport.setupResizeObserver(); - // saving state of the scene - this.current_scene_media_token = null; - this.current_scene_glb_media_token = null; - - this.cameraController.cam_obj = this.activeScene.get_object_by_name( - this.cameraController.camera_name, - ); + this.cameraController.refreshCamObj(this.activeScene.scene); this.mouse_controls = new MouseControls( this.cameraController.camera, @@ -399,10 +434,7 @@ class Editor { this.version, this.mouse_controls, this.activeScene, - true, - this.selection.updateOutliner.bind(this.selection), - this.selection.isCharacterUuid.bind(this.selection), - ); // Enabled dev mode. + ); this.mouse_controls.sceneManager = this.sceneManager; // Add spark renderer as a child of the camera @@ -658,16 +690,14 @@ class Editor { } deleteObject(uuid: string) { + const obj = this.activeScene.scene.getObjectByProperty("uuid", uuid); + if (obj) this.history.record(new DeleteAction(this, obj)); this.mouse_controls?.clearFKVisuals(); this.mouse_controls?.removeTransformControls(true); this.utils.deleteObject(uuid); this.selection.refreshOutliner(); } - async create_parim(name: string, pos: THREE.Vector3) { - return await this.activeScene.instantiate(name, pos); - } - // Render the scene to the camera, this is called in the update. async renderScene() { const { render_camera, render_width, render_height } = this.cameraController; @@ -778,14 +808,12 @@ class Editor { this.isMounted = true; this.startRenderLoop(); - this.sceneManager?.attachEventListeners(); console.log("3D Editor Engine remounted"); } unmountEngine() { setIs3DSceneLoaded(false); this.stopRenderLoop(); - this.sceneManager?.detachEventListeners(); // Fix: dispose 3D contexts this.renderer?.dispose(); diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/CameraController.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/CameraController.ts index e50c9585b2..f80f1458db 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/CameraController.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/CameraController.ts @@ -63,6 +63,17 @@ export class CameraController { this.render_height = dims.height; } + // Re-anchor the camera-person object to the named entity in the scene + // and put it on layer 1 (along with its children). Used after + // load/restore flows when a fresh scene replaces the previous one. + refreshCamObj(scene: THREE.Scene): void { + this.cam_obj = scene.getObjectByName(this.camera_name); + this.cam_obj?.layers.set(1); + this.cam_obj?.children.forEach((child) => { + child.layers.set(1); + }); + } + // Toggles between free-camera (edit) mode and the active render // camera's perspective. Lifted from SceneUtils.switchCameraView so // the camera state stays in one place. diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/HistoryManager.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/HistoryManager.ts new file mode 100644 index 0000000000..c9f6668202 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/HistoryManager.ts @@ -0,0 +1,105 @@ +// HistoryManager owns the undo/redo stack for the 3D editor. +// +// Architecture (Command pattern): +// - UndoableAction is the only interface this class knows about. Each +// action kind is implemented as a self-contained class under +// engine/editor/actions/ that captures its own state and dependencies. +// - The external API is record(action) + undo() + redo(). Adding a new +// action kind never touches this file — it's a new class file under +// actions/ that implements UndoableAction. +// - undo/redo serialize through a Promise chain so concurrent calls +// (Ctrl+Z mash during async asset reloads) never interleave. +// - During replay, isReplaying suppresses record() calls so any +// side-effecting mutator invoked from inside apply/revert (e.g. an +// undo-of-create reaching editor.deleteObject which records a delete) +// can't poison the stack. + +export interface UndoableAction { + readonly label: string; + apply(): Promise | void; // do or redo + revert(): Promise | void; // undo +} + +export interface HistoryManagerOptions { + capacity?: number; + onChange?: (state: { canUndo: boolean; canRedo: boolean }) => void; +} + +export class HistoryManager { + private past: UndoableAction[] = []; + private future: UndoableAction[] = []; + private isReplaying = false; + private serializing: Promise = Promise.resolve(); + + private readonly capacity: number; + private readonly onChange?: HistoryManagerOptions["onChange"]; + + constructor(options: HistoryManagerOptions = {}) { + this.capacity = options.capacity ?? 64; + this.onChange = options.onChange; + } + + record(action: UndoableAction): void { + if (this.isReplaying) return; + this.future.length = 0; + this.past.push(action); + if (this.past.length > this.capacity) this.past.shift(); + this.notifyChange(); + } + + canUndo(): boolean { + return this.past.length > 0; + } + + canRedo(): boolean { + return this.future.length > 0; + } + + clear(): void { + this.past.length = 0; + this.future.length = 0; + this.notifyChange(); + } + + async undo(): Promise { + return (this.serializing = this.serializing.then(() => + this.undoInternal(), + )); + } + + async redo(): Promise { + return (this.serializing = this.serializing.then(() => + this.redoInternal(), + )); + } + + private async undoInternal(): Promise { + const action = this.past.pop(); + if (!action) return; + this.isReplaying = true; + try { + await action.revert(); + } finally { + this.isReplaying = false; + } + this.future.push(action); + this.notifyChange(); + } + + private async redoInternal(): Promise { + const action = this.future.pop(); + if (!action) return; + this.isReplaying = true; + try { + await action.apply(); + } finally { + this.isReplaying = false; + } + this.past.push(action); + this.notifyChange(); + } + + private notifyChange(): void { + this.onChange?.({ canUndo: this.canUndo(), canRedo: this.canRedo() }); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/SelectionBridge.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/SelectionBridge.ts index 6251b044f7..3225b374f9 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/SelectionBridge.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/SelectionBridge.ts @@ -26,6 +26,7 @@ export type SelectionBridgeDeps = { // and the gizmo attach/detach lives on GizmoController. The bridge // calls them through these callbacks instead of importing either. toggleObjectLocked: (uuid: string) => boolean; // returns new locked state + setObjectLocked: (uuid: string, locked: boolean) => void; // direct set (history replay) isObjectLocked: (uuid: string) => boolean; removeTransformControls: () => void; attachGizmoToCurrentSelection: () => void; @@ -42,18 +43,35 @@ export class SelectionBridge { constructor(private readonly engine: SelectionBridgeDeps) {} - // Toggle the locked flag on an object's userData, then either yank - // the gizmo (lock) or re-attach it to the current selection (unlock). - // Returns the new locked state. - lockUnlockObject(object_uuid: string): boolean { - const locked = this.engine.toggleObjectLocked(object_uuid); + // Centralized side-effect chain shared by the user-toggle path and + // history replay. Sets userData.locked, attaches/detaches the gizmo, + // refreshes the inspector. No recording — the user-facing entry + // points decide whether to push to the undo stack. + private applyLockState(object_uuid: string, locked: boolean) { + this.engine.setObjectLocked(object_uuid, locked); if (locked) { this.engine.removeTransformControls(); } else { this.engine.attachGizmoToCurrentSelection(); } this.updateSelectedUI(); - return locked; + } + + // Toggle the locked flag on an object's userData, then either yank + // the gizmo (lock) or re-attach it to the current selection (unlock). + // Returns the new locked state. Recording a LockAction is the + // call site's responsibility — the bridge stays history-agnostic. + lockUnlockObject(object_uuid: string): boolean { + const before = this.engine.isObjectLocked(object_uuid); + const after = !before; + this.applyLockState(object_uuid, after); + return after; + } + + // For history replay: set the locked state directly without recording. + // Runs the same gizmo attach/detach side effects as lockUnlockObject. + setLockState(object_uuid: string, locked: boolean) { + this.applyLockState(object_uuid, locked); } // True if the object is currently locked. Pure passthrough. diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/ColorAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/ColorAction.ts new file mode 100644 index 0000000000..3cde028590 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/ColorAction.ts @@ -0,0 +1,24 @@ +import type Editor from "../../editor"; +import type { UndoableAction } from "../HistoryManager"; + +// Records a color change. apply() and revert() both call +// activeScene.setColor — the engine handles material magic. The action +// captures the uuid + before/after hex pair at construction time. +export class ColorAction implements UndoableAction { + readonly label = "Color"; + + constructor( + private readonly editor: Editor, + private readonly uuid: string, + private readonly before: string, + private readonly after: string, + ) {} + + apply(): void { + this.editor.activeScene.setColor(this.uuid, this.after); + } + + revert(): void { + this.editor.activeScene.setColor(this.uuid, this.before); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/CreateAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/CreateAction.ts new file mode 100644 index 0000000000..945d3763f8 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/CreateAction.ts @@ -0,0 +1,36 @@ +import * as THREE from "three"; +import type Editor from "../../editor"; +import type { UndoableAction } from "../HistoryManager"; +import { + ObjectSnap, + recreateFromSnapshot, + snapshotObject, +} from "./snapshots"; + +// Records the creation of an object. apply() re-creates from the +// captured snapshot (used on redo). revert() removes the object using +// the lower-level utils.deleteObject path so it does NOT recurse +// through editor.deleteObject (which would try to record a fresh +// delete entry). +export class CreateAction implements UndoableAction { + readonly label: string; + private readonly snap: ObjectSnap; + + constructor( + private readonly editor: Editor, + obj: THREE.Object3D, + ) { + this.snap = snapshotObject(obj); + this.label = `Create ${this.snap.name}`; + } + + async apply(): Promise { + await recreateFromSnapshot(this.editor, this.snap); + this.editor.selection.refreshOutliner(); + } + + async revert(): Promise { + this.editor.utils.deleteObject(this.snap.uuid); + this.editor.selection.refreshOutliner(); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/DeleteAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/DeleteAction.ts new file mode 100644 index 0000000000..2c051bee5b --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/DeleteAction.ts @@ -0,0 +1,35 @@ +import * as THREE from "three"; +import type Editor from "../../editor"; +import type { UndoableAction } from "../HistoryManager"; +import { + ObjectSnap, + recreateFromSnapshot, + snapshotObject, +} from "./snapshots"; + +// Records the deletion of an object. Mirror of CreateAction with +// apply/revert swapped. apply() removes via utils.deleteObject (the +// non-recording path) so a redo of a delete during replay can't +// re-trigger recordDelete. +export class DeleteAction implements UndoableAction { + readonly label: string; + private readonly snap: ObjectSnap; + + constructor( + private readonly editor: Editor, + obj: THREE.Object3D, + ) { + this.snap = snapshotObject(obj); + this.label = `Delete ${this.snap.name}`; + } + + async apply(): Promise { + this.editor.utils.deleteObject(this.snap.uuid); + this.editor.selection.refreshOutliner(); + } + + async revert(): Promise { + await recreateFromSnapshot(this.editor, this.snap); + this.editor.selection.refreshOutliner(); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/LockAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/LockAction.ts new file mode 100644 index 0000000000..bbd427816d --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/LockAction.ts @@ -0,0 +1,29 @@ +import type { SelectionBridge } from "../SelectionBridge"; +import type { UndoableAction } from "../HistoryManager"; + +// Records a lock-toggle. Routes both apply() and revert() through +// SelectionBridge.setLockState so the gizmo attach/detach side effects +// fire — the same path the user-toggle uses, just at a target state +// instead of a flip. +export class LockAction implements UndoableAction { + readonly label: string; + + constructor( + private readonly selection: SelectionBridge, + private readonly uuid: string, + private readonly before: boolean, + private readonly after: boolean, + ) { + this.label = after ? "Lock" : "Unlock"; + } + + apply(): void { + this.selection.setLockState(this.uuid, this.after); + this.selection.refreshOutliner(); + } + + revert(): void { + this.selection.setLockState(this.uuid, this.before); + this.selection.refreshOutliner(); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/TransformAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/TransformAction.ts new file mode 100644 index 0000000000..4d885194fe --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/TransformAction.ts @@ -0,0 +1,58 @@ +import type Editor from "../../editor"; +import type { UndoableAction } from "../HistoryManager"; +import { + TransformSnap, + snapshotTransform, + transformsEqual, + writeTransform, +} from "./snapshots"; + +// Records a transform change. Caller holds the instance between the +// drag/edit boundary callbacks (gizmo dragstart/dragend, panel input +// focus/blur) and calls commit() at the end. If the transform didn't +// actually change, commit() returns false and the caller drops the +// action without recording. +// +// Begin/end transaction state lives on the action itself — no need for +// a per-manager pendingTransform field. +export class TransformAction implements UndoableAction { + readonly label = "Transform"; + private readonly before: TransformSnap; + private after?: TransformSnap; + + constructor( + private readonly editor: Editor, + private readonly uuid: string, + ) { + const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); + if (!obj) { + this.before = { + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + return; + } + this.before = snapshotTransform(obj); + } + + // Capture the after-state from the live object. Returns true if it + // differs from the before-state (worth recording). + commit(): boolean { + const obj = this.editor.activeScene.scene.getObjectByProperty( + "uuid", + this.uuid, + ); + if (!obj) return false; + this.after = snapshotTransform(obj); + return !transformsEqual(this.before, this.after); + } + + apply(): void { + if (this.after) writeTransform(this.editor, this.uuid, this.after); + } + + revert(): void { + writeTransform(this.editor, this.uuid, this.before); + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/VisibilityAction.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/VisibilityAction.ts new file mode 100644 index 0000000000..61089b0c13 --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/VisibilityAction.ts @@ -0,0 +1,38 @@ +import type Editor from "../../editor"; +import type { UndoableAction } from "../HistoryManager"; + +// Records a visibility toggle. Writes both obj.visible and +// userData.visible directly. The outliner refresh fires in +// HistoryManager's replay wrapper so the row icon stays in sync. +export class VisibilityAction implements UndoableAction { + readonly label: string; + + constructor( + private readonly editor: Editor, + private readonly uuid: string, + private readonly before: boolean, + private readonly after: boolean, + ) { + this.label = after ? "Show" : "Hide"; + } + + apply(): void { + this.write(this.after); + this.editor.selection.refreshOutliner(); + } + + revert(): void { + this.write(this.before); + this.editor.selection.refreshOutliner(); + } + + private write(visible: boolean): void { + const obj = this.editor.activeScene.scene.getObjectByProperty( + "uuid", + this.uuid, + ); + if (!obj) return; + obj.visible = visible; + obj.userData["visible"] = visible; + } +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/snapshots.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/snapshots.ts new file mode 100644 index 0000000000..f2d83790fa --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor/actions/snapshots.ts @@ -0,0 +1,108 @@ +import * as THREE from "three"; +import type Editor from "../../editor"; + +// Shared snapshot types + helpers used by every UndoableAction that +// captures a piece of scene state. Lives outside HistoryManager so +// new action classes can import without pulling the manager itself. + +export interface TransformSnap { + position: { x: number; y: number; z: number }; + rotation: { x: number; y: number; z: number }; + scale: { x: number; y: number; z: number }; +} + +// Enough state to fully reconstruct any object: primitive shape, +// image plane, GLB, MMD, splat. Captured at create-time and at +// delete-time so undo of either operation works the same way. +export interface ObjectSnap { + uuid: string; + name: string; + media_id: string; + transform: TransformSnap; + userData: Record; +} + +export const snapshotTransform = (obj: THREE.Object3D): TransformSnap => ({ + position: { x: obj.position.x, y: obj.position.y, z: obj.position.z }, + rotation: { x: obj.rotation.x, y: obj.rotation.y, z: obj.rotation.z }, + scale: { x: obj.scale.x, y: obj.scale.y, z: obj.scale.z }, +}); + +export const snapshotObject = (obj: THREE.Object3D): ObjectSnap => ({ + uuid: obj.uuid, + name: obj.name, + media_id: (obj.userData.media_id as string) ?? "Parim", + transform: snapshotTransform(obj), + userData: { ...obj.userData }, +}); + +export const transformsEqual = (a: TransformSnap, b: TransformSnap): boolean => + a.position.x === b.position.x && + a.position.y === b.position.y && + a.position.z === b.position.z && + a.rotation.x === b.rotation.x && + a.rotation.y === b.rotation.y && + a.rotation.z === b.rotation.z && + a.scale.x === b.scale.x && + a.scale.y === b.scale.y && + a.scale.z === b.scale.z; + +export const writeTransform = ( + editor: Editor, + uuid: string, + t: TransformSnap, +): void => { + const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid); + if (!obj) return; + obj.position.set(t.position.x, t.position.y, t.position.z); + obj.rotation.set(t.rotation.x, t.rotation.y, t.rotation.z); + obj.scale.set(t.scale.x, t.scale.y, t.scale.z); +}; + +// Re-create an object from a snapshot. Used by Create/Delete actions +// to undo deletions and redo creations. Preserves the original uuid +// so subsequent history entries that reference it still resolve. +export const recreateFromSnapshot = async ( + editor: Editor, + snap: ObjectSnap, +): Promise => { + const pos = new THREE.Vector3( + snap.transform.position.x, + snap.transform.position.y, + snap.transform.position.z, + ); + // Shapes route through scene.instantiate's geometry switch, which + // matches on the geometry key ("Box", "Sphere", "PointLight"), not + // the display name ("Cube", "Point Light"). addShape stashes the + // original key in userData.shapeKey so we can recover it here. + const nameForCreate = + (snap.userData.shapeKey as string | undefined) ?? snap.name; + const obj = await editor.sceneManager?.create( + snap.media_id, + nameForCreate, + pos, + ); + if (!obj) return undefined; + obj.uuid = snap.uuid; + // Restore the display name (instantiate/loadObject set obj.name to + // nameForCreate; for shapes that's the geometry key, not the label). + obj.name = snap.name; + obj.rotation.set( + snap.transform.rotation.x, + snap.transform.rotation.y, + snap.transform.rotation.z, + ); + obj.scale.set( + snap.transform.scale.x, + snap.transform.scale.y, + snap.transform.scale.z, + ); + obj.userData = { ...snap.userData }; + if (typeof snap.userData.color === "string") { + editor.activeScene.setColor(obj.uuid, snap.userData.color); + } + if (typeof snap.userData.visible === "boolean") { + obj.visible = snap.userData.visible; + } + return obj; +}; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/helper.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/helper.ts index b3311a5617..fd78e69e6f 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/helper.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/helper.ts @@ -43,6 +43,13 @@ export class SceneUtils { return object.userData["locked"]; } + // Direct setter used by history replay — sets userData.locked without + // toggling. Side effects (gizmo attach/detach) live on SelectionBridge. + setObjectLocked(object_uuid: string, locked: boolean) { + const object = this.scene.get_object_by_uuid(object_uuid); + if (object) object.userData["locked"] = locked; + } + // Removes transform controls and publishes selected. removeTransformControls(remove_outline: boolean = true) { if (this.editor.gizmo.control == undefined) { diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts index 3ef85b7dc8..00eb252c12 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts @@ -1,5 +1,6 @@ import type Editor from "~/pages/PageScene/engine/editor"; import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; +import { CreateAction } from "~/pages/PageScene/engine/editor/actions/CreateAction"; // One declarative table for every viewport keyboard shortcut. // useViewportKeyboard dispatches against this list; a future Ctrl-hold @@ -34,8 +35,10 @@ const setGizmoMode = ( const deleteSelected = (editor: Editor) => { const mc = editor.mouse_controls; if (!mc?.selected) return; + // Route through editor.deleteObject so HistoryManager records the + // deletion. mc.deleteObject (the legacy direct path) bypasses history. mc.selected.forEach((sel) => { - mc.deleteObject(sel.uuid); + editor.deleteObject(sel.uuid); }); mc.selected = []; mc.removeTransformControls(); @@ -70,11 +73,11 @@ const openAssetModal = () => { }; const undo = async (editor: Editor) => { - await editor.sceneManager?.undo(); + await editor.history.undo(); }; const redo = async (editor: Editor) => { - await editor.sceneManager?.redo(); + await editor.history.redo(); }; const copy = async (editor: Editor) => { @@ -82,7 +85,10 @@ const copy = async (editor: Editor) => { }; const paste = async (editor: Editor) => { - await editor.sceneManager?.paste(); + const obj = await editor.sceneManager?.paste(); + if (!obj) return; + editor.history.record(new CreateAction(editor, obj)); + editor.selection.refreshOutliner(); }; export const buildKeymap = (): KeyBinding[] => [ diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/pickDropPosition.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/pickDropPosition.ts new file mode 100644 index 0000000000..59157ee01c --- /dev/null +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/pickDropPosition.ts @@ -0,0 +1,41 @@ +import * as THREE from "three"; + +export type PickDropPositionDeps = { + getCamera: () => THREE.PerspectiveCamera | null | undefined; + getCanvas: () => HTMLCanvasElement | null | undefined; + getRaycastTargets: () => THREE.Object3D[]; + removeTransformControls: () => void; +}; + +// Cast a ray from the camera through the cursor and return the first +// scene hit. Falls back to origin if the camera/canvas isn't ready or +// nothing is hit. All dependencies arrive through `deps` so the picker +// stays decoupled from Editor and any subsystem's import surface +// (matches the Phase 2 deps pattern used by CameraController etc.). +export function pickDropPosition( + deps: PickDropPositionDeps, + pageX: number, + pageY: number, +): THREE.Vector3 { + const camera = deps.getCamera(); + const canvas = deps.getCanvas(); + if (!camera || !canvas) return new THREE.Vector3(); + + // Hide the gizmo so the raycast doesn't hit it. + deps.removeTransformControls(); + + const rect = canvas.getBoundingClientRect(); + const ndc = new THREE.Vector2( + ((pageX - rect.left) / rect.width) * 2 - 1, + -((pageY - rect.top) / rect.height) * 2 + 1, + ); + + const raycaster = new THREE.Raycaster(); + raycaster.layers.enable(0); + raycaster.layers.enable(1); + raycaster.setFromCamera(ndc, camera); + + const hits = raycaster.intersectObjects(deps.getRaycastTargets(), true); + if (hits.length > 0) return hits[0].point.clone(); + return new THREE.Vector3(); +} diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/save_manager.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/save_manager.ts index 7f101a4309..a0a06a7b2e 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/save_manager.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/save_manager.ts @@ -1,121 +1,80 @@ import * as THREE from "three"; +import { Camera } from "@storyteller/common"; + import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata"; import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene"; - import { usePageSceneStore } from "../PageSceneStore"; +import { CameraAspectRatio } from "../enums"; +import type Scene from "./scene"; const showEditorLoader = (message?: string) => usePageSceneStore.getState().showEditorLoader(message); const hideEditorLoader = () => usePageSceneStore.getState().hideEditorLoader(); -import Editor from "./editor"; -import { Camera } from "@storyteller/common"; export type EditorInitializeConfig = { sceneToken: string; }; -// need to move this into the +export type SaveSceneStateArgs = { + saveJson: string; + sceneTitle: string; + sceneToken?: string; + sceneThumbnail: Blob | undefined; +}; + +// Narrow contract between SaveManager and the rest of the engine. The +// manager doesn't import Editor — every cross-subsystem reach is a +// callback or getter on this deps object (Phase 2 idiom). +export type SaveManagerDeps = { + // Engine version. Read on save, written on load (older formats may + // upgrade themselves through this). + getVersion: () => number; + setVersion: (v: number) => void; + + // Active scene reference for proxy serialization. + getActiveScene: () => Scene; + + // Renderer canvas — only used to capture the save-thumbnail. + getRenderer: () => THREE.WebGLRenderer | undefined; + + // Yank the transform gizmo before snapshot so it doesn't end up in + // the saved scene. + removeTransformControls: () => void; + + // Camera state. getCamera reads pose for save + load-restore; + // refreshCamObj re-anchors the camera-person object after a load + // replaces the scene; changeRenderCameraAspectRatio applies a saved + // aspect ratio. + getCamera: () => THREE.PerspectiveCamera | null; + refreshCamObj: () => void; + changeRenderCameraAspectRatio: (ratio: CameraAspectRatio) => void; + + // Editor field setter used during load. + setPositivePrompt: (prompt: string) => void; + + // Backend API. + saveSceneState: (args: SaveSceneStateArgs) => Promise; + loadSceneState: (token: string) => Promise; +}; + export class SaveManager { - editor: Editor; - constructor(editor: Editor) { - this.editor = editor; - } + // Token of the most-recently loaded scene. Internal load tracking + // — nothing outside SaveManager reads it. + private currentSceneMediaToken: string | null = null; - async computeHashForBrowser(data: string): Promise { - // Encode the string data to a Uint8Array - const encoder = new TextEncoder(); - const encodedData = encoder.encode(data); - - // Compute the hash using the SubtleCrypto.digest method - const hashBuffer = await crypto.subtle.digest("SHA-256", encodedData); - - // Convert the ArrayBuffer to a hexadecimal string - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - - return hashHex; - } - - public async computeSceneChecksum(): Promise { - const jsonString = await this.checkSumData(); - return this.computeHashForBrowser(jsonString); - } - - private async checkSumData(): Promise { - const proxyScene = new StoryTellerProxyScene( - this.editor.version, - this.editor.activeScene, - ); - const scene_json = await proxyScene.saveToScene(this.editor.version); - - const check_sum_data = { - scene: scene_json, - timeline: "", - camera_data: { - position: this.editor.cameraController.camera?.position, - rotation: this.editor.cameraController.camera?.rotation, - }, - }; - const jsonString = JSON.stringify(check_sum_data); - return jsonString; - } - - // JSON structure should and can return snapshot - public async saveData({ - sceneGenerationMetadata, - }: { - sceneTitle: string; - sceneToken?: string; - sceneGenerationMetadata: SceneGenereationMetaData; - }): Promise { - const proxyScene = new StoryTellerProxyScene( - this.editor.version, - this.editor.activeScene, - ); - const scene_json = await proxyScene.saveToScene(this.editor.version); - - const sceneState = usePageSceneStore.getState(); - const camerasData = sceneState.cameras.map((cam: Camera) => ({ - id: cam.id, - label: cam.label, - focalLength: cam.focalLength, - position: cam.position, - rotation: cam.rotation, - lookAt: cam.lookAt, - })); - - const save_data = { - version: this.editor.version, - scene: scene_json, - ...sceneGenerationMetadata, - timeline: "", - skybox: this.editor.activeScene.skybox, - camera_data: { - position: this.editor.cameraController.camera?.position, - rotation: this.editor.cameraController.camera?.rotation, - }, - cameras: camerasData, - selectedCameraId: sceneState.selectedCameraId, - }; - // take json scene and figure out checksum - const jsonString = JSON.stringify(save_data); - return jsonString; - } + constructor(private readonly deps: SaveManagerDeps) {} public getSceneJson({ sceneGenerationMetadata, }: { sceneGenerationMetadata: SceneGenereationMetaData; }) { - const proxyScene = new StoryTellerProxyScene( - this.editor.version, - this.editor.activeScene, - ); - const scene_json = proxyScene.saveToScene(this.editor.version); - console.log(scene_json); + const version = this.deps.getVersion(); + const scene = this.deps.getActiveScene(); + const camera = this.deps.getCamera(); + const proxyScene = new StoryTellerProxyScene(version, scene); + const scene_json = proxyScene.saveToScene(version); const sceneState = usePageSceneStore.getState(); const camerasData = sceneState.cameras.map((cam: Camera) => ({ @@ -127,24 +86,21 @@ export class SaveManager { lookAt: cam.lookAt, })); - const save_data = { - version: this.editor.version, + return { + version, scene: scene_json, ...sceneGenerationMetadata, timeline: "", - skybox: this.editor.activeScene.skybox, + skybox: scene.skybox, camera_data: { - position: this.editor.cameraController.camera?.position, - rotation: this.editor.cameraController.camera?.rotation, + position: camera?.position, + rotation: camera?.rotation, }, cameras: camerasData, selectedCameraId: sceneState.selectedCameraId, }; - - return save_data; } - // TODO Move this function into scene manager. public async saveScene({ sceneTitle, sceneToken, @@ -154,47 +110,55 @@ export class SaveManager { sceneToken?: string; sceneGenerationMetadata: SceneGenereationMetaData; }): Promise { - this.editor.generating_preview = true; // Set this to true to stop control panel from flipping out. - // remove controls when saving scene. - this.editor.utils.removeTransformControls(); + this.deps.removeTransformControls(); showEditorLoader(); - const sceneJson = this.getSceneJson({ - sceneGenerationMetadata, - }); + const sceneJson = this.getSceneJson({ sceneGenerationMetadata }); - // TODO turn scene information into and object ... - let sceneThumbnail = undefined; - - if (this.editor.renderer) { - const imgData = this.editor.renderer.domElement.toDataURL(); - const response = await fetch(imgData); // Fetch the data URL - sceneThumbnail = await response.blob(); // Convert to Blob + let sceneThumbnail: Blob | undefined = undefined; + const renderer = this.deps.getRenderer(); + if (renderer) { + const imgData = renderer.domElement.toDataURL(); + const response = await fetch(imgData); + sceneThumbnail = await response.blob(); } - const result = await this.editor.api_manager.saveSceneState({ + const result = await this.deps.saveSceneState({ saveJson: JSON.stringify(sceneJson), sceneTitle, sceneToken, sceneThumbnail, }); - // this means error going to have to fix the plumbing on this because not using - // the api manager - hideEditorLoader(); - - this.editor.generating_preview = false; // FIX THIS LATER WITH VICCCCCCCCCCCCCCCTORRRRRRRR - console.debug("Save Scene Result: ", result); - return result; // if this is an empty string it is an error. need to migrate to api manager. + return result; + } + + public async loadCache(cacheJson: string) { + showEditorLoader(); + const scene_json = JSON.parse(cacheJson); + await this.loadFromJson(scene_json); + hideEditorLoader(); + } + + public async loadScene(scene_media_token: string) { + showEditorLoader(); + this.currentSceneMediaToken = scene_media_token; + const scene_json = await this.deps + .loadSceneState(this.currentSceneMediaToken) + .catch((err) => { + hideEditorLoader(); + throw err; + }); + await this.loadFromJson(scene_json); + hideEditorLoader(); } private async loadFromJson(scene_json: any) { - const proxyScene = new StoryTellerProxyScene( - this.editor.version, - this.editor.activeScene, - ); + const version = this.deps.getVersion(); + const scene = this.deps.getActiveScene(); + const proxyScene = new StoryTellerProxyScene(version, scene); await proxyScene.loadFromSceneJson( scene_json["scene"], @@ -203,11 +167,10 @@ export class SaveManager { ); const camera_data = scene_json["camera_data"]; - const liveCamera = this.editor.cameraController.camera; + const liveCamera = this.deps.getCamera(); if (camera_data && liveCamera) { const camera_position: THREE.Vector3 = camera_data["position"]; const camera_rotation: THREE.Euler = camera_data["rotation"]; - liveCamera.position.copy(camera_position); liveCamera.rotation.copy(camera_rotation); } @@ -230,57 +193,17 @@ export class SaveManager { .setSelectedCameraId(scene_json.selectedCameraId); } - // Restore prompt text into the editor (PromptBox3D will pick it up - // through the editor's positive_prompt field). if (scene_json.positivePrompt) { - this.editor.positive_prompt = scene_json.positivePrompt; + this.deps.setPositivePrompt(scene_json.positivePrompt); } if (scene_json.cameraAspectRatio) { - this.editor.cameraController.changeRenderCameraAspectRatio( - scene_json.cameraAspectRatio, - ); + this.deps.changeRenderCameraAspectRatio(scene_json.cameraAspectRatio); usePageSceneStore .getState() .setCameraAspectRatio(scene_json.cameraAspectRatio); } - this.editor.version = scene_json["version"]; - const camCtrl = this.editor.cameraController; - camCtrl.cam_obj = this.editor.activeScene.get_object_by_name( - camCtrl.camera_name, - ); - - camCtrl.cam_obj?.layers.set(1); - camCtrl.cam_obj?.children.forEach((child) => { - child.layers.set(1); - }); - - } - - public async loadCache(cacheJson: string) { - showEditorLoader(); - - const scene_json = JSON.parse(cacheJson); - await this.loadFromJson(scene_json); - - hideEditorLoader(); - } - - // TODO Refactor remove editor. - public async loadScene(scene_media_token: string) { - showEditorLoader(); - - this.editor.current_scene_media_token = scene_media_token; - - const scene_json = await this.editor.api_manager - .loadSceneState(this.editor.current_scene_media_token) - .catch((err) => { - hideEditorLoader(); - throw err; - }); - - await this.loadFromJson(scene_json); - - hideEditorLoader(); + this.deps.setVersion(scene_json["version"]); + this.deps.refreshCamObj(); } } diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene.ts index 956ea1f8b4..905bd66b58 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene.ts @@ -535,6 +535,7 @@ class Scene { mmd.userData["media_file_type"] = MediaFileType.MMD; mmd.layers.enable(0); mmd.layers.enable(1); + mmd.position.copy(position); this.scene.add(mmd); this.updateSurfaceIdAttributeToMesh(this.scene); return mmd; @@ -585,6 +586,7 @@ class Scene { url.includes(".mp4") ) { const obj = await this.instantiate("Image::" + url); + obj.position.copy(position); // Preserve original token to enable copy/paste and timeline mapping obj.userData["media_id"] = media_id; if (url.includes(".mp4")) { @@ -664,6 +666,7 @@ class Scene { }); splat.rotation.z = Math.PI; + splat.position.copy(position); splat.userData["media_id"] = media_id; splat.userData["media_file_type"] = MediaFileType.SPZ; @@ -789,6 +792,8 @@ class Scene { throw Error("GLB Did not contain an object or children."); } + child_result.position.copy(position); + if (auto_add) { this.scene.add(child_result); if (glb.animations.length > 0) { diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene_manager_api.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene_manager_api.ts index 4814875ba2..82d9073d77 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene_manager_api.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/scene_manager_api.ts @@ -11,18 +11,6 @@ import { ClipGroup } from "~/enums"; import { AssetType } from "~/enums"; import { XYZ } from "../datastructures/common"; import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; -import { - CommandInputTypes, - CreationSceneItem, - DeletionData, - DeletionSceneItem, - ICommand, - SceneState, - TransformData, - TransformSceneItem, - UserData, - UserDataSceneItem, -} from "./Commands"; export type SceneObject = { id: string; @@ -43,105 +31,22 @@ export interface SceneManagerAPI { rotation: THREE.Euler, scale: THREE.Vector3, ): void; - undo(): void; - redo(): void; selected(): void; render_outliner(timeline_characters: { [key: string]: ClipGroup }): void; - onMouseMove(event: MouseEvent): void; - onMouseClick(): void; - onKeyDown(event: KeyboardEvent): void; - onMouseDown(event: MouseEvent): void; - onMouseUp(event: MouseEvent): void; select_object(id: string): void; } -type UpdateOutliner = () => void; - export class SceneManager implements SceneManagerAPI { scene: Scene; mouse_controls: MouseControls; version: number; selected_objects: THREE.Object3D[] | undefined; - private updateOutliner: UpdateOutliner; - public undoStack: ICommand[] = []; - private undoIndex: number = 0; - private lastSceneState: SceneState; private copiedObject: THREE.Object3D | undefined; - private is_character: Function; - private devMode: boolean; - constructor( - version: number, - mouse_controls: MouseControls, - scene: Scene, - devMode: boolean = false, - updateOutliner: UpdateOutliner, - is_character: Function, - ) { + constructor(version: number, mouse_controls: MouseControls, scene: Scene) { this.mouse_controls = mouse_controls; this.scene = scene; this.version = version; - this.lastSceneState = this.getSceneState(); - this.updateOutliner = updateOutliner; - this.is_character = is_character; - this.devMode = devMode; - - this.onMouseMove = this.onMouseMove.bind(this); - this.onMouseClick = this.onMouseClick.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - this.onMouseDown = this.onMouseDown.bind(this); - this.onMouseUp = this.onMouseUp.bind(this); - } - - // Pointer + keyboard listener attachment is now handled by - // hooks/useViewportPointer and hooks/useViewportKeyboard, which - // attach to the viewport canvas instead of window. The dispatch - // methods (onMouseMove etc.) below remain so MouseControls can - // continue to drive selection and FK; they're invoked by the hook. - public attachEventListeners() { - // intentionally empty — kept for back-compat with editor lifecycle - } - - public detachEventListeners() { - // intentionally empty — kept for back-compat with editor lifecycle - } - - public async undo() { - if (this.undoIndex >= this.undoStack.length) { - return; - } - this.undoIndex += 1; - const undoCommand = this.undoStack.at( - this.undoStack.length - this.undoIndex, - ); - await undoCommand?.undo(); - this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class. - this.lastSceneState = this.getSceneState(); - } - - public async redo() { - if (this.undoIndex <= 0) { - return; - } - const undoCommand = this.undoStack.at( - this.undoStack.length - this.undoIndex, - ); - await undoCommand?.redo(); - this.undoIndex -= 1; - this.updateOutliner(); // In the future we will address this because of its relational issues with editor and the current class. - this.lastSceneState = this.getSceneState(); - } - - // Drop any redo-future before pushing a fresh command so the new action - // replaces the branch of history the user had undone past. - private dropRedoHistory() { - if (this.undoIndex > 0) { - this.undoStack = this.undoStack.slice( - 0, - this.undoStack.length - this.undoIndex, - ); - this.undoIndex = 0; - } } public async create( @@ -306,7 +211,7 @@ export class SceneManager implements SceneManagerAPI { } } - public async paste() { + public async paste(): Promise { if (this.copiedObject && this.copiedObject.name != "::CAM::") { const userdata = this.copiedObject.userData; const position = this.copiedObject.position.clone(); @@ -333,7 +238,6 @@ export class SceneManager implements SceneManagerAPI { } this.mouse_controls.selectObject(obj); - this.updateOutliner(); const store = usePageSceneStore.getState(); if (wasCharacter) { @@ -353,219 +257,9 @@ export class SceneManager implements SceneManagerAPI { } await this.copy(); - await this.add_creation_undostack(obj); + return obj; } + return undefined; } - public onMouseMove(event: MouseEvent) { - this.mouse_controls.onMouseMove(event); - } - - public onMouseClick() { - this.mouse_controls.onMouseClick(); - } - - public onKeyDown(event: KeyboardEvent) { - this.mouse_controls.onkeydown(event); - } - - public onMouseDown(event: MouseEvent) { - this.mouse_controls.onMouseDown(event); - } - - public onMouseUp(event: MouseEvent) { - this.mouse_controls.onMouseUp(event); - this.check_scene_for_updates(); - } - - private getSceneState(): SceneState { - const scene_objects: string[] = []; - const scene_names: string[] = []; - const scene_positions: string[] = []; - const scene_userdata: string[] = []; - const scene_rotations: string[] = []; - const scene_scales: string[] = []; - - this.scene.scene.children.forEach((child) => { - if (child.name != "") { - scene_objects.push(child.uuid); - scene_names.push(child.name); - scene_userdata.push(JSON.stringify(child.userData)); - scene_positions.push(JSON.stringify(child.position.clone())); - scene_rotations.push(JSON.stringify(child.rotation.clone())); - scene_scales.push(JSON.stringify(child.scale.clone())); - } - }); - return { - scene_objects: scene_objects, - scene_names: scene_names, - scene_userdata: scene_userdata, - scene_positions: scene_positions, - scene_rotations: scene_rotations, - scene_scales: scene_scales, - }; - } - - private is_creation(sceneState: SceneState): string { - let resp = ""; - sceneState.scene_objects.forEach((object) => { - if (!this.lastSceneState.scene_objects.includes(object)) { - resp = object; - return resp; - } - }); - return resp; - } - - private is_deletion(sceneState: SceneState): string { - let resp = ""; - this.lastSceneState.scene_objects.forEach((object) => { - if (sceneState.scene_objects.includes(object) == false) { - resp = object; - return resp; - } - }); - return resp; - } - - private is_userdata(sceneState: SceneState): string { - let resp = ""; - this.lastSceneState.scene_userdata.forEach((object) => { - if (sceneState.scene_userdata.includes(object) == false) { - const respIdx = this.lastSceneState.scene_userdata.indexOf(object); - resp = sceneState.scene_objects[respIdx]; - return sceneState.scene_objects[respIdx]; - } - }); - return resp; - } - - private is_transformation(sceneState: SceneState): string { - let resp = ""; - if (resp === "") { - sceneState.scene_positions.forEach((object) => { - if (this.lastSceneState.scene_positions.includes(object) == false) { - const respIdx = sceneState.scene_positions.indexOf(object); - resp = sceneState.scene_objects[respIdx]; - return sceneState.scene_objects[respIdx]; - } - }); - } - if (resp === "") { - sceneState.scene_rotations.forEach((object) => { - if (this.lastSceneState.scene_rotations.includes(object) == false) { - const respIdx = sceneState.scene_rotations.indexOf(object); - resp = sceneState.scene_objects[respIdx]; - return sceneState.scene_objects[respIdx]; - } - }); - } - if (resp === "") { - sceneState.scene_scales.forEach((object) => { - if (this.lastSceneState.scene_scales.includes(object) == false) { - const respIdx = sceneState.scene_scales.indexOf(object); - resp = sceneState.scene_objects[respIdx]; - return sceneState.scene_objects[respIdx]; - } - }); - } - return resp; - } - - public async add_creation_undostack(object: THREE.Object3D) { - this.dropRedoHistory(); - this.undoStack.push( - new CreationSceneItem(this, { - object_uuid: object.uuid, - name: object.name, - position: object.position.clone(), - userdata: object.userData, - }), - ); - this.lastSceneState = this.getSceneState(); - } - - /* Can be called Internally or Externally for undo redo stack */ - // This function allows you to capture transform states and occurs on mouse up. - public async check_scene_for_updates() { - const sceneState = this.getSceneState(); - if (JSON.stringify(this.lastSceneState) !== JSON.stringify(sceneState)) { - // Add Scene state for undo redo here. - const is_deleted = this.is_deletion(sceneState); - const is_transform = this.is_transformation(sceneState); - const is_userdata = this.is_userdata(sceneState); - - this.dropRedoHistory(); - this.undoStack = this.undoStack.slice(-16); - - if (is_deleted != "") { - const objectIndex = - this.lastSceneState.scene_objects.indexOf(is_deleted); - const name = this.lastSceneState.scene_names[objectIndex]; - const position = this.lastSceneState.scene_positions[objectIndex]; - const userdata = JSON.parse( - this.lastSceneState.scene_userdata[objectIndex], - ); - - const undoPush: DeletionData = { - object_uuid: is_deleted, - name: name, - position: JSON.parse(position), - userdata: userdata, - }; - this.undoStack.push(new DeletionSceneItem(this, undoPush)); - } else if (is_userdata != "") { - const objectIndex = sceneState.scene_objects.indexOf(is_userdata); - const startUserdata = JSON.parse( - this.lastSceneState.scene_userdata[objectIndex], - ); - const userdata = JSON.parse(sceneState.scene_userdata[objectIndex]); - - const endPush: UserData = { - object_uuid: is_userdata, - color: userdata["color"], - locked: userdata["locked"], - visible: userdata["visible"], - }; - - const startPush: UserData = { - object_uuid: is_userdata, - color: startUserdata["color"], - locked: startUserdata["locked"], - visible: startUserdata["visible"], - }; - - this.undoStack.push(new UserDataSceneItem(this, startPush, endPush)); - } else if (is_transform != "") { - const objectIndexStart = - this.lastSceneState.scene_objects.indexOf(is_transform); - if ( - this.lastSceneState.scene_positions[objectIndexStart] !== undefined - ) { - const objectIndexEnd = sceneState.scene_objects.indexOf(is_transform); - - const startPush: TransformData = { - object_uuid: is_transform, - position: JSON.parse( - this.lastSceneState.scene_positions[objectIndexStart], - ), - rotation: JSON.parse( - this.lastSceneState.scene_rotations[objectIndexStart], - ), - scale: JSON.parse( - this.lastSceneState.scene_scales[objectIndexStart], - ), - }; - const endPush: TransformData = { - object_uuid: is_transform, - position: JSON.parse(sceneState.scene_positions[objectIndexEnd]), - rotation: JSON.parse(sceneState.scene_rotations[objectIndexEnd]), - scale: JSON.parse(sceneState.scene_scales[objectIndexEnd]), - }; - this.undoStack.push(new TransformSceneItem(this, startPush, endPush)); - } - } - } - this.lastSceneState = sceneState; - } }