babylon.quarks-editor 0.17.8 → 0.18.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 (122) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/core/binding.js +2 -5
  4. package/dist/core/defaultEffect.d.ts +12 -0
  5. package/dist/core/defaultEffect.js +43 -0
  6. package/dist/core/editorScene.d.ts +7 -0
  7. package/dist/core/editorScene.js +11 -0
  8. package/dist/core/effectTree.js +2 -1
  9. package/dist/core/emitterShapeWireframe.d.ts +20 -0
  10. package/dist/core/emitterShapeWireframe.js +329 -0
  11. package/dist/core/galleryDragSession.d.ts +4 -0
  12. package/dist/core/galleryDragSession.js +8 -0
  13. package/dist/core/galleryDropMarker.d.ts +13 -0
  14. package/dist/core/galleryDropMarker.js +42 -0
  15. package/dist/core/galleryLayout.d.ts +20 -0
  16. package/dist/core/galleryLayout.js +68 -0
  17. package/dist/core/galleryPlayback.d.ts +14 -0
  18. package/dist/core/galleryPlayback.js +72 -0
  19. package/dist/core/galleryScene.d.ts +20 -0
  20. package/dist/core/galleryScene.js +65 -0
  21. package/dist/core/gallerySelectionMarker.d.ts +25 -0
  22. package/dist/core/gallerySelectionMarker.js +69 -0
  23. package/dist/core/galleryTree.d.ts +49 -0
  24. package/dist/core/galleryTree.js +138 -0
  25. package/dist/core/loadEffect.d.ts +13 -0
  26. package/dist/core/loadEffect.js +31 -17
  27. package/dist/core/loadEffectGallery.d.ts +36 -0
  28. package/dist/core/loadEffectGallery.js +97 -0
  29. package/dist/core/material.d.ts +13 -0
  30. package/dist/core/material.js +68 -0
  31. package/dist/core/pickGalleryFiles.d.ts +5 -0
  32. package/dist/core/pickGalleryFiles.js +42 -0
  33. package/dist/core/playback.d.ts +23 -0
  34. package/dist/core/playback.js +32 -0
  35. package/dist/core/renderDebug.d.ts +8 -0
  36. package/dist/core/renderDebug.js +42 -0
  37. package/dist/core/scrub.d.ts +14 -0
  38. package/dist/core/scrub.js +70 -0
  39. package/dist/core/timeline.d.ts +4 -1
  40. package/dist/core/timeline.js +30 -3
  41. package/dist/core/transformStore.d.ts +3 -0
  42. package/dist/core/transformStore.js +16 -0
  43. package/dist/core/viewportPicker.d.ts +25 -0
  44. package/dist/core/viewportPicker.js +86 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +1 -0
  47. package/dist/react/EffectEditor.js +1 -4
  48. package/dist/react/EffectEditorHost.d.ts +7 -0
  49. package/dist/react/EffectEditorHost.js +745 -115
  50. package/dist/react/GalleryPanel.d.ts +20 -0
  51. package/dist/react/GalleryPanel.js +246 -0
  52. package/dist/react/InspectorInfoIcon.d.ts +7 -0
  53. package/dist/react/InspectorInfoIcon.js +22 -0
  54. package/dist/react/InspectorLabel.d.ts +7 -0
  55. package/dist/react/InspectorLabel.js +12 -0
  56. package/dist/react/MessageDialog.d.ts +9 -0
  57. package/dist/react/MessageDialog.js +24 -0
  58. package/dist/react/ModuleSection.d.ts +2 -0
  59. package/dist/react/ModuleSection.js +8 -1
  60. package/dist/react/ObjectModule.js +14 -2
  61. package/dist/react/TimelinePanel.d.ts +8 -11
  62. package/dist/react/TimelinePanel.js +129 -98
  63. package/dist/react/TimelineTrackRow.js +29 -15
  64. package/dist/react/Tooltip.d.ts +6 -0
  65. package/dist/react/Tooltip.js +65 -0
  66. package/dist/react/ValueField.d.ts +2 -0
  67. package/dist/react/ValueField.js +1 -1
  68. package/dist/react/behaviorModules.js +8 -8
  69. package/dist/react/fields.d.ts +2 -0
  70. package/dist/react/fields.js +14 -5
  71. package/dist/react/icons.d.ts +7 -0
  72. package/dist/react/icons.js +10 -0
  73. package/dist/react/index.d.ts +4 -0
  74. package/dist/react/index.js +2 -0
  75. package/dist/react/inspectorHints.d.ts +2 -0
  76. package/dist/react/inspectorHints.js +138 -0
  77. package/dist/react/modules.js +109 -81
  78. package/dist/react/theme.d.ts +2 -0
  79. package/dist/react/theme.js +53 -1
  80. package/package.json +8 -4
  81. package/src/core/binding.ts +2 -5
  82. package/src/core/defaultEffect.ts +74 -0
  83. package/src/core/editorScene.ts +14 -0
  84. package/src/core/effectTree.ts +2 -1
  85. package/src/core/emitterShapeWireframe.ts +373 -0
  86. package/src/core/galleryDragSession.ts +12 -0
  87. package/src/core/galleryDropMarker.ts +57 -0
  88. package/src/core/galleryLayout.ts +90 -0
  89. package/src/core/galleryPlayback.ts +90 -0
  90. package/src/core/galleryScene.ts +92 -0
  91. package/src/core/gallerySelectionMarker.ts +84 -0
  92. package/src/core/galleryTree.ts +187 -0
  93. package/src/core/loadEffect.ts +55 -18
  94. package/src/core/loadEffectGallery.ts +146 -0
  95. package/src/core/material.ts +84 -0
  96. package/src/core/pickGalleryFiles.ts +58 -0
  97. package/src/core/playback.ts +49 -0
  98. package/src/core/renderDebug.ts +46 -0
  99. package/src/core/scrub.ts +76 -1
  100. package/src/core/timeline.ts +34 -3
  101. package/src/core/transformStore.ts +19 -0
  102. package/src/core/viewportPicker.ts +114 -0
  103. package/src/index.ts +2 -0
  104. package/src/react/EffectEditor.tsx +5 -7
  105. package/src/react/EffectEditorHost.tsx +936 -142
  106. package/src/react/GalleryPanel.tsx +517 -0
  107. package/src/react/InspectorInfoIcon.tsx +36 -0
  108. package/src/react/InspectorLabel.tsx +18 -0
  109. package/src/react/MessageDialog.tsx +57 -0
  110. package/src/react/ModuleSection.tsx +17 -2
  111. package/src/react/ObjectModule.tsx +17 -6
  112. package/src/react/TimelinePanel.tsx +227 -174
  113. package/src/react/TimelineTrackRow.tsx +58 -23
  114. package/src/react/Tooltip.tsx +93 -0
  115. package/src/react/ValueField.tsx +3 -1
  116. package/src/react/behaviorModules.tsx +13 -8
  117. package/src/react/fields.tsx +15 -6
  118. package/src/react/icons.ts +19 -0
  119. package/src/react/index.ts +4 -0
  120. package/src/react/inspectorHints.ts +150 -0
  121. package/src/react/modules.tsx +198 -183
  122. package/src/react/theme.ts +54 -1
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useReducer, useRef, useState } from 'react';
2
+ import { useEffect, useMemo, useReducer, useRef, useState, useCallback } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
+ import { ArrowLeftIcon, ArrowPathIcon, ArrowUturnLeftIcon, ArrowUturnRightIcon, ArrowsPointingOutIcon, CubeTransparentIcon, CursorArrowRaysIcon, Square2StackIcon, } from '@heroicons/react/24/solid';
5
+ import { PointerEventTypes } from '@babylonjs/core/Events/pointerEvents';
4
6
  import { Engine } from '@babylonjs/core/Engines/engine';
