babylon.quarks-editor 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to the `babylon.quarks-editor` package are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project follows the `quarks.core` 0.x version line.
6
6
 
7
+ ## [0.24.0] — 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - Main module: "Playback speed", "Fixed seed" (the system's random seed, for an effect that plays
12
+ out identically every time) and "Off screen" (the system's culling mode: always simulate, pause,
13
+ pause and catch up). Renderer module: "Rendering group", "Apply fog", "Sort mode" and "Limit
14
+ screen size" (min / max particle size as fractions of the viewport). Shape module: "Align to
15
+ direction". Noise module: "Scroll speed" and "Remap" (with its curve). Velocity module: "Orbital
16
+ offset" and "Radial". Sub Emitters module: "Inherit color / size / rotation / lifetime".
17
+ - The editor now reaches everything the runtime can do. Shape module: Box (with emit from
18
+ volume / shell / edges) and Edge shapes. Main module: "Blend between two" start colour
19
+ (`ColorRange`) and "3D (axis + angle)" start rotation (`AxisAngleGenerator`). New modules:
20
+ "Rotation over Lifetime (3D)" for mesh particles (axis + angle, per axis, random) and
21
+ "Sequences" (`ApplySequences`: sequences read from an image's opaque pixels, with time window,
22
+ scale and position). Renderer module: "Layer mask", "Max particles", billboard "Pivot", the
23
+ trail flags (record in world space, UV from head, color from particle, die with particle, head
24
+ width scale) and, for meshes, an environment-map atlas picked from the host's textures with its
25
+ reflection level.
26
+ - "Random between two" fields label their min and max inputs. Export and Save warn when the
27
+ effect references `blob:` textures, which do not survive a reload.
28
+
7
29
  ## [0.23.0] — 2026-10-18
8
30
 
9
31
  ### Added
@@ -6,6 +6,9 @@ export interface RendererMaterialPatch {
6
6
  depthWrite?: boolean;
7
7
  alphaTest?: number;
8
8
  texture?: unknown;
9
+ /** A 3×2 cube-face atlas mesh particles reflect, or null for none. */
10
+ reflectionAtlas?: unknown;
11
+ reflectionLevel?: number;
9
12
  }
10
13
  /** Applies renderer material settings and keeps imported QuarksMaterial JSON in sync for export. */
11
14
  export declare function applyRendererMaterial(system: ParticleSystem, patch: RendererMaterialPatch): void;
