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.
@@ -25,21 +25,30 @@ import {GridMaterial} from '@babylonjs/materials/grid/gridMaterial';
25
25
  import {
26
26
  BatchedRenderer,
27
27
  ParticleSystem,
28
+ QuarksUtil,
28
29
  } from 'babylon.quarks';
29
30
  import {EffectBinding} from '../core/binding';
30
31
  import {EffectHistory} from '../core/history';
31
32
  import {ensureGroundResolver} from '../core/collision';
32
33
  import {createGalleryDefaultEntry} from '../core/defaultEffect';
33
34
  import {loadEffectFromJson, disposeLoadedEffect, parseEffectFromJson} from '../core/loadEffect';
34
- import {applyGalleryPlayback, countActivePreviews, countGalleryParticles, hasActivePreview} from '../core/galleryPlayback';
35
- import {createPlaybackState, getFocusElapsed, isFocusFinished, setFocusElapsed, type PlaybackState} from '../core/playback';
35
+ import {applyGalleryPlayback, countActivePreviews, countGalleryParticles, hasActivePreview, pauseFinishedPreviews} from '../core/galleryPlayback';
36
+ import {createPlaybackState, clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, type PlaybackState} from '../core/playback';
37
+ import {pauseFocus, scrubFocusToSettled} from '../core/scrub';
38
+ import {logRenderMismatch} from '../core/renderDebug';
36
39
  import {cancelGalleryCameraFocus, focusGalleryEntry} from '../core/galleryLayout';
37
40
  import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
38
41
  import {GalleryDropMarker} from '../core/galleryDropMarker';
39
42
  import {GallerySelectionMarker} from '../core/gallerySelectionMarker';
40
43
  import {EmitterShapeWireframes} from '../core/emitterShapeWireframe';
41
44
  import {getGalleryDragSession, setGalleryDragSession} from '../core/galleryDragSession';
42
- import {pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene} from '../core/galleryScene';
45
+ import {
46
+ clearGalleryEntrySimulation,
47
+ pickGroundPosition,
48
+ placeGalleryEntriesAt,
49
+ placeGalleryEntryInScene,
50
+ removeGalleryEntryFromScene,
51
+ } from '../core/galleryScene';
43
52
  import {describeGalleryDrag, GALLERY_DRAG_MIME, readGalleryDrag} from '../core/galleryTree';
44
53
  import {loadEffectGallery, collectJsonFiles, bindingFromGalleryEntry, mergeGalleryEntries, type GalleryEntry, type GalleryLoadProgress} from '../core/loadEffectGallery';
45
54
  import {pickGalleryJsonFiles} from '../core/pickGalleryFiles';
@@ -135,6 +144,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
135
144
  const importGalleryRef = useRef<(files: FileList | File[]) => Promise<void>>(async () => {});
136
145
  const selectGalleryEntryRef = useRef<(entry: GalleryEntry) => void>(() => {});
137
146
  const toggleGalleryPreviewRef = useRef<(entry: GalleryEntry) => void>(() => {});
147
+ const removeGalleryFromSceneRef = useRef<(entry: GalleryEntry) => void>(() => {});
138
148
  const placeGalleryDropRef = useRef<(clientX: number, clientY: number, dataTransfer?: DataTransfer) => void>(() => {});
139
149
  const createGalleryDefaultRef = useRef<() => void>(() => {});
140
150
  const dropMarkerRef = useRef<GalleryDropMarker | null>(null);
@@ -352,12 +362,14 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
352
362
  return;
353
363
  }
354
364
  const id = entry.root.uniqueId;
365
+ const removing = (stateRef.current?.previewRootIds ?? []).includes(id);
355
366
  setPreviewRootIds((prev) => {
356
367
  const next = new Set(prev);
357
368
  if (next.has(id)) {
358
369
  next.delete(id);
359
370
  } else {
360
371
  next.add(id);
372
+ clearFocusFinished(playbackRef.current, id);
361
373
  }
362
374
  const ids = [...next];
363
375
  if (stateRef.current) {
@@ -365,6 +377,47 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
365
377
  }
366
378
  return ids;
367
379
  });
