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.
- package/dist/core/galleryPlayback.d.ts +6 -1
- package/dist/core/galleryPlayback.js +30 -1
- package/dist/core/galleryScene.d.ts +8 -0
- package/dist/core/galleryScene.js +24 -0
- package/dist/core/renderDebug.d.ts +8 -0
- package/dist/core/renderDebug.js +42 -0
- package/dist/core/scrub.d.ts +10 -1
- package/dist/core/scrub.js +41 -1
- package/dist/core/timeline.d.ts +2 -1
- package/dist/core/timeline.js +26 -2
- package/dist/react/EffectEditorHost.js +87 -11
- package/dist/react/GalleryPanel.d.ts +1 -0
- package/dist/react/GalleryPanel.js +45 -14
- package/dist/react/TimelinePanel.d.ts +2 -0
- package/dist/react/TimelinePanel.js +30 -13
- package/package.json +1 -1
- package/src/core/galleryPlayback.ts +36 -1
- package/src/core/galleryScene.ts +34 -0
- package/src/core/renderDebug.ts +46 -0
- package/src/core/scrub.ts +44 -1
- package/src/core/timeline.ts +30 -2
- package/src/react/EffectEditorHost.tsx +107 -9
- package/src/react/GalleryPanel.tsx +78 -14
- package/src/react/TimelinePanel.tsx +30 -13
|
@@ -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
|
+
}
|
package/dist/core/scrub.d.ts
CHANGED
|
@@ -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
|
package/dist/core/scrub.js
CHANGED
|
@@ -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;
|
package/dist/core/timeline.d.ts
CHANGED
|
@@ -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 —
|
|
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;
|
package/dist/core/timeline.js
CHANGED
|
@@ -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 —
|
|
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.
|
|
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
|
-
|
|
451
|
-
if (
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
|
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;
|