mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 01:18:34 +00:00
3D SaveManager cleanup and History Manager architecture (#1466)
This commit is contained in:
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
+50
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user