babylon.quarks-editor 0.17.8 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/core/binding.js +2 -5
  4. package/dist/core/defaultEffect.d.ts +12 -0
  5. package/dist/core/defaultEffect.js +43 -0
  6. package/dist/core/editorScene.d.ts +7 -0
  7. package/dist/core/editorScene.js +11 -0
  8. package/dist/core/effectTree.js +2 -1
  9. package/dist/core/emitterShapeWireframe.d.ts +20 -0
  10. package/dist/core/emitterShapeWireframe.js +329 -0
  11. package/dist/core/galleryDragSession.d.ts +4 -0
  12. package/dist/core/galleryDragSession.js +8 -0
  13. package/dist/core/galleryDropMarker.d.ts +13 -0
  14. package/dist/core/galleryDropMarker.js +42 -0
  15. package/dist/core/galleryLayout.d.ts +20 -0
  16. package/dist/core/galleryLayout.js +68 -0
  17. package/dist/core/galleryPlayback.d.ts +14 -0
  18. package/dist/core/galleryPlayback.js +72 -0
  19. package/dist/core/galleryScene.d.ts +20 -0
  20. package/dist/core/galleryScene.js +65 -0
  21. package/dist/core/gallerySelectionMarker.d.ts +25 -0
  22. package/dist/core/gallerySelectionMarker.js +69 -0
  23. package/dist/core/galleryTree.d.ts +49 -0
  24. package/dist/core/galleryTree.js +138 -0
  25. package/dist/core/loadEffect.d.ts +13 -0
  26. package/dist/core/loadEffect.js +31 -17
  27. package/dist/core/loadEffectGallery.d.ts +36 -0
  28. package/dist/core/loadEffectGallery.js +97 -0
  29. package/dist/core/material.d.ts +13 -0
  30. package/dist/core/material.js +68 -0
  31. package/dist/core/pickGalleryFiles.d.ts +5 -0
  32. package/dist/core/pickGalleryFiles.js +42 -0
  33. package/dist/core/playback.d.ts +23 -0
  34. package/dist/core/playback.js +32 -0
  35. package/dist/core/renderDebug.d.ts +8 -0
  36. package/dist/core/renderDebug.js +42 -0
  37. package/dist/core/scrub.d.ts +14 -0
  38. package/dist/core/scrub.js +70 -0
  39. package/dist/core/timeline.d.ts +4 -1
  40. package/dist/core/timeline.js +30 -3
  41. package/dist/core/transformStore.d.ts +3 -0
  42. package/dist/core/transformStore.js +16 -0
  43. package/dist/core/viewportPicker.d.ts +25 -0
  44. package/dist/core/viewportPicker.js +86 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.js +1 -0
  47. package/dist/react/EffectEditor.js +1 -4
  48. package/dist/react/EffectEditorHost.d.ts +7 -0
  49. package/dist/react/EffectEditorHost.js +745 -115
  50. package/dist/react/GalleryPanel.d.ts +20 -0
  51. package/dist/react/GalleryPanel.js +246 -0
  52. package/dist/react/InspectorInfoIcon.d.ts +7 -0
  53. package/dist/react/InspectorInfoIcon.js +22 -0
  54. package/dist/react/InspectorLabel.d.ts +7 -0
  55. package/dist/react/InspectorLabel.js +12 -0
  56. package/dist/react/MessageDialog.d.ts +9 -0
  57. package/dist/react/MessageDialog.js +24 -0
  58. package/dist/react/ModuleSection.d.ts +2 -0
  59. package/dist/react/ModuleSection.js +8 -1
  60. package/dist/react/ObjectModule.js +14 -2
  61. package/dist/react/TimelinePanel.d.ts +8 -11
  62. package/dist/react/TimelinePanel.js +129 -98
  63. package/dist/react/TimelineTrackRow.js +29 -15
  64. package/dist/react/Tooltip.d.ts +6 -0
  65. package/dist/react/Tooltip.js +65 -0
  66. package/dist/react/ValueField.d.ts +2 -0
  67. package/dist/react/ValueField.js +1 -1
  68. package/dist/react/behaviorModules.js +8 -8
  69. package/dist/react/fields.d.ts +2 -0
  70. package/dist/react/fields.js +14 -5
  71. package/dist/react/icons.d.ts +7 -0
  72. package/dist/react/icons.js +10 -0
  73. package/dist/react/index.d.ts +4 -0
  74. package/dist/react/index.js +2 -0
  75. package/dist/react/inspectorHints.d.ts +2 -0
  76. package/dist/react/inspectorHints.js +138 -0
  77. package/dist/react/modules.js +109 -81
  78. package/dist/react/theme.d.ts +2 -0
  79. package/dist/react/theme.js +53 -1
  80. package/package.json +8 -4
  81. package/src/core/binding.ts +2 -5
  82. package/src/core/defaultEffect.ts +74 -0
  83. package/src/core/editorScene.ts +14 -0
  84. package/src/core/effectTree.ts +2 -1
  85. package/src/core/emitterShapeWireframe.ts +373 -0
  86. package/src/core/galleryDragSession.ts +12 -0
  87. package/src/core/galleryDropMarker.ts +57 -0
  88. package/src/core/galleryLayout.ts +90 -0
  89. package/src/core/galleryPlayback.ts +90 -0
  90. package/src/core/galleryScene.ts +92 -0
  91. package/src/core/gallerySelectionMarker.ts +84 -0
  92. package/src/core/galleryTree.ts +187 -0
  93. package/src/core/loadEffect.ts +55 -18
  94. package/src/core/loadEffectGallery.ts +146 -0
  95. package/src/core/material.ts +84 -0
  96. package/src/core/pickGalleryFiles.ts +58 -0
  97. package/src/core/playback.ts +49 -0
  98. package/src/core/renderDebug.ts +46 -0
  99. package/src/core/scrub.ts +76 -1
  100. package/src/core/timeline.ts +34 -3
  101. package/src/core/transformStore.ts +19 -0
  102. package/src/core/viewportPicker.ts +114 -0
  103. package/src/index.ts +2 -0
  104. package/src/react/EffectEditor.tsx +5 -7
  105. package/src/react/EffectEditorHost.tsx +936 -142
  106. package/src/react/GalleryPanel.tsx +517 -0
  107. package/src/react/InspectorInfoIcon.tsx +36 -0
  108. package/src/react/InspectorLabel.tsx +18 -0
  109. package/src/react/MessageDialog.tsx +57 -0
  110. package/src/react/ModuleSection.tsx +17 -2
  111. package/src/react/ObjectModule.tsx +17 -6
  112. package/src/react/TimelinePanel.tsx +227 -174
  113. package/src/react/TimelineTrackRow.tsx +58 -23
  114. package/src/react/Tooltip.tsx +93 -0
  115. package/src/react/ValueField.tsx +3 -1
  116. package/src/react/behaviorModules.tsx +13 -8
  117. package/src/react/fields.tsx +15 -6
  118. package/src/react/icons.ts +19 -0
  119. package/src/react/index.ts +4 -0
  120. package/src/react/inspectorHints.ts +150 -0
  121. package/src/react/modules.tsx +198 -183
  122. package/src/react/theme.ts +54 -1
