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.
- 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 +8 -4
- 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.d.ts +3 -0
- package/dist/react/EffectEditor.js +3 -1
- package/dist/react/EffectEditorHost.d.ts +7 -0
- package/dist/react/EffectEditorHost.js +717 -102
- 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.d.ts +6 -0
- package/dist/react/ObjectModule.js +40 -0
- package/dist/react/TimelinePanel.d.ts +11 -15
- package/dist/react/TimelinePanel.js +129 -65
- package/dist/react/TimelineTrackRow.d.ts +2 -2
- package/dist/react/TimelineTrackRow.js +41 -23
- 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 +97 -22
- package/package.json +72 -63
- package/src/core/binding.ts +102 -105
- package/src/core/collision.ts +40 -40
- 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/groups.ts +29 -29
- package/src/core/loadEffect.ts +139 -102
- 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 +60 -28
- package/src/core/shapes.ts +133 -133
- package/src/core/systems.ts +98 -98
- package/src/core/timeline.ts +98 -94
- package/src/core/transformStore.ts +19 -0
- package/src/core/values.ts +113 -113
- package/src/core/viewportPicker.ts +114 -0
- package/src/index.ts +34 -32
- package/src/react/CurveEditor.tsx +168 -168
- package/src/react/EffectEditor.tsx +123 -111
- package/src/react/EffectEditorHost.tsx +1162 -377
- package/src/react/GalleryPanel.tsx +453 -0
- package/src/react/GradientEditor.tsx +127 -127
- 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 +69 -54
- package/src/react/ObjectModule.tsx +58 -0
- package/src/react/PromptDialog.tsx +85 -85
- package/src/react/TimelinePanel.tsx +448 -354
- package/src/react/TimelineTrackRow.tsx +326 -267
- package/src/react/Tooltip.tsx +93 -0
- package/src/react/ValueField.tsx +3 -1
- package/src/react/behaviorModules.tsx +663 -658
- package/src/react/fields.tsx +85 -76
- package/src/react/icons.ts +19 -0
- package/src/react/index.ts +47 -43
- package/src/react/inspectorHints.ts +150 -0
- package/src/react/modules.tsx +1049 -1034
- 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,
|
|
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 {
|
|
18
|
-
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';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
70
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
+
importGallery: appendGalleryFromFiles,
|
|
382
|
+
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
120
383
|
});
|
|
121
384
|
const importJson = (json) => {
|
|
122
|
-
const current = stateRef.current
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
413
|
+
let initialEntry;
|
|
134
414
|
if (props.effectJson) {
|
|
135
415
|
const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
renderer.
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
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
|
+
}
|
|
160
456
|
}
|
|
161
457
|
}
|
|
162
458
|
scene.render();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 ? (
|
|
211
|
-
|
|
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,
|
|
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: () =>
|
|
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 = {
|