babylon.quarks-editor 0.17.7 → 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 (130) 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 +8 -4
  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.d.ts +3 -0
  46. package/dist/react/EffectEditor.js +3 -1
  47. package/dist/react/EffectEditorHost.d.ts +7 -0
  48. package/dist/react/EffectEditorHost.js +717 -102
  49. package/dist/react/GalleryPanel.d.ts +19 -0
  50. package/dist/react/GalleryPanel.js +215 -0
  51. package/dist/react/InspectorInfoIcon.d.ts +7 -0
  52. package/dist/react/InspectorInfoIcon.js +22 -0
  53. package/dist/react/InspectorLabel.d.ts +7 -0
  54. package/dist/react/InspectorLabel.js +12 -0
  55. package/dist/react/MessageDialog.d.ts +9 -0
  56. package/dist/react/MessageDialog.js +24 -0
  57. package/dist/react/ModuleSection.d.ts +2 -0
  58. package/dist/react/ModuleSection.js +8 -1
  59. package/dist/react/ObjectModule.d.ts +6 -0
  60. package/dist/react/ObjectModule.js +40 -0
  61. package/dist/react/TimelinePanel.d.ts +11 -15
  62. package/dist/react/TimelinePanel.js +129 -65
  63. package/dist/react/TimelineTrackRow.d.ts +2 -2
  64. package/dist/react/TimelineTrackRow.js +41 -23
  65. package/dist/react/Tooltip.d.ts +6 -0
  66. package/dist/react/Tooltip.js +65 -0
  67. package/dist/react/ValueField.d.ts +2 -0
  68. package/dist/react/ValueField.js +1 -1
  69. package/dist/react/behaviorModules.js +8 -8
  70. package/dist/react/fields.d.ts +2 -0
  71. package/dist/react/fields.js +14 -5
  72. package/dist/react/icons.d.ts +7 -0
  73. package/dist/react/icons.js +10 -0
  74. package/dist/react/index.d.ts +4 -0
  75. package/dist/react/index.js +2 -0
  76. package/dist/react/inspectorHints.d.ts +2 -0
  77. package/dist/react/inspectorHints.js +138 -0
  78. package/dist/react/modules.js +109 -81
  79. package/dist/react/theme.d.ts +2 -0
  80. package/dist/react/theme.js +97 -22
  81. package/package.json +72 -63
  82. package/src/core/binding.ts +102 -105
  83. package/src/core/collision.ts +40 -40
  84. package/src/core/defaultEffect.ts +74 -0
  85. package/src/core/editorScene.ts +14 -0
  86. package/src/core/effectTree.ts +2 -1
  87. package/src/core/emitterShapeWireframe.ts +373 -0
  88. package/src/core/galleryDragSession.ts +12 -0
  89. package/src/core/galleryDropMarker.ts +57 -0
  90. package/src/core/galleryLayout.ts +90 -0
  91. package/src/core/galleryPlayback.ts +55 -0
  92. package/src/core/galleryScene.ts +58 -0
  93. package/src/core/gallerySelectionMarker.ts +84 -0
  94. package/src/core/galleryTree.ts +187 -0
  95. package/src/core/groups.ts +29 -29
  96. package/src/core/loadEffect.ts +139 -102
  97. package/src/core/loadEffectGallery.ts +146 -0
  98. package/src/core/material.ts +84 -0
  99. package/src/core/pickGalleryFiles.ts +58 -0
  100. package/src/core/playback.ts +49 -0
  101. package/src/core/scrub.ts +60 -28
  102. package/src/core/shapes.ts +133 -133
  103. package/src/core/systems.ts +98 -98
  104. package/src/core/timeline.ts +98 -94
  105. package/src/core/transformStore.ts +19 -0
  106. package/src/core/values.ts +113 -113
  107. package/src/core/viewportPicker.ts +114 -0
  108. package/src/index.ts +34 -32
  109. package/src/react/CurveEditor.tsx +168 -168
  110. package/src/react/EffectEditor.tsx +123 -111
  111. package/src/react/EffectEditorHost.tsx +1162 -377
  112. package/src/react/GalleryPanel.tsx +453 -0
  113. package/src/react/GradientEditor.tsx +127 -127
  114. package/src/react/InspectorInfoIcon.tsx +36 -0
  115. package/src/react/InspectorLabel.tsx +18 -0
  116. package/src/react/MessageDialog.tsx +57 -0
  117. package/src/react/ModuleSection.tsx +69 -54
  118. package/src/react/ObjectModule.tsx +58 -0
  119. package/src/react/PromptDialog.tsx +85 -85
  120. package/src/react/TimelinePanel.tsx +448 -354
  121. package/src/react/TimelineTrackRow.tsx +326 -267
  122. package/src/react/Tooltip.tsx +93 -0
  123. package/src/react/ValueField.tsx +3 -1
  124. package/src/react/behaviorModules.tsx +663 -658
  125. package/src/react/fields.tsx +85 -76
  126. package/src/react/icons.ts +19 -0
  127. package/src/react/index.ts +47 -43
  128. package/src/react/inspectorHints.ts +150 -0
  129. package/src/react/modules.tsx +1049 -1034
  130. package/src/react/theme.ts +161 -85
