babylon.quarks-editor 0.17.7

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 (91) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/core/binding.d.ts +43 -0
  5. package/dist/core/binding.js +82 -0
  6. package/dist/core/collision.d.ts +20 -0
  7. package/dist/core/collision.js +39 -0
  8. package/dist/core/colors.d.ts +31 -0
  9. package/dist/core/colors.js +60 -0
  10. package/dist/core/effectTree.d.ts +25 -0
  11. package/dist/core/effectTree.js +115 -0
  12. package/dist/core/groups.d.ts +12 -0
  13. package/dist/core/groups.js +27 -0
  14. package/dist/core/history.d.ts +25 -0
  15. package/dist/core/history.js +69 -0
  16. package/dist/core/loadEffect.d.ts +8 -0
  17. package/dist/core/loadEffect.js +87 -0
  18. package/dist/core/scrub.d.ts +11 -0
  19. package/dist/core/scrub.js +24 -0
  20. package/dist/core/shapes.d.ts +31 -0
  21. package/dist/core/shapes.js +98 -0
  22. package/dist/core/systems.d.ts +25 -0
  23. package/dist/core/systems.js +76 -0
  24. package/dist/core/textureExport.d.ts +9 -0
  25. package/dist/core/textureExport.js +58 -0
  26. package/dist/core/timeline.d.ts +35 -0
  27. package/dist/core/timeline.js +69 -0
  28. package/dist/core/values.d.ts +31 -0
  29. package/dist/core/values.js +81 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.js +9 -0
  32. package/dist/react/CurveEditor.d.ts +12 -0
  33. package/dist/react/CurveEditor.js +106 -0
  34. package/dist/react/EffectEditor.d.ts +22 -0
  35. package/dist/react/EffectEditor.js +30 -0
  36. package/dist/react/EffectEditorHost.d.ts +42 -0
  37. package/dist/react/EffectEditorHost.js +247 -0
  38. package/dist/react/GradientEditor.d.ts +7 -0
  39. package/dist/react/GradientEditor.js +72 -0
  40. package/dist/react/ModuleSection.d.ts +9 -0
  41. package/dist/react/ModuleSection.js +24 -0
  42. package/dist/react/PromptDialog.d.ts +14 -0
  43. package/dist/react/PromptDialog.js +45 -0
  44. package/dist/react/SubEmittersModule.d.ts +10 -0
  45. package/dist/react/SubEmittersModule.js +39 -0
  46. package/dist/react/TimelinePanel.d.ts +32 -0
  47. package/dist/react/TimelinePanel.js +225 -0
  48. package/dist/react/TimelineTrackRow.d.ts +23 -0
  49. package/dist/react/TimelineTrackRow.js +107 -0
  50. package/dist/react/ValueField.d.ts +13 -0
  51. package/dist/react/ValueField.js +19 -0
  52. package/dist/react/behaviorModules.d.ts +23 -0
  53. package/dist/react/behaviorModules.js +139 -0
  54. package/dist/react/fields.d.ts +24 -0
  55. package/dist/react/fields.js +30 -0
  56. package/dist/react/index.d.ts +20 -0
  57. package/dist/react/index.js +14 -0
  58. package/dist/react/modules.d.ts +35 -0
  59. package/dist/react/modules.js +439 -0
  60. package/dist/react/theme.d.ts +26 -0
  61. package/dist/react/theme.js +78 -0
  62. package/package.json +63 -0
  63. package/src/core/binding.ts +105 -0
  64. package/src/core/collision.ts +40 -0
  65. package/src/core/colors.ts +84 -0
  66. package/src/core/effectTree.ts +143 -0
  67. package/src/core/groups.ts +29 -0
  68. package/src/core/history.ts +77 -0
  69. package/src/core/loadEffect.ts +102 -0
  70. package/src/core/scrub.ts +28 -0
  71. package/src/core/shapes.ts +133 -0
  72. package/src/core/systems.ts +98 -0
  73. package/src/core/textureExport.ts +63 -0
  74. package/src/core/timeline.ts +94 -0
  75. package/src/core/values.ts +113 -0
  76. package/src/index.ts +32 -0
  77. package/src/react/CurveEditor.tsx +168 -0
  78. package/src/react/EffectEditor.tsx +111 -0
  79. package/src/react/EffectEditorHost.tsx +377 -0
  80. package/src/react/GradientEditor.tsx +127 -0
  81. package/src/react/ModuleSection.tsx +54 -0
  82. package/src/react/PromptDialog.tsx +85 -0
  83. package/src/react/SubEmittersModule.tsx +80 -0
  84. package/src/react/TimelinePanel.tsx +354 -0
  85. package/src/react/TimelineTrackRow.tsx +267 -0
  86. package/src/react/ValueField.tsx +60 -0
  87. package/src/react/behaviorModules.tsx +658 -0
  88. package/src/react/fields.tsx +76 -0
  89. package/src/react/index.ts +43 -0
  90. package/src/react/modules.tsx +1034 -0
  91. package/src/react/theme.ts +85 -0
