babylon.quarks-editor 0.28.0 → 0.29.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,27 @@ 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.29.0] — 2026-10-24
8
+
9
+ ### Changed
10
+
11
+ - **Renderer module** has an "Image processing" switch beside "Apply fog", for
12
+ `ParticleSystem.applyImageProcessing`.
13
+
14
+ - **Value fields** offer Random Between Two Curves, with a curve editor for each bound. Switching
15
+ between Curve and Random Between Two Curves keeps every segment.
16
+ - **Curve modules** (Size, Speed and Limit Speed over Lifetime, Size by Speed, Width over Trail,
17
+ Texture Sheet frames) have a "Two curves" switch and show both bounds of an imported Random
18
+ Between Two Curves; an edit used to drop the upper one.
19
+ - **Stop action** in Main and **Ring buffer** (with its loop range) beside Max particles, as
20
+ Unity's. The editor saves the stop action but does not apply it, so previews replay.
21
+ - **Separate axes** in Limit Speed over Lifetime (with its Space), Size over Lifetime and Size by
22
+ Speed: a curve for each of X, Y and Z, as Unity's Separate Axes.
23
+ - **Scaling mode** offers Local (the default), Hierarchy and Shape, as Unity's, for world- and
24
+ local-space systems alike.
25
+ - **Shape module:** a cone's Emit from (Base, Volume) and Length, shown for Volume only, and a
26
+ grid's Sampling (Random, Sequential).
27
+
7
28
  ## [0.28.0] — 2026-10-23
8
29
 
9
30
  ### Breaking
