babylon.quarks-editor 0.17.9 → 0.18.1

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.
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useRef, useState } from 'react';
3
3
  import { useVirtualizer } from '@tanstack/react-virtual';
4
- import { CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderOpenIcon, SparklesIcon, } from '@heroicons/react/24/solid';
4
+ import { CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderOpenIcon, SparklesIcon, XMarkIcon, } from '@heroicons/react/24/solid';
5
5
  import { setGalleryDragSession } from '../core/galleryDragSession';
6
6
  import { buildGalleryTree, filterGalleryTree, flattenVisibleGalleryRows, writeGalleryDrag, } from '../core/galleryTree';
7
7
  import { iconStyle } from './icons';
@@ -15,17 +15,27 @@ function endGalleryDrag() {
15
15
  setGalleryDragSession(null);
16
16
  }
17
17
  function EffectRow(props) {
18
- const { entry, depth, focused, inPreview, onFocus, onTogglePreview } = props;
18
+ const { entry, depth, focused, inPreview, onFocus, onTogglePreview, onRemoveFromScene } = props;
19
19
  const isLooping = entry.systems.some((system) => system.looping);
20
+ const parkedOnStage = entry.inScene && !inPreview;
21
+ const rowBackground = focused
22
+ ? 'rgba(120, 165, 255, 0.22)'
23
+ : inPreview
24
+ ? 'rgba(120, 165, 255, 0.1)'
25
+ : parkedOnStage
26
+ ? 'rgba(120, 165, 255, 0.04)'
27
+ : 'transparent';
20
28
  return (_jsxs("div", { style: {
21
29
  display: 'flex',
22
30
  alignItems: 'stretch',
23
31
  width: '100%',
24
32
  height: GALLERY_ROW_HEIGHT,
25
33
  borderBottom: `1px solid ${theme.border}`,
26
- background: focused ? 'rgba(120, 165, 255, 0.22)' : inPreview ? 'rgba(120, 165, 255, 0.08)' : 'transparent',
34
+ background: rowBackground,
27
35
  boxSizing: 'border-box',
28
- }, children: [entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: inPreview ? 'Remove from stage preview' : 'Preview on stage (keep playing)', style: {
36
+ }, children: [entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: inPreview
37
+ ? 'Remove from stage preview — stops group playback and clears particles; effect stays on stage'
38
+ : 'Add to stage preview — plays with Stage Play/Pause', style: {
29
39
  flexShrink: 0,
30
40
  width: 28,
31
41
  border: 'none',
@@ -40,9 +50,9 @@ function EffectRow(props) {
40
50
  }, onClick: (e) => {
41
51
  e.stopPropagation();
42
52
  onTogglePreview(entry);
43
- }, children: inPreview ? _jsx(CheckCircleIcon, { style: iconStyle(14) }) : _jsx("span", { style: { width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box' } }) })) : (_jsx("span", { style: { width: 28, flexShrink: 0, borderRight: `1px solid ${theme.border}` } })), _jsxs("button", { type: "button", draggable: true, className: "qe-hover-bg", title: entry.inScene
44
- ? `${entry.path} — in scene · Ctrl+click toggles preview`
45
- : `${entry.path} — drag into viewport`, style: {
53
+ }, children: inPreview ? (_jsx(CheckCircleIcon, { style: iconStyle(14) })) : (_jsx("span", { style: { width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box' } })) })) : (_jsx("span", { style: { width: 28, flexShrink: 0, borderRight: `1px solid ${theme.border}` } })), _jsxs("button", { type: "button", draggable: true, className: "qe-hover-bg", title: entry.inScene
54
+ ? `${entry.path} — on stage${inPreview ? ' · previewing' : ' · parked'} · Ctrl+click toggles preview`
55
+ : `${entry.path} — catalog only · drag into viewport to place`, style: {
46
56
  display: 'flex',
47
57
  alignItems: 'center',
48
58
  gap: 8,
@@ -53,7 +63,7 @@ function EffectRow(props) {
53
63
  border: 'none',
54
64
  background: 'transparent',
55
65
  color: focused ? theme.text : theme.textDim,
56
- padding: `0 12px 0 ${8 + depth * 14}px`,
66
+ padding: `0 8px 0 ${8 + depth * 14}px`,
57
67
  fontSize: 12.5,
58
68
  cursor: 'grab',
59
69
  fontFamily: theme.font,
@@ -63,7 +73,28 @@ function EffectRow(props) {
63
73
  return;
64
74
  }
65
75
  onFocus(entry);
66
- }, onDragStart: (e) => beginGalleryDrag(e, { kind: 'effect', id: entry.root.uniqueId }), onDragEnd: endGalleryDrag, children: [_jsx("span", { style: { width: 14, flexShrink: 0, opacity: entry.inScene ? 1 : 0.35, color: entry.inScene ? theme.accent : theme.textDim, display: 'inline-flex' }, children: entry.inScene ? _jsx(SparklesIcon, { style: iconStyle(14) }) : _jsx(SparklesIcon, { style: iconStyle(14) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: entry.name }), isLooping && (_jsx("span", { title: "Looping effect", style: { flexShrink: 0, fontSize: 11, color: theme.textDim }, children: "\u27F3" }))] })] }));
76
+ }, onDragStart: (e) => beginGalleryDrag(e, { kind: 'effect', id: entry.root.uniqueId }), onDragEnd: endGalleryDrag, children: [_jsx("span", { style: {
77
+ width: 14,
78
+ flexShrink: 0,
79
+ opacity: entry.inScene ? 1 : 0.35,
80
+ color: inPreview ? theme.accent : entry.inScene ? theme.text : theme.textDim,
81
+ display: 'inline-flex',
82
+ }, title: entry.inScene ? 'On stage' : 'Catalog only', children: _jsx(SparklesIcon, { style: iconStyle(14) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: entry.name }), parkedOnStage && (_jsx("span", { title: "On stage but not in preview", style: { flexShrink: 0, fontSize: 10, color: theme.textDim, letterSpacing: 0.3 }, children: "parked" })), isLooping && (_jsx("span", { title: "Looping effect", style: { flexShrink: 0, fontSize: 11, color: theme.textDim }, children: "\u27F3" }))] }), entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: "Remove from scene \u2014 returns to catalog; drag into viewport to place again", style: {
83
+ flexShrink: 0,
84
+ width: 28,
85
+ border: 'none',
86
+ borderLeft: `1px solid ${theme.border}`,
87
+ background: 'transparent',
88
+ color: theme.textDim,
89
+ cursor: 'pointer',
90
+ display: 'inline-flex',
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ padding: 0,
94
+ }, onClick: (e) => {
95
+ e.stopPropagation();
96
+ onRemoveFromScene(entry);
97
+ }, children: _jsx(XMarkIcon, { style: iconStyle(13) }) })) : null] }));
67
98
  }
68
99
  function FolderRow(props) {
69
100
  const { node, depth, effectCount, open, onToggle } = props;
@@ -98,7 +129,7 @@ function flatRowKey(row) {
98
129
  return row.kind === 'folder' ? `folder:${row.node.path}` : `effect:${row.node.entry.root.uniqueId}`;
99
130
  }
100
131
  function VirtualGalleryTree(props) {
101
- const { rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onToggleFolder } = props;
132
+ const { rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onRemoveFromScene, onToggleFolder } = props;
102
133
  const scrollRef = useRef(null);
103
134
  const virtualizer = useVirtualizer({
104
135
  count: rows.length,
@@ -123,12 +154,12 @@ function VirtualGalleryTree(props) {
123
154
  width: '100%',
124
155
  height: virtualRow.size,
125
156
  transform: `translateY(${virtualRow.start}px)`,
126
- }, children: row.kind === 'folder' ? (_jsx(FolderRow, { node: row.node, depth: row.depth, effectCount: row.effectCount, open: open, onToggle: onToggleFolder })) : (_jsx(EffectRow, { entry: row.node.entry, depth: row.depth, focused: focusedRoot === row.node.entry.root, inPreview: previewRootIds.has(row.node.entry.root.uniqueId), onFocus: onFocus, onTogglePreview: onTogglePreview })) }, flatRowKey(row)));
157
+ }, children: row.kind === 'folder' ? (_jsx(FolderRow, { node: row.node, depth: row.depth, effectCount: row.effectCount, open: open, onToggle: onToggleFolder })) : (_jsx(EffectRow, { entry: row.node.entry, depth: row.depth, focused: focusedRoot === row.node.entry.root, inPreview: previewRootIds.has(row.node.entry.root.uniqueId), onFocus: onFocus, onTogglePreview: onTogglePreview, onRemoveFromScene: onRemoveFromScene })) }, flatRowKey(row)));
127
158
  }) }) }));