@@ -22,6 +22,12 @@ export function applyRendererMaterial(system, patch) {
22
22
  if (patch.texture !== undefined) {
23
23
  system.texture = patch.texture;
24
24
  }
25
+ if (patch.reflectionAtlas !== undefined) {
26
+ settings.reflectionAtlas = patch.reflectionAtlas;
27
+ }
28
+ if (patch.reflectionLevel !== undefined) {
29
+ settings.reflectionLevel = patch.reflectionLevel;
30
+ }
25
31
  const material = system.material;
26
32
  if (isEditableMaterialRecord(material)) {
27
33
  if (patch.blendMode !== undefined) {
@@ -44,6 +50,12 @@ export function applyRendererMaterial(system, patch) {
44
50
  material.texture = patch.texture;
45
51
  material.map = patch.texture;
46
52
  }
53
+ if (patch.reflectionAtlas !== undefined) {
54
+ material.reflectionAtlas = patch.reflectionAtlas;
55
+ }
56
+ if (patch.reflectionLevel !== undefined) {
57
+ material.reflectionLevel = patch.reflectionLevel;
58
+ }
47
59
  }
48
60
  system.neededToUpdateRender = true;
49
61
  }
@@ -1,7 +1,7 @@
1
1
  import type { Mesh } from '@babylonjs/core/Meshes/mesh';
2
2
  import type { EmitterShape } from 'babylon.quarks';
3
- import { EmitterMode } from 'babylon.quarks';
4
- export type ShapeType = 'point' | 'sphere' | 'hemisphere' | 'cone' | 'circle' | 'donut' | 'rectangle' | 'grid' | 'mesh_surface';
3
+ import { BoxEmitFrom, EmitterMode } from 'babylon.quarks';
4
+ export type ShapeType = 'point' | 'sphere' | 'hemisphere' | 'cone' | 'circle' | 'donut' | 'rectangle' | 'grid' | 'box' | 'edge' | 'mesh_surface';
5
5
  export declare const SHAPE_TYPES: Array<{
6
6
  value: ShapeType;
7
7
  label: string;
@@ -18,6 +18,12 @@ export interface ShapeParams {
18
18
  spread: number;
19
19
  column: number;
20
20
  row: number;
21
+ /** Box depth (Z). */
22
+ depth: number;
23
+ /** Where a box emits from: its volume, its faces or its edges. */
24
+ emitFrom: BoxEmitFrom;
25
+ /** How fast an edge emitter's loop / ping-pong point travels, in lengths per second. */
26
+ speed: number;
21
27
  mesh?: Mesh;
22
28
  }
23
29
  export declare const DEFAULT_SHAPE_PARAMS: ShapeParams;
@@ -1,4 +1,4 @@
1
- import { CircleEmitter, ConeEmitter, DonutEmitter, EmitterMode, GridEmitter, HemisphereEmitter, MeshSurfaceEmitter, PointEmitter, RectangleEmitter, SphereEmitter, } from 'babylon.quarks';
1
+ import { BoxEmitFrom, BoxEmitter, CircleEmitter, ConeEmitter, ConstantValue, DonutEmitter, EdgeEmitter, EmitterMode, GridEmitter, HemisphereEmitter, MeshSurfaceEmitter, PointEmitter, RectangleEmitter, SphereEmitter, } from 'babylon.quarks';
2
2
  export const SHAPE_TYPES = [
3
3
  { value: 'cone', label: 'Cone' },
4
4
  { value: 'sphere', label: 'Sphere' },
@@ -7,6 +7,8 @@ export const SHAPE_TYPES = [
7
7
  { value: 'donut', label: 'Donut' },
8
8
  { value: 'rectangle', label: 'Rectangle' },
9
9
  { value: 'grid', label: 'Grid' },
10
+ { value: 'box', label: 'Box' },
11
+ { value: 'edge', label: 'Edge' },
10
12
  { value: 'mesh_surface', label: 'Mesh surface' },
11
13
  { value: 'point', label: 'Point' },
12
14
  ];
@@ -22,6 +24,9 @@ export const DEFAULT_SHAPE_PARAMS = {
22
24
  spread: 0,
23
25
  column: 10,
24
26
  row: 10,
27
+ depth: 1,
28
+ emitFrom: BoxEmitFrom.Volume,
29
+ speed: 1,
25
30
  mesh: undefined,
26
31
  };
27
32
  export function getShapeType(shape) {
@@ -43,6 +48,9 @@ export function readShapeParams(shape) {
43
48
  height: num('height', DEFAULT_SHAPE_PARAMS.height),
44
49
  column: num('column', DEFAULT_SHAPE_PARAMS.column),
45
50
  row: num('row', DEFAULT_SHAPE_PARAMS.row),
51
+ depth: num('depth', DEFAULT_SHAPE_PARAMS.depth),
52
+ emitFrom: num('emitFrom', DEFAULT_SHAPE_PARAMS.emitFrom),
53
+ speed: anyShape.speed instanceof ConstantValue ? anyShape.speed.value : DEFAULT_SHAPE_PARAMS.speed,
46
54
  mesh: anyShape.mesh,
47
55
  };
48
56
  }
@@ -57,6 +65,8 @@ export const SHAPE_PARAM_KEYS = {
57
65
  donut: ['radius', 'arc', 'thickness', 'donutRadius'],
58
66
  rectangle: ['width', 'height'],
59
67
  grid: ['width', 'height', 'column', 'row'],
68
+ box: ['width', 'height', 'depth'],
69
+ edge: ['radius', 'speed'],
60
70
  mesh_surface: [],
61
71
  };
62
72
  export function createShape(type, params) {
@@ -119,6 +129,20 @@ export function createShape(type, params) {
119
129
  column: params.column,
120
130
  row: params.row,
121
131
  });
132
+ case 'box':
133
+ return new BoxEmitter({
134
+ width: params.width,
135
+ height: params.height,
136
+ depth: params.depth,
137
+ emitFrom: params.emitFrom,
138
+ });
139
+ case 'edge':
140
+ return new EdgeEmitter({
141
+ radius: params.radius,
142
+ mode: params.mode,
143
+ spread: params.spread,
144
+ speed: new ConstantValue(params.speed),
145
+ });
122
146
  case 'mesh_surface':
123
147
  return new MeshSurfaceEmitter(params.mesh);
124
148
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useEffect, useSyncExternalStore } from 'react';
3
3
  import { EffectBinding } from '../core/binding';
4
- import { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, InheritVelocityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, VelocityOverLifeModule, WidthOverTrailModule, } from './behaviorModules';
4
+ import { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, InheritVelocityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, Rotation3DOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SequencesModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, VelocityOverLifeModule, WidthOverTrailModule, } from './behaviorModules';
5
5
  import { ColorOverLifeModule, EmissionModule, MainModule, RendererModule, ShapeModule, SizeOverLifeModule, } from './modules';
6
6
  import { ObjectModule } from './ObjectModule';
7
7
  import { SubEmittersModule } from './SubEmittersModule';
@@ -31,5 +31,5 @@ export const EffectEditor = React.memo(function EffectEditor(props) {
31
31
  useEffect(() => (binding === props.binding ? undefined : binding.subscribe(() => props.binding.markChanged())), [binding, props.binding]);
32
32
  useSyncExternalStore((onStoreChange) => binding.subscribe(onStoreChange), () => binding.getRevision());
33
33
  const gizmoTargetIsEmitter = allSystems.some((s) => s.emitter === props.gizmoTargetNode);
34
- return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel, onBeforeChange: () => props.binding.onBeforeChange?.(), onChanged: () => props.binding.markChanged() }), !gizmoTargetIsEmitter && (_jsxs("div", { style: { fontSize: 11, color: theme.textDim, margin: '0 0 10px', lineHeight: 1.45 }, children: ["Modules below edit ", _jsx("strong", { children: activeSystem.emitter.name || 'particle system' }), ". Select its emitter in the timeline to move it with the gizmo."] })), _jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(RotationBySpeedModule, { binding: binding }), _jsx(NoiseModule, { binding: binding }), _jsx(TurbulenceModule, { binding: binding }), _jsx(CollisionModule, { binding: binding }), _jsx(WidthOverTrailModule, { binding: binding }), _jsx(SubEmittersModule, { binding: binding, rootBinding: props.binding }), _jsx(TextureSheetModule, { binding: binding }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
34
+ return (_jsxs("div", { style: { fontFamily: theme.font, color: theme.text }, children: [_jsx(ObjectModule, { node: props.gizmoTargetNode, label: props.gizmoTargetLabel, onBeforeChange: () => props.binding.onBeforeChange?.(), onChanged: () => props.binding.markChanged() }), !gizmoTargetIsEmitter && (_jsxs("div", { style: { fontSize: 11, color: theme.textDim, margin: '0 0 10px', lineHeight: 1.45 }, children: ["Modules below edit ", _jsx("strong", { children: activeSystem.emitter.name || 'particle system' }), ". Select its emitter in the timeline to move it with the gizmo."] })), _jsx(MainModule, { binding: binding }), _jsx(EmissionModule, { binding: binding }), _jsx(ShapeModule, { binding: binding }), _jsx(EmitDirectionModule, { binding: binding }), _jsx(VelocityOverLifeModule, { binding: binding }), _jsx(SpeedOverLifeModule, { binding: binding }), _jsx(LimitSpeedOverLifeModule, { binding: binding }), _jsx(InheritVelocityModule, { binding: binding }), _jsx(ForceOverLifeModule, { binding: binding }), _jsx(GravityModule, { binding: binding }), _jsx(OrbitOverLifeModule, { binding: binding }), _jsx(ColorOverLifeModule, { binding: binding }), _jsx(ColorBySpeedModule, { binding: binding }), _jsx(SizeOverLifeModule, { binding: binding }), _jsx(SizeBySpeedModule, { binding: binding }), _jsx(RotationOverLifeModule, { binding: binding }), _jsx(Rotation3DOverLifeModule, { 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(SequencesModule, { binding: binding, textureOptions: props.textureOptions }), _jsx(RendererModule, { binding: binding, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture, resolveGeometry: props.resolveGeometry })] }));
35
35
  });
@@ -48,6 +48,18 @@ import { TimelinePanel } from './TimelinePanel';
48
48
  import { TransportBar } from './TransportBar';
49
49
  import { iconStyle } from './icons';
50
50
  import { buttonStyle, globalEditorStyles, theme } from './theme';
51
+ /**
52
+ * The effect's JSON, with a warning when it points at object URLs: a texture
53
+ * dropped in from disk lives at a `blob:` URL that dies with the page, so the
54
+ * exported effect would load without it.
55
+ */
56
+ function exportEffectJSON(binding, warn) {
57
+ const json = binding.exportJSON('EditorEffect');
58
+ if (json.includes('"blob:')) {
59
+ warn('This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.');
60
+ }
61
+ return json;
62
+ }
51
63
  const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
52
64
  function toStoredAsset(asset) {
53
65
  return { path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles };
@@ -1619,7 +1631,7 @@ export function EffectEditorHost(props) {
1619
1631
  }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => {
1620
1632
  if (!binding)
1621
1633
  return;
1622
- const blob = new Blob([binding.exportJSON('EditorEffect')], {
1634
+ const blob = new Blob([exportEffectJSON(binding, showToast)], {
1623
1635
  type: 'application/json',
1624
1636
  });
1625
1637
  const link = document.createElement('a');
@@ -1627,7 +1639,7 @@ export function EffectEditorHost(props) {
1627
1639
  link.download = `${binding.root.name || 'effect'}.json`;
1628
1640
  link.click();
1629
1641
  URL.revokeObjectURL(link.href);
1630
- }, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(binding.exportJSON('EditorEffect')), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: inspectorBinding && inspectorSystem && inspectorNode ? (_jsx(EffectEditor, { binding: inspectorBinding, selectedSystem: inspectorSystem, gizmoTargetNode: inspectorNode, gizmoTargetLabel: inspectorNode.name || 'Node', textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), _jsxs("div", { "aria-hidden": !drawerOpen && !drawerPinned, style: {
1642
+ }, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(exportEffectJSON(binding, showToast)), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: inspectorBinding && inspectorSystem && inspectorNode ? (_jsx(EffectEditor, { binding: inspectorBinding, selectedSystem: inspectorSystem, gizmoTargetNode: inspectorNode, gizmoTargetLabel: inspectorNode.name || 'Node', textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), _jsxs("div", { "aria-hidden": !drawerOpen && !drawerPinned, style: {
1631
1643
  height: DRAWER_HEIGHT,
1632
1644
  display: 'flex',
1633
1645
  flexDirection: 'column',
@@ -35,5 +35,7 @@ export function SubEmittersModule(props) {
35
35
  { value: SubParticleEmitMode.Death, label: 'On death' },
36
36
  { value: SubParticleEmitMode.Birth, label: 'On birth' },
37
37
  { value: SubParticleEmitMode.Frame, label: 'Every frame' },
38
- ], onChange: (mode) => binding.apply(() => (behavior.mode = mode)) }) }), _jsx(Row, { label: "Probability", children: _jsx(NumberField, { value: behavior.emitProbability, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.emitProbability = Math.min(1, v))) }) }), _jsx(Row, { label: "Use velocity", children: _jsx(CheckboxField, { value: behavior.useVelocityAsBasis, onChange: (v) => binding.apply(() => (behavior.useVelocityAsBasis = v)) }) }), _jsxs("p", { style: { margin: '8px 0 2px', color: theme.textDim, fontSize: 11.5 }, children: ["Spawns \u201C", target?.emitter.name ?? 'sub-emitter', "\u201D \u2014 select it in the hierarchy above to edit it."] })] })) }));
38
+ ], onChange: (mode) => binding.apply(() => (behavior.mode = mode)) }) }), _jsx(Row, { label: "Probability", children: _jsx(NumberField, { value: behavior.emitProbability, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.emitProbability = Math.min(1, v))) }) }), _jsx(Row, { label: "Use velocity", children: _jsx(CheckboxField, { value: behavior.useVelocityAsBasis, onChange: (v) => binding.apply(() => (behavior.useVelocityAsBasis = v)) }) }), ['color', 'size', 'rotation', 'lifetime'].map((property) => (_jsx(Row, { label: `Inherit ${property}`, children: _jsx(CheckboxField, { value: behavior.inherit.includes(property), onChange: (v) => binding.apply(() => (behavior.inherit = v
39
+ ? [...behavior.inherit.filter((p) => p !== property), property]
40
+ : behavior.inherit.filter((p) => p !== property))) }) }, property))), _jsxs("p", { style: { margin: '8px 0 2px', color: theme.textDim, fontSize: 11.5 }, children: ["Spawns \u201C", target?.emitter.name ?? 'sub-emitter', "\u201D \u2014 select it in the hierarchy above to edit it."] })] })) }));
39
41
  }
