babylon.quarks-editor 0.17.8 → 0.17.9

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 (119) 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 +9 -0
  18. package/dist/core/galleryPlayback.js +43 -0
  19. package/dist/core/galleryScene.d.ts +12 -0
  20. package/dist/core/galleryScene.js +41 -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/scrub.d.ts +5 -0
  36. package/dist/core/scrub.js +30 -0
  37. package/dist/core/timeline.d.ts +3 -1
  38. package/dist/core/timeline.js +6 -3
  39. package/dist/core/transformStore.d.ts +3 -0
  40. package/dist/core/transformStore.js +16 -0
  41. package/dist/core/viewportPicker.d.ts +25 -0
  42. package/dist/core/viewportPicker.js +86 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +1 -0
  45. package/dist/react/EffectEditor.js +1 -4
  46. package/dist/react/EffectEditorHost.d.ts +7 -0
  47. package/dist/react/EffectEditorHost.js +669 -115
  48. package/dist/react/GalleryPanel.d.ts +19 -0
  49. package/dist/react/GalleryPanel.js +215 -0
  50. package/dist/react/InspectorInfoIcon.d.ts +7 -0
  51. package/dist/react/InspectorInfoIcon.js +22 -0
  52. package/dist/react/InspectorLabel.d.ts +7 -0
  53. package/dist/react/InspectorLabel.js +12 -0
  54. package/dist/react/MessageDialog.d.ts +9 -0
  55. package/dist/react/MessageDialog.js +24 -0
  56. package/dist/react/ModuleSection.d.ts +2 -0
  57. package/dist/react/ModuleSection.js +8 -1
  58. package/dist/react/ObjectModule.js +14 -2
  59. package/dist/react/TimelinePanel.d.ts +8 -13
  60. package/dist/react/TimelinePanel.js +113 -99
  61. package/dist/react/TimelineTrackRow.js +29 -15
  62. package/dist/react/Tooltip.d.ts +6 -0
  63. package/dist/react/Tooltip.js +65 -0
  64. package/dist/react/ValueField.d.ts +2 -0
  65. package/dist/react/ValueField.js +1 -1
  66. package/dist/react/behaviorModules.js +8 -8
  67. package/dist/react/fields.d.ts +2 -0
  68. package/dist/react/fields.js +14 -5
  69. package/dist/react/icons.d.ts +7 -0
  70. package/dist/react/icons.js +10 -0
  71. package/dist/react/index.d.ts +4 -0
  72. package/dist/react/index.js +2 -0
  73. package/dist/react/inspectorHints.d.ts +2 -0
  74. package/dist/react/inspectorHints.js +138 -0
  75. package/dist/react/modules.js +109 -81
  76. package/dist/react/theme.d.ts +2 -0
  77. package/dist/react/theme.js +53 -1
  78. package/package.json +8 -4
  79. package/src/core/binding.ts +2 -5
  80. package/src/core/defaultEffect.ts +74 -0
  81. package/src/core/editorScene.ts +14 -0
  82. package/src/core/effectTree.ts +2 -1
  83. package/src/core/emitterShapeWireframe.ts +373 -0
  84. package/src/core/galleryDragSession.ts +12 -0
  85. package/src/core/galleryDropMarker.ts +57 -0
  86. package/src/core/galleryLayout.ts +90 -0
  87. package/src/core/galleryPlayback.ts +55 -0
  88. package/src/core/galleryScene.ts +58 -0
  89. package/src/core/gallerySelectionMarker.ts +84 -0
  90. package/src/core/galleryTree.ts +187 -0
  91. package/src/core/loadEffect.ts +55 -18
  92. package/src/core/loadEffectGallery.ts +146 -0
  93. package/src/core/material.ts +84 -0
  94. package/src/core/pickGalleryFiles.ts +58 -0
  95. package/src/core/playback.ts +49 -0
  96. package/src/core/scrub.ts +33 -1
  97. package/src/core/timeline.ts +6 -3
  98. package/src/core/transformStore.ts +19 -0
  99. package/src/core/viewportPicker.ts +114 -0
  100. package/src/index.ts +2 -0
  101. package/src/react/EffectEditor.tsx +5 -7
  102. package/src/react/EffectEditorHost.tsx +839 -143
  103. package/src/react/GalleryPanel.tsx +453 -0
  104. package/src/react/InspectorInfoIcon.tsx +36 -0
  105. package/src/react/InspectorLabel.tsx +18 -0
  106. package/src/react/MessageDialog.tsx +57 -0
  107. package/src/react/ModuleSection.tsx +17 -2
  108. package/src/react/ObjectModule.tsx +17 -6
  109. package/src/react/TimelinePanel.tsx +213 -177
  110. package/src/react/TimelineTrackRow.tsx +58 -23
  111. package/src/react/Tooltip.tsx +93 -0
  112. package/src/react/ValueField.tsx +3 -1
  113. package/src/react/behaviorModules.tsx +13 -8
  114. package/src/react/fields.tsx +15 -6
  115. package/src/react/icons.ts +19 -0
  116. package/src/react/index.ts +4 -0
  117. package/src/react/inspectorHints.ts +150 -0
  118. package/src/react/modules.tsx +198 -183
  119. 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,43 @@ 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, } 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 } from '../core/galleryPlayback';
