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,354 @@
|
|
|
1
|
+
import React, {useMemo, useRef, useState} from 'react';
|
|
2
|
+
import type {ParticleSystem, BatchedRenderer} from 'babylon.quarks';
|
|
3
|
+
import type {Scene} from '@babylonjs/core/scene';
|
|
4
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
5
|
+
import type {EffectBinding} from '../core/binding';
|
|
6
|
+
import {groupNodes} from '../core/groups';
|
|
7
|
+
import {scrubTo} from '../core/scrub';
|
|
8
|
+
import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
|
|
9
|
+
import {TimelineTrackRow} from './TimelineTrackRow';
|
|
10
|
+
import {buttonStyle, theme} from './theme';
|
|
11
|
+
|
|
12
|
+
export interface PlaybackState {
|
|
13
|
+
paused: boolean;
|
|
14
|
+
speed: number;
|
|
15
|
+
stepQueued: boolean;
|
|
16
|
+
elapsed: number;
|
|
17
|
+
scrubbing: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TimelinePanelProps {
|
|
21
|
+
binding: EffectBinding;
|
|
22
|
+
selectedSystem: ParticleSystem;
|
|
23
|
+
onSelect: (system: ParticleSystem) => void;
|
|
24
|
+
scene: Scene;
|
|
25
|
+
renderer: BatchedRenderer;
|
|
26
|
+
playbackRef: React.MutableRefObject<PlaybackState>;
|
|
27
|
+
playheadRef: React.RefObject<HTMLDivElement>;
|
|
28
|
+
counterRef: React.RefObject<HTMLSpanElement>;
|
|
29
|
+
paused: boolean;
|
|
30
|
+
setPaused: (paused: boolean) => void;
|
|
31
|
+
speed: number;
|
|
32
|
+
setSpeed: (speed: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LABEL_WIDTH = 200;
|
|
36
|
+
const RULER_HEIGHT = 22;
|
|
37
|
+
const PANEL_HEIGHT = 220;
|
|
38
|
+
/** Small gap between the label column and the first pixel of the time axis. */
|
|
39
|
+
export const TIMELINE_INSET = 10;
|
|
40
|
+
|
|
41
|
+
function pickTickInterval(pxPerSecond: number): number {
|
|
42
|
+
if (pxPerSecond >= 60) return 1;
|
|
43
|
+
if (pxPerSecond >= 24) return 2;
|
|
44
|
+
if (pxPerSecond >= 10) return 5;
|
|
45
|
+
return 10;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bottom-docked, Premiere/Resolve-style timeline: transport controls, a time ruler with a
|
|
50
|
+
* draggable playhead (approximate scrub — see core/scrub.ts), and one track row per system.
|
|
51
|
+
*/
|
|
52
|
+
export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
|
|
53
|
+
const {binding, selectedSystem, onSelect, scene, renderer, playbackRef, playheadRef, counterRef, paused, setPaused, speed, setSpeed} = props;
|
|
54
|
+
|
|
55
|
+
const [bodyWidthPx, setBodyWidthPx] = useState(600);
|
|
56
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<TransformNode>>(() => new Set());
|
|
57
|
+
const [hiddenSystems, setHiddenSystems] = useState<Set<ParticleSystem>>(() => new Set());
|
|
58
|
+
const [groupSelection, setGroupSelection] = useState<Set<TransformNode>>(() => new Set());
|
|
59
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const dragRef = useRef<{raf: number | null; pendingTime: number | null}>({raf: null, pendingTime: null});
|
|
61
|
+
|
|
62
|
+
// A new effect (import/undo/redo/initial mount) replaces every system, so stale
|
|
63
|
+
// visibility toggles and grouping selection from the previous effect no longer refer to
|
|
64
|
+
// anything real.
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
setHiddenSystems(new Set());
|
|
67
|
+
setGroupSelection(new Set());
|
|
68
|
+
}, [binding]);
|
|
69
|
+
|
|
70
|
+
const toggleGroupSelect = (node: TransformNode) => {
|
|
71
|
+
setGroupSelection((prev) => {
|
|
72
|
+
const next = new Set(prev);
|
|
73
|
+
if (next.has(node)) {
|
|
74
|
+
next.delete(node);
|
|
75
|
+
} else {
|
|
76
|
+
next.add(node);
|
|
77
|
+
}
|
|
78
|
+
return next;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const groupSelectedNodes = () => {
|
|
83
|
+
if (groupSelection.size < 2) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const nodes = [...groupSelection];
|
|
87
|
+
binding.apply(() => {
|
|
88
|
+
groupNodes(nodes, `Group ${rows.filter((r) => r.kind === 'group').length + 1}`);
|
|
89
|
+
});
|
|
90
|
+
setGroupSelection(new Set());
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const toggleVisible = (system: ParticleSystem) => {
|
|
94
|
+
setHiddenSystems((prev) => {
|
|
95
|
+
const next = new Set(prev);
|
|
96
|
+
if (next.has(system)) {
|
|
97
|
+
next.delete(system);
|
|
98
|
+
system.emitter.visible = true;
|
|
99
|
+
} else {
|
|
100
|
+
next.add(system);
|
|
101
|
+
system.emitter.visible = false;
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
React.useLayoutEffect(() => {
|
|
108
|
+
const el = bodyRef.current;
|
|
109
|
+
if (!el) return;
|
|
110
|
+
const observer = new ResizeObserver((entries) => {
|
|
111
|
+
const width = entries[0]?.contentRect.width;
|
|
112
|
+
if (width) setBodyWidthPx(Math.max(1, width - LABEL_WIDTH));
|
|
113
|
+
});
|
|
114
|
+
observer.observe(el);
|
|
115
|
+
return () => observer.disconnect();
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const rows = useMemo(() => computeTimelineRows(binding), [binding, binding.getRevision()]);
|
|
119
|
+
const timelineSpan = useMemo(() => computeTimelineSpan(rows), [rows]);
|
|
120
|
+
const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET) / timelineSpan;
|
|
121
|
+
|
|
122
|
+
// Follows playbackRef.elapsed independently of Babylon's render loop (Host only owns
|
|
123
|
+
// simulation time; this owns translating it into pixels for the playhead line). Looping
|
|
124
|
+
// effects run forever, so elapsed grows unbounded — wrap it to the visible span instead
|
|
125
|
+
// of letting the playhead fly off past the right edge.
|
|
126
|
+
React.useEffect(() => {
|
|
127
|
+
let raf: number;
|
|
128
|
+
const tick = () => {
|
|
129
|
+
if (playheadRef.current) {
|
|
130
|
+
const wrapped = timelineSpan > 0 ? playbackRef.current.elapsed % timelineSpan : 0;
|
|
131
|
+
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + wrapped * pxPerSecond}px`;
|
|
132
|
+
}
|
|
133
|
+
raf = requestAnimationFrame(tick);
|
|
134
|
+
};
|
|
135
|
+
raf = requestAnimationFrame(tick);
|
|
136
|
+
return () => cancelAnimationFrame(raf);
|
|
137
|
+
}, [pxPerSecond, timelineSpan, playbackRef, playheadRef]);
|
|
138
|
+
|
|
139
|
+
const visibleRows = useMemo(() => {
|
|
140
|
+
let hideUntilDepth: number | null = null;
|
|
141
|
+
const visible = rows.filter((row) => {
|
|
142
|
+
if (hideUntilDepth !== null) {
|
|
143
|
+
if (row.depth > hideUntilDepth) return false;
|
|
144
|
+
hideUntilDepth = null;
|
|
145
|
+
}
|
|
146
|
+
if (row.kind === 'group' && collapsedGroups.has(row.node)) {
|
|
147
|
+
hideUntilDepth = row.depth;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
return visible;
|
|
152
|
+
}, [rows, collapsedGroups]);
|
|
153
|
+
|
|
154
|
+
const toggleCollapse = (node: TransformNode) => {
|
|
155
|
+
setCollapsedGroups((prev) => {
|
|
156
|
+
const next = new Set(prev);
|
|
157
|
+
if (next.has(node)) {
|
|
158
|
+
next.delete(node);
|
|
159
|
+
} else {
|
|
160
|
+
next.add(node);
|
|
161
|
+
}
|
|
162
|
+
return next;
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const pixelToTime = (clientX: number): number => {
|
|
167
|
+
const rect = bodyRef.current!.getBoundingClientRect();
|
|
168
|
+
const x = clientX - rect.left - LABEL_WIDTH - TIMELINE_INSET;
|
|
169
|
+
return Math.min(timelineSpan, Math.max(0, x / pxPerSecond));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const applyScrub = (time: number) => {
|
|
173
|
+
scrubTo(binding, renderer, time);
|
|
174
|
+
playbackRef.current.elapsed = time;
|
|
175
|
+
if (playheadRef.current) {
|
|
176
|
+
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + time * pxPerSecond}px`;
|
|
177
|
+
}
|
|
178
|
+
scene.render();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const queueScrub = (clientX: number) => {
|
|
182
|
+
dragRef.current.pendingTime = pixelToTime(clientX);
|
|
183
|
+
if (dragRef.current.raf == null) {
|
|
184
|
+
dragRef.current.raf = requestAnimationFrame(() => {
|
|
185
|
+
dragRef.current.raf = null;
|
|
186
|
+
const t = dragRef.current.pendingTime;
|
|
187
|
+
if (t != null) applyScrub(t);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const restart = () => {
|
|
193
|
+
applyScrub(0);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const ticks: number[] = [];
|
|
197
|
+
for (let t = 0; t <= timelineSpan; t += pickTickInterval(pxPerSecond)) {
|
|
198
|
+
ticks.push(t);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const overlayButton: React.CSSProperties = {...buttonStyle, padding: '5px 9px', minWidth: 32};
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div
|
|
205
|
+
style={{
|
|
206
|
+
flexShrink: 0,
|
|
207
|
+
height: PANEL_HEIGHT,
|
|
208
|
+
borderTop: `1px solid ${theme.border}`,
|
|
209
|
+
background: theme.panelBg,
|
|
210
|
+
display: 'flex',
|
|
211
|
+
flexDirection: 'column',
|
|
212
|
+
fontFamily: theme.font,
|
|
213
|
+
color: theme.text,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<div style={{display: 'flex', alignItems: 'center', gap: 6, padding: '6px 10px', borderBottom: `1px solid ${theme.border}`}}>
|
|
217
|
+
<button className="qe-hover" style={overlayButton} title={paused ? 'Play' : 'Pause'} onClick={() => {
|
|
218
|
+
playbackRef.current.paused = !paused;
|
|
219
|
+
setPaused(!paused);
|
|
220
|
+
}}>
|
|
221
|
+
{paused ? '▶' : '⏸'}
|
|
222
|
+
</button>
|
|
223
|
+
<button className="qe-hover" style={overlayButton} title="Step one frame" disabled={!paused} onClick={() => (playbackRef.current.stepQueued = true)}>
|
|
224
|
+
⏭
|
|
225
|
+
</button>
|
|
226
|
+
<button className="qe-hover" style={overlayButton} title="Restart" onClick={restart}>
|
|
227
|
+
⟲
|
|
228
|
+
</button>
|
|
229
|
+
<select
|
|
230
|
+
className="qe-hover"
|
|
231
|
+
style={{...overlayButton, padding: '5px 4px'}}
|
|
232
|
+
title="Playback speed"
|
|
233
|
+
value={speed}
|
|
234
|
+
onChange={(e) => {
|
|
235
|
+
const next = Number(e.target.value);
|
|
236
|
+
playbackRef.current.speed = next;
|
|
237
|
+
setSpeed(next);
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
{[0.1, 0.25, 0.5, 1, 2, 4].map((s) => (
|
|
241
|
+
<option key={s} value={s}>
|
|
242
|
+
×{s}
|
|
243
|
+
</option>
|
|
244
|
+
))}
|
|
245
|
+
</select>
|
|
246
|
+
<span ref={counterRef} style={{fontSize: 12, color: theme.textDim, fontVariantNumeric: 'tabular-nums', paddingLeft: 4, minWidth: 120}} />
|
|
247
|
+
{groupSelection.size >= 2 && (
|
|
248
|
+
<button className="qe-hover" style={overlayButton} title="Group the checked rows" onClick={groupSelectedNodes}>
|
|
249
|
+
Group ({groupSelection.size})
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
{/* position:relative wrapper whose box is exactly the visible panel body — the
|
|
254
|
+
ruler header/divider/playhead below are its direct children (siblings of the
|
|
255
|
+
scroller, not descendants), so top:0/bottom:0 always means "100% of the panel,"
|
|
256
|
+
independent of scroll position or how many tracks there are. Also measures the
|
|
257
|
+
full-width box (labels + time axis) for pxPerSecond, since the ruler and the
|
|
258
|
+
scroller share that width. */}
|
|
259
|
+
<div ref={bodyRef} style={{position: 'relative', flex: 1, minHeight: 0}}>
|
|
260
|
+
{/* Fixed ruler header, kept out of the vertical scroller so it never scrolls
|
|
261
|
+
away with the track rows. */}
|
|
262
|
+
<div
|
|
263
|
+
style={{
|
|
264
|
+
position: 'absolute',
|
|
265
|
+
top: 0,
|
|
266
|
+
left: 0,
|
|
267
|
+
right: 0,
|
|
268
|
+
height: RULER_HEIGHT,
|
|
269
|
+
display: 'grid',
|
|
270
|
+
gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
|
|
271
|
+
background: theme.panelBg,
|
|
272
|
+
zIndex: 2,
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
<div style={{height: RULER_HEIGHT}} />
|
|
276
|
+
<div
|
|
277
|
+
className="qe-hover-bg"
|
|
278
|
+
style={{position: 'relative', height: RULER_HEIGHT, cursor: 'ew-resize', overflow: 'hidden'}}
|
|
279
|
+
onPointerDown={(e) => {
|
|
280
|
+
playbackRef.current.scrubbing = true;
|
|
281
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
282
|
+
queueScrub(e.clientX);
|
|
283
|
+
}}
|
|
284
|
+
onPointerMove={(e) => {
|
|
285
|
+
if (playbackRef.current.scrubbing) queueScrub(e.clientX);
|
|
286
|
+
}}
|
|
287
|
+
onPointerUp={(e) => {
|
|
288
|
+
playbackRef.current.scrubbing = false;
|
|
289
|
+
(e.target as Element).releasePointerCapture?.(e.pointerId);
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
{ticks.map((t) => (
|
|
293
|
+
<div key={t} style={{position: 'absolute', left: TIMELINE_INSET + t * pxPerSecond, top: 0, bottom: 0, borderLeft: `1px solid ${theme.border}`, fontSize: 10, color: theme.textDim, paddingLeft: 3}}>
|
|
294
|
+
{t}s
|
|
295
|
+
</div>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div
|
|
300
|
+
style={{
|
|
301
|
+
position: 'absolute',
|
|
302
|
+
top: RULER_HEIGHT,
|
|
303
|
+
left: 0,
|
|
304
|
+
right: 0,
|
|
305
|
+
bottom: 0,
|
|
306
|
+
overflowY: 'auto',
|
|
307
|
+
overflowX: 'hidden',
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
<div
|
|
311
|
+
style={{
|
|
312
|
+
display: 'grid',
|
|
313
|
+
gridTemplateColumns: `${LABEL_WIDTH}px 1fr`,
|
|
314
|
+
gridAutoRows: 'min-content',
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{visibleRows.map((row) => (
|
|
318
|
+
<TimelineTrackRow
|
|
319
|
+
key={row.node.uniqueId}
|
|
320
|
+
row={row}
|
|
321
|
+
binding={binding}
|
|
322
|
+
selected={row.kind === 'track' && row.system === selectedSystem}
|
|
323
|
+
onSelect={onSelect}
|
|
324
|
+
pxPerSecond={pxPerSecond}
|
|
325
|
+
offsetPx={TIMELINE_INSET}
|
|
326
|
+
collapsed={row.kind === 'group' && collapsedGroups.has(row.node)}
|
|
327
|
+
onToggleCollapse={row.kind === 'group' ? () => toggleCollapse(row.node) : undefined}
|
|
328
|
+
hidden={row.kind === 'track' && hiddenSystems.has(row.system)}
|
|
329
|
+
onToggleVisible={row.kind === 'track' ? () => toggleVisible(row.system) : undefined}
|
|
330
|
+
groupSelected={groupSelection.has(row.node)}
|
|
331
|
+
onToggleGroupSelect={() => toggleGroupSelect(row.node)}
|
|
332
|
+
/>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
{/* Static divider between the label column and the time axis, distinct from the moving playhead. */}
|
|
337
|
+
<div style={{position: 'absolute', top: 0, bottom: 0, left: LABEL_WIDTH, width: 1, background: theme.border, pointerEvents: 'none', zIndex: 3}} />
|
|
338
|
+
<div
|
|
339
|
+
ref={playheadRef}
|
|
340
|
+
style={{
|
|
341
|
+
position: 'absolute',
|
|
342
|
+
top: 0,
|
|
343
|
+
bottom: 0,
|
|
344
|
+
left: LABEL_WIDTH + TIMELINE_INSET,
|
|
345
|
+
width: 1,
|
|
346
|
+
background: theme.accent,
|
|
347
|
+
pointerEvents: 'none',
|
|
348
|
+
zIndex: 3,
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import type {ParticleSystem} from 'babylon.quarks';
|
|
3
|
+
import type {EffectBinding} from '../core/binding';
|
|
4
|
+
import {buildEffectTree, collectSystems} from '../core/effectTree';
|
|
5
|
+
import {createChildSystem, removeSystems} from '../core/systems';
|
|
6
|
+
import {ungroupNode} from '../core/groups';
|
|
7
|
+
import type {TimelineRow} from '../core/timeline';
|
|
8
|
+
import {PromptDialog} from './PromptDialog';
|
|
9
|
+
import {theme} from './theme';
|
|
10
|
+
|
|
11
|
+
const iconButton: React.CSSProperties = {background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, padding: 0};
|
|
12
|
+
|
|
13
|
+
const ROW_HEIGHT = 28;
|
|
14
|
+
|
|
15
|
+
export interface TimelineTrackRowProps {
|
|
16
|
+
row: TimelineRow;
|
|
17
|
+
binding: EffectBinding;
|
|
18
|
+
selected: boolean;
|
|
19
|
+
onSelect: (system: ParticleSystem) => void;
|
|
20
|
+
pxPerSecond: number;
|
|
21
|
+
/** Matches the ruler's left gutter so bars line up under the correct tick. */
|
|
22
|
+
offsetPx: number;
|
|
23
|
+
collapsed: boolean;
|
|
24
|
+
onToggleCollapse?: () => void;
|
|
25
|
+
/** Preview-only visibility (toggles `system.emitter.visible`); lets you isolate one track. */
|
|
26
|
+
hidden?: boolean;
|
|
27
|
+
onToggleVisible?: () => void;
|
|
28
|
+
/** Membership in the "group selected rows together" set (unrelated to inspector selection). */
|
|
29
|
+
groupSelected: boolean;
|
|
30
|
+
onToggleGroupSelect: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const checkboxButton: React.CSSProperties = {...iconButton, fontSize: 12, width: 12};
|
|
34
|
+
|
|
35
|
+
/** One row of the timeline: a label cell (name + actions) and, for tracks, a bar cell. */
|
|
36
|
+
export function TimelineTrackRow(props: TimelineTrackRowProps): React.ReactElement {
|
|
37
|
+
const {row, binding, selected, onSelect, pxPerSecond, offsetPx, collapsed, onToggleCollapse, hidden, onToggleVisible, groupSelected, onToggleGroupSelect} = props;
|
|
38
|
+
const [renaming, setRenaming] = useState(false);
|
|
39
|
+
|
|
40
|
+
const isRoot = row.node === binding.root;
|
|
41
|
+
|
|
42
|
+
const labelStyle: React.CSSProperties = {
|
|
43
|
+
display: 'flex',
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
gap: 6,
|
|
46
|
+
height: ROW_HEIGHT,
|
|
47
|
+
padding: '0 8px',
|
|
48
|
+
paddingLeft: 8 + row.depth * 14,
|
|
49
|
+
borderRadius: 6,
|
|
50
|
+
cursor: row.kind === 'track' ? 'pointer' : onToggleCollapse ? 'pointer' : 'default',
|
|
51
|
+
fontSize: 12.5,
|
|
52
|
+
color: row.kind === 'track' && selected ? theme.text : theme.textDim,
|
|
53
|
+
whiteSpace: 'nowrap',
|
|
54
|
+
// Left unset (rather than 'transparent') when not selected so the .qe-row-bg hover
|
|
55
|
+
// tint isn't shadowed by an inline background of the same property.
|
|
56
|
+
...(row.kind === 'track' && selected ? {background: 'rgba(60, 105, 209, 0.3)'} : {}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (row.kind === 'group') {
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<div className="qe-hover-bg" style={{...labelStyle, borderTop: `1px solid ${theme.border}`}} onClick={onToggleCollapse}>
|
|
63
|
+
{!isRoot && (
|
|
64
|
+
<button
|
|
65
|
+
className="qe-hover"
|
|
66
|
+
title={groupSelected ? 'Remove from group selection' : 'Select for grouping'}
|
|
67
|
+
style={{...checkboxButton, color: groupSelected ? theme.accent : theme.textDim}}
|
|
68
|
+
onClick={(e) => {
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
onToggleGroupSelect();
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{groupSelected ? '☑' : '☐'}
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
<span style={{color: theme.textDim, fontSize: 10, width: 10}}>{collapsed ? '▸' : '▾'}</span>
|
|
77
|
+
<span style={{flex: 1, overflow: 'hidden', textOverflow: 'ellipsis'}}>{row.name}</span>
|
|
78
|
+
<button
|
|
79
|
+
className="qe-hover"
|
|
80
|
+
title="Add system to this group"
|
|
81
|
+
style={{...iconButton, color: theme.accent, fontSize: 13}}
|
|
82
|
+
onClick={(e) => {
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
let created: ParticleSystem | undefined;
|
|
85
|
+
binding.apply(() => {
|
|
86
|
+
created = createChildSystem(binding.system, {
|
|
87
|
+
name: `system ${binding.subSystems.length + 1}`,
|
|
88
|
+
parentNode: row.node,
|
|
89
|
+
});
|
|
90
|
+
binding.system._renderer?.addSystem(created);
|
|
91
|
+
});
|
|
92
|
+
if (created) {
|
|
93
|
+
binding.addSubSystem(created);
|
|
94
|
+
onSelect(created);
|
|
95
|
+
}
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
+
|
|
99
|
+
</button>
|
|
100
|
+
{!isRoot && (
|
|
101
|
+
<button className="qe-hover" title="Rename" style={{...iconButton, color: theme.textDim}} onClick={(e) => {
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
setRenaming(true);
|
|
104
|
+
}}>
|
|
105
|
+
✎
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
108
|
+
{!isRoot && (
|
|
109
|
+
<button
|
|
110
|
+
className="qe-hover"
|
|
111
|
+
title="Ungroup (moves its contents up a level)"
|
|
112
|
+
style={{...iconButton, color: theme.textDim, fontSize: 12}}
|
|
113
|
+
onClick={(e) => {
|
|
114
|
+
e.stopPropagation();
|
|
115
|
+
binding.apply(() => ungroupNode(row.node));
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
⛶
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
{!isRoot && (
|
|
122
|
+
<button
|
|
123
|
+
className="qe-hover"
|
|
124
|
+
title="Remove group"
|
|
125
|
+
style={{...iconButton, color: '#e08c8c', fontSize: 12}}
|
|
126
|
+
onClick={(e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
removeSystems(binding, collectSystems(buildEffectTree(row.node)), onSelect);
|
|
129
|
+
binding.apply(() => row.node.dispose(true, false));
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
✕
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="qe-hover-bg" style={{height: ROW_HEIGHT, borderTop: `1px solid ${theme.border}`}} onClick={onToggleCollapse} />
|
|
137
|
+
{renaming && (
|
|
138
|
+
<PromptDialog
|
|
139
|
+
title="Rename"
|
|
140
|
+
defaultValue={row.name}
|
|
141
|
+
onSubmit={(name) => {
|
|
142
|
+
binding.apply(() => (row.node.name = name));
|
|
143
|
+
setRenaming(false);
|
|
144
|
+
}}
|
|
145
|
+
onCancel={() => setRenaming(false)}
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const {system} = row;
|
|
153
|
+
const barLeft = offsetPx + row.startOffset * pxPerSecond;
|
|
154
|
+
const barWidth = Math.max(2, row.duration * pxPerSecond);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<div className="qe-hover-bg" style={{...labelStyle, opacity: hidden ? 0.5 : 1}} onClick={() => onSelect(system)}>
|
|
159
|
+
{!isRoot && (
|
|
160
|
+
<button
|
|
161
|
+
className="qe-hover"
|
|
162
|
+
title={groupSelected ? 'Remove from group selection' : 'Select for grouping'}
|
|
163
|
+
style={{...checkboxButton, color: groupSelected ? theme.accent : theme.textDim}}
|
|
164
|
+
onClick={(e) => {
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
onToggleGroupSelect();
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{groupSelected ? '☑' : '☐'}
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
<button
|
|
173
|
+
className="qe-hover"
|
|
174
|
+
title={hidden ? 'Show in viewport' : 'Hide in viewport'}
|
|
175
|
+
style={{...iconButton, color: hidden ? theme.textDim : theme.accent, fontSize: 12}}
|
|
176
|
+
onClick={(e) => {
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
onToggleVisible?.();
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{hidden ? '◯' : '◉'}
|
|
182
|
+
</button>
|
|
183
|
+
<span style={{color: theme.accent, fontSize: 10, width: 10}}>◆</span>
|
|
184
|
+
<span style={{flex: 1, overflow: 'hidden', textOverflow: 'ellipsis'}}>
|
|
185
|
+
{row.name}
|
|
186
|
+
{system.onlyUsedByOther ? ' (sub)' : ''}
|
|
187
|
+
</span>
|
|
188
|
+
{!isRoot && (
|
|
189
|
+
<button className="qe-hover" title="Rename" style={{...iconButton, color: theme.textDim}} onClick={(e) => {
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
setRenaming(true);
|
|
192
|
+
}}>
|
|
193
|
+
✎
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
{!isRoot && row.removable && (
|
|
197
|
+
<button
|
|
198
|
+
className="qe-hover"
|
|
199
|
+
title="Remove system"
|
|
200
|
+
style={{...iconButton, color: '#e08c8c', fontSize: 12}}
|
|
201
|
+
onClick={(e) => {
|
|
202
|
+
e.stopPropagation();
|
|
203
|
+
removeSystems(binding, [system], onSelect);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
✕
|
|
207
|
+
</button>
|
|
208
|
+
)}
|
|
209
|
+
{selected && (
|
|
210
|
+
<button
|
|
211
|
+
className="qe-hover"
|
|
212
|
+
title="Add child system"
|
|
213
|
+
style={{...iconButton, color: theme.accent, fontSize: 13}}
|
|
214
|
+
onClick={(e) => {
|
|
215
|
+
e.stopPropagation();
|
|
216
|
+
let created: ParticleSystem | undefined;
|
|
217
|
+
binding.apply(() => {
|
|
218
|
+
created = createChildSystem(system, {name: `system ${binding.subSystems.length + 1}`});
|
|
219
|
+
binding.system._renderer?.addSystem(created);
|
|
220
|
+
});
|
|
221
|
+
if (created) {
|
|
222
|
+
binding.addSubSystem(created);
|
|
223
|
+
onSelect(created);
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
+
|
|
228
|
+
</button>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
<div className="qe-hover-bg" style={{position: 'relative', height: ROW_HEIGHT}} onClick={() => onSelect(system)}>
|
|
232
|
+
<div
|
|
233
|
+
className="qe-hover"
|
|
234
|
+
title={row.startOffsetVariable ? 'Start time varies per spawn' : undefined}
|
|
235
|
+
style={{
|
|
236
|
+
position: 'absolute',
|
|
237
|
+
left: barLeft,
|
|
238
|
+
width: barWidth,
|
|
239
|
+
top: 5,
|
|
240
|
+
height: ROW_HEIGHT - 10,
|
|
241
|
+
borderRadius: 4,
|
|
242
|
+
cursor: 'pointer',
|
|
243
|
+
background: system.onlyUsedByOther ? 'repeating-linear-gradient(135deg, rgba(158,185,255,0.25), rgba(158,185,255,0.25) 4px, rgba(158,185,255,0.4) 4px, rgba(158,185,255,0.4) 8px)' : selected ? theme.accent : '#3c5aa0',
|
|
244
|
+
border: system.onlyUsedByOther ? `1px dashed ${theme.accent}` : `1px solid ${theme.border}`,
|
|
245
|
+
opacity: hidden ? 0.3 : system.onlyUsedByOther ? 0.75 : 1,
|
|
246
|
+
boxSizing: 'border-box',
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
{row.looping && (
|
|
250
|
+
<span style={{position: 'absolute', right: 4, top: -1, fontSize: 10, color: theme.text}}>⟳</span>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
{renaming && (
|
|
255
|
+
<PromptDialog
|
|
256
|
+
title="Rename"
|
|
257
|
+
defaultValue={row.name}
|
|
258
|
+
onSubmit={(name) => {
|
|
259
|
+
binding.apply(() => (row.node.name = name));
|
|
260
|
+
setRenaming(false);
|
|
261
|
+
}}
|
|
262
|
+
onCancel={() => setRenaming(false)}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {ScalarGenerator, ScalarValueState, buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
|
|
3
|
+
import {CurveEditor} from './CurveEditor';
|
|
4
|
+
import {NumberField, Row, SelectField} from './fields';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unity-like value field: Constant | Random Between Two Constants | Curve,
|
|
8
|
+
* bound to a quarks value generator.
|
|
9
|
+
*/
|
|
10
|
+
export function ValueField(props: {
|
|
11
|
+
label: string;
|
|
12
|
+
generator: ScalarGenerator | undefined;
|
|
13
|
+
onChange: (generator: ScalarGenerator) => void;
|
|
14
|
+
min?: number;
|
|
15
|
+
curveMax?: number;
|
|
16
|
+
}) {
|
|
17
|
+
const state = readScalar(props.generator);
|
|
18
|
+
|
|
19
|
+
const commit = (patch: Partial<ScalarValueState>) => {
|
|
20
|
+
props.onChange(buildScalar({...state, ...patch}));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Row label={props.label}>
|
|
26
|
+
<SelectField
|
|
27
|
+
value={state.mode}
|
|
28
|
+
options={[
|
|
29
|
+
{value: 'constant', label: 'Constant'},
|
|
30
|
+
{value: 'random', label: 'Random between two'},
|
|
31
|
+
{value: 'curve', label: 'Curve'},
|
|
32
|
+
]}
|
|
33
|
+
onChange={(mode) => commit({mode})}
|
|
34
|
+
/>
|
|
35
|
+
</Row>
|
|
36
|
+
{state.mode === 'constant' && (
|
|
37
|
+
<Row label="">
|
|
38
|
+
<NumberField value={state.value} min={props.min} onChange={(value) => commit({value})} />
|
|
39
|
+
</Row>
|
|
40
|
+
)}
|
|
41
|
+
{state.mode === 'random' && (
|
|
42
|
+
<Row label="">
|
|
43
|
+
<div style={{display: 'flex', gap: 6}}>
|
|
44
|
+
<NumberField value={state.min} min={props.min} onChange={(min) => commit({min})} />
|
|
45
|
+
<NumberField value={state.max} min={props.min} onChange={(max) => commit({max})} />
|
|
46
|
+
</div>
|
|
47
|
+
</Row>
|
|
48
|
+
)}
|
|
49
|
+
{state.mode === 'curve' && (
|
|
50
|
+
<div style={{marginTop: 6}}>
|
|
51
|
+
<CurveEditor
|
|
52
|
+
pieces={readPieces(props.generator)}
|
|
53
|
+
maxValue={props.curveMax}
|
|
54
|
+
onChange={(pieces) => props.onChange(buildCurve(pieces))}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|