@@ -0,0 +1,69 @@
1
+ const COALESCE_MS = 400;
2
+ const MAX_DEPTH = 50;
3
+ /**
4
+ * Snapshot-based undo/redo over serialized effect JSON. Owned by the host (not the
5
+ * binding) so history survives rebinding when a restore rebuilds the systems.
6
+ *
7
+ * Wire-up: call attach() whenever a binding is (re)mounted; on undo()/redo() feed the
8
+ * returned JSON back through the host's import path and attach the new binding.
9
+ */
10
+ export class EffectHistory {
11
+ constructor() {
12
+ this.undoStack = [];
13
+ this.redoStack = [];
14
+ this.lastCaptureTime = 0;
15
+ this.binding = null;
16
+ }
17
+ /** Points the history at the active binding and hooks snapshot capture. */
18
+ attach(binding) {
19
+ this.binding = binding;
20
+ binding.onBeforeChange = () => this.capture();
21
+ }
22
+ get canUndo() {
23
+ return this.undoStack.length > 0;
24
+ }
25
+ get canRedo() {
26
+ return this.redoStack.length > 0;
27
+ }
28
+ clear() {
29
+ this.undoStack = [];
30
+ this.redoStack = [];
31
+ this.lastCaptureTime = 0;
32
+ }
33
+ /** Pushes an undo snapshot; rapid consecutive edits are coalesced into one step. */
34
+ capture() {
35
+ if (!this.binding) {
36
+ return;
37
+ }
38
+ const now = Date.now();
39
+ if (now - this.lastCaptureTime < COALESCE_MS && this.undoStack.length > 0) {
40
+ return;
41
+ }
42
+ this.lastCaptureTime = now;
43
+ this.undoStack.push(this.binding.exportJSON());
44
+ if (this.undoStack.length > MAX_DEPTH) {
45
+ this.undoStack.shift();
46
+ }
47
+ this.redoStack = [];
48
+ }
49
+ /** Returns the previous effect state as parsed JSON, or null when nothing to undo. */
50
+ undo() {
51
+ if (!this.binding || this.undoStack.length === 0) {
52
+ return null;
53
+ }
54
+ const snapshot = this.undoStack.pop();
55
+ this.redoStack.push(this.binding.exportJSON());
56
+ this.lastCaptureTime = 0;
57
+ return JSON.parse(snapshot);
58
+ }
59
+ /** Returns the next effect state as parsed JSON, or null when nothing to redo. */
60
+ redo() {
61
+ if (!this.binding || this.redoStack.length === 0) {
62
+ return null;
63
+ }
64
+ const snapshot = this.redoStack.pop();
65
+ this.undoStack.push(this.binding.exportJSON());
66
+ this.lastCaptureTime = 0;
67
+ return JSON.parse(snapshot);
68
+ }
69
+ }
@@ -0,0 +1,8 @@
1
+ import type { BatchedRenderer, ParticleSystem } from 'babylon.quarks';
2
+ import type { Scene } from '@babylonjs/core/scene';
3
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
+ /** Parses a Quarks JSON export, registers all systems with the renderer and starts playback. */
5
+ export declare function loadEffectFromJson(scene: Scene, renderer: BatchedRenderer, json: unknown, baseUrl?: string): {
6
+ root: TransformNode;
7
+ systems: ParticleSystem[];
8
+ };
@@ -0,0 +1,87 @@
1
+ import { ParticleEmitter, QuarksLoader, QuarksUtil } from 'babylon.quarks';
2
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
+ /** `ParticleEmitter`'s constructor default (see babylon.quarks/src/ParticleEmitter.ts) — the
4
+ * name every emitter gets when the source JSON didn't give it one of its own. */
5
+ const DEFAULT_EMITTER_NAME = 'particleEmitter';
6
+ function hasMeaningfulName(node) {
7
+ return !!node.name && node.name !== DEFAULT_EMITTER_NAME;
8
+ }
9
+ function groupChildren(node) {
10
+ return node.getChildren().filter((c) => c instanceof TransformNode);
11
+ }
12
+ /** Reparents `only` onto `parent`, transferring the outgoing node's name if `only` doesn't
13
+ * already have a meaningful one of its own, then disposes the now-empty wrapper. World
14
+ * transform is preserved via `setParent`. */
15
+ function collapseInto(wrapper, only, parent) {
16
+ if (!hasMeaningfulName(only) && hasMeaningfulName(wrapper)) {
17
+ only.name = wrapper.name;
18
+ }
19
+ only.setParent(parent);
20
+ wrapper.dispose(true, false);
21
+ }
22
+ /**
23
+ * Collapses redundant organizational groups left over by importers — Unity's
24
+ * one-GameObject-per-component convention turns every visual layer into its own Group, so a
25
+ * single particle system becomes "Sparks" > unnamed emitter, and a component quarks doesn't
26
+ * understand (e.g. a Light) becomes an empty Group with nothing under it. Recurses
27
+ * bottom-up so nested wrapper chains collapse all the way down. The tree root itself is
28
+ * handled separately by `promoteIfSingleChildRoot`, since collapsing it means returning a
29
+ * different node as the effect's root.
30
+ */
31
+ function flattenRedundantGroups(node) {
32
+ for (const child of node.getChildren()) {
33
+ if (child instanceof TransformNode) {
34
+ flattenRedundantGroups(child);
35
+ }
36
+ }
37
+ if (node instanceof ParticleEmitter || !node.parent) {
38
+ return;
39
+ }
40
+ const children = groupChildren(node);
41
+ if (children.length === 0) {
42
+ node.dispose(true, false);
43
+ return;
44
+ }
45
+ if (children.length !== 1) {
46
+ return;
47
+ }
48
+ const only = children[0];
49
+ if (only instanceof ParticleEmitter && only.getChildren().length > 0) {
50
+ return;
51
+ }
52
+ collapseInto(node, only, node.parent);
53
+ }
54
+ /** If the tree root itself only ever wraps a single child (after `flattenRedundantGroups`
55
+ * has settled everything below it), that wrapper adds a tree level with nothing to organize —
56
+ * promote the child to be the new root instead. Loops in case that child is, in turn, also a
57
+ * single-child wrapper. */
58
+ function promoteIfSingleChildRoot(root) {
59
+ let current = root;
60
+ while (!(current instanceof ParticleEmitter)) {
61
+ const children = groupChildren(current);
62
+ if (children.length !== 1) {
63
+ break;
64
+ }
65
+ const only = children[0];
66
+ const parent = current.parent;
67
+ collapseInto(current, only, parent);
68
+ current = only;
69
+ }
70
+ return current;
71
+ }
72
+ /** Parses a Quarks JSON export, registers all systems with the renderer and starts playback. */
73
+ export function loadEffectFromJson(scene, renderer, json, baseUrl = '') {
74
+ const loader = new QuarksLoader(scene, { baseUrl });
75
+ let root = loader.parse(json, baseUrl);
76
+ flattenRedundantGroups(root);
77
+ root = promoteIfSingleChildRoot(root);
78
+ const systems = [];
79
+ QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
80
+ const system = emitter.system;
81
+ renderer.addSystem(system);
82
+ systems.push(system);
83
+ });
84
+ QuarksUtil.restart(root);
85
+ QuarksUtil.play(root);
86
+ return { root, systems };
87
+ }
@@ -0,0 +1,11 @@
1
+ import type { BatchedRenderer } from 'babylon.quarks';
2
+ import type { EffectBinding } from './binding';
3
+ /**
4
+ * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
5
+ * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
6
+ * `EffectBinding.restart()` and `BatchedRenderer.update()` verbatim — no new engine primitive.
7
+ *
8
+ * `ParticleSystem.update()` clamps its own `delta` to 0.1s per call, so a single big step
9
+ * can't reach `time` directly; must step through in small increments instead.
10
+ */
11
+ export declare function scrubTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void;
@@ -0,0 +1,24 @@
1
+ const WIDE_STEP_THRESHOLD = 8;
2
+ /**
3
+ * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
4
+ * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
5
+ * `EffectBinding.restart()` and `BatchedRenderer.update()` verbatim — no new engine primitive.
6
+ *
7
+ * `ParticleSystem.update()` clamps its own `delta` to 0.1s per call, so a single big step
8
+ * can't reach `time` directly; must step through in small increments instead.
9
+ */
10
+ export function scrubTo(binding, renderer, time) {
11
+ binding.restart();
12
+ if (time <= 0) {
13
+ return;
14
+ }
15
+ const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
16
+ const steps = Math.floor(time / fixedStep);
17
+ for (let i = 0; i < steps; i++) {
18
+ renderer.update(fixedStep);
19
+ }
20
+ const remainder = time - steps * fixedStep;
21
+ if (remainder > 0) {
22
+ renderer.update(remainder);
23
+ }
24
+ }
@@ -0,0 +1,31 @@
1
+ import type { EmitterShape } from 'babylon.quarks';
2
+ import { EmitterMode } from 'babylon.quarks';
3
+ import type { Mesh } from '@babylonjs/core/Meshes/mesh';
4
+ export type ShapeType = 'point' | 'sphere' | 'hemisphere' | 'cone' | 'circle' | 'donut' | 'rectangle' | 'grid' | 'mesh_surface';
5
+ export declare const SHAPE_TYPES: Array<{
6
+ value: ShapeType;
7
+ label: string;
8
+ }>;
9
+ export interface ShapeParams {
10
+ radius: number;
11
+ arc: number;
12
+ thickness: number;
13
+ angle: number;
14
+ donutRadius: number;
15
+ width: number;
16
+ height: number;
17
+ mode: EmitterMode;
18
+ spread: number;
19
+ column: number;
20
+ row: number;
21
+ mesh?: Mesh;
22
+ }
23
+ export declare const DEFAULT_SHAPE_PARAMS: ShapeParams;
24
+ export declare function getShapeType(shape: EmitterShape): ShapeType;
25
+ export declare function readShapeParams(shape: EmitterShape): ShapeParams;
26
+ /** Which parameters are relevant per shape type (drives the UI). `mesh_surface`'s mesh
27
+ * reference isn't a NumberField, so it's handled separately by the Shape module. */
28
+ export declare const SHAPE_PARAM_KEYS: {
29
+ [K in ShapeType]: Array<keyof ShapeParams>;
30
+ };
31
+ export declare function createShape(type: ShapeType, params: ShapeParams): EmitterShape;
@@ -0,0 +1,98 @@
1
+ import { CircleEmitter, ConeEmitter, DonutEmitter, GridEmitter, HemisphereEmitter, MeshSurfaceEmitter, PointEmitter, RectangleEmitter, SphereEmitter, } from 'babylon.quarks';
2
+ import { EmitterMode } from 'babylon.quarks';
3
+ export const SHAPE_TYPES = [
4
+ { value: 'cone', label: 'Cone' },
5
+ { value: 'sphere', label: 'Sphere' },
6
+ { value: 'hemisphere', label: 'Hemisphere' },
7
+ { value: 'circle', label: 'Circle' },
8
+ { value: 'donut', label: 'Donut' },
9
+ { value: 'rectangle', label: 'Rectangle' },
10
+ { value: 'grid', label: 'Grid' },
11
+ { value: 'mesh_surface', label: 'Mesh surface' },
12
+ { value: 'point', label: 'Point' },
13
+ ];
14
+ export const DEFAULT_SHAPE_PARAMS = {
15
+ radius: 0.3,
16
+ arc: Math.PI * 2,
17
+ thickness: 1,
18
+ angle: 0.5,
19
+ donutRadius: 0.1,
20
+ width: 1,
21
+ height: 1,
22
+ mode: EmitterMode.Random,
23
+ spread: 0,
24
+ column: 10,
25
+ row: 10,
26
+ mesh: undefined,
27
+ };
28
+ export function getShapeType(shape) {
29
+ const type = shape.type ?? 'point';
30
+ return (SHAPE_TYPES.some((s) => s.value === type) ? type : 'point');
31
+ }
32
+ export function readShapeParams(shape) {
33
+ const anyShape = shape;
34
+ const num = (key, fallback) => (typeof anyShape[key] === 'number' ? anyShape[key] : fallback);
35
+ return {
36
+ radius: num('radius', DEFAULT_SHAPE_PARAMS.radius),
37
+ mode: num('mode', DEFAULT_SHAPE_PARAMS.mode),
38
+ spread: num('spread', DEFAULT_SHAPE_PARAMS.spread),
39
+ arc: num('arc', DEFAULT_SHAPE_PARAMS.arc),
40
+ thickness: num('thickness', DEFAULT_SHAPE_PARAMS.thickness),
41
+ angle: num('angle', DEFAULT_SHAPE_PARAMS.angle),
42
+ donutRadius: num('donutRadius', DEFAULT_SHAPE_PARAMS.donutRadius),
43
+ width: num('width', DEFAULT_SHAPE_PARAMS.width),
44
+ height: num('height', DEFAULT_SHAPE_PARAMS.height),
45
+ column: num('column', DEFAULT_SHAPE_PARAMS.column),
46
+ row: num('row', DEFAULT_SHAPE_PARAMS.row),
47
+ mesh: anyShape.mesh,
48
+ };
49
+ }
50
+ /** Which parameters are relevant per shape type (drives the UI). `mesh_surface`'s mesh
51
+ * reference isn't a NumberField, so it's handled separately by the Shape module. */
52
+ export const SHAPE_PARAM_KEYS = {
53
+ point: [],
54
+ sphere: ['radius', 'arc', 'thickness'],
55
+ hemisphere: ['radius', 'arc', 'thickness'],
56
+ cone: ['radius', 'arc', 'thickness', 'angle'],
57
+ circle: ['radius', 'arc', 'thickness'],
58
+ donut: ['radius', 'arc', 'thickness', 'donutRadius'],
59
+ rectangle: ['width', 'height'],
60
+ grid: ['width', 'height', 'column', 'row'],
61
+ mesh_surface: [],
62
+ };
63
+ export function createShape(type, params) {
64
+ switch (type) {
65
+ case 'point':
66
+ return new PointEmitter();
67
+ case 'sphere':
68
+ return new SphereEmitter({ radius: params.radius, arc: params.arc, thickness: params.thickness, mode: params.mode, spread: params.spread });
69
+ case 'hemisphere':
70
+ return new HemisphereEmitter({ radius: params.radius, arc: params.arc, thickness: params.thickness, mode: params.mode, spread: params.spread });
71
+ case 'cone':
72
+ return new ConeEmitter({
73
+ radius: params.radius,
74
+ arc: params.arc,
75
+ thickness: params.thickness,
76
+ angle: params.angle,
77
+ mode: params.mode,
78
+ spread: params.spread,
79
+ });
80
+ case 'circle':
81
+ return new CircleEmitter({ radius: params.radius, arc: params.arc, thickness: params.thickness, mode: params.mode, spread: params.spread });
82
+ case 'donut':
83
+ return new DonutEmitter({
84
+ radius: params.radius,
85
+ arc: params.arc,
86
+ thickness: params.thickness,
87
+ donutRadius: params.donutRadius,
88
+ mode: params.mode,
89
+ spread: params.spread,
90
+ });
91
+ case 'rectangle':
92
+ return new RectangleEmitter({ width: params.width, height: params.height, mode: params.mode, spread: params.spread });
93
+ case 'grid':
94
+ return new GridEmitter({ width: params.width, height: params.height, column: params.column, row: params.row });
95
+ case 'mesh_surface':
96
+ return new MeshSurfaceEmitter(params.mesh);
97
+ }
98
+ }
@@ -0,0 +1,25 @@
1
+ import { ParticleSystem } from 'babylon.quarks';
2
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
+ import type { EffectBinding } from './binding';
4
+ export interface ChildSystemOptions {
5
+ name?: string;
6
+ /** One-shot burst (sub-emitter target) instead of a looping stream. */
7
+ oneShot?: boolean;
8
+ /** Attach point override — defaults to `parent.emitter`. Lets a new system land as a
9
+ * sibling under a group node instead of nesting under an existing system. */
10
+ parentNode?: TransformNode;
11
+ }
12
+ /**
13
+ * Creates a child system sharing the parent's scene/texture/blending, parented to the
14
+ * parent emitter (or `options.parentNode` when given) — the Unity pattern where an effect is
15
+ * a hierarchy of systems and groups.
16
+ */
17
+ export declare function createChildSystem(parent: ParticleSystem, options?: ChildSystemOptions): ParticleSystem;
18
+ /**
19
+ * Strips EmitSubParticleSystem behaviors that point at any of the removed systems, so
20
+ * deleting a sub-emitter target never leaves a dangling reference (which crashes the next
21
+ * update with `reading 'duration'` on the disposed emitter).
22
+ */
23
+ export declare function unlinkSubEmitterReferences(systems: ParticleSystem[], removed: ParticleSystem[]): void;
24
+ /** Disposes the given systems, first unlinking any sub-emitter behaviors that target them. */
25
+ export declare function removeSystems(binding: EffectBinding, targets: ParticleSystem[], onSelect: (system: ParticleSystem) => void): void;
@@ -0,0 +1,76 @@
1
+ import { ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
2
+ /**
3
+ * Creates a child system sharing the parent's scene/texture/blending, parented to the
4
+ * parent emitter (or `options.parentNode` when given) — the Unity pattern where an effect is
5
+ * a hierarchy of systems and groups.
6
+ */
7
+ export function createChildSystem(parent, options = {}) {
8
+ const oneShot = options.oneShot ?? false;
9
+ const child = new ParticleSystem({
10
+ scene: parent.emitter.getScene(),
11
+ duration: oneShot ? 1 : parent.duration,
12
+ looping: !oneShot,
13
+ startLife: new IntervalValue(0.3, oneShot ? 0.6 : 1.2),
14
+ startSpeed: new IntervalValue(1, 3),
15
+ startSize: new IntervalValue(0.05, 0.2),
16
+ startColor: new ConstantColor(new Vector4(1, 0.9, 0.6, 1)),
17
+ emissionOverTime: new ConstantValue(oneShot ? 0 : 30),
18
+ emissionBursts: oneShot ? [{ time: 0, count: new ConstantValue(8), cycle: 1, interval: 0.01, probability: 1 }] : [],
19
+ shape: new ConeEmitter({ radius: 0.1, angle: Math.PI / 3 }),
20
+ renderMode: RenderMode.BillBoard,
21
+ texture: parent.texture ?? undefined,
22
+ transparent: true,
23
+ blendMode: parent.blending,
24
+ worldSpace: true,
25
+ });
26
+ child.emitter.name = options.name ?? (oneShot ? 'sub-emitter' : 'child-system');
27
+ if (oneShot) {
28
+ child.onlyUsedByOther = true;
29
+ }
30
+ child.emitter.parent = options.parentNode ?? parent.emitter;
31
+ return child;
32
+ }
33
+ /**
34
+ * Strips EmitSubParticleSystem behaviors that point at any of the removed systems, so
35
+ * deleting a sub-emitter target never leaves a dangling reference (which crashes the next
36
+ * update with `reading 'duration'` on the disposed emitter).
37
+ */
38
+ export function unlinkSubEmitterReferences(systems, removed) {
39
+ const removedEmitters = new Set(removed.map((system) => system.emitter));
40
+ for (const system of systems) {
41
+ const behaviors = system.behaviors;
42
+ for (let i = behaviors.length - 1; i >= 0; i--) {
43
+ const behavior = behaviors[i];
44
+ if (behavior.type === 'EmitSubParticleSystem' && removedEmitters.has(behavior.subParticleSystem)) {
45
+ behaviors.splice(i, 1);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ /** Disposes the given systems, first unlinking any sub-emitter behaviors that target them. */
51
+ export function removeSystems(binding, targets, onSelect) {
52
+ // binding.system is "removable" too as long as some other top-level system can take over
53
+ // as the new main — being main vs. sub is an internal bookkeeping detail, not something
54
+ // that should make one arbitrary track un-deletable in the UI.
55
+ if (targets.includes(binding.system)) {
56
+ const replacement = binding.subSystems.find((s) => !targets.includes(s) && !s.onlyUsedByOther);
57
+ if (replacement) {
58
+ binding.promoteToMain(replacement);
59
+ }
60
+ }
61
+ const removable = targets.filter((system) => binding.subSystems.includes(system));
62
+ if (removable.length === 0) {
63
+ return;
64
+ }
65
+ binding.apply(() => {
66
+ unlinkSubEmitterReferences([binding.system, ...binding.subSystems], removable);
67
+ for (const system of removable) {
68
+ system._renderer?.deleteSystem(system);
69
+ system.dispose();
70
+ }
71
+ });
72
+ for (const system of removable) {
73
+ binding.removeSubSystem(system);
74
+ }
75
+ onSelect(binding.system);
76
+ }
@@ -0,0 +1,9 @@
1
+ import type { Texture } from '@babylonjs/core/Materials/Textures/texture';
2
+ /**
3
+ * Ensures a texture's `.url` can be reloaded in a different Scene/Engine, baking its live
4
+ * GPU pixels into a real base64 PNG data URI when it currently only carries Babylon's
5
+ * internal placeholder url (see isPortableUrl). No-ops for textures that already have a
6
+ * fetchable or real `data:` url. Call before serializing a scene that will be reloaded
7
+ * elsewhere — e.g. the demo viewer's "Open in editor" handoff, which navigates to a new page.
8
+ */
9
+ export declare function ensurePortableTextureUrl(texture: Texture): Promise<void>;
@@ -0,0 +1,58 @@
1
+ const bakedUrls = new WeakMap();
2
+ /**
3
+ * Babylon's glTF loader gives embedded (base64-in-buffer) images a placeholder `data:` url
4
+ * (e.g. `data:leave.glb#image0`) that only works as an in-memory cache key paired with the
5
+ * pixel buffer it loaded alongside — it has no real payload, so a fresh Scene/Engine (e.g.
6
+ * after "Open in editor" navigates to a new page) can't refetch or reconstruct it from that
7
+ * string. Real data URIs always carry a comma-delimited payload; this placeholder never does.
8
+ */
9
+ function isPortableUrl(url) {
10
+ if (!url) {
11
+ return false;
12
+ }
13
+ return !url.startsWith('data:') || url.includes(',');
14
+ }
15
+ /**
16
+ * Ensures a texture's `.url` can be reloaded in a different Scene/Engine, baking its live
17
+ * GPU pixels into a real base64 PNG data URI when it currently only carries Babylon's
18
+ * internal placeholder url (see isPortableUrl). No-ops for textures that already have a
19
+ * fetchable or real `data:` url. Call before serializing a scene that will be reloaded
20
+ * elsewhere — e.g. the demo viewer's "Open in editor" handoff, which navigates to a new page.
21
+ */
22
+ export async function ensurePortableTextureUrl(texture) {
23
+ if (isPortableUrl(texture.url)) {
24
+ return;
25
+ }
26
+ const cached = bakedUrls.get(texture);
27
+ if (cached) {
28
+ texture.url = cached;
29
+ return;
30
+ }
31
+ const dataUrl = await bakeTextureToDataUrl(texture);
32
+ if (dataUrl) {
33
+ bakedUrls.set(texture, dataUrl);
34
+ texture.url = dataUrl;
35
+ }
36
+ }
37
+ async function bakeTextureToDataUrl(texture) {
38
+ const size = texture.getSize();
39
+ if (!size.width || !size.height) {
40
+ return undefined;
41
+ }
42
+ const pixels = await texture.readPixels();
43
+ if (!(pixels instanceof Uint8Array || pixels instanceof Uint8ClampedArray)) {
44
+ return undefined;
45
+ }
46
+ const canvas = document.createElement('canvas');
47
+ canvas.width = size.width;
48
+ canvas.height = size.height;
49
+ const ctx = canvas.getContext('2d');
50
+ if (!ctx) {
51
+ return undefined;
52
+ }
53
+ // Copy (rather than wrap) so the result is backed by a plain ArrayBuffer — ImageData
54
+ // rejects the SharedArrayBuffer-compatible view type `readPixels` may hand back.
55
+ const bytes = new Uint8ClampedArray(pixels);
56
+ ctx.putImageData(new ImageData(bytes, size.width, size.height), 0, 0);
57
+ return canvas.toDataURL('image/png');
58
+ }
@@ -0,0 +1,35 @@
1
+ import type { ParticleSystem } from 'babylon.quarks';
2
+ import type { FunctionValueGenerator, ValueGenerator } from 'babylon.quarks';
3
+ import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
+ import type { EffectBinding } from './binding';
5
+ export type TimelineRow = {
6
+ kind: 'group';
7
+ node: TransformNode;
8
+ name: string;
9
+ depth: number;
10
+ } | {
11
+ kind: 'track';
12
+ node: TransformNode;
13
+ system: ParticleSystem;
14
+ name: string;
15
+ depth: number;
16
+ isRoot: boolean;
17
+ removable: boolean;
18
+ startOffset: number;
19
+ startOffsetVariable: boolean;
20
+ duration: number;
21
+ looping: boolean;
22
+ };
23
+ /**
24
+ * Reads a representative start-delay value for display without invoking the generator's
25
+ * stateful `genValue` (IntervalValue caches its sampled slot on first call and returns NaN
26
+ * on a later call with a fresh memory array, so it can't be safely re-read on every render).
27
+ */
28
+ export declare function computeStartOffset(gen: ValueGenerator | FunctionValueGenerator): {
29
+ value: number;
30
+ startOffsetVariable: boolean;
31
+ };
32
+ /** Flattens the effect's group/emitter tree into display rows, preserving tree-walk order. */
33
+ export declare function computeTimelineRows(binding: EffectBinding): TimelineRow[];
34
+ /** Overall visible timeline span: the furthest track extent, floored at a sane minimum. */
35
+ export declare function computeTimelineSpan(rows: TimelineRow[]): number;
@@ -0,0 +1,69 @@
1
+ import { ConstantValue, IntervalValue } from 'babylon.quarks';
2
+ const MIN_TIMELINE_SPAN = 4;
3
+ /**
4
+ * Reads a representative start-delay value for display without invoking the generator's
5
+ * stateful `genValue` (IntervalValue caches its sampled slot on first call and returns NaN
6
+ * on a later call with a fresh memory array, so it can't be safely re-read on every render).
7
+ */
8
+ export function computeStartOffset(gen) {
9
+ if (gen instanceof ConstantValue) {
10
+ return { value: gen.value, startOffsetVariable: false };
11
+ }
12
+ if (gen instanceof IntervalValue) {
13
+ return { value: Math.min(gen.a, gen.b), startOffsetVariable: false };
14
+ }
15
+ return { value: 0, startOffsetVariable: true };
16
+ }
17
+ /**
18
+ * Whether a track can be removed. Sub-systems always can; the designated "main" system can
19
+ * too as long as another top-level system remains to take over that role — being main vs.
20
+ * sub is an internal bookkeeping detail (see EffectBinding.promoteToMain), not something that
21
+ * should make one arbitrary track permanently un-deletable in the UI.
22
+ */
23
+ function isRemovable(binding, system) {
24
+ if (binding.subSystems.includes(system)) {
25
+ return true;
26
+ }
27
+ if (system === binding.system) {
28
+ return binding.subSystems.some((s) => !s.onlyUsedByOther);
29
+ }
30
+ return false;
31
+ }
32
+ /** Flattens the effect's group/emitter tree into display rows, preserving tree-walk order. */
33
+ export function computeTimelineRows(binding) {
34
+ const rows = [];
35
+ const walk = (node) => {
36
+ if (node.system) {
37
+ const { value, startOffsetVariable } = computeStartOffset(node.system.startDelay);
38
+ rows.push({
39
+ kind: 'track',
40
+ node: node.node,
41
+ system: node.system,
42
+ name: node.name,
43
+ depth: node.depth,
44
+ isRoot: node.node === binding.root,
45
+ removable: isRemovable(binding, node.system),
46
+ startOffset: value,
47
+ startOffsetVariable,
48
+ duration: node.system.duration,
49
+ looping: node.system.looping,
50
+ });
51
+ }
52
+ else {
53
+ rows.push({ kind: 'group', node: node.node, name: node.name, depth: node.depth });
54
+ }
55
+ node.children.forEach(walk);
56
+ };
57
+ walk(binding.getTree());
58
+ return rows;
59
+ }
60
+ /** Overall visible timeline span: the furthest track extent, floored at a sane minimum. */
61
+ export function computeTimelineSpan(rows) {
62
+ let span = 0;
63
+ for (const row of rows) {
64
+ if (row.kind === 'track') {
65
+ span = Math.max(span, row.startOffset + row.duration);
66
+ }
67
+ }
68
+ return Math.max(span, MIN_TIMELINE_SPAN);
69
+ }
@@ -0,0 +1,31 @@
1
+ import { PiecewiseBezier } from 'babylon.quarks';
2
+ import type { FunctionValueGenerator, ValueGenerator } from 'babylon.quarks';
3
+ export type ScalarGenerator = ValueGenerator | FunctionValueGenerator;
4
+ export type ScalarMode = 'constant' | 'random' | 'curve';
5
+ /** Editable snapshot of a scalar value generator. */
6
+ export interface ScalarValueState {
7
+ mode: ScalarMode;
8
+ /** Constant value (mode: constant). */
9
+ value: number;
10
+ /** Random range (mode: random). */
11
+ min: number;
12
+ max: number;
13
+ /** Cubic bezier control values over normalized lifetime (mode: curve). */
14
+ curve: [number, number, number, number];
15
+ }
16
+ export declare function readScalar(generator: ScalarGenerator | undefined): ScalarValueState;
17
+ export declare function buildScalar(state: ScalarValueState): ScalarGenerator;
18
+ /** One segment of a piecewise bezier curve: start x (0..1) + four control values. */
19
+ export interface CurvePiece {
20
+ start: number;
21
+ p: [number, number, number, number];
22
+ }
23
+ /** Reads pieces from a PiecewiseBezier (or synthesizes one segment for other generators). */
24
+ export declare function readPieces(generator: ScalarGenerator | undefined): CurvePiece[];
25
+ export declare function buildCurve(pieces: CurvePiece[]): PiecewiseBezier;
26
+ /** Splits the piece containing x into two exact halves using Bezier.split. */
27
+ export declare function splitPieces(pieces: CurvePiece[], x: number): CurvePiece[];
28
+ /** Removes the boundary at pieces[index].start by merging with the previous piece. */
29
+ export declare function mergePieces(pieces: CurvePiece[], index: number): CurvePiece[];
30
+ /** Samples a cubic bezier defined by four control values at normalized t. */
31
+ export declare function sampleCurve(curve: [number, number, number, number], t: number): number;