24
+ import { createPlaybackState, getFocusElapsed, isFocusFinished, setFocusElapsed } from '../core/playback';
25
+ import { cancelGalleryCameraFocus, focusGalleryEntry } from '../core/galleryLayout';
26
+ import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
27
+ import { GalleryDropMarker } from '../core/galleryDropMarker';
28
+ import { GallerySelectionMarker } from '../core/gallerySelectionMarker';
29
+ import { EmitterShapeWireframes } from '../core/emitterShapeWireframe';
30
+ import { getGalleryDragSession, setGalleryDragSession } from '../core/galleryDragSession';
31
+ import { pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene } from '../core/galleryScene';
32
+ import { describeGalleryDrag, GALLERY_DRAG_MIME, readGalleryDrag } from '../core/galleryTree';
33
+ import { loadEffectGallery, collectJsonFiles, bindingFromGalleryEntry, mergeGalleryEntries } from '../core/loadEffectGallery';
34
+ import { pickGalleryJsonFiles } from '../core/pickGalleryFiles';
35
+ import { GalleryViewportPicker } from '../core/viewportPicker';
36
+ import { notifyTransformChanged } from '../core/transformStore';
21
37
  import { EffectEditor } from './EffectEditor';
22
38
  import { PromptDialog } from './PromptDialog';
39
+ import { MessageDialog } from './MessageDialog';
40
+ import { GalleryPanel } from './GalleryPanel';
23
41
  import { TimelinePanel } from './TimelinePanel';
24
42
  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