@@ -1,8 +1,5 @@
1
+ import React from 'react';
1
2
  import { ScalarGenerator } from '../core/values';
2
- /**
3
- * Unity-like value field: Constant | Random Between Two Constants | Curve,
4
- * bound to a quarks value generator.
5
- */
6
3
  export declare function ValueField(props: {
7
4
  label: string;
8
5
  hint?: string;
@@ -11,4 +8,4 @@ export declare function ValueField(props: {
11
8
  onChange: (generator: ScalarGenerator) => void;
12
9
  min?: number;
13
10
  curveMax?: number;
14
- }): import("react").JSX.Element;
11
+ }): React.JSX.Element;
@@ -6,6 +6,7 @@ import { NumberField, Row, SelectField } from './fields';
6
6
  * Unity-like value field: Constant | Random Between Two Constants | Curve,
7
7
  * bound to a quarks value generator.
8
8
  */
9
+ const captionStyle = { fontSize: 10.5, opacity: 0.6, flex: 'none' };
9
10
  export function ValueField(props) {
10
11
  const state = readScalar(props.generator);
11
12
  const commit = (patch) => {
@@ -15,5 +16,5 @@ export function ValueField(props) {
15
16
  { value: 'constant', label: 'Constant' },
16
17
  { value: 'random', label: 'Random between two' },
17
18
  { value: 'curve', label: 'Curve' },
18
- ], onChange: (mode) => commit({ mode }) }) }), state.mode === 'constant' && (_jsx(Row, { label: "", children: _jsx(NumberField, { value: state.value, min: props.min, onChange: (value) => commit({ value }) }) })), state.mode === 'random' && (_jsx(Row, { label: "", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: state.min, min: props.min, onChange: (min) => commit({ min }) }), _jsx(NumberField, { value: state.max, min: props.min, onChange: (max) => commit({ max }) })] }) })), state.mode === 'curve' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(props.generator), maxValue: props.curveMax, onChange: (pieces) => props.onChange(buildCurve(pieces)) }) }))] }));
19
+ ], onChange: (mode) => commit({ mode }) }) }), state.mode === 'constant' && (_jsx(Row, { label: "", children: _jsx(NumberField, { value: state.value, min: props.min, onChange: (value) => commit({ value }) }) })), state.mode === 'random' && (_jsx(Row, { label: "", children: _jsxs("div", { style: { display: 'flex', gap: 6, alignItems: 'center' }, children: [_jsx("span", { style: captionStyle, children: "min" }), _jsx(NumberField, { value: state.min, min: props.min, onChange: (min) => commit({ min }) }), _jsx("span", { style: captionStyle, children: "max" }), _jsx(NumberField, { value: state.max, min: props.min, onChange: (max) => commit({ max }) })] }) })), state.mode === 'curve' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(props.generator), maxValue: props.curveMax, onChange: (pieces) => props.onChange(buildCurve(pieces)) }) }))] }));
19
20
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { EffectBinding } from '../core/binding';
3
+ import type { TextureOption } from './modules';
3
4
  interface ModuleProps {
4
5
  binding: EffectBinding;
5
6
  }
