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,81 @@
1
+ import { Bezier, ConstantValue, IntervalValue, PiecewiseBezier } from 'babylon.quarks';
2
+ export function readScalar(generator) {
3
+ const state = { mode: 'constant', value: 1, min: 0.5, max: 1.5, curve: [0, 1, 1, 0] };
4
+ if (generator instanceof ConstantValue) {
5
+ state.mode = 'constant';
6
+ state.value = generator.value;
7
+ state.min = generator.value * 0.5;
8
+ state.max = generator.value * 1.5;
9
+ }
10
+ else if (generator instanceof IntervalValue) {
11
+ state.mode = 'random';
12
+ state.min = generator.a;
13
+ state.max = generator.b;
14
+ state.value = (generator.a + generator.b) / 2;
15
+ }
16
+ else if (generator instanceof PiecewiseBezier) {
17
+ state.mode = 'curve';
18
+ const first = generator.functions[0]?.[0];
19
+ if (first) {
20
+ state.curve = [first.p[0], first.p[1], first.p[2], first.p[3]];
21
+ state.value = first.p[0];
22
+ }
23
+ }
24
+ return state;
25
+ }
26
+ export function buildScalar(state) {
27
+ switch (state.mode) {
28
+ case 'constant':
29
+ return new ConstantValue(state.value);
30
+ case 'random':
31
+ return new IntervalValue(Math.min(state.min, state.max), Math.max(state.min, state.max));
32
+ case 'curve':
33
+ return new PiecewiseBezier([[new Bezier(...state.curve), 0]]);
34
+ }
35
+ }
36
+ /** Reads pieces from a PiecewiseBezier (or synthesizes one segment for other generators). */
37
+ export function readPieces(generator) {
38
+ if (generator instanceof PiecewiseBezier && generator.functions.length > 0) {
39
+ return generator.functions.map(([bezier, start]) => ({
40
+ start,
41
+ p: [bezier.p[0], bezier.p[1], bezier.p[2], bezier.p[3]],
42
+ }));
43
+ }
44
+ const fallback = readScalar(generator);
45
+ return [{ start: 0, p: fallback.curve }];
46
+ }
47
+ export function buildCurve(pieces) {
48
+ const sorted = [...pieces].sort((a, b) => a.start - b.start);
49
+ return new PiecewiseBezier(sorted.map((piece) => [new Bezier(...piece.p), piece.start]));
50
+ }
51
+ /** Splits the piece containing x into two exact halves using Bezier.split. */
52
+ export function splitPieces(pieces, x) {
53
+ const sorted = [...pieces].sort((a, b) => a.start - b.start);
54
+ for (let i = 0; i < sorted.length; i++) {
55
+ const end = i + 1 < sorted.length ? sorted[i + 1].start : 1;
56
+ if (x > sorted[i].start + 0.02 && x < end - 0.02) {
57
+ const t = (x - sorted[i].start) / (end - sorted[i].start);
58
+ const { left, right } = new Bezier(...sorted[i].p).split(t);
59
+ sorted.splice(i, 1, { start: sorted[i].start, p: [left.p[0], left.p[1], left.p[2], left.p[3]] }, { start: x, p: [right.p[0], right.p[1], right.p[2], right.p[3]] });
60
+ return sorted;
61
+ }
62
+ }
63
+ return sorted;
64
+ }
65
+ /** Removes the boundary at pieces[index].start by merging with the previous piece. */
66
+ export function mergePieces(pieces, index) {
67
+ if (index <= 0 || index >= pieces.length) {
68
+ return pieces;
69
+ }
70
+ const sorted = [...pieces].sort((a, b) => a.start - b.start);
71
+ const a = sorted[index - 1];
72
+ const b = sorted[index];
73
+ sorted.splice(index - 1, 2, { start: a.start, p: [a.p[0], a.p[1], b.p[2], b.p[3]] });
74
+ return sorted;
75
+ }
76
+ /** Samples a cubic bezier defined by four control values at normalized t. */
77
+ export function sampleCurve(curve, t) {
78
+ const [p0, p1, p2, p3] = curve;
79
+ const u = 1 - t;
80
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
81
+ }
@@ -0,0 +1,15 @@
1
+ export { EffectBinding } from './core/binding';
2
+ export type { EditorListener } from './core/binding';
3
+ export { EffectHistory } from './core/history';
4
+ export { createChildSystem } from './core/systems';
5
+ export { buildEffectTree, collectSystems, serializeEffectTree, serializeEffectForest } from './core/effectTree';
6
+ export type { EffectTreeNode } from './core/effectTree';
7
+ export type { ChildSystemOptions } from './core/systems';
8
+ export { readScalar, buildScalar, sampleCurve, readPieces, buildCurve, splitPieces, mergePieces } from './core/values';
9
+ export type { ScalarGenerator, ScalarMode, ScalarValueState, CurvePiece } from './core/values';
10
+ export { DEFAULT_GRADIENT_STOPS, buildGradient, buildColorOverLife, rgbaToVector4, stopToCss, stopsToCssGradient, hexToRgb, rgbToHex, findBehavior, } from './core/colors';
11
+ export type { GradientStop } from './core/colors';
12
+ export { SHAPE_TYPES, SHAPE_PARAM_KEYS, DEFAULT_SHAPE_PARAMS, getShapeType, readShapeParams, createShape, } from './core/shapes';
13
+ export type { ShapeType, ShapeParams } from './core/shapes';
14
+ export { GroundPlaneResolver, ensureGroundResolver } from './core/collision';
15
+ export { ensurePortableTextureUrl } from './core/textureExport';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { EffectBinding } from './core/binding';
2
+ export { EffectHistory } from './core/history';
3
+ export { createChildSystem } from './core/systems';
4
+ export { buildEffectTree, collectSystems, serializeEffectTree, serializeEffectForest } from './core/effectTree';
5
+ export { readScalar, buildScalar, sampleCurve, readPieces, buildCurve, splitPieces, mergePieces } from './core/values';
6
+ export { DEFAULT_GRADIENT_STOPS, buildGradient, buildColorOverLife, rgbaToVector4, stopToCss, stopsToCssGradient, hexToRgb, rgbToHex, findBehavior, } from './core/colors';
7
+ export { SHAPE_TYPES, SHAPE_PARAM_KEYS, DEFAULT_SHAPE_PARAMS, getShapeType, readShapeParams, createShape, } from './core/shapes';
8
+ export { GroundPlaneResolver, ensureGroundResolver } from './core/collision';
9
+ export { ensurePortableTextureUrl } from './core/textureExport';
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { CurvePiece } from '../core/values';
3
+ /**
4
+ * Multi-segment piecewise-bezier curve editor. Drag keys/handles vertically,
5
+ * drag inner keys horizontally, double-click the curve to add a key, double-click
6
+ * a key to remove it (Unity-like).
7
+ */
8
+ export declare function CurveEditor(props: {
9
+ pieces: CurvePiece[];
10
+ onChange: (pieces: CurvePiece[]) => void;
11
+ maxValue?: number;
12
+ }): React.JSX.Element;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { mergePieces, sampleCurve, splitPieces } from '../core/values';
4
+ import { inputStyle, theme } from './theme';
5
+ const WIDTH = 218;
6
+ const HEIGHT = 84;
7
+ const PAD = 8;
8
+ /**
9
+ * Multi-segment piecewise-bezier curve editor. Drag keys/handles vertically,
10
+ * drag inner keys horizontally, double-click the curve to add a key, double-click
11
+ * a key to remove it (Unity-like).
12
+ */
13
+ export function CurveEditor(props) {
14
+ const svgRef = useRef(null);
15
+ const drag = useRef(null);
16
+ const [selected, setSelected] = useState(null);
17
+ const pieces = [...props.pieces].sort((a, b) => a.start - b.start);
18
+ const allValues = pieces.flatMap((piece) => piece.p);
19
+ const max = Math.max(props.maxValue ?? 1, ...allValues.map(Math.abs), 0.001);
20
+ const spanEnd = (i) => (i + 1 < pieces.length ? pieces[i + 1].start : 1);
21
+ const toX = (x) => PAD + x * (WIDTH - PAD * 2);
22
+ const toY = (v) => HEIGHT - PAD - (v / max) * (HEIGHT - PAD * 2);
23
+ const fromY = (y) => Math.round(Math.min(max, Math.max(0, ((HEIGHT - PAD - y) / (HEIGHT - PAD * 2)) * max)) * 1000) / 1000;
24
+ const fromX = (px) => Math.min(1, Math.max(0, (px - PAD) / (WIDTH - PAD * 2)));
25
+ // Control point positions: piece start key, two handles, plus the final end key.
26
+ const points = [];
27
+ pieces.forEach((piece, i) => {
28
+ const end = spanEnd(i);
29
+ const span = end - piece.start;
30
+ points.push({ ref: { piece: i, idx: 0 }, x: piece.start, y: piece.p[0], isKey: true });
31
+ points.push({ ref: { piece: i, idx: 1 }, x: piece.start + span / 3, y: piece.p[1], isKey: false });
32
+ points.push({ ref: { piece: i, idx: 2 }, x: piece.start + (2 * span) / 3, y: piece.p[2], isKey: false });
33
+ if (i === pieces.length - 1) {
34
+ points.push({ ref: { piece: i, idx: 3 }, x: 1, y: piece.p[3], isKey: true });
35
+ }
36
+ });
37
+ const samples = [];
38
+ pieces.forEach((piece, i) => {
39
+ const end = spanEnd(i);
40
+ for (let s = 0; s <= 16; s++) {
41
+ const t = s / 16;
42
+ samples.push(`${toX(piece.start + t * (end - piece.start)).toFixed(1)},${toY(sampleCurve(piece.p, t)).toFixed(1)}`);
43
+ }
44
+ });
45
+ const applyDrag = (clientX, clientY) => {
46
+ const target = drag.current;
47
+ if (!target || !svgRef.current) {
48
+ return;
49
+ }
50
+ const rect = svgRef.current.getBoundingClientRect();
51
+ const v = fromY(clientY - rect.top);
52
+ const next = pieces.map((piece) => ({ start: piece.start, p: [...piece.p] }));
53
+ const { piece, idx } = target;
54
+ if (idx === 0) {
55
+ next[piece].p[0] = v;
56
+ if (piece > 0) {
57
+ next[piece - 1].p[3] = v;
58
+ // Inner keys can also move horizontally between neighbours.
59
+ const x = fromX(clientX - rect.left);
60
+ const min = next[piece - 1].start + 0.02;
61
+ const maxX = (piece + 1 < next.length ? next[piece + 1].start : 1) - 0.02;
62
+ next[piece].start = Math.min(maxX, Math.max(min, x));
63
+ }
64
+ }
65
+ else if (idx === 3) {
66
+ next[piece].p[3] = v;
67
+ }
68
+ else {
69
+ next[piece].p[idx] = v;
70
+ }
71
+ props.onChange(next);
72
+ };
73
+ const selectedPoint = selected ? points.find((pt) => pt.ref.piece === selected.piece && pt.ref.idx === selected.idx) : null;
74
+ return (_jsxs("div", { children: [_jsxs("svg", { ref: svgRef, width: WIDTH, height: HEIGHT, style: { background: 'rgba(5, 8, 18, 0.85)', border: `1px solid ${theme.border}`, borderRadius: 8, touchAction: 'none', display: 'block' }, onPointerMove: (e) => applyDrag(e.clientX, e.clientY), onPointerUp: (e) => {
75
+ drag.current = null;
76
+ e.target.releasePointerCapture?.(e.pointerId);
77
+ }, onDoubleClick: (e) => {
78
+ if (e.target.tagName === 'circle') {
79
+ return;
80
+ }
81
+ const rect = svgRef.current.getBoundingClientRect();
82
+ props.onChange(splitPieces(pieces, fromX(e.clientX - rect.left)));
83
+ }, children: [[0.25, 0.5, 0.75].map((f) => (_jsx("line", { x1: PAD, x2: WIDTH - PAD, y1: PAD + f * (HEIGHT - PAD * 2), y2: PAD + f * (HEIGHT - PAD * 2), stroke: "#1c274a", strokeWidth: 1 }, f))), _jsx("polyline", { points: samples.join(' '), fill: "none", stroke: theme.curveStroke, strokeWidth: 2 }), points.map((pt) => (_jsx("circle", { className: "qe-hover", cx: toX(pt.x), cy: toY(pt.y), r: pt.isKey ? 5 : 4, fill: pt.isKey ? theme.curveStroke : '#28407f', stroke: theme.curveStroke, strokeWidth: 1.5, style: { cursor: pt.isKey && pt.ref.idx === 0 && pt.ref.piece > 0 ? 'move' : 'ns-resize' }, onPointerDown: (e) => {
84
+ drag.current = pt.ref;
85
+ setSelected(pt.ref);
86
+ e.target.setPointerCapture(e.pointerId);
87
+ }, onDoubleClick: (e) => {
88
+ e.stopPropagation();
89
+ if (pt.isKey && pt.ref.idx === 0 && pt.ref.piece > 0) {
90
+ setSelected(null);
91
+ props.onChange(mergePieces(pieces, pt.ref.piece));
92
+ }
93
+ } }, `${pt.ref.piece}-${pt.ref.idx}`)))] }), _jsx("div", { style: { display: 'flex', gap: 4, marginTop: 4, alignItems: 'center' }, children: selectedPoint ? (_jsxs(_Fragment, { children: [_jsx("span", { style: { fontSize: 10.5, color: theme.textDim }, children: "value" }), _jsx("input", { type: "number", className: "qe-hover", step: 0.05, style: { ...inputStyle, fontSize: 11, padding: '3px 5px', width: 70 }, value: Math.round(selectedPoint.y * 1000) / 1000, onChange: (e) => {
94
+ const parsed = parseFloat(e.target.value);
95
+ if (Number.isNaN(parsed)) {
96
+ return;
97
+ }
98
+ const next = pieces.map((piece) => ({ start: piece.start, p: [...piece.p] }));
99
+ const { piece, idx } = selectedPoint.ref;
100
+ if (idx === 0 && piece > 0) {
101
+ next[piece - 1].p[3] = parsed;
102
+ }
103
+ next[piece].p[idx === 3 ? 3 : idx] = parsed;
104
+ props.onChange(next);
105
+ } }), _jsxs("span", { style: { fontSize: 10.5, color: theme.textDim }, children: ["t=", Math.round(selectedPoint.x * 100) / 100] })] })) : (_jsx("span", { style: { fontSize: 10.5, color: theme.textDim }, children: "dbl-click curve: add key \u00B7 dbl-click key: remove" })) })] }));
106
+ }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { EffectBinding } from '../core/binding';
3
+ import type { GeometryData, GeometryOption, TextureOption } from './modules';
4
+ import type { ParticleSystem } from 'babylon.quarks';
5
+ export interface EffectEditorProps {
6
+ binding: EffectBinding;
7
+ /** Currently selected system, lifted up so the timeline panel and this module stack agree. */
8
+ selectedSystem: ParticleSystem;
9
+ /** Texture presets offered in the Renderer module (host supplies the loader). */
10
+ textureOptions?: TextureOption[];
11
+ /** Mesh presets offered for the Mesh render mode (host supplies the geometry buffers). */
12
+ geometryOptions?: GeometryOption[];
13
+ resolveTexture?: (url: string) => unknown;
14
+ /** Host loader for "Load from file…" in the Mesh render mode (e.g. a GLB importer). */
15
+ resolveGeometry?: (file: File) => Promise<GeometryData>;
16
+ }
17
+ /**
18
+ * Shuriken-style effect editor: a stacked module inspector for the selected system.
19
+ * Embed it anywhere React runs — the standalone examples page or a BabylonJS Editor plugin.
20
+ * Module order mirrors Unity's Particle System inspector.
21
+ */
22
+ export declare function EffectEditor(props: EffectEditorProps): React.JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useSyncExternalStore } from 'react';
3
+ import { EffectBinding } from '../core/binding';
4
+ import { ColorOverLifeModule, EmissionModule, MainModule, RendererModule, ShapeModule, SizeOverLifeModule, } from './modules';
5
+ import { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, InheritVelocityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, VelocityOverLifeModule, WidthOverTrailModule, } from './behaviorModules';
6
+ import { SubEmittersModule } from './SubEmittersModule';
7
+ import { theme } from './theme';
8
+ /**
9
+ * Shuriken-style effect editor: a stacked module inspector for the selected system.
10
+ * Embed it anywhere React runs — the standalone examples page or a BabylonJS Editor plugin.
11
+ * Module order mirrors Unity's Particle System inspector.
12
+ */
13
+ export function EffectEditor(props) {
14
+ useSyncExternalStore((onStoreChange) => props.binding.subscribe(onStoreChange), () => props.binding.getRevision());
15
+ const allSystems = [props.binding.system, ...props.binding.subSystems];
16
+ // Selection survives re-renders but falls back to root if the system was removed/replaced.
17
+ const activeSystem = allSystems.includes(props.selectedSystem) ? props.selectedSystem : props.binding.system;
18
+ // Inspector edits go through a per-system binding; changes route into the root
19
+ // binding's undo history and re-render via the subscription below.
20
+ const binding = React.useMemo(() => {
21
+ if (activeSystem === props.binding.system) {
22
+ return props.binding;
23
+ }
24
+ const nested = new EffectBinding(activeSystem);
25
+ nested.onBeforeChange = () => props.binding.onBeforeChange?.();
26
+ return nested;
27
+ }, [props.binding, activeSystem]);
28
+ useSyncExternalStore((onStoreChange) => binding.subscribe(onStoreChange), () => binding.getRevision());
29
+ return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_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 })] }));
30
+ }
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { Scene } from '@babylonjs/core/scene';
3
+ import { BatchedRenderer } from 'babylon.quarks';
4
+ import { EffectBinding } from '../core/binding';
5
+ import { EffectHistory } from '../core/history';
6
+ import type { GeometryData, GeometryOption, TextureOption } from './modules';
7
+ export interface EffectEditorHostHandle {
8
+ binding: EffectBinding;
9
+ history: EffectHistory;
10
+ scene: Scene;
11
+ renderer: BatchedRenderer;
12
+ importJson: (json: unknown) => void;
13
+ exportJson: () => string;
14
+ }
15
+ export interface EffectEditorHostProps {
16
+ /** Quarks JSON export to open; a default effect is created when omitted. */
17
+ effectJson?: unknown;
18
+ /** When set, a Save button appears and receives the serialized effect. */
19
+ onSave?: (json: string) => void;
20
+ /** Called once the engine/scene/binding are live (and after every import). */
21
+ onReady?: (handle: EffectEditorHostHandle) => void;
22
+ title?: string;
23
+ textureOptions?: TextureOption[];
24
+ /** Mesh presets for the Mesh render mode (e.g. asset meshes from a host editor). */
25
+ geometryOptions?: GeometryOption[];
26
+ resolveTexture?: (url: string, scene: Scene) => unknown;
27
+ /** Host loader for the Mesh "Load from file…" option (e.g. GLB via @babylonjs/loaders). */
28
+ resolveGeometry?: (file: File, scene: Scene) => Promise<GeometryData>;
29
+ }
30
+ /**
31
+ * Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
32
+ * Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
33
+ */
34
+ export declare function EffectEditorHost(props: EffectEditorHostProps): React.JSX.Element;
35
+ /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
36
+ export declare const QuarksEffectEditor: {
37
+ Show(options: EffectEditorHostProps & {
38
+ hostElement: HTMLElement;
39
+ }): {
40
+ dispose: () => void;
41
+ };
42
+ };
@@ -0,0 +1,247 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { Engine } from '@babylonjs/core/Engines/engine';
5
+ import { Scene } from '@babylonjs/core/scene';
6
+ import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
7
+ import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
8
+ import { Vector3 as BVector3 } from '@babylonjs/core/Maths/math.vector';
9
+ import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
10
+ import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
11
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
12
+ import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
13
+ import { BatchedRenderer, ColorOverLife, ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
14
+ import { EffectBinding } from '../core/binding';
15
+ import { EffectHistory } from '../core/history';
16
+ import { ensureGroundResolver } from '../core/collision';
17
+ import { loadEffectFromJson } from '../core/loadEffect';
18
+ import { DEFAULT_GRADIENT_STOPS, buildGradient } from '../core/colors';
19
+ import { EffectEditor } from './EffectEditor';
20
+ import { PromptDialog } from './PromptDialog';
21
+ import { TimelinePanel } from './TimelinePanel';
22
+ import { buttonStyle, globalEditorStyles, theme } from './theme';
23
+ function createDefaultEffect(scene, resolveTexture, textureUrl) {
24
+ const system = new ParticleSystem({
25
+ scene,
26
+ duration: 4,
27
+ looping: true,
28
+ startLife: new IntervalValue(1.2, 1.8),
29
+ startSpeed: new IntervalValue(3, 5),
30
+ startSize: new IntervalValue(0.25, 0.5),
31
+ startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
32
+ emissionOverTime: new ConstantValue(80),
33
+ // quarks' angle is a half-angle; Babylon's native cone uses a full angle.
34
+ shape: new ConeEmitter({ radius: 0.3, angle: 0.25 }),
35
+ renderMode: RenderMode.BillBoard,
36
+ texture: (textureUrl && resolveTexture ? resolveTexture(textureUrl, scene) : undefined),
37
+ transparent: true,
38
+ blendMode: 1,
39
+ });
40
+ system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
41
+ // quarks emits along local +Z; Babylon's native cone emits along +Y.
42
+ system.emitter.rotation.x = -Math.PI / 2;
43
+ return system;
44
+ }
45
+ /** Disposes an effect's systems and its remaining group tree (otherwise the groups leak). */
46
+ function disposeEffect(root, systems, renderer) {
47
+ for (const system of systems) {
48
+ renderer.deleteSystem(system);
49
+ system.dispose();
50
+ }
51
+ // Disposing the systems already disposed their emitter nodes; dispose the leftover group
52
+ // tree too. Skip when the root IS a disposed emitter (default single-system effect).
53
+ if (!systems.some((system) => system.emitter === root)) {
54
+ root.dispose(false, true);
55
+ }
56
+ }
57
+ /**
58
+ * Self-contained effect editor: live Babylon preview + hierarchy/inspector sidebar.
59
+ * Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
60
+ */
61
+ export function EffectEditorHost(props) {
62
+ const canvasRef = useRef(null);
63
+ const counterRef = useRef(null);
64
+ const playheadRef = useRef(null);
65
+ const importRef = useRef(null);
66
+ const stateRef = useRef(null);
67
+ const [binding, setBinding] = useState(null);
68
+ const [, force] = useReducer((x) => x + 1, 0);
69
+ const playbackRef = useRef({ paused: false, speed: 1, stepQueued: false, elapsed: 0, scrubbing: false });
70
+ const [paused, setPaused] = useState(false);
71
+ const [speed, setSpeed] = useState(1);
72
+ const [selectedSystem, setSelectedSystem] = useState(null);
73
+ const [renamingRoot, setRenamingRoot] = useState(false);
74
+ useEffect(() => {
75
+ // Register the shared ground-plane collider before any effect (default or imported)
76
+ // is built, so ApplyCollision behaviors deserialized by QuarksLoader resolve against it.
77
+ ensureGroundResolver();
78
+ const engine = new Engine(canvasRef.current, true);
79
+ const scene = new Scene(engine);
80
+ scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
81
+ const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new BVector3(0, 1.4, 0), scene);
82
+ camera.attachControl(canvasRef.current, true);
83
+ camera.wheelDeltaPercentage = 0.01;
84
+ // Mesh render mode particles are lit (billboard modes ignore these), and the grid gives
85
+ // a visual floor at the same y=0 plane the Collision module's ground resolver uses.
86
+ scene.ambientColor = new Color3(1, 1, 1);
87
+ const sunLight = new DirectionalLight('sun', new BVector3(-1, -1, -1), scene);
88
+ sunLight.intensity = 1.0;
89
+ sunLight.diffuse = new Color3(1, 1, 1);
90
+ sunLight.specular = new Color3(1, 1, 1);
91
+ const groundMaterial = new GridMaterial('groundMaterial', scene);
92
+ groundMaterial.majorUnitFrequency = 2;
93
+ groundMaterial.minorUnitVisibility = 0.1;
94
+ groundMaterial.gridRatio = 0.5;
95
+ groundMaterial.backFaceCulling = false;
96
+ groundMaterial.mainColor = new Color3(1, 1, 1);
97
+ groundMaterial.lineColor = new Color3(1, 1, 1);
98
+ groundMaterial.opacity = 0.5;
99
+ const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
100
+ ground.material = groundMaterial;
101
+ const renderer = new BatchedRenderer('quarks-editor', scene);
102
+ const history = new EffectHistory();
103
+ const mount = (nextBinding) => {
104
+ history.attach(nextBinding);
105
+ nextBinding.subscribe(force);
106
+ stateRef.current = { engine, scene, renderer, binding: nextBinding, history };
107
+ setBinding(nextBinding);
108
+ props.onReady?.(makeHandle());
109
+ };
110
+ const makeHandle = () => ({
111
+ // Live getter: stays correct after presets/imports/undo replace the binding.
112
+ get binding() {
113
+ return stateRef.current.binding;
114
+ },
115
+ history,
116
+ scene,
117
+ renderer,
118
+ importJson,
119
+ exportJson: () => stateRef.current.binding.exportJSON('EditorEffect'),
120
+ });
121
+ const importJson = (json) => {
122
+ const current = stateRef.current.binding;
123
+ // Load first so a bad/empty JSON leaves the current effect intact.
124
+ const { root, systems } = loadEffectFromJson(scene, renderer, json);
125
+ if (systems.length === 0) {
126
+ disposeEffect(root, systems, renderer);
127
+ throw new Error('No particle systems found in the JSON.');
128
+ }
129
+ disposeEffect(current.root, [current.system, ...current.subSystems], renderer);
130
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
131
+ mount(new EffectBinding(main, systems.filter((s) => s !== main), root));
132
+ };
133
+ let initial;
134
+ if (props.effectJson) {
135
+ const { root, systems } = loadEffectFromJson(scene, renderer, props.effectJson);
136
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
137
+ initial = new EffectBinding(main, systems.filter((s) => s !== main), root);
138
+ }
139
+ else {
140
+ const system = createDefaultEffect(scene, props.resolveTexture, props.textureOptions?.[0]?.url);
141
+ renderer.addSystem(system);
142
+ // Root is a plain group (not the system's own emitter) so a new top-level system
143
+ // can be added as a sibling later, instead of always nesting under this one.
144
+ const root = new TransformNode('Effect', scene);
145
+ system.emitter.parent = root;
146
+ initial = new EffectBinding(system, [], root);
147
+ }
148
+ mount(initial);
149
+ engine.runRenderLoop(() => {
150
+ const playback = playbackRef.current;
151
+ if (!playback.scrubbing) {
152
+ let delta = (engine.getDeltaTime() / 1000) * playback.speed;
153
+ if (playback.paused) {
154
+ delta = playback.stepQueued ? 1 / 60 : 0;
155
+ playback.stepQueued = false;
156
+ }
157
+ if (delta > 0) {
158
+ renderer.update(delta);
159
+ playback.elapsed += delta;
160
+ }
161
+ }
162
+ scene.render();
163
+ const b = stateRef.current.binding;
164
+ let count = b.system.particleNum;
165
+ for (const sub of b.subSystems) {
166
+ count += sub.particleNum;
167
+ }
168
+ if (counterRef.current) {
169
+ counterRef.current.textContent = `${count} particles · ${playback.elapsed.toFixed(2)}s`;
170
+ }
171
+ });
172
+ const onResize = () => engine.resize();
173
+ const onKey = (e) => {
174
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
175
+ e.preventDefault();
176
+ const json = e.shiftKey ? history.redo() : history.undo();
177
+ if (json) {
178
+ importJson(json);
179
+ }
180
+ force();
181
+ }
182
+ };
183
+ window.addEventListener('resize', onResize);
184
+ window.addEventListener('keydown', onKey);
185
+ return () => {
186
+ window.removeEventListener('resize', onResize);
187
+ window.removeEventListener('keydown', onKey);
188
+ engine.dispose();
189
+ };
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ }, []);
192
+ const state = stateRef.current;
193
+ const historyAction = (json) => {
194
+ if (json && state) {
195
+ const current = state.binding;
196
+ const { root, systems } = loadEffectFromJson(state.scene, state.renderer, json);
197
+ disposeEffect(current.root, [current.system, ...current.subSystems], state.renderer);
198
+ const main = systems.find((s) => !s.onlyUsedByOther) ?? systems[0];
199
+ const next = new EffectBinding(main, systems.filter((s) => s !== main), root);
200
+ state.history.attach(next);
201
+ next.subscribe(force);
202
+ state.binding = next;
203
+ setBinding(next);
204
+ }
205
+ force();
206
+ };
207
+ // Selection survives re-renders but falls back to root if the system was removed/replaced
208
+ // (e.g. after undo/redo or import swaps the whole binding out from under it).
209
+ const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
210
+ const activeSystem = binding ? (selectedSystem && allSystems.includes(selectedSystem) ? selectedSystem : binding.system) : null;
211
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', width: '100%', height: '100%', fontFamily: theme.font, color: theme.text, background: '#070b16' }, children: [_jsx("style", { children: globalEditorStyles }), _jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsx("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, children: _jsx("canvas", { ref: canvasRef, style: { width: '100%', height: '100%', display: 'block', touchAction: 'none', outline: 'none' } }) }), _jsxs("aside", { style: { width: 340, flexShrink: 0, borderLeft: `1px solid ${theme.border}`, background: theme.panelBg, display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: { fontSize: 16, fontWeight: 600, cursor: binding ? 'pointer' : 'default', borderRadius: 6, padding: '2px 4px', margin: '-2px -4px' }, onClick: () => binding && setRenamingRoot(true), children: (binding?.root.name || props.title) ?? 'Effect editor' }), renamingRoot && binding && (_jsx(PromptDialog, { title: "Rename effect", defaultValue: binding.root.name, onSubmit: (name) => {
212
+ binding.apply(() => (binding.root.name = name));
213
+ setRenamingRoot(false);
214
+ }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo, onClick: () => historyAction(state.history.undo()), children: "\u21B6" }), _jsx("button", { className: "qe-hover", style: buttonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo, onClick: () => historyAction(state.history.redo()), children: "\u21B7" }), _jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => {
215
+ if (!binding)
216
+ return;
217
+ const blob = new Blob([binding.exportJSON('EditorEffect')], { type: 'application/json' });
218
+ const link = document.createElement('a');
219
+ link.href = URL.createObjectURL(blob);
220
+ link.download = 'effect.json';
221
+ link.click();
222
+ URL.revokeObjectURL(link.href);
223
+ }, children: "Export" }), _jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => importRef.current?.click(), children: "Import" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" })), _jsx("input", { ref: importRef, type: "file", accept: ".json,application/json", hidden: true, onChange: async (e) => {
224
+ const file = e.target.files?.[0];
225
+ e.target.value = '';
226
+ if (!file || !state)
227
+ return;
228
+ try {
229
+ const json = JSON.parse(await file.text());
230
+ historyAction(json);
231
+ state.history.clear();
232
+ force();
233
+ }
234
+ catch (err) {
235
+ console.error('Failed to import effect JSON:', err);
236
+ }
237
+ } })] })] }), _jsx("div", { style: { flex: 1, overflowY: 'auto', padding: '6px 14px 20px' }, children: binding && activeSystem && (_jsx(EffectEditor, { binding: binding, selectedSystem: activeSystem, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture && state ? (url) => props.resolveTexture(url, state.scene) : undefined, resolveGeometry: props.resolveGeometry && state ? (file) => props.resolveGeometry(file, state.scene) : undefined })) })] })] }), binding && activeSystem && state && (_jsx(TimelinePanel, { binding: binding, selectedSystem: activeSystem, onSelect: setSelectedSystem, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: counterRef, paused: paused, setPaused: setPaused, speed: speed, setSpeed: setSpeed }))] }));
238
+ }
239
+ /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
240
+ export const QuarksEffectEditor = {
241
+ Show(options) {
242
+ const { hostElement, ...props } = options;
243
+ const root = createRoot(hostElement);
244
+ root.render(_jsx(EffectEditorHost, { ...props }));
245
+ return { dispose: () => root.unmount() };
246
+ },
247
+ };
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { GradientStop } from '../core/colors';
3
+ /** Gradient editor: RGBA stops on a bar — click to add, drag to move, edit the selected stop. */
4
+ export declare function GradientEditor(props: {
5
+ stops: GradientStop[];
6
+ onChange: (stops: GradientStop[]) => void;
7
+ }): React.JSX.Element;