- }
43
+ import { iconStyle } from './icons';
47
44
  /** 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
- }
45
+ function disposeEffect(root, systems, renderer, inRenderer = true) {
46
+ disposeLoadedEffect(root, systems, renderer, inRenderer);
58
47
  }
48
+ const GALLERY_PANEL_WIDTH = 280;
59
49
  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' },
50
+ { value: 'none', Icon: CursorArrowRaysIcon, title: 'Select (hide stage gizmo)' },
51
+ { value: 'position', Icon: ArrowsPointingOutIcon, title: 'Move the stage node (position)' },
52
+ { value: 'rotation', Icon: ArrowPathIcon, title: 'Rotate the stage node' },
53
+ { value: 'scale', Icon: Square2StackIcon, title: 'Scale the stage node' },
64
54
  ];
65
55
  const squareButtonStyle = {
66
56
  ...buttonStyle,
@@ -83,18 +73,81 @@ const activeSquareButtonStyle = {
83
73
  export function EffectEditorHost(props) {
84
74
  const canvasRef = useRef(null);
85
75
  const counterRef = useRef(null);
76
+ const fpsRef = useRef(null);
86
77
  const playheadRef = useRef(null);
87
- const importRef = useRef(null);
78
+ const galleryFolderInputRef = useRef(null);
79
+ const importJsonRef = useRef(() => { });
80
+ const importGalleryRef = useRef(async () => { });
81
+ const selectGalleryEntryRef = useRef(() => { });
82
+ const toggleGalleryPreviewRef = useRef(() => { });
83
+ const placeGalleryDropRef = useRef(() => { });
84
+ const createGalleryDefaultRef = useRef(() => { });
85
+ const dropMarkerRef = useRef(null);
86
+ const selectionMarkerRef = useRef(null);
87
+ const emitterWireframesRef = useRef(null);
88
+ const viewportPickerRef = useRef(null);
89
+ const galleryDragDepthRef = useRef(0);
88
90
  const stateRef = useRef(null);
89
91
  const gizmoManagerRef = useRef(null);
90
92
  const [binding, setBinding] = useState(null);
91
93
  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);
94
+ const playbackRef = useRef(createPlaybackState());
95
+ const focusTimelineMetaRef = useRef({ rootId: -1, revision: -1, span: 1, anyLooping: false });
96
+ const [stagePlaying, setStagePlaying] = useState(true);
94
97
  const [speed, setSpeed] = useState(1);
95
98
  const [renamingRoot, setRenamingRoot] = useState(false);
96
99
  const [gizmoMode, setGizmoMode] = useState('none');
97
100
  const [selectedNode, setSelectedNode] = useState(null);
101
+ const [gallery, setGallery] = useState([]);
102
+ const [galleryLoading, setGalleryLoading] = useState(null);
103
+ const [selectedGalleryRoot, setSelectedGalleryRoot] = useState(null);
104
+ const [previewRootIds, setPreviewRootIds] = useState([]);
105
+ const [messageDialog, setMessageDialog] = useState(null);
106
+ const [galleryDragHover, setGalleryDragHover] = useState(null);
107
+ const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
108
+ const isGalleryLoading = galleryLoading !== null;
109
+ const showMessage = (title, message) => setMessageDialog({ title, message });
110
+ /** Appends JSON effects to the catalog without replacing the open effect. */
111
+ const runGalleryImport = async (files) => {
112
+ const jsonFiles = collectJsonFiles(files);
113
+ if (jsonFiles.length === 0) {
114
+ showMessage('Import to gallery', files.length === 0
115
+ ? 'Import was cancelled or the browser returned no files.\n\nTry Chrome or Edge and pick .json files or a folder that contains them.'
116
+ : `No .json files found among ${files.length} file(s) in the selection.`);
117
+ return;
118
+ }
119
+ try {
120
+ await importGalleryRef.current(jsonFiles);
121
+ stateRef.current?.history.clear();
122
+ force();
123
+ }
124
+ catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ showMessage('Import to gallery', message);
127
+ console.error('Failed to import effects into gallery:', err);
128
+ force();
129
+ }
130
+ };
131
+ const handleImportFolder = () => {
132
+ if (typeof window.showDirectoryPicker !== 'function') {
133
+ galleryFolderInputRef.current?.click();
134
+ return;
135
+ }
136
+ void (async () => {
137
+ try {
138
+ const picked = await pickGalleryJsonFiles();
139
+ if (picked === null || picked === undefined) {
140
+ return;
141
+ }
142
+ await runGalleryImport(picked);
143
+ }
144
+ catch (err) {
145
+ const message = err instanceof Error ? err.message : String(err);
146
+ showMessage('Import to gallery', message);
147
+ console.error('Failed to open folder picker:', err);
148
+ }
149
+ })();
150
+ };
98
151
  useEffect(() => {
99
152
  // Register the shared ground-plane collider before any effect (default or imported)
100
153
  // is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
@@ -122,83 +175,351 @@ export function EffectEditorHost(props) {
122
175
  groundMaterial.opacity = 0.5;
123
176
  const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
124
177
  ground.material = groundMaterial;
125
- const stageRoot = new TransformNode('StageRoot', scene);
126
178
  const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
127
179
  const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
128
180
  gizmoManager.usePointerToAttachGizmos = false;
129
- gizmoManager.attachToNode(stageRoot);
130
181
  gizmoManagerRef.current = gizmoManager;
131
182
  const renderer = new BatchedRenderer('quarks-editor', scene);
132
183
  const history = new EffectHistory();
184
+ const galleryRoot = new TransformNode('GalleryCatalog', scene);
185
+ galleryRoot.setEnabled(false);
186
+ let galleryEntries = [];
187
+ const dropMarker = new GalleryDropMarker(scene);
188
+ dropMarkerRef.current = dropMarker;
189
+ const selectionMarker = new GallerySelectionMarker(scene);
190
+ selectionMarkerRef.current = selectionMarker;
191
+ const emitterWireframes = new EmitterShapeWireframes(scene);
192
+ emitterWireframesRef.current = emitterWireframes;
193
+ const viewportPicker = new GalleryViewportPicker(scene);
194
+ viewportPickerRef.current = viewportPicker;
195
+ const handleViewportPointerLeave = () => viewportPicker.setHover(null);
196
+ canvasRef.current?.addEventListener('pointerleave', handleViewportPointerLeave);
197
+ const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
198
+ if (galleryDragDepthRef.current > 0) {
199
+ return;
200
+ }
201
+ const event = pointerInfo.event;
202
+ if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
203
+ const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
204
+ const focusedRoot = stateRef.current?.binding?.root ?? null;
205
+ viewportPicker.setHover(effectRoot && effectRoot !== focusedRoot ? effectRoot : null);
206
+ return;
207
+ }
208
+ if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
209
+ return;
210
+ }
211
+ const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
212
+ if (!effectRoot) {
213
+ return;
214
+ }
215
+ const snap = stateRef.current;
216
+ if (!snap) {
217
+ return;
218
+ }
219
+ const entry = snap.galleryEntries.find((item) => item.root === effectRoot);
220
+ if (entry && snap.binding?.root !== effectRoot) {
221
+ selectGalleryEntryRef.current(entry);
222
+ }
223
+ setSelectedNode(effectRoot);
224
+ });
225
+ const clearGalleryDragPreview = () => {
226
+ galleryDragDepthRef.current = 0;
227
+ dropMarker.hide();
228
+ setGalleryDragHover(null);
229
+ setGalleryDragSession(null);
230
+ };
231
+ const onWindowDragEnd = () => clearGalleryDragPreview();
232
+ window.addEventListener('dragend', onWindowDragEnd);
233
+ const syncStateRef = (bindingValue) => {
234
+ stateRef.current = {
235
+ engine,
236
+ scene,
237
+ camera,
238
+ ground,
239
+ renderer,
240
+ binding: bindingValue,
241
+ history,
242
+ galleryRoot,
243
+ galleryEntries,
244
+ previewRootIds: stateRef.current?.previewRootIds ?? [],
245
+ };
246
+ };
247
+ const focusCatalogEntry = (entry) => {
248
+ const next = bindingFromGalleryEntry(entry);
249
+ history.clear();
250
+ history.attach(next);
251
+ next.subscribe(force);
252
+ syncStateRef(next);
253
+ setBinding(next);
254
+ setSelectedNode(entry.root);
255
+ setSelectedGalleryRoot(entry.root);
256
+ playbackRef.current.focusRootId = entry.root.uniqueId;
257
+ if (entry.inScene) {
258
+ selectionMarker.show(entry.root);
259
+ focusGalleryEntry(camera, entry.root);
260
+ }
261
+ else {
262
+ selectionMarker.hide();
263
+ }
264
+ };
265
+ const toggleGalleryPreview = (entry) => {
266
+ if (!entry.inScene) {
267
+ return;
268
+ }
269
+ const id = entry.root.uniqueId;
270
+ setPreviewRootIds((prev) => {
271
+ const next = new Set(prev);
272
+ if (next.has(id)) {
273
+ next.delete(id);
274
+ }
275
+ else {
276
+ next.add(id);
277
+ }
278
+ const ids = [...next];
279
+ if (stateRef.current) {
280
+ stateRef.current.previewRootIds = ids;
281
+ }
282
+ return ids;
283
+ });
284
+ };
285
+ const addGalleryPreview = (...entries) => {
286
+ const ids = entries.filter((entry) => entry.inScene).map((entry) => entry.root.uniqueId);
287
+ if (ids.length === 0) {
288
+ return;
289
+ }
290
+ setPreviewRootIds((prev) => {
291
+ const next = [...new Set([...prev, ...ids])];
292
+ if (stateRef.current) {
293
+ stateRef.current.previewRootIds = next;
294
+ }
295
+ return next;
296
+ });
297
+ };
298
+ const placeGalleryDropAt = (clientX, clientY, dataTransfer) => {
299
+ const payload = (dataTransfer && readGalleryDrag(dataTransfer)) ?? getGalleryDragSession();
300
+ if (!payload) {
301
+ return;
302
+ }
303
+ const { entries: toPlace } = describeGalleryDrag(galleryEntries, payload);
304
+ if (toPlace.length === 0) {
305
+ return;
306
+ }
307
+ const position = pickGroundPosition(scene, clientX, clientY);
308
+ if (toPlace.length === 1) {
309
+ placeGalleryEntryInScene(toPlace[0], renderer, position);
310
+ }
311
+ else {
312
+ placeGalleryEntriesAt(toPlace, renderer, position);
313
+ }
314
+ galleryEntries = [...galleryEntries];
315
+ if (stateRef.current) {
316
+ stateRef.current.galleryEntries = galleryEntries;
317
+ }
318
+ setGallery(galleryEntries);
319
+ addGalleryPreview(...toPlace);
320
+ focusCatalogEntry(toPlace[0]);
321
+ dropMarkerRef.current?.hide();
322
+ setGalleryDragHover(null);
323
+ setGalleryDragSession(null);
324
+ force();
325
+ };
133
326
  const mount = (nextBinding) => {
134
- nextBinding.root.parent = stageRoot;
327
+ nextBinding.root.parent = null;
135
328
  history.attach(nextBinding);
136
329
  nextBinding.subscribe(force);
137
- stateRef.current = { engine, scene, renderer, binding: nextBinding, history, stageRoot };
330
+ syncStateRef(nextBinding);
138
331
  setBinding(nextBinding);
139
- setSelectedNode(nextBinding.system.emitter);
332
+ setSelectedNode(nextBinding.root);
333
+ props.onReady?.(makeHandle());
334
+ };
335
+ const appendGalleryFromFiles = async (files) => {
336
+ setGalleryLoading({ done: 0, total: 0, loaded: 0, failed: 0 });
337
+ const incoming = await loadEffectGallery(scene, files, galleryRoot, (progress) => setGalleryLoading(progress));
338
+ const { merged, added } = mergeGalleryEntries(galleryEntries, incoming, renderer);
339
+ galleryEntries = merged;
340
+ if (stateRef.current) {
341
+ stateRef.current.galleryRoot = galleryRoot;
342
+ stateRef.current.galleryEntries = merged;
343
+ }
344
+ setGalleryLoading(null);
345
+ setGallery(merged);
346
+ if (incoming.length === 0) {
347
+ throw new Error('No particle effects found in the selected file(s).');
348
+ }
349
+ if (added.length === 0) {
350
+ throw new Error('All selected effects are already in the catalog.');
351
+ }
352
+ focusCatalogEntry(added[0]);
353
+ props.onReady?.(makeHandle());
354
+ };
355
+ const appendGalleryDefault = () => {
356
+ const entry = createGalleryDefaultEntry(scene, galleryRoot, galleryEntries, {
357
+ resolveTexture: props.resolveTexture,
358
+ textureUrl: props.textureOptions?.[0]?.url,
359
+ });
360
+ galleryEntries = [...galleryEntries, entry];
361
+ if (stateRef.current) {
362
+ stateRef.current.galleryRoot = galleryRoot;
363
+ stateRef.current.galleryEntries = galleryEntries;
364
+ }
365
+ setGallery(galleryEntries);
366
+ focusCatalogEntry(entry);
140
367
  props.onReady?.(makeHandle());
141
368
  };