@@ -10,6 +11,16 @@ export declare function VelocityOverLifeModule({ binding }: ModuleProps): React.
10
11
  export declare function InheritVelocityModule({ binding }: ModuleProps): React.JSX.Element;
11
12
  export declare function GravityModule({ binding }: ModuleProps): React.JSX.Element;
12
13
  export declare function RotationOverLifeModule({ binding }: ModuleProps): React.JSX.Element;
14
+ /** Turns mesh particles by a 3D angular velocity; billboards turn through Rotation over Lifetime. */
15
+ export declare function Rotation3DOverLifeModule({ binding }: ModuleProps): React.JSX.Element;
16
+ /**
17
+ * Moves particles, one after another, onto the opaque pixels of an image (a
18
+ * logo or a word drawn from sparks). Each sequence is read from an image once;
19
+ * its points, scale, position and time window are what gets saved.
20
+ */
21
+ export declare function SequencesModule({ binding, textureOptions }: ModuleProps & {
22
+ textureOptions?: TextureOption[];
23
+ }): React.JSX.Element;
13
24
  export declare function NoiseModule({ binding }: ModuleProps): React.JSX.Element;
14
25
  export declare function TurbulenceModule({ binding }: ModuleProps): React.JSX.Element;
15
26
  export declare function EmitDirectionModule({ binding }: ModuleProps): React.JSX.Element;
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ApplyCollision, ApplyForce, ChangeEmitDirection, ColorBySpeed, ConstantValue, ForceOverLife, FrameOverLife, getPhysicsResolver, Gradient, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TurbulenceField, Vector3, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
2
+ import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid';
3
+ import { ApplyCollision, ApplyForce, ApplySequences, AxisAngleGenerator, Bezier, ChangeEmitDirection, ColorBySpeed, ConstantValue, EulerGenerator, ForceOverLife, FrameOverLife, getPhysicsResolver, Gradient, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, PiecewiseBezier, RandomQuatGenerator, Rotation3DOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TextureSequencer, TurbulenceField, Vector3, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
4
+ import React from 'react';
3
5
  import { buildGradient, DEFAULT_GRADIENT_STOPS, findBehavior, readGradientStops } from '../core/colors';
4
6
  import { buildCurve, buildScalar, readPieces, readScalar } from '../core/values';
5
7
  import { CurveEditor } from './CurveEditor';
6
8
  import { CheckboxField, NumberField, Row, SelectField } from './fields';
7
9
  import { GradientEditor } from './GradientEditor';
10
+ import { iconStyle } from './icons';
8
11
  import { ModuleSection } from './ModuleSection';
12
+ import { theme } from './theme';
9
13
  import { ValueField } from './ValueField';
10
14
  function removeBehaviorOfType(behaviors, type) {
11
15
  for (let i = behaviors.length - 1; i >= 0; i--) {
@@ -37,7 +41,7 @@ export function VelocityOverLifeModule({ binding }) {
37
41
  return (_jsx(BehaviorModule, { binding: binding, title: "Velocity over Lifetime", type: "VelocityOverLife", create: () => new VelocityOverLife(new ConstantValue(0), new ConstantValue(1), new ConstantValue(0)), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Space", children: _jsx(SelectField, { value: behavior.space, options: [
38
42
  { value: 'local', label: 'Local' },
39
43
  { value: 'world', label: 'World' },
40
- ], onChange: (v) => binding.apply(() => (behavior.space = v)) }) }), _jsx(ValueField, { label: "Linear X", generator: behavior.linearX, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearX = g)) }), _jsx(ValueField, { label: "Linear Y", generator: behavior.linearY, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearY = g)) }), _jsx(ValueField, { label: "Linear Z", generator: behavior.linearZ, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearZ = g)) }), _jsx(ValueField, { label: "Orbital X", generator: behavior.orbitalX, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalX = g)) }), _jsx(ValueField, { label: "Orbital Y", generator: behavior.orbitalY, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalY = g)) }), _jsx(ValueField, { label: "Orbital Z", generator: behavior.orbitalZ, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalZ = g)) })] })) }));
44
+ ], onChange: (v) => binding.apply(() => (behavior.space = v)) }) }), _jsx(ValueField, { label: "Linear X", generator: behavior.linearX, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearX = g)) }), _jsx(ValueField, { label: "Linear Y", generator: behavior.linearY, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearY = g)) }), _jsx(ValueField, { label: "Linear Z", generator: behavior.linearZ, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.linearZ = g)) }), _jsx(ValueField, { label: "Orbital X", generator: behavior.orbitalX, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalX = g)) }), _jsx(ValueField, { label: "Orbital Y", generator: behavior.orbitalY, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalY = g)) }), _jsx(ValueField, { label: "Orbital Z", generator: behavior.orbitalZ, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.orbitalZ = g)) }), _jsx(Vector3Row, { label: "Orbital offset", value: behavior.orbitalOffset, onChange: (v) => binding.apply(() => (behavior.orbitalOffset = v)) }), _jsx(ValueField, { label: "Radial", generator: behavior.radial, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.radial = g)) })] })) }));
41
45
  }
