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.
Files changed (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/assetDragSession.d.ts +4 -0
  3. package/dist/core/assetDragSession.js +8 -0
  4. package/dist/core/assetTree.d.ts +39 -0
  5. package/dist/core/assetTree.js +112 -0
  6. package/dist/core/assets.d.ts +56 -0
  7. package/dist/core/assets.js +207 -0
  8. package/dist/core/binding.d.ts +5 -0
  9. package/dist/core/binding.js +7 -0
  10. package/dist/core/collision.d.ts +4 -5
  11. package/dist/core/collision.js +4 -5
  12. package/dist/core/defaultEffect.d.ts +4 -5
  13. package/dist/core/defaultEffect.js +5 -14
  14. package/dist/core/emitterShapeWireframe.d.ts +1 -0
  15. package/dist/core/emitterShapeWireframe.js +1 -0
  16. package/dist/core/galleryPlayback.d.ts +7 -7
  17. package/dist/core/galleryPlayback.js +2 -2
  18. package/dist/core/galleryScene.d.ts +8 -13
  19. package/dist/core/galleryScene.js +14 -42
  20. package/dist/core/gallerySelectionMarker.d.ts +1 -0
  21. package/dist/core/gallerySelectionMarker.js +1 -0
  22. package/dist/core/history.d.ts +12 -1
  23. package/dist/core/history.js +16 -3
  24. package/dist/core/indexedDbProjectStore.d.ts +35 -0
  25. package/dist/core/indexedDbProjectStore.js +299 -0
  26. package/dist/core/lineShaders.d.ts +2 -0
  27. package/dist/core/lineShaders.js +7 -0
  28. package/dist/core/pickAssetFiles.d.ts +6 -0
  29. package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
  30. package/dist/core/projectStore.d.ts +61 -0
  31. package/dist/core/projectStore.js +14 -0
  32. package/dist/core/sceneEntries.d.ts +35 -0
  33. package/dist/core/sceneEntries.js +62 -0
  34. package/dist/core/transport.d.ts +12 -0
  35. package/dist/core/transport.js +36 -0
  36. package/dist/core/viewportPicker.d.ts +4 -4
  37. package/dist/core/viewportPicker.js +3 -3
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +2 -0
  40. package/dist/react/AssetsPanel.d.ts +22 -0
  41. package/dist/react/AssetsPanel.js +252 -0
  42. package/dist/react/ChoiceDialog.d.ts +18 -0
  43. package/dist/react/ChoiceDialog.js +48 -0
  44. package/dist/react/ContextMenu.d.ts +16 -0
  45. package/dist/react/ContextMenu.js +70 -0
  46. package/dist/react/EditorWorkspace.d.ts +21 -0
  47. package/dist/react/EditorWorkspace.js +63 -0
  48. package/dist/react/EffectEditor.d.ts +1 -1
  49. package/dist/react/EffectEditor.js +7 -4
  50. package/dist/react/EffectEditorHost.d.ts +19 -2
  51. package/dist/react/EffectEditorHost.js +1385 -607
  52. package/dist/react/EffectTreeView.d.ts +17 -0
  53. package/dist/react/EffectTreeView.js +52 -0
  54. package/dist/react/ObjectModule.d.ts +4 -0
  55. package/dist/react/ObjectModule.js +7 -10
  56. package/dist/react/ProjectsPage.d.ts +9 -0
  57. package/dist/react/ProjectsPage.js +140 -0
  58. package/dist/react/ScenePanel.d.ts +26 -0
  59. package/dist/react/ScenePanel.js +134 -0
  60. package/dist/react/TimelinePanel.d.ts +8 -1
  61. package/dist/react/TimelinePanel.js +20 -41
  62. package/dist/react/TransportBar.d.ts +24 -0
  63. package/dist/react/TransportBar.js +43 -0
  64. package/dist/react/behaviorModules.js +5 -7
  65. package/dist/react/index.d.ts +16 -2
  66. package/dist/react/index.js +8 -1
  67. package/dist/react/inspectorHints.js +1 -2
  68. package/package.json +1 -1
  69. package/src/core/assetDragSession.ts +12 -0
  70. package/src/core/assetTree.ts +148 -0
  71. package/src/core/assets.ts +262 -0
  72. package/src/core/binding.ts +8 -0
  73. package/src/core/collision.ts +4 -5
  74. package/src/core/defaultEffect.ts +9 -22
  75. package/src/core/emitterShapeWireframe.ts +1 -0
  76. package/src/core/galleryPlayback.ts +9 -9
  77. package/src/core/galleryScene.ts +15 -59
  78. package/src/core/gallerySelectionMarker.ts +1 -0
  79. package/src/core/history.ts +24 -3
  80. package/src/core/indexedDbProjectStore.ts +357 -0
  81. package/src/core/lineShaders.ts +7 -0
  82. package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
  83. package/src/core/projectStore.ts +81 -0
  84. package/src/core/sceneEntries.ts +96 -0
  85. package/src/core/transport.ts +53 -0
  86. package/src/core/viewportPicker.ts +5 -5
  87. package/src/index.ts +11 -0
  88. package/src/react/AssetsPanel.tsx +559 -0
  89. package/src/react/ChoiceDialog.tsx +93 -0
  90. package/src/react/ContextMenu.tsx +107 -0
  91. package/src/react/EditorWorkspace.tsx +95 -0
  92. package/src/react/EffectEditor.tsx +15 -4
  93. package/src/react/EffectEditorHost.tsx +1922 -813
  94. package/src/react/EffectTreeView.tsx +117 -0
  95. package/src/react/ObjectModule.tsx +17 -13
  96. package/src/react/ProjectsPage.tsx +306 -0
  97. package/src/react/ScenePanel.tsx +318 -0
  98. package/src/react/TimelinePanel.tsx +102 -103
  99. package/src/react/TransportBar.tsx +114 -0
  100. package/src/react/behaviorModules.tsx +7 -15
  101. package/src/react/index.ts +16 -2
  102. package/src/react/inspectorHints.ts +1 -2
  103. package/dist/core/galleryDragSession.d.ts +0 -4
  104. package/dist/core/galleryDragSession.js +0 -8
  105. package/dist/core/galleryTree.d.ts +0 -49
  106. package/dist/core/galleryTree.js +0 -141
  107. package/dist/core/loadEffectGallery.d.ts +0 -36
  108. package/dist/core/loadEffectGallery.js +0 -97
  109. package/dist/core/pickGalleryFiles.d.ts +0 -5
  110. package/dist/react/GalleryPanel.d.ts +0 -20
  111. package/dist/react/GalleryPanel.js +0 -253
  112. package/src/core/galleryDragSession.ts +0 -12
  113. package/src/core/galleryTree.ts +0 -190
  114. package/src/core/loadEffectGallery.ts +0 -150
  115. 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 { EffectBinding } from '../core/binding';
19
- import { ensureGroundResolver } from '../core/collision';
20
- import { createGalleryDefaultEntry } from '../core/defaultEffect';
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, countActivePreviews, countGalleryParticles, hasActivePreview, pauseFinishedPreviews, } from '../core/galleryPlayback';
26
- import { clearGalleryEntrySimulation, pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene, removeGalleryEntryFromScene, } from '../core/galleryScene';
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 { disposeLoadedEffect, loadEffectFromJson, parseEffectFromJson } from '../core/loadEffect';
31
- import { bindingFromGalleryEntry, collectJsonFiles, loadEffectGallery, mergeGalleryEntries, } from '../core/loadEffectGallery';
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 { GalleryPanel } from './GalleryPanel';
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
- /** Disposes an effect's systems and its remaining group tree (otherwise the groups leak). */
47
- function disposeEffect(root, systems, renderer, inRenderer = true) {
48
- disposeLoadedEffect(root, systems, renderer, inRenderer);
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
- const GALLERY_PANEL_WIDTH = 280;
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
- * Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
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 galleryFolderInputRef = useRef(null);
81
- const importJsonRef = useRef(() => { });
82
- const importGalleryRef = useRef(async () => { });
83
- const selectGalleryEntryRef = useRef(() => { });
84
- const toggleGalleryPreviewRef = useRef(() => { });
85
- const removeGalleryFromSceneRef = useRef(() => { });
86
- const placeGalleryDropRef = useRef(() => { });
87
- const createGalleryDefaultRef = useRef(() => { });
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 galleryDragDepthRef = useRef(0);
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 [gallery, setGallery] = useState([]);
105
- const [galleryLoading, setGalleryLoading] = useState(null);
106
- const [selectedGalleryRoot, setSelectedGalleryRoot] = useState(null);
107
- const [previewRootIds, setPreviewRootIds] = useState([]);
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 [galleryDragHover, setGalleryDragHover] = useState(null);
186
+ const [assetDragHover, setAssetDragHover] = useState(null);
110
187
  const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
111
- const isGalleryLoading = galleryLoading !== null;
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 JSON effects to the catalog without replacing the open effect. */
114
- const runGalleryImport = async (files) => {
115
- const jsonFiles = collectJsonFiles(files);
116
- if (jsonFiles.length === 0) {
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 importGalleryRef.current(jsonFiles);
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 to gallery', message);
130
- console.error('Failed to import effects into gallery:', err);
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
- galleryFolderInputRef.current?.click();
244
+ assetFolderInputRef.current?.click();
137
245
  return;
138
246
  }
139
247
  void (async () => {
140
248
  try {
141
- const picked = await pickGalleryJsonFiles();
249
+ const picked = await pickAssetFolderFiles();
142
250
  if (picked === null || picked === undefined) {
143
251
  return;
144
252
  }
145
- await runGalleryImport(picked);
253
+ await runAssetImport(picked);
146
254
  }
147
255
  catch (err) {
148
256
  const message = err instanceof Error ? err.message : String(err);
149
- showMessage('Import to gallery', message);
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), and the grid gives
165
- // a visual floor at the same y=0 plane the Collision module's ground resolver uses.
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
- const galleryRoot = new TransformNode('GalleryCatalog', scene);
188
- galleryRoot.setEnabled(false);
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 (galleryDragDepthRef.current > 0) {
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 focusedRoot = stateRef.current?.binding?.root ?? null;
208
- viewportPicker.setHover(effectRoot && effectRoot !== focusedRoot ? effectRoot : null);
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 snap = stateRef.current;
219
- if (!snap) {
220
- return;
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 clearGalleryDragPreview = () => {
229
- galleryDragDepthRef.current = 0;
341
+ const clearAssetDragPreview = () => {
342
+ assetDragDepthRef.current = 0;
230
343
  dropMarker.hide();
231
- setGalleryDragHover(null);
232
- setGalleryDragSession(null);
344
+ setAssetDragHover(null);
345
+ setAssetDragSession(null);
233
346
  };
234
- const onWindowDragEnd = () => clearGalleryDragPreview();
347
+ const onWindowDragEnd = () => clearAssetDragPreview();
235
348
  window.addEventListener('dragend', onWindowDragEnd);
236
- const syncStateRef = (bindingValue) => {
237
- stateRef.current = {
238
- engine,
239
- scene,
240
- camera,
241
- ground,
242
- renderer,
243
- binding: bindingValue,
244
- history,
245
- galleryRoot,
246
- galleryEntries,
247
- previewRootIds: stateRef.current?.previewRootIds ?? [],
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 focusCatalogEntry = (entry) => {
251
- const next = bindingFromGalleryEntry(entry);
252
- history.clear();
253
- history.attach(next);
254
- next.subscribe(force);
255
- syncStateRef(next);
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
- setSelectedNode(entry.root);
258
- setSelectedGalleryRoot(entry.root);
259
- playbackRef.current.focusRootId = entry.root.uniqueId;
260
- if (entry.inScene) {
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
- focusGalleryEntry(camera, entry.root);
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
- const toggleGalleryPreview = (entry) => {
269
- if (!entry.inScene) {
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 removing = (stateRef.current?.previewRootIds ?? []).includes(id);
274
- setPreviewRootIds((prev) => {
275
- const next = new Set(prev);
276
- if (next.has(id)) {
277
- next.delete(id);
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
- next.add(id);
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
- renderer.refreshBatches();
294
- scene.render();
295
- force();
489
+ refreshPlayback();
296
490
  };
297
- const removeGalleryFromScene = (entry) => {
298
- if (!entry.inScene) {
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
- removeGalleryEntryFromScene(entry, renderer, galleryRoot);
303
- setPreviewRootIds((prev) => {
304
- const next = prev.filter((rootId) => rootId !== id);
305
- if (stateRef.current) {
306
- stateRef.current.previewRootIds = next;
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
- setGallery(galleryEntries);
317
- if (stateRef.current?.binding?.root === entry.root) {
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
- applyGalleryPlayback(galleryEntries, new Set(stateRef.current?.previewRootIds ?? []), playbackRef.current.stagePlaying, playbackRef.current.focusFinishedByRoot);
321
- renderer.refreshBatches();
322
- scene.render();
323
- force();
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 addGalleryPreview = (...entries) => {
326
- const ids = entries.filter((entry) => entry.inScene).map((entry) => entry.root.uniqueId);
327
- if (ids.length === 0) {
564
+ const createDefault = () => {
565
+ if (refuseInPrefabMode()) {
328
566
  return;
329
567
  }
330
- setPreviewRootIds((prev) => {
331
- const next = [...new Set([...prev, ...ids])];
332
- if (stateRef.current) {
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
- const placeGalleryDropAt = (clientX, clientY, dataTransfer) => {
339
- const payload = (dataTransfer && readGalleryDrag(dataTransfer)) ?? getGalleryDragSession();
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 { entries: toPlace } = describeGalleryDrag(galleryEntries, payload);
344
- if (toPlace.length === 0) {
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
- const position = pickGroundPosition(scene, clientX, clientY);
348
- if (toPlace.length === 1) {
349
- placeGalleryEntryInScene(toPlace[0], renderer, position);
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 mount = (nextBinding) => {
367
- nextBinding.root.parent = null;
368
- history.attach(nextBinding);
369
- nextBinding.subscribe(force);
370
- syncStateRef(nextBinding);
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 appendGalleryFromFiles = async (files) => {
376
- setGalleryLoading({ done: 0, total: 0, loaded: 0, failed: 0 });
377
- const incoming = await loadEffectGallery(scene, files, galleryRoot, (progress) => setGalleryLoading(progress));
378
- const { merged, added } = mergeGalleryEntries(galleryEntries, incoming, renderer);
379
- galleryEntries = merged;
380
- if (stateRef.current) {
381
- stateRef.current.galleryRoot = galleryRoot;
382
- stateRef.current.galleryEntries = merged;
383
- }
384
- setGalleryLoading(null);
385
- setGallery(merged);
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 the catalog.');
626
+ throw new Error('All selected effects are already in Assets.');
391
627
  }
392
- focusCatalogEntry(added[0]);
393
- props.onReady?.(makeHandle());
394
628
  };
395
- const appendGalleryDefault = () => {
396
- const entry = createGalleryDefaultEntry(scene, galleryRoot, galleryEntries, {
397
- resolveTexture: props.resolveTexture,
398
- textureUrl: props.textureOptions?.[0]?.url,
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
- galleryEntries = [...galleryEntries, entry];
401
- if (stateRef.current) {
402
- stateRef.current.galleryRoot = galleryRoot;
403
- stateRef.current.galleryEntries = galleryEntries;
909
+ if (systems.length === 0) {
910
+ disposeSceneEntry({ ...prev, root, systems }, renderer);
911
+ force();
912
+ return;
404
913
  }
405
- setGallery(galleryEntries);
406
- focusCatalogEntry(entry);
407
- props.onReady?.(makeHandle());
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 = stateRef.current?.binding;
948
+ const b = state.binding;
412
949
  if (!b) {
413
- throw new Error('Gallery mode — import a single JSON to edit one effect.');
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
- importGallery: appendGalleryFromFiles,
958
+ importAssets,
959
+ importGallery: importAssets,
422
960
  exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
423
961
  });
424
- const importJson = (json) => {
425
- const current = stateRef.current?.binding;
426
- const { root, systems } = loadEffectFromJson(scene, renderer, json);
427
- if (systems.length === 0) {
428
- disposeEffect(root, systems, renderer);
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
- if (current) {
432
- const catalogEntry = stateRef.current?.galleryEntries.find((entry) => entry.root === current.root);
433
- disposeEffect(current.root, [current.system, ...current.subSystems], renderer, catalogEntry?.inScene ?? true);
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
- const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
436
- const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
437
- setSelectedGalleryRoot(null);
438
- setPreviewRootIds([]);
439
- selectionMarker.hide();
440
- mount(next);
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
- importJsonRef.current = importJson;
444
- importGalleryRef.current = appendGalleryFromFiles;
445
- selectGalleryEntryRef.current = focusCatalogEntry;
446
- toggleGalleryPreviewRef.current = toggleGalleryPreview;
447
- removeGalleryFromSceneRef.current = removeGalleryFromScene;
448
- placeGalleryDropRef.current = placeGalleryDropAt;
449
- createGalleryDefaultRef.current = appendGalleryDefault;
450
- const galleryDefaults = {
451
- resolveTexture: props.resolveTexture,
452
- textureUrl: props.textureOptions?.[0]?.url,
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
- let initialEntry;
455
- if (props.effectJson) {
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
- initialEntry = createGalleryDefaultEntry(scene, galleryRoot, [], galleryDefaults);
463
- placeGalleryEntryInScene(initialEntry, renderer, BVector3.Zero());
1009
+ else if (props.effectJson) {
1010
+ addEntries([openJson(props.effectJson)], BVector3.Zero());
464
1011
  }
465
- galleryEntries = [initialEntry];
466
- const initialPreviewIds = initialEntry.inScene ? [initialEntry.root.uniqueId] : [];
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 snapshot = stateRef.current;
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 && snapshot) {
487
- const previewIds = new Set(snapshot.previewRootIds);
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(snapshot.galleryEntries, previewIds, playback.focusFinishedByRoot)) {
1032
+ if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
493
1033
  needsRefresh = true;
494
1034
  }
495
- const binding = snapshot.binding;
496
- const focusId = playback.focusRootId ?? binding?.root.uniqueId ?? null;
1035
+ const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
497
1036
  if (focusId != null &&
498
- binding &&
1037
+ focusBinding &&
499
1038
  playback.stagePlaying &&
500
1039
  !playback.focusFinishedByRoot.has(focusId) &&
501
1040
  !focusTimelineMetaRef.current.anyLooping &&
502
- QuarksUtil.isEffectFinished(binding.root)) {
1041
+ QuarksUtil.isEffectFinished(focusBinding.root)) {
503
1042
  const settleAt = getFocusElapsed(playback, focusId);
504
- scrubFocusToSettled(binding, renderer, settleAt);
1043
+ scrubFocusToSettled(focusBinding, renderer, settleAt);
505
1044
  markFocusFinished(playback, focusId);
506
- pauseFocus(binding);
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
- if (snapshot.galleryEntries.length > 0) {
519
- logRenderMismatch(renderer, countGalleryParticles(snapshot.galleryEntries, previewIds));
520
- }
1057
+ logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
521
1058
  }
522
- else if (snapshot && snapshot.galleryEntries.length > 0) {
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
- viewportPickerRef.current?.follow();
530
- const wireframes = emitterWireframesRef.current;
531
- if (wireframes && snapshot?.binding) {
532
- const b = snapshot.binding;
533
- wireframes.sync([b.system, ...b.subSystems], `${b.root.uniqueId}:${b.getRevision()}`);
534
- }
535
- const picker = viewportPickerRef.current;
536
- if (picker && snapshot) {
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
- const previewIds = new Set(snapshot.previewRootIds);
550
- const binding = snapshot.binding;
551
- if (binding &&
552
- (binding.root.uniqueId !== focusTimelineMetaRef.current.rootId ||
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: binding.root.uniqueId,
557
- revision: binding.getRevision(),
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 (snapshot.galleryEntries.length > 0) {
563
- const playing = countActivePreviews(snapshot.galleryEntries, previewIds);
564
- const count = countGalleryParticles(snapshot.galleryEntries, previewIds);
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
- if (counterRef.current) {
572
- const focusLabel = focused ? ` · focus: ${focused.name}` : '';
573
- const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
574
- const timeLabel = meta.anyLooping
575
- ? `${focusElapsed.toFixed(1)}s ⟳`
576
- : focusEnded
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
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
1106
+ const key = e.key.toLowerCase();
1107
+ if ((e.ctrlKey || e.metaKey) && key === 's') {
604
1108
  e.preventDefault();
605
- const json = e.shiftKey ? history.redo() : history.undo();
606
- if (json) {
607
- importJson(json);
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
- clearGalleryDragPreview();
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
- const isCatalogEntry = snap.galleryEntries.length > 0;
647
- if (!isCatalogEntry) {
648
- return;
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, previewRootIds, gallery]);
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(() => notifyTransformChanged());
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 ?? stateRef.current?.binding?.system.emitter ?? null);
1292
+ setSelectedNode(stateRef.current?.binding?.root ?? null);
702
1293
  return;
703
1294
  }
704
- if (isGalleryLoading) {
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
- if (target) {
713
- gm.attachToNode(target);
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
- const gizmoTargetLabel = gizmoNode?.name || 'Node';
808
- const selectedGalleryEntry = selectedGalleryRoot
809
- ? (gallery.find((entry) => entry.root === selectedGalleryRoot) ?? null)
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
- /** Timeline is always shown when an effect is selected; playback only when it is on stage. */
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 = !selectedGalleryEntry || selectedGalleryEntry.inScene;
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", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("aside", { style: {
835
- width: GALLERY_PANEL_WIDTH,
836
- flexShrink: 0,
837
- borderRight: `1px solid ${theme.border}`,
838
- background: theme.panelBg,
839
- display: 'flex',
840
- flexDirection: 'column',
841
- minHeight: 0,
842
- }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsxs("div", { style: { fontSize: 14, fontWeight: 600 }, children: ["Gallery", gallery.length > 0 ? ` · ${gallery.length}` : ''] }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4 }, children: "Drag into viewport to place \u00B7 \u2713 preview \u00B7 \u00D7 remove from stage" })] }), _jsx("div", { style: {
843
- flex: 1,
844
- minHeight: 0,
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
- alignItems: 'flex-end',
952
- gap: 6,
953
- }, children: [_jsxs("div", { style: {
1363
+ minHeight: 0,
1364
+ }, children: [props.project && (_jsxs("div", { style: {
954
1365
  display: 'flex',
955
- gap: 4,
956
- background: theme.panelBg,
957
- border: `1px solid ${theme.border}`,
958
- borderRadius: 8,
959
- padding: 4,
960
- }, children: [_jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo || !binding, onClick: () => historyAction(state.history.undo()), children: _jsx(ArrowUturnLeftIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo || !binding, onClick: () => historyAction(state.history.redo()), children: _jsx(ArrowUturnRightIcon, { style: iconStyle(14) }) })] }), _jsx("div", { style: {
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
- gap: 4,
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: 4,
968
- }, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: {
969
- ...squareButtonStyle,
970
- ...(gizmoMode === m.value ? activeSquareButtonStyle : {}),
971
- }, title: m.title, onClick: () => setGizmoMode(m.value), children: _jsx(m.Icon, { style: iconStyle(14) }) }, m.value))) }), _jsx("div", { style: {
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
- flexDirection: 'column',
974
- gap: 4,
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: 4,
979
- }, children: _jsx("button", { className: "qe-hover", style: {
980
- ...squareButtonStyle,
981
- ...(showEmitterWireframes ? activeSquareButtonStyle : {}),
982
- }, title: "Show emitter shape wireframes", disabled: !binding, onClick: () => setShowEmitterWireframes((on) => !on), children: _jsx(CubeTransparentIcon, { style: iconStyle(14) }) }) })] })] }), _jsxs("aside", { style: {
983
- width: 340,
984
- flexShrink: 0,
985
- borderLeft: `1px solid ${theme.border}`,
986
- background: theme.panelBg,
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
- minHeight: 0,
990
- }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: {
991
- fontSize: 11,
992
- fontWeight: 600,
993
- color: theme.textDim,
994
- textTransform: 'uppercase',
995
- letterSpacing: 0.4,
996
- marginBottom: 6,
997
- }, children: "Inspector" }), _jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: {
998
- fontSize: 16,
999
- fontWeight: 600,
1000
- cursor: binding ? 'pointer' : 'default',
1001
- borderRadius: 6,
1002
- padding: '2px 4px',
1003
- margin: '-2px -4px',
1004
- }, 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) => {
1005
- binding.apply(() => (binding.root.name = name));
1006
- setRenamingRoot(false);
1007
- }, 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: () => {
1008
- if (!binding)
1009
- return;
1010
- const blob = new Blob([binding.exportJSON('EditorEffect')], {
1011
- type: 'application/json',
1012
- });
1013
- const link = document.createElement('a');
1014
- link.href = URL.createObjectURL(blob);
1015
- link.download = 'effect.json';
1016
- link.click();
1017
- URL.revokeObjectURL(link.href);
1018
- }, 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: binding && activeSystem && gizmoNode ? (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, gizmoTargetNode: gizmoNode, gizmoTargetLabel: gizmoTargetLabel, 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." })) })] })] }), showTimeline && binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })), messageDialog && (_jsx(MessageDialog, { title: messageDialog.title, message: messageDialog.message, onClose: () => setMessageDialog(null) }))] }));
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 = {