142
369
  const makeHandle = () => ({
143
- // Live getter: stays correct after presets/imports/undo replace the binding.
144
370
  get binding() {
145
- return stateRef.current.binding;
371
+ const b = stateRef.current?.binding;
372
+ if (!b) {
373
+ throw new Error('Gallery mode — import a single JSON to edit one effect.');
374
+ }
375
+ return b;
146
376
  },
147
377
  history,
148
378
  scene,
149
379
  renderer,
150
380
  importJson,
151
- exportJson: () => stateRef.current.binding.exportJSON('EditorEffect'),
381
+ importGallery: appendGalleryFromFiles,
382
+ exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
152
383
  });
153
384
  const importJson = (json) => {
154
- const current = stateRef.current.binding;
155
- // Load first so a bad/empty JSON leaves the current effect intact.
385
+ const current = stateRef.current?.binding;
156
386
  const { root, systems } = loadEffectFromJson(scene, renderer, json);
157
387
  if (systems.length === 0) {
158
388
  disposeEffect(root, systems, renderer);
159
389
  throw new Error('No particle systems found in the JSON.');
160
390
  }
161
- disposeEffect(current.root, [current.system, ...current.subSystems], renderer);
391
+ if (current) {
392
+ const catalogEntry = stateRef.current?.galleryEntries.find((entry) => entry.root === current.root);
393
+ disposeEffect(current.root, [current.system, ...current.subSystems], renderer, catalogEntry?.inScene ?? true);
394
+ }
162
395
  const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
163
- mount(new EffectBinding(main, systems.filter((s) => s !== main), root));
396
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
397
+ setSelectedGalleryRoot(null);
398
+ setPreviewRootIds([]);
399
+ selectionMarker.hide();
400
+ mount(next);
401
+ playbackRef.current.focusRootId = next.root.uniqueId;
402
+ };
403
+ importJsonRef.current = importJson;
404
+ importGalleryRef.current = appendGalleryFromFiles;
405
+ selectGalleryEntryRef.current = focusCatalogEntry;
406
+ toggleGalleryPreviewRef.current = toggleGalleryPreview;
407
+ placeGalleryDropRef.current = placeGalleryDropAt;
408
+ createGalleryDefaultRef.current = appendGalleryDefault;
409
+ const galleryDefaults = {
410
+ resolveTexture: props.resolveTexture,
411
+ textureUrl: props.textureOptions?.[0]?.url,
164
412
  };
