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,105 @@
1
+ import type {ParticleSystem} from 'babylon.quarks';
2
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
3
+ import {buildEffectTree, serializeEffectTree} from './effectTree';
4
+ import type {EffectTreeNode} from './effectTree';
5
+
6
+ export type EditorListener = () => void;
7
+
8
+ /**
9
+ * Headless edit-model over a live ParticleSystem: mutate through apply() so every
10
+ * change notifies subscribers (React UI, save indicators, hosts like BabylonJS Editor).
11
+ */
12
+ export class EffectBinding {
13
+ /** Not `readonly` — `promoteToMain` reassigns this when the current main system is removed. */
14
+ system: ParticleSystem;
15
+ /** Systems spawned by sub-emitter behaviors; serialized as children of the root emitter. */
16
+ readonly subSystems: ParticleSystem[] = [];
17
+ /** Loaded scene root of the whole effect (Group tree); defaults to the system's emitter. */
18
+ readonly root: TransformNode;
19
+ /** Invoked before any mutation — used by EffectHistory to capture undo snapshots. */
20
+ onBeforeChange?: () => void;
21
+ private listeners = new Set<EditorListener>();
22
+ private revision = 0;
23
+
24
+ constructor(system: ParticleSystem, subSystems: ParticleSystem[] = [], root?: TransformNode) {
25
+ this.system = system;
26
+ this.subSystems.push(...subSystems);
27
+ this.root = root ?? system.emitter;
28
+ }
29
+
30
+ /** Editor hierarchy tree (groups + emitters) rooted at this effect's scene root. */
31
+ getTree(): EffectTreeNode {
32
+ return buildEffectTree(this.root);
33
+ }
34
+
35
+ subscribe(listener: EditorListener): () => void {
36
+ this.listeners.add(listener);
37
+ return () => this.listeners.delete(listener);
38
+ }
39
+
40
+ /** Monotonic change counter, usable as a React external-store snapshot. */
41
+ getRevision(): number {
42
+ return this.revision;
43
+ }
44
+
45
+ apply(mutate: (system: ParticleSystem) => void): void {
46
+ this.onBeforeChange?.();
47
+ mutate(this.system);
48
+ this.notify();
49
+ }
50
+
51
+ addSubSystem(sub: ParticleSystem): void {
52
+ this.subSystems.push(sub);
53
+ this.notify();
54
+ }
55
+
56
+ removeSubSystem(sub: ParticleSystem): void {
57
+ const index = this.subSystems.indexOf(sub);
58
+ if (index >= 0) {
59
+ this.subSystems.splice(index, 1);
60
+ }
61
+ this.notify();
62
+ }
63
+
64
+ /**
65
+ * Swaps which system is "main" — used when the current `system` is about to be removed
66
+ * but other top-level systems remain. Being "main" vs. a sub-system is otherwise
67
+ * meaningless to the effect itself (both play/restart/export the same way); it only
68
+ * exists so there's always a designated fallback system to select.
69
+ */
70
+ promoteToMain(next: ParticleSystem): void {
71
+ const index = this.subSystems.indexOf(next);
72
+ if (index < 0) {
73
+ return;
74
+ }
75
+ this.subSystems.splice(index, 1, this.system);
76
+ this.system = next;
77
+ this.notify();
78
+ }
79
+
80
+ private notify(): void {
81
+ this.revision++;
82
+ for (const listener of this.listeners) {
83
+ listener();
84
+ }
85
+ }
86
+
87
+ restart(): void {
88
+ // Replay every independent system; sub-emitter targets (onlyUsedByOther) are only
89
+ // reset — their parents re-drive them, so playing them directly would double-emit.
90
+ for (const system of [this.system, ...this.subSystems]) {
91
+ system.restart();
92
+ if (!system.onlyUsedByOther) {
93
+ system.play();
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Serializes the effect into the Quarks JSON envelope understood by QuarksLoader,
100
+ * preserving the loaded group/emitter hierarchy.
101
+ */
102
+ exportJSON(name = 'effect'): string {
103
+ return serializeEffectTree(this.root, name);
104
+ }
105
+ }
@@ -0,0 +1,40 @@
1
+ import {Vector3, getPhysicsResolver, setPhysicsResolver} from 'babylon.quarks';
2
+
3
+ /**
4
+ * A horizontal ground plane collider. quarks.core's ApplyCollision behavior delegates to a
5
+ * host-provided `PhysicsResolver` (a global singleton) and ships no collider primitive of its
6
+ * own, so the editor supplies this — the common "particles bounce off the floor" case.
7
+ *
8
+ * `resolve` returns true and fills the surface normal when the point is at or below the plane,
9
+ * which is exactly what ApplyCollision.update reflects the velocity against.
10
+ */
11
+ export class GroundPlaneResolver {
12
+ constructor(public y = 0) {}
13
+
14
+ resolve(position: Vector3, normal: Vector3): boolean {
15
+ if (position.y <= this.y) {
16
+ normal.set(0, 1, 0);
17
+ return true;
18
+ }
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Registers (once) a shared ground-plane resolver so ApplyCollision behaviors — whether added
25
+ * in the inspector or deserialized from imported JSON — have something to collide against.
26
+ * Idempotent: an already-registered resolver is left untouched and returned.
27
+ */
28
+ export function ensureGroundResolver(): GroundPlaneResolver {
29
+ const existing = getPhysicsResolver();
30
+ if (existing instanceof GroundPlaneResolver) {
31
+ return existing;
32
+ }
33
+ if (existing) {
34
+ // A host registered its own resolver; don't clobber it, but we can't tweak its floor.
35
+ return existing as GroundPlaneResolver;
36
+ }
37
+ const resolver = new GroundPlaneResolver(0);
38
+ setPhysicsResolver(resolver);
39
+ return resolver;
40
+ }
@@ -0,0 +1,84 @@
1
+ import {ColorOverLife, Gradient, Vector3, Vector4} from 'babylon.quarks';
2
+ import type {Behavior} from 'babylon.quarks';
3
+
4
+ /** One RGBA stop of the editable gradient (all channels 0..1, pos 0..1). */
5
+ export interface GradientStop {
6
+ pos: number;
7
+ r: number;
8
+ g: number;
9
+ b: number;
10
+ a: number;
11
+ }
12
+
13
+ export const DEFAULT_GRADIENT_STOPS: GradientStop[] = [
14
+ {pos: 0, r: 1, g: 0.85, b: 0.4, a: 1},
15
+ {pos: 1, r: 1, g: 0.25, b: 0.05, a: 0},
16
+ ];
17
+
18
+ export function buildGradient(stops: GradientStop[]): Gradient {
19
+ const sorted = [...stops].sort((a, b) => a.pos - b.pos);
20
+ return new Gradient(
21
+ sorted.map((s) => [new Vector3(s.r, s.g, s.b), s.pos] as [Vector3, number]),
22
+ sorted.map((s) => [s.a, s.pos] as [number, number])
23
+ );
24
+ }
25
+
26
+ export function buildColorOverLife(stops: GradientStop[]): ColorOverLife {
27
+ return new ColorOverLife(buildGradient(stops));
28
+ }
29
+
30
+ export function rgbaToVector4(stop: {r: number; g: number; b: number; a: number}): Vector4 {
31
+ return new Vector4(stop.r, stop.g, stop.b, stop.a);
32
+ }
33
+
34
+ export function stopToCss(stop: GradientStop): string {
35
+ const c = (v: number) => Math.round(v * 255);
36
+ return `rgba(${c(stop.r)}, ${c(stop.g)}, ${c(stop.b)}, ${stop.a.toFixed(2)})`;
37
+ }
38
+
39
+ export function stopsToCssGradient(stops: GradientStop[]): string {
40
+ const sorted = [...stops].sort((a, b) => a.pos - b.pos);
41
+ const parts = sorted.map((s) => `${stopToCss(s)} ${(s.pos * 100).toFixed(1)}%`);
42
+ return `linear-gradient(90deg, ${parts.join(', ')})`;
43
+ }
44
+
45
+ export function hexToRgb(hex: string): {r: number; g: number; b: number} {
46
+ const value = hex.replace('#', '');
47
+ return {
48
+ r: parseInt(value.slice(0, 2), 16) / 255,
49
+ g: parseInt(value.slice(2, 4), 16) / 255,
50
+ b: parseInt(value.slice(4, 6), 16) / 255,
51
+ };
52
+ }
53
+
54
+ export function rgbToHex(r: number, g: number, b: number): string {
55
+ const c = (v: number) =>
56
+ Math.round(Math.min(1, Math.max(0, v)) * 255)
57
+ .toString(16)
58
+ .padStart(2, '0');
59
+ return `#${c(r)}${c(g)}${c(b)}`;
60
+ }
61
+
62
+ /** Reads RGBA stops back from a Gradient (union of color/alpha key positions). */
63
+ export function readGradientStops(gradient: Gradient): GradientStop[] {
64
+ const positions = new Set<number>();
65
+ for (const [, pos] of gradient.color.keys) {
66
+ positions.add(Math.round(pos * 1000) / 1000);
67
+ }
68
+ for (const [, pos] of gradient.alpha.keys) {
69
+ positions.add(Math.round(pos * 1000) / 1000);
70
+ }
71
+ const color = new Vector3();
72
+ return [...positions]
73
+ .sort((a, b) => a - b)
74
+ .map((pos) => {
75
+ gradient.color.genValue(color, pos);
76
+ const alpha = gradient.alpha.genValue(0, pos);
77
+ return {pos, r: color.x, g: color.y, b: color.z, a: alpha};
78
+ });
79
+ }
80
+
81
+ /** Finds a behavior of the given quarks type string (e.g. 'ColorOverLife'). */
82
+ export function findBehavior<T extends Behavior>(behaviors: Behavior[], type: string): T | undefined {
83
+ return behaviors.find((b) => (b as {type?: string}).type === type) as T | undefined;
84
+ }
@@ -0,0 +1,143 @@
1
+ import {ParticleEmitter} from 'babylon.quarks';
2
+ import type {ParticleSystem} from 'babylon.quarks';
3
+ import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
4
+ import {Matrix, Quaternion} from '@babylonjs/core/Maths/math.vector';
5
+
6
+ /** A node in the editor's effect hierarchy: a Group (organizational) or a ParticleEmitter. */
7
+ export interface EffectTreeNode {
8
+ node: TransformNode;
9
+ name: string;
10
+ /** Present when the node is a ParticleEmitter. */
11
+ system?: ParticleSystem;
12
+ depth: number;
13
+ children: EffectTreeNode[];
14
+ }
15
+
16
+ /** Walks a loaded root TransformNode into the editor tree (groups + emitters). */
17
+ export function buildEffectTree(root: TransformNode, depth = 0): EffectTreeNode {
18
+ const system = root instanceof ParticleEmitter ? (root.system as ParticleSystem) : undefined;
19
+ const treeNode: EffectTreeNode = {
20
+ node: root,
21
+ name: root.name || (system ? 'emitter' : 'group'),
22
+ system,
23
+ depth,
24
+ children: [],
25
+ };
26
+ for (const child of root.getChildren()) {
27
+ if (child instanceof TransformNode) {
28
+ treeNode.children.push(buildEffectTree(child, depth + 1));
29
+ }
30
+ }
31
+ return treeNode;
32
+ }
33
+
34
+ /** Flattens the tree into the systems it contains, in display order. */
35
+ export function collectSystems(tree: EffectTreeNode): ParticleSystem[] {
36
+ const out: ParticleSystem[] = [];
37
+ const walk = (node: EffectTreeNode) => {
38
+ if (node.system) {
39
+ out.push(node.system);
40
+ }
41
+ node.children.forEach(walk);
42
+ };
43
+ walk(tree);
44
+ return out;
45
+ }
46
+
47
+ interface SerializeMeta {
48
+ textures: {[k: string]: {url?: string; name?: string}};
49
+ materials: {[k: string]: {[key: string]: unknown}};
50
+ geometries: {[k: string]: unknown};
51
+ }
52
+
53
+ function localMatrix(node: TransformNode): number[] {
54
+ const rotation = node.rotationQuaternion ?? Quaternion.FromEulerVector(node.rotation);
55
+ return Matrix.Compose(node.scaling, rotation, node.position).asArray().slice();
56
+ }
57
+
58
+ function serializeNode(tree: EffectTreeNode, meta: SerializeMeta, fallbackUuid: string): Record<string, unknown> {
59
+ // Emitter nodes must serialize with the ParticleEmitter's own `.uuid` — that is exactly
60
+ // what EmitSubParticleSystem.toJSON writes as its target reference, so the two match and
61
+ // QuarksLoader.linkReferences can re-resolve sub-emitters on reimport.
62
+ const uuid = tree.system
63
+ ? (tree.node as unknown as {uuid: string}).uuid
64
+ : ((tree.node as {_quarksUUID?: string})._quarksUUID ?? fallbackUuid);
65
+ const children = tree.children.map((child, i) => serializeNode(child, meta, `${fallbackUuid}-${i}`));
66
+ const base = {
67
+ uuid,
68
+ name: tree.name,
69
+ layers: 1,
70
+ matrix: localMatrix(tree.node),
71
+ children: children.length > 0 ? children : undefined,
72
+ };
73
+ if (tree.system) {
74
+ return {...base, type: 'ParticleEmitter', ps: tree.system.toJSON(meta as never, {useUrlForImage: true} as never)};
75
+ }
76
+ return {...base, type: 'Group'};
77
+ }
78
+
79
+ /**
80
+ * Serializes an effect tree into the Quarks JSON envelope understood by QuarksLoader,
81
+ * preserving the group/emitter hierarchy (not flattened).
82
+ */
83
+ export function serializeEffectTree(root: TransformNode, name = 'effect'): string {
84
+ const meta: SerializeMeta = {textures: {}, materials: {}, geometries: {}};
85
+ const object = serializeNode(buildEffectTree(root), meta, `${name}-node`);
86
+ return finishEnvelope(object, meta);
87
+ }
88
+
89
+ /**
90
+ * Serializes several effect roots as children of a synthetic group — used when an effect
91
+ * spans multiple top-level trees (e.g. a demo running several loaded effects at once).
92
+ */
93
+ export function serializeEffectForest(roots: TransformNode[], name = 'effect'): string {
94
+ if (roots.length === 1) {
95
+ return serializeEffectTree(roots[0], name);
96
+ }
97
+ const meta: SerializeMeta = {textures: {}, materials: {}, geometries: {}};
98
+ const children = roots.map((root, i) => serializeNode(buildEffectTree(root), meta, `${name}-node-${i}`));
99
+ const object = {
100
+ uuid: `${name}-root`,
101
+ name,
102
+ type: 'Group',
103
+ layers: 1,
104
+ matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
105
+ children,
106
+ };
107
+ return finishEnvelope(object, meta);
108
+ }
109
+
110
+ /** Flattens accumulated meta and wraps the serialized object into the Quarks envelope. */
111
+ function finishEnvelope(object: Record<string, unknown>, meta: SerializeMeta): string {
112
+ // toJSON stashes live Texture instances and material records in meta; flatten them into
113
+ // the serializable images/textures/materials arrays QuarksLoader expects.
114
+ const images: Array<{uuid: string; url: string}> = [];
115
+ const textures: Array<{uuid: string; image?: string}> = [];
116
+ for (const [uuid, tex] of Object.entries(meta.textures)) {
117
+ const url = tex?.url ?? tex?.name;
118
+ if (url) {
119
+ const imageUuid = `${uuid}-image`;
120
+ images.push({uuid: imageUuid, url});
121
+ textures.push({uuid, image: imageUuid});
122
+ } else {
123
+ textures.push({uuid});
124
+ }
125
+ }
126
+ const materials = Object.values(meta.materials).map((material) => {
127
+ const {sourceMaterial: _sourceMaterial, ...serializable} = material;
128
+ return serializable;
129
+ });
130
+
131
+ return JSON.stringify(
132
+ {
133
+ metadata: {version: 4.5, type: 'Object3D', generator: 'babylon.quarks-editor'},
134
+ geometries: Object.values(meta.geometries),
135
+ materials,
136
+ textures,
137
+ images,
138
+ object,
139
+ },
140
+ null,
141
+ 2
142
+ );
143
+ }
@@ -0,0 +1,29 @@
1
+ import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
+
3
+ /**
4
+ * Groups the given nodes under a new organizational TransformNode. Each node's world
5
+ * transform is preserved via `setParent` — grouping never moves anything visually, it only
6
+ * changes the hierarchy so the group can be repositioned/organized as one unit afterwards.
7
+ */
8
+ export function groupNodes(nodes: TransformNode[], name = 'Group'): TransformNode {
9
+ const scene = nodes[0].getScene();
10
+ const group = new TransformNode(name, scene);
11
+ group.setParent(nodes[0].parent);
12
+ for (const node of nodes) {
13
+ node.setParent(group);
14
+ }
15
+ return group;
16
+ }
17
+
18
+ /**
19
+ * Dissolves a group, reparenting its children onto the group's own parent and disposing the
20
+ * (now childless) group node. World transform of each child is preserved.
21
+ */
22
+ export function ungroupNode(group: TransformNode): void {
23
+ const parent = group.parent;
24
+ const children = group.getChildren().filter((c): c is TransformNode => c instanceof TransformNode);
25
+ for (const child of children) {
26
+ child.setParent(parent);
27
+ }
28
+ group.dispose(true, false);
29
+ }
@@ -0,0 +1,77 @@
1
+ import type {EffectBinding} from './binding';
2
+
3
+ const COALESCE_MS = 400;
4
+ const MAX_DEPTH = 50;
5
+
6
+ /**
7
+ * Snapshot-based undo/redo over serialized effect JSON. Owned by the host (not the
8
+ * binding) so history survives rebinding when a restore rebuilds the systems.
9
+ *
10
+ * Wire-up: call attach() whenever a binding is (re)mounted; on undo()/redo() feed the
11
+ * returned JSON back through the host's import path and attach the new binding.
12
+ */
13
+ export class EffectHistory {
14
+ private undoStack: string[] = [];
15
+ private redoStack: string[] = [];
16
+ private lastCaptureTime = 0;
17
+ private binding: EffectBinding | null = null;
18
+
19
+ /** Points the history at the active binding and hooks snapshot capture. */
20
+ attach(binding: EffectBinding): void {
21
+ this.binding = binding;
22
+ binding.onBeforeChange = () => this.capture();
23
+ }
24
+
25
+ get canUndo(): boolean {
26
+ return this.undoStack.length > 0;
27
+ }
28
+
29
+ get canRedo(): boolean {
30
+ return this.redoStack.length > 0;
31
+ }
32
+
33
+ clear(): void {
34
+ this.undoStack = [];
35
+ this.redoStack = [];
36
+ this.lastCaptureTime = 0;
37
+ }
38
+
39
+ /** Pushes an undo snapshot; rapid consecutive edits are coalesced into one step. */
40
+ capture(): void {
41
+ if (!this.binding) {
42
+ return;
43
+ }
44
+ const now = Date.now();
45
+ if (now - this.lastCaptureTime < COALESCE_MS && this.undoStack.length > 0) {
46
+ return;
47
+ }
48
+ this.lastCaptureTime = now;
49
+ this.undoStack.push(this.binding.exportJSON());
50
+ if (this.undoStack.length > MAX_DEPTH) {
51
+ this.undoStack.shift();
52
+ }
53
+ this.redoStack = [];
54
+ }
55
+
56
+ /** Returns the previous effect state as parsed JSON, or null when nothing to undo. */
57
+ undo(): unknown | null {
58
+ if (!this.binding || this.undoStack.length === 0) {
59
+ return null;
60
+ }
61
+ const snapshot = this.undoStack.pop()!;
62
+ this.redoStack.push(this.binding.exportJSON());
63
+ this.lastCaptureTime = 0;
64
+ return JSON.parse(snapshot);
65
+ }
66
+
67
+ /** Returns the next effect state as parsed JSON, or null when nothing to redo. */
68
+ redo(): unknown | null {
69
+ if (!this.binding || this.redoStack.length === 0) {
70
+ return null;
71
+ }
72
+ const snapshot = this.redoStack.pop()!;
73
+ this.undoStack.push(this.binding.exportJSON());
74
+ this.lastCaptureTime = 0;
75
+ return JSON.parse(snapshot);
76
+ }
77
+ }
@@ -0,0 +1,102 @@
1
+ import {ParticleEmitter, QuarksLoader, QuarksUtil} from 'babylon.quarks';
2
+ import type {BatchedRenderer, ParticleSystem} from 'babylon.quarks';
3
+ import type {Scene} from '@babylonjs/core/scene';
4
+ import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
5
+ import type {Node} from '@babylonjs/core/node';
6
+
7
+ /** `ParticleEmitter`'s constructor default (see babylon.quarks/src/ParticleEmitter.ts) — the
8
+ * name every emitter gets when the source JSON didn't give it one of its own. */
9
+ const DEFAULT_EMITTER_NAME = 'particleEmitter';
10
+
11
+ function hasMeaningfulName(node: TransformNode): boolean {
12
+ return !!node.name && node.name !== DEFAULT_EMITTER_NAME;
13
+ }
14
+
15
+ function groupChildren(node: TransformNode): TransformNode[] {
16
+ return node.getChildren().filter((c): c is TransformNode => c instanceof TransformNode);
17
+ }
18
+
19
+ /** Reparents `only` onto `parent`, transferring the outgoing node's name if `only` doesn't
20
+ * already have a meaningful one of its own, then disposes the now-empty wrapper. World
21
+ * transform is preserved via `setParent`. */
22
+ function collapseInto(wrapper: TransformNode, only: TransformNode, parent: Node | null): void {
23
+ if (!hasMeaningfulName(only) && hasMeaningfulName(wrapper)) {
24
+ only.name = wrapper.name;
25
+ }
26
+ only.setParent(parent);
27
+ wrapper.dispose(true, false);
28
+ }
29
+
30
+ /**
31
+ * Collapses redundant organizational groups left over by importers — Unity's
32
+ * one-GameObject-per-component convention turns every visual layer into its own Group, so a
33
+ * single particle system becomes "Sparks" > unnamed emitter, and a component quarks doesn't
34
+ * understand (e.g. a Light) becomes an empty Group with nothing under it. Recurses
35
+ * bottom-up so nested wrapper chains collapse all the way down. The tree root itself is
36
+ * handled separately by `promoteIfSingleChildRoot`, since collapsing it means returning a
37
+ * different node as the effect's root.
38
+ */
39
+ function flattenRedundantGroups(node: TransformNode): void {
40
+ for (const child of node.getChildren()) {
41
+ if (child instanceof TransformNode) {
42
+ flattenRedundantGroups(child);
43
+ }
44
+ }
45
+ if (node instanceof ParticleEmitter || !node.parent) {
46
+ return;
47
+ }
48
+ const children = groupChildren(node);
49
+ if (children.length === 0) {
50
+ node.dispose(true, false);
51
+ return;
52
+ }
53
+ if (children.length !== 1) {
54
+ return;
55
+ }
56
+ const only = children[0];
57
+ if (only instanceof ParticleEmitter && only.getChildren().length > 0) {
58
+ return;
59
+ }
60
+ collapseInto(node, only, node.parent);
61
+ }
62
+
63
+ /** If the tree root itself only ever wraps a single child (after `flattenRedundantGroups`
64
+ * has settled everything below it), that wrapper adds a tree level with nothing to organize —
65
+ * promote the child to be the new root instead. Loops in case that child is, in turn, also a
66
+ * single-child wrapper. */
67
+ function promoteIfSingleChildRoot(root: TransformNode): TransformNode {
68
+ let current = root;
69
+ while (!(current instanceof ParticleEmitter)) {
70
+ const children = groupChildren(current);
71
+ if (children.length !== 1) {
72
+ break;
73
+ }
74
+ const only = children[0];
75
+ const parent = current.parent;
76
+ collapseInto(current, only, parent);
77
+ current = only;
78
+ }
79
+ return current;
80
+ }
81
+
82
+ /** Parses a Quarks JSON export, registers all systems with the renderer and starts playback. */
83
+ export function loadEffectFromJson(
84
+ scene: Scene,
85
+ renderer: BatchedRenderer,
86
+ json: unknown,
87
+ baseUrl = ''
88
+ ): {root: TransformNode; systems: ParticleSystem[]} {
89
+ const loader = new QuarksLoader(scene, {baseUrl});
90
+ let root = loader.parse(json as never, baseUrl);
91
+ flattenRedundantGroups(root);
92
+ root = promoteIfSingleChildRoot(root);
93
+ const systems: ParticleSystem[] = [];
94
+ QuarksUtil.runOnAllParticleEmitters(root, (emitter: ParticleEmitter) => {
95
+ const system = emitter.system as ParticleSystem;
96
+ renderer.addSystem(system);
97
+ systems.push(system);
98
+ });
99
+ QuarksUtil.restart(root);
100
+ QuarksUtil.play(root);
101
+ return {root, systems};
102
+ }
@@ -0,0 +1,28 @@
1
+ import type {BatchedRenderer} from 'babylon.quarks';
2
+ import type {EffectBinding} from './binding';
3
+
4
+ const WIDE_STEP_THRESHOLD = 8;
5
+
6
+ /**
7
+ * Approximate timeline scrub: replays every system from t=0 up to `time` using the existing
8
+ * simulation loop. Not frame-exact (spawn/burst randomization is unseeded), but reuses
9
+ * `EffectBinding.restart()` and `BatchedRenderer.update()` verbatim — no new engine primitive.
10
+ *
11
+ * `ParticleSystem.update()` clamps its own `delta` to 0.1s per call, so a single big step
12
+ * can't reach `time` directly; must step through in small increments instead.
13
+ */
14
+ export function scrubTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
15
+ binding.restart();
16
+ if (time <= 0) {
17
+ return;
18
+ }
19
+ const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
20
+ const steps = Math.floor(time / fixedStep);
21
+ for (let i = 0; i < steps; i++) {
22
+ renderer.update(fixedStep);
23
+ }
24
+ const remainder = time - steps * fixedStep;
25
+ if (remainder > 0) {
26
+ renderer.update(remainder);
27
+ }
28
+ }