babylon.quarks-editor 0.23.0 → 0.24.1

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,37 @@ 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.1] — 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - With Babylon 9.31 the viewport lost its ground grid (the whole plane drew in flat grey) and the
12
+ gizmos, viewport selection and drop-to-place stopped responding: 9.31's grid needs `linesOnly`
13
+ for what opacity below 1 used to do, and `scene.pick` now needs `@babylonjs/core/Culling/ray`
14
+ imported for its side effect, which the editor now does.
15
+
16
+ ## [0.24.0] — 2026-10-20
17
+
18
+ ### Added
19
+
20
+ - Main module: "Playback speed", "Fixed seed" (the system's random seed, for an effect that plays
21
+ out identically every time) and "Off screen" (the system's culling mode: always simulate, pause,
22
+ pause and catch up). Renderer module: "Rendering group", "Apply fog", "Sort mode" and "Limit
23
+ screen size" (min / max particle size as fractions of the viewport). Shape module: "Align to
24
+ direction". Noise module: "Scroll speed" and "Remap" (with its curve). Velocity module: "Orbital
25
+ offset" and "Radial". Sub Emitters module: "Inherit color / size / rotation / lifetime".
26
+ - The editor now reaches everything the runtime can do. Shape module: Box (with emit from
27
+ volume / shell / edges) and Edge shapes. Main module: "Blend between two" start colour
28
+ (`ColorRange`) and "3D (axis + angle)" start rotation (`AxisAngleGenerator`). New modules:
29
+ "Rotation over Lifetime (3D)" for mesh particles (axis + angle, per axis, random) and
30
+ "Sequences" (`ApplySequences`: sequences read from an image's opaque pixels, with time window,
31
+ scale and position). Renderer module: "Layer mask", "Max particles", billboard "Pivot", the
32
+ trail flags (record in world space, UV from head, color from particle, die with particle, head
33
+ width scale) and, for meshes, an environment-map atlas picked from the host's textures with its
34
+ reflection level.
35
+ - "Random between two" fields label their min and max inputs. Export and Save warn when the
36
+ effect references `blob:` textures, which do not survive a reload.
37
+
7
38
  ## [0.23.0] — 2026-10-18
8
39
 
9
40
  ### Added
@@ -1,3 +1,4 @@
1
+ import '@babylonjs/core/Culling/ray';
1
2
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
2
3
  import type { Scene } from '@babylonjs/core/scene';
3
4
  import type { BatchedRenderer } from 'babylon.quarks';
@@ -1,3 +1,4 @@
1
+ import '@babylonjs/core/Culling/ray';
1
2
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
2
3
  import { DEFAULT_GALLERY_SPACING, galleryGridPosition } from './galleryLayout';
3
4
  /** Clears live particles and freezes simulation for one scene entry. */
@@ -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,3 +1,4 @@
1
+ import '@babylonjs/core/Culling/ray';
1
2
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
3
  import type { Scene } from '@babylonjs/core/scene';
3
4
  import type { SceneEntry } from './sceneEntries';
@@ -1,3 +1,4 @@
1
+ import '@babylonjs/core/Culling/ray';
1
2
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
2
3
  import { EDITOR_SCENE_NODE_METADATA } from './editorScene';
3
4
  import { GROUND_RING_RADIUS, GalleryHoverMarker } from './gallerySelectionMarker';
@@ -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
  });
@@ -1,3 +1,4 @@
1
+ import '@babylonjs/core/Culling/ray';
1
2
  import { Scene } from '@babylonjs/core/scene';
2
3
  import { BatchedRenderer } from 'babylon.quarks';
3
4
  import React from 'react';
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
3
+ // scene.pick lives in Ray's side effect (Babylon 9.31 no longer pulls it in); gizmos pick through it.
4
+ import '@babylonjs/core/Culling/ray';
3
5
  import { Engine } from '@babylonjs/core/Engines/engine';
4
6
  import { PointerEventTypes } from '@babylonjs/core/Events/pointerEvents';
5
7
  import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
@@ -48,6 +50,18 @@ import { TimelinePanel } from './TimelinePanel';
48
50
  import { TransportBar } from './TransportBar';
49
51
  import { iconStyle } from './icons';
50
52
  import { buttonStyle, globalEditorStyles, theme } from './theme';
53
+ /**
54
+ * The effect's JSON, with a warning when it points at object URLs: a texture
55
+ * dropped in from disk lives at a `blob:` URL that dies with the page, so the
56
+ * exported effect would load without it.
57
+ */
58
+ function exportEffectJSON(binding, warn) {
59
+ const json = binding.exportJSON('EditorEffect');
60
+ if (json.includes('"blob:')) {
61
+ warn('This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.');
62
+ }
63
+ return json;
64
+ }
51
65
  const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
52
66
  function toStoredAsset(asset) {
53
67
  return { path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles };
@@ -281,6 +295,9 @@ export function EffectEditorHost(props) {
281
295
  groundMaterial.mainColor = new Color3(1, 1, 1);
282
296
  groundMaterial.lineColor = new Color3(1, 1, 1);
283
297
  groundMaterial.opacity = 0.5;
298
+ // Draw only the lines. Babylon < 9.31 did this by itself for opacity < 1; 9.31 needs the flag,
299
+ // or the whole plane is filled with the main color.
300
+ groundMaterial.linesOnly = true;
284
301
  const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
285
302
  ground.material = groundMaterial;
286
303
  const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
@@ -1619,7 +1636,7 @@ export function EffectEditorHost(props) {
1619
1636
  }, 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
1637
  if (!binding)
1621
1638
  return;
1622
- const blob = new Blob([binding.exportJSON('EditorEffect')], {
1639
+ const blob = new Blob([exportEffectJSON(binding, showToast)], {
1623
1640
  type: 'application/json',
1624
1641
  });
1625
1642
  const link = document.createElement('a');
@@ -1627,7 +1644,7 @@ export function EffectEditorHost(props) {
1627
1644
  link.download = `${binding.root.name || 'effect'}.json`;
1628
1645
  link.click();
1629
1646
  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: {
1647
+ }, 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
1648
  height: DRAWER_HEIGHT,
1632
1649
  display: 'flex',
1633
1650
  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) {