165
- let initial;
413
+ let initialEntry;
166
414
  if (props.effectJson) {
167
415
  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);
416
+ root.parent = null;
417
+ const name = root.name || 'Effect';
418
+ initialEntry = { name, path: `_opened/${name}.json`, root, systems, inScene: true };
170
419
  }
171
420
  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);
421
+ initialEntry = createGalleryDefaultEntry(scene, galleryRoot, [], galleryDefaults);
422
+ placeGalleryEntryInScene(initialEntry, renderer, BVector3.Zero());
423
+ }
424
+ galleryEntries = [initialEntry];
425
+ const initialPreviewIds = initialEntry.inScene ? [initialEntry.root.uniqueId] : [];
426
+ if (stateRef.current) {
427
+ stateRef.current.galleryRoot = galleryRoot;
428
+ stateRef.current.galleryEntries = galleryEntries;
429
+ stateRef.current.previewRootIds = initialPreviewIds;
179
430
  }
180
- mount(initial);
431
+ setPreviewRootIds(initialPreviewIds);
432
+ setGallery(galleryEntries);
433
+ focusCatalogEntry(initialEntry);
434
+ props.onReady?.(makeHandle());
181
435
  engine.runRenderLoop(() => {
182
436
  const playback = playbackRef.current;
437
+ const snapshot = stateRef.current;
183
438
  if (!playback.scrubbing) {
184
439
  let delta = (engine.getDeltaTime() / 1000) * playback.speed;
185
- if (playback.paused) {
186
- delta = playback.stepQueued ? 1 / 60 : 0;
440
+ const stepQueued = playback.stepQueued;
441
+ if (!playback.stagePlaying) {
442
+ delta = stepQueued ? 1 / 60 : 0;
187
443
  playback.stepQueued = false;
188
444
  }
189
- if (delta > 0) {
190
- renderer.update(delta);
191
- playback.elapsed += delta;
445
+ if (delta > 0 && snapshot) {
446
+ const previewIds = new Set(snapshot.previewRootIds);
447
+ const simulate = hasActivePreview(snapshot.galleryEntries, previewIds) && (playback.stagePlaying || stepQueued);
448
+ if (simulate) {
449
+ renderer.update(delta);
450
+ const focusId = playback.focusRootId ?? snapshot.binding?.root.uniqueId ?? null;
451
+ if (focusId != null && playback.stagePlaying && !playback.focusFinishedByRoot.has(focusId)) {
452
+ const prev = getFocusElapsed(playback, focusId);
453
+ setFocusElapsed(playback, focusId, prev + delta);
454
+ }
455
+ }
192
456
  }
193
457
  }
194
458
  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;
459
+ selectionMarker.follow();
460
+ viewportPickerRef.current?.follow();
461
+ const wireframes = emitterWireframesRef.current;
462
+ if (wireframes && snapshot?.binding) {
463
+ const b = snapshot.binding;
464
+ wireframes.sync([b.system, ...b.subSystems], `${b.root.uniqueId}:${b.getRevision()}`);
465
+ }
466
+ const picker = viewportPickerRef.current;
467
+ if (picker && snapshot) {
468
+ const b = snapshot.binding;
469
+ const galleryKey = snapshot.galleryEntries
470
+ .map((entry) => `${entry.root.uniqueId}:${entry.inScene ? 1 : 0}`)
471
+ .join('|');
472
+ const key = `${galleryKey}::${b?.root.uniqueId ?? 'none'}`;
473
+ picker.sync(snapshot.galleryEntries, b?.root ?? null, key);
199
474
  }
200
- if (counterRef.current) {
201
- counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
475
+ if (!snapshot)
476
+ return;
477
+ if (fpsRef.current) {
478
+ fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
479
+ }
480
+ const previewIds = new Set(snapshot.previewRootIds);
481
+ const binding = snapshot.binding;
482
+ if (binding && (binding.root.uniqueId !== focusTimelineMetaRef.current.rootId || binding.getRevision() !== focusTimelineMetaRef.current.revision)) {
483
+ const rows = computeTimelineRows(binding);
484
+ focusTimelineMetaRef.current = {
485
+ rootId: binding.root.uniqueId,
486
+ revision: binding.getRevision(),
487
+ span: computeTimelineSpan(rows),
488
+ anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
489
+ };
490
+ }
491
+ if (snapshot.galleryEntries.length > 0) {
492
+ const playing = countActivePreviews(snapshot.galleryEntries, previewIds);
493
+ const count = countGalleryParticles(snapshot.galleryEntries, previewIds);
494
+ const inScene = snapshot.galleryEntries.filter((entry) => entry.inScene).length;
495
+ const focused = snapshot.galleryEntries.find((entry) => entry.root === binding?.root);
496
+ const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
497
+ const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
498
+ const focusEnded = focusId != null && isFocusFinished(playback, focusId);
499
+ const meta = focusTimelineMetaRef.current;
500
+ if (counterRef.current) {
501
+ const focusLabel = focused ? ` · focus: ${focused.name}` : '';
502
+ const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
503
+ const timeLabel = meta.anyLooping
504
+ ? `${focusElapsed.toFixed(1)}s ⟳`
505
+ : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
506
+ counterRef.current.textContent = `${playing} preview · ${stageLabel}${focusLabel} · ${timeLabel} · ${count} particles · ${inScene}/${snapshot.galleryEntries.length} in catalog`;
507
+ }
508
+ }
509
+ else if (binding) {
510
+ const b = binding;
511
+ const count = b.system.particleNum + b.subSystems.reduce((sum, sub) => sum + sub.particleNum, 0);
512
+ const focusId = playback.focusRootId ?? b.root.uniqueId;
513
+ const focusElapsed = getFocusElapsed(playback, focusId);
514
+ const focusEnded = isFocusFinished(playback, focusId);
515
+ const meta = focusTimelineMetaRef.current;
516
+ if (counterRef.current) {
517
+ const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
518
+ const timeLabel = meta.anyLooping
519
+ ? `${focusElapsed.toFixed(1)}s ⟳`
520
+ : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
521
+ counterRef.current.textContent = `${stageLabel} · ${timeLabel} · ${count} particles`;
522
+ }
202
523
  }
203
524
  });
204
525
  const resizeObserver = new ResizeObserver(() => engine.resize());
@@ -217,12 +538,45 @@ export function EffectEditorHost(props) {
217
538
  return () => {
218
539
  resizeObserver.disconnect();
219
540
  window.removeEventListener('keydown', onKey);
541
+ window.removeEventListener('dragend', onWindowDragEnd);
542
+ clearGalleryDragPreview();
543
+ cancelGalleryCameraFocus();
544
+ dropMarker.dispose();
545
+ dropMarkerRef.current = null;
546
+ selectionMarker.dispose();
547
+ selectionMarkerRef.current = null;
548
+ emitterWireframes.dispose();
549
+ emitterWireframesRef.current = null;
550
+ viewportPicker.dispose();
551
+ viewportPickerRef.current = null;
552
+ canvasRef.current?.removeEventListener('pointerleave', handleViewportPointerLeave);
553
+ scene.onPointerObservable.remove(pointerObserver);
220
554
  gizmoManager.dispose();
221
555
  gizmoUtilityLayer.dispose();
222
556
  engine.dispose();
223
557
  };
224
558
  // eslint-disable-next-line react-hooks/exhaustive-deps
225
559
  }, []);
560
+ useEffect(() => {
561
+ if (stateRef.current) {
562
+ stateRef.current.previewRootIds = previewRootIds;
563
+ }
564
+ }, [previewRootIds]);
565
+ useEffect(() => {
566
+ playbackRef.current.stagePlaying = stagePlaying;
567
+ const snap = stateRef.current;
568
+ if (!snap) {
569
+ return;
570
+ }
571
+ const isCatalogEntry = snap.galleryEntries.length > 0;
572
+ if (!isCatalogEntry) {
573
+ return;
574
+ }
575
+ applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying);
576
+ }, [stagePlaying, previewRootIds, gallery]);
577
+ useEffect(() => {
578
+ emitterWireframesRef.current?.setVisible(showEmitterWireframes);
579
+ }, [showEmitterWireframes]);
226
580
  useEffect(() => {
227
581
  const gm = gizmoManagerRef.current;
228
582
  if (!gm)
@@ -231,48 +585,262 @@ export function EffectEditorHost(props) {
231
585
  gm.rotationGizmoEnabled = gizmoMode === 'rotation';
232
586
  gm.scaleGizmoEnabled = gizmoMode === 'scale';
233
587
  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());
588
+ let dragRaf = 0;
589
+ const scheduleTransformRefresh = () => {
590
+ if (dragRaf !== 0) {
591
+ return;
592
+ }
593
+ dragRaf = requestAnimationFrame(() => {
594
+ dragRaf = 0;
595
+ notifyTransformChanged();
596
+ });
597
+ };
598
+ const dragObserver = activeGizmo?.onDragObservable.add(scheduleTransformRefresh);
599
+ const dragEndObserver = activeGizmo?.onDragEndObservable.add(() => notifyTransformChanged());
235
600
  return () => {
236
- if (activeGizmo && observer)
237
- activeGizmo.onDragObservable.remove(observer);
601
+ if (dragRaf !== 0) {
602
+ cancelAnimationFrame(dragRaf);
603
+ }
604
+ if (activeGizmo && dragObserver) {
605
+ activeGizmo.onDragObservable.remove(dragObserver);
606
+ }
607
+ if (activeGizmo && dragEndObserver) {
608
+ activeGizmo.onDragEndObservable.remove(dragEndObserver);
609
+ }
238
610
  };
239
611
  }, [gizmoMode]);
240
612
  useEffect(() => {
241
613
  const gm = gizmoManagerRef.current;
242
- const stageRoot = stateRef.current?.stageRoot;
243
- if (!gm || !stageRoot)
614
+ if (!gm)
244
615
  return;
245
616
  if (selectedNode && selectedNode.isDisposed()) {
246
- setSelectedNode(stateRef.current?.binding.system.emitter ?? null);
617
+ setSelectedNode(stateRef.current?.binding?.root ?? stateRef.current?.binding?.system.emitter ?? null);
618
+ return;
619
+ }
620
+ if (isGalleryLoading) {
247
621
  return;
248
622
  }
249
- gm.attachToNode(selectedNode ?? stageRoot);
250
- }, [selectedNode, binding]);
623
+ const target = selectedNode && !selectedNode.isDisposed()
624
+ ? selectedNode
625
+ : binding?.root && !binding.root.isDisposed()
626
+ ? binding.root
627
+ : null;
628
+ if (target) {
629
+ gm.attachToNode(target);
630
+ }
631
+ }, [selectedNode, binding, isGalleryLoading]);
251
632
  const state = stateRef.current;
