mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-05 01:38:44 +00:00
artcraft: integrate ui-pagescene -- engine files become re-export shims
Final integration step. The lib's @storyteller/ui-pagescene is now the
canonical source for PageScene's engine + state; the artcraft host
constructs a Tauri-backed PageSceneAdapter and threads it through
EngineProvider into Editor.
Changes:
apps/PageScene/engine/* -- previously held duplicate copies; now:
- Files imported by other artcraft code (editor.ts, scene.ts,
helper.ts, save_manager.ts, scene_manager_api.ts,
keybinds_controls.ts, keymap.ts, pickDropPosition.ts, pointer.ts,
cameraMath.ts, editor/{HistoryManager, ViewportController,
PostProcessingPipeline, GizmoController, CameraController,
SelectionBridge}.ts, editor/actions/*.ts) become tiny
`export * from "@storyteller/ui-pagescene"` re-export shims so the
existing 50+ importers (PageEditor, EngineProvider, EngineContext,
hooks/, DragAndDrop/, action dispatchers) keep working unchanged.
- Files only used internally by the now-shimmed engine code
(CustomOutlinePass.js, FindSurfaces.js, TransformControls.js,
chromakey.js, shaders.js, InfiniteGridHelper.ts, browserChecks.ts,
placeholder_manager.ts, KinHelpers/*, EditorState/*,
APIManagerResponseSuccess.ts, proxy/*) are deleted -- the lib
carries the canonical copies.
- engine/api_manager.ts + engine/api_fetchers.ts deleted; their
bodies move to the new useTauriPageSceneAdapter.ts hook (see below).
EngineProvider:
- Takes a new PageSceneAdapter prop and constructs `new Editor(adapter)`.
- Reads adapter via ref so adapter identity changes don't tear down
the live engine.
apps/PageScene/PageScene.tsx:
- Uses useTauriPageSceneAdapter() to build the adapter, threads it
into <EngineProvider adapter={adapter} ...>.
apps/PageScene/useTauriPageSceneAdapter.ts (new):
- Single source of all the host-side platform plumbing. Wires
enqueueGeneration -> GenerateImage (tauri-api), saveScene/loadScene
to the api_manager+api_fetchers bodies inlined here, fetchAsset to
FetchProxy with LoadWithoutCors fallback, getCdnOrigin/
getApiSchemeAndHost to artcraft singletons, getMediaUrlByToken to
MediaFilesApi.GetMediaFileByToken, onSceneTitleChange to
signalScene, getCurrentUserToken to the authentication signal.
Lib public API (src/index.ts):
- Adds Editor (default), Scene, SceneUtils, SaveManager, SceneManager,
MouseControls, buildKeymap/dispatchBinding, all editor subsystems
(HistoryManager, ViewportController, PostProcessingPipeline,
GizmoController, CameraController, SelectionBridge), all action
classes (ColorAction, CreateAction, DeleteAction, LockAction,
TransformAction, VisibilityAction), engine utilities
(pickDropPosition, cameraMath surface, pointer helpers,
isPointerLockSupported), and the SceneGenereationMetaData type.
Adapter interface (lib/adapter.ts):
- Adds getMediaUrlByToken(token): Promise<string> to replace the
scene's prior MediaFilesApi field.
Verified: artcraft typecheck at 72 errors (down from 124 baseline --
50+ pre-existing ApiManager-related errors disappeared along with
the deleted apps engine internals). All remaining errors are
pre-existing in unrelated AssetMenu/LoadUserScenes/PageEditor modal
config / SoundManager soundboard / pre-existing engine type casts.
This commit is contained in:
1 parent
9ee43d4e4a
commit
3d646b8f19
53 files changed
+437
-8558
No files matched your search
@@ -32,6 +32,7 @@ import { useGenerationFailedEvent } from "@storyteller/tauri-events";
|
||||
import { useTextToImageGenerationCompleteEvent } from "@storyteller/tauri-events";
|
||||
import { useTextToImageStore } from "~/pages/PageImage/TextToImageStore";
|
||||
import { SoundManager } from "@storyteller/soundboard";
|
||||
import { useTauriPageSceneAdapter } from "./useTauriPageSceneAdapter";
|
||||
|
||||
export const PageScene = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
useSignals();
|
||||
@@ -48,6 +49,11 @@ export const PageScene = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
const onSceneSerialized = (json: string) => {
|
||||
tabStore.updateTabData("3D", json);
|
||||
};
|
||||
const adapter = useTauriPageSceneAdapter({
|
||||
initialSceneToken: sceneToken,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
});
|
||||
|
||||
// Credits modal state (must be before any early returns)
|
||||
const { isOpen: isCreditsOpen, closeModal: closeCreditsModal } =
|
||||
@@ -129,6 +135,7 @@ export const PageScene = ({ sceneToken }: { sceneToken?: string }) => {
|
||||
return (
|
||||
<EngineProvider
|
||||
sceneToken={sceneToken}
|
||||
adapter={adapter}
|
||||
cacheJsonString={cacheJsonString}
|
||||
onSceneSerialized={onSceneSerialized}
|
||||
>
|
||||
|
||||
+14
-2
@@ -1,12 +1,19 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { EngineContext, setActiveEditor } from "./EngineContext";
|
||||
|
||||
import Editor from "~/pages/PageScene/engine/editor";
|
||||
import {
|
||||
Editor,
|
||||
type PageSceneAdapter,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
import { getSceneGenerationMetaData } from "../../sceneMetadata";
|
||||
import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore";
|
||||
|
||||
interface Props {
|
||||
sceneToken?: string;
|
||||
// The platform-abstraction surface the lib's Editor needs. Built by
|
||||
// the artcraft wrapper from Tauri APIs (FetchProxy, GenerateImage,
|
||||
// MediaFilesApi, useTabStore for tab cache, etc.) and threaded down.
|
||||
adapter: PageSceneAdapter;
|
||||
// In-memory restore: if provided, the engine deserializes this on
|
||||
// mount instead of fetching the scene by token. Host-supplied (e.g.
|
||||
// artcraft sources it from useTabStore so tab switches preserve the
|
||||
@@ -26,6 +33,7 @@ interface Props {
|
||||
// PageSceneStore drive the cleanup branch of this effect.
|
||||
export const EngineProvider = ({
|
||||
sceneToken,
|
||||
adapter,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
children,
|
||||
@@ -39,6 +47,10 @@ export const EngineProvider = ({
|
||||
cacheRef.current = cacheJsonString;
|
||||
const onSerializeRef = useRef(onSceneSerialized);
|
||||
onSerializeRef.current = onSceneSerialized;
|
||||
// Adapter is read at engine-construction time only; ref guards
|
||||
// against host-side identity changes triggering unwanted rebuilds.
|
||||
const adapterRef = useRef(adapter);
|
||||
adapterRef.current = adapter;
|
||||
|
||||
const sceneContainer = usePageSceneStore((s) => s.sceneContainerEl);
|
||||
const editorCanvas = usePageSceneStore((s) => s.editorCanvasEl);
|
||||
@@ -51,7 +63,7 @@ export const EngineProvider = ({
|
||||
// them to null on unmount).
|
||||
if (!sceneContainer || !editorCanvas || !camViewCanvas) return;
|
||||
|
||||
const newEditor = new Editor();
|
||||
const newEditor = new Editor(adapterRef.current);
|
||||
newEditor.initialize({
|
||||
sceneToken: sceneToken || "",
|
||||
sceneContainerEl: sceneContainer,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* This is designed to surface user customer facing messages as errors.
|
||||
*/
|
||||
type Data = { [key: string]: any };
|
||||
class APIManagerResponseSuccess {
|
||||
public user_message: string;
|
||||
public data: Data | null;
|
||||
constructor(user_message: string = "", data: Data | null = null) {
|
||||
this.data = data;
|
||||
this.user_message = user_message;
|
||||
}
|
||||
}
|
||||
|
||||
export { APIManagerResponseSuccess };
|
||||
@@ -1,278 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { Pass } from "three/examples/jsm/postprocessing/Pass.js";
|
||||
import { FullScreenQuad } from "three/examples/jsm/postprocessing/Pass.js";
|
||||
import {
|
||||
getSurfaceIdMaterial,
|
||||
getDebugSurfaceIdMaterial,
|
||||
} from "./FindSurfaces.js";
|
||||
|
||||
// Follows the structure of
|
||||
// https://github.com/mrdoob/three.js/blob/master/examples/jsm/postprocessing/OutlinePass.js
|
||||
class CustomOutlinePass extends Pass {
|
||||
constructor(resolution, scene, camera) {
|
||||
super();
|
||||
|
||||
this.renderScene = scene;
|
||||
this.renderCamera = camera;
|
||||
this.resolution = new THREE.Vector2(resolution.x, resolution.y);
|
||||
|
||||
this.fsQuad = new FullScreenQuad(null);
|
||||
this.fsQuad.material = this.createOutlinePostProcessMaterial();
|
||||
|
||||
// Create a buffer to store the normals of the scene onto
|
||||
// or store the "surface IDs"
|
||||
const surfaceBuffer = new THREE.WebGLRenderTarget(
|
||||
this.resolution.x,
|
||||
this.resolution.y,
|
||||
);
|
||||
surfaceBuffer.texture.format = THREE.RGBAFormat;
|
||||
surfaceBuffer.texture.type = THREE.HalfFloatType;
|
||||
surfaceBuffer.texture.minFilter = THREE.NearestFilter;
|
||||
surfaceBuffer.texture.magFilter = THREE.NearestFilter;
|
||||
surfaceBuffer.texture.generateMipmaps = false;
|
||||
surfaceBuffer.stencilBuffer = false;
|
||||
this.surfaceBuffer = surfaceBuffer;
|
||||
|
||||
this.normalOverrideMaterial = new THREE.MeshNormalMaterial();
|
||||
this.surfaceIdOverrideMaterial = getSurfaceIdMaterial();
|
||||
this.surfaceIdDebugOverrideMaterial = getDebugSurfaceIdMaterial();
|
||||
this.save_ = false;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.surfaceBuffer.dispose();
|
||||
this.fsQuad.dispose();
|
||||
}
|
||||
|
||||
updateMaxSurfaceId(maxSurfaceId) {
|
||||
this.surfaceIdOverrideMaterial.uniforms.maxSurfaceId.value = maxSurfaceId;
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
this.surfaceBuffer.setSize(width, height);
|
||||
this.resolution.set(width, height);
|
||||
|
||||
this.fsQuad.material.uniforms.screenSize.value.set(
|
||||
this.resolution.x,
|
||||
this.resolution.y,
|
||||
1 / this.resolution.x,
|
||||
1 / this.resolution.y,
|
||||
);
|
||||
}
|
||||
|
||||
getDebugVisualizeValue() {
|
||||
return this.fsQuad.material.uniforms.debugVisualize.value;
|
||||
}
|
||||
|
||||
isUsingSurfaceIds() {
|
||||
const debugVisualize = this.getDebugVisualizeValue();
|
||||
|
||||
return (
|
||||
debugVisualize == 0 || // Main outlines v2 mode
|
||||
debugVisualize == 5 || // Render just surfaceID debug buffer
|
||||
debugVisualize == 6
|
||||
); // Render just outlines with surfaceId
|
||||
}
|
||||
|
||||
save() {
|
||||
this.save_ = true;
|
||||
}
|
||||
|
||||
render(renderer, writeBuffer, readBuffer) {
|
||||
const depthBufferValue = writeBuffer.depthBuffer;
|
||||
writeBuffer.depthBuffer = false;
|
||||
|
||||
renderer.setRenderTarget(this.surfaceBuffer);
|
||||
const overrideMaterialValue = this.renderScene.overrideMaterial;
|
||||
this.renderScene.overrideMaterial = this.normalOverrideMaterial;
|
||||
|
||||
renderer.render(this.renderScene, this.renderCamera);
|
||||
this.renderScene.overrideMaterial = overrideMaterialValue;
|
||||
|
||||
this.fsQuad.material.uniforms["depthBuffer"].value =
|
||||
readBuffer.depthTexture;
|
||||
this.fsQuad.material.uniforms["surfaceBuffer"].value =
|
||||
this.surfaceBuffer.texture;
|
||||
this.fsQuad.material.uniforms["sceneColorBuffer"].value =
|
||||
readBuffer.texture;
|
||||
|
||||
renderer.setRenderTarget(writeBuffer);
|
||||
this.fsQuad.render(renderer);
|
||||
|
||||
// Reset the depthBuffer value so we continue writing to it in the next render.
|
||||
writeBuffer.depthBuffer = depthBufferValue;
|
||||
}
|
||||
|
||||
get vertexShader() {
|
||||
return `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
get fragmentShader() {
|
||||
return `
|
||||
#include <packing>
|
||||
// The above include imports "perspectiveDepthToViewZ"
|
||||
// and other GLSL functions from ThreeJS we need for reading depth.
|
||||
uniform sampler2D sceneColorBuffer;
|
||||
uniform sampler2D depthBuffer;
|
||||
uniform sampler2D surfaceBuffer;
|
||||
uniform float cameraNear;
|
||||
uniform float cameraFar;
|
||||
uniform vec4 screenSize;
|
||||
uniform vec3 outlineColor;
|
||||
uniform vec4 multiplierParameters;
|
||||
uniform int debugVisualize;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
// Helper functions for reading from depth buffer.
|
||||
float readDepth (sampler2D depthSampler, vec2 coord) {
|
||||
float fragCoordZ = texture2D(depthSampler, coord).x;
|
||||
float viewZ = perspectiveDepthToViewZ( fragCoordZ, cameraNear, cameraFar );
|
||||
return viewZToOrthographicDepth( viewZ, cameraNear, cameraFar );
|
||||
}
|
||||
float getLinearDepth(vec3 pos) {
|
||||
return -(viewMatrix * vec4(pos, 1.0)).z;
|
||||
}
|
||||
|
||||
float getLinearScreenDepth(sampler2D map) {
|
||||
vec2 uv = gl_FragCoord.xy * screenSize.zw;
|
||||
return readDepth(map,uv);
|
||||
}
|
||||
|
||||
// Helper functions for reading normals and depth of neighboring pixels.
|
||||
float getPixelDepth(int x, int y) {
|
||||
// screenSize.zw is pixel size
|
||||
// vUv is current position
|
||||
return readDepth(depthBuffer, vUv + screenSize.zw * vec2(x, y));
|
||||
}
|
||||
// "surface value" is either the normal or the "surfaceID"
|
||||
vec3 getSurfaceValue(int x, int y) {
|
||||
vec3 val = texture2D(surfaceBuffer, vUv + screenSize.zw * vec2(x, y)).rgb;
|
||||
return val;
|
||||
}
|
||||
|
||||
float saturateValue(float num) {
|
||||
return clamp(num, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float getSufaceIdDiff(vec3 surfaceValue) {
|
||||
float surfaceIdDiff = 0.0;
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(1, 0));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(0, 1));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(0, 1));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(0, -1));
|
||||
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(1, 1));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(1, -1));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(-1, 1));
|
||||
surfaceIdDiff += distance(surfaceValue, getSurfaceValue(-1, -1));
|
||||
return surfaceIdDiff;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 sceneColor = texture2D(sceneColorBuffer, vUv);
|
||||
float depth = getPixelDepth(0, 0);
|
||||
|
||||
// "surfaceValue" is either the normal or the surfaceId
|
||||
vec3 surfaceValue = getSurfaceValue(0, 0);
|
||||
|
||||
// Get the difference between depth of neighboring pixels and current.
|
||||
float depthDiff = 0.0;
|
||||
depthDiff += abs(depth - getPixelDepth(1, 0));
|
||||
depthDiff += abs(depth - getPixelDepth(-1, 0));
|
||||
depthDiff += abs(depth - getPixelDepth(0, 1));
|
||||
depthDiff += abs(depth - getPixelDepth(0, -1));
|
||||
|
||||
// Get the difference between surface values of neighboring pixels
|
||||
// and current
|
||||
float surfaceValueDiff = getSufaceIdDiff(surfaceValue);
|
||||
|
||||
// Apply multiplier & bias to each
|
||||
float depthBias = multiplierParameters.x;
|
||||
float depthMultiplier = multiplierParameters.y;
|
||||
float normalBias = multiplierParameters.z;
|
||||
float normalMultiplier = multiplierParameters.w;
|
||||
|
||||
depthDiff = depthDiff * depthMultiplier;
|
||||
depthDiff = saturateValue(depthDiff);
|
||||
depthDiff = pow(depthDiff, depthBias);
|
||||
|
||||
if (debugVisualize != 0 && debugVisualize != 6) {
|
||||
// Apply these params when using
|
||||
// normals instead of surfaceIds
|
||||
surfaceValueDiff = surfaceValueDiff * normalMultiplier;
|
||||
surfaceValueDiff = saturateValue(surfaceValueDiff);
|
||||
surfaceValueDiff = pow(surfaceValueDiff, normalBias);
|
||||
} else {
|
||||
if (surfaceValueDiff != 0.0) surfaceValueDiff = 1.0;
|
||||
}
|
||||
|
||||
float outline = saturateValue(surfaceValueDiff + depthDiff);
|
||||
|
||||
// Combine outline with scene color.
|
||||
vec4 outlineColor = vec4(outlineColor, 1.0);
|
||||
gl_FragColor = vec4(mix(sceneColor, outlineColor, outline));
|
||||
|
||||
//// For debug visualization of the different inputs to this shader.
|
||||
if (debugVisualize == 2) {
|
||||
gl_FragColor = sceneColor;
|
||||
}
|
||||
if (debugVisualize == 3) {
|
||||
gl_FragColor = vec4(vec3(depth), 1.0);
|
||||
gl_FragColor.rgb = 1.0 - gl_FragColor.rgb;
|
||||
}
|
||||
if (debugVisualize == 4 || debugVisualize == 5) {
|
||||
// 4 visualizes the normal buffer
|
||||
// 5 visualizes the surfaceID buffer
|
||||
// Either way they are the same buffer, we change
|
||||
// what we render into it
|
||||
gl_FragColor = vec4(surfaceValue, 1.0);
|
||||
}
|
||||
if (debugVisualize == 6 || debugVisualize == 7) {
|
||||
// Apply threshold
|
||||
if (outline > 0.75) {
|
||||
outline = 1.0;
|
||||
} else {
|
||||
outline = 0.0;
|
||||
}
|
||||
gl_FragColor = vec4(vec3(outline * outlineColor), 1.0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
createOutlinePostProcessMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
debugVisualize: { value: 0 },
|
||||
sceneColorBuffer: {},
|
||||
depthBuffer: {},
|
||||
surfaceBuffer: {},
|
||||
outlineColor: { value: new THREE.Color(0xffffff) },
|
||||
//4 scalar values packed in one uniform: depth multiplier, depth bias, and same for normals.
|
||||
multiplierParameters: {
|
||||
value: new THREE.Vector4(0.9, 20, 1, 1),
|
||||
},
|
||||
cameraNear: { value: 0.1 },
|
||||
cameraFar: { value: 3 },
|
||||
screenSize: {
|
||||
value: new THREE.Vector4(
|
||||
this.resolution.x,
|
||||
this.resolution.y,
|
||||
1 / this.resolution.x,
|
||||
1 / this.resolution.y,
|
||||
),
|
||||
},
|
||||
},
|
||||
vertexShader: this.vertexShader,
|
||||
fragmentShader: this.fragmentShader,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { CustomOutlinePass };
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
import { SceneGenereationMetaData } from "../../../models/sceneGenerationMetadata";
|
||||
import { SceneStateJson } from "./scene_state_json";
|
||||
|
||||
export interface EditorStateJson {
|
||||
version: number;
|
||||
sceneGenerationMetaData: SceneGenereationMetaData;
|
||||
sceneStateJson: SceneStateJson;
|
||||
snapshotTime: string; // make one via `new Date().toISOString()`
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./editor_state_json";
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
export interface Object3DJSON {
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
rotation: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
scale: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
object_name: string;
|
||||
object_uuid: string;
|
||||
object_user_data_name: string;
|
||||
media_file_token: string;
|
||||
color: string;
|
||||
metalness: number;
|
||||
shininess: number;
|
||||
specular: number;
|
||||
locked: boolean;
|
||||
visible: boolean | undefined;
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
// import * as THREE from "three";
|
||||
import { Object3DJSON } from "./object_3d_json";
|
||||
|
||||
export interface SceneStateJson {
|
||||
version: number;
|
||||
sceneItems: Object3DJSON[];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { SceneGenereationMetaData } from "../../models/sceneGenerationMetadata";
|
||||
import { EditorStateJson } from "./EditorStateJSON";
|
||||
import { SceneState } from "./scene_state";
|
||||
|
||||
export class EditorState {
|
||||
version: number;
|
||||
sceneGenerationMetaData: SceneGenereationMetaData | undefined;
|
||||
sceneState: SceneState | undefined;
|
||||
|
||||
constructor({ editorVersion }: { editorVersion: number }) {
|
||||
this.version = editorVersion;
|
||||
}
|
||||
|
||||
public async toJSON() {
|
||||
if (!this.sceneGenerationMetaData || !this.sceneState) {
|
||||
throw "Error in EditorState.toJSON: data undefined";
|
||||
}
|
||||
const result: EditorStateJson = {
|
||||
version: this.version,
|
||||
sceneGenerationMetaData: this.sceneGenerationMetaData,
|
||||
sceneStateJson: await this.sceneState.toJSON(),
|
||||
snapshotTime: new Date().toISOString(),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./editor_state";
|
||||
export * from "./EditorStateJSON";
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { Object3DJSON } from "./EditorStateJSON/object_3d_json";
|
||||
|
||||
export class Object3D {
|
||||
version: number;
|
||||
|
||||
position: THREE.Vector3;
|
||||
rotation: THREE.Euler;
|
||||
scale: THREE.Vector3;
|
||||
|
||||
object_uuid: string;
|
||||
object_name: string;
|
||||
object_user_data_name: string; // changable name
|
||||
media_file_token: string;
|
||||
color: string;
|
||||
metalness: number;
|
||||
shininess: number;
|
||||
specular: number;
|
||||
locked: boolean;
|
||||
visible: boolean;
|
||||
|
||||
constructor(version: number, media_file_token: string) {
|
||||
this.version = version;
|
||||
this.media_file_token = media_file_token;
|
||||
|
||||
this.position = new THREE.Vector3(0.0, 0.0, 0.0);
|
||||
this.rotation = new THREE.Euler(0.0, 0.0, 0.0);
|
||||
this.scale = new THREE.Vector3(1.0, 1.0, 1.0);
|
||||
|
||||
this.object_name = "";
|
||||
this.object_user_data_name = "";
|
||||
this.object_uuid = "";
|
||||
|
||||
this.color = "";
|
||||
|
||||
this.metalness = 0.0;
|
||||
this.shininess = 0.0;
|
||||
this.specular = 0.5;
|
||||
this.locked = false;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
getColorAsHexString(object: THREE.Object3D): string {
|
||||
if (
|
||||
object instanceof THREE.Mesh &&
|
||||
object.material instanceof THREE.MeshBasicMaterial
|
||||
) {
|
||||
return "#" + object.material.color.getHexString();
|
||||
}
|
||||
return "#FFFFFF";
|
||||
}
|
||||
|
||||
public async initialize(object: THREE.Object3D) {
|
||||
this.position = object.position;
|
||||
this.rotation = object.rotation;
|
||||
this.scale = object.scale;
|
||||
|
||||
this.object_name = object.name;
|
||||
this.object_user_data_name = object.userData.name;
|
||||
this.object_uuid = object.uuid;
|
||||
this.color = object.userData["color"];
|
||||
this.metalness = object.userData["metalness"];
|
||||
this.shininess = object.userData["shininess"];
|
||||
this.specular = object.userData["specular"];
|
||||
this.locked = object.userData["locked"];
|
||||
}
|
||||
|
||||
public async toJSON(): Promise<Object3DJSON> {
|
||||
const json: Object3DJSON = {
|
||||
position: {
|
||||
x: this.position.x,
|
||||
y: this.position.y,
|
||||
z: this.position.z,
|
||||
},
|
||||
rotation: {
|
||||
x: this.rotation.x,
|
||||
y: this.rotation.y,
|
||||
z: this.rotation.z,
|
||||
},
|
||||
scale: {
|
||||
x: this.scale.x,
|
||||
y: this.scale.y,
|
||||
z: this.scale.z,
|
||||
},
|
||||
object_name: this.object_name,
|
||||
object_uuid: this.object_uuid,
|
||||
object_user_data_name: this.object_user_data_name,
|
||||
media_file_token: this.media_file_token,
|
||||
color: this.color,
|
||||
metalness: this.metalness,
|
||||
shininess: this.shininess,
|
||||
specular: this.specular,
|
||||
locked: this.locked,
|
||||
visible: this.visible,
|
||||
};
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// import * as THREE from "three";
|
||||
import { Object3D } from "./object_3d";
|
||||
import { Object3DJSON } from "./EditorStateJSON/object_3d_json";
|
||||
import Scene from "../../engine/scene";
|
||||
import { SceneStateJson } from "./EditorStateJSON/scene_state_json";
|
||||
const DEFAULT_SCENE_VER = 1.0;
|
||||
|
||||
export class SceneState {
|
||||
version: number;
|
||||
sceneItems: Object3D[] | undefined;
|
||||
|
||||
constructor({ scene, version }: { scene?: Scene; version?: number }) {
|
||||
this.version = version ?? scene?.version ?? DEFAULT_SCENE_VER;
|
||||
}
|
||||
|
||||
public async initiateFromScene({ scene }: { scene: Scene }): Promise<void> {
|
||||
if (this.version < 1) {
|
||||
this.saveFromSceneObjectVer0(scene)
|
||||
.then((result) => {
|
||||
this.sceneItems = result;
|
||||
})
|
||||
.catch((e) => {
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
const result = new Array<Object3D>();
|
||||
// TODO turn the sceneItems into an object3d-json
|
||||
// reject if something goes wrong
|
||||
// or else send the result via resolve
|
||||
this.sceneItems = result;
|
||||
return;
|
||||
}
|
||||
|
||||
public async initiateFromObject3DJson({
|
||||
objectJsons,
|
||||
}: {
|
||||
objectJsons: Object3DJSON[];
|
||||
}): Promise<void> {
|
||||
const sceneItems = objectJsons.map((jsonObject: Object3DJSON) => {
|
||||
console.log(jsonObject);
|
||||
const object3D = new Object3D(this.version, "placeholder-token");
|
||||
// TODO: now translate that jsonObject back into a ThreeJS 3D object
|
||||
return object3D;
|
||||
});
|
||||
this.sceneItems = sceneItems;
|
||||
return;
|
||||
}
|
||||
|
||||
public async saveFromSceneObjectVer0(scene: Scene): Promise<Object3D[]> {
|
||||
console.log("saveFromSceneObjectVer0", scene);
|
||||
const results: Object3D[] = [];
|
||||
// TODO: turn sceneItems fron type of Object3D into Object3DJSON, the old way
|
||||
|
||||
// if (this.scene.scene != null) {
|
||||
// for (const child of this.scene.scene.children) {
|
||||
// if (child.userData["media_id"] != undefined) {
|
||||
// if (this.lookUpDictionary[child.uuid] == null) {
|
||||
// this.lookUpDictionary[child.uuid] = new Object3D(
|
||||
// this.version,
|
||||
// child.userData["media_id"],
|
||||
// );
|
||||
// }
|
||||
// const proxyObject3D: Object3D = this.lookUpDictionary[child.uuid];
|
||||
// proxyObject3D.position.copy(child.position);
|
||||
// proxyObject3D.rotation.copy(child.rotation);
|
||||
// proxyObject3D.scale.copy(child.scale);
|
||||
// proxyObject3D.object_user_data_name = child.userData.name;
|
||||
// proxyObject3D.object_name = child.name;
|
||||
// proxyObject3D.object_uuid = child.uuid;
|
||||
// proxyObject3D.color = child.userData["color"];
|
||||
// proxyObject3D.metalness = child.userData["metalness"];
|
||||
// proxyObject3D.shininess = child.userData["shininess"];
|
||||
// proxyObject3D.specular = child.userData["specular"];
|
||||
// proxyObject3D.locked = child.userData["locked"];
|
||||
// const json_data = await proxyObject3D.toJSON();
|
||||
// results.push(json_data);
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// console.log("Scene doesn't exist needs to be assigned");
|
||||
// }
|
||||
// console.log(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async toJSON(): Promise<SceneStateJson> {
|
||||
const result: SceneStateJson = {
|
||||
version: this.version,
|
||||
sceneItems: [],
|
||||
};
|
||||
if (this.sceneItems) {
|
||||
for (let i = 0; i < this.sceneItems.length; i = i + 1) {
|
||||
const itemJson = await this.sceneItems[i].toJSON();
|
||||
result.sceneItems.push(itemJson);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
/*
|
||||
This class computes "surface IDs" for a given mesh.
|
||||
|
||||
A "surface" is defined as a set of triangles that share vertices.
|
||||
|
||||
Inspired by Ian MacLarty, see:
|
||||
https://twitter.com/ianmaclarty/status/1499494878908403712
|
||||
*/
|
||||
class FindSurfaces {
|
||||
constructor() {
|
||||
// This identifier, must be globally unique for each surface
|
||||
// across all geometry rendered on screen
|
||||
this.surfaceId = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the surface Ids as a Float32Array that can be inserted as a vertex attribute
|
||||
*/
|
||||
getSurfaceIdAttribute(mesh) {
|
||||
const bufferGeometry = mesh.geometry;
|
||||
const numVertices = bufferGeometry.attributes.position.count;
|
||||
const vertexIdToSurfaceId = this._generateSurfaceIds(mesh);
|
||||
|
||||
const colors = [];
|
||||
for (let i = 0; i < numVertices; i++) {
|
||||
const vertexId = i;
|
||||
let surfaceId = vertexIdToSurfaceId[vertexId];
|
||||
|
||||
colors.push(surfaceId, 0, 0, 1);
|
||||
}
|
||||
|
||||
const colorsTypedArray = new Float32Array(colors);
|
||||
return colorsTypedArray;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a `vertexIdToSurfaceId` map
|
||||
* given a vertex, returns the surfaceId
|
||||
*/
|
||||
_generateSurfaceIds(mesh) {
|
||||
const bufferGeometry = mesh.geometry;
|
||||
//const numVertices = bufferGeometry.attributes.position.count;
|
||||
const numIndices = bufferGeometry.index.count;
|
||||
const indexBuffer = bufferGeometry.index.array;
|
||||
//const vertexBuffer = bufferGeometry.attributes.position.array;
|
||||
// For each vertex, search all its neighbors
|
||||
const vertexMap = {};
|
||||
for (let i = 0; i < numIndices; i += 3) {
|
||||
const i1 = indexBuffer[i + 0];
|
||||
const i2 = indexBuffer[i + 1];
|
||||
const i3 = indexBuffer[i + 2];
|
||||
|
||||
add(i1, i2);
|
||||
add(i1, i3);
|
||||
add(i2, i3);
|
||||
}
|
||||
function add(a, b) {
|
||||
if (vertexMap[a] == undefined) vertexMap[a] = [];
|
||||
if (vertexMap[b] == undefined) vertexMap[b] = [];
|
||||
|
||||
if (vertexMap[a].indexOf(b) == -1) vertexMap[a].push(b);
|
||||
if (vertexMap[b].indexOf(a) == -1) vertexMap[b].push(a);
|
||||
}
|
||||
|
||||
// Find cycles
|
||||
const frontierNodes = Object.keys(vertexMap).map((v) => Number(v));
|
||||
const exploredNodes = {};
|
||||
const vertexIdToSurfaceId = {};
|
||||
|
||||
while (frontierNodes.length > 0) {
|
||||
const node = frontierNodes.pop();
|
||||
if (exploredNodes[node]) continue;
|
||||
|
||||
// Get all neighbors recursively
|
||||
const surfaceVertices = getNeighborsNonRecursive(node);
|
||||
// Mark them as explored
|
||||
for (let v of surfaceVertices) {
|
||||
exploredNodes[v] = true;
|
||||
vertexIdToSurfaceId[v] = this.surfaceId;
|
||||
}
|
||||
|
||||
this.surfaceId += 1;
|
||||
}
|
||||
|
||||
// function getNeighbors(node, explored) {
|
||||
// const neighbors = vertexMap[node];
|
||||
// let result = [node];
|
||||
// explored[node] = true;
|
||||
|
||||
// for (let n of neighbors) {
|
||||
// if (explored[n]) continue;
|
||||
// explored[n] = true;
|
||||
// const newNeighbors = getNeighbors(n, explored);
|
||||
// result = result.concat(newNeighbors);
|
||||
// }
|
||||
|
||||
// return result;
|
||||
// }
|
||||
|
||||
function getNeighborsNonRecursive(node) {
|
||||
const frontier = [node];
|
||||
const explored = {};
|
||||
const result = [];
|
||||
|
||||
while (frontier.length > 0) {
|
||||
const currentNode = frontier.pop();
|
||||
if (explored[currentNode]) continue;
|
||||
const neighbors = vertexMap[currentNode];
|
||||
result.push(currentNode);
|
||||
|
||||
explored[currentNode] = true;
|
||||
|
||||
for (let n of neighbors) {
|
||||
if (!explored[n]) {
|
||||
frontier.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return vertexIdToSurfaceId;
|
||||
}
|
||||
}
|
||||
|
||||
export default FindSurfaces;
|
||||
|
||||
export function getSurfaceIdMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
maxSurfaceId: { value: 1 },
|
||||
},
|
||||
vertexShader: getVertexShader(),
|
||||
fragmentShader: getFragmentShader(),
|
||||
vertexColors: true,
|
||||
});
|
||||
}
|
||||
|
||||
function getVertexShader() {
|
||||
return `
|
||||
varying vec2 v_uv;
|
||||
varying vec4 vColor;
|
||||
|
||||
void main() {
|
||||
v_uv = uv;
|
||||
vColor = color;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getFragmentShader() {
|
||||
return `
|
||||
varying vec2 v_uv;
|
||||
varying vec4 vColor;
|
||||
uniform float maxSurfaceId;
|
||||
|
||||
void main() {
|
||||
// Normalize the surfaceId when writing to texture
|
||||
// Surface ID needs rounding as precision can be lost in perspective correct interpolation
|
||||
// - see https://github.com/OmarShehata/webgl-outlines/issues/9 for other solutions eg. flat interpolation.
|
||||
float surfaceId = round(vColor.r) / maxSurfaceId;
|
||||
gl_FragColor = vec4(surfaceId, 0.0, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// For debug rendering, assign a random color
|
||||
// to each surfaceId
|
||||
export function getDebugSurfaceIdMaterial() {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {},
|
||||
vertexShader: getVertexShader(),
|
||||
fragmentShader: `
|
||||
varying vec2 v_uv;
|
||||
varying vec4 vColor;
|
||||
|
||||
void main() {
|
||||
int surfaceId = int(round(vColor.r) * 100.0);
|
||||
float R = float(surfaceId % 255) / 255.0;
|
||||
float G = float((surfaceId + 50) % 255) / 255.0;
|
||||
float B = float((surfaceId * 20) % 255) / 255.0;
|
||||
|
||||
gl_FragColor = vec4(R, G, B, 1.0);
|
||||
}
|
||||
`,
|
||||
vertexColors: true,
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export class InfiniteGridHelper extends THREE.Mesh {
|
||||
constructor(
|
||||
size1?: number,
|
||||
size2?: number,
|
||||
color?: THREE.Color,
|
||||
distance?: number,
|
||||
axes: string = "xzy",
|
||||
) {
|
||||
color = color || new THREE.Color("white");
|
||||
size1 = size1 || 10;
|
||||
size2 = size2 || 100;
|
||||
distance = distance || 8000;
|
||||
|
||||
const planeAxes = axes.substr(0, 2);
|
||||
const geometry = new THREE.PlaneGeometry(2, 2, 1, 1);
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
side: THREE.DoubleSide,
|
||||
uniforms: {
|
||||
uSize1: {
|
||||
value: size1,
|
||||
},
|
||||
uSize2: {
|
||||
value: size2,
|
||||
},
|
||||
uColor: {
|
||||
value: color,
|
||||
},
|
||||
uDistance: {
|
||||
value: distance,
|
||||
},
|
||||
uFadeStrength: {
|
||||
value: 1.5,
|
||||
},
|
||||
uFadeStart: {
|
||||
value: 0.2,
|
||||
},
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `
|
||||
varying vec3 worldPosition;
|
||||
uniform float uDistance;
|
||||
|
||||
void main() {
|
||||
vec3 pos = position.${axes} * uDistance;
|
||||
pos.${planeAxes} += cameraPosition.${planeAxes};
|
||||
|
||||
worldPosition = pos;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec3 worldPosition;
|
||||
|
||||
uniform float uSize1;
|
||||
uniform float uSize2;
|
||||
uniform vec3 uColor;
|
||||
uniform float uDistance;
|
||||
uniform float uFadeStrength;
|
||||
uniform float uFadeStart;
|
||||
|
||||
float getGrid(float size) {
|
||||
vec2 r = worldPosition.${planeAxes} / size;
|
||||
vec2 grid = abs(fract(r - 0.5) - 0.5) / (fwidth(r) * 1.5);
|
||||
float line = min(grid.x, grid.y);
|
||||
return 1.0 - min(line, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float distFromCamera = length(cameraPosition.${planeAxes} - worldPosition.${planeAxes}) / uDistance;
|
||||
float fadeOut = 1.0 - smoothstep(uFadeStart, 1.0, distFromCamera);
|
||||
fadeOut = pow(fadeOut, uFadeStrength);
|
||||
|
||||
float g1 = getGrid(uSize1);
|
||||
float g2 = getGrid(uSize2);
|
||||
|
||||
float grid = mix(g2 * 0.1, g1, g1);
|
||||
|
||||
gl_FragColor = vec4(uColor.rgb, grid * fadeOut * 0.04);
|
||||
gl_FragColor.a = mix(0.5 * gl_FragColor.a, gl_FragColor.a, g2);
|
||||
|
||||
if (gl_FragColor.a <= 0.0) discard;
|
||||
}
|
||||
`,
|
||||
extensions: {
|
||||
derivatives: true,
|
||||
},
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
super(geometry, material);
|
||||
this.frustumCulled = false;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Object3D, ObjectLoader } from "three";
|
||||
|
||||
export class BoneJSONHelper {
|
||||
|
||||
model: Object3D;
|
||||
|
||||
constructor(model: Object3D) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
private findHipBone(objectGroup: Object3D[]): Object3D | undefined {
|
||||
let hipBone = undefined;
|
||||
for (const child of objectGroup) {
|
||||
if (child.type === "Bone" && child.name.toLowerCase().includes("mixamorighips")) {
|
||||
hipBone = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hipBone;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public toJSON(): any {
|
||||
if (!this.model.userData.isCharacter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model.children.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the hip bone
|
||||
const hipBone = this.findHipBone(this.model.children[0].children);
|
||||
|
||||
if (!hipBone) {
|
||||
console.error("No hip bone found in the model.");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hipBone.toJSON();
|
||||
}
|
||||
|
||||
/* Applies the bone tree in a recursive BFS manner */
|
||||
private applyBoneTree(hipBone: Object3D, boneTree: Object3D): void {
|
||||
hipBone.position.copy(boneTree.position);
|
||||
hipBone.quaternion.copy(boneTree.quaternion);
|
||||
hipBone.scale.copy(boneTree.scale);
|
||||
|
||||
const hipChildren = hipBone.children;
|
||||
const treeChildren = boneTree.children;
|
||||
|
||||
// NOTE: Assumption made that the lengths match!
|
||||
// This shouldn't be a problem unless the skeletal model is changed
|
||||
for (let i = 0; i < hipChildren.length; i++) {
|
||||
const hipChild = hipChildren[i];
|
||||
const treeChild = treeChildren[i];
|
||||
if (hipChild && treeChild) {
|
||||
this.applyBoneTree(hipChild, treeChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public poseFromBoneJSON(boneJSON: any): void {
|
||||
const hipBone = this.findHipBone(this.model.children[0].children);
|
||||
if (!hipBone) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = new ObjectLoader();
|
||||
const boneTree = loader.parse(boneJSON);
|
||||
this.applyBoneTree(hipBone, boneTree);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export const FKBoneBlacklistStrings = [
|
||||
"def",
|
||||
"toe_end",
|
||||
"thumb",
|
||||
"index",
|
||||
"handmiddle",
|
||||
"handring",
|
||||
"pinky",
|
||||
"headtop",
|
||||
"-ear",
|
||||
"teeth",
|
||||
"eye",
|
||||
"lid",
|
||||
"jaw",
|
||||
"tongue",
|
||||
"lip",
|
||||
"chin",
|
||||
"brow",
|
||||
"cheek",
|
||||
"forehead",
|
||||
"nose",
|
||||
"temple"
|
||||
];
|
||||
@@ -1,167 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { FKBoneBlacklistStrings } from "./FKBoneFilter";
|
||||
import { TransformControls } from "../TransformControls";
|
||||
|
||||
export const FKHelperSphereName = "__FKHelperSphere__";
|
||||
|
||||
export class FKHelper {
|
||||
|
||||
private targetBoneSpheres: THREE.Object3D<THREE.Object3DEventMap>[];
|
||||
private transformControls: TransformControls;
|
||||
private raycaster: THREE.Raycaster;
|
||||
private scene: THREE.Scene;
|
||||
|
||||
private skeletonHelper: THREE.SkeletonHelper | null = null;
|
||||
private isControlInScene = false;
|
||||
|
||||
// Need the actual object to perform FK on
|
||||
// This means the object must have bones
|
||||
// Not all bones require FK right now
|
||||
// Need a set of names to filter bones
|
||||
// Use mixamo names
|
||||
// Need a transform control (rotation mode) for the bones
|
||||
// Need to disable the object transforms for bone transforms
|
||||
// Need raycaster
|
||||
constructor({
|
||||
camera,
|
||||
domElement,
|
||||
scene,
|
||||
onDragChange
|
||||
}: {
|
||||
camera: THREE.Camera;
|
||||
domElement: HTMLElement;
|
||||
scene: THREE.Scene;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
}) {
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.scene = scene;
|
||||
this.targetBoneSpheres = [];
|
||||
this.transformControls = new TransformControls(camera, domElement);
|
||||
|
||||
this.transformControls.setSpace("local");
|
||||
this.transformControls.setMode("rotate");
|
||||
this.transformControls.setSize(0.5);
|
||||
this.transformControls.addEventListener("dragging-changed", (event: any) => {
|
||||
onDragChange(event.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Set FK target
|
||||
setTarget(target: THREE.Object3D) {
|
||||
// TODO: Highlight all the necessary bones
|
||||
this.clear();
|
||||
target.traverse((child) => {
|
||||
// Skip if not bone
|
||||
if (child.type !== "Bone") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if bones is one of the blacklisted ones
|
||||
// This step could probably be optimized better
|
||||
for (const blacklistedBone of FKBoneBlacklistStrings) {
|
||||
if (child.name.toLowerCase().includes(blacklistedBone)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(child.name);
|
||||
|
||||
// For each target bone, display a sphere to show the FK target and raycast intersect
|
||||
const geometry = new THREE.SphereGeometry(0.015, 16, 16);
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false, transparent: true });
|
||||
const sphere = new THREE.Mesh(geometry, material);
|
||||
|
||||
// FIX: Set the sphere scale to counter parent's world scale to remain constant in size
|
||||
// TODO: Make a scale clamp
|
||||
const parentScale = new THREE.Vector3();
|
||||
child.getWorldScale(parentScale);
|
||||
|
||||
const sphereScale = new THREE.Vector3(1, 1, 1);
|
||||
sphereScale.divide(parentScale);
|
||||
sphere.scale.copy(sphereScale);
|
||||
|
||||
sphere.name = FKHelperSphereName;
|
||||
child.add(sphere);
|
||||
this.targetBoneSpheres.push(sphere);
|
||||
});
|
||||
|
||||
this.skeletonHelper = new THREE.SkeletonHelper(target);
|
||||
this.scene.add(this.skeletonHelper);
|
||||
console.log("All bones:", this.skeletonHelper.bones.map((b) => b.name));
|
||||
}
|
||||
|
||||
onMouseClick(mouse: THREE.Vector2) {
|
||||
this.raycaster.setFromCamera(mouse, this.transformControls.camera);
|
||||
const intersections = this.raycaster.intersectObjects(this.targetBoneSpheres, false);
|
||||
|
||||
if (intersections.length < 1) {
|
||||
console.log("No intersections");
|
||||
this.resetHighlight();
|
||||
this.removeControls();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach the controls to the first bone
|
||||
const firstBone = intersections[0];
|
||||
this.highlightSphere(firstBone.object);
|
||||
this.showControls(firstBone.object.parent!);
|
||||
console.log("Showing bone", firstBone.object);
|
||||
}
|
||||
|
||||
private showControls(object?: THREE.Object3D) {
|
||||
if (object) {
|
||||
this.transformControls.attach(object);
|
||||
}
|
||||
|
||||
if (this.isControlInScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scene.add(this.transformControls);
|
||||
this.isControlInScene = true;
|
||||
}
|
||||
|
||||
private highlightSphere(sphere: THREE.Object3D) {
|
||||
this.targetBoneSpheres.forEach((s) => {
|
||||
// @ts-expect-error Material on object3d, but it's fine since this is a mesh
|
||||
s.material.opacity = 0.2
|
||||
});
|
||||
// @ts-expect-error Material on object3d, but it's fine since this is a mesh
|
||||
sphere.material.opacity = 1;
|
||||
}
|
||||
|
||||
private resetHighlight() {
|
||||
this.targetBoneSpheres.forEach((s) => {
|
||||
// @ts-expect-error Material on object3d, but it's fine since this is a mesh
|
||||
s.material.opacity = 1;
|
||||
});
|
||||
}
|
||||
|
||||
private removeControls() {
|
||||
this.transformControls.detach();
|
||||
|
||||
if (this.isControlInScene) {
|
||||
this.scene.remove(this.transformControls);
|
||||
this.isControlInScene = false;
|
||||
}
|
||||
}
|
||||
|
||||
private removeSpheres() {
|
||||
this.targetBoneSpheres.forEach((sphere) => {
|
||||
this.scene.remove(sphere);
|
||||
sphere.parent?.remove(sphere);
|
||||
// @ts-expect-error Geometry on object3d, but it's fine since this is a mesh
|
||||
sphere.geometry.dispose();
|
||||
});
|
||||
this.targetBoneSpheres = [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.removeControls();
|
||||
this.removeSpheres();
|
||||
|
||||
if (this.skeletonHelper) {
|
||||
this.scene.remove(this.skeletonHelper!);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,64 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { FetchProxy as fetch } from "@storyteller/tauri-utils";
|
||||
import { StorytellerApiHostStore } from "@storyteller/api";
|
||||
|
||||
export const uploadNewScene = async (file: File, sceneTitle: string) => {
|
||||
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/new_scene`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuidv4());
|
||||
formData.append("file", file);
|
||||
formData.append("maybe_title", sceneTitle);
|
||||
formData.append("maybe_visibility", "public");
|
||||
formData.append("engine_category", "scene");
|
||||
|
||||
return await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res && res.success) {
|
||||
return res;
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return { success: false };
|
||||
});
|
||||
};
|
||||
|
||||
export const updateExistingScene = async (file: File, sceneToken: string) => {
|
||||
const apiSchemeAndHost = StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
const endpoint = `${apiSchemeAndHost}/v1/media_files/upload/saved_scene/${sceneToken}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuidv4());
|
||||
formData.append("file", file);
|
||||
|
||||
return await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res && res.success) {
|
||||
return res;
|
||||
} else {
|
||||
return { success: false };
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return { success: false };
|
||||
});
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { signalScene } from "~/signals";
|
||||
import { updateExistingScene, uploadNewScene } from "./api_fetchers";
|
||||
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
|
||||
import { StorytellerApiHostStore } from "@storyteller/api";
|
||||
|
||||
/**
|
||||
* Storyteller Studio API Manager
|
||||
*
|
||||
* The 3D editor used to drive a video-render pipeline through this
|
||||
* file (uploadMediaFrameGeneration, stylizeVideo, getMediaBatch,
|
||||
* etc.); all of that is gone. What's left handles scene save +
|
||||
* load and the cover-image thumbnail that goes with a save.
|
||||
*/
|
||||
|
||||
class APIManagerResponseError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = "APIManagerResponseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIManager {
|
||||
protected getApiSchemeAndHost() {
|
||||
return StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param saveJson Serialized scene JSON to upload as the scene file.
|
||||
* @param sceneTitle Display title.
|
||||
* @param sceneToken If provided, overwrite that scene; otherwise a new scene is created.
|
||||
* @param sceneThumbnail Cover image to attach to the scene record.
|
||||
* @returns The media file token of the saved scene, or "" on failure.
|
||||
*/
|
||||
public async saveSceneState({
|
||||
saveJson,
|
||||
sceneTitle,
|
||||
sceneToken,
|
||||
sceneThumbnail,
|
||||
}: {
|
||||
saveJson: string;
|
||||
sceneTitle: string;
|
||||
sceneToken?: string;
|
||||
sceneThumbnail: Blob | undefined;
|
||||
}): Promise<string> {
|
||||
const file = new File([saveJson], `${sceneTitle}.glb`, {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
const uploadSceneResponse = sceneToken
|
||||
? await updateExistingScene(file, sceneToken)
|
||||
: await uploadNewScene(file, sceneTitle);
|
||||
|
||||
if (uploadSceneResponse["success"] == false) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (sceneThumbnail) {
|
||||
const image_resp = await this.uploadMediaSceneThumbnail(
|
||||
sceneThumbnail,
|
||||
"render.png",
|
||||
);
|
||||
|
||||
if (image_resp["success"] == false) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (image_resp["media_file_token"]) {
|
||||
const image_token = image_resp["media_file_token"];
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/cover_image/${uploadSceneResponse["media_file_token"]}`;
|
||||
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cover_image_media_file_token: image_token }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return uploadSceneResponse["media_file_token"];
|
||||
}
|
||||
|
||||
public async loadSceneState(
|
||||
scene_media_file_token: string | null,
|
||||
): Promise<any> {
|
||||
const url = `${this.getApiSchemeAndHost()}/v1/media_files/file/${scene_media_file_token}`;
|
||||
const response = await fetch(url);
|
||||
if (response.status > 200) {
|
||||
throw new APIManagerResponseError("Failed to load scene");
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (json && json.media_file) {
|
||||
if (json.media_file.maybe_title === null) {
|
||||
console.warn(`Scene /w Token: ${scene_media_file_token} has no title`);
|
||||
}
|
||||
signalScene({
|
||||
title: json.media_file.maybe_title || "Untitled Scene",
|
||||
token: scene_media_file_token || undefined,
|
||||
ownerToken: json.media_file.maybe_creator_user.user_token,
|
||||
isModified: false,
|
||||
});
|
||||
}
|
||||
const bucket_path = json["media_file"]["public_bucket_path"];
|
||||
const media_url = `${GetCdnOrigin()}${bucket_path}`;
|
||||
|
||||
const file_response = await fetch(media_url);
|
||||
if (!file_response.ok) {
|
||||
throw new APIManagerResponseError("Failed to download file");
|
||||
}
|
||||
|
||||
const blob = await file_response.blob();
|
||||
const json_result: string = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(JSON.parse(reader.result as string));
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
|
||||
return json_result;
|
||||
}
|
||||
|
||||
public async uploadMediaSceneThumbnail(blob: Blob | File, fileName: string) {
|
||||
const url = `${this.getApiSchemeAndHost()}/v1/media_files/upload/image`;
|
||||
const uuid = uuidv4();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuid);
|
||||
formData.append("is_intermediate_system_file", "true");
|
||||
formData.append("maybe_title", "Screenshot");
|
||||
formData.append("file", blob, fileName);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new APIManagerResponseError("Upload Media Failed to send file");
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Browser feature detection helpers used by the input subsystem.
|
||||
// Pointer Lock behaves differently on Safari/WebKit, so the input
|
||||
// hooks fall back to drag-based control there.
|
||||
|
||||
export const isSafariOrWebKit = (): boolean => {
|
||||
const ua = navigator.userAgent;
|
||||
const isWebKit =
|
||||
/AppleWebKit/i.test(ua) && !/Chrome|Chromium|Edg|OPR|CriOS|FxiOS/i.test(ua);
|
||||
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
|
||||
return isSafari || isWebKit;
|
||||
};
|
||||
|
||||
export const hasApplePay = (): boolean => {
|
||||
// @ts-expect-error Apple Pay is not defined in TypeScript
|
||||
return !!window.ApplePaySession;
|
||||
};
|
||||
|
||||
export const isSafari = (): boolean => isSafariOrWebKit() || hasApplePay();
|
||||
|
||||
export const isPointerLockSupported = (): boolean => !isSafari();
|
||||
@@ -1,192 +1,19 @@
|
||||
import { MathUtils, Vector3 } from "three";
|
||||
import type { PerspectiveCamera } from "three";
|
||||
|
||||
// Pure math used by the free-cam viewport controls.
|
||||
// No DOM access, no event listeners, no signals — every function
|
||||
// takes inputs and returns values. The input hooks own the listener
|
||||
// lifecycle and the per-frame loop; this module owns the formulas.
|
||||
|
||||
// Held movement keys (WASD + QE). Stored as 0/1 so update steps can
|
||||
// just sum them in opposite directions.
|
||||
export interface HeldMoveKeys {
|
||||
forward: 0 | 1;
|
||||
back: 0 | 1;
|
||||
left: 0 | 1;
|
||||
right: 0 | 1;
|
||||
up: 0 | 1;
|
||||
down: 0 | 1;
|
||||
}
|
||||
|
||||
// Held rotation keys (arrow keys + roll). Same encoding.
|
||||
export interface HeldRotateKeys {
|
||||
pitchUp: 0 | 1;
|
||||
pitchDown: 0 | 1;
|
||||
yawLeft: 0 | 1;
|
||||
yawRight: 0 | 1;
|
||||
rollLeft: 0 | 1;
|
||||
rollRight: 0 | 1;
|
||||
}
|
||||
|
||||
export const emptyMoveKeys = (): HeldMoveKeys => ({
|
||||
forward: 0,
|
||||
back: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
up: 0,
|
||||
down: 0,
|
||||
});
|
||||
|
||||
export const emptyRotateKeys = (): HeldRotateKeys => ({
|
||||
pitchUp: 0,
|
||||
pitchDown: 0,
|
||||
yawLeft: 0,
|
||||
yawRight: 0,
|
||||
rollLeft: 0,
|
||||
rollRight: 0,
|
||||
});
|
||||
|
||||
// Per-frame movement direction (in camera-local space). Multiply by
|
||||
// (delta * movementSpeed) before applying.
|
||||
export const moveVectorFromKeys = (
|
||||
keys: HeldMoveKeys,
|
||||
autoForward = false,
|
||||
): Vector3 => {
|
||||
const forward = keys.forward || (autoForward && !keys.back) ? 1 : 0;
|
||||
return new Vector3(
|
||||
-keys.left + keys.right,
|
||||
-keys.down + keys.up,
|
||||
-forward + keys.back,
|
||||
);
|
||||
};
|
||||
|
||||
// Per-frame rotation deltas. Already pre-scaled by rollSpeed.
|
||||
export const rotationVectorFromKeys = (
|
||||
keys: HeldRotateKeys,
|
||||
rollSpeed: number,
|
||||
): Vector3 =>
|
||||
new Vector3(
|
||||
-keys.pitchDown + keys.pitchUp,
|
||||
-keys.yawRight + keys.yawLeft,
|
||||
-keys.rollRight + keys.rollLeft,
|
||||
).multiplyScalar(rollSpeed);
|
||||
|
||||
// Mouse-drag pan amounts (in camera-local x/y, before applying).
|
||||
// dx/dy are pointer deltas in pixels; speed scales them.
|
||||
export const panFromDrag = (
|
||||
dx: number,
|
||||
dy: number,
|
||||
speed: number,
|
||||
): { x: number; y: number } => ({
|
||||
x: -dx * (speed * 0.01),
|
||||
y: dy * (speed * 0.01),
|
||||
});
|
||||
|
||||
// Wheel-event zoom amount along camera-local z.
|
||||
export const zoomFromWheel = (deltaY: number): number => deltaY / 120;
|
||||
|
||||
// World-space lookAt point one unit ahead of the camera along its
|
||||
// current orientation. Used to update camera entries in the store
|
||||
// after a free-cam move so the camera entry's "lookAt" stays in
|
||||
// sync with the rendered orientation.
|
||||
export const lookAtFromCamera = (camera: PerspectiveCamera): Vector3 => {
|
||||
const lookAt = new Vector3(0, 0, -1);
|
||||
lookAt.applyQuaternion(camera.quaternion);
|
||||
lookAt.add(camera.position);
|
||||
return lookAt;
|
||||
};
|
||||
|
||||
// Smooth a velocity vector toward a target with a per-component lerp.
|
||||
// Returns the same `velocity` instance for chaining.
|
||||
export const lerpVelocity = (
|
||||
velocity: Vector3,
|
||||
target: Vector3,
|
||||
smoothing: number,
|
||||
): Vector3 => {
|
||||
velocity.x = MathUtils.lerp(velocity.x, target.x, smoothing);
|
||||
velocity.y = MathUtils.lerp(velocity.y, target.y, smoothing);
|
||||
velocity.z = MathUtils.lerp(velocity.z, target.z, smoothing);
|
||||
return velocity;
|
||||
};
|
||||
|
||||
// Per-frame state owned by useFreeCam. The editor's render loop
|
||||
// reads this struct on every tick and applies the resulting motion.
|
||||
// Mutated in place by event handlers (keyboard) so the React
|
||||
// reference stays stable.
|
||||
export interface FreeCamControlState {
|
||||
enabled: boolean;
|
||||
moveKeys: HeldMoveKeys;
|
||||
rotateKeys: HeldRotateKeys;
|
||||
velocity: Vector3;
|
||||
movementSpeed: number;
|
||||
rollSpeed: number;
|
||||
smoothing: number;
|
||||
}
|
||||
|
||||
export const createFreeCamControlState = (): FreeCamControlState => ({
|
||||
enabled: false,
|
||||
moveKeys: emptyMoveKeys(),
|
||||
rotateKeys: emptyRotateKeys(),
|
||||
velocity: new Vector3(),
|
||||
movementSpeed: 1.15,
|
||||
rollSpeed: Math.PI / 180,
|
||||
smoothing: 0.2,
|
||||
});
|
||||
|
||||
// Translate a code from a KeyboardEvent into the move-state slot it
|
||||
// drives, if any. Returns the property name on HeldMoveKeys, or null.
|
||||
export const moveSlotForKeyCode = (
|
||||
code: string,
|
||||
): keyof HeldMoveKeys | null => {
|
||||
switch (code) {
|
||||
case "KeyW": return "forward";
|
||||
case "KeyS": return "back";
|
||||
case "KeyA": return "left";
|
||||
case "KeyD": return "right";
|
||||
case "KeyQ": return "down";
|
||||
case "KeyE": return "up";
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const rotateSlotForKeyCode = (
|
||||
code: string,
|
||||
): keyof HeldRotateKeys | null => {
|
||||
switch (code) {
|
||||
case "ArrowUp": return "pitchUp";
|
||||
case "ArrowDown": return "pitchDown";
|
||||
case "ArrowLeft": return "yawLeft";
|
||||
case "ArrowRight": return "yawRight";
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-frame integration step. Reads `state` (held keys + velocity),
|
||||
// applies the resulting movement / rotation to `camera`, and returns
|
||||
// `true` if anything changed. The caller is responsible for any
|
||||
// downstream sync (e.g. lookAt updates) when this returns true.
|
||||
export const freeCamFrameTick = (
|
||||
camera: PerspectiveCamera,
|
||||
state: FreeCamControlState,
|
||||
delta: number,
|
||||
): boolean => {
|
||||
if (!state.enabled) return false;
|
||||
|
||||
const moveVec = moveVectorFromKeys(state.moveKeys);
|
||||
const rotVec = rotationVectorFromKeys(state.rotateKeys, state.rollSpeed);
|
||||
|
||||
const stationary = moveVec.lengthSq() === 0;
|
||||
const noRotation = rotVec.lengthSq() === 0;
|
||||
const velocityIdle = state.velocity.lengthSq() < 0.0001;
|
||||
if (stationary && noRotation && velocityIdle) return false;
|
||||
|
||||
const target = moveVec.multiplyScalar(delta * state.movementSpeed);
|
||||
lerpVelocity(state.velocity, target, state.smoothing);
|
||||
|
||||
camera.translateX(state.velocity.x);
|
||||
camera.translateY(state.velocity.y);
|
||||
camera.translateZ(state.velocity.z);
|
||||
camera.rotateX(rotVec.x);
|
||||
camera.rotateY(rotVec.y);
|
||||
camera.rotateZ(rotVec.z);
|
||||
return true;
|
||||
};
|
||||
export {
|
||||
freeCamFrameTick,
|
||||
lookAtFromCamera,
|
||||
createFreeCamControlState,
|
||||
emptyMoveKeys,
|
||||
emptyRotateKeys,
|
||||
moveSlotForKeyCode,
|
||||
rotateSlotForKeyCode,
|
||||
panFromDrag,
|
||||
zoomFromWheel,
|
||||
moveVectorFromKeys,
|
||||
rotationVectorFromKeys,
|
||||
lerpVelocity,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
export type {
|
||||
FreeCamControlState,
|
||||
HeldMoveKeys,
|
||||
HeldRotateKeys,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* THREE.JS ShaderMaterial that removes a specified color (e.g. greens screen)
|
||||
* from a texture. Shader code by https://github.com/Mugen87 on THREE.js forum:
|
||||
* https://discourse.threejs.org/t/production-ready-green-screen-with-three-js/23113/2
|
||||
*/
|
||||
// import {ColorRepresentation} from 'three/src/utils';
|
||||
|
||||
import { VERTEX_SHADER, FRAGMENT_SHADER } from "./shaders";
|
||||
import * as THREE from "three";
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
class ChromaKeyMaterial extends THREE.ShaderMaterial {
|
||||
/**
|
||||
*
|
||||
* @param {HTMLVideoElement} videoElement Image or video to load into material's texture
|
||||
* @param {ColorRepresentation} keyColor
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} similarity
|
||||
* @param {number} smoothness
|
||||
* @param {number} spill
|
||||
*/
|
||||
constructor(
|
||||
VideoTexture,
|
||||
keyColor,
|
||||
width,
|
||||
height,
|
||||
similarity = 0.01,
|
||||
smoothness = 0.18,
|
||||
spill = 0.1,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.texture = VideoTexture;
|
||||
|
||||
const chromaKeyColor = new THREE.Color(keyColor);
|
||||
|
||||
this.setValues({
|
||||
uniforms: {
|
||||
tex: {
|
||||
value: this.texture,
|
||||
},
|
||||
keyColor: { value: chromaKeyColor },
|
||||
texWidth: { value: width },
|
||||
texHeight: { value: height },
|
||||
similarity: { value: similarity },
|
||||
smoothness: { value: smoothness },
|
||||
spill: { value: spill },
|
||||
},
|
||||
vertexShader: VERTEX_SHADER,
|
||||
fragmentShader: FRAGMENT_SHADER,
|
||||
transparent: true,
|
||||
});
|
||||
}
|
||||
|
||||
playVideo() {
|
||||
if (this.isVideo && this.video) {
|
||||
this.video.play();
|
||||
} else {
|
||||
throw new Error(`${this.url} is not a video file.`);
|
||||
}
|
||||
}
|
||||
|
||||
pauseVideo() {
|
||||
if (this.isVideo && this.video) {
|
||||
this.video.pause();
|
||||
} else {
|
||||
throw new Error(`${this.url} is not a video file.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ChromaKeyMaterial };
|
||||
@@ -1,918 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
import Scene from "./scene.js";
|
||||
import { APIManager } from "./api_manager.js";
|
||||
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
|
||||
import { CameraAspectRatio } from "~/pages/PageScene/enums";
|
||||
import { ClipGroup } from "~/enums";
|
||||
import { SceneUtils } from "./helper";
|
||||
import { MouseControls } from "./keybinds_controls";
|
||||
import { SaveManager } from "./save_manager";
|
||||
import {
|
||||
authentication,
|
||||
loadingBarData,
|
||||
loadingBarIsShowing,
|
||||
signalScene,
|
||||
} from "~/signals";
|
||||
|
||||
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
|
||||
import { MediaUploadApi } from "~/Classes/ApiManager";
|
||||
import { SceneManager } from "./scene_manager_api";
|
||||
import { ViewportController } from "./editor/ViewportController";
|
||||
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";
|
||||
import { usePageSceneStore } from "../PageSceneStore";
|
||||
import { EngineEventBus } from "./events/EngineEventBus";
|
||||
import { EngineStoreBridge } from "./EngineStoreBridge";
|
||||
import {
|
||||
EditorStateChangedEvent,
|
||||
EngineInitializedEvent,
|
||||
GridVisibleChangedEvent,
|
||||
InspectorPanelChangedEvent,
|
||||
SceneLoadedEvent,
|
||||
SceneResetEvent,
|
||||
TransformSpaceChangedEvent,
|
||||
} from "./events/EngineEvent";
|
||||
|
||||
export type EditorInitializeConfig = {
|
||||
sceneToken: string;
|
||||
editorCanvasEl: HTMLCanvasElement;
|
||||
camViewCanvasEl: HTMLCanvasElement;
|
||||
sceneContainerEl: HTMLDivElement;
|
||||
cacheJsonString?: string;
|
||||
};
|
||||
|
||||
class Editor {
|
||||
version: number;
|
||||
activeScene: Scene;
|
||||
renderer: THREE.WebGLRenderer | undefined;
|
||||
sparkRenderer: SparkRenderer | null = null;
|
||||
rawRenderer: THREE.WebGLRenderer | undefined;
|
||||
clock: THREE.Clock | undefined;
|
||||
|
||||
raycaster: THREE.Raycaster | undefined;
|
||||
mouse: THREE.Vector2 | undefined;
|
||||
api_manager: APIManager;
|
||||
orbitControls: OrbitControls | undefined;
|
||||
locked: boolean;
|
||||
|
||||
render_timer: number;
|
||||
fps_number: number;
|
||||
cap_fps: number;
|
||||
frames: number;
|
||||
lastFrameTime: number;
|
||||
|
||||
can_initialize: boolean;
|
||||
|
||||
positive_prompt: string;
|
||||
|
||||
// Owns canvas/container DOM refs and the resize cascade.
|
||||
viewport: ViewportController;
|
||||
// Owns the EffectComposer chains and post-process passes.
|
||||
postProcessing: PostProcessingPipeline;
|
||||
// Owns the TransformControls gizmo.
|
||||
gizmo: GizmoController;
|
||||
// Owns the selection-and-outliner sync into the Zustand store.
|
||||
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;
|
||||
|
||||
// Typed event bus the engine emits onto. EngineStoreBridge subscribes
|
||||
// once at construction and is the only file under engine/ that
|
||||
// imports the Zustand store.
|
||||
bus: EngineEventBus;
|
||||
private storeBridge: EngineStoreBridge;
|
||||
// Store→engine reactor for grid visibility (toggling the gridHelper
|
||||
// mesh in/out of the THREE.js scene). Cleared on unmountEngine.
|
||||
private gridSubscription: () => void;
|
||||
|
||||
// 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 {
|
||||
return this.selection.selected;
|
||||
}
|
||||
|
||||
// Read-side facade for engine-internal callers (keymap, hooks) that
|
||||
// need to consult the store without importing it directly. Keeps the
|
||||
// store import scoped to Editor + EngineStoreBridge.
|
||||
getPoseMode(): "select" | "pose" {
|
||||
return usePageSceneStore.getState().poseMode;
|
||||
}
|
||||
|
||||
utils: SceneUtils;
|
||||
mouse_controls: MouseControls | undefined;
|
||||
save_manager: SaveManager;
|
||||
|
||||
media_upload: MediaUploadApi;
|
||||
|
||||
sceneManager: SceneManager | undefined;
|
||||
|
||||
// True when the user is allowed to drive the camera with the mouse
|
||||
// (left/middle-click look-around). Flipped to false WHILE a gizmo
|
||||
// handle is being dragged, then back to true on dragend so the
|
||||
// gizmo drag doesn't accidentally pan the camera. Defaults to true
|
||||
// so camera controls are available before the user has touched the
|
||||
// gizmo for the first time.
|
||||
focused: boolean = true;
|
||||
|
||||
renderIndex: number;
|
||||
stats: Stats;
|
||||
|
||||
// Allows us to cancel the queued render
|
||||
private renderEventToken: number;
|
||||
private shouldRender: boolean;
|
||||
private isMounted: boolean = false;
|
||||
private _isEngineDataLoaded: boolean = false;
|
||||
isEngineDataLoaded() {
|
||||
return this._isEngineDataLoaded;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
console.log(
|
||||
"If you see this message twice! then it rendered twice, if you see it once it's all good.",
|
||||
);
|
||||
this.can_initialize = true;
|
||||
this.stats = new Stats();
|
||||
// TODO: REMOVE LATER WITH BETTER FIX FOR IMPORTING AMMOJS
|
||||
document.body.appendChild(
|
||||
Object.assign(document.createElement("script"), {
|
||||
src: "jsm/libs/ammo.wasm.js",
|
||||
}),
|
||||
);
|
||||
|
||||
const newElement = document.createElement("div");
|
||||
newElement.id = "created-one-element";
|
||||
document.body.appendChild(newElement);
|
||||
// life cycle fix
|
||||
|
||||
// Version and name.
|
||||
this.version = 2.0;
|
||||
// Clock, scene and camera essentials.
|
||||
|
||||
// Bus + bridge must exist before any subsystem that emits events.
|
||||
// The bridge is the only file under engine/ that imports the Zustand
|
||||
// store; every other engine→store write goes through `this.bus.emit`.
|
||||
this.bus = new EngineEventBus();
|
||||
this.storeBridge = new EngineStoreBridge({ bus: this.bus });
|
||||
|
||||
// Engine-side reactor for grid visibility. Keeps the write flow
|
||||
// strictly one-way: UI toggles emit GridVisibleChangedEvent on the
|
||||
// bus → bridge updates the store + this subscriber updates the
|
||||
// THREE.js gridHelper. No "store→engine" subscription anywhere.
|
||||
this.gridSubscription = this.bus.subscribe(
|
||||
GridVisibleChangedEvent,
|
||||
(e) => this.activeScene?.applyGridVisibility(e.visible),
|
||||
);
|
||||
|
||||
// PostProcessingPipeline must exist before Scene because Scene's
|
||||
// load paths invoke updateSurfaceIdAttributeToMesh as a callback.
|
||||
this.postProcessing = new PostProcessingPipeline();
|
||||
this.gizmo = new GizmoController({
|
||||
getTransformSpace: () => usePageSceneStore.getState().transformSpace,
|
||||
setTransformSpace: (space) =>
|
||||
this.bus.emit(new TransformSpaceChangedEvent(space)),
|
||||
});
|
||||
this.cameraController = new CameraController({
|
||||
getThreeScene: () => this.activeScene.scene,
|
||||
getHotItems: () => this.activeScene.hot_items ?? null,
|
||||
removeTransformControls: () => this.utils.removeTransformControls(),
|
||||
setSelected: (obj) => {
|
||||
this.selection.selected = obj ?? undefined;
|
||||
this.selection.publishSelect();
|
||||
this.selection.updateSelectedUI();
|
||||
},
|
||||
setEditorState: (state) =>
|
||||
this.bus.emit(new EditorStateChangedEvent(state)),
|
||||
hideObjectPanel: () =>
|
||||
this.bus.emit(new InspectorPanelChangedEvent(null)),
|
||||
getCameras: () => usePageSceneStore.getState().cameras,
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
bus: this.bus,
|
||||
});
|
||||
this.selection = new SelectionBridge({
|
||||
getSceneManager: () => this.sceneManager,
|
||||
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),
|
||||
attachGizmoToCurrentSelection: () => {
|
||||
this.gizmo.addToScene(this.activeScene.scene);
|
||||
const selected = this.sceneManager?.selected_objects?.[0];
|
||||
if (selected) this.gizmo.attach(selected);
|
||||
},
|
||||
bus: this.bus,
|
||||
getCharactersByUuid: () => {
|
||||
const characters = usePageSceneStore.getState().characters;
|
||||
const result: { [uuid: string]: ClipGroup } = {};
|
||||
for (const c of characters) result[c.id] = ClipGroup.CHARACTER;
|
||||
return result;
|
||||
},
|
||||
isCharacterUuid: (uuid) =>
|
||||
usePageSceneStore.getState().characters.some((c) => c.id === uuid),
|
||||
});
|
||||
|
||||
this.activeScene = new Scene(
|
||||
"" + this.version,
|
||||
this.cameraController.camera_name,
|
||||
(scene: THREE.Scene) =>
|
||||
this.postProcessing.updateSurfaceIdAttributeToMesh(scene),
|
||||
this.version,
|
||||
{
|
||||
getCameras: () => usePageSceneStore.getState().cameras,
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
},
|
||||
);
|
||||
this.activeScene.initialize();
|
||||
this.api_manager = new APIManager();
|
||||
this.locked = false;
|
||||
this.render_timer = 0;
|
||||
this.fps_number = 60;
|
||||
this.cap_fps = 60;
|
||||
this.frames = 0;
|
||||
this.lastFrameTime = 0;
|
||||
this.renderEventToken = -1;
|
||||
this.shouldRender = false;
|
||||
|
||||
this.utils = new SceneUtils(this.activeScene, {
|
||||
getGizmoControl: () => this.gizmo.control,
|
||||
detachGizmo: () => this.gizmo.detach(),
|
||||
removeGizmoFromScene: () =>
|
||||
this.gizmo.removeFromScene(this.activeScene.scene),
|
||||
getOutlinePass: () => this.postProcessing.outlinePass,
|
||||
publishSelect: () => this.selection.publishSelect(),
|
||||
clearSelected: () => {
|
||||
this.selection.selected = undefined;
|
||||
},
|
||||
getCameraName: () => this.cameraController.camera_name,
|
||||
getSelectedObject: () => this.sceneManager?.selected_objects?.[0],
|
||||
getThreeScene: () => this.activeScene.scene,
|
||||
bus: this.bus,
|
||||
});
|
||||
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),
|
||||
getCameras: () => usePageSceneStore.getState().cameras,
|
||||
getSelectedCameraId: () =>
|
||||
usePageSceneStore.getState().selectedCameraId,
|
||||
bus: this.bus,
|
||||
});
|
||||
this.viewport = new ViewportController({
|
||||
getCamera: () => this.cameraController.camera,
|
||||
getRenderCamera: () => this.cameraController.render_camera,
|
||||
getRenderer: () => this.renderer,
|
||||
getRenderAspectRatio: () =>
|
||||
this.cameraController.getRenderDimensions().aspectRatio,
|
||||
resizePostProcessing: (w, h) => this.postProcessing.resize(w, h),
|
||||
});
|
||||
|
||||
// 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.";
|
||||
|
||||
this.media_upload = new MediaUploadApi();
|
||||
|
||||
// set image type at this stage
|
||||
|
||||
this.renderIndex = 0;
|
||||
}
|
||||
|
||||
isEmpty(value: string | null) {
|
||||
return value === null || value.trim().length === 0;
|
||||
}
|
||||
|
||||
initialize({
|
||||
sceneToken,
|
||||
editorCanvasEl,
|
||||
camViewCanvasEl,
|
||||
sceneContainerEl,
|
||||
cacheJsonString: cacheJson = "",
|
||||
}: EditorInitializeConfig) {
|
||||
if (!this.can_initialize) {
|
||||
console.log("3D editor is already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
this._isEngineDataLoaded = false;
|
||||
this.can_initialize = false;
|
||||
|
||||
// Gets the canvas.
|
||||
this.viewport.canvReference = editorCanvasEl;
|
||||
this.viewport.canvasRenderCamReference = camViewCanvasEl;
|
||||
|
||||
// Find the container element
|
||||
this.viewport.container = sceneContainerEl;
|
||||
|
||||
// Use the container's dimensions
|
||||
const width = this.viewport.container.offsetWidth;
|
||||
const height = this.viewport.container.offsetHeight;
|
||||
|
||||
// Sets up camera and base position using camera configurations from the store.
|
||||
const mainCameraConfig = usePageSceneStore
|
||||
.getState()
|
||||
.cameras.find((cam) => cam.id === "main");
|
||||
if (mainCameraConfig) {
|
||||
const mainCamera = new THREE.PerspectiveCamera(
|
||||
this.cameraController.focalLengthToFov(mainCameraConfig.focalLength),
|
||||
width / height,
|
||||
0.1,
|
||||
2000,
|
||||
);
|
||||
mainCamera.position.set(
|
||||
mainCameraConfig.position.x,
|
||||
mainCameraConfig.position.y,
|
||||
mainCameraConfig.position.z,
|
||||
);
|
||||
mainCamera.lookAt(
|
||||
mainCameraConfig.lookAt.x,
|
||||
mainCameraConfig.lookAt.y,
|
||||
mainCameraConfig.lookAt.z,
|
||||
);
|
||||
mainCamera.layers.enable(0);
|
||||
mainCamera.layers.enable(1);
|
||||
this.cameraController.camera = mainCamera;
|
||||
}
|
||||
|
||||
const otherCameras = usePageSceneStore
|
||||
.getState()
|
||||
.cameras.filter((cam) => cam.id !== "main");
|
||||
if (otherCameras.length > 0) {
|
||||
const renderCameraConfig = otherCameras[0];
|
||||
const renderCamera = new THREE.PerspectiveCamera(
|
||||
this.cameraController.focalLengthToFov(renderCameraConfig.focalLength),
|
||||
width / height,
|
||||
0.01,
|
||||
200,
|
||||
);
|
||||
renderCamera.position.set(
|
||||
renderCameraConfig.position.x,
|
||||
renderCameraConfig.position.y,
|
||||
renderCameraConfig.position.z,
|
||||
);
|
||||
renderCamera.lookAt(
|
||||
renderCameraConfig.lookAt.x,
|
||||
renderCameraConfig.lookAt.y,
|
||||
renderCameraConfig.lookAt.z,
|
||||
);
|
||||
renderCamera.layers.disable(1); // This camera does not see this layer
|
||||
this.cameraController.render_camera = renderCamera;
|
||||
}
|
||||
|
||||
// Base WebGL render and clock for delta time.
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
canvas: this.viewport.canvReference,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
// this.sparkRenderer = new SparkRenderer({
|
||||
// renderer: this.renderer,
|
||||
// autoUpdate: true,
|
||||
// });
|
||||
|
||||
this.rawRenderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
canvas: this.viewport.canvasRenderCamReference,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.clock = new THREE.Clock();
|
||||
|
||||
// Resizes the renderer.
|
||||
this.renderer.setSize(width, height);
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
this.postProcessing.configureMain(
|
||||
this.renderer,
|
||||
this.activeScene.scene,
|
||||
this.cameraController.camera,
|
||||
this.viewport.canvReference?.width ?? 0,
|
||||
this.viewport.canvReference?.height ?? 0,
|
||||
);
|
||||
// Controls and movement.
|
||||
|
||||
if (this.cameraController.camera) {
|
||||
this.cameraController.lockControls = new PointerLockControls(
|
||||
this.cameraController.camera,
|
||||
this.renderer.domElement,
|
||||
);
|
||||
}
|
||||
// FreeCam math + listeners now live in hooks/useFreeCam.ts; the
|
||||
// editor reads `cameraController.freeCamState` (set by that hook) on
|
||||
// every render.
|
||||
|
||||
this.gizmo.configure(
|
||||
this.cameraController.camera,
|
||||
this.renderer.domElement,
|
||||
this.activeScene.scene,
|
||||
{
|
||||
onChange: () => this.renderScene(),
|
||||
onDraggingChanged: (dragging) => {
|
||||
this.selection.updateSelectedUI();
|
||||
this.cameraController.camera_last_pos.copy(
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
// Configure raycaster to check both layers
|
||||
this.raycaster.layers.set(0); // Enable default layer
|
||||
this.raycaster.layers.enable(1); // Also check objects on the custom layer
|
||||
|
||||
this.mouse = new THREE.Vector2();
|
||||
// Resets canvas size.
|
||||
this.viewport.onWindowResize();
|
||||
|
||||
this.viewport.setupResizeObserver();
|
||||
|
||||
this.cameraController.refreshCamObj(this.activeScene.scene);
|
||||
|
||||
this.mouse_controls = new MouseControls({
|
||||
camera: this.cameraController.camera,
|
||||
camera_person_mode: this.cameraController.getCameraPersonMode.bind(
|
||||
this.cameraController,
|
||||
) as unknown as boolean,
|
||||
cameraViewControls: this.cameraController.freeCamState,
|
||||
lockControls: this.cameraController.lockControls,
|
||||
camera_last_pos: this.cameraController.camera_last_pos,
|
||||
deleteObject: this.deleteObject.bind(this),
|
||||
canvReference: this.viewport.canvReference,
|
||||
mouse: this.mouse,
|
||||
timeline_mouse: this.mouse,
|
||||
raycaster: this.raycaster,
|
||||
control: this.gizmo.control,
|
||||
outlinePass: this.postProcessing.outlinePass,
|
||||
scene: this.activeScene.scene,
|
||||
publishSelect: this.selection.publishSelect.bind(this.selection),
|
||||
updateSelectedUI: this.selection.updateSelectedUI.bind(this.selection),
|
||||
transform_interaction: false,
|
||||
last_selected: undefined,
|
||||
getAssetType: this.selection.getAssetType.bind(this.selection),
|
||||
setSelected: this.selection.setSelected.bind(this.selection),
|
||||
isMovable: this.isMovable.bind(this),
|
||||
enable_stats: this.enable_stats.bind(this),
|
||||
bus: this.bus,
|
||||
getPoseMode: () => usePageSceneStore.getState().poseMode,
|
||||
isHotkeyDisabled: () =>
|
||||
usePageSceneStore.getState().hotkeyStatus.disabled,
|
||||
getTransformSpace: () => usePageSceneStore.getState().transformSpace,
|
||||
});
|
||||
|
||||
this.sceneManager = new SceneManager(
|
||||
this.version,
|
||||
this.mouse_controls,
|
||||
this.activeScene,
|
||||
this.bus,
|
||||
);
|
||||
this.mouse_controls.sceneManager = this.sceneManager;
|
||||
|
||||
// Add spark renderer as a child of the camera
|
||||
// this.activeScene.scene.add(this.sparkRenderer);
|
||||
// this.camera?.add(this.sparkRenderer);
|
||||
|
||||
const onloadCallback = () => {
|
||||
console.log("Setting Scene is loaded");
|
||||
this._isEngineDataLoaded = true;
|
||||
|
||||
this.selection.refreshOutliner();
|
||||
|
||||
this.bus.emit(new SceneLoadedEvent(true));
|
||||
};
|
||||
|
||||
if (!this.utils.isEmpty(cacheJson)) {
|
||||
this.loadCache(cacheJson).then(onloadCallback);
|
||||
} else if (!this.utils.isEmpty(sceneToken)) {
|
||||
this.loadScene(sceneToken).then(onloadCallback);
|
||||
} else {
|
||||
signalScene({
|
||||
title: "Untitled New Scene",
|
||||
token: undefined,
|
||||
ownerToken: authentication.userInfo.value?.user_token,
|
||||
isModified: false,
|
||||
});
|
||||
onloadCallback();
|
||||
}
|
||||
|
||||
this.postProcessing.configureRaw(
|
||||
this.rawRenderer,
|
||||
this.activeScene.scene,
|
||||
this.cameraController.render_camera,
|
||||
this.viewport.canvasRenderCamReference?.width ?? 0,
|
||||
this.viewport.canvasRenderCamReference?.height ?? 0,
|
||||
);
|
||||
|
||||
loadingBarData.value = {
|
||||
...loadingBarData.value,
|
||||
progress: 100,
|
||||
};
|
||||
loadingBarIsShowing.value = false;
|
||||
|
||||
this.bus.emit(new EngineInitializedEvent(true));
|
||||
|
||||
// This will enable all event and render loops
|
||||
// We'll disable it here so the UI events can control is manually
|
||||
this.remountEngine();
|
||||
}
|
||||
|
||||
public isMovable(): boolean {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
public enable_stats() {
|
||||
document.body.appendChild(this.stats.dom);
|
||||
}
|
||||
|
||||
|
||||
// Captures the scene without the grid
|
||||
public snapShotOfCurrentFrame(shouldDownload: boolean = true) {
|
||||
const camera = this.cameraController.camera;
|
||||
const renderCamera = this.cameraController.render_camera;
|
||||
if (!this.renderer?.domElement || !camera) {
|
||||
console.error("Error: Renderer or camera not available.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const store = usePageSceneStore.getState();
|
||||
const currentAspectRatio = store.cameraAspectRatio;
|
||||
|
||||
// Store grid visibility state and hide grid for the snapshot. The
|
||||
// store is the source of truth (Scene's subscriber re-syncs the
|
||||
// gridHelper); we emit through the bus to keep the boundary clean.
|
||||
const wasGridVisible = store.gridVisible;
|
||||
this.bus.emit(new GridVisibleChangedEvent(false));
|
||||
|
||||
// Store and hide transform controls
|
||||
const wasControlVisible = this.gizmo.isVisible();
|
||||
this.gizmo.setVisible(false);
|
||||
|
||||
// Store and disable outline pass
|
||||
const outlinePass = this.postProcessing.outlinePass;
|
||||
const wasOutlineEnabled = outlinePass?.enabled ?? false;
|
||||
if (outlinePass) {
|
||||
outlinePass.enabled = false;
|
||||
}
|
||||
|
||||
// High quality dimensions for each aspect ratio
|
||||
let targetWidth: number;
|
||||
let targetHeight: number;
|
||||
let aspectRatio: number;
|
||||
|
||||
switch (currentAspectRatio) {
|
||||
case CameraAspectRatio.HORIZONTAL_16_9:
|
||||
targetWidth = 1280;
|
||||
targetHeight = 720;
|
||||
aspectRatio = 16 / 9;
|
||||
break;
|
||||
case CameraAspectRatio.VERTICAL_9_16:
|
||||
targetWidth = 720;
|
||||
targetHeight = 1280;
|
||||
aspectRatio = 9 / 16;
|
||||
break;
|
||||
case CameraAspectRatio.HORIZONTAL_3_2:
|
||||
targetWidth = 1536;
|
||||
targetHeight = 1024;
|
||||
aspectRatio = 3 / 2;
|
||||
break;
|
||||
case CameraAspectRatio.VERTICAL_2_3:
|
||||
targetWidth = 1024;
|
||||
targetHeight = 1536;
|
||||
aspectRatio = 2 / 3;
|
||||
break;
|
||||
case CameraAspectRatio.SQUARE_1_1:
|
||||
default:
|
||||
targetWidth = 1024;
|
||||
targetHeight = 1024;
|
||||
aspectRatio = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Store original renderer and camera state
|
||||
const sizeVector = new THREE.Vector2();
|
||||
this.renderer.getSize(sizeVector);
|
||||
|
||||
const originalWidth = sizeVector.x;
|
||||
const originalHeight = sizeVector.y;
|
||||
const originalPixelRatio = this.renderer.getPixelRatio();
|
||||
const originalCameraAspect = camera.aspect;
|
||||
const originalRenderCameraAspect =
|
||||
renderCamera?.aspect || originalCameraAspect;
|
||||
|
||||
// Temporarily set renderer to high resolution
|
||||
this.renderer.setSize(targetWidth, targetHeight, false);
|
||||
this.renderer.setPixelRatio(1);
|
||||
|
||||
// Update camera for the new aspect ratio
|
||||
camera.aspect = aspectRatio;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
// If using render camera, update it too
|
||||
if (renderCamera) {
|
||||
renderCamera.aspect = aspectRatio;
|
||||
renderCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Re-render the scene at high resolution
|
||||
if (this.postProcessing.composer) {
|
||||
this.postProcessing.composer.setSize(targetWidth, targetHeight);
|
||||
this.postProcessing.composer.render();
|
||||
} else {
|
||||
this.renderer.render(this.activeScene.scene, camera);
|
||||
}
|
||||
|
||||
// Get the high resolution snapshot
|
||||
const snapshot = this.renderer.domElement.toDataURL("image/png", 1.0);
|
||||
const base64Snapshot = snapshot.split(",")[1];
|
||||
|
||||
// Restore original camera aspect
|
||||
camera.aspect = originalCameraAspect;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
// Restore render camera if it exists
|
||||
if (renderCamera) {
|
||||
renderCamera.aspect = originalRenderCameraAspect;
|
||||
renderCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Restore original renderer size and pixel ratio
|
||||
this.renderer.setSize(originalWidth, originalHeight, false);
|
||||
this.renderer.setPixelRatio(originalPixelRatio);
|
||||
|
||||
// Re-render at original resolution
|
||||
if (this.postProcessing.composer) {
|
||||
this.postProcessing.composer.setSize(originalWidth, originalHeight);
|
||||
this.postProcessing.composer.render();
|
||||
} else {
|
||||
this.renderer.render(this.activeScene.scene, camera);
|
||||
}
|
||||
|
||||
// Restore grid visibility
|
||||
this.bus.emit(new GridVisibleChangedEvent(wasGridVisible));
|
||||
|
||||
// Restore transform controls visibility
|
||||
this.gizmo.setVisible(wasControlVisible);
|
||||
|
||||
// Restore outline pass
|
||||
if (outlinePass) {
|
||||
outlinePass.enabled = wasOutlineEnabled;
|
||||
}
|
||||
|
||||
if (shouldDownload) {
|
||||
const link = document.createElement("a");
|
||||
link.download = "scene-snapshot.png";
|
||||
link.href = snapshot;
|
||||
link.click();
|
||||
}
|
||||
|
||||
const byteString = atob(base64Snapshot);
|
||||
const mimeString = "image/png";
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const uuid = crypto.randomUUID();
|
||||
const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
return { base64Snapshot, file };
|
||||
}
|
||||
|
||||
public async newScene(sceneTitleInput: string) {
|
||||
this.activeScene.clear();
|
||||
this.cameraController.cam_obj = this.activeScene.get_object_by_name(
|
||||
this.cameraController.camera_name,
|
||||
);
|
||||
const sceneTitle =
|
||||
sceneTitleInput && sceneTitleInput !== ""
|
||||
? sceneTitleInput
|
||||
: "Untitled New Scene";
|
||||
signalScene({
|
||||
title: sceneTitle,
|
||||
token: undefined,
|
||||
ownerToken: authentication.userInfo.value?.user_token,
|
||||
isModified: false,
|
||||
});
|
||||
this.bus.emit(new SceneResetEvent());
|
||||
|
||||
this.selection.refreshOutliner();
|
||||
}
|
||||
|
||||
public async loadCache(cacheJson: string) {
|
||||
await this.save_manager.loadCache(cacheJson);
|
||||
}
|
||||
|
||||
public async loadScene(scene_media_token: string) {
|
||||
await this.save_manager.loadScene(scene_media_token);
|
||||
this.selection.refreshOutliner();
|
||||
}
|
||||
|
||||
public async saveScene({
|
||||
sceneTitle,
|
||||
sceneToken,
|
||||
sceneGenerationMetadata,
|
||||
}: {
|
||||
sceneTitle: string;
|
||||
sceneToken?: string;
|
||||
sceneGenerationMetadata: SceneGenereationMetaData;
|
||||
}): Promise<string> {
|
||||
return await this.save_manager.saveScene({
|
||||
sceneTitle: sceneTitle,
|
||||
sceneToken: sceneToken,
|
||||
sceneGenerationMetadata: sceneGenerationMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Render the scene to the camera, this is called in the update.
|
||||
async renderScene() {
|
||||
const { render_camera, render_width, render_height } = this.cameraController;
|
||||
if (
|
||||
this.postProcessing.composer != null &&
|
||||
this.rawRenderer &&
|
||||
this.postProcessing.render_composer
|
||||
) {
|
||||
this.postProcessing.composer.render();
|
||||
} else if (this.renderer && render_camera) {
|
||||
this.renderer.setSize(render_width, render_height);
|
||||
this.renderer.render(this.activeScene.scene, render_camera);
|
||||
}
|
||||
}
|
||||
|
||||
async renderSingleFrame() {
|
||||
//console.timeEnd("Single Frame Time");
|
||||
//console.time("Single Frame Time");
|
||||
this.viewport.containerMayReset();
|
||||
|
||||
if (this.viewport.container) {
|
||||
if (
|
||||
this.viewport.container.clientWidth + this.viewport.container.clientHeight !==
|
||||
this.viewport.lastCanvasSize
|
||||
) {
|
||||
this.viewport.onWindowResize();
|
||||
this.viewport.lastCanvasSize =
|
||||
this.viewport.container.clientWidth + this.viewport.container.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clock == undefined || this.renderer == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta_time = this.clock.getDelta();
|
||||
|
||||
this.cameraController.tickPerFrame(delta_time);
|
||||
|
||||
this.activeScene.shader_objects.forEach((shader) => {
|
||||
shader.material.uniforms["time"].value += 0.5 * delta_time;
|
||||
});
|
||||
|
||||
if (this.utils.getSelectedSum() !== this.selection.last_selected_sum) {
|
||||
this.selection.updateSelectedUI();
|
||||
}
|
||||
this.selection.last_selected_sum = this.utils.getSelectedSum();
|
||||
|
||||
await this.renderScene();
|
||||
|
||||
this.stats.update();
|
||||
}
|
||||
|
||||
// Basicly Unity 3D's update loop.
|
||||
updateLoop() {
|
||||
if (!this.shouldRender) {
|
||||
console.debug("Stopping 3D render loop");
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance improvement: Handle frame cap
|
||||
// Request the next render already - this is necessary so the loop doesn't stop if the fps cap is hit
|
||||
this.renderEventToken = requestAnimationFrame(this.updateLoop.bind(this));
|
||||
const frameTime = performance.now();
|
||||
if (frameTime - this.lastFrameTime < 1000 / this.cap_fps) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastFrameTime = frameTime;
|
||||
this.renderSingleFrame();
|
||||
}
|
||||
|
||||
startRenderLoop() {
|
||||
if (this.shouldRender) {
|
||||
console.warn("Render flag is already true");
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldRender = true;
|
||||
this.updateLoop();
|
||||
}
|
||||
|
||||
stopRenderLoop() {
|
||||
this.shouldRender = false;
|
||||
|
||||
if (this.renderEventToken) {
|
||||
cancelAnimationFrame(this.renderEventToken);
|
||||
this.renderEventToken = -1;
|
||||
}
|
||||
}
|
||||
|
||||
remountEngine() {
|
||||
const store = usePageSceneStore.getState();
|
||||
if (!store.is3DEditorInitialized) {
|
||||
console.log("3D mount: Wait for initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMounted) {
|
||||
console.log("3D already mounted, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store.is3DPageMounted) {
|
||||
console.log("3D mount: Wait for DOM mount");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMounted = true;
|
||||
this.startRenderLoop();
|
||||
console.log("3D Editor Engine remounted");
|
||||
}
|
||||
|
||||
unmountEngine() {
|
||||
this.bus.emit(new SceneLoadedEvent(false));
|
||||
this.stopRenderLoop();
|
||||
|
||||
// Fix: dispose 3D contexts
|
||||
this.renderer?.dispose();
|
||||
this.postProcessing.dispose();
|
||||
this.rawRenderer?.dispose();
|
||||
|
||||
this.isMounted = false;
|
||||
this.bus.emit(new EngineInitializedEvent(false));
|
||||
this.gridSubscription();
|
||||
this.storeBridge.dispose();
|
||||
console.log("3D Editor Engine unmounted");
|
||||
}
|
||||
}
|
||||
|
||||
export default Editor;
|
||||
// Re-export shim. The Editor class lives in @storyteller/ui-pagescene.
|
||||
// External callers that imported the default Editor export from this
|
||||
// path keep working unchanged; the construction site (EngineProvider)
|
||||
// supplies the PageSceneAdapter the lib's Editor now requires.
|
||||
export { Editor as default } from "@storyteller/ui-pagescene";
|
||||
export type { EditorInitializeConfig } from "@storyteller/ui-pagescene";
|
||||
@@ -1,251 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import { PointerLockControls } from "three/addons/controls/PointerLockControls.js";
|
||||
import {
|
||||
freeCamFrameTick,
|
||||
lookAtFromCamera,
|
||||
type FreeCamControlState,
|
||||
} from "../cameraMath";
|
||||
import { CameraAspectRatio, EditorStates } from "~/pages/PageScene/enums";
|
||||
import type { Camera } from "@storyteller/common";
|
||||
import type { EngineEventBus } from "../events/EngineEventBus";
|
||||
import { CameraUpdatedEvent } from "../events/EngineEvent";
|
||||
|
||||
export type RenderDimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
};
|
||||
|
||||
// Capabilities CameraController needs from outside its own state.
|
||||
// Editor wires these in initialize() so CameraController never imports
|
||||
// sibling subsystems directly.
|
||||
export type CameraControllerDeps = {
|
||||
getThreeScene: () => THREE.Scene;
|
||||
getHotItems: () => THREE.Object3D[] | null;
|
||||
removeTransformControls: () => void;
|
||||
setSelected: (obj: THREE.Object3D | null) => void;
|
||||
setEditorState: (state: EditorStates) => void;
|
||||
hideObjectPanel: () => void;
|
||||
// Camera-list reads from the Zustand store happen at the engine
|
||||
// boundary; CameraController stays store-agnostic.
|
||||
getCameras: () => Camera[];
|
||||
getSelectedCameraId: () => string;
|
||||
// Typed event bus — every engine→store write goes through here.
|
||||
bus: EngineEventBus;
|
||||
};
|
||||
|
||||
// Owns the editor's camera-related state: the live PerspectiveCameras,
|
||||
// the cam_obj proxy in the scene, FreeCam control state, lockControls,
|
||||
// and the per-frame camera tick that ran inline in renderSingleFrame.
|
||||
//
|
||||
// Construction is empty; cameras and lockControls are populated by
|
||||
// Editor.initialize() once the renderer + canvas exist.
|
||||
export class CameraController {
|
||||
// The reserved name used to find the camera entity in the THREE scene.
|
||||
readonly camera_name: string = "::CAM::";
|
||||
|
||||
camera: THREE.PerspectiveCamera | null = null;
|
||||
render_camera: THREE.PerspectiveCamera | null = null;
|
||||
render_camera_aspect_ratio: CameraAspectRatio = CameraAspectRatio.HORIZONTAL_3_2;
|
||||
|
||||
cam_obj: THREE.Object3D | undefined;
|
||||
camera_person_mode: boolean = false;
|
||||
|
||||
lockControls: PointerLockControls | undefined;
|
||||
|
||||
freeCamState: FreeCamControlState | null = null;
|
||||
|
||||
last_cam_pos: THREE.Vector3 = new THREE.Vector3(0, 0, 0);
|
||||
last_cam_rot: THREE.Euler = new THREE.Euler(0, 0, 0);
|
||||
camera_last_pos: THREE.Vector3 = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
render_width: number;
|
||||
render_height: number;
|
||||
|
||||
private deps: CameraControllerDeps;
|
||||
|
||||
constructor(deps: CameraControllerDeps) {
|
||||
this.deps = deps;
|
||||
const dims = this.getRenderDimensions();
|
||||
this.render_width = dims.width;
|
||||
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.
|
||||
switchCameraView() {
|
||||
this.camera_person_mode = !this.camera_person_mode;
|
||||
if (this.freeCamState) {
|
||||
this.freeCamState.velocity.set(0, 0, 0);
|
||||
}
|
||||
if (!this.cam_obj) return;
|
||||
|
||||
if (this.camera_person_mode && this.camera) {
|
||||
this.last_cam_pos.copy(this.camera.position);
|
||||
this.last_cam_rot.copy(this.camera.rotation);
|
||||
|
||||
this.camera.position.copy(this.cam_obj.position);
|
||||
this.camera.rotation.copy(this.cam_obj.rotation);
|
||||
|
||||
if (this.lockControls) {
|
||||
this.deps.getThreeScene().add(this.lockControls.getObject());
|
||||
}
|
||||
// useFreeCam reads editorState from the store and enables
|
||||
// itself when CAMERA_VIEW; nothing to do here.
|
||||
this.cam_obj.scale.set(0, 0, 0);
|
||||
|
||||
this.deps.removeTransformControls();
|
||||
this.deps.setSelected(this.cam_obj);
|
||||
this.deps.setEditorState(EditorStates.CAMERA_VIEW);
|
||||
|
||||
const hot = this.deps.getHotItems();
|
||||
if (hot) {
|
||||
hot.forEach((element) => {
|
||||
element.visible = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Workaround: in camera mode, a right-click should pan rather
|
||||
// than open the browser context menu. Defer the listener attach
|
||||
// until after the letterbox swap settles.
|
||||
setTimeout(
|
||||
() =>
|
||||
document
|
||||
.getElementById("letterbox")
|
||||
?.addEventListener("contextmenu", function (event) {
|
||||
event.preventDefault();
|
||||
}),
|
||||
250,
|
||||
);
|
||||
} else if (this.camera) {
|
||||
this.camera.position.copy(this.last_cam_pos);
|
||||
this.camera.rotation.copy(this.last_cam_rot);
|
||||
if (this.lockControls) {
|
||||
this.deps.getThreeScene().remove(this.lockControls.getObject());
|
||||
}
|
||||
this.cam_obj.scale.set(1, 1, 1);
|
||||
|
||||
const hot = this.deps.getHotItems();
|
||||
if (hot) {
|
||||
hot.forEach((element) => {
|
||||
element.visible = true;
|
||||
});
|
||||
}
|
||||
|
||||
this.deps.hideObjectPanel();
|
||||
this.deps.setEditorState(EditorStates.EDIT);
|
||||
}
|
||||
}
|
||||
|
||||
setFreeCamState(state: FreeCamControlState | null) {
|
||||
this.freeCamState = state;
|
||||
}
|
||||
|
||||
getCameraPersonMode(): boolean {
|
||||
return this.camera_person_mode;
|
||||
}
|
||||
|
||||
// FOV from focal length using a 24mm sensor height by default.
|
||||
focalLengthToFov(focalLength: number, sensorHeight: number = 24): number {
|
||||
return 2 * Math.atan(sensorHeight / (2 * focalLength)) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
getRenderDimensions(): RenderDimensions {
|
||||
switch (this.render_camera_aspect_ratio) {
|
||||
case CameraAspectRatio.HORIZONTAL_16_9:
|
||||
return { width: 1280, height: 720, aspectRatio: 16 / 9 };
|
||||
case CameraAspectRatio.HORIZONTAL_3_2:
|
||||
return { width: 1200, height: 800, aspectRatio: 3 / 2 };
|
||||
case CameraAspectRatio.VERTICAL_2_3:
|
||||
return { width: 800, height: 1200, aspectRatio: 2 / 3 };
|
||||
case CameraAspectRatio.VERTICAL_9_16:
|
||||
return { width: 720, height: 1280, aspectRatio: 9 / 16 };
|
||||
case CameraAspectRatio.SQUARE_1_1:
|
||||
default:
|
||||
return { width: 1080, height: 1080, aspectRatio: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
changeRenderCameraAspectRatio(newAspectRatio: CameraAspectRatio) {
|
||||
this.render_camera_aspect_ratio = newAspectRatio;
|
||||
const { width, height, aspectRatio } = this.getRenderDimensions();
|
||||
this.render_width = width;
|
||||
this.render_height = height;
|
||||
if (this.render_camera) {
|
||||
this.render_camera.aspect = aspectRatio;
|
||||
this.render_camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
// Camera-side of the per-frame render loop. Pulls FOV from the store's
|
||||
// selected camera entry, integrates FreeCam input back into the live
|
||||
// camera, mirrors the active camera's transform to cam_obj (or vice
|
||||
// versa in camera-person mode), and syncs render_camera to cam_obj.
|
||||
tickPerFrame(deltaSeconds: number) {
|
||||
const selectedCameraId = this.deps.getSelectedCameraId();
|
||||
|
||||
if (selectedCameraId && this.camera) {
|
||||
const cameras = this.deps.getCameras();
|
||||
const camData = cameras.find((c) => c.id === selectedCameraId);
|
||||
if (camData) {
|
||||
const fov = this.focalLengthToFov(camData.focalLength);
|
||||
if (this.camera.fov !== fov) {
|
||||
this.camera.fov = fov;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.freeCamState && this.camera) {
|
||||
const moved = freeCamFrameTick(
|
||||
this.camera,
|
||||
this.freeCamState,
|
||||
5 * deltaSeconds,
|
||||
);
|
||||
// Mirror the active camera's transform back into the store so
|
||||
// PromptBox3D and the camera-list UI stay in sync.
|
||||
if (moved && selectedCameraId) {
|
||||
const lookAt = lookAtFromCamera(this.camera);
|
||||
const pos = this.camera.position;
|
||||
const rot = this.camera.rotation;
|
||||
this.deps.bus.emit(
|
||||
new CameraUpdatedEvent(selectedCameraId, {
|
||||
position: { x: pos.x, y: pos.y, z: pos.z },
|
||||
rotation: { x: rot.x, y: rot.y, z: rot.z },
|
||||
lookAt: { x: lookAt.x, y: lookAt.y, z: lookAt.z },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.camera_person_mode) {
|
||||
if (this.cam_obj && this.camera) {
|
||||
// Without a timeline scrubber, edits in camera-person mode write
|
||||
// back into cam_obj rather than copying out of it.
|
||||
this.cam_obj.position.copy(this.camera.position);
|
||||
this.cam_obj.rotation.copy(this.camera.rotation);
|
||||
this.cam_obj.visible = false;
|
||||
}
|
||||
} else if (this.cam_obj) {
|
||||
this.cam_obj.visible = true;
|
||||
}
|
||||
|
||||
if (this.render_camera && this.cam_obj) {
|
||||
this.render_camera.position.copy(this.cam_obj.position);
|
||||
this.render_camera.rotation.copy(this.cam_obj.rotation);
|
||||
this.cam_obj.scale.copy(new THREE.Vector3(1, 1, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
export { CameraController } from "@storyteller/ui-pagescene";
|
||||
export type {
|
||||
CameraControllerDeps,
|
||||
RenderDimensions,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
@@ -1,118 +1,5 @@
|
||||
// Owns the TransformControls gizmo used for translating/rotating/scaling
|
||||
// the selected object. Construction is two-phase to match the existing
|
||||
// editor lifecycle: the controller is created up front (held as a field
|
||||
// on Editor), then `configure()` wires it to the camera/renderer once
|
||||
// initialize() has produced them.
|
||||
//
|
||||
// Store access for the transform-space pref is injected via Deps so the
|
||||
// controller doesn't import the Zustand store directly.
|
||||
|
||||
import type * as THREE from "three";
|
||||
import { TransformControls } from "../TransformControls.js";
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
export type TransformSpace = "world" | "local";
|
||||
|
||||
export type GizmoCallbacks = {
|
||||
// Fires on every internal TransformControls "change" event — Editor
|
||||
// uses this to re-render the scene during a drag.
|
||||
onChange: () => void;
|
||||
// Fires when a drag starts or ends. `dragging` is true on drag start.
|
||||
onDraggingChanged: (dragging: boolean) => void;
|
||||
};
|
||||
|
||||
// Capabilities the gizmo needs from outside its own state.
|
||||
// Editor wires these in initialize() so GizmoController never imports
|
||||
// the store directly.
|
||||
export type GizmoControllerDeps = {
|
||||
getTransformSpace: () => TransformSpace;
|
||||
setTransformSpace: (space: TransformSpace) => void;
|
||||
};
|
||||
|
||||
export class GizmoController {
|
||||
control: TransformControls | undefined;
|
||||
|
||||
private deps: GizmoControllerDeps;
|
||||
|
||||
constructor(deps: GizmoControllerDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
configure(
|
||||
camera: THREE.PerspectiveCamera | null,
|
||||
domElement: HTMLElement,
|
||||
scene: THREE.Scene,
|
||||
callbacks: GizmoCallbacks,
|
||||
) {
|
||||
if (camera == undefined) return;
|
||||
this.control = new TransformControls(camera, domElement);
|
||||
this.control.space = "world";
|
||||
this.control.setScaleSnap(0.01);
|
||||
this.control.setTranslationSnap(0.01);
|
||||
this.control.setRotationSnap(0.01);
|
||||
this.control.setSize(0.5);
|
||||
this.control.addEventListener("change", callbacks.onChange);
|
||||
this.control.addEventListener("dragging-changed", (event: any) => {
|
||||
callbacks.onDraggingChanged(event.value);
|
||||
});
|
||||
scene.add(this.control);
|
||||
}
|
||||
|
||||
// Apply a transform mode. Reads the desired space from the store so
|
||||
// callers don't need to plumb it through. Scale is always local.
|
||||
changeMode(type: TransformMode) {
|
||||
if (this.control == undefined) return;
|
||||
const space = type === "scale" ? "local" : this.deps.getTransformSpace();
|
||||
this.control.mode = type;
|
||||
this.control.space = space;
|
||||
}
|
||||
|
||||
setSpace(space: TransformSpace) {
|
||||
if (this.control == undefined) return;
|
||||
this.control.space = space;
|
||||
}
|
||||
|
||||
// Flip the store's transform-space preference and push the new value
|
||||
// into the live gizmo. No-op when the gizmo is in scale mode (scale
|
||||
// is always local).
|
||||
toggleTransformSpace() {
|
||||
if (!this.canToggleSpace()) return;
|
||||
const next =
|
||||
this.deps.getTransformSpace() === "world" ? "local" : "world";
|
||||
this.deps.setTransformSpace(next);
|
||||
this.setSpace(next);
|
||||
}
|
||||
|
||||
// True when the gizmo exists and is in a mode where toggling between
|
||||
// world/local space is meaningful (scale is always local).
|
||||
canToggleSpace(): boolean {
|
||||
return this.control != undefined && this.control.mode !== "scale";
|
||||
}
|
||||
|
||||
attach(object: THREE.Object3D) {
|
||||
this.control?.attach(object);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.control?.detach();
|
||||
}
|
||||
|
||||
// Add/remove the gizmo from the scene. Used by SceneUtils when
|
||||
// locking/unlocking objects: the gizmo is yanked out of the scene on
|
||||
// lock and re-added on unlock.
|
||||
addToScene(scene: THREE.Scene) {
|
||||
if (this.control) scene.add(this.control);
|
||||
}
|
||||
|
||||
removeFromScene(scene: THREE.Scene) {
|
||||
if (this.control) scene.remove(this.control);
|
||||
}
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
if (this.control) this.control.visible = visible;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.control?.visible ?? false;
|
||||
}
|
||||
}
|
||||
export { GizmoController } from "@storyteller/ui-pagescene";
|
||||
export type {
|
||||
GizmoControllerDeps,
|
||||
GizmoCallbacks,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
@@ -1,105 +1,2 @@
|
||||
// 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() });
|
||||
}
|
||||
}
|
||||
export { HistoryManager } from "@storyteller/ui-pagescene";
|
||||
export type { UndoableAction, HistoryManagerOptions } from "@storyteller/ui-pagescene";
|
||||
+1
-118
@@ -1,118 +1 @@
|
||||
// Owns both EffectComposer chains (main + raw) and the post-process
|
||||
// passes the editor actually uses today: OutputPass + RenderPass on each
|
||||
// composer, OutlinePass on the main one (created for selection
|
||||
// highlighting in MouseControls and SceneUtils, not currently added to
|
||||
// the composer), and CustomOutlinePass + FindSurfaces on the raw one.
|
||||
//
|
||||
// Deliberately small surface — no `editor` reference, no circular import.
|
||||
// Editor passes the renderer / scene / camera / dimensions explicitly to
|
||||
// configureMain / configureRaw, and the Scene callback dispatches into
|
||||
// updateSurfaceIdAttributeToMesh with the scene as an argument. The
|
||||
// `?.setSize` calls inside resize() are load-bearing: configureMain and
|
||||
// configureRaw run at different points in Editor.initialize, and resize
|
||||
// can fire between them via the first viewport.onWindowResize() call.
|
||||
|
||||
import * as THREE from "three";
|
||||
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
|
||||
import { OutlinePass } from "three/addons/postprocessing/OutlinePass.js";
|
||||
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
|
||||
import { CustomOutlinePass } from "../CustomOutlinePass.js";
|
||||
import FindSurfaces from "../FindSurfaces.js";
|
||||
|
||||
export class PostProcessingPipeline {
|
||||
composer: EffectComposer | undefined;
|
||||
render_composer: EffectComposer | undefined;
|
||||
outlinePass: OutlinePass | undefined;
|
||||
customOutlinerPass: CustomOutlinePass | undefined;
|
||||
private surfaceFinder: FindSurfaces | undefined;
|
||||
|
||||
configureMain(
|
||||
renderer: THREE.WebGLRenderer | undefined,
|
||||
scene: THREE.Scene,
|
||||
camera: THREE.PerspectiveCamera | null,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
if (renderer == undefined || camera == undefined) return;
|
||||
this.composer = new EffectComposer(renderer);
|
||||
this.composer.addPass(new RenderPass(scene, camera));
|
||||
|
||||
// Created for MouseControls / SceneUtils.removeTransformControls to
|
||||
// mutate `selectedObjects`. Not currently added to the composer (the
|
||||
// historical addPass call is commented out in the original code).
|
||||
this.outlinePass = new OutlinePass(
|
||||
new THREE.Vector2(width / 10, height / 10),
|
||||
scene,
|
||||
camera,
|
||||
);
|
||||
|
||||
this.composer.addPass(new OutputPass());
|
||||
}
|
||||
|
||||
configureRaw(
|
||||
rawRenderer: THREE.WebGLRenderer | undefined,
|
||||
scene: THREE.Scene,
|
||||
renderCamera: THREE.PerspectiveCamera | null,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
if (rawRenderer == undefined || renderCamera == undefined) return;
|
||||
const depthTexture = new THREE.DepthTexture(width, height);
|
||||
depthTexture.type = THREE.UnsignedShortType;
|
||||
|
||||
const renderTarget = new THREE.WebGLRenderTarget(
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
{ depthTexture, depthBuffer: true },
|
||||
);
|
||||
|
||||
this.customOutlinerPass = new CustomOutlinePass(
|
||||
new THREE.Vector2(width, height),
|
||||
scene,
|
||||
renderCamera,
|
||||
);
|
||||
this.surfaceFinder = new FindSurfaces();
|
||||
this.render_composer = new EffectComposer(rawRenderer, renderTarget);
|
||||
this.render_composer.addPass(new RenderPass(scene, renderCamera));
|
||||
this.render_composer.addPass(this.customOutlinerPass);
|
||||
this.render_composer.addPass(new OutputPass());
|
||||
|
||||
// Initial visualization mode: rendered colour. The depth/normal/
|
||||
// outline-only modes that the original code carried as `setRenderDepth`
|
||||
// / `setNormalMap` / `setOutlineRender` had no callers and were
|
||||
// dropped during this extraction.
|
||||
this.refreshSurfaceIds();
|
||||
this.customOutlinerPass.fsQuad.material.uniforms.debugVisualize.value = 2;
|
||||
}
|
||||
|
||||
// Resize both composers and the custom outline pass — called from
|
||||
// ViewportController via the resizePostProcessing callback. The
|
||||
// optional chaining handles configureMain having run while configureRaw
|
||||
// hasn't yet (the lifecycle gap during initialize).
|
||||
resize(width: number, height: number) {
|
||||
this.composer?.setSize(width, height);
|
||||
this.render_composer?.setSize(width, height);
|
||||
this.customOutlinerPass?.setSize(width, height);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.composer?.dispose();
|
||||
this.render_composer?.dispose();
|
||||
}
|
||||
|
||||
// Bound through Editor's Scene callback; called on each asset load.
|
||||
// The `_scene` arg is part of the original callback contract but unused
|
||||
// here — the actual surface-id traversal lives in CustomOutlinePass /
|
||||
// FindSurfaces; this method just resets the counter and bumps the
|
||||
// shader uniform so the next pass starts clean.
|
||||
updateSurfaceIdAttributeToMesh(_scene: THREE.Scene) {
|
||||
this.refreshSurfaceIds();
|
||||
}
|
||||
|
||||
private refreshSurfaceIds() {
|
||||
if (this.surfaceFinder === undefined) return;
|
||||
this.surfaceFinder.surfaceId = 0;
|
||||
this.customOutlinerPass?.updateMaxSurfaceId(this.surfaceFinder.surfaceId + 1);
|
||||
}
|
||||
}
|
||||
export { PostProcessingPipeline } from "@storyteller/ui-pagescene";
|
||||
@@ -1,199 +1,2 @@
|
||||
// Owns the selection-and-outliner sync surface between the Three.js
|
||||
// SceneManager and the Zustand store: which object is selected, the
|
||||
// inspector panel ("Object Panel") values for that object, and the
|
||||
// outliner row list. Centralizes the engine→store writes that were
|
||||
// previously sprinkled across editor.ts.
|
||||
//
|
||||
// SceneManager is forwarded via a closure-based getter (it's constructed
|
||||
// later in Editor.initialize); cameraName + version are stable values
|
||||
// passed at construction time. No `editor` reference, no circular import.
|
||||
|
||||
import * as THREE from "three";
|
||||
import { AssetType, ClipGroup } from "~/enums";
|
||||
import type { SceneManager } from "../scene_manager_api";
|
||||
import type { EngineEventBus } from "../events/EngineEventBus";
|
||||
import {
|
||||
InspectorPanelChangedEvent,
|
||||
OutlinerRefreshedEvent,
|
||||
SelectionChangedEvent,
|
||||
} from "../events/EngineEvent";
|
||||
|
||||
export type SelectionBridgeDeps = {
|
||||
// Lazy: SceneManager is created in Editor.initialize, after the bridge.
|
||||
getSceneManager: () => SceneManager | undefined;
|
||||
// The reserved camera-entity name ("::CAM::") used to distinguish
|
||||
// ClipGroup.CAMERA / AssetType.CAMERA from regular meshes.
|
||||
cameraName: string;
|
||||
// Editor's data version, written into the object panel.
|
||||
version: number;
|
||||
|
||||
// Lock/unlock plumbing — the userData mutation lives in SceneUtils,
|
||||
// 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;
|
||||
|
||||
// Typed event bus — every engine→store write goes through here.
|
||||
bus: EngineEventBus;
|
||||
// Character roster — read from the store at the boundary; the bridge
|
||||
// stays store-agnostic.
|
||||
getCharactersByUuid: () => { [uuid: string]: ClipGroup };
|
||||
isCharacterUuid: (uuid: string) => boolean;
|
||||
};
|
||||
|
||||
export class SelectionBridge {
|
||||
// The currently inspected object. Read by ControlPanelSceneObject via
|
||||
// a forwarding getter on Editor (`editor.selected`).
|
||||
selected: THREE.Object3D | undefined;
|
||||
|
||||
// Frame-to-frame "did the selection's transform change" check —
|
||||
// compared in renderSingleFrame against utils.getSelectedSum().
|
||||
last_selected_sum: number = 0;
|
||||
|
||||
constructor(private readonly engine: SelectionBridgeDeps) {}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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.
|
||||
isObjectLocked(object_uuid: string): boolean {
|
||||
return this.engine.isObjectLocked(object_uuid);
|
||||
}
|
||||
|
||||
setSelected(object: THREE.Object3D[] | undefined) {
|
||||
const sceneManager = this.engine.getSceneManager();
|
||||
if (sceneManager) sceneManager.selected_objects = object;
|
||||
}
|
||||
|
||||
// Push the current selection (or null) into the store. Drives the
|
||||
// global "what's selected?" state used by toolbars, panels, etc.
|
||||
publishSelect() {
|
||||
const target = this.engine.getSceneManager()?.selected_objects?.[0];
|
||||
if (target) {
|
||||
this.engine.bus.emit(
|
||||
new SelectionChangedEvent({
|
||||
type: this.getAssetType(target),
|
||||
id: target.uuid,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.engine.bus.emit(new SelectionChangedEvent(null));
|
||||
}
|
||||
}
|
||||
|
||||
// Push the selected object's transform into the inspector panel.
|
||||
// No-op if nothing is selected.
|
||||
updateSelectedUI() {
|
||||
const selected_objects = this.engine.getSceneManager()?.selected_objects;
|
||||
if (selected_objects === undefined || selected_objects.length === 0) return;
|
||||
const mainSelected = selected_objects[0];
|
||||
this.selected = mainSelected;
|
||||
|
||||
const pos = mainSelected.position;
|
||||
const rot = mainSelected.rotation;
|
||||
const scale = mainSelected.scale;
|
||||
|
||||
this.engine.bus.emit(
|
||||
new InspectorPanelChangedEvent({
|
||||
// TODO: add metadata to determine whether this is a camera, an
|
||||
// object, or a character into prefab clips.
|
||||
group:
|
||||
mainSelected.name === this.engine.cameraName
|
||||
? ClipGroup.CAMERA
|
||||
: ClipGroup.OBJECT,
|
||||
object_uuid: mainSelected.uuid,
|
||||
object_name: mainSelected.name,
|
||||
version: String(this.engine.version),
|
||||
objectVectors: {
|
||||
position: {
|
||||
x: parseFloat(pos.x.toFixed(2)),
|
||||
y: parseFloat(pos.y.toFixed(2)),
|
||||
z: parseFloat(pos.z.toFixed(2)),
|
||||
},
|
||||
rotation: {
|
||||
x: parseFloat(THREE.MathUtils.radToDeg(rot.x).toFixed(2)),
|
||||
y: parseFloat(THREE.MathUtils.radToDeg(rot.y).toFixed(2)),
|
||||
z: parseFloat(THREE.MathUtils.radToDeg(rot.z).toFixed(2)),
|
||||
},
|
||||
scale: {
|
||||
x: parseFloat(scale.x.toFixed(6)),
|
||||
y: parseFloat(scale.y.toFixed(6)),
|
||||
z: parseFloat(scale.z.toFixed(6)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute outliner rows and push them into the store. Replaces the
|
||||
// four near-identical copies of this snippet that used to live in
|
||||
// Editor.initialize, newScene, loadScene, and deleteObject.
|
||||
refreshOutliner() {
|
||||
const result = this.engine
|
||||
.getSceneManager()
|
||||
?.render_outliner(this.engine.getCharactersByUuid());
|
||||
if (result) this.engine.bus.emit(new OutlinerRefreshedEvent(result.items));
|
||||
}
|
||||
|
||||
// Refresh outliner and inspector together — used after operations
|
||||
// that may have changed both (e.g. an asset finishing loading).
|
||||
updateOutliner() {
|
||||
this.refreshOutliner();
|
||||
this.updateSelectedUI();
|
||||
}
|
||||
|
||||
getAssetType(selected: THREE.Object3D): AssetType {
|
||||
if (selected.type === "Mesh") {
|
||||
return selected.name === this.engine.cameraName
|
||||
? AssetType.CAMERA
|
||||
: AssetType.OBJECT;
|
||||
}
|
||||
return AssetType.CHARACTER;
|
||||
}
|
||||
|
||||
// Replaces the deleted Timeline.characters (a Record<uuid, ClipGroup>) —
|
||||
// used by SceneManager.render_outliner to know which scene objects to
|
||||
// render as characters. The store is read at the boundary (Editor's
|
||||
// SelectionBridgeDeps wiring) — the bridge stays store-agnostic.
|
||||
getCharactersByUuid(): { [uuid: string]: ClipGroup } {
|
||||
return this.engine.getCharactersByUuid();
|
||||
}
|
||||
|
||||
// Replaces the deleted Timeline.isCharacter — checks the Zustand
|
||||
// store's character list (read via deps callback), which is the
|
||||
// source of truth for which scene objects are characters.
|
||||
isCharacterUuid(uuid: string): boolean {
|
||||
return this.engine.isCharacterUuid(uuid);
|
||||
}
|
||||
}
|
||||
export { SelectionBridge } from "@storyteller/ui-pagescene";
|
||||
export type { SelectionBridgeDeps } from "@storyteller/ui-pagescene";
|
||||
@@ -1,90 +1,2 @@
|
||||
// Owns the canvas/container DOM refs, the ResizeObserver, and the resize
|
||||
// cascade that keeps the renderer, post-processing composers, and camera
|
||||
// projection matrices in lockstep with the visible viewport.
|
||||
//
|
||||
// Cameras and renderer are forwarded via closure-based getters that
|
||||
// resolve at call time (so the viewport is robust to construction-order
|
||||
// changes in Editor.initialize). Post-processing is reached through a
|
||||
// single `resizePostProcessing(w, h)` callback rather than three
|
||||
// separate composer getters — that keeps the viewport from peeking at
|
||||
// composer fields that may not exist yet at the first resize, and lets
|
||||
// the post-processing module own its own undefined-pass handling.
|
||||
//
|
||||
// No `import type Editor`, no circular import.
|
||||
|
||||
import type * as THREE from "three";
|
||||
|
||||
export type ViewportEngineRefs = {
|
||||
getCamera: () => THREE.PerspectiveCamera | null;
|
||||
getRenderCamera: () => THREE.PerspectiveCamera | null;
|
||||
getRenderer: () => THREE.WebGLRenderer | undefined;
|
||||
getRenderAspectRatio: () => number;
|
||||
resizePostProcessing: (width: number, height: number) => void;
|
||||
};
|
||||
|
||||
export class ViewportController {
|
||||
container: HTMLElement | null = null;
|
||||
canvReference: HTMLCanvasElement | null = null;
|
||||
canvasRenderCamReference: HTMLCanvasElement | null = null;
|
||||
lastCanvasSize: number = 0;
|
||||
private observer: ResizeObserver | undefined;
|
||||
|
||||
constructor(private readonly engine: ViewportEngineRefs) {}
|
||||
|
||||
containerMayReset() {
|
||||
if (!this.container) {
|
||||
console.warn(
|
||||
"ViewportController - Container does not exist, querying from DOM via document.getElementById",
|
||||
);
|
||||
this.container = document.getElementById("video-scene-container");
|
||||
}
|
||||
}
|
||||
|
||||
// Full resize cascade — cameras + renderer + post-processing.
|
||||
// Called from initialize() and the per-frame check in renderSingleFrame.
|
||||
onWindowResize() {
|
||||
this.containerMayReset();
|
||||
if (!this.container) return;
|
||||
|
||||
const width = this.container.clientWidth;
|
||||
const height = this.container.clientHeight;
|
||||
const camera = this.engine.getCamera();
|
||||
const renderer = this.engine.getRenderer();
|
||||
if (camera == undefined || renderer == undefined) return;
|
||||
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
this.engine.resizePostProcessing(width, height);
|
||||
|
||||
const renderCamera = this.engine.getRenderCamera();
|
||||
if (renderCamera == undefined) return;
|
||||
|
||||
renderCamera.aspect = this.engine.getRenderAspectRatio();
|
||||
renderCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Observed resizes from the ResizeObserver use a narrower update —
|
||||
// camera + renderer.setSize + setPixelRatio only — preserved from the
|
||||
// original editor.ts behaviour. Post-processing is intentionally
|
||||
// skipped here, matching the pre-refactor code.
|
||||
setupResizeObserver() {
|
||||
this.containerMayReset();
|
||||
if (!this.container) return;
|
||||
|
||||
this.observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
const camera = this.engine.getCamera();
|
||||
if (camera) {
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
const renderer = this.engine.getRenderer();
|
||||
renderer?.setSize(width, height);
|
||||
renderer?.setPixelRatio(window.devicePixelRatio);
|
||||
}
|
||||
});
|
||||
this.observer.observe(this.container);
|
||||
}
|
||||
}
|
||||
export { ViewportController } from "@storyteller/ui-pagescene";
|
||||
export type { ViewportEngineRefs } from "@storyteller/ui-pagescene";
|
||||
+1
-33
@@ -1,33 +1 @@
|
||||
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.
|
||||
//
|
||||
// Each replay also fires SelectionBridge.updateSelectedUI() so the
|
||||
// Object Panel re-reads userData.color and refreshes its preview.
|
||||
// Without this, undo correctly reverts the THREE.js material but
|
||||
// ControlPanelSceneObject's local React state stays at the redo
|
||||
// value (the per-frame getSelectedSum check that picks up transform
|
||||
// changes doesn't notice color changes).
|
||||
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);
|
||||
this.editor.selection.updateSelectedUI();
|
||||
}
|
||||
|
||||
revert(): void {
|
||||
this.editor.activeScene.setColor(this.uuid, this.before);
|
||||
this.editor.selection.updateSelectedUI();
|
||||
}
|
||||
}
|
||||
export { ColorAction } from "@storyteller/ui-pagescene";
|
||||
+1
-36
@@ -1,36 +1 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
export { CreateAction } from "@storyteller/ui-pagescene";
|
||||
+1
-35
@@ -1,35 +1 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
export { DeleteAction } from "@storyteller/ui-pagescene";
|
||||
@@ -1,29 +1 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
export { LockAction } from "@storyteller/ui-pagescene";
|
||||
+1
-58
@@ -1,58 +1 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
export { TransformAction } from "@storyteller/ui-pagescene";
|
||||
+1
-38
@@ -1,38 +1 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
export { VisibilityAction } from "@storyteller/ui-pagescene";
|
||||
@@ -1,190 +1,2 @@
|
||||
import * as THREE from "three";
|
||||
import type { OutlinePass } from "three/addons/postprocessing/OutlinePass.js";
|
||||
import type { TransformControls } from "./TransformControls.js";
|
||||
|
||||
import Scene from "./scene";
|
||||
import type { EngineEventBus } from "./events/EngineEventBus";
|
||||
import {
|
||||
InspectorPanelChangedEvent,
|
||||
ObjectRemovedEvent,
|
||||
} from "./events/EngineEvent";
|
||||
|
||||
// Capabilities SceneUtils needs from outside its own state. Editor
|
||||
// wires these in inline at construction (Phase 2 idiom — same shape
|
||||
// as CameraControllerDeps, SaveManagerDeps, etc.). SceneUtils does
|
||||
// NOT import Editor — every cross-subsystem reach is a callback or
|
||||
// getter on this object.
|
||||
export type SceneUtilsDeps = {
|
||||
// Gizmo lifecycle (used by removeTransformControls)
|
||||
getGizmoControl: () => TransformControls | undefined;
|
||||
detachGizmo: () => void;
|
||||
removeGizmoFromScene: () => void;
|
||||
// Outline pass (used by removeTransformControls)
|
||||
getOutlinePass: () => OutlinePass | undefined;
|
||||
// Selection sync (used by removeTransformControls + deleteObject)
|
||||
publishSelect: () => void;
|
||||
clearSelected: () => void;
|
||||
// Camera identity (used by deleteObject — protects ::CAM:: from delete)
|
||||
getCameraName: () => string;
|
||||
// Selected object lookup (used by getSelectedSum)
|
||||
getSelectedObject: () => THREE.Object3D | undefined;
|
||||
// Three scene root (used by deleteObject + removeTransformControls)
|
||||
getThreeScene: () => THREE.Scene;
|
||||
// Typed event bus — every engine→store write goes through here.
|
||||
bus: EngineEventBus;
|
||||
};
|
||||
|
||||
export class SceneUtils {
|
||||
scene: Scene;
|
||||
private deps: SceneUtilsDeps;
|
||||
|
||||
constructor(scene: Scene, deps: SceneUtilsDeps) {
|
||||
this.scene = scene;
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
// If string is empty.
|
||||
isEmpty(value: string): boolean {
|
||||
return (
|
||||
value == null || (typeof value === "string" && value.trim().length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Returns if the object is locked or unlocked.
|
||||
isObjectLocked(object_uuid: string): boolean {
|
||||
const object = this.scene.get_object_by_uuid(object_uuid);
|
||||
if (object) {
|
||||
if (object.userData["locked"] == undefined) {
|
||||
object.userData["locked"] = false;
|
||||
}
|
||||
return object.userData["locked"];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pure userData mutation: flips the locked flag and returns the new
|
||||
// value. Higher-level wiring (gizmo attach/detach, selection refresh)
|
||||
// lives on SelectionBridge.lockUnlockObject.
|
||||
toggleObjectLocked(object_uuid: string): boolean {
|
||||
const object = this.scene.get_object_by_uuid(object_uuid);
|
||||
if (!object) return false;
|
||||
if (object.userData["locked"] == undefined) {
|
||||
object.userData["locked"] = false;
|
||||
}
|
||||
object.userData["locked"] = !object.userData["locked"];
|
||||
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.deps.getGizmoControl() == undefined) {
|
||||
return;
|
||||
}
|
||||
const outlinePass = this.deps.getOutlinePass();
|
||||
if (outlinePass == undefined) {
|
||||
return;
|
||||
}
|
||||
if (remove_outline) {
|
||||
outlinePass.selectedObjects = [];
|
||||
this.deps.publishSelect();
|
||||
}
|
||||
this.deps.detachGizmo();
|
||||
this.deps.removeGizmoFromScene();
|
||||
if (remove_outline) outlinePass.selectedObjects = [];
|
||||
}
|
||||
|
||||
// Returns the "check sum" of the editor's selected object.
|
||||
getSelectedSum(): number {
|
||||
const selected = this.deps.getSelectedObject();
|
||||
if (selected === undefined) return 0;
|
||||
const posCombo =
|
||||
selected.position.x + selected.position.y + selected.position.z;
|
||||
const rotCombo =
|
||||
selected.rotation.x + selected.rotation.y + selected.rotation.z;
|
||||
const sclCombo = selected.scale.x + selected.scale.y + selected.scale.z;
|
||||
return posCombo + rotCombo + sclCombo;
|
||||
}
|
||||
|
||||
/* Will add in the future
|
||||
|
||||
A good practice to remove 3D objects from Three.js scenes
|
||||
function removeObject3D(object3D) {
|
||||
if (!(object3D instanceof THREE.Object3D)) return false;
|
||||
|
||||
// for better memory management and performance
|
||||
if (object3D.geometry) object3D.geometry.dispose();
|
||||
|
||||
if (object3D.material) {
|
||||
if (object3D.material instanceof Array) {
|
||||
// for better memory management and performance
|
||||
object3D.material.forEach(material => material.dispose());
|
||||
} else {
|
||||
// for better memory management and performance
|
||||
object3D.material.dispose();
|
||||
}
|
||||
}
|
||||
object3D.removeFromParent(); // the parent might be the scene or another Object3D, but it is sure to be removed this way
|
||||
return true;
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
deleteObject(uuid: string) {
|
||||
const obj = this.scene.get_object_by_uuid(uuid);
|
||||
|
||||
if (!obj) {
|
||||
return
|
||||
}
|
||||
|
||||
this.removeTransformControls();
|
||||
if (obj.name === this.deps.getCameraName()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally remove the object from the scene
|
||||
this.scene.scene.remove(obj);
|
||||
|
||||
obj.traverse(child => {
|
||||
(child as THREE.Mesh)?.geometry?.dispose()
|
||||
if (Array.isArray((child as THREE.Mesh).texture)) {
|
||||
(child as THREE.Mesh).texture.forEach(mat => mat.dispose());
|
||||
} else if ((child as THREE.Mesh).texture) {
|
||||
(child as THREE.Mesh).texture.dispose();
|
||||
}
|
||||
|
||||
if (Array.isArray((child as THREE.Mesh).material)) {
|
||||
(child as THREE.Mesh).material.forEach(mat => mat.dispose());
|
||||
} else if ((child as THREE.Mesh).material) {
|
||||
(child as THREE.Mesh).material.dispose();
|
||||
}
|
||||
})
|
||||
|
||||
if (Array.isArray((obj as THREE.Mesh).texture)) {
|
||||
(obj as THREE.Mesh).texture.forEach(mat => mat.dispose());
|
||||
} else if ((obj as THREE.Mesh).texture) {
|
||||
(obj as THREE.Mesh).texture.dispose();
|
||||
}
|
||||
|
||||
if (Array.isArray((obj as THREE.Mesh).material)) {
|
||||
(obj as THREE.Mesh).material.forEach(mat => mat.dispose());
|
||||
} else if ((obj as THREE.Mesh).material) {
|
||||
(obj as THREE.Mesh).material.dispose();
|
||||
}
|
||||
|
||||
if ((obj as THREE.Mesh).geometry) {
|
||||
(obj as THREE.Mesh).geometry.dispose()
|
||||
}
|
||||
|
||||
this.deps.bus.emit(new ObjectRemovedEvent(uuid));
|
||||
this.deps.clearSelected();
|
||||
this.deps.publishSelect();
|
||||
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
|
||||
}
|
||||
}
|
||||
export { SceneUtils } from "@storyteller/ui-pagescene";
|
||||
export type { SceneUtilsDeps } from "@storyteller/ui-pagescene";
|
||||
@@ -1,637 +1,2 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
OrbitControls,
|
||||
OutlinePass,
|
||||
PointerLockControls,
|
||||
} from "three/examples/jsm/Addons.js";
|
||||
import { TransformControls } from "./TransformControls";
|
||||
import { SceneManager, SceneObject } from "./scene_manager_api";
|
||||
import { isPointerLockSupported } from "./browserChecks";
|
||||
import type { FreeCamControlState } from "./cameraMath";
|
||||
import { FKHelper } from "./KinHelpers/FKHelper";
|
||||
import { Euler } from "three";
|
||||
import type { EngineEventBus } from "./events/EngineEventBus";
|
||||
import {
|
||||
AssetModalVisibilityChangedEvent,
|
||||
InspectorPanelChangedEvent,
|
||||
OutlinerSelectedItemChangedEvent,
|
||||
PoseControlsVisibilityChangedEvent,
|
||||
PoseModeChangedEvent,
|
||||
SelectedModeChangedEvent,
|
||||
TransformSpaceChangedEvent,
|
||||
} from "./events/EngineEvent";
|
||||
import type { PoseMode, TransformSpace } from "../PageSceneStore";
|
||||
|
||||
const EDITABLE_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel",
|
||||
]);
|
||||
|
||||
const isEventFromEditableElement = (event: KeyboardEvent): boolean => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
if (target.disabled || target.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const type = target.type?.toLowerCase() ?? "";
|
||||
return type === "" || EDITABLE_INPUT_TYPES.has(type);
|
||||
}
|
||||
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
return !(target.disabled || target.readOnly);
|
||||
}
|
||||
|
||||
return target.isContentEditable;
|
||||
};
|
||||
|
||||
export enum KinMode {
|
||||
FK,
|
||||
IK,
|
||||
NONE,
|
||||
}
|
||||
|
||||
// Capabilities MouseControls needs from outside its own state. Editor
|
||||
// wires these in inline at construction (Phase 2 idiom — same shape as
|
||||
// CameraControllerDeps, SaveManagerDeps, etc.). Replaces the prior
|
||||
// 20-positional-arg constructor; new fields go here, not in a 21st
|
||||
// positional slot.
|
||||
export type MouseControlsDeps = {
|
||||
camera: THREE.PerspectiveCamera | null;
|
||||
camera_person_mode: boolean;
|
||||
cameraViewControls: FreeCamControlState | null;
|
||||
lockControls: PointerLockControls | undefined;
|
||||
camera_last_pos: THREE.Vector3;
|
||||
deleteObject: Function;
|
||||
canvReference: HTMLCanvasElement | null;
|
||||
mouse: THREE.Vector2 | undefined;
|
||||
timeline_mouse: THREE.Vector2 | undefined;
|
||||
raycaster: THREE.Raycaster | undefined;
|
||||
control: TransformControls | undefined;
|
||||
outlinePass: OutlinePass | undefined;
|
||||
scene: THREE.Scene;
|
||||
publishSelect: Function;
|
||||
updateSelectedUI: Function;
|
||||
transform_interaction: boolean;
|
||||
last_selected: THREE.Object3D | undefined;
|
||||
getAssetType: Function;
|
||||
setSelected: Function;
|
||||
isMovable: Function;
|
||||
enable_stats: Function;
|
||||
|
||||
// Typed event bus — every engine→store write goes through here.
|
||||
bus: EngineEventBus;
|
||||
// Reads from the store happen at the boundary (Editor wires these up).
|
||||
// The class itself stays store-agnostic.
|
||||
getPoseMode: () => PoseMode;
|
||||
isHotkeyDisabled: () => boolean;
|
||||
getTransformSpace: () => TransformSpace;
|
||||
};
|
||||
|
||||
export class MouseControls {
|
||||
camera: THREE.PerspectiveCamera | null;
|
||||
camera_person_mode: boolean;
|
||||
lockControls: PointerLockControls | undefined;
|
||||
camera_last_pos: THREE.Vector3;
|
||||
selected: THREE.Object3D[] | undefined;
|
||||
orbitControls: OrbitControls | undefined;
|
||||
deleteObject: Function;
|
||||
canvReference: HTMLCanvasElement | null = null;
|
||||
mouse: THREE.Vector2 | undefined;
|
||||
timeline_mouse: THREE.Vector2 | undefined;
|
||||
control: TransformControls | undefined;
|
||||
raycaster: THREE.Raycaster | undefined;
|
||||
outlinePass: OutlinePass | undefined;
|
||||
scene: THREE.Scene;
|
||||
publishSelect: Function;
|
||||
updateSelectedUI: Function;
|
||||
transform_interaction: boolean;
|
||||
last_selected: THREE.Object3D[] | undefined;
|
||||
getAssetType: Function;
|
||||
setSelected: Function;
|
||||
sceneManager: SceneManager | undefined;
|
||||
private isProcessing: boolean = false;
|
||||
private cameraViewControls: FreeCamControlState | null;
|
||||
private isMouseClicked: boolean = false;
|
||||
private isMovable: Function;
|
||||
enable_stats: Function;
|
||||
|
||||
private kinMode: KinMode = KinMode.NONE;
|
||||
private fkHelper: FKHelper;
|
||||
private isBoneDragged: boolean = false;
|
||||
private ignoreNextClick: boolean = false;
|
||||
|
||||
// Deps held for read callbacks + bus emissions throughout the class.
|
||||
// Constructor still copies primitive fields onto `this.x` for the
|
||||
// existing internals; new code reads through `this.deps` directly.
|
||||
private deps: MouseControlsDeps;
|
||||
|
||||
constructor(deps: MouseControlsDeps) {
|
||||
this.deps = deps;
|
||||
this.camera = deps.camera;
|
||||
this.camera_person_mode = deps.camera_person_mode;
|
||||
this.cameraViewControls = deps.cameraViewControls;
|
||||
this.lockControls = deps.lockControls;
|
||||
this.camera_last_pos = deps.camera_last_pos;
|
||||
this.selected = [];
|
||||
this.deleteObject = deps.deleteObject;
|
||||
this.canvReference = deps.canvReference;
|
||||
this.mouse = deps.mouse;
|
||||
this.timeline_mouse = deps.timeline_mouse;
|
||||
this.raycaster = deps.raycaster;
|
||||
this.control = deps.control;
|
||||
this.outlinePass = deps.outlinePass;
|
||||
this.scene = deps.scene;
|
||||
this.publishSelect = deps.publishSelect;
|
||||
this.updateSelectedUI = deps.updateSelectedUI;
|
||||
this.transform_interaction = deps.transform_interaction;
|
||||
this.last_selected = [];
|
||||
this.getAssetType = deps.getAssetType;
|
||||
this.setSelected = deps.setSelected;
|
||||
this.sceneManager = undefined;
|
||||
this.isMovable = deps.isMovable;
|
||||
this.enable_stats = deps.enable_stats;
|
||||
this.fkHelper = new FKHelper({
|
||||
camera: this.camera!,
|
||||
domElement: this.control!.domElement,
|
||||
scene: this.scene,
|
||||
onDragChange: this.onFKControlsDragging.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
onFKControlsDragging(dragging: boolean) {
|
||||
this.isBoneDragged = dragging;
|
||||
|
||||
// FIX: Window dispatches a click event after FK dragging is complete
|
||||
// This flag can be used to ignore that when FK is adjusted
|
||||
this.ignoreNextClick = true;
|
||||
}
|
||||
|
||||
clearFKVisuals() {
|
||||
this.fkHelper.clear();
|
||||
}
|
||||
|
||||
focus() {
|
||||
if (this.lockControls && this.selected) {
|
||||
this.lockControls.camera.lookAt(this.selected[0].position);
|
||||
this.lockControls.camera.position.copy(this.selected[0].position);
|
||||
this.lockControls.moveForward(-5);
|
||||
this.lockControls.camera.position.add(new THREE.Vector3(0, 5, 0));
|
||||
this.lockControls.camera.lookAt(this.selected[0].position);
|
||||
}
|
||||
}
|
||||
|
||||
removeTransformControls(remove_outline: boolean = true) {
|
||||
if (this.control == undefined) {
|
||||
return;
|
||||
}
|
||||
if (this.outlinePass == undefined) {
|
||||
return;
|
||||
}
|
||||
if (remove_outline) {
|
||||
this.last_selected = this.selected;
|
||||
this.selected = [];
|
||||
this.publishSelect();
|
||||
}
|
||||
this.hideTransformControls();
|
||||
if (remove_outline) this.outlinePass.selectedObjects = [];
|
||||
}
|
||||
|
||||
hideTransformControls() {
|
||||
if (this.control == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.control.detach();
|
||||
this.scene.remove(this.control);
|
||||
}
|
||||
|
||||
reattachTransformControls() {
|
||||
if (this.control == undefined || this.selected == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.control.attach(this.selected[0]);
|
||||
this.scene.add(this.control);
|
||||
}
|
||||
|
||||
selectObject(currentObject: THREE.Object3D) {
|
||||
this.selected = [currentObject];
|
||||
this.setSelected(this.selected);
|
||||
this.publishSelect();
|
||||
|
||||
if (currentObject.userData.isCharacter) {
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(true));
|
||||
}
|
||||
|
||||
// Normal selection behavior
|
||||
if (currentObject.userData["locked"] !== true && this.control) {
|
||||
this.scene.add(this.control);
|
||||
this.control.attach(currentObject);
|
||||
}
|
||||
|
||||
if (this.selected && this.outlinePass) {
|
||||
this.outlinePass.selectedObjects = this.selected;
|
||||
}
|
||||
this.transform_interaction = true;
|
||||
// Contact react land — updateSelectedUI emits InspectorPanelChangedEvent
|
||||
// with the panel data, which the bridge translates into both
|
||||
// updateObjectPanel + showObjectPanel store calls.
|
||||
this.updateSelectedUI();
|
||||
}
|
||||
|
||||
onMouseDown(event: any) {
|
||||
if (
|
||||
(event.button === 0 || event.button === 1) &&
|
||||
this.isMovable() &&
|
||||
!this.isBoneDragged
|
||||
) {
|
||||
this.isMouseClicked = true;
|
||||
// Request pointer lock here (a real primary-gesture event)
|
||||
// rather than from onMouseMove. Browsers reliably honor lock
|
||||
// requests from mousedown but may reject ones from mousemove
|
||||
// — the latter started failing intermittently after the
|
||||
// useFreeCam listeners became always-attached.
|
||||
if (
|
||||
isPointerLockSupported() &&
|
||||
this.lockControls &&
|
||||
!this.lockControls.isLocked
|
||||
) {
|
||||
this.lockControls.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event: any) {
|
||||
if (event.button === 0 || event.button === 1) {
|
||||
this.lockControls?.unlock();
|
||||
this.isMouseClicked = false;
|
||||
}
|
||||
|
||||
if (event.button !== 0 && this.camera) {
|
||||
const camera_pos = new THREE.Vector3(
|
||||
parseFloat(this.camera.position.x.toFixed(2)),
|
||||
parseFloat(this.camera.position.y.toFixed(2)),
|
||||
parseFloat(this.camera.position.z.toFixed(2)),
|
||||
);
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
toggleFKMode() {
|
||||
if (this.kinMode == KinMode.FK) {
|
||||
this.fkHelper.clear();
|
||||
this.kinMode = KinMode.NONE;
|
||||
this.reattachTransformControls();
|
||||
if (this.deps.getPoseMode() === "pose") {
|
||||
this.deps.bus.emit(new PoseModeChangedEvent("select"));
|
||||
}
|
||||
console.log("FK mode off");
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure we have an intersection
|
||||
if (!(this.selected && this.selected.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure FK is supported only on character type objects
|
||||
const firstSelection = this.selected[0];
|
||||
if (!firstSelection.userData.isCharacter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FK is good to go
|
||||
// Disable main transform controls
|
||||
this.hideTransformControls();
|
||||
this.kinMode = KinMode.FK;
|
||||
this.fkHelper.setTarget(this.selected[0]);
|
||||
if (this.deps.getPoseMode() === "select") {
|
||||
this.deps.bus.emit(new PoseModeChangedEvent("pose"));
|
||||
}
|
||||
console.log("FK mode on");
|
||||
return;
|
||||
}
|
||||
|
||||
async onkeydown(event: KeyboardEvent) {
|
||||
if (isEventFromEditableElement(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.deps.isHotkeyDisabled()) {
|
||||
return;
|
||||
} else if (event.key === "f" && this.selected && this.lockControls) {
|
||||
this.focus();
|
||||
return;
|
||||
} else if (event.key === "Backspace" || event.key === "Delete") {
|
||||
if (this.selected) {
|
||||
this.selected.forEach((selected) => {
|
||||
this.deleteObject(selected.uuid);
|
||||
this.selected = [];
|
||||
if (this.kinMode === KinMode.FK) {
|
||||
this.toggleFKMode();
|
||||
}
|
||||
this.removeTransformControls();
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (event.key === "t") {
|
||||
// transform
|
||||
this.control?.setMode("translate");
|
||||
const ts = this.deps.getTransformSpace();
|
||||
if (this.control) this.control.space = ts;
|
||||
this.deps.bus.emit(new SelectedModeChangedEvent("move"));
|
||||
return;
|
||||
} else if (event.key === "x") {
|
||||
// toggle world/local space (blocked in scale mode)
|
||||
if (this.control?.mode === "scale") return;
|
||||
const next: TransformSpace =
|
||||
this.deps.getTransformSpace() === "world" ? "local" : "world";
|
||||
this.deps.bus.emit(new TransformSpaceChangedEvent(next));
|
||||
if (this.control) this.control.space = next;
|
||||
return;
|
||||
} else if (event.key === "r" && !event.ctrlKey) {
|
||||
// rotate
|
||||
this.control?.setMode("rotate");
|
||||
if (this.control) this.control.space = this.deps.getTransformSpace();
|
||||
this.deps.bus.emit(new SelectedModeChangedEvent("rotate"));
|
||||
return;
|
||||
} else if (event.key === "g") {
|
||||
// scale
|
||||
this.control?.setMode("scale");
|
||||
if (this.control) this.control.space = this.deps.getTransformSpace();
|
||||
this.deps.bus.emit(new SelectedModeChangedEvent("scale"));
|
||||
return;
|
||||
} else if (event.key === "k") {
|
||||
this.toggleFKMode();
|
||||
return;
|
||||
} else if (event.key === "b") {
|
||||
// Open asset modal
|
||||
this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
|
||||
const keyLower = event.key.toLowerCase();
|
||||
if (keyLower === "z" && !event.shiftKey) {
|
||||
// undo
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.undo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "z" && event.shiftKey) {
|
||||
// redo (Ctrl+Shift+Z / Cmd+Shift+Z)
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.redo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "y") {
|
||||
// redo (Ctrl+Y on Windows/Linux, Cmd+Y on macOS)
|
||||
event.preventDefault();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.redo();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "c") {
|
||||
// Copy
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.copy();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (keyLower === "v") {
|
||||
// Paste
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
await this.sceneManager?.paste();
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
} else if (event.key === "0") {
|
||||
// Stats Menu
|
||||
this.enable_stats();
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent browser shortcuts for Alt combinations
|
||||
if (
|
||||
event.altKey &&
|
||||
(event.key === "Alt" || event.key.toLowerCase() === "d")
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (this.cameraViewControls) {
|
||||
if (event.shiftKey) {
|
||||
this.cameraViewControls.movementSpeed = 3;
|
||||
} else if (event.altKey) {
|
||||
this.cameraViewControls.movementSpeed = 0.1;
|
||||
} else {
|
||||
this.cameraViewControls.movementSpeed = 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (this.deps.getPoseMode() === "pose") {
|
||||
this.toggleFKMode();
|
||||
return;
|
||||
} else if (this.selected && this.selected.length > 0) {
|
||||
this.removeTransformControls();
|
||||
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMousePointerLock() {
|
||||
if (this.isMouseClicked && this.lockControls) {
|
||||
if (this.lockControls.isLocked == false) {
|
||||
this.lockControls.lock();
|
||||
}
|
||||
} else if (this.lockControls) {
|
||||
if (this.lockControls.isLocked == true) {
|
||||
this.lockControls.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseManualLock(event: MouseEvent) {
|
||||
if (this.isMouseClicked && this.lockControls) {
|
||||
// If the mouse is clicked and the lockControls is not locked, lock it
|
||||
if (this.lockControls.isLocked == false) {
|
||||
// Lock the mouse flag
|
||||
this.lockControls.isLocked = true;
|
||||
|
||||
// Change the cursor to a dragging cursor
|
||||
this.lockControls.domElement.style.cursor = "move";
|
||||
console.log("Mouse locked manually");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the mouse is clicked but controls also locked, move the camera with the mouse
|
||||
const camera = this.lockControls.getObject();
|
||||
const _euler = new Euler(0, 0, 0, "YXZ");
|
||||
const _MOUSE_SENSITIVITY = 0.002;
|
||||
const pointerSpeed = 1.0;
|
||||
const _PI_2 = Math.PI / 2;
|
||||
const minPolarAngle = 0;
|
||||
const maxPolarAngle = Math.PI;
|
||||
|
||||
_euler.setFromQuaternion(camera.quaternion);
|
||||
|
||||
_euler.y -= event.movementX * _MOUSE_SENSITIVITY * pointerSpeed;
|
||||
_euler.x -= event.movementY * _MOUSE_SENSITIVITY * pointerSpeed;
|
||||
|
||||
_euler.x = Math.max(
|
||||
_PI_2 - maxPolarAngle,
|
||||
Math.min(_PI_2 - minPolarAngle, _euler.x),
|
||||
);
|
||||
|
||||
camera.quaternion.setFromEuler(_euler);
|
||||
} else if (this.lockControls) {
|
||||
if (this.lockControls.isLocked == true) {
|
||||
// Unlock the mouse flag
|
||||
this.lockControls.isLocked = false;
|
||||
|
||||
// Change the cursor back to default
|
||||
this.lockControls.domElement.style.cursor = "default";
|
||||
console.log("Mouse unlocked manually");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sets new mouse location usually used in raycasts.
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (this.canvReference == undefined) {
|
||||
return;
|
||||
}
|
||||
const rect = this.canvReference.getBoundingClientRect();
|
||||
if (this.mouse == undefined) {
|
||||
return;
|
||||
}
|
||||
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
this.timeline_mouse = this.mouse;
|
||||
|
||||
// this causes an issue https://discourse.threejs.org/t/unable-to-use-pointer-lock-api/11092
|
||||
if (!isPointerLockSupported()) {
|
||||
this.handleMouseManualLock(event);
|
||||
} else {
|
||||
this.handleMousePointerLock();
|
||||
}
|
||||
}
|
||||
|
||||
// When the mouse clicks the screen.
|
||||
onMouseClick() {
|
||||
if (this.camera == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore window clicks if FK is active and bone is being transformed
|
||||
if (this.ignoreNextClick) {
|
||||
this.ignoreNextClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const camera_pos = new THREE.Vector3(
|
||||
parseFloat(this.camera.position.x.toFixed(2)),
|
||||
parseFloat(this.camera.position.y.toFixed(2)),
|
||||
parseFloat(this.camera.position.z.toFixed(2)),
|
||||
);
|
||||
if (this.camera_last_pos.equals(new THREE.Vector3(0, 0, 0))) {
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
}
|
||||
|
||||
if (
|
||||
this.raycaster == undefined ||
|
||||
this.mouse == undefined ||
|
||||
this.control == undefined ||
|
||||
this.outlinePass == undefined ||
|
||||
!this.camera_last_pos.equals(camera_pos)
|
||||
) {
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
return;
|
||||
}
|
||||
this.camera_last_pos.copy(camera_pos);
|
||||
|
||||
if (this.kinMode == KinMode.FK) {
|
||||
this.fkHelper.onMouseClick(this.mouse);
|
||||
return;
|
||||
}
|
||||
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const interactable: any[] = [];
|
||||
this.scene.children.forEach((child: THREE.Object3D) => {
|
||||
if (child.name != "") {
|
||||
if (
|
||||
child.type == "Mesh" ||
|
||||
child.type == "Object3D" ||
|
||||
child.type == "Group" ||
|
||||
child.type == "SkinnedMesh"
|
||||
) {
|
||||
interactable.push(child);
|
||||
}
|
||||
}
|
||||
});
|
||||
const intersects = this.raycaster.intersectObjects(interactable, true);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
if (intersects[0].object.type != "GridHelper") {
|
||||
let currentObject = intersects[0].object;
|
||||
while (currentObject.parent && currentObject.parent.type !== "Scene") {
|
||||
currentObject = currentObject.parent;
|
||||
}
|
||||
|
||||
this.selected = [];
|
||||
|
||||
// Show panel here
|
||||
if (currentObject.type == "Scene") {
|
||||
this.selected?.push(intersects[0].object);
|
||||
} else {
|
||||
this.selected?.push(currentObject);
|
||||
}
|
||||
|
||||
this.selectObject(currentObject);
|
||||
}
|
||||
} else {
|
||||
this.selected = [];
|
||||
this.setSelected(this.selected);
|
||||
this.removeTransformControls();
|
||||
this.deps.bus.emit(new InspectorPanelChangedEvent(null));
|
||||
this.deps.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
}
|
||||
|
||||
if (this.sceneManager) {
|
||||
const selected: SceneObject | null = this.sceneManager.selected();
|
||||
this.deps.bus.emit(new OutlinerSelectedItemChangedEvent(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
export { MouseControls } from "@storyteller/ui-pagescene";
|
||||
export type { MouseControlsDeps } from "@storyteller/ui-pagescene";
|
||||
@@ -1,168 +1,5 @@
|
||||
import type Editor from "~/pages/PageScene/engine/editor";
|
||||
import { CreateAction } from "~/pages/PageScene/engine/editor/actions/CreateAction";
|
||||
import {
|
||||
AssetModalVisibilityChangedEvent,
|
||||
PoseControlsVisibilityChangedEvent,
|
||||
SelectedModeChangedEvent,
|
||||
TransformModeChangedEvent,
|
||||
} from "~/pages/PageScene/engine/events/EngineEvent";
|
||||
import type { PoseMode } from "~/pages/PageScene/PageSceneStore";
|
||||
|
||||
// One declarative table for every viewport keyboard shortcut.
|
||||
// useViewportKeyboard dispatches against this list; a future Ctrl-hold
|
||||
// cheatsheet overlay can render the same data without duplication.
|
||||
|
||||
export type KeyGroup = "Transform" | "Selection" | "Edit" | "View";
|
||||
|
||||
export interface KeyBinding {
|
||||
code: string; // matches event.code (e.g. "KeyT", "Backspace")
|
||||
modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean };
|
||||
label: string;
|
||||
group: KeyGroup;
|
||||
run: (editor: Editor) => void | Promise<void>;
|
||||
// Whether the binding should preventDefault + stopPropagation when
|
||||
// matched. Used for browser shortcut conflicts (Ctrl+Z, Ctrl+C, etc).
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
|
||||
// The TransformControls gizmo uses "translate" / "rotate" / "scale";
|
||||
// the store's TransformMode union uses "move" / "rotate" / "scale".
|
||||
// Keep the gizmo and store both in sync from one place.
|
||||
const setGizmoMode = (
|
||||
editor: Editor,
|
||||
gizmoMode: "translate" | "rotate" | "scale",
|
||||
storeMode: "move" | "rotate" | "scale",
|
||||
) => {
|
||||
editor.gizmo.changeMode(gizmoMode);
|
||||
editor.bus.emit(new TransformModeChangedEvent(storeMode));
|
||||
editor.bus.emit(new SelectedModeChangedEvent(storeMode));
|
||||
};
|
||||
|
||||
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) => {
|
||||
editor.deleteObject(sel.uuid);
|
||||
});
|
||||
mc.selected = [];
|
||||
mc.removeTransformControls();
|
||||
editor.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
};
|
||||
|
||||
const onEscape = (editor: Editor) => {
|
||||
const poseMode: PoseMode = editor.getPoseMode();
|
||||
if (poseMode === "pose") {
|
||||
editor.mouse_controls?.toggleFKMode();
|
||||
return;
|
||||
}
|
||||
if (editor.mouse_controls?.selected?.length) {
|
||||
editor.mouse_controls.removeTransformControls();
|
||||
editor.bus.emit(new PoseControlsVisibilityChangedEvent(false));
|
||||
}
|
||||
};
|
||||
|
||||
const focusSelected = (editor: Editor) => {
|
||||
if (
|
||||
editor.mouse_controls?.selected?.length &&
|
||||
editor.mouse_controls.lockControls
|
||||
) {
|
||||
editor.mouse_controls.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const openAssetModal = (editor: Editor) => {
|
||||
editor.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
|
||||
};
|
||||
|
||||
const toggleCameraView = (editor: Editor) => {
|
||||
editor.cameraController.switchCameraView();
|
||||
};
|
||||
|
||||
const undo = async (editor: Editor) => {
|
||||
await editor.history.undo();
|
||||
};
|
||||
|
||||
const redo = async (editor: Editor) => {
|
||||
await editor.history.redo();
|
||||
};
|
||||
|
||||
const copy = async (editor: Editor) => {
|
||||
await editor.sceneManager?.copy();
|
||||
};
|
||||
|
||||
const paste = async (editor: Editor) => {
|
||||
const obj = await editor.sceneManager?.paste();
|
||||
if (!obj) return;
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
editor.selection.refreshOutliner();
|
||||
};
|
||||
|
||||
export const buildKeymap = (): KeyBinding[] => [
|
||||
// Transform
|
||||
{ code: "KeyT", label: "Translate", group: "Transform",
|
||||
run: (e) => setGizmoMode(e, "translate", "move") },
|
||||
{ code: "KeyR", label: "Rotate", group: "Transform",
|
||||
run: (e) => setGizmoMode(e, "rotate", "rotate") },
|
||||
{ code: "KeyG", label: "Scale", group: "Transform",
|
||||
run: (e) => setGizmoMode(e, "scale", "scale") },
|
||||
{ code: "KeyX", label: "Toggle local/world", group: "Transform",
|
||||
run: (e) => e.gizmo.toggleTransformSpace() },
|
||||
{ code: "KeyK", label: "Toggle pose (FK)", group: "Transform",
|
||||
run: (e) => e.mouse_controls?.toggleFKMode() },
|
||||
|
||||
// Selection / view
|
||||
{ code: "KeyF", label: "Focus selection", group: "View",
|
||||
run: focusSelected },
|
||||
{ code: "KeyB", label: "Open asset menu", group: "View",
|
||||
run: openAssetModal },
|
||||
{ code: "Space", label: "Toggle camera view", group: "View",
|
||||
run: toggleCameraView, preventDefault: true },
|
||||
{ code: "Escape", label: "Clear selection / exit pose", group: "Selection",
|
||||
run: onEscape },
|
||||
|
||||
// Edit
|
||||
{ code: "Backspace", label: "Delete selected", group: "Edit",
|
||||
run: deleteSelected },
|
||||
{ code: "Delete", label: "Delete selected", group: "Edit",
|
||||
run: deleteSelected },
|
||||
{ code: "KeyZ", modifiers: { ctrl: true }, label: "Undo", group: "Edit",
|
||||
run: undo, preventDefault: true },
|
||||
{ code: "KeyZ", modifiers: { ctrl: true, shift: true }, label: "Redo",
|
||||
group: "Edit", run: redo, preventDefault: true },
|
||||
{ code: "KeyY", modifiers: { ctrl: true }, label: "Redo", group: "Edit",
|
||||
run: redo, preventDefault: true },
|
||||
{ code: "KeyC", modifiers: { ctrl: true }, label: "Copy", group: "Edit",
|
||||
run: copy, preventDefault: true },
|
||||
{ code: "KeyV", modifiers: { ctrl: true }, label: "Paste", group: "Edit",
|
||||
run: paste, preventDefault: true },
|
||||
];
|
||||
|
||||
const matches = (binding: KeyBinding, e: KeyboardEvent): boolean => {
|
||||
if (binding.code !== e.code) return false;
|
||||
const m = binding.modifiers ?? {};
|
||||
// Treat Ctrl and Meta interchangeably so macOS Cmd+X works too.
|
||||
const ctrlOrMeta = e.ctrlKey || e.metaKey;
|
||||
if (!!m.ctrl !== !!ctrlOrMeta) return false;
|
||||
if (!!m.shift !== !!e.shiftKey) return false;
|
||||
if (!!m.alt !== !!e.altKey) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const dispatchBinding = (
|
||||
bindings: KeyBinding[],
|
||||
event: KeyboardEvent,
|
||||
editor: Editor,
|
||||
): boolean => {
|
||||
for (const binding of bindings) {
|
||||
if (!matches(binding, event)) continue;
|
||||
if (binding.preventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
void binding.run(editor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
export {
|
||||
buildKeymap,
|
||||
dispatchBinding,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
export type { KeyBinding, KeyGroup } from "@storyteller/ui-pagescene";
|
||||
@@ -1,41 +1 @@
|
||||
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();
|
||||
}
|
||||
export { pickDropPosition } from "@storyteller/ui-pagescene";
|
||||
@@ -1,117 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
|
||||
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
|
||||
import { Font } from "three/examples/jsm/loaders/FontLoader.js";
|
||||
|
||||
export class LoadingPlaceHolder3DModel {
|
||||
public message_mesh: THREE.Mesh | undefined;
|
||||
|
||||
constructor() {
|
||||
this.message_mesh = undefined;
|
||||
}
|
||||
|
||||
public async destroyWithScene(scene: THREE.Scene) {
|
||||
if (this.message_mesh === undefined) {
|
||||
return;
|
||||
}
|
||||
scene.remove(this.message_mesh);
|
||||
this.message_mesh.geometry.dispose();
|
||||
}
|
||||
|
||||
public async initialize(
|
||||
message: string,
|
||||
position: THREE.Vector3,
|
||||
font: Font,
|
||||
): Promise<THREE.Mesh> {
|
||||
return (this.message_mesh = await this.createTextGeometry(
|
||||
message,
|
||||
position,
|
||||
font,
|
||||
));
|
||||
}
|
||||
|
||||
private async createTextGeometry(
|
||||
text: string,
|
||||
position: THREE.Vector3,
|
||||
font: Font,
|
||||
): Promise<THREE.Mesh> {
|
||||
if (font) {
|
||||
const textGeometry = new TextGeometry(text, {
|
||||
font: font,
|
||||
size: 0.25,
|
||||
height: 0.1,
|
||||
curveSegments: 1,
|
||||
bevelEnabled: false,
|
||||
bevelThickness: 1,
|
||||
bevelSize: 1,
|
||||
bevelOffset: 1,
|
||||
bevelSegments: 5,
|
||||
});
|
||||
|
||||
const textMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff });
|
||||
const message_mesh = new THREE.Mesh(textGeometry, textMaterial);
|
||||
message_mesh.position.set(position.x, position.y, position.z);
|
||||
return message_mesh;
|
||||
}
|
||||
throw Error(`Could not Create Text Geometry for ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LoadingPlaceHolderManager {
|
||||
private messages: Map<string, LoadingPlaceHolder3DModel>;
|
||||
private font: Font | undefined;
|
||||
private scene: THREE.Scene;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.messages = new Map<string, LoadingPlaceHolder3DModel>();
|
||||
this.font = undefined;
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.font = await this.loadFont();
|
||||
}
|
||||
|
||||
public async remove(id: string) {
|
||||
const result = this.messages.get(id);
|
||||
if (result === undefined) {
|
||||
return;
|
||||
}
|
||||
result.destroyWithScene(this.scene);
|
||||
this.messages.delete(id);
|
||||
}
|
||||
|
||||
public async add(id: string, message: string, position: THREE.Vector3) {
|
||||
const placeholder = new LoadingPlaceHolder3DModel();
|
||||
if (this.font === undefined) {
|
||||
console.log("Forgot to Initialize LoadingPlaceHolderManager");
|
||||
return;
|
||||
}
|
||||
const placeholder3DObj = await placeholder.initialize(
|
||||
message,
|
||||
position,
|
||||
this.font,
|
||||
);
|
||||
this.scene.add(placeholder3DObj);
|
||||
this.messages.set(id, placeholder);
|
||||
}
|
||||
|
||||
// Load the font wrapper
|
||||
private async loadFont(): Promise<Font> {
|
||||
return new Promise<Font>((resolve, reject) => {
|
||||
const loader = new FontLoader();
|
||||
loader.load(
|
||||
"/resources/fonts/CarterOneRegular.json",
|
||||
(font) => {
|
||||
resolve(font);
|
||||
},
|
||||
(progress) => {
|
||||
console.log(`Font Loaded: ${progress.loaded / progress.total}`);
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,4 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// Pure helpers for translating browser pointer events into the
|
||||
// normalized device coordinates the renderer expects. No DOM access
|
||||
// beyond the rect we get handed; no listener attachment.
|
||||
|
||||
// Convert a clientX/clientY to NDC (normalized device coords:
|
||||
// [-1, 1] in both axes, origin at center, y up).
|
||||
export const ndcFromClient = (
|
||||
rect: DOMRect,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number } => ({
|
||||
x: ((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
});
|
||||
|
||||
// Apply NDC to a Three.js Vector2 (the engine stores its current
|
||||
// pointer as one). Mutates in place so the engine's existing
|
||||
// references don't need to change.
|
||||
export const applyNdcToVector2 = (
|
||||
vec: THREE.Vector2,
|
||||
rect: DOMRect,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): void => {
|
||||
const { x, y } = ndcFromClient(rect, clientX, clientY);
|
||||
vec.x = x;
|
||||
vec.y = y;
|
||||
};
|
||||
export {
|
||||
ndcFromClient,
|
||||
applyNdcToVector2,
|
||||
} from "@storyteller/ui-pagescene";
|
||||
@@ -1,217 +1,2 @@
|
||||
import * as THREE from "three";
|
||||
import { Camera } from "@storyteller/common";
|
||||
|
||||
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
|
||||
import { StoryTellerProxyScene } from "../proxy/storyteller_proxy_scene";
|
||||
import { CameraAspectRatio } from "../enums";
|
||||
import type Scene from "./scene";
|
||||
import type { EngineEventBus } from "./events/EngineEventBus";
|
||||
import {
|
||||
CameraAspectRatioChangedEvent,
|
||||
CamerasReplacedEvent,
|
||||
EditorLoaderEvent,
|
||||
SelectedCameraChangedEvent,
|
||||
} from "./events/EngineEvent";
|
||||
|
||||
export type EditorInitializeConfig = {
|
||||
sceneToken: string;
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
// Camera state. Reads happen at the engine→store boundary so the
|
||||
// SaveManager itself stays store-agnostic.
|
||||
getCameras: () => Camera[];
|
||||
getSelectedCameraId: () => string;
|
||||
|
||||
// Typed event bus — every engine→store write goes through here.
|
||||
bus: EngineEventBus;
|
||||
};
|
||||
|
||||
export class SaveManager {
|
||||
// Token of the most-recently loaded scene. Internal load tracking
|
||||
// — nothing outside SaveManager reads it.
|
||||
private currentSceneMediaToken: string | null = null;
|
||||
|
||||
constructor(private readonly deps: SaveManagerDeps) {}
|
||||
|
||||
public getSceneJson({
|
||||
sceneGenerationMetadata,
|
||||
}: {
|
||||
sceneGenerationMetadata: SceneGenereationMetaData;
|
||||
}) {
|
||||
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 camerasData = this.deps.getCameras().map((cam: Camera) => ({
|
||||
id: cam.id,
|
||||
label: cam.label,
|
||||
focalLength: cam.focalLength,
|
||||
position: cam.position,
|
||||
rotation: cam.rotation,
|
||||
lookAt: cam.lookAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
version,
|
||||
scene: scene_json,
|
||||
...sceneGenerationMetadata,
|
||||
timeline: "",
|
||||
skybox: scene.skybox,
|
||||
camera_data: {
|
||||
position: camera?.position,
|
||||
rotation: camera?.rotation,
|
||||
},
|
||||
cameras: camerasData,
|
||||
selectedCameraId: this.deps.getSelectedCameraId(),
|
||||
};
|
||||
}
|
||||
|
||||
public async saveScene({
|
||||
sceneTitle,
|
||||
sceneToken,
|
||||
sceneGenerationMetadata,
|
||||
}: {
|
||||
sceneTitle: string;
|
||||
sceneToken?: string;
|
||||
sceneGenerationMetadata: SceneGenereationMetaData;
|
||||
}): Promise<string> {
|
||||
this.deps.removeTransformControls();
|
||||
this.deps.bus.emit(new EditorLoaderEvent(true));
|
||||
|
||||
const sceneJson = this.getSceneJson({ sceneGenerationMetadata });
|
||||
|
||||
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.deps.saveSceneState({
|
||||
saveJson: JSON.stringify(sceneJson),
|
||||
sceneTitle,
|
||||
sceneToken,
|
||||
sceneThumbnail,
|
||||
});
|
||||
|
||||
this.deps.bus.emit(new EditorLoaderEvent(false));
|
||||
console.debug("Save Scene Result: ", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async loadCache(cacheJson: string) {
|
||||
this.deps.bus.emit(new EditorLoaderEvent(true));
|
||||
const scene_json = JSON.parse(cacheJson);
|
||||
await this.loadFromJson(scene_json);
|
||||
this.deps.bus.emit(new EditorLoaderEvent(false));
|
||||
}
|
||||
|
||||
public async loadScene(scene_media_token: string) {
|
||||
this.deps.bus.emit(new EditorLoaderEvent(true));
|
||||
this.currentSceneMediaToken = scene_media_token;
|
||||
const scene_json = await this.deps
|
||||
.loadSceneState(this.currentSceneMediaToken)
|
||||
.catch((err) => {
|
||||
this.deps.bus.emit(new EditorLoaderEvent(false));
|
||||
throw err;
|
||||
});
|
||||
await this.loadFromJson(scene_json);
|
||||
this.deps.bus.emit(new EditorLoaderEvent(false));
|
||||
}
|
||||
|
||||
private async loadFromJson(scene_json: any) {
|
||||
const version = this.deps.getVersion();
|
||||
const scene = this.deps.getActiveScene();
|
||||
const proxyScene = new StoryTellerProxyScene(version, scene);
|
||||
|
||||
await proxyScene.loadFromSceneJson(
|
||||
scene_json["scene"],
|
||||
scene_json["skybox"],
|
||||
scene_json["version"],
|
||||
);
|
||||
|
||||
const camera_data = scene_json["camera_data"];
|
||||
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);
|
||||
}
|
||||
|
||||
if (scene_json.cameras) {
|
||||
const restored: Camera[] = scene_json.cameras.map((cam: Camera) => ({
|
||||
id: cam.id,
|
||||
label: cam.label,
|
||||
focalLength: cam.focalLength,
|
||||
position: cam.position,
|
||||
rotation: cam.rotation,
|
||||
lookAt: cam.lookAt,
|
||||
}));
|
||||
this.deps.bus.emit(new CamerasReplacedEvent(restored));
|
||||
}
|
||||
|
||||
if (scene_json.selectedCameraId) {
|
||||
this.deps.bus.emit(
|
||||
new SelectedCameraChangedEvent(scene_json.selectedCameraId),
|
||||
);
|
||||
}
|
||||
|
||||
if (scene_json.positivePrompt) {
|
||||
this.deps.setPositivePrompt(scene_json.positivePrompt);
|
||||
}
|
||||
if (scene_json.cameraAspectRatio) {
|
||||
this.deps.changeRenderCameraAspectRatio(scene_json.cameraAspectRatio);
|
||||
this.deps.bus.emit(
|
||||
new CameraAspectRatioChangedEvent(scene_json.cameraAspectRatio),
|
||||
);
|
||||
}
|
||||
|
||||
this.deps.setVersion(scene_json["version"]);
|
||||
this.deps.refreshCamObj();
|
||||
}
|
||||
}
|
||||
export { SaveManager } from "@storyteller/ui-pagescene";
|
||||
export type { SaveManagerDeps, SaveSceneStateArgs } from "@storyteller/ui-pagescene";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,265 +1 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
IconDefinition,
|
||||
faCamera,
|
||||
faCube,
|
||||
faPerson,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import Scene from "./scene";
|
||||
import { MouseControls } from "./keybinds_controls";
|
||||
import { ClipGroup } from "~/enums";
|
||||
import { AssetType } from "~/enums";
|
||||
import { XYZ } from "../datastructures/common";
|
||||
import type { EngineEventBus } from "./events/EngineEventBus";
|
||||
import { ObjectAddedEvent } from "./events/EngineEvent";
|
||||
|
||||
export type SceneObject = {
|
||||
id: string;
|
||||
icon: IconDefinition;
|
||||
name: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export interface SceneManagerAPI {
|
||||
create(media_token: string, name: string, position: THREE.Vector3): void;
|
||||
retrieve(object_uuid: string): void;
|
||||
delete(object_uuid: string): void;
|
||||
update(
|
||||
object_uuid: string,
|
||||
position: THREE.Vector3,
|
||||
rotation: THREE.Euler,
|
||||
scale: THREE.Vector3,
|
||||
): void;
|
||||
selected(): void;
|
||||
render_outliner(timeline_characters: { [key: string]: ClipGroup }): void;
|
||||
select_object(id: string): void;
|
||||
}
|
||||
|
||||
export class SceneManager implements SceneManagerAPI {
|
||||
scene: Scene;
|
||||
mouse_controls: MouseControls;
|
||||
version: number;
|
||||
selected_objects: THREE.Object3D[] | undefined;
|
||||
private copiedObject: THREE.Object3D | undefined;
|
||||
private bus: EngineEventBus;
|
||||
|
||||
constructor(
|
||||
version: number,
|
||||
mouse_controls: MouseControls,
|
||||
scene: Scene,
|
||||
bus: EngineEventBus,
|
||||
) {
|
||||
this.mouse_controls = mouse_controls;
|
||||
this.scene = scene;
|
||||
this.version = version;
|
||||
this.bus = bus;
|
||||
}
|
||||
|
||||
public async create(
|
||||
media_token: string,
|
||||
name: string,
|
||||
position: THREE.Vector3,
|
||||
): Promise<THREE.Object3D<THREE.Object3DEventMap> | undefined> {
|
||||
if (media_token.includes("SKY::")) {
|
||||
const token = media_token.replace("SKY::", "");
|
||||
this.scene.updateSkybox(token);
|
||||
} else if (media_token !== "Parim") {
|
||||
// Support direct image/video URL pastes via the Image::<url> scheme
|
||||
if (media_token.startsWith("Image::")) {
|
||||
const url = media_token.replace("Image::", "");
|
||||
const obj = await this.scene.loadObjectFromUrl(url, position);
|
||||
if (obj) {
|
||||
obj.name = name;
|
||||
obj.userData["name"] = name;
|
||||
}
|
||||
return obj as THREE.Object3D<THREE.Object3DEventMap>;
|
||||
}
|
||||
|
||||
const obj = await this.scene.loadObject(
|
||||
media_token,
|
||||
name,
|
||||
true,
|
||||
position,
|
||||
this.version,
|
||||
);
|
||||
if (obj) {
|
||||
obj.name = name;
|
||||
obj.userData["name"] = name;
|
||||
}
|
||||
return obj as THREE.Object3D<THREE.Object3DEventMap> | undefined;
|
||||
} else {
|
||||
return this.scene.instantiate(name, position);
|
||||
}
|
||||
}
|
||||
|
||||
updateSkybox(media_id: string) {
|
||||
this.scene.updateSkybox(media_id);
|
||||
}
|
||||
|
||||
/* NEVER CALL THIS INTERNALLY */
|
||||
public render_outliner(timeline_characters: { [key: string]: ClipGroup }) {
|
||||
// needs timeline_characters to render favicons.
|
||||
// Not permanent just in place until we have multi object select ability.
|
||||
const selected_item = this.selected();
|
||||
const signal_items: SceneObject[] = [];
|
||||
this.scene.scene.children.forEach((child) => {
|
||||
const converted = this.convert_object(child, timeline_characters);
|
||||
if (converted.name !== "") {
|
||||
signal_items.push(converted);
|
||||
}
|
||||
});
|
||||
const outlinerState = {
|
||||
selectedItem: selected_item,
|
||||
items: signal_items,
|
||||
};
|
||||
return outlinerState;
|
||||
}
|
||||
|
||||
public async retrieve(object_uuid: string) {
|
||||
return this.scene.get_object_by_uuid(object_uuid);
|
||||
}
|
||||
|
||||
public async update(
|
||||
object_uuid: string,
|
||||
position: THREE.Vector3,
|
||||
rotation: THREE.Euler,
|
||||
scale: THREE.Vector3,
|
||||
) {
|
||||
const object = await this.retrieve(object_uuid);
|
||||
if (object) {
|
||||
object.position.copy(position);
|
||||
object.rotation.copy(rotation);
|
||||
object.scale.copy(scale);
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(object_uuid: string) {
|
||||
// Deletes an object.
|
||||
this.mouse_controls.deleteObject(object_uuid);
|
||||
}
|
||||
|
||||
public async double_click() {
|
||||
this.mouse_controls.focus();
|
||||
}
|
||||
|
||||
public async hideObject(object_uuid: string) {
|
||||
const object = await this.retrieve(object_uuid);
|
||||
if (object?.visible !== undefined) {
|
||||
object.visible = !object.visible;
|
||||
object.userData["visible"] = object.visible;
|
||||
}
|
||||
}
|
||||
|
||||
public selected() {
|
||||
let selected_item = null;
|
||||
if (this.selected_objects && this.selected_objects.length > 0) {
|
||||
selected_item = this.selected_objects[0];
|
||||
}
|
||||
if (selected_item) {
|
||||
return this.convert_object(selected_item, {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public select_object(id: string) {
|
||||
const object = this.scene.get_object_by_uuid(id);
|
||||
if (object) {
|
||||
this.mouse_controls.selected = [object];
|
||||
this.mouse_controls.selectObject(object);
|
||||
}
|
||||
}
|
||||
|
||||
// Writes position/rotation/scale onto the currently selected object.
|
||||
// Rotation is taken in degrees (matches the Object Panel input).
|
||||
public updateSelectedTransform(position: XYZ, rotation: XYZ, scale: XYZ) {
|
||||
const object = this.selected_objects?.[0];
|
||||
if (!object) return;
|
||||
object.position.set(position.x, position.y, position.z);
|
||||
object.rotation.set(
|
||||
THREE.MathUtils.degToRad(rotation.x),
|
||||
THREE.MathUtils.degToRad(rotation.y),
|
||||
THREE.MathUtils.degToRad(rotation.z),
|
||||
);
|
||||
object.scale.set(scale.x, scale.y, scale.z);
|
||||
}
|
||||
|
||||
// Converts a 3d object to signal item format.
|
||||
private convert_object(
|
||||
object: THREE.Object3D,
|
||||
timeline_characters: { [key: string]: ClipGroup },
|
||||
) {
|
||||
let faicon = faCube;
|
||||
let name = object.name;
|
||||
if (object.name == "::CAM::") {
|
||||
faicon = faCamera;
|
||||
name = "Camera";
|
||||
} else if (object.uuid in timeline_characters) {
|
||||
faicon = faPerson;
|
||||
}
|
||||
let locked = object.userData["locked"];
|
||||
if (locked == undefined) {
|
||||
locked = false;
|
||||
}
|
||||
return {
|
||||
id: object.uuid,
|
||||
icon: faicon,
|
||||
name: name.charAt(0).toUpperCase() + name.slice(1),
|
||||
type: object.type,
|
||||
visible: object.visible,
|
||||
locked: object.userData["locked"],
|
||||
};
|
||||
}
|
||||
|
||||
public async copy() {
|
||||
const object = this.mouse_controls.selected?.at(0);
|
||||
if (object !== undefined && object.name !== "::CAM::") {
|
||||
this.copiedObject = object;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const rotation = this.copiedObject.rotation.clone();
|
||||
const scale = this.copiedObject.scale.clone();
|
||||
|
||||
const media_id = userdata["media_id"];
|
||||
const color = userdata["color"];
|
||||
const name = this.copiedObject.name;
|
||||
const wasCharacter: boolean = !!this.copiedObject.userData.isCharacter;
|
||||
|
||||
const obj = await this.create(media_id, name, position);
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
this.scene.setColor(obj.uuid, color);
|
||||
obj.position.copy(position.add(new THREE.Vector3(0.5, 0.0, 0.5)));
|
||||
obj.rotation.copy(rotation);
|
||||
obj.scale.copy(scale);
|
||||
|
||||
// Preserve character stuff on pasted object
|
||||
if (wasCharacter) {
|
||||
obj.userData.isCharacter = true;
|
||||
}
|
||||
|
||||
this.mouse_controls.selectObject(obj);
|
||||
|
||||
this.bus.emit(
|
||||
new ObjectAddedEvent({
|
||||
id: obj.uuid,
|
||||
kind: wasCharacter ? "character" : "object",
|
||||
name,
|
||||
mediaId: media_id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.copy();
|
||||
return obj;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
export { SceneManager } from "@storyteller/ui-pagescene";
|
||||
@@ -1,52 +0,0 @@
|
||||
// @see https://discourse.threejs.org/t/production-ready-green-screen-with-three-js/23113/2
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
}
|
||||
`;
|
||||
const FRAGMENT_SHADER = `
|
||||
uniform sampler2D tex;
|
||||
uniform float texWidth;
|
||||
uniform float texHeight;
|
||||
|
||||
uniform vec3 keyColor;
|
||||
uniform float similarity;
|
||||
uniform float smoothness;
|
||||
uniform float spill;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
// From https://github.com/libretro/glsl-shaders/blob/master/nnedi3/shaders/rgb-to-yuv.glsl
|
||||
vec2 RGBtoUV(vec3 rgb) {
|
||||
return vec2(
|
||||
rgb.r * -0.169 + rgb.g * -0.331 + rgb.b * 0.5 + 0.5,
|
||||
rgb.r * 0.5 + rgb.g * -0.419 + rgb.b * -0.081 + 0.5
|
||||
);
|
||||
}
|
||||
|
||||
vec4 ProcessChromaKey(vec2 texCoord) {
|
||||
vec4 rgba = texture2D(tex, texCoord);
|
||||
float chromaDist = distance(RGBtoUV(texture2D(tex, texCoord).rgb), RGBtoUV(keyColor));
|
||||
|
||||
float baseMask = chromaDist - similarity;
|
||||
float fullMask = pow(clamp(baseMask / smoothness, 0., 1.), 1.5);
|
||||
rgba.a = fullMask;
|
||||
|
||||
float spillVal = pow(clamp(baseMask / spill, 0., 1.), 1.5);
|
||||
float desat = clamp(rgba.r * 0.2126 + rgba.g * 0.7152 + rgba.b * 0.0722, 0., 1.);
|
||||
rgba.rgb = mix(vec3(desat, desat, desat), rgba.rgb, spillVal);
|
||||
|
||||
return rgba;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 texCoord = vUv;
|
||||
gl_FragColor = ProcessChromaKey(texCoord);
|
||||
}
|
||||
`;
|
||||
|
||||
export { VERTEX_SHADER, FRAGMENT_SHADER };
|
||||
@@ -1,141 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { MediaFileType } from "../enums";
|
||||
|
||||
export interface ObjectJSON {
|
||||
version: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
rotation: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
scale: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
object_name: string;
|
||||
object_uuid: string;
|
||||
object_user_data_name: string;
|
||||
media_file_token: string;
|
||||
color: string;
|
||||
metalness: number;
|
||||
shininess: number;
|
||||
specular: number;
|
||||
locked: boolean;
|
||||
visible: boolean | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rigData: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
user_data: Record<string, any>;
|
||||
}
|
||||
|
||||
export class StoryTellerProxy3DObject {
|
||||
version: number;
|
||||
|
||||
position: THREE.Vector3;
|
||||
rotation: THREE.Euler;
|
||||
scale: THREE.Vector3;
|
||||
|
||||
object_uuid: string;
|
||||
object_name: string;
|
||||
object_user_data_name: string; // changable name
|
||||
media_file_token: string;
|
||||
color: string;
|
||||
metalness: number;
|
||||
shininess: number;
|
||||
specular: number;
|
||||
locked: boolean;
|
||||
visible: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rigData: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
userData: Record<string, any>;
|
||||
|
||||
constructor(version: number, media_file_token: string) {
|
||||
this.version = version;
|
||||
this.media_file_token = media_file_token;
|
||||
|
||||
this.position = new THREE.Vector3(0.0, 0.0, 0.0);
|
||||
this.rotation = new THREE.Euler(0.0, 0.0, 0.0);
|
||||
this.scale = new THREE.Vector3(1.0, 1.0, 1.0);
|
||||
|
||||
this.object_name = "";
|
||||
this.object_user_data_name = "";
|
||||
this.object_uuid = "";
|
||||
|
||||
this.color = "";
|
||||
|
||||
this.metalness = 0.0;
|
||||
this.shininess = 0.0;
|
||||
this.specular = 0.5;
|
||||
this.locked = false;
|
||||
this.visible = true;
|
||||
this.rigData = undefined;
|
||||
this.userData = {};
|
||||
this.userData.media_file_type = MediaFileType.None;
|
||||
}
|
||||
|
||||
getColorAsHexString(object: THREE.Object3D): string {
|
||||
if (
|
||||
object instanceof THREE.Mesh &&
|
||||
object.material instanceof THREE.MeshBasicMaterial
|
||||
) {
|
||||
return "#" + object.material.color.getHexString();
|
||||
}
|
||||
return "#FFFFFF";
|
||||
}
|
||||
|
||||
public async initialize(object: THREE.Object3D) {
|
||||
this.position = object.position;
|
||||
this.rotation = object.rotation;
|
||||
this.scale = object.scale;
|
||||
|
||||
this.object_name = object.name;
|
||||
this.object_user_data_name = object.userData.name;
|
||||
this.object_uuid = object.uuid;
|
||||
this.color = object.userData["color"];
|
||||
this.metalness = object.userData["metalness"];
|
||||
this.shininess = object.userData["shininess"];
|
||||
this.specular = object.userData["specular"];
|
||||
this.locked = object.userData["locked"];
|
||||
}
|
||||
|
||||
public toJSON(): ObjectJSON {
|
||||
const json: ObjectJSON = {
|
||||
version: this.version,
|
||||
position: {
|
||||
x: this.position.x,
|
||||
y: this.position.y,
|
||||
z: this.position.z,
|
||||
},
|
||||
rotation: {
|
||||
x: this.rotation.x,
|
||||
y: this.rotation.y,
|
||||
z: this.rotation.z,
|
||||
},
|
||||
scale: {
|
||||
x: this.scale.x,
|
||||
y: this.scale.y,
|
||||
z: this.scale.z,
|
||||
},
|
||||
object_name: this.object_name,
|
||||
object_uuid: this.object_uuid,
|
||||
object_user_data_name: this.object_user_data_name,
|
||||
media_file_token: this.media_file_token,
|
||||
color: this.color,
|
||||
metalness: this.metalness,
|
||||
shininess: this.shininess,
|
||||
specular: this.specular,
|
||||
locked: this.locked,
|
||||
visible: this.visible,
|
||||
user_data: this.userData,
|
||||
rigData: this.rigData,
|
||||
};
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import {
|
||||
StoryTellerProxy3DObject,
|
||||
ObjectJSON,
|
||||
} from "./storyteller_proxy_3d_object";
|
||||
import Scene from "../engine/scene";
|
||||
import { BoneJSONHelper } from "../engine/KinHelpers/BoneJSONHelper";
|
||||
|
||||
interface LookUpDictionary {
|
||||
[key: string]: StoryTellerProxy3DObject;
|
||||
}
|
||||
|
||||
export class StoryTellerProxyScene {
|
||||
sceneItemProxy: StoryTellerProxy3DObject[];
|
||||
scene: Scene;
|
||||
glbLoader: GLTFLoader;
|
||||
|
||||
lookUpDictionary: LookUpDictionary;
|
||||
version: number;
|
||||
|
||||
constructor(version: number, scene: Scene) {
|
||||
this.version = version;
|
||||
this.scene = scene;
|
||||
this.glbLoader = new GLTFLoader();
|
||||
this.lookUpDictionary = {};
|
||||
this.sceneItemProxy = [];
|
||||
}
|
||||
|
||||
getChildren(child: THREE.Object3D) {
|
||||
if (this.lookUpDictionary[child.uuid] == null) {
|
||||
this.lookUpDictionary[child.uuid] = new StoryTellerProxy3DObject(
|
||||
this.version,
|
||||
child.userData["media_id"],
|
||||
);
|
||||
}
|
||||
|
||||
const proxyObject3D: StoryTellerProxy3DObject =
|
||||
this.lookUpDictionary[child.uuid];
|
||||
proxyObject3D.position.copy(child.position);
|
||||
proxyObject3D.rotation.copy(child.rotation);
|
||||
proxyObject3D.scale.copy(child.scale);
|
||||
proxyObject3D.object_user_data_name = child.userData.name;
|
||||
proxyObject3D.object_name = child.name;
|
||||
proxyObject3D.object_uuid = child.uuid;
|
||||
proxyObject3D.color = child.userData["color"];
|
||||
proxyObject3D.metalness = child.userData["metalness"];
|
||||
proxyObject3D.shininess = child.userData["shininess"];
|
||||
proxyObject3D.specular = child.userData["specular"];
|
||||
proxyObject3D.locked = child.userData["locked"];
|
||||
proxyObject3D.visible = child.visible;
|
||||
proxyObject3D.userData = child.userData;
|
||||
// Add rig data if present
|
||||
const boneHelper = new BoneJSONHelper(child);
|
||||
proxyObject3D.rigData = boneHelper.toJSON();
|
||||
|
||||
const json_data = proxyObject3D.toJSON();
|
||||
return json_data;
|
||||
}
|
||||
|
||||
public saveToSceneOlder(): ObjectJSON[] {
|
||||
const results: ObjectJSON[] = [];
|
||||
if (this.scene.scene != null) {
|
||||
for (const child of this.scene.scene.children) {
|
||||
if (child.userData["media_id"] != undefined) {
|
||||
if (this.lookUpDictionary[child.uuid] == null) {
|
||||
this.lookUpDictionary[child.uuid] = new StoryTellerProxy3DObject(
|
||||
this.version,
|
||||
child.userData["media_id"],
|
||||
);
|
||||
}
|
||||
const proxyObject3D: StoryTellerProxy3DObject =
|
||||
this.lookUpDictionary[child.uuid];
|
||||
proxyObject3D.position.copy(child.position);
|
||||
proxyObject3D.rotation.copy(child.rotation);
|
||||
proxyObject3D.scale.copy(child.scale);
|
||||
proxyObject3D.object_user_data_name = child.userData.name;
|
||||
proxyObject3D.object_name = child.name;
|
||||
proxyObject3D.object_uuid = child.uuid;
|
||||
proxyObject3D.color = child.userData["color"];
|
||||
proxyObject3D.metalness = child.userData["metalness"];
|
||||
proxyObject3D.shininess = child.userData["shininess"];
|
||||
proxyObject3D.specular = child.userData["specular"];
|
||||
proxyObject3D.locked = child.userData["locked"];
|
||||
const json_data = proxyObject3D.toJSON();
|
||||
results.push(json_data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Scene doesn't exist needs to be assigned");
|
||||
}
|
||||
console.log(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
public saveToScene(version: number): ObjectJSON[] {
|
||||
this.version = version;
|
||||
console.log("Saving with version:", this.version);
|
||||
const results: ObjectJSON[] = [];
|
||||
if (this.scene.scene != null) {
|
||||
for (const pchild of this.scene.scene.children) {
|
||||
if (this.version >= 1.0) {
|
||||
if (pchild.userData["media_id"] != undefined) {
|
||||
console.debug("Object JSON:", pchild.toJSON());
|
||||
results.push(this.getChildren(pchild));
|
||||
}
|
||||
} else {
|
||||
console.log("Saving older.");
|
||||
return this.saveToSceneOlder();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Scene doesn't exist needs to be assigned");
|
||||
}
|
||||
console.debug("Scene saved.", results);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async loadFromSceneJson(
|
||||
scene_json: ObjectJSON[],
|
||||
skybox_media_id: string,
|
||||
version: number,
|
||||
) {
|
||||
console.log(scene_json);
|
||||
if (scene_json != null && this.scene != null) {
|
||||
while (this.scene.scene.children.length > 0) {
|
||||
this.scene.scene.remove(this.scene.scene.children[0]);
|
||||
}
|
||||
for (const json_object of scene_json) {
|
||||
const token: string = json_object.media_file_token;
|
||||
let obj;
|
||||
switch (token) {
|
||||
case "Parim": {
|
||||
const newScene = await this.scene.instantiate(
|
||||
json_object.object_name,
|
||||
);
|
||||
const prim_uuid = newScene.uuid;
|
||||
obj = this.scene.get_object_by_uuid(prim_uuid);
|
||||
break;
|
||||
}
|
||||
case "DirectionalLight": {
|
||||
obj = this.scene._create_base_lighting();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (token.includes("m_")) {
|
||||
obj = await this.scene.loadObject(
|
||||
token,
|
||||
json_object.object_name,
|
||||
true,
|
||||
new THREE.Vector3(-0.5, 1.5, 0),
|
||||
version,
|
||||
);
|
||||
// Add any missing userdata back to the object
|
||||
obj.userData = json_object.user_data;
|
||||
|
||||
// Add rig data if present
|
||||
if (json_object.rigData) {
|
||||
const boneHelper = new BoneJSONHelper(obj);
|
||||
boneHelper.poseFromBoneJSON(json_object.rigData);
|
||||
} else {
|
||||
console.log("No rig data found");
|
||||
}
|
||||
} else if (token.includes("Point::")) {
|
||||
const keyframe_uuid = token.replace("Point::", "");
|
||||
obj = this.scene.createPoint(
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
keyframe_uuid,
|
||||
);
|
||||
} else if (token.includes("Image::")) {
|
||||
const prim_uuid = (await this.scene.instantiate(token)).uuid;
|
||||
obj = this.scene.get_object_by_uuid(prim_uuid);
|
||||
|
||||
if (obj) {
|
||||
obj.name = json_object.object_name;
|
||||
obj.userData["name"] = json_object.object_name;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (obj) {
|
||||
obj.position.copy(json_object.position);
|
||||
obj.rotation.copy(
|
||||
new THREE.Euler(
|
||||
json_object.rotation.x,
|
||||
json_object.rotation.y,
|
||||
json_object.rotation.z,
|
||||
),
|
||||
);
|
||||
obj.scale.copy(json_object.scale);
|
||||
obj.name = json_object.object_name;
|
||||
obj.userData.name = json_object.object_user_data_name;
|
||||
obj.uuid = json_object.object_uuid;
|
||||
obj.userData["media_id"] = json_object.media_file_token;
|
||||
obj.userData["locked"] = json_object.locked;
|
||||
obj.userData["color"] = json_object.color;
|
||||
obj.userData["metalness"] = json_object.metalness;
|
||||
obj.userData["shininess"] = json_object.shininess;
|
||||
obj.userData["specular"] = json_object.specular;
|
||||
obj.userData["media_file_type"] = json_object.media_file_type;
|
||||
//console.log(obj)
|
||||
|
||||
if (json_object.visible !== undefined) {
|
||||
this.scene.setVisible(obj.uuid, json_object.visible);
|
||||
}
|
||||
this.scene.setColor(obj.uuid, json_object.color);
|
||||
}
|
||||
}
|
||||
this.scene._createGrid();
|
||||
this.scene.updateSkybox(skybox_media_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// PageSceneAdapter implementation backed by the artcraft host's Tauri
|
||||
// + HTTP plumbing. Built once per <PageScene> mount via useMemo so the
|
||||
// reference is stable across re-renders (matters because the lib's
|
||||
// EngineProvider holds the adapter in a ref keyed at construction time).
|
||||
//
|
||||
// Replaces the previous engine/api_manager.ts + engine/api_fetchers.ts
|
||||
// surface — those files are gone; their bodies live here as the
|
||||
// host-side implementation of the adapter contract.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
GenerateImage,
|
||||
GenerateImageRequest,
|
||||
LoadWithoutCors,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { FetchProxy } from "@storyteller/tauri-utils";
|
||||
import {
|
||||
MediaFilesApi,
|
||||
StorytellerApiHostStore,
|
||||
UploadImageMedia,
|
||||
} from "@storyteller/api";
|
||||
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
|
||||
import { GetCdnOrigin } from "~/api/GetCdnOrigin";
|
||||
import { authentication, signalScene } from "~/signals";
|
||||
|
||||
const apiHost = () =>
|
||||
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
|
||||
// — Scene file save/load (replaces api_fetchers.ts) ────────────────────
|
||||
|
||||
const uploadNewScene = async (file: File, sceneTitle: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuidv4());
|
||||
formData.append("file", file);
|
||||
formData.append("maybe_title", sceneTitle);
|
||||
formData.append("maybe_visibility", "public");
|
||||
formData.append("engine_category", "scene");
|
||||
|
||||
return FetchProxy(`${apiHost()}/v1/media_files/upload/new_scene`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => (res?.success ? res : { success: false }))
|
||||
.catch(() => ({ success: false }));
|
||||
};
|
||||
|
||||
const updateExistingScene = async (file: File, sceneToken: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuidv4());
|
||||
formData.append("file", file);
|
||||
|
||||
return FetchProxy(
|
||||
`${apiHost()}/v1/media_files/upload/saved_scene/${sceneToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => (res?.success ? res : { success: false }))
|
||||
.catch(() => ({ success: false }));
|
||||
};
|
||||
|
||||
const uploadSceneCoverImage = async (blob: Blob, fileName: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("uuid_idempotency_token", uuidv4());
|
||||
formData.append("is_intermediate_system_file", "true");
|
||||
formData.append("maybe_title", "Screenshot");
|
||||
formData.append("file", blob, fileName);
|
||||
|
||||
const response = await fetch(`${apiHost()}/v1/media_files/upload/image`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) throw new Error("Upload Media Failed to send file");
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const setSceneCoverImage = async (
|
||||
sceneToken: string,
|
||||
coverImageToken: string,
|
||||
) => {
|
||||
await fetch(`${apiHost()}/v1/media_files/cover_image/${sceneToken}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ cover_image_media_file_token: coverImageToken }),
|
||||
});
|
||||
};
|
||||
|
||||
// — Scene load (replaces api_manager.loadSceneState body) ──────────────
|
||||
|
||||
const loadSceneState = async (
|
||||
scene_media_file_token: string,
|
||||
): Promise<unknown> => {
|
||||
const url = `${apiHost()}/v1/media_files/file/${scene_media_file_token}`;
|
||||
const response = await fetch(url);
|
||||
if (response.status > 200) throw new Error("Failed to load scene");
|
||||
|
||||
const json = await response.json();
|
||||
if (json?.media_file) {
|
||||
if (json.media_file.maybe_title === null) {
|
||||
console.warn(`Scene /w Token: ${scene_media_file_token} has no title`);
|
||||
}
|
||||
signalScene({
|
||||
title: json.media_file.maybe_title || "Untitled Scene",
|
||||
token: scene_media_file_token || undefined,
|
||||
ownerToken: json.media_file.maybe_creator_user.user_token,
|
||||
isModified: false,
|
||||
});
|
||||
}
|
||||
const bucket_path = json["media_file"]["public_bucket_path"];
|
||||
const media_url = `${GetCdnOrigin()}${bucket_path}`;
|
||||
|
||||
const file_response = await fetch(media_url);
|
||||
if (!file_response.ok) throw new Error("Failed to download file");
|
||||
|
||||
const blob = await file_response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(JSON.parse(reader.result as string));
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
};
|
||||
|
||||
// — Hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type TauriPageSceneAdapterOptions = {
|
||||
initialSceneToken?: string;
|
||||
cacheJsonString?: string;
|
||||
onSceneSerialized?: (json: string) => void;
|
||||
};
|
||||
|
||||
export const useTauriPageSceneAdapter = (
|
||||
options: TauriPageSceneAdapterOptions = {},
|
||||
): PageSceneAdapter => {
|
||||
const { initialSceneToken, cacheJsonString, onSceneSerialized } = options;
|
||||
return useMemo<PageSceneAdapter>(
|
||||
() => ({
|
||||
enqueueGeneration: async (req) => {
|
||||
const request: GenerateImageRequest = {
|
||||
model: req.model,
|
||||
provider: req.provider,
|
||||
scene_image_media_token: req.sceneImageMediaToken,
|
||||
image_media_tokens: req.imageMediaTokens,
|
||||
prompt: req.prompt,
|
||||
batch_size: req.imageCount,
|
||||
aspect_ratio: req.aspectRatio,
|
||||
resolution: req.resolution,
|
||||
frontend_caller: req.frontendCaller,
|
||||
frontend_subscriber_id: req.frontendSubscriberId,
|
||||
};
|
||||
return GenerateImage(request);
|
||||
},
|
||||
|
||||
uploadAsset: UploadImageMedia,
|
||||
|
||||
saveScene: async ({
|
||||
saveJson,
|
||||
sceneTitle,
|
||||
sceneToken,
|
||||
sceneThumbnail,
|
||||
}) => {
|
||||
const file = new File([saveJson], `${sceneTitle}.glb`, {
|
||||
type: "application/json",
|
||||
});
|
||||
const uploadSceneResponse = sceneToken
|
||||
? await updateExistingScene(file, sceneToken)
|
||||
: await uploadNewScene(file, sceneTitle);
|
||||
|
||||
if (uploadSceneResponse["success"] === false) return "";
|
||||
|
||||
if (sceneThumbnail) {
|
||||
const image_resp = await uploadSceneCoverImage(
|
||||
sceneThumbnail,
|
||||
"render.png",
|
||||
);
|
||||
if (image_resp["success"] === false) return "";
|
||||
if (image_resp["media_file_token"]) {
|
||||
await setSceneCoverImage(
|
||||
uploadSceneResponse["media_file_token"],
|
||||
image_resp["media_file_token"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return uploadSceneResponse["media_file_token"];
|
||||
},
|
||||
|
||||
loadScene: loadSceneState,
|
||||
|
||||
uploadSceneCoverImage: async (blob: Blob, fileName: string) =>
|
||||
uploadSceneCoverImage(blob, fileName),
|
||||
|
||||
setSceneCoverImage,
|
||||
|
||||
fetchAsset: async (url: string) => {
|
||||
// FetchProxy returns a Response; LoadWithoutCors returns
|
||||
// ArrayBuffer. Scene's load paths consume `.arrayBuffer()` from
|
||||
// the Response, so wrap LoadWithoutCors's result if FetchProxy
|
||||
// doesn't fit the URL (CDN media often needs the no-CORS path).
|
||||
try {
|
||||
return await FetchProxy(url, {});
|
||||
} catch {
|
||||
const buffer = await LoadWithoutCors(url);
|
||||
return new Response(buffer);
|
||||
}
|
||||
},
|
||||
|
||||
getCdnOrigin: () => GetCdnOrigin(),
|
||||
getApiSchemeAndHost: apiHost,
|
||||
getCurrentUserToken: () =>
|
||||
authentication.userInfo.value?.user_token,
|
||||
|
||||
getMediaUrlByToken: async (token) => {
|
||||
const mediaFilesApi = new MediaFilesApi();
|
||||
const response = await mediaFilesApi.GetMediaFileByToken({
|
||||
mediaFileToken: token,
|
||||
});
|
||||
return response.data!.media_links.cdn_url;
|
||||
},
|
||||
|
||||
onSceneTitleChange: (meta) => signalScene(meta as never),
|
||||
|
||||
// Slot renderers — host-owned UI for asset browser + scene
|
||||
// loader. Today these are still rendered inside PageEditor's own
|
||||
// asset modal; once those move into the lib, the lib will call
|
||||
// these renderers from inside its own AssetMenu container.
|
||||
renderAssetBrowser: () => null,
|
||||
renderSceneLoader: () => null,
|
||||
|
||||
initialSceneToken,
|
||||
cacheJsonString,
|
||||
onSceneSerialized,
|
||||
}),
|
||||
[initialSceneToken, cacheJsonString, onSceneSerialized],
|
||||
);
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
|
||||
{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.es2024.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.es2023.intl.d.ts","../../node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","../../node_modules/typescript/lib/lib.es2024.collection.d.ts","../../node_modules/typescript/lib/lib.es2024.object.d.ts","../../node_modules/typescript/lib/lib.es2024.promise.d.ts","../../node_modules/typescript/lib/lib.es2024.regexp.d.ts","../../node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2024.string.d.ts","../../node_modules/typescript/lib/lib.esnext.array.d.ts","../../node_modules/typescript/lib/lib.esnext.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.promise.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.esnext.iterator.d.ts","../../node_modules/typescript/lib/lib.esnext.float16.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/@remix-run/node/dist/globals.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@remix-run/server-runtime/dist/crypto.d.ts","../../node_modules/@remix-run/server-runtime/dist/cookies.d.ts","../../node_modules/@remix-run/server-runtime/dLine truncated
|
||||
@@ -39,8 +39,75 @@ export {
|
||||
// Datastructures
|
||||
export type { XYZ, Simple3DVector } from "./lib/datastructures/common";
|
||||
|
||||
// Models (lib-internal types — only what's needed at the public boundary)
|
||||
// Models
|
||||
export type { MediaItem, AudioMediaItem } from "./lib/models/assets";
|
||||
export type { SceneGenereationMetaData } from "./lib/models/sceneGenerationMetadata";
|
||||
|
||||
// Engine — main entry point for hosts. Editor takes a PageSceneAdapter
|
||||
// at construction; everything platform-specific (HTTP, signals, auth)
|
||||
// flows through that single surface.
|
||||
export { default as Editor } from "./lib/engine/editor";
|
||||
export type { EditorInitializeConfig } from "./lib/engine/editor";
|
||||
export { default as Scene } from "./lib/engine/scene";
|
||||
export type { SceneDeps } from "./lib/engine/scene";
|
||||
export { SceneUtils } from "./lib/engine/helper";
|
||||
export type { SceneUtilsDeps } from "./lib/engine/helper";
|
||||
export { SaveManager } from "./lib/engine/save_manager";
|
||||
export type { SaveManagerDeps, SaveSceneStateArgs } from "./lib/engine/save_manager";
|
||||
export { SceneManager } from "./lib/engine/scene_manager_api";
|
||||
export { MouseControls } from "./lib/engine/keybinds_controls";
|
||||
export type { MouseControlsDeps } from "./lib/engine/keybinds_controls";
|
||||
export { buildKeymap, dispatchBinding } from "./lib/engine/keymap";
|
||||
export type { KeyBinding, KeyGroup } from "./lib/engine/keymap";
|
||||
|
||||
// Engine subsystems
|
||||
export { HistoryManager } from "./lib/engine/editor/HistoryManager";
|
||||
export type { UndoableAction, HistoryManagerOptions } from "./lib/engine/editor/HistoryManager";
|
||||
export { ViewportController } from "./lib/engine/editor/ViewportController";
|
||||
export type { ViewportEngineRefs } from "./lib/engine/editor/ViewportController";
|
||||
export { PostProcessingPipeline } from "./lib/engine/editor/PostProcessingPipeline";
|
||||
export { GizmoController } from "./lib/engine/editor/GizmoController";
|
||||
export type { GizmoControllerDeps, GizmoCallbacks } from "./lib/engine/editor/GizmoController";
|
||||
export { CameraController } from "./lib/engine/editor/CameraController";
|
||||
export type { CameraControllerDeps, RenderDimensions } from "./lib/engine/editor/CameraController";
|
||||
export { SelectionBridge } from "./lib/engine/editor/SelectionBridge";
|
||||
export type { SelectionBridgeDeps } from "./lib/engine/editor/SelectionBridge";
|
||||
|
||||
// Action classes (UndoableAction implementations) — exported so
|
||||
// host-side action dispatchers can construct them.
|
||||
export { ColorAction } from "./lib/engine/editor/actions/ColorAction";
|
||||
export { CreateAction } from "./lib/engine/editor/actions/CreateAction";
|
||||
export { DeleteAction } from "./lib/engine/editor/actions/DeleteAction";
|
||||
export { LockAction } from "./lib/engine/editor/actions/LockAction";
|
||||
export { TransformAction } from "./lib/engine/editor/actions/TransformAction";
|
||||
export { VisibilityAction } from "./lib/engine/editor/actions/VisibilityAction";
|
||||
|
||||
// Engine utilities exported for host wrappers + hooks.
|
||||
export { pickDropPosition } from "./lib/engine/pickDropPosition";
|
||||
export {
|
||||
freeCamFrameTick,
|
||||
lookAtFromCamera,
|
||||
createFreeCamControlState,
|
||||
emptyMoveKeys,
|
||||
emptyRotateKeys,
|
||||
moveSlotForKeyCode,
|
||||
rotateSlotForKeyCode,
|
||||
panFromDrag,
|
||||
zoomFromWheel,
|
||||
moveVectorFromKeys,
|
||||
rotationVectorFromKeys,
|
||||
lerpVelocity,
|
||||
} from "./lib/engine/cameraMath";
|
||||
export type {
|
||||
FreeCamControlState,
|
||||
HeldMoveKeys,
|
||||
HeldRotateKeys,
|
||||
} from "./lib/engine/cameraMath";
|
||||
export {
|
||||
ndcFromClient,
|
||||
applyNdcToVector2,
|
||||
} from "./lib/engine/pointer";
|
||||
export { isPointerLockSupported } from "./lib/engine/browserChecks";
|
||||
|
||||
// Engine event bus + event classes (host wrappers can emit
|
||||
// GridVisibleChangedEvent etc. through editor.bus to keep the
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// Pure helpers for translating browser pointer events into the
|
||||
// normalized device coordinates the renderer expects. No DOM access
|
||||
// beyond the rect we get handed; no listener attachment.
|
||||
|
||||
// Convert a clientX/clientY to NDC (normalized device coords:
|
||||
// [-1, 1] in both axes, origin at center, y up).
|
||||
export const ndcFromClient = (
|
||||
rect: DOMRect,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number } => ({
|
||||
x: ((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
});
|
||||
|
||||
// Apply NDC to a Three.js Vector2 (the engine stores its current
|
||||
// pointer as one). Mutates in place so the engine's existing
|
||||
// references don't need to change.
|
||||
export const applyNdcToVector2 = (
|
||||
vec: THREE.Vector2,
|
||||
rect: DOMRect,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): void => {
|
||||
const { x, y } = ndcFromClient(rect, clientX, clientY);
|
||||
vec.x = x;
|
||||
vec.y = y;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/tslib/tslib.d.ts","./node_modules/tslib/modules/index.d.ts","./vitest.workspace.ts","./node_modules/@remix-run/node/dist/globals.d.ts","./node_modules/@types/cookie/index.d.ts","./node_modules/@remix-run/server-runtime/dist/crypto.d.ts","./node_modules/@remix-run/server-runtime/dist/cookies.d.ts","./node_modules/@remix-run/server-runtime/dist/formdata.d.ts","./node_modules/@remix-run/router/dist/history.d.ts","./node_modules/@remix-run/router/dist/utils.d.ts","./node_modules/@remix-run/router/dist/router.d.ts","./node_modules/@remix-run/router/dist/index.d.ts","./node_modules/@remix-run/server-runtime/dist/mode.d.ts","./node_modules/@remix-run/server-runtime/dist/responses.d.ts","./node_modules/@remix-run/server-runtime/dist/data.d.ts","./node_modules/@remix-run/server-runtime/dist/links.d.ts","./node_modules/@remix-run/server-runtime/dist/jsonify.d.ts","./node_modules/@remix-run/server-runtime/dist/future.d.ts","./node_modules/@remix-run/server-runtime/dist/serialize.d.ts","./node_modules/@remix-run/server-runtime/dist/routemodules.d.ts","./node_modules/@remix-run/server-runtime/dist/errors.d.ts","./node_modules/@remix-run/server-runtime/dist/routes.d.ts","./node_modules/@remix-run/server-runtime/dist/entry.d.ts","./node_modules/@remix-run/server-runtime/dist/build.d.ts","./node_modules/@remix-run/server-runtime/dist/single-fetch.d.ts","./node_modules/@remix-run/server-runtime/dist/server.d.ts","./node_modules/@remix-run/server-runtime/dist/sessions.d.ts","./node_modules/@remix-run/server-runtime/dist/sessions/cookiestorage.d.ts","./node_modules/@remix-run/server-runtime/dist/sessions/memorystorage.d.ts","./node_modules/@remix-run/server-runtime/dist/upload/memoryuploadhandler.d.ts","./node_modules/@remix-run/server-runtime/dist/upload/errors.d.ts","./node_modules/@reLine truncated
|
||||
Reference in new issue
Block a user