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.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -1
- package/dist/core/binding.js +2 -5
- package/dist/core/defaultEffect.d.ts +12 -0
- package/dist/core/defaultEffect.js +43 -0
- package/dist/core/editorScene.d.ts +7 -0
- package/dist/core/editorScene.js +11 -0
- package/dist/core/effectTree.js +2 -1
- package/dist/core/emitterShapeWireframe.d.ts +20 -0
- package/dist/core/emitterShapeWireframe.js +329 -0
- package/dist/core/galleryDragSession.d.ts +4 -0
- package/dist/core/galleryDragSession.js +8 -0
- package/dist/core/galleryDropMarker.d.ts +13 -0
- package/dist/core/galleryDropMarker.js +42 -0
- package/dist/core/galleryLayout.d.ts +20 -0
- package/dist/core/galleryLayout.js +68 -0
- package/dist/core/galleryPlayback.d.ts +9 -0
- package/dist/core/galleryPlayback.js +43 -0
- package/dist/core/galleryScene.d.ts +12 -0
- package/dist/core/galleryScene.js +41 -0
- package/dist/core/gallerySelectionMarker.d.ts +25 -0
- package/dist/core/gallerySelectionMarker.js +69 -0
- package/dist/core/galleryTree.d.ts +49 -0
- package/dist/core/galleryTree.js +138 -0
- package/dist/core/loadEffect.d.ts +13 -0
- package/dist/core/loadEffect.js +31 -17
- package/dist/core/loadEffectGallery.d.ts +36 -0
- package/dist/core/loadEffectGallery.js +97 -0
- package/dist/core/material.d.ts +13 -0
- package/dist/core/material.js +68 -0
- package/dist/core/pickGalleryFiles.d.ts +5 -0
- package/dist/core/pickGalleryFiles.js +42 -0
- package/dist/core/playback.d.ts +23 -0
- package/dist/core/playback.js +32 -0
- package/dist/core/scrub.d.ts +5 -0
- package/dist/core/scrub.js +30 -0
- package/dist/core/timeline.d.ts +3 -1
- package/dist/core/timeline.js +6 -3
- package/dist/core/transformStore.d.ts +3 -0
- package/dist/core/transformStore.js +16 -0
- package/dist/core/viewportPicker.d.ts +25 -0
- package/dist/core/viewportPicker.js +86 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/react/EffectEditor.js +1 -4
- package/dist/react/EffectEditorHost.d.ts +7 -0
- package/dist/react/EffectEditorHost.js +669 -115
- package/dist/react/GalleryPanel.d.ts +19 -0
- package/dist/react/GalleryPanel.js +215 -0
- package/dist/react/InspectorInfoIcon.d.ts +7 -0
- package/dist/react/InspectorInfoIcon.js +22 -0
- package/dist/react/InspectorLabel.d.ts +7 -0
- package/dist/react/InspectorLabel.js +12 -0
- package/dist/react/MessageDialog.d.ts +9 -0
- package/dist/react/MessageDialog.js +24 -0
- package/dist/react/ModuleSection.d.ts +2 -0
- package/dist/react/ModuleSection.js +8 -1
- package/dist/react/ObjectModule.js +14 -2
- package/dist/react/TimelinePanel.d.ts +8 -13
- package/dist/react/TimelinePanel.js +113 -99
- package/dist/react/TimelineTrackRow.js +29 -15
- package/dist/react/Tooltip.d.ts +6 -0
- package/dist/react/Tooltip.js +65 -0
- package/dist/react/ValueField.d.ts +2 -0
- package/dist/react/ValueField.js +1 -1
- package/dist/react/behaviorModules.js +8 -8
- package/dist/react/fields.d.ts +2 -0
- package/dist/react/fields.js +14 -5
- package/dist/react/icons.d.ts +7 -0
- package/dist/react/icons.js +10 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/inspectorHints.d.ts +2 -0
- package/dist/react/inspectorHints.js +138 -0
- package/dist/react/modules.js +109 -81
- package/dist/react/theme.d.ts +2 -0
- package/dist/react/theme.js +53 -1
- package/package.json +8 -4
- package/src/core/binding.ts +2 -5
- package/src/core/defaultEffect.ts +74 -0
- package/src/core/editorScene.ts +14 -0
- package/src/core/effectTree.ts +2 -1
- package/src/core/emitterShapeWireframe.ts +373 -0
- package/src/core/galleryDragSession.ts +12 -0
- package/src/core/galleryDropMarker.ts +57 -0
- package/src/core/galleryLayout.ts +90 -0
- package/src/core/galleryPlayback.ts +55 -0
- package/src/core/galleryScene.ts +58 -0
- package/src/core/gallerySelectionMarker.ts +84 -0
- package/src/core/galleryTree.ts +187 -0
- package/src/core/loadEffect.ts +55 -18
- package/src/core/loadEffectGallery.ts +146 -0
- package/src/core/material.ts +84 -0
- package/src/core/pickGalleryFiles.ts +58 -0
- package/src/core/playback.ts +49 -0
- package/src/core/scrub.ts +33 -1
- package/src/core/timeline.ts +6 -3
- package/src/core/transformStore.ts +19 -0
- package/src/core/viewportPicker.ts +114 -0
- package/src/index.ts +2 -0
- package/src/react/EffectEditor.tsx +5 -7
- package/src/react/EffectEditorHost.tsx +839 -143
- package/src/react/GalleryPanel.tsx +453 -0
- package/src/react/InspectorInfoIcon.tsx +36 -0
- package/src/react/InspectorLabel.tsx +18 -0
- package/src/react/MessageDialog.tsx +57 -0
- package/src/react/ModuleSection.tsx +17 -2
- package/src/react/ObjectModule.tsx +17 -6
- package/src/react/TimelinePanel.tsx +213 -177
- package/src/react/TimelineTrackRow.tsx +58 -23
- package/src/react/Tooltip.tsx +93 -0
- package/src/react/ValueField.tsx +3 -1
- package/src/react/behaviorModules.tsx +13 -8
- package/src/react/fields.tsx +15 -6
- package/src/react/icons.ts +19 -0
- package/src/react/index.ts +4 -0
- package/src/react/inspectorHints.ts +150 -0
- package/src/react/modules.tsx +198 -183
- 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,
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
61
|
-
{ value: 'position',
|
|
62
|
-
{ value: 'rotation',
|
|
63
|
-
{ value: 'scale',
|
|
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
|
|
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(
|
|
93
|
-
const
|
|
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 =
|
|
327
|
+
nextBinding.root.parent = null;
|
|
135
328
|
history.attach(nextBinding);
|
|
136
329
|
nextBinding.subscribe(force);
|
|
137
|
-
|
|
330
|
+
syncStateRef(nextBinding);
|
|
138
331
|
setBinding(nextBinding);
|
|
139
|
-
setSelectedNode(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);
|
|
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
|
-
|
|
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
|
-
|
|
381
|
+
importGallery: appendGalleryFromFiles,
|
|
382
|
+
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
152
383
|
});
|
|
153
384
|
const importJson = (json) => {
|
|
154
|
-
const current = stateRef.current
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
413
|
+
let initialEntry;
|
|
166
414
|
if (props.effectJson) {
|
|
167
415
|
const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
renderer.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
playback.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 (
|
|
201
|
-
|
|
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
|
-
|
|
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 (
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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()
|
|
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
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 =
|
|
272
|
-
|
|
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: () =>
|
|
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 = {
|