3D SaveManager cleanup and History Manager architecture (#1466)

This commit is contained in:
Tanish Manku authored and GitHub committed 2026-05-01 04:06:08 -07:00
1 parent 5e05877619
commit 3bfb405e8e
30 files changed
+906 -745

No files matched your search

@@ -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);
}
}
}
@@ -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";
@@ -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",
@@ -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",
@@ -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<string | undefined> {
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();
@@ -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";
@@ -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));
}
@@ -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),
);
}
@@ -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),
);
}
@@ -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();
},
};
}
@@ -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<TransformSession | null>(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<HTMLInputElement>) => {
editorEngine?.activeScene.setColor(
editorEngine?.selected?.uuid || "",
e.target.value,
);
setColor(e.target.value);
// <input type="color"> 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}
@@ -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 (
@@ -1,169 +0,0 @@
import { SceneManager } from "./scene_manager_api";
import * as THREE from "three";
export interface ICommand<T> {
undo(): Promise<void>;
redo(): Promise<void>;
}
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<string, any>;
}
export interface DeletionData {
object_uuid: string;
name: string;
position: THREE.Vector3;
userdata: Record<string, any>;
}
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<string> {
private component: SceneManager;
private creationData: CreationData;
public constructor(sceneManager: SceneManager, creationData: CreationData) {
this.component = sceneManager;
this.creationData = creationData;
}
public async undo(): Promise<void> {
this.component.delete(this.creationData.object_uuid);
}
public async redo(): Promise<void> {
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<string> {
private component: SceneManager;
private deletionData: DeletionData;
public constructor(sceneManager: SceneManager, creationData: DeletionData) {
this.component = sceneManager;
this.deletionData = creationData;
}
public async undo(): Promise<void> {
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<void> {
this.component.delete(this.deletionData.object_uuid);
}
}
export class TransformSceneItem implements ICommand<string> {
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<void> {
this.component.update(
this.transformDataStart.object_uuid,
this.transformDataStart.position,
this.transformDataStart.rotation,
this.transformDataStart.scale,
);
}
public async redo(): Promise<void> {
this.component.update(
this.transformDataEnd.object_uuid,
this.transformDataEnd.position,
this.transformDataEnd.rotation,
this.transformDataEnd.scale,
);
}
}
export class UserDataSceneItem implements ICommand<string> {
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<void> {
this.component.scene.setColor(
this.startUserData.object_uuid,
this.startUserData.color,
);
}
public async redo(): Promise<void> {
this.component.scene.setColor(
this.endUserData.object_uuid,
this.endUserData.color,
);
}
}
@@ -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();
@@ -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.
@@ -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> | void; // do or redo
revert(): Promise<void> | 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<void> = 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<void> {
return (this.serializing = this.serializing.then(() =>
this.undoInternal(),
));
}
async redo(): Promise<void> {
return (this.serializing = this.serializing.then(() =>
this.redoInternal(),
));
}
private async undoInternal(): Promise<void> {
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<void> {
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() });
}
}
@@ -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.
@@ -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);
}
}
@@ -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<void> {
await recreateFromSnapshot(this.editor, this.snap);
this.editor.selection.refreshOutliner();
}
async revert(): Promise<void> {
this.editor.utils.deleteObject(this.snap.uuid);
this.editor.selection.refreshOutliner();
}
}
@@ -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<void> {
this.editor.utils.deleteObject(this.snap.uuid);
this.editor.selection.refreshOutliner();
}
async revert(): Promise<void> {
await recreateFromSnapshot(this.editor, this.snap);
this.editor.selection.refreshOutliner();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<string, unknown>;
}
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<THREE.Object3D | undefined> => {
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;
};
@@ -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) {
@@ -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[] => [
@@ -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();
}
@@ -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<string>;
loadSceneState: (token: string) => Promise<unknown>;
};
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<string> {
// 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<string> {
const jsonString = await this.checkSumData();
return this.computeHashForBrowser(jsonString);
}
private async checkSumData(): Promise<string> {
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<string> {
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<string> {
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();
}
}
@@ -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) {
@@ -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<CommandInputTypes>[] = [];
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<THREE.Object3D | undefined> {
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;
}
}