128
159
  }
129
160
  /** Folder-tree catalog; import effects here and drag them into the viewport. */
130
161
  export function GalleryPanel(props) {
131
- const { entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onImportFiles, onImportFolder, onCreateDefault } = props;
162
+ const { entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onRemoveFromScene, onImportFiles, onImportFolder, onCreateDefault } = props;
132
163
  const [query, setQuery] = useState('');
133
164
  const [closedFolders, setClosedFolders] = useState(() => new Set());
134
165
  const fileInputRef = useRef(null);
@@ -192,7 +223,7 @@ export function GalleryPanel(props) {
192
223
  onImportFiles(files);
193
224
  } })] })] }), _jsx("div", { style: { fontSize: 12, color: theme.textDim, lineHeight: 1.45 }, children: isLoading
194
225
  ? `Loading… ${loading.done} / ${loading.total || '…'} files`
195
- : `${previewCount} previewing · ${inSceneCount} in scene · ${entries.length} total` }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, lineHeight: 1.4 }, children: "Preview plays on stage \u00B7 Click = edit \u00B7 Checkbox or Ctrl+click toggles preview." }), _jsx("input", { className: "qe-hover", style: inputStyle, placeholder: "Filter by name or path\u2026", value: query, onChange: (e) => setQuery(e.target.value) }), entries.length === 0 && !isLoading ? (_jsxs("div", { style: {
226
+ : `${previewCount} previewing · ${inSceneCount} on stage · ${entries.length} in catalog` }), _jsxs("div", { style: { fontSize: 11, color: theme.textDim, lineHeight: 1.45 }, children: ["\u2713 stage preview \u00B7 \u2728 on stage \u00B7 drag to place \u00B7 \u00D7 remove from stage", _jsx("br", {}), "Click = edit \u00B7 Ctrl+click = toggle preview"] }), _jsx("input", { className: "qe-hover", style: inputStyle, placeholder: "Filter by name or path\u2026", value: query, onChange: (e) => setQuery(e.target.value) }), entries.length === 0 && !isLoading ? (_jsxs("div", { style: {
196
227
  flex: 1,
197
228
  minHeight: 120,
198
229
  padding: '14px 12px',
@@ -211,5 +242,5 @@ export function GalleryPanel(props) {
211
242
  border: `1px solid ${theme.border}`,
212
243
  borderRadius: 8,
213
244
  background: theme.sectionBg,
214
- }, children: "No matches." })) : (_jsx(VirtualGalleryTree, { rows: flatRows, closedFolders: closedFolders, focusedRoot: focusedRoot, previewRootIds: previewRootIds, onFocus: onFocus, onTogglePreview: onTogglePreview, onToggleFolder: toggleFolder }))] }));
245
+ }, children: "No matches." })) : (_jsx(VirtualGalleryTree, { rows: flatRows, closedFolders: closedFolders, focusedRoot: focusedRoot, previewRootIds: previewRootIds, onFocus: onFocus, onTogglePreview: onTogglePreview, onRemoveFromScene: onRemoveFromScene, onToggleFolder: toggleFolder }))] }));
215
246
  }
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import type { BatchedRenderer } from 'babylon.quarks';
2
3
  import type { Scene } from '@babylonjs/core/scene';