@@ -0,0 +1,97 @@
1
+ import { EffectBinding } from './binding';
2
+ import { disposeLoadedEffect, parseEffectFromJson } from './loadEffect';
3
+ const YIELD_EVERY = 8;
4
+ function yieldToBrowser() {
5
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
6
+ }
7
+ /** Collects `.json` files from a folder picker result, sorted by relative path. */
8
+ export function collectJsonFiles(files) {
9
+ const list = Array.from(files);
10
+ return list
11
+ .filter((file) => file.name.toLowerCase().endsWith('.json'))
12
+ .sort((a, b) => {
13
+ const pathA = a.webkitRelativePath || a.name;
14
+ const pathB = b.webkitRelativePath || b.name;
15
+ return pathA.localeCompare(pathB);
16
+ });
17
+ }
18
+ /**
19
+ * Parses every JSON effect into a hidden catalog — not registered with the renderer until the
20
+ * user drops the effect into the viewport.
21
+ */
22
+ export async function loadEffectGallery(scene, files, catalogRoot, onProgress) {
23
+ const jsonFiles = collectJsonFiles(files);
24
+ const entries = [];
25
+ onProgress({ done: 0, total: jsonFiles.length, loaded: 0, failed: 0 });
26
+ for (let i = 0; i < jsonFiles.length; i++) {
27
+ const file = jsonFiles[i];
28
+ try {
29
+ const json = JSON.parse(await file.text());
30
+ const { root, systems } = parseEffectFromJson(scene, null, json, {
31
+ registerRenderer: false,
32
+ autoplay: false,
33
+ });
34
+ if (systems.length === 0) {
35
+ disposeLoadedEffect(root, systems, null, false);
36
+ continue;
37
+ }
38
+ const relPath = file.webkitRelativePath || file.name;
39
+ const label = file.name.replace(/\.json$/i, '');
40
+ if (!root.name || root.name === 'Effect' || root.name === 'Object3D') {
41
+ root.name = label;
42
+ }
43
+ root.parent = catalogRoot;
44
+ root.setEnabled(false);
45
+ entries.push({
46
+ name: label,
47
+ path: relPath.replace(/\\/g, '/'),
48
+ root,
49
+ systems,
50
+ inScene: false,
51
+ });
52
+ }
53
+ catch (err) {
54
+ console.warn(`[Quarks editor] Skipped "${file.name}":`, err);
55
+ }
56
+ onProgress({
57
+ done: i + 1,
58
+ total: jsonFiles.length,
59
+ loaded: entries.length,
60
+ failed: i + 1 - entries.length,
61
+ });
62
+ if ((i + 1) % YIELD_EVERY === 0) {
63
+ await yieldToBrowser();
64
+ }
65
+ }
66
+ return entries;
67
+ }
68
+ /** Builds an edit binding for one catalog entry without unloading the rest. */
69
+ export function bindingFromGalleryEntry(entry) {
70
+ const main = entry.systems.find((s) => !s.onlyUsedByOther) ?? entry.systems[0];
71
+ return new EffectBinding(main, entry.systems.filter((s) => s !== main), entry.root);
72
+ }
73
+ /** Merges newly loaded catalog entries, skipping duplicate paths. */
74
+ export function mergeGalleryEntries(existing, incoming, renderer) {
75
+ const paths = new Set(existing.map((entry) => entry.path));
76
+ const added = [];
77
+ let skipped = 0;
78
+ for (const entry of incoming) {
79
+ if (paths.has(entry.path)) {
80
+ disposeLoadedEffect(entry.root, entry.systems, renderer, false);
81
+ skipped++;
82
+ continue;
83
+ }
84
+ paths.add(entry.path);
85
+ added.push(entry);
86
+ }
87
+ return { merged: [...existing, ...added], added, skipped };
88
+ }
89
+ /** Disposes every catalog entry and the hidden catalog root. */
90
+ export function disposeGallery(catalogRoot, entries, renderer) {
91
+ for (const entry of entries) {
92
+ disposeLoadedEffect(entry.root, entry.systems, renderer, entry.inScene);
93
+ }
94
+ if (catalogRoot && !catalogRoot.isDisposed()) {
95
+ catalogRoot.dispose(false, true);
96
+ }
97
+ }
@@ -0,0 +1,13 @@
1
+ import type { ParticleSystem } from 'babylon.quarks';
2
+ export interface RendererMaterialPatch {
3
+ blendMode?: number;
4
+ transparent?: boolean;
5
+ depthTest?: boolean;
6
+ depthWrite?: boolean;
7
+ alphaTest?: number;
8
+ texture?: unknown;
9
+ }
10
+ /** Applies renderer material settings and keeps imported QuarksMaterial JSON in sync for export. */
11
+ export declare function applyRendererMaterial(system: ParticleSystem, patch: RendererMaterialPatch): void;
12
+ /** Human-readable label for an imported or live material reference. */
13
+ export declare function getMaterialLabel(material: unknown): string | null;
@@ -0,0 +1,68 @@
1
+ function isEditableMaterialRecord(material) {
2
+ return !!material && typeof material === 'object' && !('getClassName' in material);
3
+ }
4
+ /** Applies renderer material settings and keeps imported QuarksMaterial JSON in sync for export. */
5
+ export function applyRendererMaterial(system, patch) {
6
+ const settings = system.getRendererSettings();
7
+ if (patch.blendMode !== undefined) {
8
+ system.blending = patch.blendMode;
9
+ }
10
+ if (patch.transparent !== undefined) {
11
+ settings.materialTransparent = patch.transparent;
12
+ }
13
+ if (patch.depthTest !== undefined) {
14
+ settings.materialDepthTest = patch.depthTest;
15
+ }
16
+ if (patch.depthWrite !== undefined) {
17
+ settings.materialDepthWrite = patch.depthWrite;
18
+ }
19
+ if (patch.alphaTest !== undefined) {
20
+ settings.materialAlphaTest = patch.alphaTest;
21
+ }
22
+ if (patch.texture !== undefined) {
23
+ system.texture = patch.texture;
24
+ }
25
+ const material = system.material;
26
+ if (isEditableMaterialRecord(material)) {
27
+ if (patch.blendMode !== undefined) {
28
+ material.alphaMode = patch.blendMode;
29
+ material.blending = patch.blendMode;
30
+ }
31
+ if (patch.transparent !== undefined) {
32
+ material.transparent = patch.transparent;
33
+ }
34
+ if (patch.depthTest !== undefined) {
35
+ material.depthTest = patch.depthTest;
36
+ }
37
+ if (patch.depthWrite !== undefined) {
38
+ material.depthWrite = patch.depthWrite;
39
+ }
40
+ if (patch.alphaTest !== undefined) {
41
+ material.alphaTest = patch.alphaTest;
42
+ }
43
+ if (patch.texture !== undefined) {
44
+ material.texture = patch.texture;
45
+ material.map = patch.texture;
46
+ }
47
+ }
48
+ system.neededToUpdateRender = true;
49
+ }
50
+ /** Human-readable label for an imported or live material reference. */
51
+ export function getMaterialLabel(material) {
52
+ if (!material) {
53
+ return null;
54
+ }
55
+ if (isEditableMaterialRecord(material)) {
56
+ const name = typeof material.name === 'string' ? material.name : null;
57
+ const type = typeof material.type === 'string' ? material.type : 'QuarksMaterial';
58
+ return name ? `${name} (${type})` : type;
59
+ }
60
+ const named = material;
61
+ if (named.name) {
62
+ return named.name;
63
+ }
64
+ if (typeof named.getClassName === 'function') {
65
+ return named.getClassName();
66
+ }
67
+ return 'Material';
68
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Opens a native folder picker when the host supports it. Returns `null` when the user
3
+ * cancels, or when the API is unavailable (caller should fall back to `<input webkitdirectory>`).
4
+ */
5
+ export declare function pickGalleryJsonFiles(): Promise<File[] | null | undefined>;
@@ -0,0 +1,42 @@
1
+ import { collectJsonFiles } from './loadEffectGallery';
2
+ /** Recursively collects `.json` files from a directory handle (File System Access API). */
3
+ async function readJsonFilesFromDirectory(dir, prefix = '') {
4
+ const files = [];
5
+ for await (const [name, entry] of dir.entries()) {
6
+ if (entry.kind === 'file') {
7
+ if (!name.toLowerCase().endsWith('.json'))
8
+ continue;
9
+ const file = await entry.getFile();
10
+ Object.defineProperty(file, 'webkitRelativePath', {
11
+ value: prefix + name,
12
+ configurable: true,
13
+ });
14
+ files.push(file);
15
+ continue;
16
+ }
17
+ if (entry.kind === 'directory') {
18
+ files.push(...(await readJsonFilesFromDirectory(entry, `${prefix}${name}/`)));
19
+ }
20
+ }
21
+ return files;
22
+ }
23
+ /**
24
+ * Opens a native folder picker when the host supports it. Returns `null` when the user
25
+ * cancels, or when the API is unavailable (caller should fall back to `<input webkitdirectory>`).
26
+ */
27
+ export async function pickGalleryJsonFiles() {
28
+ const picker = window.showDirectoryPicker;
29
+ if (typeof picker !== 'function') {
30
+ return undefined;
31
+ }
32
+ try {
33
+ const dir = (await picker.call(window, { mode: 'read' }));
34
+ return collectJsonFiles(await readJsonFilesFromDirectory(dir));
35
+ }
36
+ catch (err) {
37
+ if (err instanceof DOMException && err.name === 'AbortError') {
38
+ return null;
39
+ }
40
+ throw err;
41
+ }
42
+ }
@@ -0,0 +1,23 @@
1
+ /** Stage transport + per-focus timeline state (decoupled from gallery preview set). */
2
+ export interface PlaybackState {
3
+ /** When true, all in-scene preview effects simulate. */
4
+ stagePlaying: boolean;
5
+ focusRootId: number | null;
6
+ focusElapsedByRoot: Map<number, number>;
7
+ focusFinishedByRoot: Set<number>;
8
+ speed: number;
9
+ stepQueued: boolean;
10
+ scrubbing: boolean;
11
+ }
12
+ /** Default playback state for a new editor session. */
13
+ export declare function createPlaybackState(): PlaybackState;
14
+ /** Elapsed timeline time for one focused effect root. */
15
+ export declare function getFocusElapsed(state: PlaybackState, rootId: number): number;
16
+ /** Writes focus elapsed time (playhead position). */
17
+ export declare function setFocusElapsed(state: PlaybackState, rootId: number, elapsed: number): void;
18
+ /** Clears the parked-at-end flag after restart or scrub before the end. */
19
+ export declare function clearFocusFinished(state: PlaybackState, rootId: number): void;
20
+ /** Marks a one-shot focus effect as finished (playhead parked at duration). */
21
+ export declare function markFocusFinished(state: PlaybackState, rootId: number): void;
22
+ /** Whether a one-shot focus effect has reached the end of its timeline. */
23
+ export declare function isFocusFinished(state: PlaybackState, rootId: number): boolean;
@@ -0,0 +1,32 @@
1
+ /** Default playback state for a new editor session. */
2
+ export function createPlaybackState() {
3
+ return {
4
+ stagePlaying: true,
5
+ focusRootId: null,
6
+ focusElapsedByRoot: new Map(),
7
+ focusFinishedByRoot: new Set(),
8
+ speed: 1,
9
+ stepQueued: false,
10
+ scrubbing: false,
11
+ };
12
+ }
13
+ /** Elapsed timeline time for one focused effect root. */
14
+ export function getFocusElapsed(state, rootId) {
15
+ return state.focusElapsedByRoot.get(rootId) ?? 0;
16
+ }
17
+ /** Writes focus elapsed time (playhead position). */
18
+ export function setFocusElapsed(state, rootId, elapsed) {
19
+ state.focusElapsedByRoot.set(rootId, elapsed);
20
+ }
21
+ /** Clears the parked-at-end flag after restart or scrub before the end. */
22
+ export function clearFocusFinished(state, rootId) {
23
+ state.focusFinishedByRoot.delete(rootId);
24
+ }
25
+ /** Marks a one-shot focus effect as finished (playhead parked at duration). */
26
+ export function markFocusFinished(state, rootId) {
27
+ state.focusFinishedByRoot.add(rootId);
28
+ }
29
+ /** Whether a one-shot focus effect has reached the end of its timeline. */
30
+ export function isFocusFinished(state, rootId) {
31
+ return state.focusFinishedByRoot.has(rootId);
32
+ }
@@ -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,5 +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;
7
+ /**
8
+ * Scrub only the focused effect's systems — does not advance other preview effects
9
+ * via the shared BatchedRenderer.update loop.
10
+ */
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;
3
17
  /**
4
18
  * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
5
19
  * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
@@ -1,4 +1,73 @@
1
1
  const WIDE_STEP_THRESHOLD = 8;
2
+ function bindingSystems(binding) {
3
+ return [binding.system, ...binding.subSystems];
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
+ }
17
+ /**
18
+ * Scrub only the focused effect's systems — does not advance other preview effects
19
+ * via the shared BatchedRenderer.update loop.
20
+ */
21
+ export function scrubFocusTo(binding, renderer, time) {
22
+ const systems = bindingSystems(binding);
23
+ for (const system of systems) {
24
+ system.restart();
25
+ system.play();
26
+ }
27
+ if (time <= 0) {
28
+ renderer.refreshBatches();
29
+ return;
30
+ }
31
+ const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
32
+ const steps = Math.floor(time / fixedStep);
33
+ for (let i = 0; i < steps; i++) {
34
+ for (const system of systems) {
35
+ system.update(fixedStep);
36
+ }
37
+ }
38
+ const remainder = time - steps * fixedStep;
39
+ if (remainder > 0) {
40
+ for (const system of systems) {
41
+ system.update(remainder);
42
+ }
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();
70
+ }
2
71
  /**
3
72
  * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
4
73
  * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
@@ -10,6 +79,7 @@ const WIDE_STEP_THRESHOLD = 8;
10
79
  export function scrubTo(binding, renderer, time) {
11
80
  binding.restart();
12
81
  if (time <= 0) {
82
+ renderer.refreshBatches();
13
83
  return;
14
84
  }
15
85
  const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
@@ -31,5 +31,8 @@ export declare function computeStartOffset(gen: ValueGenerator | FunctionValueGe
31
31
  };
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
- /** Overall timeline span: the furthest track extent (start offset + duration). */
34
+ /**
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.
37
+ */
35
38
  export declare function computeTimelineSpan(rows: TimelineRow[]): number;
@@ -58,13 +58,40 @@ export function computeTimelineRows(binding) {
58
58
  walk(binding.getTree());
59
59
  return rows;
60
60
  }
61
- /** Overall timeline span: the furthest track extent (start offset + duration). */
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
+ }
84
+ /**
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.
87
+ */
62
88
  export function computeTimelineSpan(rows) {
63
89
  let span = 0;
64
90
  for (const row of rows) {
65
- if (row.kind === 'track') {
66
- span = Math.max(span, row.startOffset + row.duration);
91
+ if (row.kind !== 'track') {
92
+ continue;
67
93
  }
94
+ span = Math.max(span, row.startOffset + estimateEmissionEnd(row.system) + estimateMaxStartLife(row.system));
68
95
  }
69
96
  return Math.max(span, MIN_TIMELINE_SPAN);
70
97
  }
@@ -0,0 +1,3 @@
1
+ export declare function subscribeTransform(listener: () => void): () => void;
2
+ export declare function getTransformRevision(): number;
3
+ export declare function notifyTransformChanged(): void;
@@ -0,0 +1,16 @@
1
+ /** Lightweight external store so gizmo drags refresh ObjectModule without re-rendering the whole host. */
2
+ let revision = 0;
3
+ const listeners = new Set();
4
+ export function subscribeTransform(listener) {
5
+ listeners.add(listener);
6
+ return () => listeners.delete(listener);
7
+ }
8
+ export function getTransformRevision() {
9
+ return revision;
10
+ }
11
+ export function notifyTransformChanged() {
12
+ revision++;
13
+ for (const listener of listeners) {
14
+ listener();
15
+ }
16
+ }
@@ -0,0 +1,25 @@
1
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import type { Scene } from '@babylonjs/core/scene';
3
+ import type { GalleryEntry } from './loadEffectGallery';
4
+ /**
5
+ * Viewport pick/hover for in-scene gallery effect roots only (not child emitters).
6
+ * Hover uses the same ground ring as focus selection, in yellow.
7
+ */
8
+ export declare class GalleryViewportPicker {
9
+ private readonly scene;
10
+ private readonly hoverMarker;
11
+ private readonly handles;
12
+ private hoveredRoot;
13
+ private syncKey;
14
+ constructor(scene: Scene);
15
+ /** Rebuilds root pick colliders when the gallery changes. */
16
+ sync(entries: GalleryEntry[], soloRoot: TransformNode | null, syncKey: string): void;
17
+ /** Yellow focus-style ring under the hovered effect root. */
18
+ setHover(effectRoot: TransformNode | null): void;
19
+ follow(): void;
20
+ /** Ray-picks an in-scene effect root from canvas coordinates. */
21
+ pick(clientX: number, clientY: number): TransformNode | null;
22
+ dispose(): void;
23
+ private createHandle;
24
+ private clearHandles;
25
+ }
@@ -0,0 +1,86 @@
1
+ import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
2
+ import { EDITOR_SCENE_NODE_METADATA } from './editorScene';
3
+ import { GROUND_RING_RADIUS, GalleryHoverMarker } from './gallerySelectionMarker';
4
+ /** Invisible pick proxy size — matches the ground ring footprint. */
5
+ const PICK_DIAMETER = GROUND_RING_RADIUS * 2;
6
+ /**
7
+ * Viewport pick/hover for in-scene gallery effect roots only (not child emitters).
8
+ * Hover uses the same ground ring as focus selection, in yellow.
9
+ */
10
+ export class GalleryViewportPicker {
11
+ constructor(scene) {
12
+ this.handles = new Map();
13
+ this.hoveredRoot = null;
14
+ this.syncKey = '';
15
+ this.scene = scene;
16
+ this.hoverMarker = new GalleryHoverMarker(scene);
17
+ }
18
+ /** Rebuilds root pick colliders when the gallery changes. */
19
+ sync(entries, soloRoot, syncKey) {
20
+ if (syncKey === this.syncKey) {
21
+ return;
22
+ }
23
+ this.syncKey = syncKey;
24
+ this.clearHandles();
25
+ if (entries.length > 0) {
26
+ for (const entry of entries) {
27
+ if (!entry.inScene || entry.root.isDisposed()) {
28
+ continue;
29
+ }
30
+ this.createHandle(entry.root);
31
+ }
32
+ }
33
+ else if (soloRoot && !soloRoot.isDisposed()) {
34
+ this.createHandle(soloRoot);
35
+ }
36
+ const hovered = this.hoveredRoot;
37
+ this.hoveredRoot = null;
38
+ this.setHover(hovered && !hovered.isDisposed() ? hovered : null);
39
+ }
40
+ /** Yellow focus-style ring under the hovered effect root. */
41
+ setHover(effectRoot) {
42
+ if (effectRoot === this.hoveredRoot) {
43
+ return;
44
+ }
45
+ this.hoveredRoot = effectRoot && !effectRoot.isDisposed() ? effectRoot : null;
46
+ if (!this.hoveredRoot) {
47
+ this.hoverMarker.hide();
48
+ return;
49
+ }
50
+ this.hoverMarker.show(this.hoveredRoot);
51
+ }
52
+ follow() {
53
+ this.hoverMarker.follow();
54
+ }
55
+ /** Ray-picks an in-scene effect root from canvas coordinates. */
56
+ pick(clientX, clientY) {
57
+ const canvas = this.scene.getEngine().getRenderingCanvas();
58
+ if (!canvas) {
59
+ return null;
60
+ }
61
+ const rect = canvas.getBoundingClientRect();
62
+ const pickInfo = this.scene.pick(clientX - rect.left, clientY - rect.top, (mesh) => !!mesh.metadata?.quarksEditorPick);
63
+ const meta = pickInfo?.pickedMesh?.metadata;
64
+ return meta?.quarksEditorPick ? meta.effectRoot : null;
65
+ }
66
+ dispose() {
67
+ this.clearHandles();
68
+ this.hoverMarker.dispose();
69
+ }
70
+ createHandle(effectRoot) {
71
+ const mesh = MeshBuilder.CreateSphere(`viewport-pick-root-${effectRoot.uniqueId}`, { diameter: PICK_DIAMETER, segments: 10 }, this.scene);
72
+ mesh.parent = effectRoot;
73
+ mesh.position.set(0, 0, 0);
74
+ mesh.isPickable = true;
75
+ mesh.visibility = 0;
76
+ const metadata = { quarksEditorPick: true, effectRoot };
77
+ mesh.metadata = { ...EDITOR_SCENE_NODE_METADATA, ...metadata };
78
+ this.handles.set(effectRoot.uniqueId, mesh);
79
+ }
80
+ clearHandles() {
81
+ for (const mesh of this.handles.values()) {
82
+ mesh.dispose();
83
+ }
84
+ this.handles.clear();
85
+ }
86
+ }
package/dist/index.d.ts CHANGED
@@ -12,4 +12,6 @@ export type { GradientStop } from './core/colors';
12
12
  export { SHAPE_TYPES, SHAPE_PARAM_KEYS, DEFAULT_SHAPE_PARAMS, getShapeType, readShapeParams, createShape, } from './core/shapes';
13
13
  export type { ShapeType, ShapeParams } from './core/shapes';
14
14
  export { GroundPlaneResolver, ensureGroundResolver } from './core/collision';
15
+ export { applyRendererMaterial, getMaterialLabel } from './core/material';
16
+ export type { RendererMaterialPatch } from './core/material';
15
17
  export { ensurePortableTextureUrl } from './core/textureExport';
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@ export { readScalar, buildScalar, sampleCurve, readPieces, buildCurve, splitPiec
6
6
  export { DEFAULT_GRADIENT_STOPS, buildGradient, buildColorOverLife, rgbaToVector4, stopToCss, stopsToCssGradient, hexToRgb, rgbToHex, findBehavior, } from './core/colors';
7
7
  export { SHAPE_TYPES, SHAPE_PARAM_KEYS, DEFAULT_SHAPE_PARAMS, getShapeType, readShapeParams, createShape, } from './core/shapes';
8
8
  export { GroundPlaneResolver, ensureGroundResolver } from './core/collision';
9
+ export { applyRendererMaterial, getMaterialLabel } from './core/material';
9
10
  export { ensurePortableTextureUrl } from './core/textureExport';
@@ -28,8 +28,5 @@ export function EffectEditor(props) {
28
28
  }, [props.binding, activeSystem]);
29
29
  useSyncExternalStore((onStoreChange) => binding.subscribe(onStoreChange), () => binding.getRevision());
30
30
  const gizmoTargetIsEmitter = allSystems.some((s) => s.emitter === props.gizmoTargetNode);
31
- if (!gizmoTargetIsEmitter) {
32
- return (_jsx("div", { style: { fontFamily: theme.font, color: theme.text }, children: _jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel }) }));
33
- }
34
- return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel }), _jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(RotationBySpeedModule, { binding: binding }), _jsx(NoiseModule, { binding: binding }), _jsx(TurbulenceModule, { binding: binding }), _jsx(CollisionModule, { binding: binding }), _jsx(WidthOverTrailModule, { binding: binding }), _jsx(SubEmittersModule, { binding: binding, rootBinding: props.binding }), _jsx(TextureSheetModule, { binding: binding }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
31
+ return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel }), !gizmoTargetIsEmitter && (_jsxs("div", { style: { fontSize: 11, color: theme.textDim, margin: '0 0 10px', lineHeight: 1.45 }, children: ["Modules below edit ", _jsx("strong", { children: activeSystem.emitter.name || 'particle system' }), ". Select its emitter in the timeline to move it with the gizmo."] })), _jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(RotationBySpeedModule, { binding: binding }), _jsx(NoiseModule, { binding: binding }), _jsx(TurbulenceModule, { binding: binding }), _jsx(CollisionModule, { binding: binding }), _jsx(WidthOverTrailModule, { binding: binding }), _jsx(SubEmittersModule, { binding: binding, rootBinding: props.binding }), _jsx(TextureSheetModule, { binding: binding }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
35
32
  }