380
+ if (removing) {
381
+ clearGalleryEntrySimulation(entry, renderer);
382
+ clearFocusFinished(playbackRef.current, id);
383
+ }
384
+ renderer.refreshBatches();
385
+ scene.render();
386
+ force();
387
+ };
388
+
389
+ const removeGalleryFromScene = (entry: GalleryEntry) => {
390
+ if (!entry.inScene) {
391
+ return;
392
+ }
393
+ const id = entry.root.uniqueId;
394
+ removeGalleryEntryFromScene(entry, renderer, galleryRoot);
395
+ setPreviewRootIds((prev) => {
396
+ const next = prev.filter((rootId) => rootId !== id);
397
+ if (stateRef.current) {
398
+ stateRef.current.previewRootIds = next;
399
+ }
400
+ return next;
401
+ });
402
+ clearFocusFinished(playbackRef.current, id);
403
+ playbackRef.current.focusElapsedByRoot.delete(id);
404
+ galleryEntries = [...galleryEntries];
405
+ if (stateRef.current) {
406
+ stateRef.current.galleryEntries = galleryEntries;
407
+ }
408
+ setGallery(galleryEntries);
409
+ if (stateRef.current?.binding?.root === entry.root) {
410
+ selectionMarker.hide();
411
+ }
412
+ applyGalleryPlayback(
413
+ galleryEntries,
414
+ new Set(stateRef.current?.previewRootIds ?? []),
415
+ playbackRef.current.stagePlaying,
416
+ playbackRef.current.focusFinishedByRoot
417
+ );
418
+ renderer.refreshBatches();
419
+ scene.render();
420
+ force();
368
421
  };
369
422
 
370
423
  const addGalleryPreview = (...entries: GalleryEntry[]) => {
@@ -503,6 +556,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
503
556
  importGalleryRef.current = appendGalleryFromFiles;
504
557
  selectGalleryEntryRef.current = focusCatalogEntry;
505
558
  toggleGalleryPreviewRef.current = toggleGalleryPreview;
559
+ removeGalleryFromSceneRef.current = removeGalleryFromScene;
506
560
  placeGalleryDropRef.current = placeGalleryDropAt;
507
561
  createGalleryDefaultRef.current = appendGalleryDefault;
508
562
 
@@ -550,11 +604,41 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
550
604
  hasActivePreview(snapshot.galleryEntries, previewIds) && (playback.stagePlaying || stepQueued);
551
605
  if (simulate) {
552
606
  renderer.update(delta);
553
- const focusId = playback.focusRootId ?? snapshot.binding?.root.uniqueId ?? null;
554
- if (focusId != null && playback.stagePlaying && !playback.focusFinishedByRoot.has(focusId)) {
607
+ let needsRefresh = false;
608
+ if (pauseFinishedPreviews(snapshot.galleryEntries, previewIds, playback.focusFinishedByRoot)) {
609
+ needsRefresh = true;
610
+ }
611
+ const binding = snapshot.binding;
612
+ const focusId = playback.focusRootId ?? binding?.root.uniqueId ?? null;
613
+ if (
614
+ focusId != null &&
615
+ binding &&
616
+ playback.stagePlaying &&
617
+ !playback.focusFinishedByRoot.has(focusId) &&
618
+ !focusTimelineMetaRef.current.anyLooping &&
619
+ QuarksUtil.isEffectFinished(binding.root)
620
+ ) {
621
+ const settleAt = getFocusElapsed(playback, focusId);
622
+ scrubFocusToSettled(binding, renderer, settleAt);
623
+ markFocusFinished(playback, focusId);
624
+ pauseFocus(binding);
625
+ needsRefresh = true;
626
+ } else if (
627
+ focusId != null &&
628
+ playback.stagePlaying &&
629
+ !playback.focusFinishedByRoot.has(focusId)
630
+ ) {
555
631
  const prev = getFocusElapsed(playback, focusId);
556
632
  setFocusElapsed(playback, focusId, prev + delta);
557
633
  }
634
+ if (needsRefresh) {
635
+ renderer.refreshBatches();
636
+ }
637
+ if (snapshot.galleryEntries.length > 0) {
638
+ logRenderMismatch(renderer, countGalleryParticles(snapshot.galleryEntries, previewIds));
639
+ }
640
+ } else if (snapshot && snapshot.galleryEntries.length > 0) {
641
+ renderer.refreshBatches();
558
642
  }
559
643
  }
560
644
  }
