babylon.quarks-editor 0.17.7
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/core/binding.d.ts +43 -0
- package/dist/core/binding.js +82 -0
- package/dist/core/collision.d.ts +20 -0
- package/dist/core/collision.js +39 -0
- package/dist/core/colors.d.ts +31 -0
- package/dist/core/colors.js +60 -0
- package/dist/core/effectTree.d.ts +25 -0
- package/dist/core/effectTree.js +115 -0
- package/dist/core/groups.d.ts +12 -0
- package/dist/core/groups.js +27 -0
- package/dist/core/history.d.ts +25 -0
- package/dist/core/history.js +69 -0
- package/dist/core/loadEffect.d.ts +8 -0
- package/dist/core/loadEffect.js +87 -0
- package/dist/core/scrub.d.ts +11 -0
- package/dist/core/scrub.js +24 -0
- package/dist/core/shapes.d.ts +31 -0
- package/dist/core/shapes.js +98 -0
- package/dist/core/systems.d.ts +25 -0
- package/dist/core/systems.js +76 -0
- package/dist/core/textureExport.d.ts +9 -0
- package/dist/core/textureExport.js +58 -0
- package/dist/core/timeline.d.ts +35 -0
- package/dist/core/timeline.js +69 -0
- package/dist/core/values.d.ts +31 -0
- package/dist/core/values.js +81 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +9 -0
- package/dist/react/CurveEditor.d.ts +12 -0
- package/dist/react/CurveEditor.js +106 -0
- package/dist/react/EffectEditor.d.ts +22 -0
- package/dist/react/EffectEditor.js +30 -0
- package/dist/react/EffectEditorHost.d.ts +42 -0
- package/dist/react/EffectEditorHost.js +247 -0
- package/dist/react/GradientEditor.d.ts +7 -0
- package/dist/react/GradientEditor.js +72 -0
- package/dist/react/ModuleSection.d.ts +9 -0
- package/dist/react/ModuleSection.js +24 -0
- package/dist/react/PromptDialog.d.ts +14 -0
- package/dist/react/PromptDialog.js +45 -0
- package/dist/react/SubEmittersModule.d.ts +10 -0
- package/dist/react/SubEmittersModule.js +39 -0
- package/dist/react/TimelinePanel.d.ts +32 -0
- package/dist/react/TimelinePanel.js +225 -0
- package/dist/react/TimelineTrackRow.d.ts +23 -0
- package/dist/react/TimelineTrackRow.js +107 -0
- package/dist/react/ValueField.d.ts +13 -0
- package/dist/react/ValueField.js +19 -0
- package/dist/react/behaviorModules.d.ts +23 -0
- package/dist/react/behaviorModules.js +139 -0
- package/dist/react/fields.d.ts +24 -0
- package/dist/react/fields.js +30 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.js +14 -0
- package/dist/react/modules.d.ts +35 -0
- package/dist/react/modules.js +439 -0
- package/dist/react/theme.d.ts +26 -0
- package/dist/react/theme.js +78 -0
- package/package.json +63 -0
- package/src/core/binding.ts +105 -0
- package/src/core/collision.ts +40 -0
- package/src/core/colors.ts +84 -0
- package/src/core/effectTree.ts +143 -0
- package/src/core/groups.ts +29 -0
- package/src/core/history.ts +77 -0
- package/src/core/loadEffect.ts +102 -0
- package/src/core/scrub.ts +28 -0
- package/src/core/shapes.ts +133 -0
- package/src/core/systems.ts +98 -0
- package/src/core/textureExport.ts +63 -0
- package/src/core/timeline.ts +94 -0
- package/src/core/values.ts +113 -0
- package/src/index.ts +32 -0
- package/src/react/CurveEditor.tsx +168 -0
- package/src/react/EffectEditor.tsx +111 -0
- package/src/react/EffectEditorHost.tsx +377 -0
- package/src/react/GradientEditor.tsx +127 -0
- package/src/react/ModuleSection.tsx +54 -0
- package/src/react/PromptDialog.tsx +85 -0
- package/src/react/SubEmittersModule.tsx +80 -0
- package/src/react/TimelinePanel.tsx +354 -0
- package/src/react/TimelineTrackRow.tsx +267 -0
- package/src/react/ValueField.tsx +60 -0
- package/src/react/behaviorModules.tsx +658 -0
- package/src/react/fields.tsx +76 -0
- package/src/react/index.ts +43 -0
- package/src/react/modules.tsx +1034 -0
- package/src/react/theme.ts +85 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React, {useRef, useState} from 'react';
|
|
2
|
+
import {CurvePiece, mergePieces, sampleCurve, splitPieces} from '../core/values';
|
|
3
|
+
import {inputStyle, theme} from './theme';
|
|
4
|
+
|
|
5
|
+
const WIDTH = 218;
|
|
6
|
+
const HEIGHT = 84;
|
|
7
|
+
const PAD = 8;
|
|
8
|
+
|
|
9
|
+
interface PointRef {
|
|
10
|
+
piece: number;
|
|
11
|
+
/** 0 = segment start key, 1|2 = handles, 3 = final end key (last piece only). */
|
|
12
|
+
idx: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Multi-segment piecewise-bezier curve editor. Drag keys/handles vertically,
|
|
17
|
+
* drag inner keys horizontally, double-click the curve to add a key, double-click
|
|
18
|
+
* a key to remove it (Unity-like).
|
|
19
|
+
*/
|
|
20
|
+
export function CurveEditor(props: {pieces: CurvePiece[]; onChange: (pieces: CurvePiece[]) => void; maxValue?: number}) {
|
|
21
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
22
|
+
const drag = useRef<PointRef | null>(null);
|
|
23
|
+
const [selected, setSelected] = useState<PointRef | null>(null);
|
|
24
|
+
|
|
25
|
+
const pieces = [...props.pieces].sort((a, b) => a.start - b.start);
|
|
26
|
+
const allValues = pieces.flatMap((piece) => piece.p);
|
|
27
|
+
const max = Math.max(props.maxValue ?? 1, ...allValues.map(Math.abs), 0.001);
|
|
28
|
+
|
|
29
|
+
const spanEnd = (i: number) => (i + 1 < pieces.length ? pieces[i + 1].start : 1);
|
|
30
|
+
const toX = (x: number) => PAD + x * (WIDTH - PAD * 2);
|
|
31
|
+
const toY = (v: number) => HEIGHT - PAD - (v / max) * (HEIGHT - PAD * 2);
|
|
32
|
+
const fromY = (y: number) => Math.round(Math.min(max, Math.max(0, ((HEIGHT - PAD - y) / (HEIGHT - PAD * 2)) * max)) * 1000) / 1000;
|
|
33
|
+
const fromX = (px: number) => Math.min(1, Math.max(0, (px - PAD) / (WIDTH - PAD * 2)));
|
|
34
|
+
|
|
35
|
+
// Control point positions: piece start key, two handles, plus the final end key.
|
|
36
|
+
const points: Array<{ref: PointRef; x: number; y: number; isKey: boolean}> = [];
|
|
37
|
+
pieces.forEach((piece, i) => {
|
|
38
|
+
const end = spanEnd(i);
|
|
39
|
+
const span = end - piece.start;
|
|
40
|
+
points.push({ref: {piece: i, idx: 0}, x: piece.start, y: piece.p[0], isKey: true});
|
|
41
|
+
points.push({ref: {piece: i, idx: 1}, x: piece.start + span / 3, y: piece.p[1], isKey: false});
|
|
42
|
+
points.push({ref: {piece: i, idx: 2}, x: piece.start + (2 * span) / 3, y: piece.p[2], isKey: false});
|
|
43
|
+
if (i === pieces.length - 1) {
|
|
44
|
+
points.push({ref: {piece: i, idx: 3}, x: 1, y: piece.p[3], isKey: true});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const samples: string[] = [];
|
|
49
|
+
pieces.forEach((piece, i) => {
|
|
50
|
+
const end = spanEnd(i);
|
|
51
|
+
for (let s = 0; s <= 16; s++) {
|
|
52
|
+
const t = s / 16;
|
|
53
|
+
samples.push(`${toX(piece.start + t * (end - piece.start)).toFixed(1)},${toY(sampleCurve(piece.p, t)).toFixed(1)}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const applyDrag = (clientX: number, clientY: number) => {
|
|
58
|
+
const target = drag.current;
|
|
59
|
+
if (!target || !svgRef.current) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const rect = svgRef.current.getBoundingClientRect();
|
|
63
|
+
const v = fromY(clientY - rect.top);
|
|
64
|
+
const next = pieces.map((piece) => ({start: piece.start, p: [...piece.p] as CurvePiece['p']}));
|
|
65
|
+
const {piece, idx} = target;
|
|
66
|
+
if (idx === 0) {
|
|
67
|
+
next[piece].p[0] = v;
|
|
68
|
+
if (piece > 0) {
|
|
69
|
+
next[piece - 1].p[3] = v;
|
|
70
|
+
// Inner keys can also move horizontally between neighbours.
|
|
71
|
+
const x = fromX(clientX - rect.left);
|
|
72
|
+
const min = next[piece - 1].start + 0.02;
|
|
73
|
+
const maxX = (piece + 1 < next.length ? next[piece + 1].start : 1) - 0.02;
|
|
74
|
+
next[piece].start = Math.min(maxX, Math.max(min, x));
|
|
75
|
+
}
|
|
76
|
+
} else if (idx === 3) {
|
|
77
|
+
next[piece].p[3] = v;
|
|
78
|
+
} else {
|
|
79
|
+
next[piece].p[idx] = v;
|
|
80
|
+
}
|
|
81
|
+
props.onChange(next);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const selectedPoint = selected ? points.find((pt) => pt.ref.piece === selected.piece && pt.ref.idx === selected.idx) : null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<svg
|
|
89
|
+
ref={svgRef}
|
|
90
|
+
width={WIDTH}
|
|
91
|
+
height={HEIGHT}
|
|
92
|
+
style={{background: 'rgba(5, 8, 18, 0.85)', border: `1px solid ${theme.border}`, borderRadius: 8, touchAction: 'none', display: 'block'}}
|
|
93
|
+
onPointerMove={(e) => applyDrag(e.clientX, e.clientY)}
|
|
94
|
+
onPointerUp={(e) => {
|
|
95
|
+
drag.current = null;
|
|
96
|
+
(e.target as Element).releasePointerCapture?.(e.pointerId);
|
|
97
|
+
}}
|
|
98
|
+
onDoubleClick={(e) => {
|
|
99
|
+
if ((e.target as Element).tagName === 'circle') {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const rect = svgRef.current!.getBoundingClientRect();
|
|
103
|
+
props.onChange(splitPieces(pieces, fromX(e.clientX - rect.left)));
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{[0.25, 0.5, 0.75].map((f) => (
|
|
107
|
+
<line key={f} x1={PAD} x2={WIDTH - PAD} y1={PAD + f * (HEIGHT - PAD * 2)} y2={PAD + f * (HEIGHT - PAD * 2)} stroke="#1c274a" strokeWidth={1} />
|
|
108
|
+
))}
|
|
109
|
+
<polyline points={samples.join(' ')} fill="none" stroke={theme.curveStroke} strokeWidth={2} />
|
|
110
|
+
{points.map((pt) => (
|
|
111
|
+
<circle
|
|
112
|
+
key={`${pt.ref.piece}-${pt.ref.idx}`}
|
|
113
|
+
className="qe-hover"
|
|
114
|
+
cx={toX(pt.x)}
|
|
115
|
+
cy={toY(pt.y)}
|
|
116
|
+
r={pt.isKey ? 5 : 4}
|
|
117
|
+
fill={pt.isKey ? theme.curveStroke : '#28407f'}
|
|
118
|
+
stroke={theme.curveStroke}
|
|
119
|
+
strokeWidth={1.5}
|
|
120
|
+
style={{cursor: pt.isKey && pt.ref.idx === 0 && pt.ref.piece > 0 ? 'move' : 'ns-resize'}}
|
|
121
|
+
onPointerDown={(e) => {
|
|
122
|
+
drag.current = pt.ref;
|
|
123
|
+
setSelected(pt.ref);
|
|
124
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
125
|
+
}}
|
|
126
|
+
onDoubleClick={(e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
if (pt.isKey && pt.ref.idx === 0 && pt.ref.piece > 0) {
|
|
129
|
+
setSelected(null);
|
|
130
|
+
props.onChange(mergePieces(pieces, pt.ref.piece));
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</svg>
|
|
136
|
+
<div style={{display: 'flex', gap: 4, marginTop: 4, alignItems: 'center'}}>
|
|
137
|
+
{selectedPoint ? (
|
|
138
|
+
<>
|
|
139
|
+
<span style={{fontSize: 10.5, color: theme.textDim}}>value</span>
|
|
140
|
+
<input
|
|
141
|
+
type="number"
|
|
142
|
+
className="qe-hover"
|
|
143
|
+
step={0.05}
|
|
144
|
+
style={{...inputStyle, fontSize: 11, padding: '3px 5px', width: 70}}
|
|
145
|
+
value={Math.round(selectedPoint.y * 1000) / 1000}
|
|
146
|
+
onChange={(e) => {
|
|
147
|
+
const parsed = parseFloat(e.target.value);
|
|
148
|
+
if (Number.isNaN(parsed)) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const next = pieces.map((piece) => ({start: piece.start, p: [...piece.p] as CurvePiece['p']}));
|
|
152
|
+
const {piece, idx} = selectedPoint.ref;
|
|
153
|
+
if (idx === 0 && piece > 0) {
|
|
154
|
+
next[piece - 1].p[3] = parsed;
|
|
155
|
+
}
|
|
156
|
+
next[piece].p[idx === 3 ? 3 : idx] = parsed;
|
|
157
|
+
props.onChange(next);
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
<span style={{fontSize: 10.5, color: theme.textDim}}>t={Math.round(selectedPoint.x * 100) / 100}</span>
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<span style={{fontSize: 10.5, color: theme.textDim}}>dbl-click curve: add key · dbl-click key: remove</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React, {useSyncExternalStore} from 'react';
|
|
2
|
+
import {EffectBinding} from '../core/binding';
|
|
3
|
+
import {
|
|
4
|
+
ColorOverLifeModule,
|
|
5
|
+
EmissionModule,
|
|
6
|
+
MainModule,
|
|
7
|
+
RendererModule,
|
|
8
|
+
ShapeModule,
|
|
9
|
+
SizeOverLifeModule,
|
|
10
|
+
} from './modules';
|
|
11
|
+
import type {GeometryData, GeometryOption, TextureOption} from './modules';
|
|
12
|
+
import {
|
|
13
|
+
CollisionModule,
|
|
14
|
+
ColorBySpeedModule,
|
|
15
|
+
EmitDirectionModule,
|
|
16
|
+
ForceOverLifeModule,
|
|
17
|
+
GravityModule,
|
|
18
|
+
InheritVelocityModule,
|
|
19
|
+
LimitSpeedOverLifeModule,
|
|
20
|
+
NoiseModule,
|
|
21
|
+
OrbitOverLifeModule,
|
|
22
|
+
RotationBySpeedModule,
|
|
23
|
+
RotationOverLifeModule,
|
|
24
|
+
SizeBySpeedModule,
|
|
25
|
+
SpeedOverLifeModule,
|
|
26
|
+
TextureSheetModule,
|
|
27
|
+
TurbulenceModule,
|
|
28
|
+
VelocityOverLifeModule,
|
|
29
|
+
WidthOverTrailModule,
|
|
30
|
+
} from './behaviorModules';
|
|
31
|
+
import {SubEmittersModule} from './SubEmittersModule';
|
|
32
|
+
import {theme} from './theme';
|
|
33
|
+
import type {ParticleSystem} from 'babylon.quarks';
|
|
34
|
+
|
|
35
|
+
export interface EffectEditorProps {
|
|
36
|
+
binding: EffectBinding;
|
|
37
|
+
/** Currently selected system, lifted up so the timeline panel and this module stack agree. */
|
|
38
|
+
selectedSystem: ParticleSystem;
|
|
39
|
+
/** Texture presets offered in the Renderer module (host supplies the loader). */
|
|
40
|
+
textureOptions?: TextureOption[];
|
|
41
|
+
/** Mesh presets offered for the Mesh render mode (host supplies the geometry buffers). */
|
|
42
|
+
geometryOptions?: GeometryOption[];
|
|
43
|
+
resolveTexture?: (url: string) => unknown;
|
|
44
|
+
/** Host loader for "Load from file…" in the Mesh render mode (e.g. a GLB importer). */
|
|
45
|
+
resolveGeometry?: (file: File) => Promise<GeometryData>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shuriken-style effect editor: a stacked module inspector for the selected system.
|
|
50
|
+
* Embed it anywhere React runs — the standalone examples page or a BabylonJS Editor plugin.
|
|
51
|
+
* Module order mirrors Unity's Particle System inspector.
|
|
52
|
+
*/
|
|
53
|
+
export function EffectEditor(props: EffectEditorProps) {
|
|
54
|
+
useSyncExternalStore(
|
|
55
|
+
(onStoreChange) => props.binding.subscribe(onStoreChange),
|
|
56
|
+
() => props.binding.getRevision()
|
|
57
|
+
);
|
|
58
|
+
const allSystems = [props.binding.system, ...props.binding.subSystems];
|
|
59
|
+
// Selection survives re-renders but falls back to root if the system was removed/replaced.
|
|
60
|
+
const activeSystem = allSystems.includes(props.selectedSystem) ? props.selectedSystem : props.binding.system;
|
|
61
|
+
|
|
62
|
+
// Inspector edits go through a per-system binding; changes route into the root
|
|
63
|
+
// binding's undo history and re-render via the subscription below.
|
|
64
|
+
const binding = React.useMemo(() => {
|
|
65
|
+
if (activeSystem === props.binding.system) {
|
|
66
|
+
return props.binding;
|
|
67
|
+
}
|
|
68
|
+
const nested = new EffectBinding(activeSystem);
|
|
69
|
+
nested.onBeforeChange = () => props.binding.onBeforeChange?.();
|
|
70
|
+
return nested;
|
|
71
|
+
}, [props.binding, activeSystem]);
|
|
72
|
+
useSyncExternalStore(
|
|
73
|
+
(onStoreChange) => binding.subscribe(onStoreChange),
|
|
74
|
+
() => binding.getRevision()
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div style={{fontFamily: theme.font, color: theme.text}}>
|
|
79
|
+
<MainModule binding={binding} />
|
|
80
|
+
<EmissionModule binding={binding} />
|
|
81
|
+
<ShapeModule binding={binding} />
|
|
82
|
+
<EmitDirectionModule binding={binding} />
|
|
83
|
+
<VelocityOverLifeModule binding={binding} />
|
|
84
|
+
<SpeedOverLifeModule binding={binding} />
|
|
85
|
+
<LimitSpeedOverLifeModule binding={binding} />
|
|
86
|
+
<InheritVelocityModule binding={binding} />
|
|
87
|
+
<ForceOverLifeModule binding={binding} />
|
|
88
|
+
<GravityModule binding={binding} />
|
|
89
|
+
<OrbitOverLifeModule binding={binding} />
|
|
90
|
+
<ColorOverLifeModule binding={binding} />
|
|
91
|
+
<ColorBySpeedModule binding={binding} />
|
|
92
|
+
<SizeOverLifeModule binding={binding} />
|
|
93
|
+
<SizeBySpeedModule binding={binding} />
|
|
94
|
+
<RotationOverLifeModule binding={binding} />
|
|
95
|
+
<RotationBySpeedModule binding={binding} />
|
|
96
|
+
<NoiseModule binding={binding} />
|
|
97
|
+
<TurbulenceModule binding={binding} />
|
|
98
|
+
<CollisionModule binding={binding} />
|
|
99
|
+
<WidthOverTrailModule binding={binding} />
|
|
100
|
+
<SubEmittersModule binding={binding} rootBinding={props.binding} />
|
|
101
|
+
<TextureSheetModule binding={binding} />
|
|
102
|
+
<RendererModule
|
|
103
|
+
binding={binding}
|
|
104
|
+
textureOptions={props.textureOptions}
|
|
105
|
+
geometryOptions={props.geometryOptions}
|
|
106
|
+
resolveTexture={props.resolveTexture}
|
|
107
|
+
resolveGeometry={props.resolveGeometry}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,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 {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
|
+
};
|