babylon.quarks-editor 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/core/assetDragSession.d.ts +4 -0
- package/dist/core/assetDragSession.js +8 -0
- package/dist/core/assetTree.d.ts +39 -0
- package/dist/core/assetTree.js +112 -0
- package/dist/core/assets.d.ts +56 -0
- package/dist/core/assets.js +207 -0
- package/dist/core/binding.d.ts +5 -0
- package/dist/core/binding.js +7 -0
- package/dist/core/collision.d.ts +4 -5
- package/dist/core/collision.js +4 -5
- package/dist/core/defaultEffect.d.ts +4 -5
- package/dist/core/defaultEffect.js +5 -14
- package/dist/core/emitterShapeWireframe.d.ts +1 -0
- package/dist/core/emitterShapeWireframe.js +1 -0
- package/dist/core/galleryPlayback.d.ts +7 -7
- package/dist/core/galleryPlayback.js +2 -2
- package/dist/core/galleryScene.d.ts +8 -13
- package/dist/core/galleryScene.js +14 -42
- package/dist/core/gallerySelectionMarker.d.ts +1 -0
- package/dist/core/gallerySelectionMarker.js +1 -0
- package/dist/core/history.d.ts +12 -1
- package/dist/core/history.js +16 -3
- package/dist/core/indexedDbProjectStore.d.ts +35 -0
- package/dist/core/indexedDbProjectStore.js +299 -0
- package/dist/core/lineShaders.d.ts +2 -0
- package/dist/core/lineShaders.js +7 -0
- package/dist/core/pickAssetFiles.d.ts +6 -0
- package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
- package/dist/core/projectStore.d.ts +61 -0
- package/dist/core/projectStore.js +14 -0
- package/dist/core/sceneEntries.d.ts +35 -0
- package/dist/core/sceneEntries.js +62 -0
- package/dist/core/transport.d.ts +12 -0
- package/dist/core/transport.js +36 -0
- package/dist/core/viewportPicker.d.ts +4 -4
- package/dist/core/viewportPicker.js +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/react/AssetsPanel.d.ts +22 -0
- package/dist/react/AssetsPanel.js +252 -0
- package/dist/react/ChoiceDialog.d.ts +18 -0
- package/dist/react/ChoiceDialog.js +48 -0
- package/dist/react/ContextMenu.d.ts +16 -0
- package/dist/react/ContextMenu.js +70 -0
- package/dist/react/EditorWorkspace.d.ts +21 -0
- package/dist/react/EditorWorkspace.js +63 -0
- package/dist/react/EffectEditor.d.ts +1 -1
- package/dist/react/EffectEditor.js +7 -4
- package/dist/react/EffectEditorHost.d.ts +19 -2
- package/dist/react/EffectEditorHost.js +1385 -607
- package/dist/react/EffectTreeView.d.ts +17 -0
- package/dist/react/EffectTreeView.js +52 -0
- package/dist/react/ObjectModule.d.ts +4 -0
- package/dist/react/ObjectModule.js +7 -10
- package/dist/react/ProjectsPage.d.ts +9 -0
- package/dist/react/ProjectsPage.js +140 -0
- package/dist/react/ScenePanel.d.ts +26 -0
- package/dist/react/ScenePanel.js +134 -0
- package/dist/react/TimelinePanel.d.ts +8 -1
- package/dist/react/TimelinePanel.js +20 -41
- package/dist/react/TransportBar.d.ts +24 -0
- package/dist/react/TransportBar.js +43 -0
- package/dist/react/behaviorModules.js +5 -7
- package/dist/react/index.d.ts +16 -2
- package/dist/react/index.js +8 -1
- package/dist/react/inspectorHints.js +1 -2
- package/package.json +1 -1
- package/src/core/assetDragSession.ts +12 -0
- package/src/core/assetTree.ts +148 -0
- package/src/core/assets.ts +262 -0
- package/src/core/binding.ts +8 -0
- package/src/core/collision.ts +4 -5
- package/src/core/defaultEffect.ts +9 -22
- package/src/core/emitterShapeWireframe.ts +1 -0
- package/src/core/galleryPlayback.ts +9 -9
- package/src/core/galleryScene.ts +15 -59
- package/src/core/gallerySelectionMarker.ts +1 -0
- package/src/core/history.ts +24 -3
- package/src/core/indexedDbProjectStore.ts +357 -0
- package/src/core/lineShaders.ts +7 -0
- package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
- package/src/core/projectStore.ts +81 -0
- package/src/core/sceneEntries.ts +96 -0
- package/src/core/transport.ts +53 -0
- package/src/core/viewportPicker.ts +5 -5
- package/src/index.ts +11 -0
- package/src/react/AssetsPanel.tsx +559 -0
- package/src/react/ChoiceDialog.tsx +93 -0
- package/src/react/ContextMenu.tsx +107 -0
- package/src/react/EditorWorkspace.tsx +95 -0
- package/src/react/EffectEditor.tsx +15 -4
- package/src/react/EffectEditorHost.tsx +1922 -813
- package/src/react/EffectTreeView.tsx +117 -0
- package/src/react/ObjectModule.tsx +17 -13
- package/src/react/ProjectsPage.tsx +306 -0
- package/src/react/ScenePanel.tsx +318 -0
- package/src/react/TimelinePanel.tsx +102 -103
- package/src/react/TransportBar.tsx +114 -0
- package/src/react/behaviorModules.tsx +7 -15
- package/src/react/index.ts +16 -2
- package/src/react/inspectorHints.ts +1 -2
- package/dist/core/galleryDragSession.d.ts +0 -4
- package/dist/core/galleryDragSession.js +0 -8
- package/dist/core/galleryTree.d.ts +0 -49
- package/dist/core/galleryTree.js +0 -141
- package/dist/core/loadEffectGallery.d.ts +0 -36
- package/dist/core/loadEffectGallery.js +0 -97
- package/dist/core/pickGalleryFiles.d.ts +0 -5
- package/dist/react/GalleryPanel.d.ts +0 -20
- package/dist/react/GalleryPanel.js +0 -253
- package/src/core/galleryDragSession.ts +0 -12
- package/src/core/galleryTree.ts +0 -190
- package/src/core/loadEffectGallery.ts +0 -150
- package/src/react/GalleryPanel.tsx +0 -561
|
@@ -6,48 +6,65 @@ import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
|
|
|
6
6
|
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
|
|
7
7
|
import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
|
|
8
8
|
import { Vector3 as BVector3 } from '@babylonjs/core/Maths/math.vector';
|
|
9
|
+
import { Viewport } from '@babylonjs/core/Maths/math.viewport';
|
|
9
10
|
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
|
10
|
-
import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
|
|
11
11
|
import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
|
|
12
12
|
import { Scene } from '@babylonjs/core/scene';
|
|
13
13
|
import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
|
|
14
|
-
import { ArrowLeftIcon, ArrowPathIcon, ArrowsPointingOutIcon, ArrowUturnLeftIcon, ArrowUturnRightIcon, CubeTransparentIcon, CursorArrowRaysIcon, Square2StackIcon, } from '@heroicons/react/24/solid';
|
|
14
|
+
import { ArrowLeftIcon, ArrowPathIcon, ArrowsPointingOutIcon, ArrowUturnLeftIcon, ArrowUturnRightIcon, ChevronDownIcon, ChevronUpIcon, CubeTransparentIcon, CursorArrowRaysIcon, MapPinIcon, Square2StackIcon, XMarkIcon, } from '@heroicons/react/24/solid';
|
|
15
15
|
import { BatchedRenderer, QuarksUtil } from 'babylon.quarks';
|
|
16
|
-
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
16
|
+
import { useCallback, useDeferredValue, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
17
17
|
import { createRoot } from 'react-dom/client';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { getAssetDragSession, setAssetDragSession } from '../core/assetDragSession';
|
|
19
|
+
import { ASSET_DRAG_MIME, describeAssetDrag, readAssetDrag } from '../core/assetTree';
|
|
20
|
+
import { collectJsonFiles, createEffectAsset, loadEffectAssets, mergeAssets, removeAssets, } from '../core/assets';
|
|
21
|
+
import { createSceneDefaultEntry } from '../core/defaultEffect';
|
|
22
|
+
import { serializeEffectTree } from '../core/effectTree';
|
|
21
23
|
import { EmitterShapeWireframes } from '../core/emitterShapeWireframe';
|
|
22
|
-
import { getGalleryDragSession, setGalleryDragSession } from '../core/galleryDragSession';
|
|
23
24
|
import { GalleryDropMarker } from '../core/galleryDropMarker';
|
|
24
25
|
import { cancelGalleryCameraFocus, focusGalleryEntry } from '../core/galleryLayout';
|
|
25
|
-
import { applyGalleryPlayback,
|
|
26
|
-
import {
|
|
26
|
+
import { applyGalleryPlayback, countGalleryParticles, hasActivePreview, pauseFinishedPreviews, } from '../core/galleryPlayback';
|
|
27
|
+
import { pickGroundPosition, placeSceneEntriesAt, setSceneEntryVisible } from '../core/galleryScene';
|
|
27
28
|
import { GallerySelectionMarker } from '../core/gallerySelectionMarker';
|
|
28
|
-
import { describeGalleryDrag, GALLERY_DRAG_MIME, readGalleryDrag } from '../core/galleryTree';
|
|
29
29
|
import { EffectHistory } from '../core/history';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { pickGalleryJsonFiles } from '../core/pickGalleryFiles';
|
|
30
|
+
import { parseEffectFromJson } from '../core/loadEffect';
|
|
31
|
+
import { pickAssetFolderFiles } from '../core/pickAssetFiles';
|
|
33
32
|
import { clearFocusFinished, createPlaybackState, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from '../core/playback';
|
|
34
33
|
import { logRenderMismatch } from '../core/renderDebug';
|
|
34
|
+
import { bindingFromSceneEntry, countInstancesByAsset, disposeSceneEntry, instantiateSceneEntry, } from '../core/sceneEntries';
|
|
35
35
|
import { pauseFocus, scrubFocusToSettled } from '../core/scrub';
|
|
36
36
|
import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
|
|
37
|
-
import { notifyTransformChanged } from '../core/transformStore';
|
|
37
|
+
import { notifyTransformChanged, subscribeTransform } from '../core/transformStore';
|
|
38
|
+
import { toggleStagePlayback } from '../core/transport';
|
|
38
39
|
import { GalleryViewportPicker } from '../core/viewportPicker';
|
|
40
|
+
import { AssetsPanel } from './AssetsPanel';
|
|
41
|
+
import { ChoiceDialog } from './ChoiceDialog';
|
|
39
42
|
import { EffectEditor } from './EffectEditor';
|
|
40
|
-
import {
|
|
43
|
+
import { EffectTreeView } from './EffectTreeView';
|
|
41
44
|
import { MessageDialog } from './MessageDialog';
|
|
42
45
|
import { PromptDialog } from './PromptDialog';
|
|
46
|
+
import { ScenePanel } from './ScenePanel';
|
|
43
47
|
import { TimelinePanel } from './TimelinePanel';
|
|
48
|
+
import { TransportBar } from './TransportBar';
|
|
44
49
|
import { iconStyle } from './icons';
|
|
45
50
|
import { buttonStyle, globalEditorStyles, theme } from './theme';
|
|
46
|
-
|
|
47
|
-
function
|
|
48
|
-
|
|
51
|
+
const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
|
|
52
|
+
function toStoredAsset(asset) {
|
|
53
|
+
return { path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles };
|
|
49
54
|
}
|
|
50
|
-
|
|
55
|
+
/** `Folder/Name` typed by the user → a clean asset path ending in `.json`. */
|
|
56
|
+
function normalizeAssetPath(input) {
|
|
57
|
+
const segments = input
|
|
58
|
+
.replace(/\\/g, '/')
|
|
59
|
+
.split('/')
|
|
60
|
+
.map((part) => part.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const path = segments.join('/') || 'Effect';
|
|
63
|
+
return /\.json$/i.test(path) ? path : `${path}.json`;
|
|
64
|
+
}
|
|
65
|
+
const SCENE_PANEL_WIDTH = 280;
|
|
66
|
+
const TIMELINE_HEIGHT = 200;
|
|
67
|
+
const DRAWER_HEIGHT = 280;
|
|
51
68
|
const GIZMO_MODE_BUTTONS = [
|
|
52
69
|
{ value: 'none', Icon: CursorArrowRaysIcon, title: 'Select (hide stage gizmo)' },
|
|
53
70
|
{ value: 'position', Icon: ArrowsPointingOutIcon, title: 'Move the stage node (position)' },
|
|
@@ -68,8 +85,53 @@ const activeSquareButtonStyle = {
|
|
|
68
85
|
border: `1px solid ${theme.borderActive}`,
|
|
69
86
|
background: 'rgba(120, 165, 255, 0.35)',
|
|
70
87
|
};
|
|
88
|
+
const dockTitleStyle = {
|
|
89
|
+
fontSize: 11,
|
|
90
|
+
fontWeight: 600,
|
|
91
|
+
letterSpacing: 0.4,
|
|
92
|
+
textTransform: 'uppercase',
|
|
93
|
+
color: theme.text,
|
|
94
|
+
};
|
|
95
|
+
const dockIconButtonStyle = {
|
|
96
|
+
...buttonStyle,
|
|
97
|
+
width: 24,
|
|
98
|
+
height: 22,
|
|
99
|
+
padding: 0,
|
|
100
|
+
display: 'inline-flex',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
justifyContent: 'center',
|
|
103
|
+
};
|
|
104
|
+
function isTextInput(target) {
|
|
105
|
+
const el = target;
|
|
106
|
+
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
|
107
|
+
}
|
|
71
108
|
/**
|
|
72
|
-
*
|
|
109
|
+
* Inspector value controls (numbers, checkboxes, selects): Ctrl+Z there means the effect's undo,
|
|
110
|
+
* not the browser's per-field text undo. Free-text inputs (names, filters) keep the native one.
|
|
111
|
+
*/
|
|
112
|
+
function isInspectorValueControl(target) {
|
|
113
|
+
const el = target;
|
|
114
|
+
if (!el)
|
|
115
|
+
return false;
|
|
116
|
+
if (el.tagName === 'SELECT')
|
|
117
|
+
return true;
|
|
118
|
+
return el.tagName === 'INPUT' && ['number', 'checkbox', 'range', 'color'].includes(el.type);
|
|
119
|
+
}
|
|
120
|
+
/** Position of `node` in its effect tree (0 = root), to find the same node after a rebuild. */
|
|
121
|
+
function nodeIndexInEffect(root, node) {
|
|
122
|
+
if (!node || node === root)
|
|
123
|
+
return 0;
|
|
124
|
+
return root.getDescendants(false).indexOf(node) + 1;
|
|
125
|
+
}
|
|
126
|
+
function nodeAtIndex(root, index) {
|
|
127
|
+
if (index <= 0)
|
|
128
|
+
return root;
|
|
129
|
+
return root.getDescendants(false)[index - 1] ?? root;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Self-contained effect editor: live Babylon preview, a Scene list of the effects placed in it,
|
|
133
|
+
* an inspector, and a bottom dock with the timeline and the Assets browser. Assets are effect
|
|
134
|
+
* files; dragging one into the viewport instantiates an independent copy, as many times as needed.
|
|
73
135
|
* Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
|
|
74
136
|
*/
|
|
75
137
|
export function EffectEditorHost(props) {
|
|
@@ -77,19 +139,31 @@ export function EffectEditorHost(props) {
|
|
|
77
139
|
const counterRef = useRef(null);
|
|
78
140
|
const fpsRef = useRef(null);
|
|
79
141
|
const playheadRef = useRef(null);
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
142
|
+
const assetFolderInputRef = useRef(null);
|
|
143
|
+
const actionsRef = useRef({
|
|
144
|
+
importAssets: async (_files) => { },
|
|
145
|
+
focusEntry: (_entry) => { },
|
|
146
|
+
toggleVisible: (_entry) => { },
|
|
147
|
+
toggleSolo: (_entry) => { },
|
|
148
|
+
deleteEntry: (_entry) => { },
|
|
149
|
+
createDefault: () => { },
|
|
150
|
+
placeDrop: (_clientX, _clientY, _dataTransfer) => { },
|
|
151
|
+
addToScene: (_payload) => { },
|
|
152
|
+
deleteAssets: (_payload) => { },
|
|
153
|
+
saveEntryAsAsset: (_entry, _path) => { },
|
|
154
|
+
saveScene: async () => false,
|
|
155
|
+
openPrefab: (_assetId, _discardChanges) => { },
|
|
156
|
+
overwritePrefab: () => { },
|
|
157
|
+
savePrefabAsNew: (_path) => { },
|
|
158
|
+
closePrefab: () => { },
|
|
159
|
+
prefabInstanceCount: () => 0,
|
|
160
|
+
historyStep: (_redo) => { },
|
|
161
|
+
});
|
|
88
162
|
const dropMarkerRef = useRef(null);
|
|
89
163
|
const selectionMarkerRef = useRef(null);
|
|
90
164
|
const emitterWireframesRef = useRef(null);
|
|
91
165
|
const viewportPickerRef = useRef(null);
|
|
92
|
-
const
|
|
166
|
+
const assetDragDepthRef = useRef(0);
|
|
93
167
|
const stateRef = useRef(null);
|
|
94
168
|
const gizmoManagerRef = useRef(null);
|
|
95
169
|
const [binding, setBinding] = useState(null);
|
|
@@ -101,68 +175,99 @@ export function EffectEditorHost(props) {
|
|
|
101
175
|
const [renamingRoot, setRenamingRoot] = useState(false);
|
|
102
176
|
const [gizmoMode, setGizmoMode] = useState('none');
|
|
103
177
|
const [selectedNode, setSelectedNode] = useState(null);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const [
|
|
107
|
-
const [
|
|
178
|
+
const selectedNodeRef = useRef(null);
|
|
179
|
+
selectedNodeRef.current = selectedNode;
|
|
180
|
+
const [sceneEntries, setSceneEntries] = useState([]);
|
|
181
|
+
const [assets, setAssets] = useState([]);
|
|
182
|
+
const [assetsLoading, setAssetsLoading] = useState(null);
|
|
183
|
+
const [focusedRoot, setFocusedRoot] = useState(null);
|
|
184
|
+
const [visibleRootIds, setVisibleRootIds] = useState([]);
|
|
108
185
|
const [messageDialog, setMessageDialog] = useState(null);
|
|
109
|
-
const [
|
|
186
|
+
const [assetDragHover, setAssetDragHover] = useState(null);
|
|
110
187
|
const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
|
|
111
|
-
const
|
|
188
|
+
const [timelineCollapsed, setTimelineCollapsed] = useState(false);
|
|
189
|
+
const [soloRootId, setSoloRootId] = useState(null);
|
|
190
|
+
// Assets drawer: floats over the viewport until closed (X, Esc, Ctrl+Space or the Assets
|
|
191
|
+
// button); pinned, it is docked above the timeline instead.
|
|
192
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
193
|
+
const [drawerPinned, setDrawerPinned] = useState(false);
|
|
194
|
+
const drawerPinnedRef = useRef(false);
|
|
195
|
+
drawerPinnedRef.current = drawerPinned;
|
|
196
|
+
const timelineCounterRef = useRef(null);
|
|
197
|
+
const mainAreaRef = useRef(null);
|
|
198
|
+
/** Pixels at the bottom of the viewport covered by the floating Assets drawer. */
|
|
199
|
+
const [viewportInset, setViewportInset] = useState(0);
|
|
200
|
+
const [sceneDirty, setSceneDirty] = useState(false);
|
|
201
|
+
const [projectLoading, setProjectLoading] = useState(Boolean(props.project));
|
|
202
|
+
const [toast, setToast] = useState(null);
|
|
203
|
+
const [saveAsTarget, setSaveAsTarget] = useState(null);
|
|
204
|
+
const [exitPrompt, setExitPrompt] = useState(false);
|
|
205
|
+
// Prefab mode: one asset opened on its own, the scene set aside until "← Scene".
|
|
206
|
+
const [prefabInfo, setPrefabInfo] = useState(null);
|
|
207
|
+
const [prefabDirty, setPrefabDirty] = useState(false);
|
|
208
|
+
const [prefabDialog, setPrefabDialog] = useState(null);
|
|
209
|
+
/** Asset waiting to replace an open prefab that has unsaved edits. */
|
|
210
|
+
const [prefabSwitch, setPrefabSwitch] = useState(null);
|
|
211
|
+
const toastTimerRef = useRef(0);
|
|
212
|
+
const showToast = (text) => {
|
|
213
|
+
window.clearTimeout(toastTimerRef.current);
|
|
214
|
+
setToast(text);
|
|
215
|
+
toastTimerRef.current = window.setTimeout(() => setToast(null), 2600);
|
|
216
|
+
};
|
|
217
|
+
const closeFloatingDrawer = () => {
|
|
218
|
+
if (!drawerPinnedRef.current) {
|
|
219
|
+
setDrawerOpen(false);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const isAssetsLoading = assetsLoading !== null;
|
|
112
223
|
const showMessage = (title, message) => setMessageDialog({ title, message });
|
|
113
|
-
/** Appends
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
showMessage('Import to gallery', files.length === 0
|
|
224
|
+
/** Appends effect files to Assets without touching the scene. */
|
|
225
|
+
const runAssetImport = async (files) => {
|
|
226
|
+
if (collectJsonFiles(files).length === 0) {
|
|
227
|
+
showMessage('Import assets', files.length === 0
|
|
118
228
|
? 'Import was cancelled or the browser returned no files.\n\nTry Chrome or Edge and pick .json files or a folder that contains them.'
|
|
119
229
|
: `No .json files found among ${files.length} file(s) in the selection.`);
|
|
120
230
|
return;
|
|
121
231
|
}
|
|
122
232
|
try {
|
|
123
|
-
await
|
|
124
|
-
stateRef.current?.history.clear();
|
|
125
|
-
force();
|
|
233
|
+
await actionsRef.current.importAssets(files);
|
|
126
234
|
}
|
|
127
235
|
catch (err) {
|
|
128
236
|
const message = err instanceof Error ? err.message : String(err);
|
|
129
|
-
showMessage('Import
|
|
130
|
-
console.error('Failed to import
|
|
131
|
-
force();
|
|
237
|
+
showMessage('Import assets', message);
|
|
238
|
+
console.error('Failed to import assets:', err);
|
|
132
239
|
}
|
|
240
|
+
force();
|
|
133
241
|
};
|
|
134
242
|
const handleImportFolder = () => {
|
|
135
243
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
136
|
-
|
|
244
|
+
assetFolderInputRef.current?.click();
|
|
137
245
|
return;
|
|
138
246
|
}
|
|
139
247
|
void (async () => {
|
|
140
248
|
try {
|
|
141
|
-
const picked = await
|
|
249
|
+
const picked = await pickAssetFolderFiles();
|
|
142
250
|
if (picked === null || picked === undefined) {
|
|
143
251
|
return;
|
|
144
252
|
}
|
|
145
|
-
await
|
|
253
|
+
await runAssetImport(picked);
|
|
146
254
|
}
|
|
147
255
|
catch (err) {
|
|
148
256
|
const message = err instanceof Error ? err.message : String(err);
|
|
149
|
-
showMessage('Import
|
|
257
|
+
showMessage('Import assets', message);
|
|
150
258
|
console.error('Failed to open folder picker:', err);
|
|
151
259
|
}
|
|
152
260
|
})();
|
|
153
261
|
};
|
|
154
262
|
useEffect(() => {
|
|
155
|
-
// Register the shared ground-plane collider before any effect (default or imported)
|
|
156
|
-
// is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
|
|
157
|
-
ensureGroundResolver();
|
|
158
263
|
const engine = new Engine(canvasRef.current, true);
|
|
159
264
|
const scene = new Scene(engine);
|
|
160
265
|
scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
|
|
161
266
|
const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new BVector3(0, 1.4, 0), scene);
|
|
162
267
|
camera.attachControl(canvasRef.current, true);
|
|
163
268
|
camera.wheelDeltaPercentage = 0.01;
|
|
164
|
-
// Mesh render mode particles are lit (billboard modes ignore these)
|
|
165
|
-
//
|
|
269
|
+
// Mesh render mode particles are lit (billboard modes ignore these); the grid is visual
|
|
270
|
+
// reference only and is not a physics collider.
|
|
166
271
|
scene.ambientColor = new Color3(1, 1, 1);
|
|
167
272
|
const sunLight = new DirectionalLight('sun', new BVector3(-1, -1, -1), scene);
|
|
168
273
|
sunLight.intensity = 1.0;
|
|
@@ -184,9 +289,8 @@ export function EffectEditorHost(props) {
|
|
|
184
289
|
gizmoManagerRef.current = gizmoManager;
|
|
185
290
|
const renderer = new BatchedRenderer('quarks-editor', scene);
|
|
186
291
|
const history = new EffectHistory();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
let galleryEntries = [];
|
|
292
|
+
/** Undo/redo of effects that are not focused right now, by root uniqueId. */
|
|
293
|
+
const historyByRoot = new Map();
|
|
190
294
|
const dropMarker = new GalleryDropMarker(scene);
|
|
191
295
|
dropMarkerRef.current = dropMarker;
|
|
192
296
|
const selectionMarker = new GallerySelectionMarker(scene);
|
|
@@ -195,17 +299,30 @@ export function EffectEditorHost(props) {
|
|
|
195
299
|
emitterWireframesRef.current = emitterWireframes;
|
|
196
300
|
const viewportPicker = new GalleryViewportPicker(scene);
|
|
197
301
|
viewportPickerRef.current = viewportPicker;
|
|
302
|
+
stateRef.current = {
|
|
303
|
+
engine,
|
|
304
|
+
scene,
|
|
305
|
+
camera,
|
|
306
|
+
ground,
|
|
307
|
+
renderer,
|
|
308
|
+
binding: null,
|
|
309
|
+
history,
|
|
310
|
+
sceneEntries: [],
|
|
311
|
+
visibleRootIds: [],
|
|
312
|
+
assets: [],
|
|
313
|
+
};
|
|
314
|
+
const state = stateRef.current;
|
|
198
315
|
const handleViewportPointerLeave = () => viewportPicker.setHover(null);
|
|
199
316
|
canvasRef.current?.addEventListener('pointerleave', handleViewportPointerLeave);
|
|
200
317
|
const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
|
|
201
|
-
if (
|
|
318
|
+
if (assetDragDepthRef.current > 0) {
|
|
202
319
|
return;
|
|
203
320
|
}
|
|
204
321
|
const event = pointerInfo.event;
|
|
205
322
|
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
|
|
206
323
|
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
207
|
-
const
|
|
208
|
-
viewportPicker.setHover(effectRoot && effectRoot !==
|
|
324
|
+
const focused = state.binding?.root ?? null;
|
|
325
|
+
viewportPicker.setHover(effectRoot && effectRoot !== focused ? effectRoot : null);
|
|
209
326
|
return;
|
|
210
327
|
}
|
|
211
328
|
if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
|
|
@@ -215,202 +332,622 @@ export function EffectEditorHost(props) {
|
|
|
215
332
|
if (!effectRoot) {
|
|
216
333
|
return;
|
|
217
334
|
}
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
const entry = snap.galleryEntries.find((item) => item.root === effectRoot);
|
|
223
|
-
if (entry && snap.binding?.root !== effectRoot) {
|
|
224
|
-
selectGalleryEntryRef.current(entry);
|
|
335
|
+
const entry = state.sceneEntries.find((item) => item.root === effectRoot);
|
|
336
|
+
if (entry && state.binding?.root !== effectRoot) {
|
|
337
|
+
focusEntry(entry);
|
|
225
338
|
}
|
|
226
339
|
setSelectedNode(effectRoot);
|
|
227
340
|
});
|
|
228
|
-
const
|
|
229
|
-
|
|
341
|
+
const clearAssetDragPreview = () => {
|
|
342
|
+
assetDragDepthRef.current = 0;
|
|
230
343
|
dropMarker.hide();
|
|
231
|
-
|
|
232
|
-
|
|
344
|
+
setAssetDragHover(null);
|
|
345
|
+
setAssetDragSession(null);
|
|
233
346
|
};
|
|
234
|
-
const onWindowDragEnd = () =>
|
|
347
|
+
const onWindowDragEnd = () => clearAssetDragPreview();
|
|
235
348
|
window.addEventListener('dragend', onWindowDragEnd);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
349
|
+
// ---- state commits ------------------------------------------------------------------
|
|
350
|
+
/**
|
|
351
|
+
* The open prefab, if any: its asset, and the scene set aside while it is edited. In prefab
|
|
352
|
+
* mode `state.sceneEntries` holds just the prefab's instance, so playback, picking, undo and
|
|
353
|
+
* the timeline all work on it unchanged.
|
|
354
|
+
*/
|
|
355
|
+
let prefab = null;
|
|
356
|
+
let prefabChanged = false;
|
|
357
|
+
/** Scene changes since the last save; suppressed while a project scene loads. */
|
|
358
|
+
let dirty = false;
|
|
359
|
+
let loadingScene = Boolean(props.project);
|
|
360
|
+
const markDirty = () => {
|
|
361
|
+
if (prefab) {
|
|
362
|
+
if (!prefabChanged) {
|
|
363
|
+
prefabChanged = true;
|
|
364
|
+
setPrefabDirty(true);
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!loadingScene && !dirty) {
|
|
369
|
+
dirty = true;
|
|
370
|
+
setSceneDirty(true);
|
|
371
|
+
}
|
|
249
372
|
};
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
373
|
+
const markClean = () => {
|
|
374
|
+
dirty = false;
|
|
375
|
+
setSceneDirty(false);
|
|
376
|
+
};
|
|
377
|
+
const unsubscribeTransform = subscribeTransform(markDirty);
|
|
378
|
+
const commitEntries = (next) => {
|
|
379
|
+
state.sceneEntries = next;
|
|
380
|
+
setSceneEntries(next);
|
|
381
|
+
markDirty();
|
|
382
|
+
};
|
|
383
|
+
const commitAssets = (next) => {
|
|
384
|
+
state.assets = next;
|
|
385
|
+
setAssets(next);
|
|
386
|
+
};
|
|
387
|
+
const commitVisible = (next) => {
|
|
388
|
+
state.visibleRootIds = next;
|
|
389
|
+
setVisibleRootIds(next);
|
|
390
|
+
markDirty();
|
|
391
|
+
};
|
|
392
|
+
const onBindingChange = () => {
|
|
393
|
+
force();
|
|
394
|
+
markDirty();
|
|
395
|
+
};
|
|
396
|
+
const isVisible = (entry) => state.visibleRootIds.includes(entry.root.uniqueId);
|
|
397
|
+
const refreshPlayback = () => {
|
|
398
|
+
applyGalleryPlayback(state.sceneEntries, new Set(state.visibleRootIds), playbackRef.current.stagePlaying, playbackRef.current.focusFinishedByRoot);
|
|
399
|
+
renderer.refreshBatches();
|
|
400
|
+
scene.render();
|
|
401
|
+
force();
|
|
402
|
+
};
|
|
403
|
+
// ---- focus ----------------------------------------------------------------------------
|
|
404
|
+
const attachBinding = (next) => {
|
|
405
|
+
const previous = state.binding;
|
|
406
|
+
if (previous && !previous.root.isDisposed()) {
|
|
407
|
+
historyByRoot.set(previous.root.uniqueId, history.saveStacks());
|
|
408
|
+
}
|
|
409
|
+
history.restoreStacks(next ? historyByRoot.get(next.root.uniqueId) : undefined);
|
|
410
|
+
if (next) {
|
|
411
|
+
history.attach(next);
|
|
412
|
+
next.subscribe(onBindingChange);
|
|
413
|
+
}
|
|
414
|
+
state.binding = next;
|
|
256
415
|
setBinding(next);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
416
|
+
};
|
|
417
|
+
const focusEntry = (entry, moveCamera = true) => {
|
|
418
|
+
attachBinding(entry ? bindingFromSceneEntry(entry) : null);
|
|
419
|
+
setSelectedNode(entry?.root ?? null);
|
|
420
|
+
setFocusedRoot(entry?.root ?? null);
|
|
421
|
+
playbackRef.current.focusRootId = entry?.root.uniqueId ?? null;
|
|
422
|
+
if (entry && isVisible(entry)) {
|
|
261
423
|
selectionMarker.show(entry.root);
|
|
262
|
-
|
|
424
|
+
if (moveCamera) {
|
|
425
|
+
focusGalleryEntry(camera, entry.root);
|
|
426
|
+
}
|
|
263
427
|
}
|
|
264
428
|
else {
|
|
265
429
|
selectionMarker.hide();
|
|
266
430
|
}
|
|
431
|
+
props.onReady?.(makeHandle());
|
|
267
432
|
};
|
|
268
|
-
|
|
269
|
-
|
|
433
|
+
// ---- scene entries ------------------------------------------------------------------
|
|
434
|
+
/** Adds entries to the scene (visible) and focuses the first. */
|
|
435
|
+
const addEntries = (entries, center) => {
|
|
436
|
+
if (entries.length === 0) {
|
|
270
437
|
return;
|
|
271
438
|
}
|
|
439
|
+
placeSceneEntriesAt(entries, center);
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
entry.root.setEnabled(true);
|
|
442
|
+
}
|
|
443
|
+
commitEntries([...state.sceneEntries, ...entries]);
|
|
444
|
+
commitVisible([...state.visibleRootIds, ...entries.map((entry) => entry.root.uniqueId)]);
|
|
445
|
+
focusEntry(entries[0], false);
|
|
446
|
+
refreshPlayback();
|
|
447
|
+
};
|
|
448
|
+
const refuseInPrefabMode = () => {
|
|
449
|
+
if (!prefab)
|
|
450
|
+
return false;
|
|
451
|
+
showToast('Effects go into the scene — go back to the scene (← Scene) to add them');
|
|
452
|
+
return true;
|
|
453
|
+
};
|
|
454
|
+
const instantiateAssets = (toPlace, center) => {
|
|
455
|
+
if (refuseInPrefabMode()) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const created = [];
|
|
459
|
+
for (const asset of toPlace) {
|
|
460
|
+
try {
|
|
461
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, [...state.sceneEntries, ...created]);
|
|
462
|
+
if (entry) {
|
|
463
|
+
created.push(entry);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
console.warn(`[Quarks editor] Could not instantiate "${asset.path}":`, err);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (created.length < toPlace.length) {
|
|
471
|
+
showMessage('Add to scene', `${toPlace.length - created.length} of ${toPlace.length} effect(s) could not be loaded — see the console.`);
|
|
472
|
+
}
|
|
473
|
+
addEntries(created, center);
|
|
474
|
+
};
|
|
475
|
+
const toggleVisible = (entry) => {
|
|
272
476
|
const id = entry.root.uniqueId;
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
477
|
+
const show = !isVisible(entry);
|
|
478
|
+
setSceneEntryVisible(entry, renderer, show);
|
|
479
|
+
clearFocusFinished(playbackRef.current, id);
|
|
480
|
+
commitVisible(show ? [...state.visibleRootIds, id] : state.visibleRootIds.filter((rootId) => rootId !== id));
|
|
481
|
+
if (state.binding?.root === entry.root) {
|
|
482
|
+
if (show) {
|
|
483
|
+
selectionMarker.show(entry.root);
|
|
278
484
|
}
|
|
279
485
|
else {
|
|
280
|
-
|
|
281
|
-
clearFocusFinished(playbackRef.current, id);
|
|
282
|
-
}
|
|
283
|
-
const ids = [...next];
|
|
284
|
-
if (stateRef.current) {
|
|
285
|
-
stateRef.current.previewRootIds = ids;
|
|
486
|
+
selectionMarker.hide();
|
|
286
487
|
}
|
|
287
|
-
return ids;
|
|
288
|
-
});
|
|
289
|
-
if (removing) {
|
|
290
|
-
clearGalleryEntrySimulation(entry, renderer);
|
|
291
|
-
clearFocusFinished(playbackRef.current, id);
|
|
292
488
|
}
|
|
293
|
-
|
|
294
|
-
scene.render();
|
|
295
|
-
force();
|
|
489
|
+
refreshPlayback();
|
|
296
490
|
};
|
|
297
|
-
|
|
298
|
-
|
|
491
|
+
/** Entry shown alone, and the entries solo hid (shown again when it ends). */
|
|
492
|
+
let solo = null;
|
|
493
|
+
const exitSolo = () => {
|
|
494
|
+
if (!solo) {
|
|
299
495
|
return;
|
|
300
496
|
}
|
|
497
|
+
const hidden = new Set(solo.hidden);
|
|
498
|
+
solo = null;
|
|
499
|
+
setSoloRootId(null);
|
|
500
|
+
const toShow = state.sceneEntries.filter((entry) => hidden.has(entry.root.uniqueId) && !isVisible(entry));
|
|
501
|
+
for (const entry of toShow) {
|
|
502
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
503
|
+
clearFocusFinished(playbackRef.current, entry.root.uniqueId);
|
|
504
|
+
}
|
|
505
|
+
commitVisible([...state.visibleRootIds, ...toShow.map((entry) => entry.root.uniqueId)]);
|
|
506
|
+
};
|
|
507
|
+
const toggleSolo = (entry) => {
|
|
508
|
+
if (prefab)
|
|
509
|
+
return;
|
|
301
510
|
const id = entry.root.uniqueId;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
511
|
+
const wasSolo = solo?.rootId === id;
|
|
512
|
+
exitSolo();
|
|
513
|
+
if (!wasSolo) {
|
|
514
|
+
const others = state.sceneEntries.filter((item) => item !== entry && isVisible(item));
|
|
515
|
+
for (const other of others) {
|
|
516
|
+
setSceneEntryVisible(other, renderer, false);
|
|
517
|
+
}
|
|
518
|
+
const hiddenIds = others.map((other) => other.root.uniqueId);
|
|
519
|
+
const visible = state.visibleRootIds.filter((rootId) => !hiddenIds.includes(rootId));
|
|
520
|
+
if (!visible.includes(id)) {
|
|
521
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
522
|
+
visible.push(id);
|
|
523
|
+
}
|
|
524
|
+
commitVisible(visible);
|
|
525
|
+
solo = { rootId: id, hidden: hiddenIds };
|
|
526
|
+
setSoloRootId(id);
|
|
527
|
+
if (state.binding?.root !== entry.root) {
|
|
528
|
+
focusEntry(entry);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
selectionMarker.show(entry.root);
|
|
532
|
+
focusGalleryEntry(camera, entry.root);
|
|
307
533
|
}
|
|
308
|
-
return next;
|
|
309
|
-
});
|
|
310
|
-
clearFocusFinished(playbackRef.current, id);
|
|
311
|
-
playbackRef.current.focusElapsedByRoot.delete(id);
|
|
312
|
-
galleryEntries = [...galleryEntries];
|
|
313
|
-
if (stateRef.current) {
|
|
314
|
-
stateRef.current.galleryEntries = galleryEntries;
|
|
315
534
|
}
|
|
316
|
-
|
|
317
|
-
|
|
535
|
+
refreshPlayback();
|
|
536
|
+
};
|
|
537
|
+
const deleteEntry = (entry) => {
|
|
538
|
+
if (prefab)
|
|
539
|
+
return;
|
|
540
|
+
const index = state.sceneEntries.indexOf(entry);
|
|
541
|
+
if (index < 0) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const id = entry.root.uniqueId;
|
|
545
|
+
if (solo?.rootId === id) {
|
|
546
|
+
exitSolo();
|
|
547
|
+
}
|
|
548
|
+
const wasFocused = state.binding?.root === entry.root;
|
|
549
|
+
if (wasFocused) {
|
|
318
550
|
selectionMarker.hide();
|
|
319
551
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
552
|
+
disposeSceneEntry(entry, renderer);
|
|
553
|
+
const remaining = state.sceneEntries.filter((item) => item !== entry);
|
|
554
|
+
commitEntries(remaining);
|
|
555
|
+
commitVisible(state.visibleRootIds.filter((rootId) => rootId !== id));
|
|
556
|
+
clearFocusFinished(playbackRef.current, id);
|
|
557
|
+
playbackRef.current.focusElapsedByRoot.delete(id);
|
|
558
|
+
if (wasFocused) {
|
|
559
|
+
focusEntry(remaining[Math.min(index, remaining.length - 1)] ?? null, false);
|
|
560
|
+
}
|
|
561
|
+
historyByRoot.delete(id);
|
|
562
|
+
refreshPlayback();
|
|
324
563
|
};
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
if (ids.length === 0) {
|
|
564
|
+
const createDefault = () => {
|
|
565
|
+
if (refuseInPrefabMode()) {
|
|
328
566
|
return;
|
|
329
567
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
stateRef.current.previewRootIds = next;
|
|
334
|
-
}
|
|
335
|
-
return next;
|
|
568
|
+
const entry = createSceneDefaultEntry(scene, state.sceneEntries, {
|
|
569
|
+
resolveTexture: props.resolveTexture,
|
|
570
|
+
textureUrl: props.textureOptions?.[0]?.url,
|
|
336
571
|
});
|
|
572
|
+
for (const system of entry.systems) {
|
|
573
|
+
renderer.addSystem(system);
|
|
574
|
+
}
|
|
575
|
+
addEntries([entry], new BVector3(camera.target.x, 0, camera.target.z));
|
|
337
576
|
};
|
|
338
|
-
|
|
339
|
-
|
|
577
|
+
// ---- assets ---------------------------------------------------------------------------
|
|
578
|
+
const placeDrop = (clientX, clientY, dataTransfer) => {
|
|
579
|
+
const payload = (dataTransfer && readAssetDrag(dataTransfer)) ?? getAssetDragSession();
|
|
580
|
+
clearAssetDragPreview();
|
|
340
581
|
if (!payload) {
|
|
341
582
|
return;
|
|
342
583
|
}
|
|
343
|
-
const {
|
|
344
|
-
|
|
584
|
+
const { assets: toPlace } = describeAssetDrag(state.assets, payload);
|
|
585
|
+
instantiateAssets(toPlace, pickGroundPosition(scene, clientX, clientY));
|
|
586
|
+
};
|
|
587
|
+
const addToScene = (payload) => {
|
|
588
|
+
const { assets: toPlace } = describeAssetDrag(state.assets, payload);
|
|
589
|
+
instantiateAssets(toPlace, new BVector3(camera.target.x, 0, camera.target.z));
|
|
590
|
+
};
|
|
591
|
+
/** Writes asset changes through to the project; failures are reported, not thrown. */
|
|
592
|
+
const persist = (action) => {
|
|
593
|
+
const project = props.project;
|
|
594
|
+
if (!project)
|
|
345
595
|
return;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
placeGalleryEntriesAt(toPlace, renderer, position);
|
|
353
|
-
}
|
|
354
|
-
galleryEntries = [...galleryEntries];
|
|
355
|
-
if (stateRef.current) {
|
|
356
|
-
stateRef.current.galleryEntries = galleryEntries;
|
|
357
|
-
}
|
|
358
|
-
setGallery(galleryEntries);
|
|
359
|
-
addGalleryPreview(...toPlace);
|
|
360
|
-
focusCatalogEntry(toPlace[0]);
|
|
361
|
-
dropMarkerRef.current?.hide();
|
|
362
|
-
setGalleryDragHover(null);
|
|
363
|
-
setGalleryDragSession(null);
|
|
364
|
-
force();
|
|
596
|
+
action(project.store, project.id).catch((err) => {
|
|
597
|
+
console.error('[Quarks editor] Could not save to the project:', err);
|
|
598
|
+
showMessage('Project storage', err instanceof Error ? err.message : String(err));
|
|
599
|
+
});
|
|
365
600
|
};
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
setBinding(nextBinding);
|
|
372
|
-
setSelectedNode(nextBinding.root);
|
|
373
|
-
props.onReady?.(makeHandle());
|
|
601
|
+
const deleteAssets = (payload) => {
|
|
602
|
+
const next = removeAssets(state.assets, payload);
|
|
603
|
+
const removed = state.assets.filter((asset) => !next.includes(asset)).map((asset) => asset.path);
|
|
604
|
+
commitAssets(next);
|
|
605
|
+
persist((store, id) => store.deleteAssets(id, removed));
|
|
374
606
|
};
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
607
|
+
const importAssets = async (files) => {
|
|
608
|
+
setAssetsLoading({ done: 0, total: 0, loaded: 0, failed: 0 });
|
|
609
|
+
let incoming;
|
|
610
|
+
try {
|
|
611
|
+
incoming = await loadEffectAssets(files, (progress) => setAssetsLoading(progress));
|
|
612
|
+
}
|
|
613
|
+
finally {
|
|
614
|
+
setAssetsLoading(null);
|
|
615
|
+
}
|
|
616
|
+
const { merged, added } = mergeAssets(state.assets, incoming);
|
|
617
|
+
commitAssets(merged);
|
|
618
|
+
setDrawerOpen(true);
|
|
619
|
+
if (added.length > 0) {
|
|
620
|
+
persist((store, id) => store.putAssets(id, added.map(toStoredAsset)));
|
|
621
|
+
}
|
|
386
622
|
if (incoming.length === 0) {
|
|
387
623
|
throw new Error('No particle effects found in the selected file(s).');
|
|
388
624
|
}
|
|
389
625
|
if (added.length === 0) {
|
|
390
|
-
throw new Error('All selected effects are already in
|
|
626
|
+
throw new Error('All selected effects are already in Assets.');
|
|
391
627
|
}
|
|
392
|
-
focusCatalogEntry(added[0]);
|
|
393
|
-
props.onReady?.(makeHandle());
|
|
394
628
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Saves a scene instance as a new asset at `rawPath` (`Folder/Name`, never overwriting) and
|
|
631
|
+
* makes the instance an instance of it.
|
|
632
|
+
*/
|
|
633
|
+
/** New asset at `rawPath` (`Folder/Name`, a free path — never overwrites) from an effect tree. */
|
|
634
|
+
const createAssetFromRoot = (root, rawPath) => {
|
|
635
|
+
const taken = new Set(state.assets.map((asset) => asset.path));
|
|
636
|
+
const wanted = normalizeAssetPath(rawPath);
|
|
637
|
+
let path = wanted;
|
|
638
|
+
for (let n = 2; taken.has(path); n++) {
|
|
639
|
+
path = wanted.replace(/\.json$/i, ` ${n}.json`);
|
|
640
|
+
}
|
|
641
|
+
const name = path
|
|
642
|
+
.split('/')
|
|
643
|
+
.pop()
|
|
644
|
+
.replace(/\.json$/i, '');
|
|
645
|
+
const asset = createEffectAsset(name, path, JSON.parse(serializeEffectTree(root, name)));
|
|
646
|
+
commitAssets([...state.assets, asset]);
|
|
647
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(asset)]));
|
|
648
|
+
return asset;
|
|
649
|
+
};
|
|
650
|
+
const saveEntryAsAsset = (entry, rawPath) => {
|
|
651
|
+
const asset = createAssetFromRoot(entry.root, rawPath);
|
|
652
|
+
entry.assetId = asset.id;
|
|
653
|
+
entry.path = asset.path;
|
|
654
|
+
commitEntries([...state.sceneEntries]);
|
|
655
|
+
setDrawerOpen(true);
|
|
656
|
+
showToast(`Saved as asset “${asset.path.replace(/\.json$/i, '')}”`);
|
|
657
|
+
};
|
|
658
|
+
// ---- prefab mode ------------------------------------------------------------------------
|
|
659
|
+
/** Scene instances made from the open prefab — they keep their own copy when it is saved. */
|
|
660
|
+
const prefabInstanceCount = () => prefab ? prefab.stash.entries.filter((entry) => entry.assetId === prefab.asset.id).length : 0;
|
|
661
|
+
/** Drops the prefab instance(s) currently in `state.sceneEntries`, with their history. */
|
|
662
|
+
const disposePrefabInstances = () => {
|
|
663
|
+
for (const entry of state.sceneEntries) {
|
|
664
|
+
const id = entry.root.uniqueId;
|
|
665
|
+
disposeSceneEntry(entry, renderer);
|
|
666
|
+
historyByRoot.delete(id);
|
|
667
|
+
clearFocusFinished(playbackRef.current, id);
|
|
668
|
+
playbackRef.current.focusElapsedByRoot.delete(id);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
/**
|
|
672
|
+
* Opens an asset as a prefab. With another prefab open it is swapped in place — the scene stays
|
|
673
|
+
* set aside — after asking about unsaved edits unless `discardChanges`.
|
|
674
|
+
*/
|
|
675
|
+
const openPrefab = (assetId, discardChanges = false) => {
|
|
676
|
+
if (prefab?.asset.id === assetId) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const asset = state.assets.find((item) => item.id === assetId);
|
|
680
|
+
if (!asset)
|
|
681
|
+
return;
|
|
682
|
+
if (prefab && prefabChanged && !discardChanges) {
|
|
683
|
+
setPrefabSwitch({ id: asset.id, name: asset.name });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let entry = null;
|
|
687
|
+
try {
|
|
688
|
+
entry = instantiateSceneEntry(scene, renderer, asset, []);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
console.warn(`[Quarks editor] Could not open prefab "${asset.path}":`, err);
|
|
692
|
+
}
|
|
693
|
+
if (!entry) {
|
|
694
|
+
showMessage('Open prefab', `“${asset.name}” has no particle systems to edit.`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (prefab) {
|
|
698
|
+
selectionMarker.hide();
|
|
699
|
+
disposePrefabInstances();
|
|
700
|
+
prefab.asset = asset;
|
|
701
|
+
prefabChanged = false;
|
|
702
|
+
setPrefabDirty(false);
|
|
703
|
+
setPrefabInfo({ name: asset.name, path: asset.path });
|
|
704
|
+
entry.root.setEnabled(true);
|
|
705
|
+
state.sceneEntries = [entry];
|
|
706
|
+
setSceneEntries([entry]);
|
|
707
|
+
state.visibleRootIds = [entry.root.uniqueId];
|
|
708
|
+
setVisibleRootIds([entry.root.uniqueId]);
|
|
709
|
+
focusEntry(entry);
|
|
710
|
+
refreshPlayback();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
exitSolo();
|
|
714
|
+
const stash = {
|
|
715
|
+
entries: state.sceneEntries,
|
|
716
|
+
visible: state.visibleRootIds,
|
|
717
|
+
focusedRoot: state.binding?.root ?? null,
|
|
718
|
+
camera: { target: camera.target.clone(), radius: camera.radius, alpha: camera.alpha, beta: camera.beta },
|
|
719
|
+
};
|
|
720
|
+
// Hide the scene without touching its saved visibility.
|
|
721
|
+
for (const item of stash.entries) {
|
|
722
|
+
if (stash.visible.includes(item.root.uniqueId)) {
|
|
723
|
+
setSceneEntryVisible(item, renderer, false);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
selectionMarker.hide();
|
|
727
|
+
prefab = { asset, stash };
|
|
728
|
+
prefabChanged = false;
|
|
729
|
+
setPrefabDirty(false);
|
|
730
|
+
setPrefabInfo({ name: asset.name, path: asset.path });
|
|
731
|
+
entry.root.setEnabled(true);
|
|
732
|
+
state.sceneEntries = [entry];
|
|
733
|
+
setSceneEntries([entry]);
|
|
734
|
+
state.visibleRootIds = [entry.root.uniqueId];
|
|
735
|
+
setVisibleRootIds([entry.root.uniqueId]);
|
|
736
|
+
focusEntry(entry);
|
|
737
|
+
refreshPlayback();
|
|
738
|
+
};
|
|
739
|
+
/** Leaves prefab mode without saving: drops the prefab instance and brings the scene back. */
|
|
740
|
+
const closePrefab = () => {
|
|
741
|
+
if (!prefab)
|
|
742
|
+
return;
|
|
743
|
+
const { stash } = prefab;
|
|
744
|
+
prefab = null;
|
|
745
|
+
selectionMarker.hide();
|
|
746
|
+
disposePrefabInstances();
|
|
747
|
+
state.sceneEntries = stash.entries;
|
|
748
|
+
setSceneEntries(stash.entries);
|
|
749
|
+
state.visibleRootIds = stash.visible;
|
|
750
|
+
setVisibleRootIds(stash.visible);
|
|
751
|
+
for (const entry of stash.entries) {
|
|
752
|
+
if (stash.visible.includes(entry.root.uniqueId)) {
|
|
753
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
754
|
+
clearFocusFinished(playbackRef.current, entry.root.uniqueId);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
cancelGalleryCameraFocus();
|
|
758
|
+
camera.setTarget(stash.camera.target);
|
|
759
|
+
camera.radius = stash.camera.radius;
|
|
760
|
+
camera.alpha = stash.camera.alpha;
|
|
761
|
+
camera.beta = stash.camera.beta;
|
|
762
|
+
prefabChanged = false;
|
|
763
|
+
setPrefabDirty(false);
|
|
764
|
+
setPrefabInfo(null);
|
|
765
|
+
focusEntry(stash.entries.find((entry) => entry.root === stash.focusedRoot) ?? null, false);
|
|
766
|
+
refreshPlayback();
|
|
767
|
+
};
|
|
768
|
+
/** Overwrites the open prefab's asset with the edited instance. Scene instances keep theirs. */
|
|
769
|
+
const overwritePrefab = () => {
|
|
770
|
+
const root = state.sceneEntries[0]?.root;
|
|
771
|
+
if (!prefab || !root)
|
|
772
|
+
return;
|
|
773
|
+
const updated = {
|
|
774
|
+
...prefab.asset,
|
|
775
|
+
json: JSON.parse(serializeEffectTree(root, prefab.asset.name)),
|
|
776
|
+
};
|
|
777
|
+
const exists = state.assets.some((asset) => asset.id === updated.id);
|
|
778
|
+
commitAssets(exists
|
|
779
|
+
? state.assets.map((asset) => (asset.id === updated.id ? updated : asset))
|
|
780
|
+
: [...state.assets, updated]);
|
|
781
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(updated)]));
|
|
782
|
+
prefab.asset = updated;
|
|
783
|
+
prefabChanged = false;
|
|
784
|
+
setPrefabDirty(false);
|
|
785
|
+
showToast(`Prefab “${updated.name}” saved · scene unchanged`);
|
|
786
|
+
};
|
|
787
|
+
/** Saves the edited prefab as a new asset and keeps editing that one. */
|
|
788
|
+
const savePrefabAsNew = (rawPath) => {
|
|
789
|
+
const root = state.sceneEntries[0]?.root;
|
|
790
|
+
if (!prefab || !root)
|
|
791
|
+
return;
|
|
792
|
+
const asset = createAssetFromRoot(root, rawPath);
|
|
793
|
+
prefab.asset = asset;
|
|
794
|
+
prefabChanged = false;
|
|
795
|
+
setPrefabDirty(false);
|
|
796
|
+
setPrefabInfo({ name: asset.name, path: asset.path });
|
|
797
|
+
showToast(`Saved as new prefab “${asset.path.replace(/\.json$/i, '')}”`);
|
|
798
|
+
};
|
|
799
|
+
// ---- project scene ----------------------------------------------------------------------
|
|
800
|
+
/** Saves the scene to the project; resolves to false when it could not. */
|
|
801
|
+
const saveScene = async () => {
|
|
802
|
+
const project = props.project;
|
|
803
|
+
if (!project || prefab)
|
|
804
|
+
return false;
|
|
805
|
+
const visibleIds = new Set(state.visibleRootIds);
|
|
806
|
+
const assetPathById = new Map(state.assets.map((asset) => [asset.id, asset.path]));
|
|
807
|
+
const stored = {
|
|
808
|
+
savedAt: Date.now(),
|
|
809
|
+
entries: state.sceneEntries.map((entry) => ({
|
|
810
|
+
name: entry.name,
|
|
811
|
+
assetPath: entry.assetId != null ? (assetPathById.get(entry.assetId) ?? null) : null,
|
|
812
|
+
json: JSON.parse(serializeEffectTree(entry.root, entry.name)),
|
|
813
|
+
visible: visibleIds.has(entry.root.uniqueId),
|
|
814
|
+
})),
|
|
815
|
+
};
|
|
816
|
+
try {
|
|
817
|
+
await project.store.saveScene(project.id, stored);
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
console.error('[Quarks editor] Could not save the scene:', err);
|
|
821
|
+
showMessage('Save scene', err instanceof Error ? err.message : String(err));
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
markClean();
|
|
825
|
+
let infoSeen = true;
|
|
826
|
+
try {
|
|
827
|
+
infoSeen = localStorage.getItem(SCENE_SAVE_INFO_KEY) === '1';
|
|
828
|
+
localStorage.setItem(SCENE_SAVE_INFO_KEY, '1');
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// Storage blocked — fall back to the toast.
|
|
832
|
+
}
|
|
833
|
+
if (infoSeen) {
|
|
834
|
+
showToast('Scene saved · assets unchanged');
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
showMessage('Scene saved', 'Saving the scene stores the effects placed in it, with their own edits.\n\n' +
|
|
838
|
+
'It does not change the assets they came from. To keep an edited effect as an asset, ' +
|
|
839
|
+
'right-click it in the Scene list → “Save as new asset…”.');
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
};
|
|
843
|
+
const loadProject = async (project) => {
|
|
844
|
+
try {
|
|
845
|
+
const [storedAssets, storedScene] = await Promise.all([
|
|
846
|
+
project.store.loadAssets(project.id),
|
|
847
|
+
project.store.loadScene(project.id),
|
|
848
|
+
]);
|
|
849
|
+
const loadedAssets = storedAssets.map((stored) => ({
|
|
850
|
+
...createEffectAsset(stored.name, stored.path, stored.json),
|
|
851
|
+
missingFiles: stored.missingFiles,
|
|
852
|
+
}));
|
|
853
|
+
commitAssets(loadedAssets);
|
|
854
|
+
const assetIdByPath = new Map(loadedAssets.map((asset) => [asset.path, asset.id]));
|
|
855
|
+
if (!storedScene) {
|
|
856
|
+
createDefault();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const created = [];
|
|
860
|
+
const visible = [];
|
|
861
|
+
for (const stored of storedScene.entries) {
|
|
862
|
+
const entry = instantiateSceneEntry(scene, renderer, { id: -1, name: stored.name, path: stored.assetPath ?? '', json: stored.json, missingFiles: [] }, created);
|
|
863
|
+
if (!entry)
|
|
864
|
+
continue;
|
|
865
|
+
entry.name = stored.name;
|
|
866
|
+
entry.root.name = stored.name;
|
|
867
|
+
entry.assetId = stored.assetPath != null ? (assetIdByPath.get(stored.assetPath) ?? null) : null;
|
|
868
|
+
entry.path = stored.assetPath ?? `_created/${stored.name}.json`;
|
|
869
|
+
setSceneEntryVisible(entry, renderer, stored.visible);
|
|
870
|
+
if (stored.visible)
|
|
871
|
+
visible.push(entry.root.uniqueId);
|
|
872
|
+
created.push(entry);
|
|
873
|
+
}
|
|
874
|
+
commitEntries(created);
|
|
875
|
+
commitVisible(visible);
|
|
876
|
+
focusEntry(created[0] ?? null, false);
|
|
877
|
+
refreshPlayback();
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
console.error('[Quarks editor] Could not open the project:', err);
|
|
881
|
+
showMessage('Open project', err instanceof Error ? err.message : String(err));
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
loadingScene = false;
|
|
885
|
+
markClean();
|
|
886
|
+
setProjectLoading(false);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
// ---- undo / redo ----------------------------------------------------------------------
|
|
890
|
+
/** Rebuilds the focused entry from a history snapshot, in place in the scene list. */
|
|
891
|
+
const historyStep = (redo) => {
|
|
892
|
+
const current = state.binding;
|
|
893
|
+
if (!current) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const json = redo ? history.redo() : history.undo();
|
|
897
|
+
const index = state.sceneEntries.findIndex((entry) => entry.root === current.root);
|
|
898
|
+
const prev = index >= 0 ? state.sceneEntries[index] : null;
|
|
899
|
+
if (!json || !prev) {
|
|
900
|
+
force();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const selectedIndex = nodeIndexInEffect(current.root, selectedNodeRef.current);
|
|
904
|
+
const visible = isVisible(prev);
|
|
905
|
+
const { root, systems } = parseEffectFromJson(scene, renderer, json, {
|
|
906
|
+
registerRenderer: true,
|
|
907
|
+
autoplay: false,
|
|
399
908
|
});
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
909
|
+
if (systems.length === 0) {
|
|
910
|
+
disposeSceneEntry({ ...prev, root, systems }, renderer);
|
|
911
|
+
force();
|
|
912
|
+
return;
|
|
404
913
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
914
|
+
disposeSceneEntry(prev, renderer);
|
|
915
|
+
// The snapshot carries the root transform too, so a moved effect moves back.
|
|
916
|
+
root.parent = null;
|
|
917
|
+
root.name = prev.name;
|
|
918
|
+
const nextEntry = { ...prev, root, systems };
|
|
919
|
+
setSceneEntryVisible(nextEntry, renderer, visible);
|
|
920
|
+
const entries = [...state.sceneEntries];
|
|
921
|
+
entries[index] = nextEntry;
|
|
922
|
+
commitEntries(entries);
|
|
923
|
+
commitVisible(state.visibleRootIds.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id)));
|
|
924
|
+
if (solo) {
|
|
925
|
+
if (solo.rootId === prev.root.uniqueId) {
|
|
926
|
+
solo.rootId = root.uniqueId;
|
|
927
|
+
setSoloRootId(root.uniqueId);
|
|
928
|
+
}
|
|
929
|
+
solo.hidden = solo.hidden.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id));
|
|
930
|
+
}
|
|
931
|
+
const next = bindingFromSceneEntry(nextEntry);
|
|
932
|
+
history.attach(next);
|
|
933
|
+
next.subscribe(onBindingChange);
|
|
934
|
+
state.binding = next;
|
|
935
|
+
setBinding(next);
|
|
936
|
+
setSelectedNode(nodeAtIndex(root, selectedIndex));
|
|
937
|
+
setFocusedRoot(root);
|
|
938
|
+
playbackRef.current.focusRootId = root.uniqueId;
|
|
939
|
+
if (visible) {
|
|
940
|
+
selectionMarker.show(root);
|
|
941
|
+
}
|
|
942
|
+
notifyTransformChanged();
|
|
943
|
+
refreshPlayback();
|
|
408
944
|
};
|
|
945
|
+
// ---- handle -----------------------------------------------------------------------------
|
|
409
946
|
const makeHandle = () => ({
|
|
410
947
|
get binding() {
|
|
411
|
-
const b =
|
|
948
|
+
const b = state.binding;
|
|
412
949
|
if (!b) {
|
|
413
|
-
throw new Error('
|
|
950
|
+
throw new Error('No effect is focused — add one to the scene first.');
|
|
414
951
|
}
|
|
415
952
|
return b;
|
|
416
953
|
},
|
|
@@ -418,64 +955,68 @@ export function EffectEditorHost(props) {
|
|
|
418
955
|
scene,
|
|
419
956
|
renderer,
|
|
420
957
|
importJson,
|
|
421
|
-
|
|
958
|
+
importAssets,
|
|
959
|
+
importGallery: importAssets,
|
|
422
960
|
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
423
961
|
});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
962
|
+
/** Adds `json` to Assets and returns its single instance; throws when it has no systems. */
|
|
963
|
+
const openJson = (json, name = 'Effect') => {
|
|
964
|
+
const asset = createEffectAsset(name, `_opened/${name}.json`, json);
|
|
965
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, state.sceneEntries);
|
|
966
|
+
if (!entry) {
|
|
429
967
|
throw new Error('No particle systems found in the JSON.');
|
|
430
968
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
969
|
+
asset.name = entry.name;
|
|
970
|
+
asset.path = `_opened/${entry.name}.json`;
|
|
971
|
+
commitAssets([...state.assets, asset]);
|
|
972
|
+
return entry;
|
|
973
|
+
};
|
|
974
|
+
const importJson = (json) => {
|
|
975
|
+
closePrefab();
|
|
976
|
+
const entry = openJson(json);
|
|
977
|
+
for (const existing of state.sceneEntries) {
|
|
978
|
+
disposeSceneEntry(existing, renderer);
|
|
434
979
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
playbackRef.current.focusRootId = next.root.uniqueId;
|
|
980
|
+
historyByRoot.clear();
|
|
981
|
+
solo = null;
|
|
982
|
+
setSoloRootId(null);
|
|
983
|
+
commitEntries([]);
|
|
984
|
+
commitVisible([]);
|
|
985
|
+
addEntries([entry], BVector3.Zero());
|
|
442
986
|
};
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
987
|
+
actionsRef.current = {
|
|
988
|
+
importAssets,
|
|
989
|
+
focusEntry: (entry) => focusEntry(entry),
|
|
990
|
+
toggleVisible,
|
|
991
|
+
toggleSolo,
|
|
992
|
+
deleteEntry,
|
|
993
|
+
createDefault,
|
|
994
|
+
placeDrop,
|
|
995
|
+
addToScene,
|
|
996
|
+
deleteAssets,
|
|
997
|
+
saveEntryAsAsset,
|
|
998
|
+
saveScene,
|
|
999
|
+
openPrefab,
|
|
1000
|
+
overwritePrefab,
|
|
1001
|
+
savePrefabAsNew,
|
|
1002
|
+
closePrefab,
|
|
1003
|
+
prefabInstanceCount,
|
|
1004
|
+
historyStep,
|
|
453
1005
|
};
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
|
|
457
|
-
root.parent = null;
|
|
458
|
-
const name = root.name || 'Effect';
|
|
459
|
-
initialEntry = { name, path: `_opened/${name}.json`, root, systems, inScene: true };
|
|
1006
|
+
if (props.project) {
|
|
1007
|
+
void loadProject(props.project);
|
|
460
1008
|
}
|
|
461
|
-
else {
|
|
462
|
-
|
|
463
|
-
placeGalleryEntryInScene(initialEntry, renderer, BVector3.Zero());
|
|
1009
|
+
else if (props.effectJson) {
|
|
1010
|
+
addEntries([openJson(props.effectJson)], BVector3.Zero());
|
|
464
1011
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (stateRef.current) {
|
|
468
|
-
stateRef.current.galleryRoot = galleryRoot;
|
|
469
|
-
stateRef.current.galleryEntries = galleryEntries;
|
|
470
|
-
stateRef.current.previewRootIds = initialPreviewIds;
|
|
1012
|
+
else {
|
|
1013
|
+
createDefault();
|
|
471
1014
|
}
|
|
472
|
-
setPreviewRootIds(initialPreviewIds);
|
|
473
|
-
setGallery(galleryEntries);
|
|
474
|
-
focusCatalogEntry(initialEntry);
|
|
475
|
-
props.onReady?.(makeHandle());
|
|
476
1015
|
engine.runRenderLoop(() => {
|
|
477
1016
|
const playback = playbackRef.current;
|
|
478
|
-
const
|
|
1017
|
+
const entries = state.sceneEntries;
|
|
1018
|
+
const visibleIds = new Set(state.visibleRootIds);
|
|
1019
|
+
const focusBinding = state.binding;
|
|
479
1020
|
if (!playback.scrubbing) {
|
|
480
1021
|
let delta = (engine.getDeltaTime() / 1000) * playback.speed;
|
|
481
1022
|
const stepQueued = playback.stepQueued;
|
|
@@ -483,27 +1024,25 @@ export function EffectEditorHost(props) {
|
|
|
483
1024
|
delta = stepQueued ? 1 / 60 : 0;
|
|
484
1025
|
playback.stepQueued = false;
|
|
485
1026
|
}
|
|
486
|
-
if (delta > 0
|
|
487
|
-
const
|
|
488
|
-
const simulate = hasActivePreview(snapshot.galleryEntries, previewIds) && (playback.stagePlaying || stepQueued);
|
|
1027
|
+
if (delta > 0) {
|
|
1028
|
+
const simulate = hasActivePreview(entries, visibleIds) && (playback.stagePlaying || stepQueued);
|
|
489
1029
|
if (simulate) {
|
|
490
1030
|
renderer.update(delta);
|
|
491
1031
|
let needsRefresh = false;
|
|
492
|
-
if (pauseFinishedPreviews(
|
|
1032
|
+
if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
|
|
493
1033
|
needsRefresh = true;
|
|
494
1034
|
}
|
|
495
|
-
const
|
|
496
|
-
const focusId = playback.focusRootId ?? binding?.root.uniqueId ?? null;
|
|
1035
|
+
const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
|
|
497
1036
|
if (focusId != null &&
|
|
498
|
-
|
|
1037
|
+
focusBinding &&
|
|
499
1038
|
playback.stagePlaying &&
|
|
500
1039
|
!playback.focusFinishedByRoot.has(focusId) &&
|
|
501
1040
|
!focusTimelineMetaRef.current.anyLooping &&
|
|
502
|
-
QuarksUtil.isEffectFinished(
|
|
1041
|
+
QuarksUtil.isEffectFinished(focusBinding.root)) {
|
|
503
1042
|
const settleAt = getFocusElapsed(playback, focusId);
|
|
504
|
-
scrubFocusToSettled(
|
|
1043
|
+
scrubFocusToSettled(focusBinding, renderer, settleAt);
|
|
505
1044
|
markFocusFinished(playback, focusId);
|
|
506
|
-
pauseFocus(
|
|
1045
|
+
pauseFocus(focusBinding);
|
|
507
1046
|
needsRefresh = true;
|
|
508
1047
|
}
|
|
509
1048
|
else if (focusId != null &&
|
|
@@ -515,106 +1054,107 @@ export function EffectEditorHost(props) {
|
|
|
515
1054
|
if (needsRefresh) {
|
|
516
1055
|
renderer.refreshBatches();
|
|
517
1056
|
}
|
|
518
|
-
|
|
519
|
-
logRenderMismatch(renderer, countGalleryParticles(snapshot.galleryEntries, previewIds));
|
|
520
|
-
}
|
|
1057
|
+
logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
|
|
521
1058
|
}
|
|
522
|
-
else if (
|
|
1059
|
+
else if (entries.length > 0) {
|
|
523
1060
|
renderer.refreshBatches();
|
|
524
1061
|
}
|
|
525
1062
|
}
|
|
526
1063
|
}
|
|
527
1064
|
scene.render();
|
|
528
1065
|
selectionMarker.follow();
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const b = snapshot.binding;
|
|
538
|
-
const galleryKey = snapshot.galleryEntries
|
|
539
|
-
.map((entry) => `${entry.root.uniqueId}:${entry.inScene ? 1 : 0}`)
|
|
540
|
-
.join('|');
|
|
541
|
-
const key = `${galleryKey}::${b?.root.uniqueId ?? 'none'}`;
|
|
542
|
-
picker.sync(snapshot.galleryEntries, b?.root ?? null, key);
|
|
543
|
-
}
|
|
544
|
-
if (!snapshot)
|
|
545
|
-
return;
|
|
1066
|
+
viewportPicker.follow();
|
|
1067
|
+
if (focusBinding) {
|
|
1068
|
+
emitterWireframes.sync([focusBinding.system, ...focusBinding.subSystems], `${focusBinding.root.uniqueId}:${focusBinding.getRevision()}`);
|
|
1069
|
+
}
|
|
1070
|
+
const pickKey = entries
|
|
1071
|
+
.map((entry) => `${entry.root.uniqueId}:${visibleIds.has(entry.root.uniqueId) ? 1 : 0}`)
|
|
1072
|
+
.join('|');
|
|
1073
|
+
viewportPicker.sync(entries, null, `${pickKey}::${focusBinding?.root.uniqueId ?? 'none'}`);
|
|
546
1074
|
if (fpsRef.current) {
|
|
547
1075
|
fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
|
|
548
1076
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
binding.getRevision() !== focusTimelineMetaRef.current.revision)) {
|
|
554
|
-
const rows = computeTimelineRows(binding);
|
|
1077
|
+
if (focusBinding &&
|
|
1078
|
+
(focusBinding.root.uniqueId !== focusTimelineMetaRef.current.rootId ||
|
|
1079
|
+
focusBinding.getRevision() !== focusTimelineMetaRef.current.revision)) {
|
|
1080
|
+
const rows = computeTimelineRows(focusBinding);
|
|
555
1081
|
focusTimelineMetaRef.current = {
|
|
556
|
-
rootId:
|
|
557
|
-
revision:
|
|
1082
|
+
rootId: focusBinding.root.uniqueId,
|
|
1083
|
+
revision: focusBinding.getRevision(),
|
|
558
1084
|
span: computeTimelineSpan(rows),
|
|
559
1085
|
anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
|
|
560
1086
|
};
|
|
561
1087
|
}
|
|
562
|
-
if (
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
const inScene = snapshot.galleryEntries.filter((entry) => entry.inScene).length;
|
|
566
|
-
const focused = snapshot.galleryEntries.find((entry) => entry.root === binding?.root);
|
|
1088
|
+
if (counterRef.current) {
|
|
1089
|
+
const count = countGalleryParticles(entries, visibleIds);
|
|
1090
|
+
const focused = entries.find((entry) => entry.root === focusBinding?.root);
|
|
567
1091
|
const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
|
|
568
1092
|
const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
|
|
569
1093
|
const focusEnded = focusId != null && isFocusFinished(playback, focusId);
|
|
570
1094
|
const meta = focusTimelineMetaRef.current;
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
578
|
-
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
579
|
-
counterRef.current.textContent = `${playing} preview · ${stageLabel}${focusLabel} · ${timeLabel} · ${count} particles · ${inScene}/${snapshot.galleryEntries.length} in catalog`;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
else if (binding) {
|
|
583
|
-
const b = binding;
|
|
584
|
-
const count = b.system.particleNum + b.subSystems.reduce((sum, sub) => sum + sub.particleNum, 0);
|
|
585
|
-
const focusId = playback.focusRootId ?? b.root.uniqueId;
|
|
586
|
-
const focusElapsed = getFocusElapsed(playback, focusId);
|
|
587
|
-
const focusEnded = isFocusFinished(playback, focusId);
|
|
588
|
-
const meta = focusTimelineMetaRef.current;
|
|
589
|
-
if (counterRef.current) {
|
|
590
|
-
const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
|
|
591
|
-
const timeLabel = meta.anyLooping
|
|
592
|
-
? `${focusElapsed.toFixed(1)}s ⟳`
|
|
593
|
-
: focusEnded
|
|
594
|
-
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
595
|
-
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
596
|
-
counterRef.current.textContent = `${stageLabel} · ${timeLabel} · ${count} particles`;
|
|
597
|
-
}
|
|
1095
|
+
const timeLabel = meta.anyLooping
|
|
1096
|
+
? `${focusElapsed.toFixed(1)}s ⟳`
|
|
1097
|
+
: focusEnded
|
|
1098
|
+
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
1099
|
+
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
1100
|
+
counterRef.current.textContent = focused ? `${timeLabel} · ${count} particles` : `${count} particles`;
|
|
598
1101
|
}
|
|
599
1102
|
});
|
|
600
1103
|
const resizeObserver = new ResizeObserver(() => engine.resize());
|
|
601
1104
|
resizeObserver.observe(canvasRef.current);
|
|
602
1105
|
const onKey = (e) => {
|
|
603
|
-
|
|
1106
|
+
const key = e.key.toLowerCase();
|
|
1107
|
+
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
|
604
1108
|
e.preventDefault();
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1109
|
+
if (prefab) {
|
|
1110
|
+
setPrefabDialog('overwrite');
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
void saveScene();
|
|
1114
|
+
}
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (e.code === 'Space' && (e.ctrlKey || e.metaKey)) {
|
|
1118
|
+
e.preventDefault();
|
|
1119
|
+
setDrawerOpen((open) => !open);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const target = e.target;
|
|
1123
|
+
if (e.code === 'Space' && !isTextInput(target) && target?.tagName !== 'BUTTON') {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
const playback = playbackRef.current;
|
|
1126
|
+
toggleStagePlayback(state.binding, scene, playback, playback.stagePlaying, setStagePlaying);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (e.key === 'Escape') {
|
|
1130
|
+
closeFloatingDrawer();
|
|
1131
|
+
}
|
|
1132
|
+
if ((e.ctrlKey || e.metaKey) && (key === 'z' || key === 'y')) {
|
|
1133
|
+
if (isTextInput(e.target) && !isInspectorValueControl(e.target)) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
e.preventDefault();
|
|
1137
|
+
// Leave the field first so it shows the restored value instead of its draft text.
|
|
1138
|
+
e.target?.blur?.();
|
|
1139
|
+
historyStep(key === 'y' || e.shiftKey);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (e.key === 'Delete' && !isTextInput(e.target)) {
|
|
1143
|
+
const entry = state.sceneEntries.find((item) => item.root === state.binding?.root);
|
|
1144
|
+
if (entry) {
|
|
1145
|
+
e.preventDefault();
|
|
1146
|
+
deleteEntry(entry);
|
|
608
1147
|
}
|
|
609
|
-
force();
|
|
610
1148
|
}
|
|
611
1149
|
};
|
|
612
1150
|
window.addEventListener('keydown', onKey);
|
|
613
1151
|
return () => {
|
|
1152
|
+
unsubscribeTransform();
|
|
1153
|
+
window.clearTimeout(toastTimerRef.current);
|
|
614
1154
|
resizeObserver.disconnect();
|
|
615
1155
|
window.removeEventListener('keydown', onKey);
|
|
616
1156
|
window.removeEventListener('dragend', onWindowDragEnd);
|
|
617
|
-
|
|
1157
|
+
clearAssetDragPreview();
|
|
618
1158
|
cancelGalleryCameraFocus();
|
|
619
1159
|
dropMarker.dispose();
|
|
620
1160
|
dropMarkerRef.current = null;
|
|
@@ -632,29 +1172,72 @@ export function EffectEditorHost(props) {
|
|
|
632
1172
|
};
|
|
633
1173
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
634
1174
|
}, []);
|
|
635
|
-
useEffect(() => {
|
|
636
|
-
if (stateRef.current) {
|
|
637
|
-
stateRef.current.previewRootIds = previewRootIds;
|
|
638
|
-
}
|
|
639
|
-
}, [previewRootIds]);
|
|
640
1175
|
useEffect(() => {
|
|
641
1176
|
playbackRef.current.stagePlaying = stagePlaying;
|
|
642
1177
|
const snap = stateRef.current;
|
|
643
1178
|
if (!snap) {
|
|
644
1179
|
return;
|
|
645
1180
|
}
|
|
646
|
-
|
|
647
|
-
if (!
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying, playbackRef.current.focusFinishedByRoot);
|
|
651
|
-
if (!stagePlaying && stateRef.current?.renderer) {
|
|
652
|
-
stateRef.current.renderer.refreshBatches();
|
|
1181
|
+
applyGalleryPlayback(snap.sceneEntries, new Set(visibleRootIds), stagePlaying, playbackRef.current.focusFinishedByRoot);
|
|
1182
|
+
if (!stagePlaying) {
|
|
1183
|
+
snap.renderer.refreshBatches();
|
|
653
1184
|
}
|
|
654
|
-
}, [stagePlaying,
|
|
1185
|
+
}, [stagePlaying, visibleRootIds, sceneEntries]);
|
|
655
1186
|
useEffect(() => {
|
|
656
1187
|
emitterWireframesRef.current?.setVisible(showEmitterWireframes);
|
|
657
1188
|
}, [showEmitterWireframes]);
|
|
1189
|
+
// The floating drawer is opaque, so render the scene only in the part of the viewport above
|
|
1190
|
+
// it: the view — and every "frame this effect" camera move — re-centres on what is visible.
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
const update = () => {
|
|
1193
|
+
const camera = stateRef.current?.camera;
|
|
1194
|
+
const canvas = canvasRef.current;
|
|
1195
|
+
const area = mainAreaRef.current;
|
|
1196
|
+
if (!camera || !canvas || !area)
|
|
1197
|
+
return;
|
|
1198
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
1199
|
+
const drawerTop = area.getBoundingClientRect().bottom - DRAWER_HEIGHT;
|
|
1200
|
+
const covered = drawerOpen && !drawerPinned
|
|
1201
|
+
? Math.max(0, Math.min(canvasRect.height * 0.8, canvasRect.bottom - drawerTop))
|
|
1202
|
+
: 0;
|
|
1203
|
+
const fraction = canvasRect.height > 0 ? covered / canvasRect.height : 0;
|
|
1204
|
+
camera.viewport = new Viewport(0, fraction, 1, 1 - fraction);
|
|
1205
|
+
setViewportInset(covered);
|
|
1206
|
+
};
|
|
1207
|
+
update();
|
|
1208
|
+
window.addEventListener('resize', update);
|
|
1209
|
+
return () => window.removeEventListener('resize', update);
|
|
1210
|
+
}, [drawerOpen, drawerPinned, timelineCollapsed]);
|
|
1211
|
+
useEffect(() => {
|
|
1212
|
+
if (!props.project || !sceneDirty)
|
|
1213
|
+
return;
|
|
1214
|
+
const onBeforeUnload = (e) => {
|
|
1215
|
+
e.preventDefault();
|
|
1216
|
+
e.returnValue = '';
|
|
1217
|
+
};
|
|
1218
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
1219
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
1220
|
+
}, [props.project, sceneDirty]);
|
|
1221
|
+
const requestPrefabExit = () => {
|
|
1222
|
+
if (prefabDirty) {
|
|
1223
|
+
setPrefabDialog('exit');
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
actionsRef.current.closePrefab();
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
const requestExitProject = () => {
|
|
1230
|
+
if (prefabInfo) {
|
|
1231
|
+
requestPrefabExit();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (sceneDirty) {
|
|
1235
|
+
setExitPrompt(true);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
props.onExitProject?.();
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
658
1241
|
useEffect(() => {
|
|
659
1242
|
const gm = gizmoManagerRef.current;
|
|
660
1243
|
if (!gm)
|
|
@@ -679,12 +1262,20 @@ export function EffectEditorHost(props) {
|
|
|
679
1262
|
notifyTransformChanged();
|
|
680
1263
|
});
|
|
681
1264
|
};
|
|
1265
|
+
// One gizmo drag = one undo step: snapshot at drag start, refresh the UI at the end.
|
|
1266
|
+
const dragStartObserver = activeGizmo?.onDragStartObservable.add(() => stateRef.current?.binding?.onBeforeChange?.());
|
|
682
1267
|
const dragObserver = activeGizmo?.onDragObservable.add(scheduleTransformRefresh);
|
|
683
|
-
const dragEndObserver = activeGizmo?.onDragEndObservable.add(() =>
|
|
1268
|
+
const dragEndObserver = activeGizmo?.onDragEndObservable.add(() => {
|
|
1269
|
+
notifyTransformChanged();
|
|
1270
|
+
stateRef.current?.binding?.markChanged();
|
|
1271
|
+
});
|
|
684
1272
|
return () => {
|
|
685
1273
|
if (dragRaf !== 0) {
|
|
686
1274
|
cancelAnimationFrame(dragRaf);
|
|
687
1275
|
}
|
|
1276
|
+
if (activeGizmo && dragStartObserver) {
|
|
1277
|
+
activeGizmo.onDragStartObservable.remove(dragStartObserver);
|
|
1278
|
+
}
|
|
688
1279
|
if (activeGizmo && dragObserver) {
|
|
689
1280
|
activeGizmo.onDragObservable.remove(dragObserver);
|
|
690
1281
|
}
|
|
@@ -698,10 +1289,10 @@ export function EffectEditorHost(props) {
|
|
|
698
1289
|
if (!gm)
|
|
699
1290
|
return;
|
|
700
1291
|
if (selectedNode && selectedNode.isDisposed()) {
|
|
701
|
-
setSelectedNode(stateRef.current?.binding?.root ??
|
|
1292
|
+
setSelectedNode(stateRef.current?.binding?.root ?? null);
|
|
702
1293
|
return;
|
|
703
1294
|
}
|
|
704
|
-
if (
|
|
1295
|
+
if (isAssetsLoading) {
|
|
705
1296
|
return;
|
|
706
1297
|
}
|
|
707
1298
|
const target = selectedNode && !selectedNode.isDisposed()
|
|
@@ -709,109 +1300,33 @@ export function EffectEditorHost(props) {
|
|
|
709
1300
|
: binding?.root && !binding.root.isDisposed()
|
|
710
1301
|
? binding.root
|
|
711
1302
|
: null;
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
}, [selectedNode, binding, isGalleryLoading]);
|
|
1303
|
+
gm.attachToNode(target);
|
|
1304
|
+
}, [selectedNode, binding, isAssetsLoading]);
|
|
716
1305
|
const state = stateRef.current;
|
|
717
1306
|
const gizmoNode = selectedNode && !selectedNode.isDisposed()
|
|
718
1307
|
? selectedNode
|
|
719
1308
|
: binding?.root && !binding.root.isDisposed()
|
|
720
1309
|
? binding.root
|
|
721
1310
|
: null;
|
|
722
|
-
const historyAction = (json) => {
|
|
723
|
-
if (json && state?.binding) {
|
|
724
|
-
const current = state.binding;
|
|
725
|
-
const inGallery = state.galleryEntries.length > 0;
|
|
726
|
-
const entryIndex = inGallery ? state.galleryEntries.findIndex((e) => e.root === current.root) : -1;
|
|
727
|
-
const prev = entryIndex >= 0 ? state.galleryEntries[entryIndex] : null;
|
|
728
|
-
const wasInScene = prev?.inScene ?? true;
|
|
729
|
-
const savedPosition = wasInScene ? current.root.position.clone() : null;
|
|
730
|
-
if (inGallery && entryIndex >= 0 && prev) {
|
|
731
|
-
disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer, wasInScene);
|
|
732
|
-
const { root, systems } = parseEffectFromJson(state.scene, wasInScene ? state.renderer : null, json, {
|
|
733
|
-
registerRenderer: wasInScene,
|
|
734
|
-
autoplay: false,
|
|
735
|
-
});
|
|
736
|
-
if (systems.length === 0) {
|
|
737
|
-
disposeLoadedEffect(root, systems, state.renderer, wasInScene);
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
if (wasInScene && savedPosition) {
|
|
741
|
-
root.position.copyFrom(savedPosition);
|
|
742
|
-
root.parent = null;
|
|
743
|
-
root.setEnabled(true);
|
|
744
|
-
}
|
|
745
|
-
else {
|
|
746
|
-
root.parent = state.galleryRoot;
|
|
747
|
-
root.setEnabled(false);
|
|
748
|
-
}
|
|
749
|
-
if (!root.name || root.name === 'Effect' || root.name === 'Object3D') {
|
|
750
|
-
root.name = prev.name;
|
|
751
|
-
}
|
|
752
|
-
const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
|
|
753
|
-
const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
|
|
754
|
-
const oldRootId = current.root.uniqueId;
|
|
755
|
-
const updated = [...state.galleryEntries];
|
|
756
|
-
updated[entryIndex] = { name: prev.name, path: prev.path, root, systems, inScene: wasInScene };
|
|
757
|
-
state.galleryEntries = updated;
|
|
758
|
-
setGallery(updated);
|
|
759
|
-
setPreviewRootIds((ids) => {
|
|
760
|
-
const next = ids.map((id) => (id === oldRootId ? root.uniqueId : id));
|
|
761
|
-
if (stateRef.current) {
|
|
762
|
-
stateRef.current.previewRootIds = next;
|
|
763
|
-
}
|
|
764
|
-
return next;
|
|
765
|
-
});
|
|
766
|
-
state.history.attach(next);
|
|
767
|
-
next.subscribe(force);
|
|
768
|
-
state.binding = next;
|
|
769
|
-
setBinding(next);
|
|
770
|
-
setSelectedNode(root);
|
|
771
|
-
setSelectedGalleryRoot(root);
|
|
772
|
-
playbackRef.current.focusRootId = root.uniqueId;
|
|
773
|
-
if (wasInScene) {
|
|
774
|
-
selectionMarkerRef.current?.show(root);
|
|
775
|
-
}
|
|
776
|
-
else {
|
|
777
|
-
selectionMarkerRef.current?.hide();
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
else {
|
|
781
|
-
const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
|
|
782
|
-
if (systems.length === 0) {
|
|
783
|
-
disposeEffect(root, systems, state.renderer);
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
|
|
787
|
-
const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
|
|
788
|
-
const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
|
|
789
|
-
next.root.parent = null;
|
|
790
|
-
state.history.attach(next);
|
|
791
|
-
next.subscribe(force);
|
|
792
|
-
state.binding = next;
|
|
793
|
-
setBinding(next);
|
|
794
|
-
setSelectedNode(root);
|
|
795
|
-
setSelectedGalleryRoot(null);
|
|
796
|
-
selectionMarkerRef.current?.hide();
|
|
797
|
-
state.ground.scaling.x = 1;
|
|
798
|
-
state.ground.scaling.z = 1;
|
|
799
|
-
state.camera.radius = 9;
|
|
800
|
-
state.camera.setTarget(new BVector3(0, 1.4, 0));
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
force();
|
|
804
|
-
};
|
|
805
1311
|
const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
|
|
806
1312
|
const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1313
|
+
// The inspector is the expensive part of a selection change (every module re-renders for the
|
|
1314
|
+
// new system). It renders from deferred values, so the click, tree highlight and gizmo update
|
|
1315
|
+
// at once and the inspector catches up in an interruptible render instead of stalling frames.
|
|
1316
|
+
const deferredBinding = useDeferredValue(binding);
|
|
1317
|
+
const deferredNode = useDeferredValue(selectedNode);
|
|
1318
|
+
const inspectorBinding = deferredBinding && !deferredBinding.root.isDisposed() ? deferredBinding : binding;
|
|
1319
|
+
const inspectorNode = deferredNode && !deferredNode.isDisposed() && inspectorBinding === deferredBinding
|
|
1320
|
+
? deferredNode
|
|
1321
|
+
: (inspectorBinding?.root ?? null);
|
|
1322
|
+
const inspectorSystems = inspectorBinding ? [inspectorBinding.system, ...inspectorBinding.subSystems] : [];
|
|
1323
|
+
const inspectorSystem = inspectorBinding
|
|
1324
|
+
? (inspectorSystems.find((s) => s.emitter === inspectorNode) ?? inspectorBinding.system)
|
|
810
1325
|
: null;
|
|
811
|
-
|
|
1326
|
+
const visibleRootIdSet = useMemo(() => new Set(visibleRootIds), [visibleRootIds]);
|
|
1327
|
+
const instanceCounts = useMemo(() => countInstancesByAsset(sceneEntries), [sceneEntries]);
|
|
812
1328
|
const showTimeline = Boolean(binding && activeSystem && state);
|
|
813
|
-
const timelinePlaybackEnabled = !
|
|
814
|
-
const previewRootIdSet = useMemo(() => new Set(previewRootIds), [previewRootIds]);
|
|
1329
|
+
const timelinePlaybackEnabled = !focusedRoot || visibleRootIdSet.has(focusedRoot.uniqueId);
|
|
815
1330
|
const resolveTextureForEditor = useCallback((url) => {
|
|
816
1331
|
const scene = stateRef.current?.scene;
|
|
817
1332
|
return scene && props.resolveTexture ? props.resolveTexture(url, scene) : undefined;
|
|
@@ -831,191 +1346,454 @@ export function EffectEditorHost(props) {
|
|
|
831
1346
|
fontFamily: theme.font,
|
|
832
1347
|
color: theme.text,
|
|
833
1348
|
background: '#070b16',
|
|
834
|
-
}, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
overflow: 'hidden',
|
|
846
|
-
padding: '8px 12px 14px',
|
|
847
|
-
display: 'flex',
|
|
848
|
-
flexDirection: 'column',
|
|
849
|
-
}, children: _jsx(GalleryPanel, { entries: gallery, focusedRoot: selectedGalleryRoot, previewRootIds: previewRootIdSet, loading: galleryLoading, folderInputRef: galleryFolderInputRef, onFocus: (entry) => selectGalleryEntryRef.current(entry), onTogglePreview: (entry) => toggleGalleryPreviewRef.current(entry), onRemoveFromScene: (entry) => removeGalleryFromSceneRef.current(entry), onImportFiles: (files) => void runGalleryImport(files), onImportFolder: () => void handleImportFolder(), onCreateDefault: () => createGalleryDefaultRef.current() }) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
|
|
850
|
-
if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
|
|
851
|
-
return;
|
|
852
|
-
galleryDragDepthRef.current += 1;
|
|
853
|
-
}, onDragLeave: (e) => {
|
|
854
|
-
if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
|
|
855
|
-
return;
|
|
856
|
-
galleryDragDepthRef.current -= 1;
|
|
857
|
-
if (galleryDragDepthRef.current <= 0) {
|
|
858
|
-
galleryDragDepthRef.current = 0;
|
|
859
|
-
dropMarkerRef.current?.hide();
|
|
860
|
-
setGalleryDragHover(null);
|
|
861
|
-
}
|
|
862
|
-
}, onDragOver: (e) => {
|
|
863
|
-
if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
|
|
864
|
-
return;
|
|
865
|
-
e.preventDefault();
|
|
866
|
-
e.dataTransfer.dropEffect = 'copy';
|
|
867
|
-
const payload = getGalleryDragSession();
|
|
868
|
-
const snap = stateRef.current;
|
|
869
|
-
if (!payload || !snap)
|
|
870
|
-
return;
|
|
871
|
-
const described = describeGalleryDrag(snap.galleryEntries, payload);
|
|
872
|
-
if (described.entries.length === 0)
|
|
873
|
-
return;
|
|
874
|
-
const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
|
|
875
|
-
dropMarkerRef.current?.show(position, described.entries.length);
|
|
876
|
-
setGalleryDragHover({ label: described.label, count: described.entries.length });
|
|
877
|
-
}, onDrop: (e) => {
|
|
878
|
-
if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
|
|
879
|
-
return;
|
|
880
|
-
e.preventDefault();
|
|
881
|
-
galleryDragDepthRef.current = 0;
|
|
882
|
-
placeGalleryDropRef.current(e.clientX, e.clientY, e.dataTransfer);
|
|
883
|
-
}, children: [_jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }), _jsx("div", { style: {
|
|
884
|
-
position: 'absolute',
|
|
885
|
-
bottom: 12,
|
|
886
|
-
left: 12,
|
|
1349
|
+
}, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { ref: mainAreaRef, style: {
|
|
1350
|
+
position: 'relative',
|
|
1351
|
+
flex: 1,
|
|
1352
|
+
minHeight: 0,
|
|
1353
|
+
display: 'flex',
|
|
1354
|
+
flexDirection: 'column',
|
|
1355
|
+
overflow: 'hidden',
|
|
1356
|
+
}, children: [_jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("aside", { style: {
|
|
1357
|
+
width: SCENE_PANEL_WIDTH,
|
|
1358
|
+
flexShrink: 0,
|
|
1359
|
+
borderRight: `1px solid ${theme.border}`,
|
|
887
1360
|
background: theme.panelBg,
|
|
888
|
-
border: `1px solid ${theme.border}`,
|
|
889
|
-
borderRadius: 8,
|
|
890
|
-
padding: '4px 10px',
|
|
891
|
-
fontSize: 12,
|
|
892
|
-
color: theme.textDim,
|
|
893
|
-
fontVariantNumeric: 'tabular-nums',
|
|
894
|
-
pointerEvents: 'none',
|
|
895
|
-
fontFamily: theme.font,
|
|
896
|
-
}, children: _jsx("span", { ref: fpsRef, children: "\u2014 FPS" }) }), galleryDragHover && (_jsx("div", { style: {
|
|
897
|
-
position: 'absolute',
|
|
898
|
-
inset: 10,
|
|
899
|
-
border: '2px dashed rgba(120, 165, 255, 0.75)',
|
|
900
|
-
borderRadius: 12,
|
|
901
|
-
pointerEvents: 'none',
|
|
902
|
-
boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
|
|
903
|
-
}, children: _jsxs("div", { style: {
|
|
904
|
-
position: 'absolute',
|
|
905
|
-
left: '50%',
|
|
906
|
-
bottom: 18,
|
|
907
|
-
transform: 'translateX(-50%)',
|
|
908
|
-
background: 'rgba(7, 11, 22, 0.88)',
|
|
909
|
-
border: `1px solid ${theme.border}`,
|
|
910
|
-
borderRadius: 8,
|
|
911
|
-
padding: '8px 14px',
|
|
912
|
-
fontSize: 13,
|
|
913
|
-
color: theme.text,
|
|
914
|
-
whiteSpace: 'nowrap',
|
|
915
|
-
}, children: ["Drop ", galleryDragHover.count === 1 ? 'effect' : `${galleryDragHover.count} effects`, " \u2014", ' ', galleryDragHover.label] }) })), galleryLoading && (_jsx("div", { style: {
|
|
916
|
-
position: 'absolute',
|
|
917
|
-
inset: 0,
|
|
918
|
-
display: 'flex',
|
|
919
|
-
alignItems: 'center',
|
|
920
|
-
justifyContent: 'center',
|
|
921
|
-
background: 'rgba(7, 11, 22, 0.72)',
|
|
922
|
-
pointerEvents: 'none',
|
|
923
|
-
}, children: _jsxs("div", { style: {
|
|
924
|
-
background: theme.panelBg,
|
|
925
|
-
border: `1px solid ${theme.border}`,
|
|
926
|
-
borderRadius: 10,
|
|
927
|
-
padding: '16px 22px',
|
|
928
|
-
textAlign: 'center',
|
|
929
|
-
}, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 6 }, children: "Loading gallery\u2026" }), _jsxs("div", { style: { fontSize: 13, color: theme.textDim }, children: [galleryLoading.done, " / ", galleryLoading.total || '…', " files", galleryLoading.failed > 0 ? ` · ${galleryLoading.failed} skipped` : ''] })] }) })), props.backLink && (_jsxs("a", { className: "qe-hover", href: props.backLink.href, style: {
|
|
930
|
-
position: 'absolute',
|
|
931
|
-
top: 12,
|
|
932
|
-
left: 12,
|
|
933
|
-
display: 'inline-flex',
|
|
934
|
-
alignItems: 'center',
|
|
935
|
-
gap: 6,
|
|
936
|
-
background: theme.panelBg,
|
|
937
|
-
border: `1px solid ${theme.border}`,
|
|
938
|
-
borderRadius: 8,
|
|
939
|
-
padding: '6px 12px',
|
|
940
|
-
fontSize: 13,
|
|
941
|
-
fontWeight: 500,
|
|
942
|
-
color: theme.text,
|
|
943
|
-
textDecoration: 'none',
|
|
944
|
-
fontFamily: theme.font,
|
|
945
|
-
}, children: [_jsx(ArrowLeftIcon, { style: iconStyle(14) }), props.backLink.label.replace(/^←\s*/, '')] })), _jsxs("div", { style: {
|
|
946
|
-
position: 'absolute',
|
|
947
|
-
top: 12,
|
|
948
|
-
right: 12,
|
|
949
1361
|
display: 'flex',
|
|
950
1362
|
flexDirection: 'column',
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}, children: [_jsxs("div", { style: {
|
|
1363
|
+
minHeight: 0,
|
|
1364
|
+
}, children: [props.project && (_jsxs("div", { style: {
|
|
954
1365
|
display: 'flex',
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1366
|
+
alignItems: 'center',
|
|
1367
|
+
gap: 6,
|
|
1368
|
+
padding: '8px 10px',
|
|
1369
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
1370
|
+
}, children: [props.onExitProject && (_jsx("button", { type: "button", className: "qe-hover", title: "Back to projects", style: { ...dockIconButtonStyle, width: 26, height: 26 }, onClick: requestExitProject, children: _jsx(ArrowLeftIcon, { style: iconStyle(13) }) })), _jsxs("span", { title: props.project.name, style: {
|
|
1371
|
+
flex: 1,
|
|
1372
|
+
minWidth: 0,
|
|
1373
|
+
fontSize: 13,
|
|
1374
|
+
fontWeight: 600,
|
|
1375
|
+
overflow: 'hidden',
|
|
1376
|
+
textOverflow: 'ellipsis',
|
|
1377
|
+
whiteSpace: 'nowrap',
|
|
1378
|
+
}, children: [props.project.name, sceneDirty ? ' *' : ''] }), _jsx("button", { type: "button", className: "qe-hover", title: "Save the scene (Ctrl+S) \u2014 assets are not changed", disabled: projectLoading, style: {
|
|
1379
|
+
...buttonStyle,
|
|
1380
|
+
padding: '4px 12px',
|
|
1381
|
+
...(sceneDirty
|
|
1382
|
+
? {
|
|
1383
|
+
background: theme.accent,
|
|
1384
|
+
color: '#0a1128',
|
|
1385
|
+
border: `1px solid ${theme.accent}`,
|
|
1386
|
+
}
|
|
1387
|
+
: {}),
|
|
1388
|
+
}, onClick: () => prefabInfo ? setPrefabDialog('overwrite') : void actionsRef.current.saveScene(), children: "Save" })] })), _jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: { fontSize: 14, fontWeight: 600 }, children: prefabInfo
|
|
1389
|
+
? 'Prefab'
|
|
1390
|
+
: `Scene${sceneEntries.length > 0 ? ` · ${sceneEntries.length}` : ''}` }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4 }, children: prefabInfo
|
|
1391
|
+
? 'The scene is set aside while you edit this asset'
|
|
1392
|
+
: 'Effects in the viewport · click to edit · eye shows/hides' })] }), _jsx("div", { style: {
|
|
1393
|
+
flex: 1,
|
|
1394
|
+
minHeight: 0,
|
|
1395
|
+
overflow: 'hidden',
|
|
1396
|
+
padding: '8px 12px 14px',
|
|
961
1397
|
display: 'flex',
|
|
962
1398
|
flexDirection: 'column',
|
|
963
|
-
|
|
1399
|
+
}, children: prefabInfo ? (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0 }, children: [_jsxs("div", { style: {
|
|
1400
|
+
display: 'flex',
|
|
1401
|
+
flexDirection: 'column',
|
|
1402
|
+
gap: 10,
|
|
1403
|
+
padding: 12,
|
|
1404
|
+
border: `1px solid ${theme.borderActive}`,
|
|
1405
|
+
borderRadius: 8,
|
|
1406
|
+
background: 'rgba(120, 165, 255, 0.08)',
|
|
1407
|
+
fontSize: 12,
|
|
1408
|
+
color: theme.textDim,
|
|
1409
|
+
lineHeight: 1.5,
|
|
1410
|
+
}, children: [_jsxs("div", { style: { fontSize: 13, fontWeight: 600, color: theme.text }, children: [prefabInfo.name, prefabDirty ? ' *' : ''] }), _jsx("div", { style: { wordBreak: 'break-all' }, children: prefabInfo.path }), _jsx("div", { children: "Save (Ctrl+S) overwrites this asset. Effects already placed in the scene keep their own copy and do not change." }), _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx("button", { type: "button", className: "qe-hover", style: buttonStyle, onClick: requestPrefabExit, children: "\u2190 Scene" }), _jsx("button", { type: "button", className: "qe-hover", style: buttonStyle, onClick: () => setPrefabDialog('overwrite'), children: "Save prefab" })] })] }), sceneEntries[0] && (_jsx("div", { style: {
|
|
1411
|
+
flex: 1,
|
|
1412
|
+
minHeight: 0,
|
|
1413
|
+
overflowY: 'auto',
|
|
1414
|
+
border: `1px solid ${theme.border}`,
|
|
1415
|
+
borderRadius: 8,
|
|
1416
|
+
background: theme.sectionBg,
|
|
1417
|
+
padding: '4px 0',
|
|
1418
|
+
}, children: _jsx(EffectTreeView, { includeRoot: true, root: sceneEntries[0].root, selectedNode: selectedNode, onSelect: setSelectedNode }) }))] })) : (_jsx(ScenePanel, { entries: sceneEntries, focusedRoot: focusedRoot, visibleRootIds: visibleRootIdSet, onFocus: (entry) => actionsRef.current.focusEntry(entry), onToggleVisible: (entry) => actionsRef.current.toggleVisible(entry), soloRootId: soloRootId, onToggleSolo: (entry) => actionsRef.current.toggleSolo(entry), onSaveAsAsset: (entry) => setSaveAsTarget(entry), onDelete: (entry) => actionsRef.current.deleteEntry(entry), onCreateDefault: () => actionsRef.current.createDefault(), selectedNode: selectedNode, onSelectNode: (entry, node) => {
|
|
1419
|
+
if (stateRef.current?.binding?.root !== entry.root) {
|
|
1420
|
+
actionsRef.current.focusEntry(entry);
|
|
1421
|
+
}
|
|
1422
|
+
setSelectedNode(node);
|
|
1423
|
+
} })) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
|
|
1424
|
+
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
|
|
1425
|
+
return;
|
|
1426
|
+
assetDragDepthRef.current += 1;
|
|
1427
|
+
}, onDragLeave: (e) => {
|
|
1428
|
+
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
|
|
1429
|
+
return;
|
|
1430
|
+
assetDragDepthRef.current -= 1;
|
|
1431
|
+
if (assetDragDepthRef.current <= 0) {
|
|
1432
|
+
assetDragDepthRef.current = 0;
|
|
1433
|
+
dropMarkerRef.current?.hide();
|
|
1434
|
+
setAssetDragHover(null);
|
|
1435
|
+
}
|
|
1436
|
+
}, onDragOver: (e) => {
|
|
1437
|
+
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
|
|
1438
|
+
return;
|
|
1439
|
+
e.preventDefault();
|
|
1440
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
1441
|
+
const payload = getAssetDragSession();
|
|
1442
|
+
const snap = stateRef.current;
|
|
1443
|
+
if (!payload || !snap)
|
|
1444
|
+
return;
|
|
1445
|
+
const described = describeAssetDrag(snap.assets, payload);
|
|
1446
|
+
if (described.assets.length === 0)
|
|
1447
|
+
return;
|
|
1448
|
+
const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
|
|
1449
|
+
dropMarkerRef.current?.show(position, described.assets.length);
|
|
1450
|
+
setAssetDragHover({ label: described.label, count: described.assets.length });
|
|
1451
|
+
}, onDrop: (e) => {
|
|
1452
|
+
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
|
|
1453
|
+
return;
|
|
1454
|
+
e.preventDefault();
|
|
1455
|
+
actionsRef.current.placeDrop(e.clientX, e.clientY, e.dataTransfer);
|
|
1456
|
+
}, children: [_jsx("canvas", { ref: canvasRef, style: {
|
|
1457
|
+
width: '100%',
|
|
1458
|
+
height: '100%',
|
|
1459
|
+
display: 'block',
|
|
1460
|
+
touchAction: 'none',
|
|
1461
|
+
outline: 'none',
|
|
1462
|
+
} }), _jsx("div", { style: {
|
|
1463
|
+
position: 'absolute',
|
|
1464
|
+
bottom: 12 + viewportInset,
|
|
1465
|
+
left: 12,
|
|
964
1466
|
background: theme.panelBg,
|
|
965
1467
|
border: `1px solid ${theme.border}`,
|
|
966
1468
|
borderRadius: 8,
|
|
967
|
-
padding:
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1469
|
+
padding: '4px 10px',
|
|
1470
|
+
fontSize: 12,
|
|
1471
|
+
color: theme.textDim,
|
|
1472
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1473
|
+
pointerEvents: 'none',
|
|
1474
|
+
fontFamily: theme.font,
|
|
1475
|
+
}, children: _jsx("span", { ref: fpsRef, children: "\u2014 FPS" }) }), state && (_jsx("div", { style: {
|
|
1476
|
+
position: 'absolute',
|
|
1477
|
+
bottom: 12 + viewportInset,
|
|
1478
|
+
left: '50%',
|
|
1479
|
+
transform: 'translateX(-50%)',
|
|
1480
|
+
}, children: _jsx(TransportBar, { binding: binding, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, enabled: timelinePlaybackEnabled }) })), assetDragHover && (_jsx("div", { style: {
|
|
1481
|
+
position: 'absolute',
|
|
1482
|
+
inset: 10,
|
|
1483
|
+
border: '2px dashed rgba(120, 165, 255, 0.75)',
|
|
1484
|
+
borderRadius: 12,
|
|
1485
|
+
pointerEvents: 'none',
|
|
1486
|
+
boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
|
|
1487
|
+
}, children: _jsxs("div", { style: {
|
|
1488
|
+
position: 'absolute',
|
|
1489
|
+
left: '50%',
|
|
1490
|
+
top: 18,
|
|
1491
|
+
transform: 'translateX(-50%)',
|
|
1492
|
+
background: 'rgba(7, 11, 22, 0.88)',
|
|
1493
|
+
border: `1px solid ${theme.border}`,
|
|
1494
|
+
borderRadius: 8,
|
|
1495
|
+
padding: '8px 14px',
|
|
1496
|
+
fontSize: 13,
|
|
1497
|
+
color: theme.text,
|
|
1498
|
+
whiteSpace: 'nowrap',
|
|
1499
|
+
}, children: ["Drop ", assetDragHover.count === 1 ? 'effect' : `${assetDragHover.count} effects`, " \u2014", ' ', assetDragHover.label] }) })), assetsLoading && (_jsx("div", { style: {
|
|
1500
|
+
position: 'absolute',
|
|
1501
|
+
inset: 0,
|
|
972
1502
|
display: 'flex',
|
|
973
|
-
|
|
974
|
-
|
|
1503
|
+
alignItems: 'center',
|
|
1504
|
+
justifyContent: 'center',
|
|
1505
|
+
background: 'rgba(7, 11, 22, 0.72)',
|
|
1506
|
+
pointerEvents: 'none',
|
|
1507
|
+
}, children: _jsxs("div", { style: {
|
|
1508
|
+
background: theme.panelBg,
|
|
1509
|
+
border: `1px solid ${theme.border}`,
|
|
1510
|
+
borderRadius: 10,
|
|
1511
|
+
padding: '16px 22px',
|
|
1512
|
+
textAlign: 'center',
|
|
1513
|
+
}, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 6 }, children: "Importing assets\u2026" }), _jsxs("div", { style: { fontSize: 13, color: theme.textDim }, children: [assetsLoading.done, " / ", assetsLoading.total || '…', " files", assetsLoading.failed > 0 ? ` · ${assetsLoading.failed} skipped` : ''] })] }) })), prefabInfo && (_jsxs("div", { style: {
|
|
1514
|
+
position: 'absolute',
|
|
1515
|
+
top: 12,
|
|
1516
|
+
left: '50%',
|
|
1517
|
+
transform: 'translateX(-50%)',
|
|
1518
|
+
display: 'flex',
|
|
1519
|
+
alignItems: 'center',
|
|
1520
|
+
gap: 8,
|
|
1521
|
+
padding: '4px 4px 4px 6px',
|
|
1522
|
+
background: theme.panelBg,
|
|
1523
|
+
border: `1px solid ${theme.borderActive}`,
|
|
1524
|
+
borderRadius: 8,
|
|
1525
|
+
fontSize: 12.5,
|
|
1526
|
+
zIndex: 5,
|
|
1527
|
+
}, children: [_jsx("button", { type: "button", className: "qe-hover", title: "Back to the scene", style: { ...buttonStyle, padding: '3px 10px' }, onClick: requestPrefabExit, children: "\u2190 Scene" }), _jsx("span", { style: { color: theme.textDim }, children: "\u203A" }), _jsxs("span", { style: { fontWeight: 600, whiteSpace: 'nowrap' }, children: [prefabInfo.name, prefabDirty ? ' *' : ''] }), _jsx("button", { type: "button", className: "qe-hover", title: "Overwrite the prefab asset (Ctrl+S)", style: {
|
|
1528
|
+
...buttonStyle,
|
|
1529
|
+
padding: '3px 12px',
|
|
1530
|
+
...(prefabDirty
|
|
1531
|
+
? {
|
|
1532
|
+
background: theme.accent,
|
|
1533
|
+
color: '#0a1128',
|
|
1534
|
+
border: `1px solid ${theme.accent}`,
|
|
1535
|
+
}
|
|
1536
|
+
: {}),
|
|
1537
|
+
}, onClick: () => setPrefabDialog('overwrite'), children: "Save" })] })), props.backLink && !prefabInfo && (_jsxs("a", { className: "qe-hover", href: props.backLink.href, style: {
|
|
1538
|
+
position: 'absolute',
|
|
1539
|
+
top: 12,
|
|
1540
|
+
left: 12,
|
|
1541
|
+
display: 'inline-flex',
|
|
1542
|
+
alignItems: 'center',
|
|
1543
|
+
gap: 6,
|
|
975
1544
|
background: theme.panelBg,
|
|
976
1545
|
border: `1px solid ${theme.border}`,
|
|
977
1546
|
borderRadius: 8,
|
|
978
|
-
padding:
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1547
|
+
padding: '6px 12px',
|
|
1548
|
+
fontSize: 13,
|
|
1549
|
+
fontWeight: 500,
|
|
1550
|
+
color: theme.text,
|
|
1551
|
+
textDecoration: 'none',
|
|
1552
|
+
fontFamily: theme.font,
|
|
1553
|
+
}, children: [_jsx(ArrowLeftIcon, { style: iconStyle(14) }), props.backLink.label.replace(/^←\s*/, '')] })), _jsxs("div", { style: {
|
|
1554
|
+
position: 'absolute',
|
|
1555
|
+
top: 12,
|
|
1556
|
+
right: 12,
|
|
1557
|
+
display: 'flex',
|
|
1558
|
+
flexDirection: 'column',
|
|
1559
|
+
alignItems: 'flex-end',
|
|
1560
|
+
gap: 6,
|
|
1561
|
+
}, children: [_jsxs("div", { style: {
|
|
1562
|
+
display: 'flex',
|
|
1563
|
+
gap: 4,
|
|
1564
|
+
background: theme.panelBg,
|
|
1565
|
+
border: `1px solid ${theme.border}`,
|
|
1566
|
+
borderRadius: 8,
|
|
1567
|
+
padding: 4,
|
|
1568
|
+
}, children: [_jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo || !binding, onClick: () => actionsRef.current.historyStep(false), children: _jsx(ArrowUturnLeftIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo || !binding, onClick: () => actionsRef.current.historyStep(true), children: _jsx(ArrowUturnRightIcon, { style: iconStyle(14) }) })] }), _jsx("div", { style: {
|
|
1569
|
+
display: 'flex',
|
|
1570
|
+
flexDirection: 'column',
|
|
1571
|
+
gap: 4,
|
|
1572
|
+
background: theme.panelBg,
|
|
1573
|
+
border: `1px solid ${theme.border}`,
|
|
1574
|
+
borderRadius: 8,
|
|
1575
|
+
padding: 4,
|
|
1576
|
+
}, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: {
|
|
1577
|
+
...squareButtonStyle,
|
|
1578
|
+
...(gizmoMode === m.value ? activeSquareButtonStyle : {}),
|
|
1579
|
+
}, title: m.title, onClick: () => setGizmoMode(m.value), children: _jsx(m.Icon, { style: iconStyle(14) }) }, m.value))) }), _jsx("div", { style: {
|
|
1580
|
+
display: 'flex',
|
|
1581
|
+
flexDirection: 'column',
|
|
1582
|
+
gap: 4,
|
|
1583
|
+
background: theme.panelBg,
|
|
1584
|
+
border: `1px solid ${theme.border}`,
|
|
1585
|
+
borderRadius: 8,
|
|
1586
|
+
padding: 4,
|
|
1587
|
+
}, children: _jsx("button", { className: "qe-hover", style: {
|
|
1588
|
+
...squareButtonStyle,
|
|
1589
|
+
...(showEmitterWireframes ? activeSquareButtonStyle : {}),
|
|
1590
|
+
}, title: "Show emitter shape wireframes", disabled: !binding, onClick: () => setShowEmitterWireframes((on) => !on), children: _jsx(CubeTransparentIcon, { style: iconStyle(14) }) }) })] })] }), _jsxs("aside", { style: {
|
|
1591
|
+
width: 340,
|
|
1592
|
+
flexShrink: 0,
|
|
1593
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
1594
|
+
background: theme.panelBg,
|
|
1595
|
+
display: 'flex',
|
|
1596
|
+
flexDirection: 'column',
|
|
1597
|
+
minHeight: 0,
|
|
1598
|
+
}, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: {
|
|
1599
|
+
fontSize: 11,
|
|
1600
|
+
fontWeight: 600,
|
|
1601
|
+
color: theme.textDim,
|
|
1602
|
+
textTransform: 'uppercase',
|
|
1603
|
+
letterSpacing: 0.4,
|
|
1604
|
+
marginBottom: 6,
|
|
1605
|
+
}, children: "Inspector" }), _jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: {
|
|
1606
|
+
fontSize: 16,
|
|
1607
|
+
fontWeight: 600,
|
|
1608
|
+
cursor: binding ? 'pointer' : 'default',
|
|
1609
|
+
borderRadius: 6,
|
|
1610
|
+
padding: '2px 4px',
|
|
1611
|
+
margin: '-2px -4px',
|
|
1612
|
+
}, onClick: () => binding && setRenamingRoot(true), children: (binding?.root.name || props.title) ?? 'Effect editor' }), renamingRoot && binding && (_jsx(PromptDialog, { title: "Rename effect", defaultValue: binding.root.name, onSubmit: (name) => {
|
|
1613
|
+
binding.apply(() => (binding.root.name = name));
|
|
1614
|
+
const entry = sceneEntries.find((item) => item.root === binding.root);
|
|
1615
|
+
if (entry) {
|
|
1616
|
+
entry.name = name;
|
|
1617
|
+
}
|
|
1618
|
+
setRenamingRoot(false);
|
|
1619
|
+
}, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => {
|
|
1620
|
+
if (!binding)
|
|
1621
|
+
return;
|
|
1622
|
+
const blob = new Blob([binding.exportJSON('EditorEffect')], {
|
|
1623
|
+
type: 'application/json',
|
|
1624
|
+
});
|
|
1625
|
+
const link = document.createElement('a');
|
|
1626
|
+
link.href = URL.createObjectURL(blob);
|
|
1627
|
+
link.download = `${binding.root.name || 'effect'}.json`;
|
|
1628
|
+
link.click();
|
|
1629
|
+
URL.revokeObjectURL(link.href);
|
|
1630
|
+
}, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: inspectorBinding && inspectorSystem && inspectorNode ? (_jsx(EffectEditor, { binding: inspectorBinding, selectedSystem: inspectorSystem, gizmoTargetNode: inspectorNode, gizmoTargetLabel: inspectorNode.name || 'Node', textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), _jsxs("div", { "aria-hidden": !drawerOpen && !drawerPinned, style: {
|
|
1631
|
+
height: DRAWER_HEIGHT,
|
|
987
1632
|
display: 'flex',
|
|
988
1633
|
flexDirection: 'column',
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1634
|
+
background: theme.panelBg,
|
|
1635
|
+
borderTop: `1px solid ${theme.border}`,
|
|
1636
|
+
...(drawerPinned
|
|
1637
|
+
? { flexShrink: 0 }
|
|
1638
|
+
: {
|
|
1639
|
+
position: 'absolute',
|
|
1640
|
+
left: 0,
|
|
1641
|
+
right: 0,
|
|
1642
|
+
bottom: 0,
|
|
1643
|
+
zIndex: 20,
|
|
1644
|
+
background: '#0a0f20',
|
|
1645
|
+
boxShadow: '0 -10px 30px rgba(0, 0, 0, 0.5)',
|
|
1646
|
+
transform: drawerOpen ? 'none' : 'translateY(100%)',
|
|
1647
|
+
opacity: drawerOpen ? 1 : 0,
|
|
1648
|
+
pointerEvents: drawerOpen ? 'auto' : 'none',
|
|
1649
|
+
transition: 'transform 0.18s ease, opacity 0.18s ease',
|
|
1650
|
+
}),
|
|
1651
|
+
}, children: [_jsxs("div", { style: {
|
|
1652
|
+
display: 'flex',
|
|
1653
|
+
alignItems: 'center',
|
|
1654
|
+
gap: 6,
|
|
1655
|
+
padding: '4px 6px 4px 12px',
|
|
1656
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
1657
|
+
}, children: [_jsx("span", { style: dockTitleStyle, children: "Assets" }), _jsx("span", { style: { fontSize: 11, color: theme.textDim }, children: "Ctrl+Space" }), _jsx("span", { style: { flex: 1 } }), _jsx("button", { type: "button", className: "qe-hover", title: drawerPinned
|
|
1658
|
+
? 'Unpin — float over the viewport again'
|
|
1659
|
+
: 'Pin — keep it docked above the timeline', style: { ...dockIconButtonStyle, ...(drawerPinned ? activeSquareButtonStyle : {}) }, onClick: () => {
|
|
1660
|
+
setDrawerPinned((pinned) => !pinned);
|
|
1661
|
+
setDrawerOpen(true);
|
|
1662
|
+
}, children: _jsx(MapPinIcon, { style: iconStyle(13) }) }), _jsx("button", { type: "button", className: "qe-hover", title: "Close (Esc)", style: dockIconButtonStyle, onClick: () => {
|
|
1663
|
+
setDrawerPinned(false);
|
|
1664
|
+
setDrawerOpen(false);
|
|
1665
|
+
}, children: _jsx(XMarkIcon, { style: iconStyle(13) }) })] }), _jsx(AssetsPanel, { assets: assets, instanceCounts: instanceCounts, loading: assetsLoading, folderInputRef: assetFolderInputRef, onImportFiles: (files) => void runAssetImport(files), onImportFolder: handleImportFolder, onAddToScene: (payload) => actionsRef.current.addToScene(payload), onOpenPrefab: (asset) => actionsRef.current.openPrefab(asset.id), onDelete: (payload) => actionsRef.current.deleteAssets(payload) })] })] }), _jsxs("div", { style: {
|
|
1666
|
+
flexShrink: 0,
|
|
1667
|
+
borderTop: `1px solid ${theme.border}`,
|
|
1668
|
+
background: theme.panelBg,
|
|
1669
|
+
display: 'flex',
|
|
1670
|
+
flexDirection: 'column',
|
|
1671
|
+
}, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '3px 6px' }, children: [_jsx("button", { type: "button", className: "qe-hover-bg", title: timelineCollapsed ? 'Expand timeline' : 'Collapse timeline', style: { ...dockIconButtonStyle, border: 'none', background: 'transparent' }, onClick: () => setTimelineCollapsed((collapsed) => !collapsed), children: timelineCollapsed ? (_jsx(ChevronUpIcon, { style: iconStyle(13) })) : (_jsx(ChevronDownIcon, { style: iconStyle(13) })) }), _jsx("span", { style: dockTitleStyle, children: "Timeline" }), binding && (_jsx("span", { style: {
|
|
1672
|
+
fontSize: 12,
|
|
1673
|
+
color: theme.textDim,
|
|
1674
|
+
overflow: 'hidden',
|
|
1675
|
+
textOverflow: 'ellipsis',
|
|
1676
|
+
whiteSpace: 'nowrap',
|
|
1677
|
+
}, children: binding.root.name })), _jsx("span", { style: { flex: 1 } }), _jsxs("button", { type: "button", className: "qe-hover", title: "Assets (Ctrl+Space)", style: {
|
|
1678
|
+
...buttonStyle,
|
|
1679
|
+
padding: '3px 10px',
|
|
1680
|
+
...(drawerOpen || drawerPinned ? activeSquareButtonStyle : {}),
|
|
1681
|
+
}, onClick: () => {
|
|
1682
|
+
if (drawerPinned) {
|
|
1683
|
+
setDrawerPinned(false);
|
|
1684
|
+
setDrawerOpen(false);
|
|
1685
|
+
}
|
|
1686
|
+
else {
|
|
1687
|
+
setDrawerOpen((open) => !open);
|
|
1688
|
+
}
|
|
1689
|
+
}, children: ["Assets", assets.length > 0 ? ` · ${assets.length}` : ''] })] }), _jsx("div", { style: {
|
|
1690
|
+
height: TIMELINE_HEIGHT,
|
|
1691
|
+
display: timelineCollapsed ? 'none' : 'flex',
|
|
1692
|
+
borderTop: `1px solid ${theme.border}`,
|
|
1693
|
+
}, children: showTimeline && binding && activeSystem && state ? (_jsx(TimelinePanel, { docked: true, showTransport: false, binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: timelineCounterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })) : (_jsx("div", { style: { padding: '14px', fontSize: 12, color: theme.textDim }, children: "Select an effect in the Scene to see its timeline." })) })] }), projectLoading && (_jsxs("div", { style: {
|
|
1694
|
+
position: 'fixed',
|
|
1695
|
+
inset: 0,
|
|
1696
|
+
display: 'flex',
|
|
1697
|
+
alignItems: 'center',
|
|
1698
|
+
justifyContent: 'center',
|
|
1699
|
+
background: 'rgba(7, 11, 22, 0.8)',
|
|
1700
|
+
zIndex: 900,
|
|
1701
|
+
color: theme.text,
|
|
1702
|
+
fontSize: 14,
|
|
1703
|
+
}, children: ["Opening ", props.project?.name ?? 'project', "\u2026"] })), toast && (_jsx("div", { role: "status", style: {
|
|
1704
|
+
position: 'fixed',
|
|
1705
|
+
top: 16,
|
|
1706
|
+
left: '50%',
|
|
1707
|
+
transform: 'translateX(-50%)',
|
|
1708
|
+
zIndex: 950,
|
|
1709
|
+
background: '#13204a',
|
|
1710
|
+
border: `1px solid ${theme.borderActive}`,
|
|
1711
|
+
borderRadius: 8,
|
|
1712
|
+
padding: '8px 14px',
|
|
1713
|
+
fontSize: 12.5,
|
|
1714
|
+
color: theme.text,
|
|
1715
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
|
1716
|
+
pointerEvents: 'none',
|
|
1717
|
+
}, children: toast })), saveAsTarget && (_jsx(PromptDialog, { title: "Save as new asset \u2014 Folder/Name", defaultValue: saveAsTarget.name, placeholder: "Fire/Big explosion", confirmLabel: "Save", onSubmit: (path) => {
|
|
1718
|
+
const entry = saveAsTarget;
|
|
1719
|
+
setSaveAsTarget(null);
|
|
1720
|
+
actionsRef.current.saveEntryAsAsset(entry, path);
|
|
1721
|
+
}, onCancel: () => setSaveAsTarget(null) })), prefabDialog === 'overwrite' && prefabInfo && (_jsx(ChoiceDialog, { title: `Overwrite prefab “${prefabInfo.name}”?`, message: (() => {
|
|
1722
|
+
const count = actionsRef.current.prefabInstanceCount();
|
|
1723
|
+
return count > 0
|
|
1724
|
+
? `The asset is replaced with your edits. The ${count} “${prefabInfo.name}” effect(s) already in the scene will not change.`
|
|
1725
|
+
: 'The asset is replaced with your edits. Effects already in the scene will not change.';
|
|
1726
|
+
})(), actions: [
|
|
1727
|
+
{ label: 'Cancel', onSelect: () => setPrefabDialog(null) },
|
|
1728
|
+
{ label: 'Save as new…', onSelect: () => setPrefabDialog('saveAs') },
|
|
1729
|
+
{
|
|
1730
|
+
label: 'Overwrite',
|
|
1731
|
+
primary: true,
|
|
1732
|
+
onSelect: () => {
|
|
1733
|
+
setPrefabDialog(null);
|
|
1734
|
+
actionsRef.current.overwritePrefab();
|
|
1735
|
+
},
|
|
1736
|
+
},
|
|
1737
|
+
], onCancel: () => setPrefabDialog(null) })), prefabSwitch && prefabInfo && (_jsx(ChoiceDialog, { title: `Save changes to prefab “${prefabInfo.name}”?`, message: `Before opening “${prefabSwitch.name}”. Saving overwrites the prefab asset; effects already in the scene will not change.`, actions: [
|
|
1738
|
+
{ label: 'Cancel', onSelect: () => setPrefabSwitch(null) },
|
|
1739
|
+
{
|
|
1740
|
+
label: "Don't save",
|
|
1741
|
+
danger: true,
|
|
1742
|
+
onSelect: () => {
|
|
1743
|
+
setPrefabSwitch(null);
|
|
1744
|
+
actionsRef.current.openPrefab(prefabSwitch.id, true);
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
label: 'Save',
|
|
1749
|
+
primary: true,
|
|
1750
|
+
onSelect: () => {
|
|
1751
|
+
setPrefabSwitch(null);
|
|
1752
|
+
actionsRef.current.overwritePrefab();
|
|
1753
|
+
actionsRef.current.openPrefab(prefabSwitch.id, true);
|
|
1754
|
+
},
|
|
1755
|
+
},
|
|
1756
|
+
], onCancel: () => setPrefabSwitch(null) })), prefabDialog === 'exit' && prefabInfo && (_jsx(ChoiceDialog, { title: `Save changes to prefab “${prefabInfo.name}”?`, message: "Saving overwrites the prefab asset. Effects already in the scene will not change.", actions: [
|
|
1757
|
+
{ label: 'Cancel', onSelect: () => setPrefabDialog(null) },
|
|
1758
|
+
{
|
|
1759
|
+
label: "Don't save",
|
|
1760
|
+
danger: true,
|
|
1761
|
+
onSelect: () => {
|
|
1762
|
+
setPrefabDialog(null);
|
|
1763
|
+
actionsRef.current.closePrefab();
|
|
1764
|
+
},
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
label: 'Save',
|
|
1768
|
+
primary: true,
|
|
1769
|
+
onSelect: () => {
|
|
1770
|
+
setPrefabDialog(null);
|
|
1771
|
+
actionsRef.current.overwritePrefab();
|
|
1772
|
+
actionsRef.current.closePrefab();
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
], onCancel: () => setPrefabDialog(null) })), prefabDialog === 'saveAs' && prefabInfo && (_jsx(PromptDialog, { title: "Save prefab as new asset \u2014 Folder/Name", defaultValue: prefabInfo.path.replace(/\.json$/i, ''), confirmLabel: "Save", onSubmit: (path) => {
|
|
1776
|
+
setPrefabDialog(null);
|
|
1777
|
+
actionsRef.current.savePrefabAsNew(path);
|
|
1778
|
+
}, onCancel: () => setPrefabDialog(null) })), exitPrompt && (_jsx(ChoiceDialog, { title: "Save changes to the scene?", message: "The scene has changes that are not saved. Assets are already saved.", actions: [
|
|
1779
|
+
{ label: 'Cancel', onSelect: () => setExitPrompt(false) },
|
|
1780
|
+
{
|
|
1781
|
+
label: "Don't save",
|
|
1782
|
+
danger: true,
|
|
1783
|
+
onSelect: () => {
|
|
1784
|
+
setExitPrompt(false);
|
|
1785
|
+
props.onExitProject?.();
|
|
1786
|
+
},
|
|
1787
|
+
},
|
|
1788
|
+
{
|
|
1789
|
+
label: 'Save',
|
|
1790
|
+
primary: true,
|
|
1791
|
+
onSelect: () => {
|
|
1792
|
+
setExitPrompt(false);
|
|
1793
|
+
void actionsRef.current.saveScene().then((saved) => saved && props.onExitProject?.());
|
|
1794
|
+
},
|
|
1795
|
+
},
|
|
1796
|
+
], onCancel: () => setExitPrompt(false) })), messageDialog && (_jsx(MessageDialog, { title: messageDialog.title, message: messageDialog.message, onClose: () => setMessageDialog(null) }))] }));
|
|
1019
1797
|
}
|
|
1020
1798
|
/** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
|
|
1021
1799
|
export const QuarksEffectEditor = {
|