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,9 +1,14 @@
1
1
  import type { GalleryEntry } from './loadEffectGallery';
2
2
  /** Freezes every gallery entry; unpauses all in-scene entries in the preview set when transport is playing. */
3
- export declare function applyGalleryPlayback(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>, transportPlaying: boolean): void;
3
+ export declare function applyGalleryPlayback(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>, transportPlaying: boolean, focusFinishedRootIds?: ReadonlySet<number>): void;
4
4
  /** Whether any preview effect on stage should keep simulating. */
5
5
  export declare function hasActivePreview(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): boolean;
6
6
  /** In-scene preview effects currently in the preview set. */
7
7
  export declare function countActivePreviews(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): number;
8
8
  /** Particle count across all active preview effects. */
9
9
  export declare function countGalleryParticles(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): number;
10
+ /**
11
+ * Pauses one-shot preview effects whose particle systems report `isFinished()`.
12
+ * Returns true when any entry was paused (caller should refresh renderer batches).
13
+ */
14
+ export declare function pauseFinishedPreviews(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>, focusFinishedRootIds?: ReadonlySet<number>): boolean;
@@ -1,8 +1,9 @@
1
+ import { QuarksUtil } from 'babylon.quarks';
1
2
  function isPreviewEntry(entry, previewRootIds) {
2
3
  return entry.inScene && previewRootIds.has(entry.root.uniqueId);
3
4
  }
4
5
  /** Freezes every gallery entry; unpauses all in-scene entries in the preview set when transport is playing. */