@@ -606,7 +690,9 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
606
690
  const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
607
691
  const timeLabel = meta.anyLooping
608
692
  ? `${focusElapsed.toFixed(1)}s ⟳`
609
- : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
693
+ : focusEnded
694
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
695
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
610
696
  counterRef.current.textContent = `${playing} preview · ${stageLabel}${focusLabel} · ${timeLabel} · ${count} particles · ${inScene}/${snapshot.galleryEntries.length} in catalog`;
611
697
  }
612
698
  } else if (binding) {
@@ -620,7 +706,9 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
620
706
  const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
621
707
  const timeLabel = meta.anyLooping
622
708
  ? `${focusElapsed.toFixed(1)}s ⟳`
623
- : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
709
+ : focusEnded
710
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
711
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
624
712
  counterRef.current.textContent = `${stageLabel} · ${timeLabel} · ${count} particles`;
625
713
  }
626
714
  }
@@ -678,7 +766,15 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
678
766
  if (!isCatalogEntry) {
679
767
  return;
680
768
  }
681
- applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying);
769
+ applyGalleryPlayback(
770
+ snap.galleryEntries,
771
+ new Set(previewRootIds),
772
+ stagePlaying,
773
+ playbackRef.current.focusFinishedByRoot
774
+ );
775
+ if (!stagePlaying && stateRef.current?.renderer) {
776
+ stateRef.current.renderer.refreshBatches();
777
+ }
682
778
  }, [stagePlaying, previewRootIds, gallery]);
683
779
 