5
7
  import { Scene } from '@babylonjs/core/scene';
6
8
  import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
@@ -12,55 +14,45 @@ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
12
14
  import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
13
15
  import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
14
16
  import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
15
- import { BatchedRenderer, ColorOverLife, ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
17
+ import { BatchedRenderer, QuarksUtil, } from 'babylon.quarks';
16
18
  import { EffectBinding } from '../core/binding';
17
19
  import { EffectHistory } from '../core/history';
18
20
  import { ensureGroundResolver } from '../core/collision';
19
- import { loadEffectFromJson } from '../core/loadEffect';
20
- import { DEFAULT_GRADIENT_STOPS, buildGradient } from '../core/colors';
21
+ import { createGalleryDefaultEntry } from '../core/defaultEffect';
22
+ import { loadEffectFromJson, disposeLoadedEffect, parseEffectFromJson } from '../core/loadEffect';
23
+ import { applyGalleryPlayback, countActivePreviews, countGalleryParticles, hasActivePreview, pauseFinishedPreviews } from '../core/galleryPlayback';
24
+ import { createPlaybackState, clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed } from '../core/playback';
25
+ import { pauseFocus, scrubFocusToSettled } from '../core/scrub';
26
+ import { logRenderMismatch } from '../core/renderDebug';
27
+ import { cancelGalleryCameraFocus, focusGalleryEntry } from '../core/galleryLayout';
28
+ import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
29
+ import { GalleryDropMarker } from '../core/galleryDropMarker';
30
+ import { GallerySelectionMarker } from '../core/gallerySelectionMarker';
31
+ import { EmitterShapeWireframes } from '../core/emitterShapeWireframe';
32
+ import { getGalleryDragSession, setGalleryDragSession } from '../core/galleryDragSession';
33
+ import { clearGalleryEntrySimulation, pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene, removeGalleryEntryFromScene, } from '../core/galleryScene';
34
+ import { describeGalleryDrag, GALLERY_DRAG_MIME, readGalleryDrag } from '../core/galleryTree';
35
+ import { loadEffectGallery, collectJsonFiles, bindingFromGalleryEntry, mergeGalleryEntries } from '../core/loadEffectGallery';
36
+ import { pickGalleryJsonFiles } from '../core/pickGalleryFiles';
37
+ import { GalleryViewportPicker } from '../core/viewportPicker';
38
+ import { notifyTransformChanged } from '../core/transformStore';
21
39
  import { EffectEditor } from './EffectEditor';
22
40
  import { PromptDialog } from './PromptDialog';
41
+ import { MessageDialog } from './MessageDialog';
42
+ import { GalleryPanel } from './GalleryPanel';
23
43
  import { TimelinePanel } from './TimelinePanel';
24
44
  import { buttonStyle, globalEditorStyles, theme } from './theme';
25
- function createDefaultEffect(scene, resolveTexture, textureUrl) {
26
- const system = new ParticleSystem({
27
- scene,
28
- duration: 4,
29
- looping: true,
30
- startLife: new IntervalValue(1.2, 1.8),
31
- startSpeed: new IntervalValue(3, 5),
32
- startSize: new IntervalValue(0.25, 0.5),
33
- startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
34
- emissionOverTime: new ConstantValue(80),
35
- // quarks' angle is a half-angle; Babylon's native cone uses a full angle.
36
- shape: new ConeEmitter({ radius: 0.3, angle: 0.25 }),
37
- renderMode: RenderMode.BillBoard,
38
- texture: (textureUrl && resolveTexture ? resolveTexture(textureUrl, scene) : undefined),
39
- transparent: true,
40
- blendMode: 1,
41
- });
42
- system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
43
- // quarks emits along local +Z; Babylon's native cone emits along +Y.
44
- system.emitter.rotation.x = -Math.PI / 2;
45
- return system;
46
- }
45
+ import { iconStyle } from './icons';
47
46
  /** Disposes an effect's systems and its remaining group tree (otherwise the groups leak). */
48
- function disposeEffect(root, systems, renderer) {
49
- for (const system of systems) {
50
- renderer.deleteSystem(system);
51
- system.dispose();
52
- }
53
- // Disposing the systems already disposed their emitter nodes; dispose the leftover group
54
- // tree too. Skip when the root IS a disposed emitter (default single-system effect).
55
- if (!systems.some((system) => system.emitter === root)) {
56
- root.dispose(false, true);
57
- }
47
+ function disposeEffect(root, systems, renderer, inRenderer = true) {
48
+ disposeLoadedEffect(root, systems, renderer, inRenderer);
58
49
  }
50
+ const GALLERY_PANEL_WIDTH = 280;
59
51
  const GIZMO_MODE_BUTTONS = [
60
- { value: 'none', icon: '↖', title: 'Select (hide stage gizmo)' },
61
- { value: 'position', icon: 'M', title: 'Move the stage node (position)' },
62
- { value: 'rotation', icon: 'R', title: 'Rotate the stage node' },
63
- { value: 'scale', icon: 'S', title: 'Scale the stage node' },
52
+ { value: 'none', Icon: CursorArrowRaysIcon, title: 'Select (hide stage gizmo)' },
53
+ { value: 'position', Icon: ArrowsPointingOutIcon, title: 'Move the stage node (position)' },
54
+ { value: 'rotation', Icon: ArrowPathIcon, title: 'Rotate the stage node' },
55
+ { value: 'scale', Icon: Square2StackIcon, title: 'Scale the stage node' },
64
56
  ];
65
57
  const squareButtonStyle = {
66
58
  ...buttonStyle,
@@ -83,18 +75,82 @@ const activeSquareButtonStyle = {
83
75
  export function EffectEditorHost(props) {
84
76
  const canvasRef = useRef(null);
85
77
  const counterRef = useRef(null);
78
+ const fpsRef = useRef(null);
86
79
  const playheadRef = useRef(null);
87
- const importRef = 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(() => { });
88
+ const dropMarkerRef = useRef(null);
89
+ const selectionMarkerRef = useRef(null);
90
+ const emitterWireframesRef = useRef(null);
91
+ const viewportPickerRef = useRef(null);
92
+ const galleryDragDepthRef = useRef(0);
88
93
  const stateRef = useRef(null);
89
94
  const gizmoManagerRef = useRef(null);
90
95
  const [binding, setBinding] = useState(null);
91
96
  const [, force] = useReducer((x) => x + 1, 0);
92
- const playbackRef = useRef({ paused: false, speed: 1, stepQueued: false, elapsed: 0, scrubbing: false });
93
- const [paused, setPaused] = useState(false);
97
+ const playbackRef = useRef(createPlaybackState());
98
+ const focusTimelineMetaRef = useRef({ rootId: -1, revision: -1, span: 1, anyLooping: false });
99
+ const [stagePlaying, setStagePlaying] = useState(true);
94
100
  const [speed, setSpeed] = useState(1);
95
101
  const [renamingRoot, setRenamingRoot] = useState(false);
96
102
  const [gizmoMode, setGizmoMode] = useState('none');
97
103
  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([]);
108
+ const [messageDialog, setMessageDialog] = useState(null);
109
+ const [galleryDragHover, setGalleryDragHover] = useState(null);
110
+ const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
111
+ const isGalleryLoading = galleryLoading !== null;
112
+ 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
118
+ ? '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
+ : `No .json files found among ${files.length} file(s) in the selection.`);
120
+ return;
121
+ }
122
+ try {
123
+ await importGalleryRef.current(jsonFiles);
124
+ stateRef.current?.history.clear();
125
+ force();
126
+ }
127
+ catch (err) {
128
+ 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();
132
+ }
133
+ };
134
+ const handleImportFolder = () => {
135
+ if (typeof window.showDirectoryPicker !== 'function') {
136
+ galleryFolderInputRef.current?.click();
137
+ return;
138
+ }
139
+ void (async () => {
140
+ try {
141
+ const picked = await pickGalleryJsonFiles();
142
+ if (picked === null || picked === undefined) {
143
+ return;
144
+ }
145
+ await runGalleryImport(picked);
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ showMessage('Import to gallery', message);
150
+ console.error('Failed to open folder picker:', err);
151
+ }
152
+ })();
153
+ };
98
154
  useEffect(() => {
99
155
  // Register the shared ground-plane collider before any effect (default or imported)
100
156
  // is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
@@ -122,83 +178,421 @@ export function EffectEditorHost(props) {
122
178
  groundMaterial.opacity = 0.5;
123
179
  const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
124
180
  ground.material = groundMaterial;
125
- const stageRoot = new TransformNode('StageRoot', scene);
126
181
  const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
127
182
  const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
128
183
  gizmoManager.usePointerToAttachGizmos = false;
129
- gizmoManager.attachToNode(stageRoot);
130
184
  gizmoManagerRef.current = gizmoManager;
131
185
  const renderer = new BatchedRenderer('quarks-editor', scene);
132
186
  const history = new EffectHistory();
187
+ const galleryRoot = new TransformNode('GalleryCatalog', scene);
188
+ galleryRoot.setEnabled(false);
189
+ let galleryEntries = [];
190
+ const dropMarker = new GalleryDropMarker(scene);
191
+ dropMarkerRef.current = dropMarker;
192
+ const selectionMarker = new GallerySelectionMarker(scene);
193
+ selectionMarkerRef.current = selectionMarker;
194
+ const emitterWireframes = new EmitterShapeWireframes(scene);
195
+ emitterWireframesRef.current = emitterWireframes;
196
+ const viewportPicker = new GalleryViewportPicker(scene);
197
+ viewportPickerRef.current = viewportPicker;
198
+ const handleViewportPointerLeave = () => viewportPicker.setHover(null);
199
+ canvasRef.current?.addEventListener('pointerleave', handleViewportPointerLeave);
200
+ const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
201
+ if (galleryDragDepthRef.current > 0) {
202
+ return;
203
+ }
204
+ const event = pointerInfo.event;
205
+ if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
206
+ const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
207
+ const focusedRoot = stateRef.current?.binding?.root ?? null;
208
+ viewportPicker.setHover(effectRoot && effectRoot !== focusedRoot ? effectRoot : null);
209
+ return;
210
+ }
211
+ if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
212
+ return;
213
+ }
214
+ const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
215
+ if (!effectRoot) {
216
+ return;
217
+ }
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);
225
+ }
226
+ setSelectedNode(effectRoot);
227
+ });
228
+ const clearGalleryDragPreview = () => {
229
+ galleryDragDepthRef.current = 0;
230
+ dropMarker.hide();
231
+ setGalleryDragHover(null);
232
+ setGalleryDragSession(null);
233
+ };
234
+ const onWindowDragEnd = () => clearGalleryDragPreview();
235
+ 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
+ };
249
+ };
250
+ const focusCatalogEntry = (entry) => {
251
+ const next = bindingFromGalleryEntry(entry);
252
+ history.clear();
253
+ history.attach(next);
254
+ next.subscribe(force);
255
+ syncStateRef(next);
256
+ setBinding(next);
257
+ setSelectedNode(entry.root);
258
+ setSelectedGalleryRoot(entry.root);
259
+ playbackRef.current.focusRootId = entry.root.uniqueId;
260
+ if (entry.inScene) {
261
+ selectionMarker.show(entry.root);
262
+ focusGalleryEntry(camera, entry.root);
263
+ }
264
+ else {
265
+ selectionMarker.hide();
266
+ }
267
+ };
268
+ const toggleGalleryPreview = (entry) => {
269
+ if (!entry.inScene) {
270
+ return;
271
+ }
272
+ 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);
278
+ }
279
+ else {
280
+ next.add(id);
281
+ clearFocusFinished(playbackRef.current, id);
282
+ }
283
+ const ids = [...next];
284
+ if (stateRef.current) {
285
+ stateRef.current.previewRootIds = ids;
286
+ }
287
+ return ids;
288
+ });
289
+ if (removing) {
290
+ clearGalleryEntrySimulation(entry, renderer);
291
+ clearFocusFinished(playbackRef.current, id);
292
+ }
293
+ renderer.refreshBatches();
294
+ scene.render();
295
+ force();
296
+ };
297
+ const removeGalleryFromScene = (entry) => {
298
+ if (!entry.inScene) {
299
+ return;
300
+ }
301
+ 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;
307
+ }
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
+ }
316
+ setGallery(galleryEntries);
317
+ if (stateRef.current?.binding?.root === entry.root) {
318
+ selectionMarker.hide();
319
+ }
320
+ applyGalleryPlayback(galleryEntries, new Set(stateRef.current?.previewRootIds ?? []), playbackRef.current.stagePlaying, playbackRef.current.focusFinishedByRoot);
321
+ renderer.refreshBatches();
322
+ scene.render();
323
+ force();
324
+ };
325
+ const addGalleryPreview = (...entries) => {
326
+ const ids = entries.filter((entry) => entry.inScene).map((entry) => entry.root.uniqueId);
327
+ if (ids.length === 0) {
328
+ return;
329
+ }
330
+ setPreviewRootIds((prev) => {
331
+ const next = [...new Set([...prev, ...ids])];
332
+ if (stateRef.current) {
333
+ stateRef.current.previewRootIds = next;
334
+ }
335
+ return next;
336
+ });
337
+ };
338
+ const placeGalleryDropAt = (clientX, clientY, dataTransfer) => {
339
+ const payload = (dataTransfer && readGalleryDrag(dataTransfer)) ?? getGalleryDragSession();
340
+ if (!payload) {
341
+ return;
342
+ }
343
+ const { entries: toPlace } = describeGalleryDrag(galleryEntries, payload);
344
+ if (toPlace.length === 0) {
345
+ 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();
365
+ };
133
366
  const mount = (nextBinding) => {
134
- nextBinding.root.parent = stageRoot;
367
+ nextBinding.root.parent = null;
135
368
  history.attach(nextBinding);
136
369
  nextBinding.subscribe(force);
137
- stateRef.current = { engine, scene, renderer, binding: nextBinding, history, stageRoot };
370
+ syncStateRef(nextBinding);
138
371
  setBinding(nextBinding);
139
- setSelectedNode(nextBinding.system.emitter);
372
+ setSelectedNode(nextBinding.root);
373
+ props.onReady?.(makeHandle());
374
+ };
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);
386
+ if (incoming.length === 0) {
387
+ throw new Error('No particle effects found in the selected file(s).');
388
+ }
389
+ if (added.length === 0) {
390
+ throw new Error('All selected effects are already in the catalog.');
391
+ }
392
+ focusCatalogEntry(added[0]);
393
+ props.onReady?.(makeHandle());
394
+ };
395
+ const appendGalleryDefault = () => {
396
+ const entry = createGalleryDefaultEntry(scene, galleryRoot, galleryEntries, {
397
+ resolveTexture: props.resolveTexture,
398
+ textureUrl: props.textureOptions?.[0]?.url,
399
+ });
400
+ galleryEntries = [...galleryEntries, entry];
401
+ if (stateRef.current) {
402
+ stateRef.current.galleryRoot = galleryRoot;
403
+ stateRef.current.galleryEntries = galleryEntries;
404
+ }
405
+ setGallery(galleryEntries);
406
+ focusCatalogEntry(entry);
140
407
  props.onReady?.(makeHandle());
141
408
  };