252
- const gizmoNode = selectedNode && !selectedNode.isDisposed() ? selectedNode : state?.stageRoot;
633
+ const gizmoNode = selectedNode && !selectedNode.isDisposed()
634
+ ? selectedNode
635
+ : binding?.root && !binding.root.isDisposed()
636
+ ? binding.root
637
+ : null;
253
638
  const historyAction = (json) => {
254
- if (json && state) {
639
+ if (json && state?.binding) {
255
640
  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);
641
+ const inGallery = state.galleryEntries.length > 0;
642
+ const entryIndex = inGallery ? state.galleryEntries.findIndex((e) => e.root === current.root) : -1;
643
+ const prev = entryIndex >= 0 ? state.galleryEntries[entryIndex] : null;
644
+ const wasInScene = prev?.inScene ?? true;
645
+ const savedPosition = wasInScene ? current.root.position.clone() : null;
646
+ if (inGallery && entryIndex >= 0 && prev) {
647
+ disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer, wasInScene);
648
+ const { root, systems } = parseEffectFromJson(state.scene, wasInScene ? state.renderer : null, json, {
649
+ registerRenderer: wasInScene,
650
+ autoplay: false,
651
+ });
652
+ if (systems.length === 0) {
653
+ disposeLoadedEffect(root, systems, state.renderer, wasInScene);
654
+ return;
655
+ }
656
+ if (wasInScene && savedPosition) {
657
+ root.position.copyFrom(savedPosition);
658
+ root.parent = null;
659
+ root.setEnabled(true);
660
+ }
661
+ else {
662
+ root.parent = state.galleryRoot;
663
+ root.setEnabled(false);
664
+ }
665
+ if (!root.name || root.name === 'Effect' || root.name === 'Object3D') {
666
+ root.name = prev.name;
667
+ }
668
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
669
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
670
+ const oldRootId = current.root.uniqueId;
671
+ const updated = [...state.galleryEntries];
672
+ updated[entryIndex] = { name: prev.name, path: prev.path, root, systems, inScene: wasInScene };
673
+ state.galleryEntries = updated;
674
+ setGallery(updated);
675
+ setPreviewRootIds((ids) => {
676
+ const next = ids.map((id) => (id === oldRootId ? root.uniqueId : id));
677
+ if (stateRef.current) {
678
+ stateRef.current.previewRootIds = next;
679
+ }
680
+ return next;
681
+ });
682
+ state.history.attach(next);
683
+ next.subscribe(force);
684
+ state.binding = next;
685
+ setBinding(next);
686
+ setSelectedNode(root);
687
+ setSelectedGalleryRoot(root);
688
+ playbackRef.current.focusRootId = root.uniqueId;
689
+ if (wasInScene) {
690
+ selectionMarkerRef.current?.show(root);
691
+ }
692
+ else {
693
+ selectionMarkerRef.current?.hide();
694
+ }
695
+ }
696
+ else {
697
+ const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
698
+ if (systems.length === 0) {
699
+ disposeEffect(root, systems, state.renderer);
700
+ return;
701
+ }
702
+ disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
703
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
704
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
705
+ next.root.parent = null;
706
+ state.history.attach(next);
707
+ next.subscribe(force);
708
+ state.binding = next;
709
+ setBinding(next);
710
+ setSelectedNode(root);
711
+ setSelectedGalleryRoot(null);
712
+ selectionMarkerRef.current?.hide();
713
+ state.ground.scaling.x = 1;
714
+ state.ground.scaling.z = 1;
715
+ state.camera.radius = 9;
716
+ state.camera.setTarget(new BVector3(0, 1.4, 0));
717
+ }
266
718
  }