684
780
  useEffect(() => {
@@ -875,7 +971,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
875
971
  Gallery{gallery.length > 0 ? ` · ${gallery.length}` : ''}
876
972
  </div>
877
973
  <div style={{fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4}}>
878
- Drag into the viewport to place effects.
974
+ Drag into viewport to place · ✓ preview · × remove from stage
879
975
  </div>
880
976
  </div>
881
977
  <div style={{flex: 1, minHeight: 0, overflow: 'hidden', padding: '8px 12px 14px', display: 'flex', flexDirection: 'column'}}>
@@ -887,6 +983,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
887
983
  folderInputRef={galleryFolderInputRef}
888
984
  onFocus={(entry) => selectGalleryEntryRef.current(entry)}
889
985
  onTogglePreview={(entry) => toggleGalleryPreviewRef.current(entry)}
986
+ onRemoveFromScene={(entry) => removeGalleryFromSceneRef.current(entry)}
890
987
  onImportFiles={(files) => void runGalleryImport(files)}
891
988
  onImportFolder={() => void handleImportFolder()}
892
989
  onCreateDefault={() => createGalleryDefaultRef.current()}
@@ -1134,6 +1231,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
1134
1231
  selectedNode={selectedNode}
1135
1232
  onSelectNode={setSelectedNode}
1136
1233
  scene={state.scene}
1234
+ renderer={state.renderer}
1137
1235
  playbackRef={playbackRef}
1138
1236
  playheadRef={playheadRef}
1139
1237
  counterRef={counterRef}
@@ -7,6 +7,7 @@ import {
7
7
  FolderIcon,
8
8
  FolderOpenIcon,
9
9
  SparklesIcon,
10
+ XMarkIcon,
10
11
  } from '@heroicons/react/24/solid';
11
12
  import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
12
13
  import type {GalleryEntry, GalleryLoadProgress} from '../core/loadEffectGallery';
@@ -34,6 +35,7 @@ export interface GalleryPanelProps {
34
35
  folderInputRef: React.RefObject<HTMLInputElement | null>;
35
36
  onFocus: (entry: GalleryEntry) => void;
36
37
  onTogglePreview: (entry: GalleryEntry) => void;
38
+ onRemoveFromScene: (entry: GalleryEntry) => void;
37
39
  onImportFiles: (files: FileList | File[]) => void;
38
40
  onImportFolder: () => void;
39
41
  onCreateDefault: () => void;
@@ -55,9 +57,18 @@ function EffectRow(props: {
55
57
  inPreview: boolean;
56
58
  onFocus: (entry: GalleryEntry) => void;
57
59
  onTogglePreview: (entry: GalleryEntry) => void;
60
+ onRemoveFromScene: (entry: GalleryEntry) => void;
58
61
  }): React.ReactElement {
59
- const {entry, depth, focused, inPreview, onFocus, onTogglePreview} = props;
62
+ const {entry, depth, focused, inPreview, onFocus, onTogglePreview, onRemoveFromScene} = props;
60
63
  const isLooping = entry.systems.some((system) => system.looping);
64
+ const parkedOnStage = entry.inScene && !inPreview;
65
+ const rowBackground = focused
66
+ ? 'rgba(120, 165, 255, 0.22)'
67
+ : inPreview
68
+ ? 'rgba(120, 165, 255, 0.1)'
69
+ : parkedOnStage
70
+ ? 'rgba(120, 165, 255, 0.04)'
71
+ : 'transparent';
61
72
  return (
62
73
  <div
63
74
  style={{
@@ -66,7 +77,7 @@ function EffectRow(props: {
66
77
  width: '100%',
67
78
  height: GALLERY_ROW_HEIGHT,
68
79
  borderBottom: `1px solid ${theme.border}`,
69
- background: focused ? 'rgba(120, 165, 255, 0.22)' : inPreview ? 'rgba(120, 165, 255, 0.08)' : 'transparent',
80
+ background: rowBackground,
70
81
  boxSizing: 'border-box',
71
82
  }}
72
83
  >
@@ -74,7 +85,11 @@ function EffectRow(props: {
74
85
  <button
75
86
  type="button"
76
87
  className="qe-hover"
77
- title={inPreview ? 'Remove from stage preview' : 'Preview on stage (keep playing)'}
88
+ title={
89
+ inPreview
90
+ ? 'Remove from stage preview — stops group playback and clears particles; effect stays on stage'
91
+ : 'Add to stage preview — plays with Stage Play/Pause'
92
+ }
78
93
  style={{
79
94
  flexShrink: 0,
80
95
  width: 28,
@@ -93,7 +108,11 @@ function EffectRow(props: {
93
108
  onTogglePreview(entry);
94
109
  }}
95
110
  >
96
- {inPreview ? <CheckCircleIcon style={iconStyle(14)} /> : <span style={{width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box'}} />}
111
+ {inPreview ? (
112
+ <CheckCircleIcon style={iconStyle(14)} />
113
+ ) : (
114
+ <span style={{width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box'}} />
115
+ )}
97
116
  </button>
98
117
  ) : (
99
118
  <span style={{width: 28, flexShrink: 0, borderRight: `1px solid ${theme.border}`}} />
@@ -104,8 +123,8 @@ function EffectRow(props: {
104
123
  className="qe-hover-bg"
105
124
  title={
106
125
  entry.inScene
107
- ? `${entry.path} — in scene · Ctrl+click toggles preview`
108
- : `${entry.path} — drag into viewport`
126
+ ? `${entry.path} — on stage${inPreview ? ' · previewing' : ' · parked'} · Ctrl+click toggles preview`
127
+ : `${entry.path} — catalog only · drag into viewport to place`
109
128
  }
110
129
  style={{
111
130
  display: 'flex',
@@ -118,7 +137,7 @@ function EffectRow(props: {
118
137
  border: 'none',
119
138
  background: 'transparent',
120
139
  color: focused ? theme.text : theme.textDim,
121
- padding: `0 12px 0 ${8 + depth * 14}px`,
140
+ padding: `0 8px 0 ${8 + depth * 14}px`,
122
141
  fontSize: 12.5,
123
142
  cursor: 'grab',
124
143
  fontFamily: theme.font,
@@ -133,16 +152,56 @@ function EffectRow(props: {
133
152
  onDragStart={(e) => beginGalleryDrag(e, {kind: 'effect', id: entry.root.uniqueId})}
134
153
  onDragEnd={endGalleryDrag}
135
154
  >
136
- <span style={{width: 14, flexShrink: 0, opacity: entry.inScene ? 1 : 0.35, color: entry.inScene ? theme.accent : theme.textDim, display: 'inline-flex'}}>
137
- {entry.inScene ? <SparklesIcon style={iconStyle(14)} /> : <SparklesIcon style={iconStyle(14)} />}
155
+ <span
156
+ style={{
157
+ width: 14,
158
+ flexShrink: 0,
159
+ opacity: entry.inScene ? 1 : 0.35,
160
+ color: inPreview ? theme.accent : entry.inScene ? theme.text : theme.textDim,
161
+ display: 'inline-flex',
162
+ }}
163
+ title={entry.inScene ? 'On stage' : 'Catalog only'}
164
+ >
165
+ <SparklesIcon style={iconStyle(14)} />
138
166
  </span>
139
167
  <span style={{overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>{entry.name}</span>
168
+ {parkedOnStage && (
169
+ <span title="On stage but not in preview" style={{flexShrink: 0, fontSize: 10, color: theme.textDim, letterSpacing: 0.3}}>
170
+ parked
171
+ </span>
172
+ )}
140
173
  {isLooping && (
141
174
  <span title="Looping effect" style={{flexShrink: 0, fontSize: 11, color: theme.textDim}}>
142
175
  ⟳
143
176
  </span>
144
177
  )}
145
178
  </button>
179
+ {entry.inScene ? (
180
+ <button
181
+ type="button"
182
+ className="qe-hover"
183
+ title="Remove from scene — returns to catalog; drag into viewport to place again"
184
+ style={{
185
+ flexShrink: 0,
186
+ width: 28,
187
+ border: 'none',
188
+ borderLeft: `1px solid ${theme.border}`,
189
+ background: 'transparent',
190
+ color: theme.textDim,
191
+ cursor: 'pointer',
192
+ display: 'inline-flex',
193
+ alignItems: 'center',
194
+ justifyContent: 'center',
195
+ padding: 0,
196
+ }}
197
+ onClick={(e) => {
198
+ e.stopPropagation();
199
+ onRemoveFromScene(entry);
200
+ }}
201
+ >
202
+ <XMarkIcon style={iconStyle(13)} />
203
+ </button>
204
+ ) : null}
146
205
  </div>
147
206
  );
148
207
  }
@@ -218,9 +277,10 @@ function VirtualGalleryTree(props: {
218
277
  previewRootIds: ReadonlySet<number>;
219
278
  onFocus: (entry: GalleryEntry) => void;
220
279
  onTogglePreview: (entry: GalleryEntry) => void;
280
+ onRemoveFromScene: (entry: GalleryEntry) => void;
221
281
  onToggleFolder: (path: string) => void;
222
282
  }): React.ReactElement {
223
- const {rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onToggleFolder} = props;
283
+ const {rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onRemoveFromScene, onToggleFolder} = props;
224
284
  const scrollRef = useRef<HTMLDivElement>(null);
225
285
  const virtualizer = useVirtualizer({
226
286
  count: rows.length,
@@ -273,6 +333,7 @@ function VirtualGalleryTree(props: {
273
333
  inPreview={previewRootIds.has(row.node.entry.root.uniqueId)}
274
334
  onFocus={onFocus}
275
335
  onTogglePreview={onTogglePreview}
336
+ onRemoveFromScene={onRemoveFromScene}
276
337
  />
277
338
  )}
278
339
  </div>
@@ -285,7 +346,7 @@ function VirtualGalleryTree(props: {
285
346
 
286
347
  /** Folder-tree catalog; import effects here and drag them into the viewport. */
287
348
  export function GalleryPanel(props: GalleryPanelProps): React.ReactElement {
288
- const {entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onImportFiles, onImportFolder, onCreateDefault} = props;
349
+ const {entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onRemoveFromScene, onImportFiles, onImportFolder, onCreateDefault} = props;
289
350
  const [query, setQuery] = useState('');
290
351
  const [closedFolders, setClosedFolders] = useState<Set<string>>(() => new Set());
291
352
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -394,10 +455,12 @@ export function GalleryPanel(props: GalleryPanelProps): React.ReactElement {
394
455
  <div style={{fontSize: 12, color: theme.textDim, lineHeight: 1.45}}>
395
456
  {isLoading
396
457
  ? `Loading… ${loading.done} / ${loading.total || '…'} files`
397
- : `${previewCount} previewing · ${inSceneCount} in scene · ${entries.length} total`}
458
+ : `${previewCount} previewing · ${inSceneCount} on stage · ${entries.length} in catalog`}
398
459
  </div>
399
- <div style={{fontSize: 11, color: theme.textDim, lineHeight: 1.4}}>
400
- Preview plays on stage · Click = edit · Checkbox or Ctrl+click toggles preview.
460
+ <div style={{fontSize: 11, color: theme.textDim, lineHeight: 1.45}}>
461
+ ✓ stage preview · ✨ on stage · drag to place · × remove from stage
462
+ <br />
463
+ Click = edit · Ctrl+click = toggle preview
401
464
  </div>
402
465
  <input
403
466
  className="qe-hover"
@@ -445,6 +508,7 @@ export function GalleryPanel(props: GalleryPanelProps): React.ReactElement {
445
508
  previewRootIds={previewRootIds}
446
509
  onFocus={onFocus}
447
510
  onTogglePreview={onTogglePreview}
511
+ onRemoveFromScene={onRemoveFromScene}
448
512
  onToggleFolder={toggleFolder}
449
513
  />
450
514
  )}
@@ -6,7 +6,7 @@ import {
6
6
  PlayIcon,
7
7
  RectangleGroupIcon,
8
8
  } from '@heroicons/react/24/solid';
9
- import type {ParticleSystem} from 'babylon.quarks';
9
+ import type {ParticleSystem, BatchedRenderer} from 'babylon.quarks';
10
10
  import type {Scene} from '@babylonjs/core/scene';
11
11
  import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
12
12
  import type {EffectBinding} from '../core/binding';
@@ -19,7 +19,7 @@ import {
19
19
  type PlaybackState,
20
20
  } from '../core/playback';
21
21
  import {groupNodes} from '../core/groups';
22
- import {scrubFocusTo} from '../core/scrub';
22
+ import {pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled} from '../core/scrub';
23
23
  import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
24
24
  import {TimelineTrackRow} from './TimelineTrackRow';
25
25
  import {iconStyle} from './icons';
@@ -32,6 +32,7 @@ export interface TimelinePanelProps {
32
32
  selectedNode: TransformNode | null;
33
33
  onSelectNode: (node: TransformNode | null) => void;
34
34
  scene: Scene;
35
+ renderer: BatchedRenderer;
35
36
  playbackRef: React.MutableRefObject<PlaybackState>;
36
37
  playheadRef: React.RefObject<HTMLDivElement>;
37
38
  counterRef: React.RefObject<HTMLSpanElement>;
@@ -63,7 +64,7 @@ function pickTickInterval(pxPerSecond: number): number {
63
64
  * apply only to the focused effect without stopping other previews.
64
65
  */
65
66
  export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
66
- const {binding, selectedNode, onSelectNode, scene, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true} = props;
67
+ const {binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true} = props;
67
68
 
68
69
  const [bodyWidthPx, setBodyWidthPx] = useState(600);
69
70
  const [collapsedGroups, setCollapsedGroups] = useState<Set<TransformNode>>(() => new Set());
@@ -144,13 +145,19 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
144
145
  let shown = elapsed;
145
146
  if (anyLooping) {
146
147
  shown = timelineSpan > 0 ? elapsed % timelineSpan : 0;
147
- } else if (elapsed >= timelineSpan || isFocusFinished(state, focusRootId)) {
148
+ } else if (isFocusFinished(state, focusRootId)) {
149
+ // Park at real simulation time — core may finish before the ruler's worst-case span.
150
+ shown = elapsed;
151
+ } else if (elapsed >= timelineSpan) {
148
152
  shown = timelineSpan;
149
153
  const justFinishedPlaying =
150
- !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan && elapsed >= timelineSpan;
154
+ !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan;
151
155
  if (justFinishedPlaying) {
156
+ scrubFocusToSettled(binding, renderer, timelineSpan);
152
157
  markFocusFinished(state, focusRootId);
153
158
  setFocusElapsed(state, focusRootId, timelineSpan);
159
+ pauseFocus(binding);
160
+ scene.render();
154
161
  }
155
162
  }
156
163
  prevElapsedRef.current = elapsed;
@@ -161,7 +168,7 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
161
168
  };
162
169
  raf = requestAnimationFrame(tick);
163
170
  return () => cancelAnimationFrame(raf);
164
- }, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled]);
171
+ }, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled, binding, scene, renderer]);
165
172
 
166
173
  const visibleRows = useMemo(() => {
167
174
  let hideUntilDepth: number | null = null;
@@ -197,16 +204,23 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
197
204
  };
198
205
 
199
206
  const applyScrub = (time: number) => {
200
- scrubFocusTo(binding, time);
201
207
  const state = playbackRef.current;
202
- setFocusElapsed(state, focusRootId, time);
203
- if (time < timelineSpan) {
204
- clearFocusFinished(state, focusRootId);
205
- } else if (!anyLooping) {
208
+ const atEnd = !anyLooping && time >= timelineSpan;
209
+ const shown = atEnd ? timelineSpan : time;
210
+ if (atEnd) {
211
+ scrubFocusToSettled(binding, renderer, timelineSpan);
206
212
  markFocusFinished(state, focusRootId);
213
+ pauseFocus(binding);
214
+ } else {
215
+ scrubFocusTo(binding, renderer, time);
216
+ clearFocusFinished(state, focusRootId);
217
+ if (stagePlaying) {
218
+ playFocus(binding);
219
+ }
207
220
  }
221
+ setFocusElapsed(state, focusRootId, shown);
208
222
  if (playheadRef.current) {
209
- playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + time * pxPerSecond}px`;
223
+ playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + shown * pxPerSecond}px`;
210
224
  }
211
225
  scene.render();
212
226
  };
@@ -223,10 +237,13 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
223
237
  };
224
238
 
225
239
  const restartFocus = () => {
226
- scrubFocusTo(binding, 0);
240
+ scrubFocusTo(binding, renderer, 0);
227
241
  const state = playbackRef.current;
228
242
  setFocusElapsed(state, focusRootId, 0);
229
243
  clearFocusFinished(state, focusRootId);
244
+ if (stagePlaying) {
245
+ playFocus(binding);
246
+ }
230
247
  if (playheadRef.current) {
231
248
  playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET}px`;
232
249
  }