5
- export function applyGalleryPlayback(entries, previewRootIds, transportPlaying) {
6
+ export function applyGalleryPlayback(entries, previewRootIds, transportPlaying, focusFinishedRootIds = new Set()) {
6
7
  for (const entry of entries) {
7
8
  for (const system of entry.systems) {
8
9
  system.pause();
@@ -15,6 +16,9 @@ export function applyGalleryPlayback(entries, previewRootIds, transportPlaying)
15
16
  if (!isPreviewEntry(entry, previewRootIds)) {
16
17
  continue;
17
18
  }
19
+ if (focusFinishedRootIds.has(entry.root.uniqueId)) {
20
+ continue;
21
+ }
18
22
  for (const system of entry.systems) {
19
23
  system.play();
20
24
  }
@@ -41,3 +45,28 @@ export function countGalleryParticles(entries, previewRootIds) {
41
45
  }
42
46
  return count;
43
47
  }
48
+ /**
49
+ * Pauses one-shot preview effects whose particle systems report `isFinished()`.
50
+ * Returns true when any entry was paused (caller should refresh renderer batches).
51
+ */
52
+ export function pauseFinishedPreviews(entries, previewRootIds, focusFinishedRootIds = new Set()) {
53
+ let pausedAny = false;
54
+ for (const entry of entries) {
55
+ if (!isPreviewEntry(entry, previewRootIds)) {
56
+ continue;
57
+ }
58
+ if (focusFinishedRootIds.has(entry.root.uniqueId)) {
59
+ continue;
60
+ }
61
+ if (!QuarksUtil.isEffectFinished(entry.root)) {
62
+ continue;
63
+ }
64
+ for (const system of entry.systems) {
65
+ if (!system.paused) {
66
+ system.pause();
67
+ pausedAny = true;
68
+ }
69
+ }
70
+ }
71
+ return pausedAny;
72
+ }
@@ -1,11 +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';
6
+ /** Clears live particles and freezes simulation for one placed catalog entry. */
7
+ export declare function clearGalleryEntrySimulation(entry: GalleryEntry, renderer: BatchedRenderer): void;
5
8
  /** Parents a catalog entry into the live scene and registers its systems with the renderer. */
6
9
  export declare function placeGalleryEntryInScene(entry: GalleryEntry, renderer: BatchedRenderer, worldPosition: Vector3): void;
7
10
  /** Spawns a batch of catalog effects in a square grid centered on `center`. */
8
11
  export declare function placeGalleryEntriesAt(entries: GalleryEntry[], renderer: BatchedRenderer, center: Vector3, spacing?: number): void;
12
+ /**
13
+ * Unregisters a placed effect from the renderer and returns it to the hidden catalog.
14
+ * Systems stay alive for inspector editing; drag into the viewport to place again.
15
+ */
16
+ export declare function removeGalleryEntryFromScene(entry: GalleryEntry, renderer: BatchedRenderer, catalogRoot: TransformNode): void;
9
17
  /** Raycasts the editor ground plane from canvas/client coordinates. */
10
18
  export declare function pickGroundPosition(scene: Scene, clientX: number, clientY: number): Vector3;
11
19
  /** Finds a gallery entry by Babylon node id from a drag payload. */
@@ -1,5 +1,13 @@
1
1
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
2
2
  import { DEFAULT_GALLERY_SPACING, galleryGridPosition } from './galleryLayout';
3
+ /** Clears live particles and freezes simulation for one placed catalog entry. */
4
+ export function clearGalleryEntrySimulation(entry, renderer) {
5
+ for (const system of entry.systems) {
6
+ system.restart();
7
+ system.pause();
8
+ }
9
+ renderer.refreshBatches();
10
+ }
3
11
  /** Parents a catalog entry into the live scene and registers its systems with the renderer. */
4
12
  export function placeGalleryEntryInScene(entry, renderer, worldPosition) {
5
13
  entry.root.setEnabled(true);
@@ -21,6 +29,22 @@ export function placeGalleryEntriesAt(entries, renderer, center, spacing = DEFAU
21
29
  placeGalleryEntryInScene(entry, renderer, position);
22
30
  });
23
31
  }
32
+ /**
33
+ * Unregisters a placed effect from the renderer and returns it to the hidden catalog.
34
+ * Systems stay alive for inspector editing; drag into the viewport to place again.
35
+ */
36
+ export function removeGalleryEntryFromScene(entry, renderer, catalogRoot) {
37
+ if (!entry.inScene) {
38
+ return;
39
+ }
40
+ clearGalleryEntrySimulation(entry, renderer);
41
+ for (const system of entry.systems) {
42
+ renderer.deleteSystem(system);
43
+ }
44
+ entry.root.parent = catalogRoot;
45
+ entry.root.setEnabled(false);
46
+ entry.inScene = false;
47
+ }
24
48
  /** Raycasts the editor ground plane from canvas/client coordinates. */
25
49
  export function pickGroundPosition(scene, clientX, clientY) {
26
50
  const canvas = scene.getEngine().getRenderingCanvas();
@@ -0,0 +1,8 @@
1
+ import type { BatchedRenderer } from 'babylon.quarks';
2
+ /** Whether GPU/particle mismatch logging is enabled (localStorage quarksDebugRender=1). */
3
+ export declare function isRenderDebugEnabled(): boolean;
4
+ /**
5
+ * Logs when live particle count is zero but a batch still reports GPU instances.
6
+ * Enable with: localStorage.setItem('quarksDebugRender', '1')
7
+ */
8
+ export declare function logRenderMismatch(renderer: BatchedRenderer, liveParticleCount: number): void;
@@ -0,0 +1,42 @@
1
+ const STORAGE_KEY = 'quarksDebugRender';
2
+ /** Whether GPU/particle mismatch logging is enabled (localStorage quarksDebugRender=1). */
3
+ export function isRenderDebugEnabled() {
4
+ try {
5
+ return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ let lastMismatchKey = '';
12
+ /**
13
+ * Logs when live particle count is zero but a batch still reports GPU instances.
14
+ * Enable with: localStorage.setItem('quarksDebugRender', '1')
15
+ */
16
+ export function logRenderMismatch(renderer, liveParticleCount) {
17
+ if (!isRenderDebugEnabled()) {
18
+ return;
19
+ }
20
+ const stats = renderer.getBatchRenderStats();
21
+ const instanceCount = stats.reduce((sum, row) => sum + row.instanceCount, 0);
22
+ const batchParticleCount = stats.reduce((sum, row) => sum + row.particleCount, 0);
23
+ if (liveParticleCount !== 0 && instanceCount === liveParticleCount) {
24
+ lastMismatchKey = '';
25
+ return;
26
+ }
27
+ if (liveParticleCount === 0 && instanceCount === 0 && batchParticleCount === 0) {
28
+ lastMismatchKey = '';
29
+ return;
30
+ }
31
+ const key = `${liveParticleCount}|${instanceCount}|${batchParticleCount}|${stats.map((row) => `${row.batchIndex}:${row.instanceCount}/${row.particleCount}`).join(';')}`;
32
+ if (key === lastMismatchKey) {
33
+ return;
34
+ }
35
+ lastMismatchKey = key;
36
+ console.warn('[quarks render debug] particle vs GPU instance mismatch', {
37
+ liveParticleCount,
38
+ batchParticleCount,
39
+ instanceCount,
40
+ batches: stats,
41
+ });
42
+ }
@@ -1,10 +1,19 @@
1
1
  import type { BatchedRenderer } from 'babylon.quarks';
2
2
  import type { EffectBinding } from './binding';
3
+ /** Unpauses every system in the focused effect. */
4
+ export declare function playFocus(binding: EffectBinding): void;
5
+ /** Freezes the focused effect at its current simulation state. */
6
+ export declare function pauseFocus(binding: EffectBinding): void;
3
7
  /**
4
8
  * Scrub only the focused effect's systems — does not advance other preview effects
5
9
  * via the shared BatchedRenderer.update loop.
6
10
  */
7
- export declare function scrubFocusTo(binding: EffectBinding, time: number): void;
11
+ export declare function scrubFocusTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void;
12
+ /**
13
+ * Scrub to `time`, then keep simulating the focused effect until every particle has died.
14
+ * Used when parking a one-shot at the timeline end so burst tails do not linger on screen.
15
+ */
16
+ export declare function scrubFocusToSettled(binding: EffectBinding, renderer: BatchedRenderer, time: number): void;
8
17
  /**
9
18
  * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
10
19
  * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
@@ -2,17 +2,30 @@ const WIDE_STEP_THRESHOLD = 8;
2
2
  function bindingSystems(binding) {
3
3
  return [binding.system, ...binding.subSystems];
4
4
  }
5
+ /** Unpauses every system in the focused effect. */
6
+ export function playFocus(binding) {
7
+ for (const system of bindingSystems(binding)) {
8
+ system.play();
9
+ }
10
+ }
11
+ /** Freezes the focused effect at its current simulation state. */
12
+ export function pauseFocus(binding) {
13
+ for (const system of bindingSystems(binding)) {
14
+ system.pause();
15
+ }
16
+ }
5
17
  /**
6
18
  * Scrub only the focused effect's systems — does not advance other preview effects
7
19
  * via the shared BatchedRenderer.update loop.
8
20
  */
9
- export function scrubFocusTo(binding, time) {
21
+ export function scrubFocusTo(binding, renderer, time) {
10
22
  const systems = bindingSystems(binding);
11
23
  for (const system of systems) {
12
24
  system.restart();
13
25
  system.play();
14
26
  }
15
27
  if (time <= 0) {
28
+ renderer.refreshBatches();
16
29
  return;
17
30
  }
18
31
  const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
@@ -28,6 +41,32 @@ export function scrubFocusTo(binding, time) {
28
41
  system.update(remainder);
29
42
  }
30
43
  }
44
+ renderer.refreshBatches();
45
+ }
46
+ /**
47
+ * Scrub to `time`, then keep simulating the focused effect until every particle has died.
48
+ * Used when parking a one-shot at the timeline end so burst tails do not linger on screen.
49
+ */
50
+ export function scrubFocusToSettled(binding, renderer, time) {
51
+ scrubFocusTo(binding, renderer, time);
52
+ const systems = bindingSystems(binding);
53
+ const step = 1 / 60;
54
+ const maxExtra = 2;
55
+ let extra = 0;
56
+ while (extra < maxExtra) {
57
+ let alive = 0;
58
+ for (const system of systems) {
59
+ alive += system.particleNum;
60
+ }
61
+ if (alive === 0) {
62
+ break;
63
+ }
64
+ for (const system of systems) {
65
+ system.update(step);
66
+ }
67
+ extra += step;
68
+ }
69
+ renderer.refreshBatches();
31
70
  }
32
71
  /**
33
72
  * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
@@ -40,6 +79,7 @@ export function scrubFocusTo(binding, time) {
40
79
  export function scrubTo(binding, renderer, time) {
41
80
  binding.restart();
42
81
  if (time <= 0) {
82
+ renderer.refreshBatches();
43
83
  return;
44
84
  }
45
85
  const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
@@ -32,6 +32,7 @@ export declare function computeStartOffset(gen: ValueGenerator | FunctionValueGe
32
32
  /** Flattens the effect's group/emitter tree into display rows, preserving tree-walk order. */
33
33
  export declare function computeTimelineRows(binding: EffectBinding): TimelineRow[];
34
34
  /**
35
- * Overall timeline ruler span — emission window per track (`startOffset + duration`).
35
+ * Overall timeline ruler span — worst-case visual tail per track (max startLife, last burst).
36
+ * Actual playback often ends earlier when interval lives sample short or tracks finish out of phase.
36
37
  */
37
38
  export declare function computeTimelineSpan(rows: TimelineRow[]): number;
@@ -58,8 +58,32 @@ export function computeTimelineRows(binding) {
58
58
  walk(binding.getTree());
59
59
  return rows;
60
60
  }
61
+ /** Upper bound on startLife for timeline span (burst/one-shot tail). */
62
+ function estimateMaxStartLife(system) {
63
+ const life = system.startLife;
64
+ if (life instanceof ConstantValue) {
65
+ return Math.max(0, life.value);
66
+ }
67
+ if (life instanceof IntervalValue) {
68
+ return Math.max(life.a, life.b);
69
+ }
70
+ return 1;
71
+ }
72
+ /** When emission is burst-only, the effect window ends at the last burst time, not full duration. */
73
+ function estimateEmissionEnd(system) {
74
+ const rate = system.emissionOverTime instanceof ConstantValue ? system.emissionOverTime.value : null;
75
+ const perMeter = system.emissionOverDistance instanceof ConstantValue ? system.emissionOverDistance.value : null;
76
+ if ((rate != null && rate > 0) || (perMeter != null && perMeter > 0)) {
77
+ return system.duration;
78
+ }
79
+ if (system.emissionBursts.length === 0) {
80
+ return system.duration;
81
+ }
82
+ return Math.max(...system.emissionBursts.map((burst) => burst.time));
83
+ }
61
84
  /**
62
- * Overall timeline ruler span — emission window per track (`startOffset + duration`).
85
+ * Overall timeline ruler span — worst-case visual tail per track (max startLife, last burst).
86
+ * Actual playback often ends earlier when interval lives sample short or tracks finish out of phase.
63
87
  */
64
88
  export function computeTimelineSpan(rows) {
65
89
  let span = 0;
@@ -67,7 +91,7 @@ export function computeTimelineSpan(rows) {
67
91
  if (row.kind !== 'track') {
68
92
  continue;
69
93
  }
70
- span = Math.max(span, row.startOffset + row.duration);
94
+ span = Math.max(span, row.startOffset + estimateEmissionEnd(row.system) + estimateMaxStartLife(row.system));
71
95
  }
72
96
  return Math.max(span, MIN_TIMELINE_SPAN);
73
97
  }
@@ -14,21 +14,23 @@ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
14
14
  import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
15
15
  import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
16
16
  import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
17
- import { BatchedRenderer, } from 'babylon.quarks';
17
+ import { BatchedRenderer, QuarksUtil, } from 'babylon.quarks';
18
18
  import { EffectBinding } from '../core/binding';
19
19
  import { EffectHistory } from '../core/history';
20
20
  import { ensureGroundResolver } from '../core/collision';
21
21
  import { createGalleryDefaultEntry } from '../core/defaultEffect';
22
22
  import { loadEffectFromJson, disposeLoadedEffect, parseEffectFromJson } from '../core/loadEffect';
23
- import { applyGalleryPlayback, countActivePreviews, countGalleryParticles, hasActivePreview } from '../core/galleryPlayback';
24
- import { createPlaybackState, getFocusElapsed, isFocusFinished, setFocusElapsed } from '../core/playback';
23
+ import { applyGalleryPlayback, countActivePreviews, countGalleryParticles, hasActivePreview, pauseFinishedPreviews } from '../core/galleryPlayback';
24
+ import { createPlaybackState, clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed } from '../core/playback';
25
+ import { pauseFocus, scrubFocusToSettled } from '../core/scrub';
26
+ import { logRenderMismatch } from '../core/renderDebug';
25
27
  import { cancelGalleryCameraFocus, focusGalleryEntry } from '../core/galleryLayout';
26
28
  import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
27
29
  import { GalleryDropMarker } from '../core/galleryDropMarker';
28
30
  import { GallerySelectionMarker } from '../core/gallerySelectionMarker';
29
31
  import { EmitterShapeWireframes } from '../core/emitterShapeWireframe';
30
32
  import { getGalleryDragSession, setGalleryDragSession } from '../core/galleryDragSession';
31
- import { pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene } from '../core/galleryScene';
33
+ import { clearGalleryEntrySimulation, pickGroundPosition, placeGalleryEntriesAt, placeGalleryEntryInScene, removeGalleryEntryFromScene, } from '../core/galleryScene';
32
34
  import { describeGalleryDrag, GALLERY_DRAG_MIME, readGalleryDrag } from '../core/galleryTree';
33
35
  import { loadEffectGallery, collectJsonFiles, bindingFromGalleryEntry, mergeGalleryEntries } from '../core/loadEffectGallery';
34
36
  import { pickGalleryJsonFiles } from '../core/pickGalleryFiles';
@@ -80,6 +82,7 @@ export function EffectEditorHost(props) {
80
82
  const importGalleryRef = useRef(async () => { });
81
83
  const selectGalleryEntryRef = useRef(() => { });
82
84
  const toggleGalleryPreviewRef = useRef(() => { });
85
+ const removeGalleryFromSceneRef = useRef(() => { });
83
86
  const placeGalleryDropRef = useRef(() => { });
84
87
  const createGalleryDefaultRef = useRef(() => { });
85
88
  const dropMarkerRef = useRef(null);
@@ -267,6 +270,7 @@ export function EffectEditorHost(props) {
267
270
  return;
268
271
  }
269
272
  const id = entry.root.uniqueId;
273
+ const removing = (stateRef.current?.previewRootIds ?? []).includes(id);
270
274
  setPreviewRootIds((prev) => {
271
275
  const next = new Set(prev);
272
276
  if (next.has(id)) {
@@ -274,6 +278,7 @@ export function EffectEditorHost(props) {
274
278
  }
275
279
  else {
276
280
  next.add(id);
281
+ clearFocusFinished(playbackRef.current, id);
277
282
  }
278
283
  const ids = [...next];
279
284
  if (stateRef.current) {
@@ -281,6 +286,41 @@ export function EffectEditorHost(props) {
281
286
  }
282
287
  return ids;
283
288
  });
289
+ if (removing) {
290
+ clearGalleryEntrySimulation(entry, renderer);
291
+ clearFocusFinished(playbackRef.current, id);
292
+ }
293
+ renderer.refreshBatches();
294
+ scene.render();
295
+ force();
296
+ };
297
+ const removeGalleryFromScene = (entry) => {
298
+ if (!entry.inScene) {
299
+ return;
300
+ }
301
+ const id = entry.root.uniqueId;
302
+ removeGalleryEntryFromScene(entry, renderer, galleryRoot);
303
+ setPreviewRootIds((prev) => {
304
+ const next = prev.filter((rootId) => rootId !== id);
305
+ if (stateRef.current) {
306
+ stateRef.current.previewRootIds = next;
307
+ }
308
+ return next;
309
+ });
310
+ clearFocusFinished(playbackRef.current, id);
311
+ playbackRef.current.focusElapsedByRoot.delete(id);
312
+ galleryEntries = [...galleryEntries];
313
+ if (stateRef.current) {
314
+ stateRef.current.galleryEntries = galleryEntries;
315
+ }
316
+ setGallery(galleryEntries);
317
+ if (stateRef.current?.binding?.root === entry.root) {
318
+ selectionMarker.hide();
319
+ }
320
+ applyGalleryPlayback(galleryEntries, new Set(stateRef.current?.previewRootIds ?? []), playbackRef.current.stagePlaying, playbackRef.current.focusFinishedByRoot);
321
+ renderer.refreshBatches();
322
+ scene.render();
323
+ force();
284
324
  };
285
325
  const addGalleryPreview = (...entries) => {
286
326
  const ids = entries.filter((entry) => entry.inScene).map((entry) => entry.root.uniqueId);
@@ -404,6 +444,7 @@ export function EffectEditorHost(props) {
404
444
  importGalleryRef.current = appendGalleryFromFiles;
405
445
  selectGalleryEntryRef.current = focusCatalogEntry;
406
446
  toggleGalleryPreviewRef.current = toggleGalleryPreview;
447
+ removeGalleryFromSceneRef.current = removeGalleryFromScene;
407
448
  placeGalleryDropRef.current = placeGalleryDropAt;
408
449
  createGalleryDefaultRef.current = appendGalleryDefault;
409
450
  const galleryDefaults = {
@@ -447,11 +488,39 @@ export function EffectEditorHost(props) {
447
488
  const simulate = hasActivePreview(snapshot.galleryEntries, previewIds) && (playback.stagePlaying || stepQueued);
448
489
  if (simulate) {
449
490
  renderer.update(delta);
450
- const focusId = playback.focusRootId ?? snapshot.binding?.root.uniqueId ?? null;
451
- if (focusId != null && playback.stagePlaying && !playback.focusFinishedByRoot.has(focusId)) {
491
+ let needsRefresh = false;
492
+ if (pauseFinishedPreviews(snapshot.galleryEntries, previewIds, playback.focusFinishedByRoot)) {
493
+ needsRefresh = true;
494
+ }
495
+ const binding = snapshot.binding;
496
+ const focusId = playback.focusRootId ?? binding?.root.uniqueId ?? null;
497
+ if (focusId != null &&
498
+ binding &&
499
+ playback.stagePlaying &&
500
+ !playback.focusFinishedByRoot.has(focusId) &&
501
+ !focusTimelineMetaRef.current.anyLooping &&
502
+ QuarksUtil.isEffectFinished(binding.root)) {
503
+ const settleAt = getFocusElapsed(playback, focusId);
504
+ scrubFocusToSettled(binding, renderer, settleAt);
505
+ markFocusFinished(playback, focusId);
506
+ pauseFocus(binding);
507
+ needsRefresh = true;
508
+ }
509
+ else if (focusId != null &&
510
+ playback.stagePlaying &&
511
+ !playback.focusFinishedByRoot.has(focusId)) {
452
512
  const prev = getFocusElapsed(playback, focusId);
453
513
  setFocusElapsed(playback, focusId, prev + delta);
454
514
  }
515
+ if (needsRefresh) {
516
+ renderer.refreshBatches();
517
+ }
518
+ if (snapshot.galleryEntries.length > 0) {
519
+ logRenderMismatch(renderer, countGalleryParticles(snapshot.galleryEntries, previewIds));
520
+ }
521
+ }
522
+ else if (snapshot && snapshot.galleryEntries.length > 0) {
523
+ renderer.refreshBatches();
455
524
  }
456
525
  }
457
526
  }
@@ -502,7 +571,9 @@ export function EffectEditorHost(props) {
502
571
  const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
503
572
  const timeLabel = meta.anyLooping
504
573
  ? `${focusElapsed.toFixed(1)}s ⟳`
505
- : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
574
+ : focusEnded
575
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
576
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
506
577
  counterRef.current.textContent = `${playing} preview · ${stageLabel}${focusLabel} · ${timeLabel} · ${count} particles · ${inScene}/${snapshot.galleryEntries.length} in catalog`;
507
578
  }
508
579
  }
@@ -517,7 +588,9 @@ export function EffectEditorHost(props) {
517
588
  const stageLabel = playback.stagePlaying ? 'playing' : 'paused';
518
589
  const timeLabel = meta.anyLooping
519
590
  ? `${focusElapsed.toFixed(1)}s ⟳`
520
- : `${Math.min(focusElapsed, meta.span).toFixed(1)}s / ${meta.span.toFixed(1)}s${focusEnded ? ' (end)' : ''}`;
591
+ : focusEnded
592
+ ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
593
+ : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
521
594
  counterRef.current.textContent = `${stageLabel} · ${timeLabel} · ${count} particles`;
522
595
  }
523
596
  }
@@ -572,7 +645,10 @@ export function EffectEditorHost(props) {
572
645
  if (!isCatalogEntry) {
573
646
  return;
574
647
  }
575
- applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying);
648
+ applyGalleryPlayback(snap.galleryEntries, new Set(previewRootIds), stagePlaying, playbackRef.current.focusFinishedByRoot);
649
+ if (!stagePlaying && stateRef.current?.renderer) {
650
+ stateRef.current.renderer.refreshBatches();
651
+ }
576
652
  }, [stagePlaying, previewRootIds, gallery]);
577
653
  useEffect(() => {
578
654
  emitterWireframesRef.current?.setVisible(showEmitterWireframes);
@@ -747,7 +823,7 @@ export function EffectEditorHost(props) {
747
823
  display: 'flex',
748
824
  flexDirection: 'column',
749
825
  minHeight: 0,
750
- }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsxs("div", { style: { fontSize: 14, fontWeight: 600 }, children: ["Gallery", gallery.length > 0 ? ` · ${gallery.length}` : ''] }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4 }, children: "Drag into the viewport to place effects." })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'hidden', padding: '8px 12px 14px', display: 'flex', flexDirection: 'column' }, children: _jsx(GalleryPanel, { entries: gallery, focusedRoot: selectedGalleryRoot, previewRootIds: previewRootIdSet, loading: galleryLoading, folderInputRef: galleryFolderInputRef, onFocus: (entry) => selectGalleryEntryRef.current(entry), onTogglePreview: (entry) => toggleGalleryPreviewRef.current(entry), onImportFiles: (files) => void runGalleryImport(files), onImportFolder: () => void handleImportFolder(), onCreateDefault: () => createGalleryDefaultRef.current() }) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
826
+ }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsxs("div", { style: { fontSize: 14, fontWeight: 600 }, children: ["Gallery", gallery.length > 0 ? ` · ${gallery.length}` : ''] }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4 }, children: "Drag into viewport to place \u00B7 \u2713 preview \u00B7 \u00D7 remove from stage" })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'hidden', padding: '8px 12px 14px', display: 'flex', flexDirection: 'column' }, children: _jsx(GalleryPanel, { entries: gallery, focusedRoot: selectedGalleryRoot, previewRootIds: previewRootIdSet, loading: galleryLoading, folderInputRef: galleryFolderInputRef, onFocus: (entry) => selectGalleryEntryRef.current(entry), onTogglePreview: (entry) => toggleGalleryPreviewRef.current(entry), onRemoveFromScene: (entry) => removeGalleryFromSceneRef.current(entry), onImportFiles: (files) => void runGalleryImport(files), onImportFolder: () => void handleImportFolder(), onCreateDefault: () => createGalleryDefaultRef.current() }) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
751
827
  if (!e.dataTransfer.types.includes(GALLERY_DRAG_MIME))
752
828
  return;
753
829
  galleryDragDepthRef.current += 1;
@@ -849,7 +925,7 @@ export function EffectEditorHost(props) {
849
925
  link.download = 'effect.json';
850
926
  link.click();
851
927
  URL.revokeObjectURL(link.href);
852
- }, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && gizmoNode ? (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, gizmoTargetNode: gizmoNode, gizmoTargetLabel: gizmoTargetLabel, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), showTimeline && binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })), messageDialog && (_jsx(MessageDialog, { title: messageDialog.title, message: messageDialog.message, onClose: () => setMessageDialog(null) }))] }));
928
+ }, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && gizmoNode ? (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, gizmoTargetNode: gizmoNode, gizmoTargetLabel: gizmoTargetLabel, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), showTimeline && binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })), messageDialog && (_jsx(MessageDialog, { title: messageDialog.title, message: messageDialog.message, onClose: () => setMessageDialog(null) }))] }));
853
929
  }
854
930
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
855
931
  export const QuarksEffectEditor = {
@@ -11,6 +11,7 @@ export interface GalleryPanelProps {
11
11
  folderInputRef: React.RefObject<HTMLInputElement | null>;
12
12
  onFocus: (entry: GalleryEntry) => void;
13
13
  onTogglePreview: (entry: GalleryEntry) => void;
14
+ onRemoveFromScene: (entry: GalleryEntry) => void;
14
15
  onImportFiles: (files: FileList | File[]) => void;
15
16
  onImportFolder: () => void;
16
17
  onCreateDefault: () => void;