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