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.
Files changed (91) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/core/binding.d.ts +43 -0
  5. package/dist/core/binding.js +82 -0
  6. package/dist/core/collision.d.ts +20 -0
  7. package/dist/core/collision.js +39 -0
  8. package/dist/core/colors.d.ts +31 -0
  9. package/dist/core/colors.js +60 -0
  10. package/dist/core/effectTree.d.ts +25 -0
  11. package/dist/core/effectTree.js +115 -0
  12. package/dist/core/groups.d.ts +12 -0
  13. package/dist/core/groups.js +27 -0
  14. package/dist/core/history.d.ts +25 -0
  15. package/dist/core/history.js +69 -0
  16. package/dist/core/loadEffect.d.ts +8 -0
  17. package/dist/core/loadEffect.js +87 -0
  18. package/dist/core/scrub.d.ts +11 -0
  19. package/dist/core/scrub.js +24 -0
  20. package/dist/core/shapes.d.ts +31 -0
  21. package/dist/core/shapes.js +98 -0
  22. package/dist/core/systems.d.ts +25 -0
  23. package/dist/core/systems.js +76 -0
  24. package/dist/core/textureExport.d.ts +9 -0
  25. package/dist/core/textureExport.js +58 -0
  26. package/dist/core/timeline.d.ts +35 -0
  27. package/dist/core/timeline.js +69 -0
  28. package/dist/core/values.d.ts +31 -0
  29. package/dist/core/values.js +81 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.js +9 -0
  32. package/dist/react/CurveEditor.d.ts +12 -0
  33. package/dist/react/CurveEditor.js +106 -0
  34. package/dist/react/EffectEditor.d.ts +22 -0
  35. package/dist/react/EffectEditor.js +30 -0
  36. package/dist/react/EffectEditorHost.d.ts +42 -0
  37. package/dist/react/EffectEditorHost.js +247 -0
  38. package/dist/react/GradientEditor.d.ts +7 -0
  39. package/dist/react/GradientEditor.js +72 -0
  40. package/dist/react/ModuleSection.d.ts +9 -0
  41. package/dist/react/ModuleSection.js +24 -0
  42. package/dist/react/PromptDialog.d.ts +14 -0
  43. package/dist/react/PromptDialog.js +45 -0
  44. package/dist/react/SubEmittersModule.d.ts +10 -0
  45. package/dist/react/SubEmittersModule.js +39 -0
  46. package/dist/react/TimelinePanel.d.ts +32 -0
  47. package/dist/react/TimelinePanel.js +225 -0
  48. package/dist/react/TimelineTrackRow.d.ts +23 -0
  49. package/dist/react/TimelineTrackRow.js +107 -0
  50. package/dist/react/ValueField.d.ts +13 -0
  51. package/dist/react/ValueField.js +19 -0
  52. package/dist/react/behaviorModules.d.ts +23 -0
  53. package/dist/react/behaviorModules.js +139 -0
  54. package/dist/react/fields.d.ts +24 -0
  55. package/dist/react/fields.js +30 -0
  56. package/dist/react/index.d.ts +20 -0
  57. package/dist/react/index.js +14 -0
  58. package/dist/react/modules.d.ts +35 -0
  59. package/dist/react/modules.js +439 -0
  60. package/dist/react/theme.d.ts +26 -0
  61. package/dist/react/theme.js +78 -0
  62. package/package.json +63 -0
  63. package/src/core/binding.ts +105 -0
  64. package/src/core/collision.ts +40 -0
  65. package/src/core/colors.ts +84 -0
  66. package/src/core/effectTree.ts +143 -0
  67. package/src/core/groups.ts +29 -0
  68. package/src/core/history.ts +77 -0
  69. package/src/core/loadEffect.ts +102 -0
  70. package/src/core/scrub.ts +28 -0
  71. package/src/core/shapes.ts +133 -0
  72. package/src/core/systems.ts +98 -0
  73. package/src/core/textureExport.ts +63 -0
  74. package/src/core/timeline.ts +94 -0
  75. package/src/core/values.ts +113 -0
  76. package/src/index.ts +32 -0
  77. package/src/react/CurveEditor.tsx +168 -0
  78. package/src/react/EffectEditor.tsx +111 -0
  79. package/src/react/EffectEditorHost.tsx +377 -0
  80. package/src/react/GradientEditor.tsx +127 -0
  81. package/src/react/ModuleSection.tsx +54 -0
  82. package/src/react/PromptDialog.tsx +85 -0
  83. package/src/react/SubEmittersModule.tsx +80 -0
  84. package/src/react/TimelinePanel.tsx +354 -0
  85. package/src/react/TimelineTrackRow.tsx +267 -0
  86. package/src/react/ValueField.tsx +60 -0
  87. package/src/react/behaviorModules.tsx +658 -0
  88. package/src/react/fields.tsx +76 -0
  89. package/src/react/index.ts +43 -0
  90. package/src/react/modules.tsx +1034 -0
  91. 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
+ }