142
409
  const makeHandle = () => ({
143
- // Live getter: stays correct after presets/imports/undo replace the binding.
144
410
  get binding() {
145
- return stateRef.current.binding;
411
+ const b = stateRef.current?.binding;
412
+ if (!b) {
413
+ throw new Error('Gallery mode — import a single JSON to edit one effect.');
414
+ }
415
+ return b;
146
416
  },
147
417
  history,
148
418
  scene,
149
419
  renderer,
150
420
  importJson,
151
- exportJson: () => stateRef.current.binding.exportJSON('EditorEffect'),
421
+ importGallery: appendGalleryFromFiles,
422
+ exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
152
423
  });
153
424
  const importJson = (json) => {
154
- const current = stateRef.current.binding;
155
- // Load first so a bad/empty JSON leaves the current effect intact.
425
+ const current = stateRef.current?.binding;
156
426
  const { root, systems } = loadEffectFromJson(scene, renderer, json);
157
427
  if (systems.length === 0) {
158
428
  disposeEffect(root, systems, renderer);
159
429
  throw new Error('No particle systems found in the JSON.');
160
430
  }
161
- disposeEffect(current.root, [current.system, ...current.subSystems], renderer);
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);
434
+ }
162
435
  const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
163
- mount(new EffectBinding(main, systems.filter((s) => s !== main), root));
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;
442
+ };
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,
164
453
  };