42
46
  export function InheritVelocityModule({ binding }) {
43
47
  return (_jsx(BehaviorModule, { binding: binding, title: "Inherit Velocity", type: "InheritVelocity", create: () => new InheritVelocity(new ConstantValue(1), 'initial'), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Mode", hintKey: "inheritVelocity.mode", children: _jsx(SelectField, { value: behavior.mode, options: [
@@ -51,8 +55,82 @@ export function GravityModule({ binding }) {
51
55
  export function RotationOverLifeModule({ binding }) {
52
56
  return (_jsx(BehaviorModule, { binding: binding, title: "Rotation over Lifetime", type: "RotationOverLife", create: () => new RotationOverLife(new ConstantValue(Math.PI)), children: (behavior) => (_jsx(ValueField, { hintKey: "rotationOverLife.velocity", label: "Velocity (rad/s)", generator: behavior.angularVelocity, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.angularVelocity = g)) })) }));
53
57
  }
58
+ /** Turns mesh particles by a 3D angular velocity; billboards turn through Rotation over Lifetime. */
59
+ export function Rotation3DOverLifeModule({ binding }) {
60
+ return (_jsx(BehaviorModule, { binding: binding, title: "Rotation over Lifetime (3D)", type: "Rotation3DOverLife", create: () => new Rotation3DOverLife(new AxisAngleGenerator(new Vector3(0, 1, 0), new ConstantValue(Math.PI))), children: (behavior) => {
61
+ const generator = behavior.angularVelocity;
62
+ const mode = generator instanceof AxisAngleGenerator
63
+ ? 'axisAngle'
64
+ : generator instanceof EulerGenerator
65
+ ? 'euler'
66
+ : 'random';
67
+ const axisAngle = generator instanceof AxisAngleGenerator ? generator : undefined;
68
+ const euler = generator instanceof EulerGenerator ? generator : undefined;
69
+ return (_jsxs(_Fragment, { children: [_jsx("p", { style: { margin: '2px 0 6px', color: theme.textDim, fontSize: 11.5 }, children: "Mesh particles only; billboards spin with Rotation over Lifetime." }), _jsx(Row, { label: "Mode", children: _jsx(SelectField, { value: mode, options: [
70
+ { value: 'axisAngle', label: 'Axis + angle' },
71
+ { value: 'euler', label: 'Per axis' },
72
+ { value: 'random', label: 'Random' },
73
+ ], onChange: (next) => binding.apply(() => {
74
+ behavior.angularVelocity =
75
+ next === 'axisAngle'
76
+ ? new AxisAngleGenerator(new Vector3(0, 1, 0), new ConstantValue(Math.PI))
77
+ : next === 'euler'
78
+ ? new EulerGenerator(new ConstantValue(0), new ConstantValue(Math.PI), new ConstantValue(0))
79
+ : new RandomQuatGenerator();
80
+ }) }) }), axisAngle && (_jsxs(_Fragment, { children: [_jsx(Vector3Row, { label: "Axis", value: axisAngle.axis, step: 0.1, onChange: (v) => binding.apply(() => (behavior.angularVelocity = new AxisAngleGenerator(v, axisAngle.angle))) }), _jsx(ValueField, { label: "Angle (rad/s)", generator: axisAngle.angle, curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.angularVelocity = new AxisAngleGenerator(axisAngle.axis.clone(), g))) })] })), euler &&
81
+ ['angleX', 'angleY', 'angleZ'].map((key) => (_jsx(ValueField, { label: `${key.slice(-1)} (rad/s)`, generator: euler[key], curveMax: Math.PI * 4, onChange: (g) => binding.apply(() => (behavior.angularVelocity = new EulerGenerator(key === 'angleX' ? g : euler.angleX, key === 'angleY' ? g : euler.angleY, key === 'angleZ' ? g : euler.angleZ))) }, key)))] }));
82
+ } }));
83
+ }
84
+ /**
85
+ * Moves particles, one after another, onto the opaque pixels of an image (a
86
+ * logo or a word drawn from sparks). Each sequence is read from an image once;
87
+ * its points, scale, position and time window are what gets saved.
88
+ */
89
+ export function SequencesModule({ binding, textureOptions }) {
90
+ const [imageUrl, setImageUrl] = React.useState(textureOptions?.[0]?.url ?? '');
91
+ const [threshold, setThreshold] = React.useState(0.5);
92
+ const [status, setStatus] = React.useState(null);
93
+ const addFromImage = async (behavior) => {
94
+ if (!imageUrl) {
95
+ setStatus('Pick an image first.');
96
+ return;
97
+ }
98
+ try {
99
+ const image = new Image();
100
+ image.crossOrigin = 'anonymous';
101
+ image.src = imageUrl;
102
+ await image.decode();
103
+ const sequencer = new TextureSequencer(0.05, 0.05, new Vector3());
104
+ sequencer.fromImage(image, threshold);
105
+ const last = behavior.sequencers[behavior.sequencers.length - 1];
106
+ const start = last ? last[0].b : 0;
107
+ binding.apply(() => behavior.appendSequencer(new IntervalValue(start, start + 2), sequencer));
108
+ setStatus(`${sequencer.locations.length} points read.`);
109
+ }
110
+ catch (error) {
111
+ setStatus(`Could not read the image: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ };
114
+ const pair = (a, b, onA, onB, step) => (_jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: a, step: step, onChange: onA }), _jsx(NumberField, { value: b, step: step, onChange: onB })] }));
115
+ return (_jsx(BehaviorModule, { binding: binding, title: "Sequences", type: "ApplySequences", create: () => new ApplySequences(0.001), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("p", { style: { margin: '2px 0 6px', color: theme.textDim, fontSize: 11.5 }, children: "Particles fly, one after another, onto the opaque pixels of an image during each sequence's time window." }), _jsx(Row, { label: "Delay per particle", children: _jsx(NumberField, { value: behavior.delay, min: 0, step: 0.001, onChange: (v) => binding.apply(() => (behavior.delay = Math.max(0, v))) }) }), behavior.sequencers.map(([range, sequencer], index) => {
116
+ const texture = sequencer;
117
+ return (_jsxs("div", { style: {
118
+ border: `1px solid ${theme.border}`,
119
+ borderRadius: 6,
120
+ padding: '4px 6px',
121
+ marginTop: 6,
122
+ }, children: [_jsxs(Row, { label: `Sequence ${index + 1}`, children: [_jsxs("span", { style: { fontSize: 11.5, color: theme.textDim }, children: [texture.locations?.length ?? 0, " points"] }), _jsx("button", { className: "qe-hover", style: {
123
+ marginLeft: 'auto',
124
+ background: 'none',
125
+ border: 'none',
126
+ cursor: 'pointer',
127
+ }, title: "Remove sequence", onClick: () => binding.apply(() => behavior.sequencers.splice(index, 1)), children: _jsx(XMarkIcon, { style: iconStyle(13) }) })] }), _jsx(Row, { label: "Time from / to (s)", children: pair(range.a, range.b, (v) => binding.apply(() => (range.a = v)), (v) => binding.apply(() => (range.b = v)), 0.1) }), texture.locations !== undefined && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Scale X / Y", children: pair(texture.scaleX, texture.scaleY, (v) => binding.apply(() => (texture.scaleX = v)), (v) => binding.apply(() => (texture.scaleY = v)), 0.01) }), _jsx(Vector3Row, { label: "Position", value: texture.position, step: 0.1, onChange: (v) => binding.apply(() => texture.position.copy(v)) })] }))] }, index));
128
+ }), _jsx(Row, { label: "Image", children: textureOptions && textureOptions.length > 0 ? (_jsx(SelectField, { value: imageUrl, options: textureOptions.map((option) => ({ value: option.url, label: option.label })), onChange: (url) => setImageUrl(url) })) : (_jsx("input", { className: "qe-hover", style: { flex: 1, minWidth: 0, fontSize: 12 }, placeholder: "Image URL", value: imageUrl, onChange: (event) => setImageUrl(event.target.value) })) }), _jsx(Row, { label: "Alpha threshold", children: _jsx(NumberField, { value: threshold, min: 0, step: 0.05, onChange: (v) => setThreshold(Math.min(1, v)) }) }), _jsxs("button", { className: "qe-hover", style: { display: 'flex', alignItems: 'center', gap: 4, marginTop: 6, fontSize: 12 }, onClick: () => void addFromImage(behavior), children: [_jsx(PlusIcon, { style: iconStyle(13) }), "Add sequence from image"] }), status && _jsx("p", { style: { margin: '4px 0 0', color: theme.textDim, fontSize: 11.5 }, children: status })] })) }));
129
+ }
54
130
  export function NoiseModule({ binding }) {
55
- return (_jsx(BehaviorModule, { binding: binding, title: "Noise", type: "Noise", create: () => new Noise(new ConstantValue(1), new ConstantValue(1), new ConstantValue(1), new ConstantValue(0)), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(ValueField, { hintKey: "noise.frequency", label: "Frequency", generator: behavior.frequency, min: 0, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.frequency = g)) }), _jsx(ValueField, { hintKey: "noise.strength", label: "Strength", generator: behavior.power, min: 0, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.power = g)) }), _jsx(ValueField, { label: "Position amount", generator: behavior.positionAmount, min: 0, curveMax: 2, onChange: (g) => binding.apply(() => (behavior.positionAmount = g)) }), _jsx(ValueField, { label: "Rotation amount", generator: behavior.rotationAmount, min: 0, curveMax: Math.PI, onChange: (g) => binding.apply(() => (behavior.rotationAmount = g)) })] })) }));
131
+ return (_jsx(BehaviorModule, { binding: binding, title: "Noise", type: "Noise", create: () => new Noise(new ConstantValue(1), new ConstantValue(1), new ConstantValue(1), new ConstantValue(0)), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(ValueField, { hintKey: "noise.frequency", label: "Frequency", generator: behavior.frequency, min: 0, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.frequency = g)) }), _jsx(ValueField, { hintKey: "noise.strength", label: "Strength", generator: behavior.power, min: 0, curveMax: 10, onChange: (g) => binding.apply(() => (behavior.power = g)) }), _jsx(ValueField, { label: "Position amount", generator: behavior.positionAmount, min: 0, curveMax: 2, onChange: (g) => binding.apply(() => (behavior.positionAmount = g)) }), _jsx(ValueField, { label: "Rotation amount", generator: behavior.rotationAmount, min: 0, curveMax: Math.PI, onChange: (g) => binding.apply(() => (behavior.rotationAmount = g)) }), _jsx(ValueField, { label: "Scroll speed", generator: behavior.scrollSpeed, curveMax: 5, onChange: (g) => binding.apply(() => (behavior.scrollSpeed = g)) }), _jsx(Row, { label: "Remap", children: _jsx(CheckboxField, { value: behavior.remap !== undefined, onChange: (v) => binding.apply(() => (behavior.remap = v
132
+ ? new PiecewiseBezier([[new Bezier(-1, -1 / 3, 1 / 3, 1), 0]])
133
+ : undefined)) }) }), behavior.remap !== undefined && (_jsx(ValueField, { label: "Remap curve", generator: behavior.remap, curveMax: 1, onChange: (g) => binding.apply(() => (behavior.remap = g)) }))] })) }));
56
134
  }
57
135
  /** Three numeric inputs for a Vector3 field, replacing the whole vector on any edit. */
58
136
  function Vector3Row(props) {