267
719
  force();
268
720
  };
269
721
  const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
270
722
  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) => {
723
+ const gizmoTargetLabel = gizmoNode?.name || 'Node';
724
+ const selectedGalleryEntry = selectedGalleryRoot
725
+ ? gallery.find((entry) => entry.root === selectedGalleryRoot) ?? null
726
+ : null;
727
+ /** Timeline is always shown when an effect is selected; playback only when it is on stage. */
728
+ const showTimeline = Boolean(binding && activeSystem && state);
729
+ const timelinePlaybackEnabled = !selectedGalleryEntry || selectedGalleryEntry.inScene;
730
+ const previewRootIdSet = useMemo(() => new Set(previewRootIds), [previewRootIds]);
731
+ const resolveTextureForEditor = useCallback((url) => {
732
+ const scene = stateRef.current?.scene;
733
+ return scene && props.resolveTexture ? props.resolveTexture(url, scene) : undefined;
734
+ }, [props.resolveTexture]);
735
+ const resolveGeometryForEditor = useCallback((file) => {
736
+ const scene = stateRef.current?.scene;
737
+ if (!scene || !props.resolveGeometry) {
738
+ return Promise.reject(new Error('Geometry loader is not available.'));
739
+ }
740
+ return props.resolveGeometry(file, scene);
741
+ }, [props.resolveGeometry]);
742
+ 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: {
743
+ width: GALLERY_PANEL_WIDTH,
744
+ flexShrink: 0,
745
+ borderRight: `1px solid ${theme.border}`,
746
+ background: theme.panelBg,
747
+ display: 'flex',
748
+ flexDirection: 'column',
749
+ minHeight: 0,
750
+ }, 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 the viewport to place effects." })] }), _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), onImportFiles: (files) => void runGalleryImport(files), onImportFolder: () => void handleImportFolder(), onCreateDefault: () => createGalleryDefaultRef.current() }) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
751
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
752
+ return;
753
+ galleryDragDepthRef.current += 1;
754
+ }, onDragLeave: (e) => {
755
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
756
+ return;
757
+ galleryDragDepthRef.current -= 1;
758
+ if (galleryDragDepthRef.current <= 0) {
759
+ galleryDragDepthRef.current = 0;
760
+ dropMarkerRef.current?.hide();
761
+ setGalleryDragHover(null);
762
+ }
763
+ }, onDragOver: (e) => {
764
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
765
+ return;
766
+ e.preventDefault();
767
+ e.dataTransfer.dropEffect = 'copy';
768
+ const payload = getGalleryDragSession();
769
+ const snap = stateRef.current;
770
+ if (!payload || !snap)
771
+ return;
772
+ const described = describeGalleryDrag(snap.galleryEntries, payload);
773
+ if (described.entries.length === 0)
774
+ return;
775
+ const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
776
+ dropMarkerRef.current?.show(position, described.entries.length);
777
+ setGalleryDragHover({ label: described.label, count: described.entries.length });
778
+ }, onDrop: (e) => {
779
+ if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
780
+ return;
781
+ e.preventDefault();
782
+ galleryDragDepthRef.current = 0;
783
+ placeGalleryDropRef.current(e.clientX, e.clientY, e.dataTransfer);
784
+ }, children: [_jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }), _jsx("div", { style: {
785
+ position: 'absolute',
786
+ bottom: 12,
787
+ left: 12,
788
+ background: theme.panelBg,
789
+ border: `1px solid ${theme.border}`,
790
+ borderRadius: 8,
791
+ padding: '4px 10px',
792
+ fontSize: 12,
793
+ color: theme.textDim,
794
+ fontVariantNumeric: 'tabular-nums',
795
+ pointerEvents: 'none',
796
+ fontFamily: theme.font,
797
+ }, children: _jsx("span", { ref: fpsRef, children: "\u2014 FPS" }) }), galleryDragHover && (_jsx("div", { style: {
798
+ position: 'absolute',
799
+ inset: 10,
800
+ border: '2px dashed rgba(120, 165, 255, 0.75)',
801
+ borderRadius: 12,
802
+ pointerEvents: 'none',
803
+ boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
804
+ }, children: _jsxs("div", { style: {
805
+ position: 'absolute',
806
+ left: '50%',
807
+ bottom: 18,
808
+ transform: 'translateX(-50%)',
809
+ background: 'rgba(7, 11, 22, 0.88)',
810
+ border: `1px solid ${theme.border}`,
811
+ borderRadius: 8,
812
+ padding: '8px 14px',
813
+ fontSize: 13,
814
+ color: theme.text,
815
+ whiteSpace: 'nowrap',
816
+ }, children: ["Drop ", galleryDragHover.count === 1 ? 'effect' : `${galleryDragHover.count} effects`, " \u2014 ", galleryDragHover.label] }) })), galleryLoading && (_jsx("div", { style: {
817
+ position: 'absolute',
818
+ inset: 0,
819
+ display: 'flex',
820
+ alignItems: 'center',
821
+ justifyContent: 'center',
822
+ background: 'rgba(7, 11, 22, 0.72)',
823
+ pointerEvents: 'none',
824
+ }, 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: {
825
+ position: 'absolute',
826
+ top: 12,
827
+ left: 12,
828
+ display: 'inline-flex',
829
+ alignItems: 'center',
830
+ gap: 6,
831
+ background: theme.panelBg,
832
+ border: `1px solid ${theme.border}`,
833
+ borderRadius: 8,
834
+ padding: '6px 12px',
835
+ fontSize: 13,
836
+ fontWeight: 500,
837
+ color: theme.text,
838
+ textDecoration: 'none',
839
+ fontFamily: theme.font,
840
+ }, 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
841
  binding.apply(() => (binding.root.name = name));
274
842
  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: () => {
843
+ }, 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
844
  if (!binding)
277
845
  return;
278
846
  const blob = new Blob([binding.exportJSON('EditorEffect')], { type: 'application/json' });
@@ -281,21 +849,7 @@ export function EffectEditorHost(props) {
281
849
  link.download = 'effect.json';
282
850
  link.click();
283
851
  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 }))] }));
852
+ }, 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, 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
853
  }
300
854
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
301
855
  export const QuarksEffectEditor = {