babylon.quarks-editor 0.17.7 → 0.17.8
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/dist/core/timeline.d.ts +1 -1
- package/dist/core/timeline.js +3 -2
- package/dist/react/EffectEditor.d.ts +3 -0
- package/dist/react/EffectEditor.js +6 -1
- package/dist/react/EffectEditorHost.js +72 -11
- package/dist/react/ObjectModule.d.ts +6 -0
- package/dist/react/ObjectModule.js +28 -0
- package/dist/react/TimelinePanel.d.ts +4 -3
- package/dist/react/TimelinePanel.js +60 -10
- package/dist/react/TimelineTrackRow.d.ts +2 -2
- package/dist/react/TimelineTrackRow.js +16 -12
- package/dist/react/theme.js +44 -21
- package/package.json +68 -63
- package/src/core/binding.ts +105 -105
- package/src/core/collision.ts +40 -40
- package/src/core/groups.ts +29 -29
- package/src/core/loadEffect.ts +102 -102
- package/src/core/scrub.ts +28 -28
- package/src/core/shapes.ts +133 -133
- package/src/core/systems.ts +98 -98
- package/src/core/timeline.ts +95 -94
- package/src/core/values.ts +113 -113
- package/src/index.ts +32 -32
- package/src/react/CurveEditor.tsx +168 -168
- package/src/react/EffectEditor.tsx +125 -111
- package/src/react/EffectEditorHost.tsx +466 -377
- package/src/react/GradientEditor.tsx +127 -127
- package/src/react/ModuleSection.tsx +54 -54
- package/src/react/ObjectModule.tsx +47 -0
- package/src/react/PromptDialog.tsx +85 -85
- package/src/react/TimelinePanel.tsx +412 -354
- package/src/react/TimelineTrackRow.tsx +291 -267
- package/src/react/behaviorModules.tsx +658 -658
- package/src/react/fields.tsx +76 -76
- package/src/react/index.ts +43 -43
- package/src/react/modules.tsx +1034 -1034
- package/src/react/theme.ts +108 -85
|
@@ -1,377 +1,466 @@
|
|
|
1
|
-
import React, {useEffect, useReducer, useRef, useState} from 'react';
|
|
2
|
-
import {createRoot} from 'react-dom/client';
|
|
3
|
-
import {Engine} from '@babylonjs/core/Engines/engine';
|
|
4
|
-
import {Scene} from '@babylonjs/core/scene';
|
|
5
|
-
import {ArcRotateCamera} from '@babylonjs/core/Cameras/arcRotateCamera';
|
|
6
|
-
import {DirectionalLight} from '@babylonjs/core/Lights/directionalLight';
|
|
7
|
-
import {Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
|
|
8
|
-
import {Color3, Color4} from '@babylonjs/core/Maths/math.color';
|
|
9
|
-
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
|
|
10
|
-
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
system.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
renderer
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1
|
+
import React, {useEffect, useReducer, useRef, useState} from 'react';
|
|
2
|
+
import {createRoot} from 'react-dom/client';
|
|
3
|
+
import {Engine} from '@babylonjs/core/Engines/engine';
|
|
4
|
+
import {Scene} from '@babylonjs/core/scene';
|
|
5
|
+
import {ArcRotateCamera} from '@babylonjs/core/Cameras/arcRotateCamera';
|
|
6
|
+
import {DirectionalLight} from '@babylonjs/core/Lights/directionalLight';
|
|
7
|
+
import {Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
|
|
8
|
+
import {Color3, Color4} from '@babylonjs/core/Maths/math.color';
|
|
9
|
+
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
|
|
10
|
+
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
11
|
+
import {GizmoManager} from '@babylonjs/core/Gizmos/gizmoManager';
|
|
12
|
+
import {UtilityLayerRenderer} from '@babylonjs/core/Rendering/utilityLayerRenderer';
|
|
13
|
+
import {GridMaterial} from '@babylonjs/materials/grid/gridMaterial';
|
|
14
|
+
import {
|
|
15
|
+
BatchedRenderer,
|
|
16
|
+
ColorOverLife,
|
|
17
|
+
ConeEmitter,
|
|
18
|
+
ConstantColor,
|
|
19
|
+
ConstantValue,
|
|
20
|
+
IntervalValue,
|
|
21
|
+
ParticleSystem,
|
|
22
|
+
RenderMode,
|
|
23
|
+
Vector4,
|
|
24
|
+
} from 'babylon.quarks';
|
|
25
|
+
import {EffectBinding} from '../core/binding';
|
|
26
|
+
import {EffectHistory} from '../core/history';
|
|
27
|
+
import {ensureGroundResolver} from '../core/collision';
|
|
28
|
+
import {loadEffectFromJson} from '../core/loadEffect';
|
|
29
|
+
import {DEFAULT_GRADIENT_STOPS, buildGradient} from '../core/colors';
|
|
30
|
+
import {EffectEditor} from './EffectEditor';
|
|
31
|
+
import {PromptDialog} from './PromptDialog';
|
|
32
|
+
import {TimelinePanel} from './TimelinePanel';
|
|
33
|
+
import type {PlaybackState} from './TimelinePanel';
|
|
34
|
+
import type {GeometryData, GeometryOption, TextureOption} from './modules';
|
|
35
|
+
import {buttonStyle, globalEditorStyles, theme} from './theme';
|
|
36
|
+
|
|
37
|
+
export interface EffectEditorHostHandle {
|
|
38
|
+
binding: EffectBinding;
|
|
39
|
+
history: EffectHistory;
|
|
40
|
+
scene: Scene;
|
|
41
|
+
renderer: BatchedRenderer;
|
|
42
|
+
importJson: (json: unknown) => void;
|
|
43
|
+
exportJson: () => string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EffectEditorHostProps {
|
|
47
|
+
/** Quarks JSON export to open; a default effect is created when omitted. */
|
|
48
|
+
effectJson?: unknown;
|
|
49
|
+
/** When set, a Save button appears and receives the serialized effect. */
|
|
50
|
+
onSave?: (json: string) => void;
|
|
51
|
+
/** Called once the engine/scene/binding are live (and after every import). */
|
|
52
|
+
onReady?: (handle: EffectEditorHostHandle) => void;
|
|
53
|
+
title?: string;
|
|
54
|
+
textureOptions?: TextureOption[];
|
|
55
|
+
/** Mesh presets for the Mesh render mode (e.g. asset meshes from a host editor). */
|
|
56
|
+
geometryOptions?: GeometryOption[];
|
|
57
|
+
resolveTexture?: (url: string, scene: Scene) => unknown;
|
|
58
|
+
/** Host loader for the Mesh "Load from file…" option (e.g. GLB via @babylonjs/loaders). */
|
|
59
|
+
resolveGeometry?: (file: File, scene: Scene) => Promise<GeometryData>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createDefaultEffect(scene: Scene, resolveTexture?: (url: string, scene: Scene) => unknown, textureUrl?: string): ParticleSystem {
|
|
63
|
+
const system = new ParticleSystem({
|
|
64
|
+
scene,
|
|
65
|
+
duration: 4,
|
|
66
|
+
looping: true,
|
|
67
|
+
startLife: new IntervalValue(1.2, 1.8),
|
|
68
|
+
startSpeed: new IntervalValue(3, 5),
|
|
69
|
+
startSize: new IntervalValue(0.25, 0.5),
|
|
70
|
+
startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
|
|
71
|
+
emissionOverTime: new ConstantValue(80),
|
|
72
|
+
// quarks' angle is a half-angle; Babylon's native cone uses a full angle.
|
|
73
|
+
shape: new ConeEmitter({radius: 0.3, angle: 0.25}),
|
|
74
|
+
renderMode: RenderMode.BillBoard,
|
|
75
|
+
texture: (textureUrl && resolveTexture ? resolveTexture(textureUrl, scene) : undefined) as never,
|
|
76
|
+
transparent: true,
|
|
77
|
+
blendMode: 1,
|
|
78
|
+
});
|
|
79
|
+
system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
|
|
80
|
+
// quarks emits along local +Z; Babylon's native cone emits along +Y.
|
|
81
|
+
system.emitter.rotation.x = -Math.PI / 2;
|
|
82
|
+
return system;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Disposes an effect's systems and its remaining group tree (otherwise the groups leak). */
|
|
86
|
+
function disposeEffect(root: TransformNode, systems: ParticleSystem[], renderer: BatchedRenderer): void {
|
|
87
|
+
for (const system of systems) {
|
|
88
|
+
renderer.deleteSystem(system);
|
|
89
|
+
system.dispose();
|
|
90
|
+
}
|
|
91
|
+
// Disposing the systems already disposed their emitter nodes; dispose the leftover group
|
|
92
|
+
// tree too. Skip when the root IS a disposed emitter (default single-system effect).
|
|
93
|
+
if (!systems.some((system) => system.emitter === root)) {
|
|
94
|
+
root.dispose(false, true);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type GizmoMode = 'none' | 'position' | 'rotation' | 'scale';
|
|
99
|
+
|
|
100
|
+
const GIZMO_MODE_BUTTONS: Array<{value: GizmoMode; icon: string; title: string}> = [
|
|
101
|
+
{value: 'none', icon: '↖', title: 'Select (hide stage gizmo)'},
|
|
102
|
+
{value: 'position', icon: 'M', title: 'Move the stage node (position)'},
|
|
103
|
+
{value: 'rotation', icon: 'R', title: 'Rotate the stage node'},
|
|
104
|
+
{value: 'scale', icon: 'S', title: 'Scale the stage node'},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const squareButtonStyle: React.CSSProperties = {
|
|
108
|
+
...buttonStyle,
|
|
109
|
+
width: 28,
|
|
110
|
+
height: 28,
|
|
111
|
+
padding: 0,
|
|
112
|
+
display: 'flex',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
justifyContent: 'center',
|
|
115
|
+
fontWeight: 700,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const activeSquareButtonStyle: React.CSSProperties = {
|
|
119
|
+
border: `1px solid ${theme.borderActive}`,
|
|
120
|
+
background: 'rgba(120, 165, 255, 0.35)',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
|
|
125
|
+
* Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
|
|
126
|
+
*/
|
|
127
|
+
export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
128
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
129
|
+
const counterRef = useRef<HTMLSpanElement>(null);
|
|
130
|
+
const playheadRef = useRef<HTMLDivElement>(null);
|
|
131
|
+
const importRef = useRef<HTMLInputElement>(null);
|
|
132
|
+
const stateRef = useRef<{engine: Engine; scene: Scene; renderer: BatchedRenderer; binding: EffectBinding; history: EffectHistory; stageRoot: TransformNode} | null>(null);
|
|
133
|
+
const gizmoManagerRef = useRef<GizmoManager | null>(null);
|
|
134
|
+
const [binding, setBinding] = useState<EffectBinding | null>(null);
|
|
135
|
+
const [, force] = useReducer((x: number) => x + 1, 0);
|
|
136
|
+
const playbackRef = useRef<PlaybackState>({paused: false, speed: 1, stepQueued: false, elapsed: 0, scrubbing: false});
|
|
137
|
+
const [paused, setPaused] = useState(false);
|
|
138
|
+
const [speed, setSpeed] = useState(1);
|
|
139
|
+
const [renamingRoot, setRenamingRoot] = useState(false);
|
|
140
|
+
const [gizmoMode, setGizmoMode] = useState<GizmoMode>('none');
|
|
141
|
+
const [selectedNode, setSelectedNode] = useState<TransformNode | null>(null);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
// Register the shared ground-plane collider before any effect (default or imported)
|
|
145
|
+
// is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
|
|
146
|
+
ensureGroundResolver();
|
|
147
|
+
const engine = new Engine(canvasRef.current!, true);
|
|
148
|
+
const scene = new Scene(engine);
|
|
149
|
+
scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
|
|
150
|
+
const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new BVector3(0, 1.4, 0), scene);
|
|
151
|
+
camera.attachControl(canvasRef.current!, true);
|
|
152
|
+
camera.wheelDeltaPercentage = 0.01;
|
|
153
|
+
|
|
154
|
+
// Mesh render mode particles are lit (billboard modes ignore these), and the grid gives
|
|
155
|
+
// a visual floor at the same y=0 plane the Collision module's ground resolver uses.
|
|
156
|
+
scene.ambientColor = new Color3(1, 1, 1);
|
|
157
|
+
const sunLight = new DirectionalLight('sun', new BVector3(-1, -1, -1), scene);
|
|
158
|
+
sunLight.intensity = 1.0;
|
|
159
|
+
sunLight.diffuse = new Color3(1, 1, 1);
|
|
160
|
+
sunLight.specular = new Color3(1, 1, 1);
|
|
161
|
+
|
|
162
|
+
const groundMaterial = new GridMaterial('groundMaterial', scene);
|
|
163
|
+
groundMaterial.majorUnitFrequency = 2;
|
|
164
|
+
groundMaterial.minorUnitVisibility = 0.1;
|
|
165
|
+
groundMaterial.gridRatio = 0.5;
|
|
166
|
+
groundMaterial.backFaceCulling = false;
|
|
167
|
+
groundMaterial.mainColor = new Color3(1, 1, 1);
|
|
168
|
+
groundMaterial.lineColor = new Color3(1, 1, 1);
|
|
169
|
+
groundMaterial.opacity = 0.5;
|
|
170
|
+
const ground = MeshBuilder.CreateGround('ground', {width: 100, height: 100}, scene);
|
|
171
|
+
ground.material = groundMaterial;
|
|
172
|
+
|
|
173
|
+
const stageRoot = new TransformNode('StageRoot', scene);
|
|
174
|
+
const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
|
|
175
|
+
const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
|
|
176
|
+
gizmoManager.usePointerToAttachGizmos = false;
|
|
177
|
+
gizmoManager.attachToNode(stageRoot);
|
|
178
|
+
gizmoManagerRef.current = gizmoManager;
|
|
179
|
+
|
|
180
|
+
const renderer = new BatchedRenderer('quarks-editor', scene);
|
|
181
|
+
const history = new EffectHistory();
|
|
182
|
+
|
|
183
|
+
const mount = (nextBinding: EffectBinding) => {
|
|
184
|
+
nextBinding.root.parent = stageRoot;
|
|
185
|
+
history.attach(nextBinding);
|
|
186
|
+
nextBinding.subscribe(force);
|
|
187
|
+
stateRef.current = {engine, scene, renderer, binding: nextBinding, history, stageRoot};
|
|
188
|
+
setBinding(nextBinding);
|
|
189
|
+
setSelectedNode(nextBinding.system.emitter);
|
|
190
|
+
props.onReady?.(makeHandle());
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const makeHandle = (): EffectEditorHostHandle => ({
|
|
194
|
+
// Live getter: stays correct after presets/imports/undo replace the binding.
|
|
195
|
+
get binding() {
|
|
196
|
+
return stateRef.current!.binding;
|
|
197
|
+
},
|
|
198
|
+
history,
|
|
199
|
+
scene,
|
|
200
|
+
renderer,
|
|
201
|
+
importJson,
|
|
202
|
+
exportJson: () => stateRef.current!.binding.exportJSON('EditorEffect'),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const importJson = (json: unknown) => {
|
|
206
|
+
const current = stateRef.current!.binding;
|
|
207
|
+
// Load first so a bad/empty JSON leaves the current effect intact.
|
|
208
|
+
const {root, systems} = loadEffectFromJson(scene, renderer, json);
|
|
209
|
+
if (systems.length === 0) {
|
|
210
|
+
disposeEffect(root, systems, renderer);
|
|
211
|
+
throw new Error('No particle systems found in the JSON.');
|
|
212
|
+
}
|
|
213
|
+
disposeEffect(current.root, [current.system, ...current.subSystems], renderer);
|
|
214
|
+
const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
|
|
215
|
+
mount(new EffectBinding(main, systems.filter((s) => s !== main), root));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let initial: EffectBinding;
|
|
219
|
+
if (props.effectJson) {
|
|
220
|
+
const {root, systems} = loadEffectFromJson(scene, renderer, props.effectJson);
|
|
221
|
+
const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
|
|
222
|
+
initial = new EffectBinding(main, systems.filter((s) => s !== main), root);
|
|
223
|
+
} else {
|
|
224
|
+
const system = createDefaultEffect(scene, props.resolveTexture, props.textureOptions?.[0]?.url);
|
|
225
|
+
renderer.addSystem(system);
|
|
226
|
+
// Root is a plain group (not the system's own emitter) so a new top-level system
|
|
227
|
+
// can be added as a sibling later, instead of always nesting under this one.
|
|
228
|
+
const root = new TransformNode('Effect', scene);
|
|
229
|
+
system.emitter.parent = root;
|
|
230
|
+
initial = new EffectBinding(system, [], root);
|
|
231
|
+
}
|
|
232
|
+
mount(initial);
|
|
233
|
+
|
|
234
|
+
engine.runRenderLoop(() => {
|
|
235
|
+
const playback = playbackRef.current;
|
|
236
|
+
if (!playback.scrubbing) {
|
|
237
|
+
let delta = (engine.getDeltaTime() / 1000) * playback.speed;
|
|
238
|
+
if (playback.paused) {
|
|
239
|
+
delta = playback.stepQueued ? 1 / 60 : 0;
|
|
240
|
+
playback.stepQueued = false;
|
|
241
|
+
}
|
|
242
|
+
if (delta > 0) {
|
|
243
|
+
renderer.update(delta);
|
|
244
|
+
playback.elapsed += delta;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
scene.render();
|
|
248
|
+
const b = stateRef.current!.binding;
|
|
249
|
+
let count = b.system.particleNum;
|
|
250
|
+
for (const sub of b.subSystems) {
|
|
251
|
+
count += sub.particleNum;
|
|
252
|
+
}
|
|
253
|
+
if (counterRef.current) {
|
|
254
|
+
counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const resizeObserver = new ResizeObserver(() => engine.resize());
|
|
259
|
+
resizeObserver.observe(canvasRef.current!);
|
|
260
|
+
const onKey = (e: KeyboardEvent) => {
|
|
261
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
const json = e.shiftKey ? history.redo() : history.undo();
|
|
264
|
+
if (json) {
|
|
265
|
+
importJson(json);
|
|
266
|
+
}
|
|
267
|
+
force();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
window.addEventListener('keydown', onKey);
|
|
271
|
+
return () => {
|
|
272
|
+
resizeObserver.disconnect();
|
|
273
|
+
window.removeEventListener('keydown', onKey);
|
|
274
|
+
gizmoManager.dispose();
|
|
275
|
+
gizmoUtilityLayer.dispose();
|
|
276
|
+
engine.dispose();
|
|
277
|
+
};
|
|
278
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const gm = gizmoManagerRef.current;
|
|
283
|
+
if (!gm) return;
|
|
284
|
+
gm.positionGizmoEnabled = gizmoMode === 'position';
|
|
285
|
+
gm.rotationGizmoEnabled = gizmoMode === 'rotation';
|
|
286
|
+
gm.scaleGizmoEnabled = gizmoMode === 'scale';
|
|
287
|
+
const activeGizmo = gizmoMode === 'position' ? gm.gizmos.positionGizmo : gizmoMode === 'rotation' ? gm.gizmos.rotationGizmo : gizmoMode === 'scale' ? gm.gizmos.scaleGizmo : null;
|
|
288
|
+
const observer = activeGizmo?.onDragObservable.add(() => force());
|
|
289
|
+
return () => {
|
|
290
|
+
if (activeGizmo && observer) activeGizmo.onDragObservable.remove(observer);
|
|
291
|
+
};
|
|
292
|
+
}, [gizmoMode]);
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const gm = gizmoManagerRef.current;
|
|
296
|
+
const stageRoot = stateRef.current?.stageRoot;
|
|
297
|
+
if (!gm || !stageRoot) return;
|
|
298
|
+
if (selectedNode && selectedNode.isDisposed()) {
|
|
299
|
+
setSelectedNode(stateRef.current?.binding.system.emitter ?? null);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
gm.attachToNode(selectedNode ?? stageRoot);
|
|
303
|
+
}, [selectedNode, binding]);
|
|
304
|
+
|
|
305
|
+
const state = stateRef.current;
|
|
306
|
+
const gizmoNode = selectedNode && !selectedNode.isDisposed() ? selectedNode : state?.stageRoot;
|
|
307
|
+
const historyAction = (json: unknown | null) => {
|
|
308
|
+
if (json && state) {
|
|
309
|
+
const current = state.binding;
|
|
310
|
+
const {root, systems} = loadEffectFromJson(state.scene, state.renderer, json);
|
|
311
|
+
disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
|
|
312
|
+
const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
|
|
313
|
+
const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
|
|
314
|
+
next.root.parent = state.stageRoot;
|
|
315
|
+
state.history.attach(next);
|
|
316
|
+
next.subscribe(force);
|
|
317
|
+
state.binding = next;
|
|
318
|
+
setBinding(next);
|
|
319
|
+
setSelectedNode(next.system.emitter);
|
|
320
|
+
}
|
|
321
|
+
force();
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
|
|
325
|
+
const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
|
|
326
|
+
const gizmoTargetLabel = selectedNode ? gizmoNode?.name || 'Node' : 'Stage (preview parent)';
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className="qe-root" style={{display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16'}}>
|
|
330
|
+
<style>{globalEditorStyles}</style>
|
|
331
|
+
<div style={{display: 'flex', flex: 1, minHeight: 0}}>
|
|
332
|
+
<div style={{position: 'relative', flex: 1, minWidth: 0}}>
|
|
333
|
+
<canvas ref={canvasRef} style={{width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none'}} />
|
|
334
|
+
<div style={{position: 'absolute', top: 12, right: 12, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6}}>
|
|
335
|
+
<div style={{display: 'flex', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4}}>
|
|
336
|
+
<button className="qe-hover" style={squareButtonStyle} title="Undo (Ctrl+Z)" disabled={!state?.history.canUndo} onClick={() => historyAction(state!.history.undo())}>
|
|
337
|
+
↶
|
|
338
|
+
</button>
|
|
339
|
+
<button className="qe-hover" style={squareButtonStyle} title="Redo (Ctrl+Shift+Z)" disabled={!state?.history.canRedo} onClick={() => historyAction(state!.history.redo())}>
|
|
340
|
+
↷
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 4, background: theme.panelBg, border: `1px solid ${theme.border}`, borderRadius: 8, padding: 4}}>
|
|
344
|
+
{GIZMO_MODE_BUTTONS.map((m) => (
|
|
345
|
+
<button
|
|
346
|
+
key={m.value}
|
|
347
|
+
className="qe-hover"
|
|
348
|
+
style={{...squareButtonStyle, ...(gizmoMode === m.value ? activeSquareButtonStyle : {})}}
|
|
349
|
+
title={m.title}
|
|
350
|
+
onClick={() => setGizmoMode(m.value)}
|
|
351
|
+
>
|
|
352
|
+
{m.icon}
|
|
353
|
+
</button>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
<aside style={{width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column'}}>
|
|
359
|
+
<div style={{padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}`}}>
|
|
360
|
+
<div
|
|
361
|
+
className="qe-hover-bg"
|
|
362
|
+
title={binding ? 'Rename effect' : undefined}
|
|
363
|
+
style={{fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px'}}
|
|
364
|
+
onClick={() => binding && setRenamingRoot(true)}
|
|
365
|
+
>
|
|
366
|
+
{(binding?.root.name || props.title) ?? 'Effect editor'}
|
|
367
|
+
</div>
|
|
368
|
+
{renamingRoot && binding && (
|
|
369
|
+
<PromptDialog
|
|
370
|
+
title="Rename effect"
|
|
371
|
+
defaultValue={binding.root.name}
|
|
372
|
+
onSubmit={(name) => {
|
|
373
|
+
binding.apply(() => (binding.root.name = name));
|
|
374
|
+
setRenamingRoot(false);
|
|
375
|
+
}}
|
|
376
|
+
onCancel={() => setRenamingRoot(false)}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
<div style={{display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10}}>
|
|
380
|
+
<button
|
|
381
|
+
className="qe-hover"
|
|
382
|
+
style={buttonStyle}
|
|
383
|
+
onClick={() => {
|
|
384
|
+
if (!binding) return;
|
|
385
|
+
const blob = new Blob([binding.exportJSON('EditorEffect')], {type: 'application/json'});
|
|
386
|
+
const link = document.createElement('a');
|
|
387
|
+
link.href = URL.createObjectURL(blob);
|
|
388
|
+
link.download = 'effect.json';
|
|
389
|
+
link.click();
|
|
390
|
+
URL.revokeObjectURL(link.href);
|
|
391
|
+
}}
|
|
392
|
+
>
|
|
393
|
+
Export
|
|
394
|
+
</button>
|
|
395
|
+
<button className="qe-hover" style={buttonStyle} onClick={() => importRef.current?.click()}>Import</button>
|
|
396
|
+
{props.onSave && (
|
|
397
|
+
<button className="qe-hover" style={buttonStyle} onClick={() => binding && props.onSave!(binding.exportJSON('EditorEffect'))}>
|
|
398
|
+
Save
|
|
399
|
+
</button>
|
|
400
|
+
)}
|
|
401
|
+
<input
|
|
402
|
+
ref={importRef}
|
|
403
|
+
type="file"
|
|
404
|
+
accept=".json,application/json"
|
|
405
|
+
hidden
|
|
406
|
+
onChange={async (e) => {
|
|
407
|
+
const file = e.target.files?.[0];
|
|
408
|
+
e.target.value = '';
|
|
409
|
+
if (!file || !state) return;
|
|
410
|
+
try {
|
|
411
|
+
const json = JSON.parse(await file.text());
|
|
412
|
+
historyAction(json);
|
|
413
|
+
state.history.clear();
|
|
414
|
+
force();
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error('Failed to import effect JSON:', err);
|
|
417
|
+
}
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
<div style={{flex: 1, overflowY: 'auto', padding: '6px 14px 20px'}}>
|
|
423
|
+
{binding && activeSystem && gizmoNode && (
|
|
424
|
+
<EffectEditor
|
|
425
|
+
binding={binding}
|
|
426
|
+
selectedSystem={activeSystem}
|
|
427
|
+
gizmoTargetNode={gizmoNode}
|
|
428
|
+
gizmoTargetLabel={gizmoTargetLabel}
|
|
429
|
+
textureOptions={props.textureOptions}
|
|
430
|
+
geometryOptions={props.geometryOptions}
|
|
431
|
+
resolveTexture={props.resolveTexture && state ? (url) => props.resolveTexture!(url, state.scene) : undefined}
|
|
432
|
+
resolveGeometry={props.resolveGeometry && state ? (file) => props.resolveGeometry!(file, state.scene) : undefined}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
</aside>
|
|
437
|
+
</div>
|
|
438
|
+
{binding && activeSystem && state && (
|
|
439
|
+
<TimelinePanel
|
|
440
|
+
binding={binding}
|
|
441
|
+
selectedNode={selectedNode}
|
|
442
|
+
onSelectNode={setSelectedNode}
|
|
443
|
+
scene={state.scene}
|
|
444
|
+
renderer={state.renderer}
|
|
445
|
+
playbackRef={playbackRef}
|
|
446
|
+
playheadRef={playheadRef}
|
|
447
|
+
counterRef={counterRef}
|
|
448
|
+
paused={paused}
|
|
449
|
+
setPaused={setPaused}
|
|
450
|
+
speed={speed}
|
|
451
|
+
setSpeed={setSpeed}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
|
|
459
|
+
export const QuarksEffectEditor = {
|
|
460
|
+
Show(options: EffectEditorHostProps & {hostElement: HTMLElement}): {dispose: () => void} {
|
|
461
|
+
const {hostElement, ...props} = options;
|
|
462
|
+
const root = createRoot(hostElement);
|
|
463
|
+
root.render(<EffectEditorHost {...props} />);
|
|
464
|
+
return {dispose: () => root.unmount()};
|
|
465
|
+
},
|
|
466
|
+
};
|