@@ -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';
@@ -9,51 +11,61 @@ import { Vector3 as BVector3 } from '@babylonjs/core/Maths/math.vector';
9
11
  import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
10
12
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
11
13
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
14
+ import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
15
+ import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
12
16
  import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
13
- import { BatchedRenderer, ColorOverLife, ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
17
+ import { BatchedRenderer, } from 'babylon.quarks';
14
18
  import { EffectBinding } from '../core/binding';
15
19
  import { EffectHistory } from '../core/history';
16
20
  import { ensureGroundResolver } from '../core/collision';
17
- import { loadEffectFromJson } from '../core/loadEffect';
18
- 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';
19
37
  import { EffectEditor } from './EffectEditor';
20
38
  import { PromptDialog } from './PromptDialog';
39
+ import { MessageDialog } from './MessageDialog';
40
+ import { GalleryPanel } from './GalleryPanel';
21
41
  import { TimelinePanel } from './TimelinePanel';
22
42
  import { buttonStyle, globalEditorStyles, theme } from './theme';
23
- function createDefaultEffect(scene, resolveTexture, textureUrl) {
24
- const system = new ParticleSystem({
25
- scene,
26
- duration: 4,
27
- looping: true,
28
- startLife: new IntervalValue(1.2, 1.8),
29
- startSpeed: new IntervalValue(3, 5),
30
- startSize: new IntervalValue(0.25, 0.5),
31
- startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
32
- emissionOverTime: new ConstantValue(80),
33
- // quarks' angle is a half-angle; Babylon's native cone uses a full angle.
34
- shape: new ConeEmitter({ radius: 0.3, angle: 0.25 }),
35
- renderMode: RenderMode.BillBoard,
36
- texture: (textureUrl && resolveTexture ? resolveTexture(textureUrl, scene) : undefined),
37
- transparent: true,
38
- blendMode: 1,
39
- });
40
- system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
41
- // quarks emits along local +Z; Babylon's native cone emits along +Y.
42
- system.emitter.rotation.x = -Math.PI / 2;
43
- return system;
44
- }
43
+ import { iconStyle } from './icons';
45
44
  /** Disposes an effect's systems and its remaining group tree (otherwise the groups leak). */
46
- function disposeEffect(root, systems, renderer) {
47
- for (const system of systems) {
48
- renderer.deleteSystem(system);
49
- system.dispose();
50
- }
51
- // Disposing the systems already disposed their emitter nodes; dispose the leftover group
52
- // tree too. Skip when the root IS a disposed emitter (default single-system effect).
53
- if (!systems.some((system) => system.emitter === root)) {
54
- root.dispose(false, true);
55
- }
45
+ function disposeEffect(root, systems, renderer, inRenderer = true) {
46
+ disposeLoadedEffect(root, systems, renderer, inRenderer);
56
47
  }
48
+ const GALLERY_PANEL_WIDTH = 280;
49
+ const GIZMO_MODE_BUTTONS = [
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' },
54
+ ];
55
+ const squareButtonStyle = {
56
+ ...buttonStyle,
57
+ width: 28,
58
+ height: 28,
59
+ padding: 0,
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ fontWeight: 700,
64
+ };
65
+ const activeSquareButtonStyle = {
66
+ border: `1px solid ${theme.borderActive}`,
67
+ background: 'rgba(120, 165, 255, 0.35)',
68
+ };
57
69
  /**
58
70
  * Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
59
71
  * Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
@@ -61,16 +73,81 @@ function disposeEffect(root, systems, renderer) {
61
73
  export function EffectEditorHost(props) {
62
74
  const canvasRef = useRef(null);
63
75
  const counterRef = useRef(null);
76
+ const fpsRef = useRef(null);
64
77
  const playheadRef = useRef(null);
65
- 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);
66
90
  const stateRef = useRef(null);
91
+ const gizmoManagerRef = useRef(null);
67
92
  const [binding, setBinding] = useState(null);
68
93
  const [, force] = useReducer((x) => x + 1, 0);
69
- const playbackRef = useRef({ paused: false, speed: 1, stepQueued: false, elapsed: 0, scrubbing: false });
70
- 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);
71
97
  const [speed, setSpeed] = useState(1);
72
- const [selectedSystem, setSelectedSystem] = useState(null);
73
98
  const [renamingRoot, setRenamingRoot] = useState(false);
99
+ const [gizmoMode, setGizmoMode] = useState('none');
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
+ };
74
151
  useEffect(() => {
75
152
  // Register the shared ground-plane collider before any effect (default or imported)
76
153
  // is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
@@ -98,78 +175,355 @@ export function EffectEditorHost(props) {
98
175
  groundMaterial.opacity = 0.5;
99
176
  const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
100
177
  ground.material = groundMaterial;
178
+ const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
179
+ const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
180
+ gizmoManager.usePointerToAttachGizmos = false;
181
+ gizmoManagerRef.current = gizmoManager;
101
182
  const renderer = new BatchedRenderer('quarks-editor', scene);
102
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
+ };
103
326
  const mount = (nextBinding) => {
327
+ nextBinding.root.parent = null;
104
328
  history.attach(nextBinding);
105
329
  nextBinding.subscribe(force);
106
- stateRef.current = { engine, scene, renderer, binding: nextBinding, history };
330
+ syncStateRef(nextBinding);
107
331
  setBinding(nextBinding);
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);
108
367
  props.onReady?.(makeHandle());
109
368
  };
110
369
  const makeHandle = () => ({
111
- // Live getter: stays correct after presets/imports/undo replace the binding.
112
370
  get binding() {
113
- 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;
114
376
  },
115
377
  history,
116
378
  scene,
117
379
  renderer,
118
380
  importJson,
119
- exportJson: () => stateRef.current.binding.exportJSON('EditorEffect'),
381
+ importGallery: appendGalleryFromFiles,
382
+ exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
120
383
  });
121
384
  const importJson = (json) => {
122
- const current = stateRef.current.binding;
123
- // Load first so a bad/empty JSON leaves the current effect intact.
385
+ const current = stateRef.current?.binding;
124
386
  const { root, systems } = loadEffectFromJson(scene, renderer, json);
125
387
  if (systems.length === 0) {
126
388
  disposeEffect(root, systems, renderer);
127
389
  throw new Error('No particle systems found in the JSON.');
128
390
  }
129
- 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
+ }
130
395
  const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
131
- 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,
132
412
  };
133
- let initial;
413
+ let initialEntry;
134
414
  if (props.effectJson) {
135
415
  const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
136
- const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
137
- 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 };
138
419
  }
139
420
  else {
140
- const system = createDefaultEffect(scene, props.resolveTexture, props.textureOptions?.[0]?.url);
141
- renderer.addSystem(system);
142
- // Root is a plain group (not the system's own emitter) so a new top-level system
143
- // can be added as a sibling later, instead of always nesting under this one.
144
- const root = new TransformNode('Effect', scene);
145
- system.emitter.parent = root;
146
- initial = new EffectBinding(system, [], root);
421
+ initialEntry = createGalleryDefaultEntry(scene, galleryRoot, [], galleryDefaults);
422
+ placeGalleryEntryInScene(initialEntry, renderer, BVector3.Zero());
147
423
  }
148
- mount(initial);
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;
430
+ }
431
+ setPreviewRootIds(initialPreviewIds);
432
+ setGallery(galleryEntries);
433
+ focusCatalogEntry(initialEntry);
434
+ props.onReady?.(makeHandle());
149
435
  engine.runRenderLoop(() => {
150
436
  const playback = playbackRef.current;
437
+ const snapshot = stateRef.current;
151
438
  if (!playback.scrubbing) {
152
439
  let delta = (engine.getDeltaTime() / 1000) * playback.speed;
153
- if (playback.paused) {
154
- delta = playback.stepQueued ? 1 / 60 : 0;
440
+ const stepQueued = playback.stepQueued;
441
+ if (!playback.stagePlaying) {
442
+ delta = stepQueued ? 1 / 60 : 0;
155
443
  playback.stepQueued = false;
156
444
  }
157
- if (delta > 0) {
158
- renderer.update(delta);
159
- 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
+ }
160
456
  }
161
457
  }
162
458
  scene.render();
163
- const b = stateRef.current.binding;
164
- let count = b.system.particleNum;
165
- for (const sub of b.subSystems) {
166
- 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);
474
+ }
475
+ if (!snapshot)
476
+ return;
477
+ if (fpsRef.current) {
478
+ fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
167
479
  }
168
- if (counterRef.current) {
169
- counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
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
+ }
170
523
  }
171
524
  });
172
- const onResize = () => engine.resize();
525
+ const resizeObserver = new ResizeObserver(() => engine.resize());
526
+ resizeObserver.observe(canvasRef.current);
173
527
  const onKey = (e) => {
174
528
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
175
529
  e.preventDefault();
@@ -180,38 +534,313 @@ export function EffectEditorHost(props) {
180
534
  force();
181
535
  }
182
536
  };
183
- window.addEventListener('resize', onResize);
184
537
  window.addEventListener('keydown', onKey);
185
538
  return () => {
186
- window.removeEventListener('resize', onResize);
539
+ resizeObserver.disconnect();
187
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);
554
+ gizmoManager.dispose();
555
+ gizmoUtilityLayer.dispose();
188
556
  engine.dispose();
189
557
  };
190
558
  // eslint-disable-next-line react-hooks/exhaustive-deps
191
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]);
580
+ useEffect(() => {
581
+ const gm = gizmoManagerRef.current;
582
+ if (!gm)
583
+ return;
584
+ gm.positionGizmoEnabled = gizmoMode === 'position';
585
+ gm.rotationGizmoEnabled = gizmoMode === 'rotation';
586
+ gm.scaleGizmoEnabled = gizmoMode === 'scale';
587
+ const activeGizmo = gizmoMode === 'position' ? gm.gizmos.positionGizmo : gizmoMode === 'rotation' ? gm.gizmos.rotationGizmo : gizmoMode === 'scale' ? gm.gizmos.scaleGizmo : null;
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());
600
+ return () => {
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
+ }
610
+ };
611
+ }, [gizmoMode]);
612
+ useEffect(() => {
613
+ const gm = gizmoManagerRef.current;
614
+ if (!gm)
615
+ return;
616
+ if (selectedNode && selectedNode.isDisposed()) {
617
+ setSelectedNode(stateRef.current?.binding?.root ?? stateRef.current?.binding?.system.emitter ?? null);
618
+ return;
619
+ }
620
+ if (isGalleryLoading) {
621
+ return;
622
+ }
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]);
192
632
  const state = stateRef.current;
633
+ const gizmoNode = selectedNode && !selectedNode.isDisposed()
634
+ ? selectedNode
635
+ : binding?.root && !binding.root.isDisposed()
636
+ ? binding.root
637
+ : null;
193
638
  const historyAction = (json) => {
194
- if (json && state) {
639
+ if (json && state?.binding) {
195
640
  const current = state.binding;
196
- const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
197
- disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
198
- const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
199
- const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
200
- state.history.attach(next);
201
- next.subscribe(force);
202
- state.binding = next;
203
- setBinding(next);
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
+ }
204
718
  }
205
719
  force();
206
720
  };
207
- // Selection survives re-renders but falls back to root if the system was removed/replaced
208
- // (e.g. after undo/redo or import swaps the whole binding out from under it).
209
721
  const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
210
- const activeSystem = binding ? (selectedSystem && allSystems.includes(selectedSystem) ? selectedSystem : binding.system) : null;
211
- return (_jsxs("div", { 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: [_jsx("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("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) => {
722
+ const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
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) => {
212
841
  binding.apply(() => (binding.root.name = name));
213
842
  setRenamingRoot(false);
214
- }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo, onClick: () => historyAction(state.history.undo()), children: "\u21B6" }), _jsx("button", { className: "qe-hover", style: buttonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo, onClick: () => historyAction(state.history.redo()), children: "\u21B7" }), _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: () => {
215
844
  if (!binding)
216
845
  return;
217
846
  const blob = new Blob([binding.exportJSON('EditorEffect')], { type: 'application/json' });
@@ -220,21 +849,7 @@ export function EffectEditorHost(props) {
220
849
  link.download = 'effect.json';
221
850
  link.click();
222
851
  URL.revokeObjectURL(link.href);
223
- }, 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) => {
224
- const file = e.target.files?.[0];
225
- e.target.value = '';
226
- if (!file || !state)
227
- return;
228
- try {
229
- const json = JSON.parse(await file.text());
230
- historyAction(json);
231
- state.history.clear();
232
- force();
233
- }
234
- catch (err) {
235
- console.error('Failed to import effect JSON:', err);
236
- }
237
- } })] })] }), _jsx("div", { style: { flex: 1, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, 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, selectedSystem: activeSystem, onSelect: setSelectedSystem, 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) }))] }));
238
853
  }
239
854
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
240
855
  export const QuarksEffectEditor = {