165
- let initial;
454
+ let initialEntry;
166
455
  if (props.effectJson) {
167
456
  const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
168
- const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
169
- initial = new EffectBinding(main, systems.filter((s) => s !== main), root);
457
+ root.parent = null;
458
+ const name = root.name || 'Effect';
459
+ initialEntry = { name, path: `_opened/${name}.json`, root, systems, inScene: true };
170
460
  }
171
461
  else {
172
- const system = createDefaultEffect(scene, props.resolveTexture, props.textureOptions?.[0]?.url);
173
- renderer.addSystem(system);
174
- // Root is a plain group (not the system's own emitter) so a new top-level system
175
- // can be added as a sibling later, instead of always nesting under this one.
176
- const root = new TransformNode('Effect', scene);
177
- system.emitter.parent = root;
178
- initial = new EffectBinding(system, [], root);
462
+ initialEntry = createGalleryDefaultEntry(scene, galleryRoot, [], galleryDefaults);
463
+ placeGalleryEntryInScene(initialEntry, renderer, BVector3.Zero());
179
464
  }
180
- mount(initial);
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;
471
+ }
472
+ setPreviewRootIds(initialPreviewIds);
473
+ setGallery(galleryEntries);
474
+ focusCatalogEntry(initialEntry);
475
+ props.onReady?.(makeHandle());
181
476
  engine.runRenderLoop(() => {
182
477
  const playback = playbackRef.current;
478
+ const snapshot = stateRef.current;
183
479
  if (!playback.scrubbing) {
184
480
  let delta = (engine.getDeltaTime() / 1000) * playback.speed;
185
- if (playback.paused) {
186
- delta = playback.stepQueued ? 1 / 60 : 0;
481
+ const stepQueued = playback.stepQueued;
482
+ if (!playback.stagePlaying) {
483
+ delta = stepQueued ? 1 / 60 : 0;
187
484
  playback.stepQueued = false;
188
485
  }
189
- if (delta > 0) {
190
- renderer.update(delta);
191
- playback.elapsed += delta;
486
+ if (delta > 0 && snapshot) {
487
+ const previewIds = new Set(snapshot.previewRootIds);
488
+ const simulate = hasActivePreview(snapshot.galleryEntries, previewIds) && (playback.stagePlaying || stepQueued);
489
+ if (simulate) {
490
+ renderer.update(delta);
491
+ let needsRefresh = false;
492
+ if (pauseFinishedPreviews(snapshot.galleryEntries, previewIds, playback.focusFinishedByRoot)) {
493
+ needsRefresh = true;
494
+ }
495
+ const binding = snapshot.binding;
496
+ const focusId = playback.focusRootId ?? binding?.root.uniqueId ?? null;
497
+ if (focusId != null &&
498
+ binding &&
499
+ playback.stagePlaying &&
500
+ !playback.focusFinishedByRoot.has(focusId) &&
501
+ !focusTimelineMetaRef.current.anyLooping &&
502
+ QuarksUtil.isEffectFinished(binding.root)) {
503
+ const settleAt = getFocusElapsed(playback, focusId);
504
+ scrubFocusToSettled(binding, renderer, settleAt);
505
+ markFocusFinished(playback, focusId);
506
+ pauseFocus(binding);
507
+ needsRefresh = true;
508
+ }
509
+ else if (focusId != null &&
510
+ playback.stagePlaying &&
511
+ !playback.focusFinishedByRoot.has(focusId)) {
512
+ const prev = getFocusElapsed(playback, focusId);
513
+ setFocusElapsed(playback, focusId, prev + delta);
514
+ }
515
+ if (needsRefresh) {
516
+ renderer.refreshBatches();
517
+ }
518
+ if (snapshot.galleryEntries.length > 0) {
519
+ logRenderMismatch(renderer, countGalleryParticles(snapshot.galleryEntries, previewIds));
520
+ }
521
+ }
522
+ else if (snapshot && snapshot.galleryEntries.length > 0) {
523
+ renderer.refreshBatches();
524
+ }
192
525
  }
193
526
  }
194
527
  scene.render();
195
- const b = stateRef.current.binding;
196
- let count = b.system.particleNum;
197
- for (const sub of b.subSystems) {
198
- count += sub.particleNum;
528
+ 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;
546
+ if (fpsRef.current) {
547
+ fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
199
548
  }
200
- if (counterRef.current) {
201
- counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
549
+ const previewIds = new Set(snapshot.previewRootIds);
550
+ const binding = snapshot.binding;
551
+ if (binding && (binding.root.uniqueId !== focusTimelineMetaRef.current.rootId || binding.getRevision() !== focusTimelineMetaRef.current.revision)) {
552
+ const rows = computeTimelineRows(binding);
553
+ focusTimelineMetaRef.current = {
554
+ rootId: binding.root.uniqueId,
555
+ revision: binding.getRevision(),
556
+ span: computeTimelineSpan(rows),
557
+ anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
558
+ };
559
+ }
560
+ if (snapshot.galleryEntries.length > 0) {
561
+ const playing = countActivePreviews(snapshot.galleryEntries, previewIds);
562
+ const count = countGalleryParticles(snapshot.galleryEntries, previewIds);
563
+ const inScene = snapshot.galleryEntries.filter((entry) => entry.inScene).length;
564
+ const focused = snapshot.galleryEntries.find((entry) => entry.root === binding?.root);
565
+ const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
566
+ const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
567
+ const focusEnded = focusId != null && isFocusFinished(playback, focusId);
568
+ const meta = focusTimelineMetaRef.current;
569
+ if (counterRef.current) {
570
+ const focusLabel = focused ? ` · focus: ${focused.name}` : '';
571
+ const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
572
+ const timeLabel = meta.anyLooping
573
+ ? `${focusElapsed.toFixed(1)}s ⟳`
574
+ : focusEnded
575
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
576
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
577
+ counterRef.current.textContent = `${playing} preview · ${stageLabel}${focusLabel} · ${timeLabel} · ${count} particles · ${inScene}/${snapshot.galleryEntries.length} in catalog`;
578
+ }
579
+ }
580
+ else if (binding) {
581
+ const b = binding;
582
+ const count = b.system.particleNum + b.subSystems.reduce((sum, sub) => sum + sub.particleNum, 0);
583
+ const focusId = playback.focusRootId ?? b.root.uniqueId;
584
+ const focusElapsed = getFocusElapsed(playback, focusId);
585
+ const focusEnded = isFocusFinished(playback, focusId);
586
+ const meta = focusTimelineMetaRef.current;
587
+ if (counterRef.current) {
588
+ const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
589
+ const timeLabel = meta.anyLooping
590
+ ? `${focusElapsed.toFixed(1)}s ⟳`
591
+ : focusEnded
592
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
593
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
594
+ counterRef.current.textContent = `${stageLabel} · ${timeLabel} · ${count} particles`;
595
+ }
202
596
  }
203
597
  });
204
598
  const resizeObserver = new ResizeObserver(() => engine.resize());
@@ -217,12 +611,48 @@ export function EffectEditorHost(props) {
217
611
  return () => {
218
612
  resizeObserver.disconnect();
219
613
  window.removeEventListener('keydown', onKey);
614
+ window.removeEventListener('dragend', onWindowDragEnd);
615
+ clearGalleryDragPreview();
616
+ cancelGalleryCameraFocus();
617
+ dropMarker.dispose();
618
+ dropMarkerRef.current = null;
619
+ selectionMarker.dispose();
620
+ selectionMarkerRef.current = null;
621
+ emitterWireframes.dispose();
622
+ emitterWireframesRef.current = null;
623
+ viewportPicker.dispose();
624
+ viewportPickerRef.current = null;
625
+ canvasRef.current?.removeEventListener('pointerleave', handleViewportPointerLeave);
626
+ scene.onPointerObservable.remove(pointerObserver);
220
627
  gizmoManager.dispose();
221
628
  gizmoUtilityLayer.dispose();
222
629
  engine.dispose();
223
630
  };
224
631
  // eslint-disable-next-line react-hooks/exhaustive-deps
225
632
  }, []);
633
+ useEffect(() => {
634
+ if (stateRef.current) {
635
+ stateRef.current.previewRootIds = previewRootIds;
636
+ }
637
+ }, [previewRootIds]);
638
+ useEffect(() => {
639
+ playbackRef.current.stagePlaying = stagePlaying;
640
+ const snap = stateRef.current;
641
+ if (!snap) {
642
+ return;
643
+ }
644
+ const isCatalogEntry = snap.galleryEntries.length > 0;
645
+ if (!isCatalogEntry) {
646
+ return;
647
+ }
648
+ applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying, playbackRef.current.focusFinishedByRoot);
649
+ if (!stagePlaying && stateRef.current?.renderer) {
650
+ stateRef.current.renderer.refreshBatches();
651
+ }
652
+ }, [stagePlaying, previewRootIds, gallery]);
653
+ useEffect(() => {
654
+ emitterWireframesRef.current?.setVisible(showEmitterWireframes);
655
+ }, [showEmitterWireframes]);
226
656
  useEffect(() => {
227
657
  const gm = gizmoManagerRef.current;
228
658
  if (!gm)
@@ -231,48 +661,262 @@ export function EffectEditorHost(props) {
231
661
  gm.rotationGizmoEnabled = gizmoMode === 'rotation';
232
662
  gm.scaleGizmoEnabled = gizmoMode === 'scale';
233
663
  const activeGizmo = gizmoMode === 'position' ? gm.gizmos.positionGizmo : gizmoMode === 'rotation' ? gm.gizmos.rotationGizmo : gizmoMode === 'scale' ? gm.gizmos.scaleGizmo : null;
234
- const observer = activeGizmo?.onDragObservable.add(() => force());
664
+ let dragRaf = 0;
665
+ const scheduleTransformRefresh = () => {
666
+ if (dragRaf !== 0) {
667
+ return;
668
+ }
669
+ dragRaf = requestAnimationFrame(() => {
670
+ dragRaf = 0;
671
+ notifyTransformChanged();
672
+ });
673
+ };
674
+ const dragObserver = activeGizmo?.onDragObservable.add(scheduleTransformRefresh);
675
+ const dragEndObserver = activeGizmo?.onDragEndObservable.add(() => notifyTransformChanged());
235
676
  return () => {
236
- if (activeGizmo && observer)
237
- activeGizmo.onDragObservable.remove(observer);
677
+ if (dragRaf !== 0) {
678
+ cancelAnimationFrame(dragRaf);
679
+ }
680
+ if (activeGizmo && dragObserver) {
681
+ activeGizmo.onDragObservable.remove(dragObserver);
682
+ }
683
+ if (activeGizmo && dragEndObserver) {
684
+ activeGizmo.onDragEndObservable.remove(dragEndObserver);
685
+ }
238
686
  };
239
687
  }, [gizmoMode]);
240
688
  useEffect(() => {
241
689
  const gm = gizmoManagerRef.current;
242
- const stageRoot = stateRef.current?.stageRoot;
243
- if (!gm || !stageRoot)
690
+ if (!gm)
244
691
  return;
245
692
  if (selectedNode && selectedNode.isDisposed()) {
246
- setSelectedNode(stateRef.current?.binding.system.emitter ?? null);
693
+ setSelectedNode(stateRef.current?.binding?.root ?? stateRef.current?.binding?.system.emitter ?? null);
247
694
  return;
248
695
  }
249
- gm.attachToNode(selectedNode ?? stageRoot);
250
- }, [selectedNode, binding]);
696
+ if (isGalleryLoading) {
697
+ return;
698
+ }
699
+ const target = selectedNode && !selectedNode.isDisposed()
700
+ ? selectedNode
701
+ : binding?.root && !binding.root.isDisposed()
702
+ ? binding.root
703
+ : null;
704
+ if (target) {
705
+ gm.attachToNode(target);
706
+ }
707
+ }, [selectedNode, binding, isGalleryLoading]);
251
708
  const state = stateRef.current;
252
- const gizmoNode = selectedNode && !selectedNode.isDisposed() ? selectedNode : state?.stageRoot;
709
+ const gizmoNode = selectedNode && !selectedNode.isDisposed()
710
+ ? selectedNode
711
+ : binding?.root && !binding.root.isDisposed()
712
+ ? binding.root
713
+ : null;
253
714
  const historyAction = (json) => {
254
- if (json && state) {
715
+ if (json && state?.binding) {
255
716
  const current = state.binding;
256
- const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
257
- disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
258
- const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
259
- const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
260
- next.root.parent = state.stageRoot;
261
- state.history.attach(next);
262
- next.subscribe(force);
263
- state.binding = next;
264
- setBinding(next);
265
- setSelectedNode(next.system.emitter);
717
+ const inGallery = state.galleryEntries.length > 0;
718
+ const entryIndex = inGallery ? state.galleryEntries.findIndex((e) => e.root === current.root) : -1;
719
+ const prev = entryIndex >= 0 ? state.galleryEntries[entryIndex] : null;
720
+ const wasInScene = prev?.inScene ?? true;
721
+ const savedPosition = wasInScene ? current.root.position.clone() : null;
722
+ if (inGallery && entryIndex >= 0 && prev) {
723
+ disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer, wasInScene);
724
+ const { root, systems } = parseEffectFromJson(state.scene, wasInScene ? state.renderer : null, json, {
725
+ registerRenderer: wasInScene,
726
+ autoplay: false,
727
+ });
728
+ if (systems.length === 0) {
729
+ disposeLoadedEffect(root, systems, state.renderer, wasInScene);
730
+ return;
731
+ }
732
+ if (wasInScene && savedPosition) {
733
+ root.position.copyFrom(savedPosition);
734
+ root.parent = null;
735
+ root.setEnabled(true);
736
+ }
737
+ else {
738
+ root.parent = state.galleryRoot;
739
+ root.setEnabled(false);
740
+ }
741
+ if (!root.name || root.name === 'Effect' || root.name === 'Object3D') {
742
+ root.name = prev.name;
743
+ }
744
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
745
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
746
+ const oldRootId = current.root.uniqueId;
747
+ const updated = [...state.galleryEntries];
748
+ updated[entryIndex] = { name: prev.name, path: prev.path, root, systems, inScene: wasInScene };
749
+ state.galleryEntries = updated;
750
+ setGallery(updated);
751
+ setPreviewRootIds((ids) => {
752
+ const next = ids.map((id) => (id === oldRootId ? root.uniqueId : id));
753
+ if (stateRef.current) {
754
+ stateRef.current.previewRootIds = next;
755
+ }
756
+ return next;
757
+ });
758
+ state.history.attach(next);
759
+ next.subscribe(force);
760
+ state.binding = next;
761
+ setBinding(next);
762
+ setSelectedNode(root);
763
+ setSelectedGalleryRoot(root);
764
+ playbackRef.current.focusRootId = root.uniqueId;
765
+ if (wasInScene) {
766
+ selectionMarkerRef.current?.show(root);
767
+ }
768
+ else {
769
+ selectionMarkerRef.current?.hide();
770
+ }
771
+ }
772
+ else {
773
+ const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
774
+ if (systems.length === 0) {
775
+ disposeEffect(root, systems, state.renderer);
776
+ return;
777
+ }
778
+ disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
779
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
780
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
781
+ next.root.parent = null;
782
+ state.history.attach(next);
783
+ next.subscribe(force);
784
+ state.binding = next;
785
+ setBinding(next);
786
+ setSelectedNode(root);
787
+ setSelectedGalleryRoot(null);
788
+ selectionMarkerRef.current?.hide();
789
+ state.ground.scaling.x = 1;
790
+ state.ground.scaling.z = 1;
791
+ state.camera.radius = 9;
792
+ state.camera.setTarget(new BVector3(0, 1.4, 0));
793
+ }
266
794
  }
267
795
  force();
268
796
  };
269
797
  const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
270
798
  const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
271
- const gizmoTargetLabel = selectedNode ? gizmoNode?.name || 'Node' : 'Stage (preview parent)';
272
- return (_jsxs("div", { className: "qe-root", style: { display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16' }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, children: [_jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }), _jsxs("div", { style: { position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }, children: [_jsxs("div", { style: { display: 'flex', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: [_jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo, onClick: () => historyAction(state.history.undo()), children: "\u21B6" }), _jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo, onClick: () => historyAction(state.history.redo()), children: "\u21B7" })] }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: { ...squareButtonStyle, ...(gizmoMode === m.value ? activeSquareButtonStyle : {}) }, title: m.title, onClick: () => setGizmoMode(m.value), children: m.icon }, m.value))) })] })] }), _jsxs("aside", { style: { width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: { fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px' }, 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) => {
799
+ const gizmoTargetLabel = gizmoNode?.name || 'Node';
800
+ const selectedGalleryEntry = selectedGalleryRoot
801
+ ? gallery.find((entry) => entry.root === selectedGalleryRoot) ?? null
802
+ : null;
803
+ /** Timeline is always shown when an effect is selected; playback only when it is on stage. */
804
+ const showTimeline = Boolean(binding && activeSystem && state);
805
+ const timelinePlaybackEnabled = !selectedGalleryEntry || selectedGalleryEntry.inScene;
806
+ const previewRootIdSet = useMemo(() => new Set(previewRootIds), [previewRootIds]);
807
+ const resolveTextureForEditor = useCallback((url) => {
808
+ const scene = stateRef.current?.scene;
809
+ return scene && props.resolveTexture ? props.resolveTexture(url, scene) : undefined;
810
+ }, [props.resolveTexture]);
811
+ const resolveGeometryForEditor = useCallback((file) => {
812
+ const scene = stateRef.current?.scene;
813
+ if (!scene || !props.resolveGeometry) {
814
+ return Promise.reject(new Error('Geometry loader is not available.'));
815
+ }
816
+ return props.resolveGeometry(file, scene);
817
+ }, [props.resolveGeometry]);
818
+ return (_jsxs("div", { className: "qe-root", style: { display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16' }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("aside", { style: {
819
+ width: GALLERY_PANEL_WIDTH,
820
+ flexShrink: 0,
821
+ borderRight: `1px solid ${theme.border}`,
822
+ background: theme.panelBg,
823
+ display: 'flex',
824
+ flexDirection: 'column',
825
+ minHeight: 0,
826
+ }, 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: { flex: 1, minHeight: 0, overflow: 'hidden', padding: '8px 12px 14px', display: 'flex', flexDirection: 'column' }, 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) => {
827
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
828
+ return;
829
+ galleryDragDepthRef.current += 1;
830
+ }, onDragLeave: (e) => {
831
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
832
+ return;
833
+ galleryDragDepthRef.current -= 1;
834
+ if (galleryDragDepthRef.current <= 0) {
835
+ galleryDragDepthRef.current = 0;
836
+ dropMarkerRef.current?.hide();
837
+ setGalleryDragHover(null);
838
+ }
839
+ }, onDragOver: (e) => {
840
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
841
+ return;
842
+ e.preventDefault();
843
+ e.dataTransfer.dropEffect = 'copy';
844
+ const payload = getGalleryDragSession();
845
+ const snap = stateRef.current;
846
+ if (!payload || !snap)
847
+ return;
848
+ const described = describeGalleryDrag(snap.galleryEntries, payload);
849
+ if (described.entries.length === 0)
850
+ return;
851
+ const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
852
+ dropMarkerRef.current?.show(position, described.entries.length);
853
+ setGalleryDragHover({ label: described.label, count: described.entries.length });
854
+ }, onDrop: (e) => {
855
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
856
+ return;
857
+ e.preventDefault();
858
+ galleryDragDepthRef.current = 0;
859
+ placeGalleryDropRef.current(e.clientX, e.clientY, e.dataTransfer);
860
+ }, children: [_jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }), _jsx("div", { style: {
861
+ position: 'absolute',
862
+ bottom: 12,
863
+ left: 12,
864
+ background: theme.panelBg,
865
+ border: `1px solid ${theme.border}`,
866
+ borderRadius: 8,
867
+ padding: '4px 10px',
868
+ fontSize: 12,
869
+ color: theme.textDim,
870
+ fontVariantNumeric: 'tabular-nums',
871
+ pointerEvents: 'none',
872
+ fontFamily: theme.font,
873
+ }, children: _jsx("span", { ref: fpsRef, children: "\u2014 FPS" }) }), galleryDragHover && (_jsx("div", { style: {
874
+ position: 'absolute',
875
+ inset: 10,
876
+ border: '2px dashed rgba(120, 165, 255, 0.75)',
877
+ borderRadius: 12,
878
+ pointerEvents: 'none',
879
+ boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
880
+ }, children: _jsxs("div", { style: {
881
+ position: 'absolute',
882
+ left: '50%',
883
+ bottom: 18,
884
+ transform: 'translateX(-50%)',
885
+ background: 'rgba(7, 11, 22, 0.88)',
886
+ border: `1px solid ${theme.border}`,
887
+ borderRadius: 8,
888
+ padding: '8px 14px',
889
+ fontSize: 13,
890
+ color: theme.text,
891
+ whiteSpace: 'nowrap',
892
+ }, children: ["Drop ", galleryDragHover.count === 1 ? 'effect' : `${galleryDragHover.count} effects`, " \u2014 ", galleryDragHover.label] }) })), galleryLoading && (_jsx("div", { style: {
893
+ position: 'absolute',
894
+ inset: 0,
895
+ display: 'flex',
896
+ alignItems: 'center',
897
+ justifyContent: 'center',
898
+ background: 'rgba(7, 11, 22, 0.72)',
899
+ pointerEvents: 'none',
900
+ }, children: _jsxs("div", { style: { background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 10, padding: '16px 22px', textAlign: 'center' }, 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: {
901
+ position: 'absolute',
902
+ top: 12,
903
+ left: 12,
904
+ display: 'inline-flex',
905
+ alignItems: 'center',
906
+ gap: 6,
907
+ background: theme.panelBg,
908
+ border: `1px solid ${theme.border}`,
909
+ borderRadius: 8,
910
+ padding: '6px 12px',
911
+ fontSize: 13,
912
+ fontWeight: 500,
913
+ color: theme.text,
914
+ textDecoration: 'none',
915
+ fontFamily: theme.font,
916
+ }, children: [_jsx(ArrowLeftIcon, { style: iconStyle(14) }), props.backLink.label.replace(/^←\s*/, '')] })), _jsxs("div", { style: { position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }, children: [_jsxs("div", { style: { display: 'flex', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, 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: { display: 'flex', flexDirection: 'column', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: { ...squareButtonStyle, ...(gizmoMode === m.value ? activeSquareButtonStyle : {}) }, title: m.title, onClick: () => setGizmoMode(m.value), children: _jsx(m.Icon, { style: iconStyle(14) }) }, m.value))) }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4 }, children: _jsx("button", { className: "qe-hover", style: { ...squareButtonStyle, ...(showEmitterWireframes ? activeSquareButtonStyle : {}) }, title: "Show emitter shape wireframes", disabled: !binding, onClick: () => setShowEmitterWireframes((on) => !on), children: _jsx(CubeTransparentIcon, { style: iconStyle(14) }) }) })] })] }), _jsxs("aside", { style: { width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: { fontSize: 11, fontWeight: 600, color: theme.textDim, textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 6 }, children: "Inspector" }), _jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: { fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px' }, 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) => {
273
917
  binding.apply(() => (binding.root.name = name));
274
918
  setRenamingRoot(false);
275
- }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => {
919
+ }, 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: () => {
276
920
  if (!binding)
277
921
  return;
278
922
  const blob = new Blob([binding.exportJSON('EditorEffect')], { type: 'application/json' });
@@ -281,21 +925,7 @@ export function EffectEditorHost(props) {
281
925
  link.download = 'effect.json';
282
926
  link.click();
283
927
  URL.revokeObjectURL(link.href);
284
- }, children: "Export" }), _jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => importRef.current?.click(), children: "Import" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" })), _jsx("input", { ref: importRef, type: "file", accept: ".json,application/json", hidden: true, onChange: async (e) => {
285
- const file = e.target.files?.[0];
286
- e.target.value = '';
287
- if (!file || !state)
288
- return;
289
- try {
290
- const json = JSON.parse(await file.text());
291
- historyAction(json);
292
- state.history.clear();
293
- force();
294
- }
295
- catch (err) {
296
- console.error('Failed to import effect JSON:', err);
297
- }
298
- } })] })] }), _jsx("div", { style: { flex: 1, 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 && state ? (url) => props.resolveTexture(url, state.scene) : undefined, resolveGeometry: props.resolveGeometry && state ? (file) => props.resolveGeometry(file, state.scene) : undefined })) })] })] }), binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, paused: paused, setPaused: setPaused, speed: speed, setSpeed: setSpeed }))] }));
928
+ }, 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) }))] }));
299
929
  }
300
930
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
301
931
  export const QuarksEffectEditor = {