package/README.md CHANGED
@@ -11,7 +11,7 @@ npm install babylon.quarks-editor babylon.quarks @babylonjs/core @babylonjs/mate
11
11
  ```
12
12
 
13
13
  The editor is an ES module package (`import`, not `require`). Its version follows babylon.quarks:
14
- editor 0.28.x needs babylon.quarks 0.28.x.
14
+ editor 0.29.x needs babylon.quarks 0.29.x.
15
15
 
16
16
  ## Usage
17
17
 
@@ -19,6 +19,8 @@ export function createDefaultEffect(scene, resolveTexture, textureUrl) {
19
19
  transparent: true,
20
20
  blendMode: 1,
21
21
  });
22
+ // The editor previews over and over; the stop action is saved, not applied.
23
+ system.ignoreStopAction = true;
22
24
  system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
23
25
  system.emitter.rotation.x = -Math.PI / 2;
24
26
  return system;
@@ -2,6 +2,7 @@ import { Color3 } from '@babylonjs/core/Maths/math.color.js';
2
2
  import { Vector3 } from '@babylonjs/core/Maths/math.vector.js';
3
3
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder.js';
4
4
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode.js';
5
+ import { ConeEmitFrom } from 'babylon.quarks';
5
6
  import { EDITOR_SCENE_NODE_METADATA } from './editorScene.js';
6
7
  import './lineShaders.js';
7
8
  import { getShapeType, readShapeParams } from './shapes.js';
@@ -103,17 +104,23 @@ function buildEmitterWireframe(system, scene) {
103
104
  lines('wf-cone-spoke-a', [Vector3.Zero(), arcPoint(radius, 0)]);
104
105
  lines('wf-cone-spoke-b', [Vector3.Zero(), arcPoint(radius, arc)]);
105
106
  }
106
- const preview = Math.max(radius * 2, 1.25);
107
- const farRadius = radius + preview * Math.tan(angle);
107
+ // Emit from Volume draws the volume itself, out to its length; the base a stretch of the
108
+ // directions.
109
+ const preview = params.coneEmitFrom === ConeEmitFrom.Volume
110
+ ? Math.max(Number.isFinite(params.length) ? params.length : 0, 0.001)
111
+ : Math.max(radius * 2, 1.25);
112
+ // The rays and the far rim end at the same point, which a near-flat cone keeps in view.
113
+ const travel = preview / Math.max(Math.cos(angle), 0.05);
114
+ const farRadius = radius + Math.sin(angle) * travel;
115
+ const farZ = Math.cos(angle) * travel;
108
116
  const spokeCount = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 12));
109
117
  for (let i = 0; i <= spokeCount; i++) {
110
118
  const theta = (i / spokeCount) * arc;
111
119
  const rim = arcPoint(radius, theta);
112
120
  const dir = new Vector3(Math.sin(angle) * Math.cos(theta), Math.sin(angle) * Math.sin(theta), Math.cos(angle));
113
- const travel = preview / Math.max(Math.cos(angle), 0.05);
114
121
  lines(`wf-cone-ray-${i}`, [rim, rim.add(dir.scale(travel))], DIRECTION_COLOR);
115
122
  }
116
- lines('wf-cone-far', xyArc(farRadius, arc, 32, preview), DIRECTION_COLOR);
123
+ lines('wf-cone-far', xyArc(farRadius, arc, 32, farZ), DIRECTION_COLOR);
117
124
  break;
118
125
  }
119
126
  case 'sphere': {
@@ -79,6 +79,8 @@ export function parseEffectFromJson(scene, renderer, json, options = {}, baseUrl
79
79
  const systems = [];
80
80
  QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
81
81
  const system = emitter.system;
82
+ // The editor previews over and over; the stop action is saved, not applied.
83
+ system.ignoreStopAction = true;
82
84
  if (registerRenderer && renderer) {
83
85
  renderer.addSystem(system);
84
86
  }
@@ -1,6 +1,6 @@
1
1
  import type { Mesh } from '@babylonjs/core/Meshes/mesh.js';
2
2
  import type { EmitterShape } from 'babylon.quarks';
3
- import { BoxEmitFrom, EmitterMode } from 'babylon.quarks';
3
+ import { BoxEmitFrom, ConeEmitFrom, EmitterMode, GridSampling } from 'babylon.quarks';
4
4
  /** The emitter shapes the Shape module offers, by the `type` they serialise with. */
5
5
  export type ShapeType = 'point' | 'sphere' | 'hemisphere' | 'cone' | 'circle' | 'donut' | 'rectangle' | 'grid' | 'box' | 'edge' | 'mesh_surface';
6
6
  /** The Shape module's shape list, in menu order, with the label each shows. */
@@ -41,6 +41,12 @@ export interface ShapeParams {
41
41
  depth: number;
42
42
  /** Where a box emits from: its volume, its faces or its edges. */
43
43
  emitFrom: BoxEmitFrom;
44
+ /** Where a cone emits from: its base or its volume. */
45
+ coneEmitFrom: ConeEmitFrom;
46
+ /** Height of a cone's volume (Unity's Length), for Emit from Volume. */
47
+ length: number;
48
+ /** How a grid picks its cells: at random or in turn. */
49
+ sampling: GridSampling;
44
50
  /** How fast an edge emitter's loop / ping-pong point travels, in lengths per second. */
45
51
  speed: number;
46
52
  /** The mesh a mesh surface emitter emits from. */
@@ -1,4 +1,4 @@
1
- import { BoxEmitFrom, BoxEmitter, CircleEmitter, ConeEmitter, ConstantValue, DonutEmitter, EdgeEmitter, EmitterMode, GridEmitter, HemisphereEmitter, MeshSurfaceEmitter, PointEmitter, RectangleEmitter, SphereEmitter, } from 'babylon.quarks';
1
+ import { BoxEmitFrom, BoxEmitter, CircleEmitter, ConeEmitFrom, ConeEmitter, ConstantValue, DonutEmitter, EdgeEmitter, EmitterMode, GridEmitter, GridSampling, HemisphereEmitter, MeshSurfaceEmitter, PointEmitter, RectangleEmitter, SphereEmitter, } from 'babylon.quarks';
2
2
  /** The Shape module's shape list, in menu order, with the label each shows. */
3
3
  export const SHAPE_TYPES = [
4
4
  { value: 'cone', label: 'Cone' },
@@ -28,6 +28,9 @@ export const DEFAULT_SHAPE_PARAMS = {
28
28
  row: 10,
29
29
  depth: 1,
30
30
  emitFrom: BoxEmitFrom.Volume,
31
+ coneEmitFrom: ConeEmitFrom.Base,
32
+ length: 5,
33
+ sampling: GridSampling.Random,
31
34
  speed: 1,
32
35
  mesh: undefined,
33
36
  };
@@ -53,7 +56,11 @@ export function readShapeParams(shape) {
53
56
  column: num('column', DEFAULT_SHAPE_PARAMS.column),
54
57
  row: num('row', DEFAULT_SHAPE_PARAMS.row),
55
58
  depth: num('depth', DEFAULT_SHAPE_PARAMS.depth),
56
- emitFrom: num('emitFrom', DEFAULT_SHAPE_PARAMS.emitFrom),
59
+ // A box and a cone both name it emitFrom, with different enums: each keeps its own.
60
+ emitFrom: shape instanceof BoxEmitter ? shape.emitFrom : DEFAULT_SHAPE_PARAMS.emitFrom,
61
+ coneEmitFrom: shape instanceof ConeEmitter ? shape.emitFrom : DEFAULT_SHAPE_PARAMS.coneEmitFrom,
62
+ length: num('length', DEFAULT_SHAPE_PARAMS.length),
63
+ sampling: num('sampling', DEFAULT_SHAPE_PARAMS.sampling),
57
64
  speed: anyShape.speed instanceof ConstantValue ? anyShape.speed.value : DEFAULT_SHAPE_PARAMS.speed,
58
65
  mesh: anyShape.mesh,
59
66
  };
@@ -64,7 +71,7 @@ export const SHAPE_PARAM_KEYS = {
64
71
  point: [],
65
72
  sphere: ['radius', 'arc', 'thickness'],
66
73
  hemisphere: ['radius', 'arc', 'thickness'],
67
- cone: ['radius', 'arc', 'thickness', 'angle'],
74
+ cone: ['radius', 'arc', 'thickness', 'angle', 'length'],
68
75
  circle: ['radius', 'arc', 'thickness'],
69
76
  donut: ['radius', 'arc', 'thickness', 'donutRadius'],
70
77
  rectangle: ['width', 'height'],
@@ -100,6 +107,8 @@ export function createShape(type, params) {
100
107
  arc: params.arc,
101
108
  thickness: params.thickness,
102
109
  angle: params.angle,
110
+ emitFrom: params.coneEmitFrom,
111
+ length: params.length,
103
112
  mode: params.mode,
104
113
  spread: params.spread,
105
114
  });
@@ -133,6 +142,7 @@ export function createShape(type, params) {
133
142
  height: params.height,
134
143
  column: params.column,
135
144
  row: params.row,
145
+ sampling: params.sampling,
136
146
  });
137
147
  case 'box':
138
148
  return new BoxEmitter({
@@ -26,6 +26,8 @@ export function createChildSystem(parent, options = {}) {
26
26
  worldSpace: true,
27
27
  });
28
28
  child.emitter.name = options.name ?? (oneShot ? 'sub-emitter' : 'child-system');
29
+ // The editor previews over and over; the stop action is saved, not applied.
30
+ child.ignoreStopAction = true;
29
31
  if (oneShot) {
30
32
  child.onlyUsedByOther = true;
31
33
  }
@@ -1,11 +1,14 @@
1
1
  import type { FunctionValueGenerator, RotationGenerator, ValueGenerator, Vector3Generator } from 'babylon.quarks';
2
- import { PiecewiseBezier } from 'babylon.quarks';
2
+ import { PiecewiseBezier, RandomBetweenCurves } from 'babylon.quarks';
3
3
  /** A generator of one number: a constant or random value, or a curve over the particle's life. */
4
4
  export type ScalarGenerator = ValueGenerator | FunctionValueGenerator;
5
5
  /** Any generator a value field can be pointed at; ones that are not scalar read as the default. */
6
6
  export type AnyGenerator = ScalarGenerator | Vector3Generator | RotationGenerator;
7
- /** How a value field produces its number: Unity's Constant, Random Between Two Constants or Curve. */
8
- export type ScalarMode = 'constant' | 'random' | 'curve';
7
+ /**
8
+ * How a value field produces its number: Unity's Constant, Random Between Two Constants, Curve or
9
+ * Random Between Two Curves.
10
+ */
11
+ export type ScalarMode = 'constant' | 'random' | 'curve' | 'curves';
9
12
  /** Editable snapshot of a scalar value generator. */
10
13
  export interface ScalarValueState {
11
14
  /** Which of the fields below is the value. */
@@ -16,8 +19,10 @@ export interface ScalarValueState {
16
19
  min: number;
17
20
  /** Upper end of the random range (mode: random). */
18
21
  max: number;
19
- /** Cubic bezier control values over normalized lifetime (mode: curve). */
22
+ /** Cubic bezier control values over normalized lifetime (mode: curve; the first curve of curves). */
20
23
  curve: [number, number, number, number];
24
+ /** The second curve's control values (mode: curves); the first curve's when left out. */
25
+ curve2?: [number, number, number, number];
21
26
  }
22
27
  /**
23
28
  * What a value field shows for `generator`: its constant, its range or its first curve segment.
@@ -36,10 +41,22 @@ export interface CurvePiece {
36
41
  /** The segment's four bezier control values. */
37
42
  p: [number, number, number, number];
38
43
  }
39
- /** Reads pieces from a PiecewiseBezier (or synthesizes one segment for other generators). */
40
- export declare function readPieces(generator: AnyGenerator | undefined): CurvePiece[];
44
+ /**
45
+ * Reads pieces from a PiecewiseBezier, or from the first curve of a RandomBetweenCurves (the
46
+ * second with `second`). A constant reads as a flat curve, a random range as a flat curve at its
47
+ * lower end (its upper with `second`), and other generators as one default segment.
48
+ */
49
+ export declare function readPieces(generator: AnyGenerator | undefined, second?: boolean): CurvePiece[];
41
50
  /** A piecewise bezier from segments, sorted by their start. */
42
51
  export declare function buildCurve(pieces: CurvePiece[]): PiecewiseBezier;
52
+ /**
53
+ * `generator` as one curve (`'curve'`) or two (`'curves'`, Unity's Random Between Two Curves),
54
+ * keeping every segment: one curve becomes both bounds of two, and two keep their first as one.
55
+ * A constant is a flat curve, and a random range flat curves at its ends.
56
+ */
57
+ export declare function withCurves(generator: AnyGenerator | undefined, mode: 'curve'): PiecewiseBezier;
58
+ export declare function withCurves(generator: AnyGenerator | undefined, mode: 'curves'): RandomBetweenCurves;
59
+ export declare function withCurves(generator: AnyGenerator | undefined, mode: 'curve' | 'curves'): PiecewiseBezier | RandomBetweenCurves;
43
60
  /** Splits the piece containing x into two exact halves using Bezier.split. */
44
61
  export declare function splitPieces(pieces: CurvePiece[], x: number): CurvePiece[];
45
62
  /** Removes the boundary at pieces[index].start by merging with the previous piece. */
@@ -1,10 +1,17 @@
1
- import { Bezier, ConstantValue, IntervalValue, PiecewiseBezier } from 'babylon.quarks';
1
+ import { Bezier, ConstantValue, IntervalValue, PiecewiseBezier, RandomBetweenCurves } from 'babylon.quarks';
2
2
  /**
3
3
  * What a value field shows for `generator`: its constant, its range or its first curve segment.
4
4
  * Anything else, including a missing generator, reads as the constant 1.
5
5
  */
6
6
  export function readScalar(generator) {
7
- const state = { mode: 'constant', value: 1, min: 0.5, max: 1.5, curve: [0, 1, 1, 0] };
7
+ const state = {
8
+ mode: 'constant',
9
+ value: 1,
10
+ min: 0.5,
11
+ max: 1.5,
12
+ curve: [0, 1, 1, 0],
13
+ curve2: [0, 1, 1, 0],
14
+ };
8
15
  if (generator instanceof ConstantValue) {
9
16
  state.mode = 'constant';
10
17
  state.value = generator.value;
@@ -19,14 +26,23 @@ export function readScalar(generator) {
19
26
  }
20
27
  else if (generator instanceof PiecewiseBezier) {
21
28
  state.mode = 'curve';
22
- const first = generator.functions[0]?.[0];
23
- if (first) {
24
- state.curve = [first.p[0], first.p[1], first.p[2], first.p[3]];
25
- state.value = first.p[0];
26
- }
29
+ state.curve = firstSegment(generator) ?? state.curve;
30
+ state.curve2 = state.curve;
31
+ state.value = state.curve[0];
32
+ }
33
+ else if (generator instanceof RandomBetweenCurves) {
34
+ state.mode = 'curves';
35
+ state.curve = firstSegment(generator.curve1) ?? state.curve;
36
+ state.curve2 = firstSegment(generator.curve2) ?? state.curve;
37
+ state.value = state.curve[0];
27
38
  }
28
39
  return state;
29
40
  }
41
+ /** The control values of a curve's first segment, if it has one. */
42
+ function firstSegment(curve) {
43
+ const first = curve.functions[0]?.[0];
44
+ return first ? [first.p[0], first.p[1], first.p[2], first.p[3]] : undefined;
45
+ }
30
46
  export function buildScalar(state) {
31
47
  switch (state.mode) {
32
48
  case 'constant':
@@ -35,10 +51,24 @@ export function buildScalar(state) {
35
51
  return new IntervalValue(Math.min(state.min, state.max), Math.max(state.min, state.max));
36
52
  case 'curve':
37
53
  return new PiecewiseBezier([[new Bezier(...state.curve), 0]]);
54
+ case 'curves':
55
+ return new RandomBetweenCurves(new PiecewiseBezier([[new Bezier(...state.curve), 0]]), new PiecewiseBezier([[new Bezier(...(state.curve2 ?? state.curve)), 0]]));
38
56
  }
39
57
  }
40
- /** Reads pieces from a PiecewiseBezier (or synthesizes one segment for other generators). */
41
- export function readPieces(generator) {
58
+ /**
59
+ * Reads pieces from a PiecewiseBezier, or from the first curve of a RandomBetweenCurves (the
60
+ * second with `second`). A constant reads as a flat curve, a random range as a flat curve at its
61
+ * lower end (its upper with `second`), and other generators as one default segment.
62
+ */
63
+ export function readPieces(generator, second = false) {
64
+ if (generator instanceof RandomBetweenCurves) {
65
+ return readPieces(second ? generator.curve2 : generator.curve1);
66
+ }
67
+ // A constant is a flat curve, and a random range two flat curves at its ends.
68
+ if (generator instanceof ConstantValue || generator instanceof IntervalValue) {
69
+ const value = generator instanceof ConstantValue ? generator.value : second ? generator.b : generator.a;
70
+ return [{ start: 0, p: [value, value, value, value] }];
71
+ }
42
72
  if (generator instanceof PiecewiseBezier && generator.functions.length > 0) {
43
73
  return generator.functions.map(([bezier, start]) => ({
44
74
  start,
@@ -53,6 +83,14 @@ export function buildCurve(pieces) {
53
83
  const sorted = [...pieces].sort((a, b) => a.start - b.start);
54
84
  return new PiecewiseBezier(sorted.map((piece) => [new Bezier(...piece.p), piece.start]));
55
85
  }
86
+ export function withCurves(generator, mode) {
87
+ const first = buildCurve(readPieces(generator));
88
+ if (mode === 'curve') {
89
+ return first;
90
+ }
91
+ const second = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
92
+ return new RandomBetweenCurves(first, buildCurve(readPieces(generator, second)));
93
+ }
56
94
  /** Splits the piece containing x into two exact halves using Bezier.split. */
57
95
  export function splitPieces(pieces, x) {
58
96
  const sorted = [...pieces].sort((a, b) => a.start - b.start);
@@ -0,0 +1,21 @@
1
+ import { PiecewiseBezier, RandomBetweenCurves, Vector3Function } from 'babylon.quarks';
2
+ import React from 'react';
3
+ import { AnyGenerator } from '../core/values.js';
4
+ /** One curve or two, for one axis or for all of them. */
5
+ type AxisCurve = PiecewiseBezier | RandomBetweenCurves;
6
+ /** Props of an {@link AxesCurveField}. */
7
+ export interface AxesCurveFieldProps {
8
+ /** The generator shown: one curve (or two) for every axis, or a `Vector3Function` per axis. */
9
+ generator: AnyGenerator | undefined;
10
+ /** Top of the curve editors' value range. */
11
+ maxValue?: number;
12
+ /** Receives the edited generator. */
13
+ onChange: (generator: AxisCurve | Vector3Function) => void;
14
+ }
15
+ /**
16
+ * A curve over life with Unity's Separate Axes switch: off, one curve (or two) for all axes; on, a
17
+ * {@link Vector3Function} with its own for X, Y and Z. Switching on gives every axis the shared
18
+ * curve; switching off keeps the X one.
19
+ */
20
+ export declare function AxesCurveField(props: AxesCurveFieldProps): React.ReactElement;
21
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { IntervalValue, RandomBetweenCurves, Vector3Function } from 'babylon.quarks';
3
+ import { withCurves } from '../core/values.js';
4
+ import { CheckboxField, Row } from './fields.js';
5
+ import { LifeCurveField } from './LifeCurveField.js';
6
+ const captionStyle = { fontSize: 10.5, opacity: 0.6, marginTop: 6 };
7
+ /**
8
+ * The curve or curves `generator` holds, as a fresh copy: a constant as a flat curve, a random
9
+ * range as two flat ones.
10
+ */
11
+ function curveOf(generator) {
12
+ const two = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
13
+ return withCurves(generator, two ? 'curves' : 'curve');
14
+ }
15
+ /**
16
+ * A curve over life with Unity's Separate Axes switch: off, one curve (or two) for all axes; on, a
17
+ * {@link Vector3Function} with its own for X, Y and Z. Switching on gives every axis the shared
18
+ * curve; switching off keeps the X one.
19
+ */
20
+ export function AxesCurveField(props) {
21
+ const { generator } = props;
22
+ const axes = generator instanceof Vector3Function ? generator : undefined;
23
+ const toggle = (on) => props.onChange(on ? new Vector3Function(curveOf(generator), curveOf(generator), curveOf(generator)) : curveOf(axes?.x));
24
+ return (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Separate axes", hint: "A curve for each of X, Y and Z, as Unity's Separate Axes.", children: _jsx(CheckboxField, { value: axes !== undefined, onChange: toggle }) }), axes === undefined ? (_jsx(LifeCurveField, { generator: generator, maxValue: props.maxValue, onChange: props.onChange })) : (['x', 'y', 'z'].map((axis) => (_jsxs("div", { children: [_jsx("div", { style: captionStyle, children: axis.toUpperCase() }), _jsx(LifeCurveField, { generator: axes[axis], maxValue: props.maxValue, onChange: (curve) => props.onChange(new Vector3Function(axis === 'x' ? curve : axes.x, axis === 'y' ? curve : axes.y, axis === 'z' ? curve : axes.z)) })] }, axis))))] }));
25
+ }
@@ -0,0 +1,18 @@
1
+ import { PiecewiseBezier, RandomBetweenCurves } from 'babylon.quarks';
2
+ import React from 'react';
3
+ import { AnyGenerator } from '../core/values.js';
4
+ /** Props of a {@link LifeCurveField}. */
5
+ export interface LifeCurveFieldProps {
6
+ /** The generator shown: one curve, two curves, or anything else, read as one curve. */
7
+ generator: AnyGenerator | undefined;
8
+ /** Top of the curve editors' value range. */
9
+ maxValue?: number;
10
+ /** Receives the edited curve or curves. */
11
+ onChange: (generator: PiecewiseBezier | RandomBetweenCurves) => void;
12
+ }
13
+ /**
14
+ * A setting that is a curve over the particle's life, or two with a "Two curves" switch (Unity's
15
+ * Random Between Two Curves): each particle then follows its own curve between the lower and the
16
+ * upper one. Every segment of both curves is kept through edits and switches.
17
+ */
18
+ export declare function LifeCurveField(props: LifeCurveFieldProps): React.ReactElement;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { IntervalValue, RandomBetweenCurves } from 'babylon.quarks';
3
+ import { buildCurve, readPieces, withCurves } from '../core/values.js';
4
+ import { CurveEditor } from './CurveEditor.js';
5
+ import { CheckboxField, Row } from './fields.js';
6
+ const captionStyle = { fontSize: 10.5, opacity: 0.6, flex: 'none' };
7
+ /**
8
+ * A setting that is a curve over the particle's life, or two with a "Two curves" switch (Unity's
9
+ * Random Between Two Curves): each particle then follows its own curve between the lower and the
10
+ * upper one. Every segment of both curves is kept through edits and switches.
11
+ */
12
+ export function LifeCurveField(props) {
13
+ const { generator } = props;
14
+ // A random range shows as two flat curves, and an edit turns it into them.
15
+ const two = generator instanceof RandomBetweenCurves || generator instanceof IntervalValue;
16
+ const edit = (pieces, upper) => {
17
+ if (!two) {
18
+ props.onChange(buildCurve(pieces));
19
+ return;
20
+ }
21
+ const lower = upper ? buildCurve(readPieces(generator)) : buildCurve(pieces);
22
+ const higher = upper ? buildCurve(pieces) : buildCurve(readPieces(generator, true));
23
+ props.onChange(new RandomBetweenCurves(lower, higher));
24
+ };
25
+ return (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Two curves", hint: "Random between two curves: each particle follows its own curve between them.", children: _jsx(CheckboxField, { value: two, onChange: (on) => props.onChange(withCurves(generator, on ? 'curves' : 'curve')) }) }), (two ? ['lower', 'upper'] : ['curve']).map((bound) => (_jsxs("div", { style: { marginTop: 6 }, children: [two && _jsx("span", { style: captionStyle, children: bound }), _jsx(CurveEditor, { pieces: readPieces(generator, bound === 'upper'), maxValue: props.maxValue, onChange: (pieces) => edit(pieces, bound === 'upper') })] }, bound)))] }));
26
+ }
@@ -31,8 +31,9 @@ export interface ConstantValueFieldProps extends ValueFieldBaseProps {
31
31
  onChange: (generator: ValueGenerator) => void;
32
32
  }
33
33
  /**
34
- * Unity-like value field: Constant | Random Between Two Constants | Curve, bound to a quarks value
35
- * generator. With `curve={false}` it offers only the first two, for settings sampled once.
34
+ * Unity-like value field: Constant | Random Between Two Constants | Curve | Random Between Two
35
+ * Curves, bound to a quarks value generator. With `curve={false}` it offers only the first two,
36
+ * for settings sampled once.
36
37
  */
37
38
  export declare function ValueField(props: CurveValueFieldProps | ConstantValueFieldProps): React.ReactElement;
38
39
  export {};
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { buildCurve, buildScalar, readPieces, readScalar, } from '../core/values.js';
2
+ import { RandomBetweenCurves } from 'babylon.quarks';
3
+ import { buildCurve, buildScalar, readPieces, readScalar, withCurves, } from '../core/values.js';
3
4
  import { CurveEditor } from './CurveEditor.js';
4
5
  import { NumberField, Row, SelectField } from './fields.js';
5
6
  const captionStyle = { fontSize: 10.5, opacity: 0.6, flex: 'none' };
@@ -7,13 +8,15 @@ function isValueGenerator(generator) {
7
8
  return generator.type === 'value';
8
9
  }
9
10
  /**
10
- * Unity-like value field: Constant | Random Between Two Constants | Curve, bound to a quarks value
11
- * generator. With `curve={false}` it offers only the first two, for settings sampled once.
11
+ * Unity-like value field: Constant | Random Between Two Constants | Curve | Random Between Two
12
+ * Curves, bound to a quarks value generator. With `curve={false}` it offers only the first two,
13
+ * for settings sampled once.
12
14
  */
13
15
  export function ValueField(props) {
14
16
  const read = readScalar(props.generator);
15
17
  // A setting without curves shows a curve it was loaded with as a constant, so an edit replaces it.
16
- const state = props.curve === false && read.mode === 'curve' ? { ...read, mode: 'constant' } : read;
18
+ const curved = read.mode === 'curve' || read.mode === 'curves';
19
+ const state = props.curve === false && curved ? { ...read, mode: 'constant' } : read;
17
20
  const emit = (generator) => {
18
21
  if (props.curve !== false) {
19
22
  props.onChange(generator);
@@ -26,6 +29,17 @@ export function ValueField(props) {
26
29
  return (_jsxs(_Fragment, { children: [_jsx(Row, { label: props.label, hint: props.hint, hintKey: props.hintKey, children: _jsx(SelectField, { value: state.mode, options: [
27
30
  { value: 'constant', label: 'Constant' },
28
31
  { value: 'random', label: 'Random between two' },
29
- ...(props.curve !== false ? [{ value: 'curve', label: 'Curve' }] : []),
30
- ], 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) => emit(buildCurve(pieces)) }) }))] }));
32
+ ...(props.curve !== false
33
+ ? [
34
+ { value: 'curve', label: 'Curve' },
35
+ { value: 'curves', label: 'Random between two curves' },
36
+ ]
37
+ : []),
38
+ ], onChange: (mode) =>
39
+ // Between one curve and two, every segment is kept; other switches start afresh.
40
+ (mode === 'curve' || mode === 'curves') && curved
41
+ ? emit(withCurves(props.generator, mode))
42
+ : 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) => emit(buildCurve(pieces)) }) })), state.mode === 'curves' && (_jsx(_Fragment, { children: ['lower', 'upper'].map((bound, index) => (_jsxs("div", { style: { marginTop: 6 }, children: [_jsx("span", { style: captionStyle, children: bound }), _jsx(CurveEditor, { pieces: readPieces(props.generator, index === 1), maxValue: props.curveMax, onChange: (pieces) => emit(index === 0
43
+ ? new RandomBetweenCurves(buildCurve(pieces), buildCurve(readPieces(props.generator, true)))
44
+ : new RandomBetweenCurves(buildCurve(readPieces(props.generator)), buildCurve(pieces))) })] }, bound))) }))] }));
31
45
  }
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid';
3
- import { ApplyCollision, ApplyForce, ApplySequences, AxisAngleGenerator, Bezier, ChangeEmitDirection, ColorBySpeed, ConstantValue, EulerGenerator, ForceOverLife, FrameOverLife, Gradient, GravityForce, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, PiecewiseBezier, RandomQuatGenerator, Rotation3DOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TextureSequencer, TurbulenceField, Vector3, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
3
+ import { ApplyCollision, ApplyForce, ApplySequences, AxisAngleGenerator, Bezier, ChangeEmitDirection, ColorBySpeed, ConstantValue, EulerGenerator, ForceOverLife, FrameOverLife, Gradient, GravityForce, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, PiecewiseBezier, RandomQuatGenerator, Rotation3DOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TextureSequencer, TurbulenceField, Vector3, Vector3Function, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
4
4
  import React from 'react';
5
5
  import { buildGradient, DEFAULT_GRADIENT_STOPS, findBehavior, readGradientStops } from '../core/colors.js';
6
- import { buildCurve, buildScalar, readPieces, readScalar } from '../core/values.js';
7
- import { CurveEditor } from './CurveEditor.js';
6
+ import { buildScalar, readScalar } from '../core/values.js';
7
+ import { AxesCurveField } from './AxesCurveField.js';
8
8
  import { CheckboxField, NumberField, Row, SelectField } from './fields.js';
9
9
  import { GradientEditor } from './GradientEditor.js';
10
10
  import { iconStyle } from './icons.js';
11
+ import { LifeCurveField } from './LifeCurveField.js';
11
12
  import { ModuleSection } from './ModuleSection.js';
12
13
  import { theme } from './theme.js';
13
14
  import { ValueField } from './ValueField.js';
@@ -30,11 +31,14 @@ function BehaviorModule(props) {
30
31
  }
31
32
  /** The Speed over Lifetime module (Unity's Speed Modifier): scales speed by a curve over the particle's life. */
32
33
  export function SpeedOverLifeModule({ binding }) {
33
- return (_jsx(BehaviorModule, { binding: binding, title: "Speed over Lifetime", type: "SpeedOverLife", create: () => new SpeedOverLife(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.5, 0] })), children: (behavior) => (_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.speed), maxValue: 2, onChange: (pieces) => binding.apply(() => (behavior.speed = buildCurve(pieces))) }) })) }));
34
+ return (_jsx(BehaviorModule, { binding: binding, title: "Speed over Lifetime", type: "SpeedOverLife", create: () => new SpeedOverLife(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.5, 0] })), children: (behavior) => (_jsx("div", { style: { marginTop: 6 }, children: _jsx(LifeCurveField, { generator: behavior.speed, maxValue: 2, onChange: (curve) => binding.apply(() => (behavior.speed = curve)) }) })) }));
34
35
  }
35
36
  /** The Limit Speed over Lifetime module (Unity's Limit Velocity over Lifetime): caps speed by a curve, with dampening. */
36
37
  export function LimitSpeedOverLifeModule({ binding }) {
37
- return (_jsx(BehaviorModule, { binding: binding, title: "Limit Speed over Lifetime", type: "LimitSpeedOverLife", create: () => new LimitSpeedOverLife(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [4, 2, 1, 1] }), 0.5), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.speed), maxValue: 8, onChange: (pieces) => binding.apply(() => (behavior.speed = buildCurve(pieces))) }) }), _jsx(Row, { label: "Dampen", children: _jsx(NumberField, { value: behavior.dampen, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.dampen = Math.min(1, v))) }) })] })) }));
38
+ return (_jsx(BehaviorModule, { binding: binding, title: "Limit Speed over Lifetime", type: "LimitSpeedOverLife", create: () => new LimitSpeedOverLife(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [4, 2, 1, 1] }), 0.5), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(AxesCurveField, { generator: behavior.speed, maxValue: 8, onChange: (speed) => binding.apply(() => (behavior.speed = speed)) }) }), behavior.speed instanceof Vector3Function && (_jsx(Row, { label: "Space", hint: "The axes each limit is measured along: the emitter's or the world's.", children: _jsx(SelectField, { value: behavior.space, options: [
39
+ { value: 'local', label: 'Local' },
40
+ { value: 'world', label: 'World' },
41
+ ], onChange: (v) => binding.apply(() => (behavior.space = v)) }) })), _jsx(Row, { label: "Dampen", children: _jsx(NumberField, { value: behavior.dampen, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.dampen = Math.min(1, v))) }) })] })) }));
38
42
  }
39
43
  /** The Force over Lifetime module (Unity's Force over Lifetime): a constant push per axis. */
40
44
  export function ForceOverLifeModule({ binding }) {
@@ -190,7 +194,7 @@ export function ColorBySpeedModule({ binding }) {
190
194
  }
191
195
  /** The Size by Speed module (Unity's Size by Speed): size over a speed range. */
192
196
  export function SizeBySpeedModule({ binding }) {
193
- return (_jsx(BehaviorModule, { binding: binding, title: "Size by Speed", type: "SizeBySpeed", create: () => new SizeBySpeed(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [0.5, 0.8, 1.2, 1.5] }), new IntervalValue(0, 5)), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.size), maxValue: 2, onChange: (pieces) => binding.apply(() => (behavior.size = buildCurve(pieces))) }) }), _jsx(SpeedRangeRows, { binding: binding, behavior: behavior })] })) }));
197
+ return (_jsx(BehaviorModule, { binding: binding, title: "Size by Speed", type: "SizeBySpeed", create: () => new SizeBySpeed(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [0.5, 0.8, 1.2, 1.5] }), new IntervalValue(0, 5)), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(AxesCurveField, { generator: behavior.size, maxValue: 2, onChange: (size) => binding.apply(() => (behavior.size = size)) }) }), _jsx(SpeedRangeRows, { binding: binding, behavior: behavior })] })) }));
194
198
  }
195
199
  /** The Rotation by Speed module (Unity's Rotation by Speed): spin rate over a speed range. */
196
200
  export function RotationBySpeedModule({ binding }) {
@@ -202,7 +206,7 @@ export function OrbitOverLifeModule({ binding }) {
202
206
  }
203
207
  /** The Width over Trail module (Unity's Trails width over trail): a trail's width along its length. */
204
208
  export function WidthOverTrailModule({ binding }) {
205
- return (_jsx(BehaviorModule, { binding: binding, title: "Width over Trail", type: "WidthOverLength", create: () => new WidthOverLength(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.4, 0] })), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(behavior.width), maxValue: 2, onChange: (pieces) => binding.apply(() => (behavior.width = buildCurve(pieces))) }) }), _jsx(Row, { label: "Absolute width", children: _jsx(CheckboxField, { value: behavior.absolute, onChange: (v) => binding.apply(() => (behavior.absolute = v)) }) })] })) }));
209
+ return (_jsx(BehaviorModule, { binding: binding, title: "Width over Trail", type: "WidthOverLength", create: () => new WidthOverLength(buildScalar({ mode: 'curve', value: 1, min: 0, max: 1, curve: [1, 0.75, 0.4, 0] })), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx("div", { style: { marginTop: 6 }, children: _jsx(LifeCurveField, { generator: behavior.width, maxValue: 2, onChange: (curve) => binding.apply(() => (behavior.width = curve)) }) }), _jsx(Row, { label: "Absolute width", children: _jsx(CheckboxField, { value: behavior.absolute, onChange: (v) => binding.apply(() => (behavior.absolute = v)) }) })] })) }));
206
210
  }
207
211
  /** How many of the first frames a start-frame generator picks from: its exclusive upper bound. */
208
212
  function startFrameCount(startFrame) {
@@ -276,5 +280,5 @@ export function TextureSheetModule({ binding }) {
276
280
  if (v && frameBehavior.frameCount <= 0) {
277
281
  frameBehavior.frameCount = tiles;
278
282
  }
279
- }) }), frameBehavior.startFrame && (_jsx(NumberField, { value: startFrameCount(frameBehavior.startFrame), min: 1, max: tiles, step: 1, onChange: (v) => binding.apply(() => (frameBehavior.startFrame = new IntervalValue(0, Math.max(1, Math.round(v))))) }))] }), _jsx("div", { style: { marginTop: 6 }, children: _jsx(CurveEditor, { pieces: readPieces(frameBehavior.frame), maxValue: Math.max(1, tiles - 1), onChange: (pieces) => binding.apply(() => (frameBehavior.frame = buildCurve(pieces))) }) })] }))] }));
283
+ }) }), frameBehavior.startFrame && (_jsx(NumberField, { value: startFrameCount(frameBehavior.startFrame), min: 1, max: tiles, step: 1, onChange: (v) => binding.apply(() => (frameBehavior.startFrame = new IntervalValue(0, Math.max(1, Math.round(v))))) }))] }), _jsx("div", { style: { marginTop: 6 }, children: _jsx(LifeCurveField, { generator: frameBehavior.frame, maxValue: Math.max(1, tiles - 1), onChange: (curve) => binding.apply(() => (frameBehavior.frame = curve)) }) })] }))] }));
280
284
  }
@@ -31,7 +31,7 @@ const INSPECTOR_HINTS = {
31
31
  Duration: 'How long the particle system runs before stopping (non-looping) or one loop cycle.',
32
32
  Looping: 'When enabled, the effect restarts from the beginning after duration ends.',
33
33
  'World space': 'Simulates in world coordinates so moving the emitter does not drag existing particles.',
34
- 'Scaling mode': 'What the emitter’s scale does to a world-space system: only the shape and sizes, or speeds and forces too (Unity’s Scaling Mode).',
34
+ 'Scaling mode': 'Which scale reaches the effect (Unity’s Scaling Mode): Local, the emitter’s own; Hierarchy, its parents’ too; Shape, the whole scale on the emission shape alone.',
35
35
  Prewarm: 'Simulates the effect once at start so it already looks mid-playback when play begins.',
36
36
  'Warm-up time': 'Seconds of the cycle simulated before the first frame of a looping system (Niagara’s Warmup Time); 0 warms up a whole cycle, as Unity does.',
37
37
  'Start delay': 'Wait time before emission starts after play or restart.',