3
4
  import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
5
  import type { EffectBinding } from '../core/binding';
@@ -9,6 +10,7 @@ export interface TimelinePanelProps {
9
10
  selectedNode: TransformNode | null;
10
11
  onSelectNode: (node: TransformNode | null) => void;
11
12
  scene: Scene;
13
+ renderer: BatchedRenderer;
12
14
  playbackRef: React.MutableRefObject<PlaybackState>;
13
15
  playheadRef: React.RefObject<HTMLDivElement>;
14
16
  counterRef: React.RefObject<HTMLSpanElement>;
@@ -3,7 +3,7 @@ import React, { useMemo, useRef, useState } from 'react';
3
3
  import { ArrowPathIcon, ForwardIcon, PauseIcon, PlayIcon, RectangleGroupIcon, } from '@heroicons/react/24/solid';
4
4
  import { clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from '../core/playback';
5
5
  import { groupNodes } from '../core/groups';
6
- import { scrubFocusTo } from '../core/scrub';
6
+ import { pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled } from '../core/scrub';
7
7
  import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
8
8
  import { TimelineTrackRow } from './TimelineTrackRow';
9
9
  import { iconStyle } from './icons';
@@ -29,7 +29,7 @@ function pickTickInterval(pxPerSecond) {
29
29
  * apply only to the focused effect without stopping other previews.
30
30
  */
31
31
  export function TimelinePanel(props) {
32
- const { binding, selectedNode, onSelectNode, scene, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true } = props;
32
+ const { binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true } = props;
33
33
  const [bodyWidthPx, setBodyWidthPx] = useState(600);
34
34
  const [collapsedGroups, setCollapsedGroups] = useState(() => new Set());
35
35
  const [hiddenSystems, setHiddenSystems] = useState(() => new Set());
@@ -106,12 +106,19 @@ export function TimelinePanel(props) {
106
106
  if (anyLooping) {
107
107
  shown = timelineSpan > 0 ? elapsed % timelineSpan : 0;
108
108
  }
109
- else if (elapsed >= timelineSpan || isFocusFinished(state, focusRootId)) {
109
+ else if (isFocusFinished(state, focusRootId)) {
110
+ // Park at real simulation time — core may finish before the ruler's worst-case span.
111
+ shown = elapsed;
112
+ }
113
+ else if (elapsed >= timelineSpan) {
110
114
  shown = timelineSpan;
111
- const justFinishedPlaying = !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan && elapsed >= timelineSpan;
115
+ const justFinishedPlaying = !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan;
112
116
  if (justFinishedPlaying) {
117
+ scrubFocusToSettled(binding, renderer, timelineSpan);
113
118
  markFocusFinished(state, focusRootId);
114
119
  setFocusElapsed(state, focusRootId, timelineSpan);
120
+ pauseFocus(binding);
121
+ scene.render();
115
122
  }
116
123
  }
117
124
  prevElapsedRef.current = elapsed;
@@ -122,7 +129,7 @@ export function TimelinePanel(props) {
122
129
  };
123
130
  raf = requestAnimationFrame(tick);
124
131
  return () => cancelAnimationFrame(raf);
125
- }, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled]);
132
+ }, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled, binding, scene, renderer]);
126
133
  const visibleRows = useMemo(() => {
127
134
  let hideUntilDepth = null;
128
135
  const visible = rows.filter((row) => {
@@ -156,17 +163,24 @@ export function TimelinePanel(props) {
156
163
  return Math.min(timelineSpan, Math.max(0, x / pxPerSecond));
157
164
  };
158
165
  const applyScrub = (time) => {
159
- scrubFocusTo(binding, time);
160
166
  const state = playbackRef.current;
161
- setFocusElapsed(state, focusRootId, time);
162
- if (time < timelineSpan) {
163
- clearFocusFinished(state, focusRootId);
164
- }
165
- else if (!anyLooping) {
167
+ const atEnd = !anyLooping && time >= timelineSpan;
168
+ const shown = atEnd ? timelineSpan : time;
169
+ if (atEnd) {
170
+ scrubFocusToSettled(binding, renderer, timelineSpan);
166
171
  markFocusFinished(state, focusRootId);
172
+ pauseFocus(binding);
167
173
  }
174
+ else {
175
+ scrubFocusTo(binding, renderer, time);
176
+ clearFocusFinished(state, focusRootId);
177
+ if (stagePlaying) {
178
+ playFocus(binding);
179
+ }
180
+ }
181
+ setFocusElapsed(state, focusRootId, shown);
168
182
  if (playheadRef.current) {
169
- playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + time * pxPerSecond}px`;
183
+ playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + shown * pxPerSecond}px`;
170
184
  }
171
185
  scene.render();
172
186
  };
@@ -182,10 +196,13 @@ export function TimelinePanel(props) {
182
196
  }
183
197
  };
184
198
  const restartFocus = () => {
185
- scrubFocusTo(binding, 0);
199
+ scrubFocusTo(binding, renderer, 0);
186
200
  const state = playbackRef.current;
187
201
  setFocusElapsed(state, focusRootId, 0);
188
202
  clearFocusFinished(state, focusRootId);
203
+ if (stagePlaying) {
204
+ playFocus(binding);
205
+ }
189
206
  if (playheadRef.current) {
190
207
  playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET}px`;
191
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks-editor",
3
- "version": "0.17.9",
3
+ "version": "0.18.1",
4
4
  "description": "Shuriken-style effect editor for babylon.quarks: headless edit-model core + React module-stack UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,5 @@
1
1
  import type {GalleryEntry} from './loadEffectGallery';
2
+ import {QuarksUtil} from 'babylon.quarks';
2
3
 
3
4
  function isPreviewEntry(entry: GalleryEntry, previewRootIds: ReadonlySet<number>): boolean {
4
5
  return entry.inScene && previewRootIds.has(entry.root.uniqueId);
@@ -8,7 +9,8 @@ function isPreviewEntry(entry: GalleryEntry, previewRootIds: ReadonlySet<number>
8
9
  export function applyGalleryPlayback(
9
10
  entries: GalleryEntry[],
10
11
  previewRootIds: ReadonlySet<number>,
11
- transportPlaying: boolean
12
+ transportPlaying: boolean,
13
+ focusFinishedRootIds: ReadonlySet<number> = new Set()
12
14
  ): void {
13
15
  for (const entry of entries) {
14
16
  for (const system of entry.systems) {
@@ -24,6 +26,9 @@ export function applyGalleryPlayback(
24
26
  if (!isPreviewEntry(entry, previewRootIds)) {
25
27
  continue;
26
28
  }
29
+ if (focusFinishedRootIds.has(entry.root.uniqueId)) {
30
+ continue;
31
+ }
27
32
  for (const system of entry.systems) {
28
33
  system.play();
29
34
  }
@@ -53,3 +58,33 @@ export function countGalleryParticles(entries: GalleryEntry[], previewRootIds: R
53
58
  }
54
59
  return count;
55
60
  }
61
+
62
+ /**
63
+ * Pauses one-shot preview effects whose particle systems report `isFinished()`.
64
+ * Returns true when any entry was paused (caller should refresh renderer batches).
65
+ */
66
+ export function pauseFinishedPreviews(
67
+ entries: GalleryEntry[],
68
+ previewRootIds: ReadonlySet<number>,
69
+ focusFinishedRootIds: ReadonlySet<number> = new Set()
70
+ ): boolean {
71
+ let pausedAny = false;
72
+ for (const entry of entries) {
73
+ if (!isPreviewEntry(entry, previewRootIds)) {
74
+ continue;
75
+ }
76
+ if (focusFinishedRootIds.has(entry.root.uniqueId)) {
77
+ continue;
78
+ }
79
+ if (!QuarksUtil.isEffectFinished(entry.root)) {
80
+ continue;
81
+ }
82
+ for (const system of entry.systems) {
83
+ if (!system.paused) {
84
+ system.pause();
85
+ pausedAny = true;
86
+ }
87
+ }
88
+ }
89
+ return pausedAny;
90
+ }
@@ -1,9 +1,19 @@
1
1
  import {Vector3} from '@babylonjs/core/Maths/math.vector';
2
2
  import type {Scene} from '@babylonjs/core/scene';
3
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
3
4
  import type {BatchedRenderer} from 'babylon.quarks';
4
5
  import type {GalleryEntry} from './loadEffectGallery';
5
6
  import {DEFAULT_GALLERY_SPACING, galleryGridPosition} from './galleryLayout';
6
7
 
8
+ /** Clears live particles and freezes simulation for one placed catalog entry. */
9
+ export function clearGalleryEntrySimulation(entry: GalleryEntry, renderer: BatchedRenderer): void {
10
+ for (const system of entry.systems) {
11
+ system.restart();
12
+ system.pause();
13
+ }
14
+ renderer.refreshBatches();
15
+ }
16
+
7
17
  /** Parents a catalog entry into the live scene and registers its systems with the renderer. */
8
18
  export function placeGalleryEntryInScene(
9
19
  entry: GalleryEntry,
@@ -37,6 +47,30 @@ export function placeGalleryEntriesAt(
37
47
  });
38
48
  }
39
49
 
50
+ /**
51
+ * Unregisters a placed effect from the renderer and returns it to the hidden catalog.
52
+ * Systems stay alive for inspector editing; drag into the viewport to place again.
53
+ */
54
+ export function removeGalleryEntryFromScene(
55
+ entry: GalleryEntry,
56
+ renderer: BatchedRenderer,
57
+ catalogRoot: TransformNode
58
+ ): void {
59
+ if (!entry.inScene) {
60
+ return;
61
+ }
62
+
63
+ clearGalleryEntrySimulation(entry, renderer);
64
+
65
+ for (const system of entry.systems) {
66
+ renderer.deleteSystem(system);
67
+ }
68
+
69
+ entry.root.parent = catalogRoot;
70
+ entry.root.setEnabled(false);
71
+ entry.inScene = false;
72
+ }
73
+
40
74
  /** Raycasts the editor ground plane from canvas/client coordinates. */
41
75
  export function pickGroundPosition(scene: Scene, clientX: number, clientY: number): Vector3 {
42
76
  const canvas = scene.getEngine().getRenderingCanvas();
@@ -0,0 +1,46 @@
1
+ import type {BatchedRenderer} from 'babylon.quarks';
2
+
3
+ const STORAGE_KEY = 'quarksDebugRender';
4
+
5
+ /** Whether GPU/particle mismatch logging is enabled (localStorage quarksDebugRender=1). */
6
+ export function isRenderDebugEnabled(): boolean {
7
+ try {
8
+ return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ let lastMismatchKey = '';
15
+
16
+ /**
17
+ * Logs when live particle count is zero but a batch still reports GPU instances.
18
+ * Enable with: localStorage.setItem('quarksDebugRender', '1')
19
+ */
20
+ export function logRenderMismatch(renderer: BatchedRenderer, liveParticleCount: number): void {
21
+ if (!isRenderDebugEnabled()) {
22
+ return;
23
+ }
24
+ const stats = renderer.getBatchRenderStats();
25
+ const instanceCount = stats.reduce((sum, row) => sum + row.instanceCount, 0);
26
+ const batchParticleCount = stats.reduce((sum, row) => sum + row.particleCount, 0);
27
+ if (liveParticleCount !== 0 && instanceCount === liveParticleCount) {
28
+ lastMismatchKey = '';
29
+ return;
30
+ }
31
+ if (liveParticleCount === 0 && instanceCount === 0 && batchParticleCount === 0) {
32
+ lastMismatchKey = '';
33
+ return;
34
+ }
35
+ const key = `${liveParticleCount}|${instanceCount}|${batchParticleCount}|${stats.map((row) => `${row.batchIndex}:${row.instanceCount}/${row.particleCount}`).join(';')}`;
36
+ if (key === lastMismatchKey) {
37
+ return;
38
+ }
39
+ lastMismatchKey = key;
40
+ console.warn('[quarks render debug] particle vs GPU instance mismatch', {
41
+ liveParticleCount,
42
+ batchParticleCount,
43
+ instanceCount,
44
+ batches: stats,
45
+ });
46
+ }
package/src/core/scrub.ts CHANGED
@@ -7,17 +7,32 @@ function bindingSystems(binding: EffectBinding): ParticleSystem[] {
7
7
  return [binding.system, ...binding.subSystems];
8
8
  }
9
9
 
10
+ /** Unpauses every system in the focused effect. */
11
+ export function playFocus(binding: EffectBinding): void {
12
+ for (const system of bindingSystems(binding)) {
13
+ system.play();
14
+ }
15
+ }
16
+
17
+ /** Freezes the focused effect at its current simulation state. */
18
+ export function pauseFocus(binding: EffectBinding): void {
19
+ for (const system of bindingSystems(binding)) {
20
+ system.pause();
21
+ }
22
+ }
23
+
10
24
  /**
11
25
  * Scrub only the focused effect's systems — does not advance other preview effects
12
26
  * via the shared BatchedRenderer.update loop.
13
27
  */
14
- export function scrubFocusTo(binding: EffectBinding, time: number): void {
28
+ export function scrubFocusTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
15
29
  const systems = bindingSystems(binding);
16
30
  for (const system of systems) {
17
31
  system.restart();
18
32
  system.play();
19
33
  }
20
34
  if (time <= 0) {
35
+ renderer.refreshBatches();
21
36
  return;
22
37
  }
23
38
  const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
@@ -33,6 +48,33 @@ export function scrubFocusTo(binding: EffectBinding, time: number): void {
33
48
  system.update(remainder);
34
49
  }
35
50
  }
51
+ renderer.refreshBatches();
52
+ }
53
+
54
+ /**
55
+ * Scrub to `time`, then keep simulating the focused effect until every particle has died.
56
+ * Used when parking a one-shot at the timeline end so burst tails do not linger on screen.
57
+ */
58
+ export function scrubFocusToSettled(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
59
+ scrubFocusTo(binding, renderer, time);
60
+ const systems = bindingSystems(binding);
61
+ const step = 1 / 60;
62
+ const maxExtra = 2;
63
+ let extra = 0;
64
+ while (extra < maxExtra) {
65
+ let alive = 0;
66
+ for (const system of systems) {
67
+ alive += system.particleNum;
68
+ }
69
+ if (alive === 0) {
70
+ break;
71
+ }
72
+ for (const system of systems) {
73
+ system.update(step);
74
+ }
75
+ extra += step;
76
+ }
77
+ renderer.refreshBatches();
36
78
  }
37
79
 
38
80
  /**
@@ -46,6 +88,7 @@ export function scrubFocusTo(binding: EffectBinding, time: number): void {
46
88
  export function scrubTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
47
89
  binding.restart();
48
90
  if (time <= 0) {
91
+ renderer.refreshBatches();
49
92
  return;
50
93
  }
51
94
  const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
@@ -83,8 +83,36 @@ export function computeTimelineRows(binding: EffectBinding): TimelineRow[] {
83
83
  return rows;
84
84
  }
85
85
 
86
+ /** Upper bound on startLife for timeline span (burst/one-shot tail). */
87
+ function estimateMaxStartLife(system: ParticleSystem): number {
88
+ const life = system.startLife;
89
+ if (life instanceof ConstantValue) {
90
+ return Math.max(0, life.value);
91
+ }
92
+ if (life instanceof IntervalValue) {
93
+ return Math.max(life.a, life.b);
94
+ }
95
+ return 1;
96
+ }
97
+
98
+ /** When emission is burst-only, the effect window ends at the last burst time, not full duration. */
99
+ function estimateEmissionEnd(system: ParticleSystem): number {
100
+ const rate =
101
+ system.emissionOverTime instanceof ConstantValue ? system.emissionOverTime.value : null;
102
+ const perMeter =
103
+ system.emissionOverDistance instanceof ConstantValue ? system.emissionOverDistance.value : null;
104
+ if ((rate != null && rate > 0) || (perMeter != null && perMeter > 0)) {
105
+ return system.duration;
106
+ }
107
+ if (system.emissionBursts.length === 0) {
108
+ return system.duration;
109
+ }
110
+ return Math.max(...system.emissionBursts.map((burst) => burst.time));
111
+ }
112
+
86
113
  /**
87
- * Overall timeline ruler span — emission window per track (`startOffset + duration`).
114
+ * Overall timeline ruler span — worst-case visual tail per track (max startLife, last burst).
115
+ * Actual playback often ends earlier when interval lives sample short or tracks finish out of phase.
88
116
  */
89
117
  export function computeTimelineSpan(rows: TimelineRow[]): number {
90
118
  let span = 0;
@@ -92,7 +120,7 @@ export function computeTimelineSpan(rows: TimelineRow[]): number {
92
120
  if (row.kind !== 'track') {
93
121
  continue;
94
122
  }
95
- span = Math.max(span, row.startOffset + row.duration);
123
+ span = Math.max(span, row.startOffset + estimateEmissionEnd(row.system) + estimateMaxStartLife(row.system));
96
124
  }
97
125
  return Math.max(span, MIN_TIMELINE_SPAN);
98
126
  }