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.
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Texture } from '@babylonjs/core/Materials/Textures/texture';
3
3
  import { Mesh } from '@babylonjs/core/Meshes/mesh';
4
4
  import { PlusIcon, XMarkIcon } from '@heroicons/react/24/solid';
5
- import { ColorOverLife, ConstantColor, ConstantValue, EulerGenerator, Gradient, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector3Function, Vector4, } from 'babylon.quarks';
5
+ import { AxisAngleGenerator, BoxEmitFrom, ColorOverLife, ColorRange, ConstantColor, ConstantValue, EulerGenerator, Gradient, IntervalValue, RandomColor, RandomColorBetweenGradient, RandomQuatGenerator, RenderMode, SizeOverLife, Vector3, Vector3Function, Vector4, } from 'babylon.quarks';
6
6
  import React, { useMemo, useState } from 'react';
7
7
  import { DEFAULT_GRADIENT_STOPS, buildGradient, findBehavior, hexToRgb, readGradientStops, rgbToHex, } from '../core/colors';
8
8
  import { applyRendererMaterial, getMaterialLabel } from '../core/material';
@@ -58,6 +58,8 @@ const PARAM_LABELS = {
58
58
  height: 'Height',
59
59
  column: 'Columns',
60
60
  row: 'Rows',
61
+ depth: 'Depth',
62
+ speed: 'Speed (lengths/s)',
61
63
  };
62
64
  function ColorInput(props) {
63
65
  const { value, onChange } = props;
@@ -76,23 +78,30 @@ function ColorInput(props) {
76
78
  export function MainModule({ binding }) {
77
79
  const system = binding.system;
78
80
  const startColor = system.startColor;
79
- const colorMode = startColor instanceof RandomColor ? 'random' : 'constant';
80
- const colorA = startColor instanceof RandomColor
81
+ const colorMode = startColor instanceof RandomColor ? 'random' : startColor instanceof ColorRange ? 'range' : 'constant';
82
+ const twoColors = startColor instanceof RandomColor || startColor instanceof ColorRange;
83
+ const colorA = twoColors
81
84
  ? startColor.a
82
85
  : startColor instanceof ConstantColor
83
86
  ? startColor.color
84
87
  : new Vector4(1, 1, 1, 1);
85
- const colorB = startColor instanceof RandomColor ? startColor.b : new Vector4(1, 0.5, 0.2, 1);
88
+ const colorB = twoColors ? startColor.b : new Vector4(1, 0.5, 0.2, 1);
89
+ /** The two-colour generator of the current mode. */
90
+ const makeTwoColors = (a, b) => colorMode === 'range' ? new ColorRange(a, b) : new RandomColor(a, b);
86
91
  const rotation = system.startRotation;
87
92
  const isEuler = rotation instanceof EulerGenerator;
93
+ const isAxisAngle = rotation instanceof AxisAngleGenerator;
88
94
  const rotationMode = rotation instanceof RandomQuatGenerator
89
95
  ? '3d'
90
96
  : isEuler
91
97
  ? 'euler'
92
- : readScalar(rotation).mode === 'random'
93
- ? 'random'
94
- : 'angle';
95
- const rotationState = readScalar(rotation instanceof RandomQuatGenerator || isEuler ? undefined : rotation);
98
+ : isAxisAngle
99
+ ? 'axisAngle'
100
+ : readScalar(rotation).mode === 'random'
101
+ ? 'random'
102
+ : 'angle';
103
+ const rotationState = readScalar(rotation instanceof RandomQuatGenerator || isEuler || isAxisAngle ? undefined : rotation);
104
+ const axisAngle = isAxisAngle ? rotation : undefined;
96
105
  const eulerAngles = {
97
106
  x: isEuler ? readScalar(rotation.angleX).value : 0,
98
107
  y: isEuler ? readScalar(rotation.angleY).value : 0,
@@ -109,7 +118,11 @@ export function MainModule({ binding }) {
109
118
  : startColor instanceof Gradient
110
119
  ? 'gradient'
111
120
  : colorMode;
112
- return (_jsxs(ModuleSection, { title: "Main", children: [_jsx(Row, { label: "Duration", children: _jsx(NumberField, { value: system.duration, min: 0.05, onChange: (v) => binding.apply((s) => (s.duration = v)) }) }), _jsx(Row, { label: "Looping", children: _jsx(CheckboxField, { value: system.looping, onChange: (v) => binding.apply((s) => (s.looping = v)) }) }), _jsx(Row, { label: "World space", children: _jsx(CheckboxField, { value: system.worldSpace, onChange: (v) => binding.apply((s) => (s.worldSpace = v)) }) }), _jsx(Row, { label: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = v)) }) }), _jsx(ValueField, { label: "Start delay", generator: system.startDelay, min: 0, curveMax: 5, onChange: (g) => binding.apply((s) => {
121
+ return (_jsxs(ModuleSection, { title: "Main", children: [_jsx(Row, { label: "Duration", children: _jsx(NumberField, { value: system.duration, min: 0.05, onChange: (v) => binding.apply((s) => (s.duration = v)) }) }), _jsx(Row, { label: "Looping", children: _jsx(CheckboxField, { value: system.looping, onChange: (v) => binding.apply((s) => (s.looping = v)) }) }), _jsx(Row, { label: "World space", children: _jsx(CheckboxField, { value: system.worldSpace, onChange: (v) => binding.apply((s) => (s.worldSpace = v)) }) }), _jsx(Row, { label: "Prewarm", children: _jsx(CheckboxField, { value: system.prewarm, onChange: (v) => binding.apply((s) => (s.prewarm = v)) }) }), _jsx(Row, { label: "Playback speed", children: _jsx(NumberField, { value: system.playbackSpeed, step: 0.1, min: 0, onChange: (v) => binding.apply((s) => (s.playbackSpeed = Math.max(0, v))) }) }), _jsxs(Row, { label: "Fixed seed", children: [_jsx(CheckboxField, { value: system.seed !== undefined, onChange: (v) => binding.apply((s) => (s.seed = v ? (s.seed ?? 1) : undefined)) }), system.seed !== undefined && (_jsx(NumberField, { value: system.seed, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.seed = Math.max(0, Math.floor(v)))) }))] }), _jsx(Row, { label: "Off screen", children: _jsx(SelectField, { value: system.cullingMode, options: [
122
+ { value: 'always', label: 'Always simulate' },
123
+ { value: 'pause', label: 'Pause' },
124
+ { value: 'pauseAndCatchUp', label: 'Pause and catch up' },
125
+ ], onChange: (mode) => binding.apply((s) => (s.cullingMode = mode)) }) }), _jsx(ValueField, { label: "Start delay", generator: system.startDelay, min: 0, curveMax: 5, onChange: (g) => binding.apply((s) => {
113
126
  s.startDelay = g;
114
127
  s.restart();
115
128
  }) }), _jsx(ValueField, { label: "Start lifetime", generator: system.startLife, min: 0.01, curveMax: 10, onChange: (g) => binding.apply((s) => (s.startLife = g)) }), _jsx(ValueField, { label: "Start speed", generator: system.startSpeed, min: 0, curveMax: 20, onChange: (g) => binding.apply((s) => (s.startSpeed = g)) }), _jsx(Row, { label: "Start size", children: _jsx(SelectField, { value: size3D ? '3d' : 'uniform', options: [
@@ -131,12 +144,16 @@ export function MainModule({ binding }) {
131
144
  }) }, axis))) })), _jsx(Row, { label: "Start color", children: _jsx(SelectField, { value: colorGenMode, options: [
132
145
  { value: 'constant', label: 'Constant' },
133
146
  { value: 'random', label: 'Random between two' },
147
+ { value: 'range', label: 'Blend between two' },
134
148
  { value: 'gradient', label: 'Gradient' },
135
149
  { value: 'gradient2', label: 'Random between gradients' },
136
150
  ], onChange: (mode) => binding.apply((s) => {
137
151
  if (mode === 'random') {
138
152
  s.startColor = new RandomColor(colorA, colorB);
139
153
  }
154
+ else if (mode === 'range') {
155
+ s.startColor = new ColorRange(colorA, colorB);
156
+ }
140
157
  else if (mode === 'gradient') {
141
158
  s.startColor = buildGradient(DEFAULT_GRADIENT_STOPS);
142
159
  }
@@ -146,10 +163,9 @@ export function MainModule({ binding }) {
146
163
  else {
147
164
  s.startColor = new ConstantColor(colorA);
148
165
  }
149
- }) }) }), (colorGenMode === 'constant' || colorGenMode === 'random') && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorA, onChange: (next) => binding.apply((s) => {
150
- s.startColor =
151
- colorGenMode === 'random' ? new RandomColor(next, colorB) : new ConstantColor(next);
152
- }) }) })), colorGenMode === 'random' && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorB, onChange: (next) => binding.apply((s) => (s.startColor = new RandomColor(colorA, next))) }) })), colorGenMode === 'gradient' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(GradientEditor, { stops: readGradientStops(startColor), onChange: (stops) => binding.apply((s) => (s.startColor = buildGradient(stops))) }) })), colorGenMode === 'gradient2' && (_jsxs("div", { style: { marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6 }, children: [_jsx(GradientEditor, { stops: readGradientStops(startColor.gradient1), onChange: (stops) => binding.apply((s) => {
166
+ }) }) }), (colorGenMode === 'constant' || twoColors) && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorA, onChange: (next) => binding.apply((s) => {
167
+ s.startColor = twoColors ? makeTwoColors(next, colorB) : new ConstantColor(next);
168
+ }) }) })), twoColors && (_jsx(Row, { label: "", children: _jsx(ColorInput, { value: colorB, onChange: (next) => binding.apply((s) => (s.startColor = makeTwoColors(colorA, next))) }) })), colorGenMode === 'gradient' && (_jsx("div", { style: { marginTop: 6 }, children: _jsx(GradientEditor, { stops: readGradientStops(startColor), onChange: (stops) => binding.apply((s) => (s.startColor = buildGradient(stops))) }) })), colorGenMode === 'gradient2' && (_jsxs("div", { style: { marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6 }, children: [_jsx(GradientEditor, { stops: readGradientStops(startColor.gradient1), onChange: (stops) => binding.apply((s) => {
153
169
  const g = s.startColor;
154
170
  s.startColor = new RandomColorBetweenGradient(buildGradient(stops), g.gradient2);
155
171
  }) }), _jsx(GradientEditor, { stops: readGradientStops(startColor.gradient2), onChange: (stops) => binding.apply((s) => {
@@ -159,17 +175,24 @@ export function MainModule({ binding }) {
159
175
  { value: 'angle', label: 'Angle (rad)' },
160
176
  { value: 'random', label: 'Random angle' },
161
177
  { value: 'euler', label: '3D (per axis)' },
178
+ { value: 'axisAngle', label: '3D (axis + angle)' },
162
179
  { value: '3d', label: 'Random 3D' },
163
180
  ], onChange: (mode) => binding.apply((s) => {
164
181
  s.startRotation =
165
182
  mode === '3d'
166
183
  ? new RandomQuatGenerator()
167
- : mode === 'euler'
168
- ? new EulerGenerator(new ConstantValue(eulerAngles.x), new ConstantValue(eulerAngles.y), new ConstantValue(eulerAngles.z))
169
- : mode === 'random'
170
- ? new IntervalValue(0, Math.PI * 2)
171
- : new ConstantValue(rotationState.value);
172
- }) }) }), rotationMode === 'euler' && (_jsx(_Fragment, { children: ['x', 'y', 'z'].map((axis) => (_jsx(Row, { label: ` ${axis.toUpperCase()} (rad)`, children: _jsx(NumberField, { value: eulerAngles[axis], step: 0.1, onChange: (v) => setEulerAxis(axis, v) }) }, axis))) })), rotationMode === 'angle' && (_jsx(Row, { label: "", children: _jsx(NumberField, { value: rotationState.value, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new ConstantValue(v))) }) })), rotationMode === 'random' && (_jsx(Row, { label: "", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: rotationState.min, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(v, rotationState.max))) }), _jsx(NumberField, { value: rotationState.max, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(rotationState.min, v))) })] }) }))] }));
184
+ : mode === 'axisAngle'
185
+ ? new AxisAngleGenerator(new Vector3(0, 1, 0), new ConstantValue(rotationState.value))
186
+ : mode === 'euler'
187
+ ? new EulerGenerator(new ConstantValue(eulerAngles.x), new ConstantValue(eulerAngles.y), new ConstantValue(eulerAngles.z))
188
+ : mode === 'random'
189
+ ? new IntervalValue(0, Math.PI * 2)
190
+ : new ConstantValue(rotationState.value);
191
+ }) }) }), rotationMode === 'euler' && (_jsx(_Fragment, { children: ['x', 'y', 'z'].map((axis) => (_jsx(Row, { label: ` ${axis.toUpperCase()} (rad)`, children: _jsx(NumberField, { value: eulerAngles[axis], step: 0.1, onChange: (v) => setEulerAxis(axis, v) }) }, axis))) })), axisAngle && (_jsxs(_Fragment, { children: [_jsx(Row, { label: " Axis", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y', 'z'].map((axis) => (_jsx(NumberField, { value: axisAngle.axis[axis], step: 0.1, onChange: (v) => binding.apply((s) => {
192
+ const next = axisAngle.axis.clone();
193
+ next[axis] = v;
194
+ s.startRotation = new AxisAngleGenerator(next, axisAngle.angle);
195
+ }) }, axis))) }) }), _jsx(ValueField, { label: " Angle (rad)", generator: axisAngle.angle, curveMax: Math.PI * 2, onChange: (g) => binding.apply((s) => (s.startRotation = new AxisAngleGenerator(axisAngle.axis.clone(), g))) })] })), rotationMode === 'angle' && (_jsx(Row, { label: "", children: _jsx(NumberField, { value: rotationState.value, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new ConstantValue(v))) }) })), rotationMode === 'random' && (_jsx(Row, { label: "", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: rotationState.min, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(v, rotationState.max))) }), _jsx(NumberField, { value: rotationState.max, step: 0.1, onChange: (v) => binding.apply((s) => (s.startRotation = new IntervalValue(rotationState.min, v))) })] }) }))] }));
173
196
  }
174
197
  export function EmissionModule({ binding }) {
175
198
  const system = binding.system;
@@ -204,7 +227,7 @@ export function EmissionModule({ binding }) {
204
227
  // Shapes whose constructor doesn't accept `mode`/`spread` at all — showing those controls
205
228
  // for them would edit a value the shape silently ignores, just like the old "default to
206
229
  // first texture" bug: a control that looks live but does nothing.
207
- const SHAPES_WITHOUT_MODE_SPREAD = new Set(['point', 'grid', 'mesh_surface']);
230
+ const SHAPES_WITHOUT_MODE_SPREAD = new Set(['point', 'grid', 'box', 'mesh_surface']);
208
231
  export function ShapeModule({ binding }) {
209
232
  const system = binding.system;
210
233
  const type = getShapeType(system.emitterShape);
@@ -219,12 +242,16 @@ export function ShapeModule({ binding }) {
219
242
  ], onChange: (id) => {
220
243
  const mesh = id === '__none' ? undefined : meshOptions.find((m) => String(m.uniqueId) === id);
221
244
  binding.apply((s) => (s.emitterShape = createShape('mesh_surface', { ...params, mesh })));
222
- } }) })), !SHAPES_WITHOUT_MODE_SPREAD.has(type) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Emit mode", children: _jsx(SelectField, { value: params.mode, options: [
245
+ } }) })), type === 'box' && (_jsx(Row, { label: "Emit from", children: _jsx(SelectField, { value: params.emitFrom, options: [
246
+ { value: BoxEmitFrom.Volume, label: 'Volume' },
247
+ { value: BoxEmitFrom.Shell, label: 'Shell (faces)' },
248
+ { value: BoxEmitFrom.Edge, label: 'Edges' },
249
+ ], onChange: (emitFrom) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, emitFrom }))) }) })), !SHAPES_WITHOUT_MODE_SPREAD.has(type) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Emit mode", children: _jsx(SelectField, { value: params.mode, options: [
223
250
  { value: 0, label: 'Random' },
224
251
  { value: 1, label: 'Loop' },
225
252
  { value: 2, label: 'Ping-pong' },
226
253
  { value: 3, label: 'Burst spread' },
227
- ], onChange: (mode) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, mode }))) }) }), _jsx(Row, { label: "Spread", children: _jsx(NumberField, { value: params.spread, min: 0, step: 0.05, onChange: (spread) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, spread }))) }) })] }))] }));
254
+ ], onChange: (mode) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, mode }))) }) }), _jsx(Row, { label: "Spread", children: _jsx(NumberField, { value: params.spread, min: 0, step: 0.05, onChange: (spread) => binding.apply((s) => (s.emitterShape = createShape(type, { ...params, spread }))) }) })] })), _jsx(Row, { label: "Align to direction", children: _jsx(CheckboxField, { value: binding.system.alignToDirection, onChange: (v) => binding.apply((s) => (s.alignToDirection = v)) }) })] }));
228
255
  }
229
256
  export function SizeOverLifeModule({ binding }) {
230
257
  const system = binding.system;
@@ -351,6 +378,15 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
351
378
  new ConstantValue(Math.round(v));
352
379
  }) }) }), _jsx(Row, { label: "Follow origin", children: _jsx(CheckboxField, { value: !!system.rendererEmitterSettings.followLocalOrigin, onChange: (v) => binding.apply((s) => {
353
380
  s.rendererEmitterSettings.followLocalOrigin = v;
381
+ }) }) }), [
382
+ ['recordWorldSpace', 'Record in world space'],
383
+ ['uvFromHead', 'UV from head'],
384
+ ['colorFromParticle', 'Color from particle'],
385
+ ['dieWithParticle', 'Die with particle'],
386
+ ].map(([key, label]) => (_jsx(Row, { label: label, children: _jsx(CheckboxField, { value: !!system.rendererEmitterSettings[key], onChange: (v) => binding.apply((s) => {
387
+ s.rendererEmitterSettings[key] = v;
388
+ }) }) }, key))), _jsx(Row, { label: "Head width scale", children: _jsx(NumberField, { value: system.rendererEmitterSettings.headWidthScale ?? 1, min: 0, step: 0.1, onChange: (v) => binding.apply((s) => {
389
+ s.rendererEmitterSettings.headWidthScale = v;
354
390
  }) }) })] })), system.renderMode === RenderMode.StretchedBillBoard && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Speed factor", children: _jsx(NumberField, { value: system.rendererEmitterSettings.speedFactor ?? 0, step: 0.1, onChange: (v) => binding.apply((s) => (s.rendererEmitterSettings.speedFactor = v)) }) }), _jsx(Row, { label: "Length factor", children: _jsx(NumberField, { value: system.rendererEmitterSettings.lengthFactor ?? 2, step: 0.1, onChange: (v) => binding.apply((s) => (s.rendererEmitterSettings.lengthFactor = v)) }) })] })), _jsxs("div", { style: {
355
391
  marginTop: 10,
356
392
  marginBottom: 2,
@@ -503,7 +539,22 @@ export function RendererModule({ binding, textureOptions, geometryOptions, resol
503
539
  { value: 'add', label: 'Add (glow)' },
504
540
  { value: 'mix', label: 'Mix (outline)' },
505
541
  ], onChange: (mode) => patchRim({ mode }) }) }), _jsx(Row, { label: "Rim color", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: [0, 1, 2].map((channel) => (_jsx(NumberField, { value: rim.color[channel], min: 0, step: 0.05, onChange: (v) => setChannel(channel, v) }, channel))) }) }), _jsx(Row, { label: "Rim power", children: _jsx(NumberField, { value: rim.power, min: 0.01, step: 0.25, onChange: (power) => patchRim({ power }) }) }), _jsx(Row, { label: "Rim strength", children: _jsx(NumberField, { value: rim.strength, min: 0, step: 0.05, onChange: (strength) => patchRim({ strength }) }) })] }));
506
- })(), _jsx(Row, { label: "Render order", children: _jsx(NumberField, { value: system.renderOrder, step: 1, onChange: (v) => binding.apply((s) => (s.renderOrder = Math.round(v))) }) }), _jsx(Row, { label: "Soft particles", children: _jsx(CheckboxField, { value: system.softParticles, onChange: (v) => binding.apply((s) => (s.softParticles = v)) }) }), system.softParticles && (_jsx(Row, { label: "Fade near/far", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: system.softNearFade, min: 0, onChange: (v) => binding.apply((s) => (s.softNearFade = v)) }), _jsx(NumberField, { value: system.softFarFade, min: 0, onChange: (v) => binding.apply((s) => (s.softFarFade = v)) })] }) }))] }));
542
+ })(), _jsx(Row, { label: "Render order", children: _jsx(NumberField, { value: system.renderOrder, step: 1, onChange: (v) => binding.apply((s) => (s.renderOrder = Math.round(v))) }) }), _jsx(Row, { label: "Rendering group", children: _jsx(NumberField, { value: system.renderingGroupId, step: 1, min: 0, onChange: (v) => binding.apply((s) => (s.renderingGroupId = Math.max(0, Math.round(v)))) }) }), _jsx(Row, { label: "Layer mask", children: _jsx(NumberField, { value: system.layers.mask, min: 0, step: 1, onChange: (v) => binding.apply((s) => (s.layers.mask = Math.max(0, Math.floor(v)))) }) }), _jsxs(Row, { label: "Max particles", children: [_jsx(CheckboxField, { value: system.maxParticles !== undefined, onChange: (v) => binding.apply((s) => (s.maxParticles = v ? 1000 : undefined)) }), system.maxParticles !== undefined && (_jsx(NumberField, { value: system.maxParticles, min: 1, step: 10, onChange: (v) => binding.apply((s) => (s.maxParticles = Math.max(1, Math.floor(v)))) }))] }), system.renderMode !== RenderMode.Mesh && system.renderMode !== RenderMode.Trail && (_jsx(Row, { label: "Pivot", children: _jsx("div", { style: { display: 'flex', gap: 6 }, children: ['x', 'y', 'z'].map((axis) => (_jsx(NumberField, { value: system.pivot?.[axis] ?? 0, step: 0.05, onChange: (v) => binding.apply((s) => {
543
+ const next = (s.pivot ?? new Vector3()).clone();
544
+ next[axis] = v;
545
+ s.pivot = next;
546
+ }) }, axis))) }) })), system.renderMode === RenderMode.Mesh && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Env map atlas", children: _jsx(SelectField, { value: system.getRendererSettings().reflectionAtlas?.url ?? '', options: [
547
+ { value: '', label: 'None' },
548
+ ...(textureOptions ?? []).map((option) => ({ value: option.url, label: option.label })),
549
+ ], onChange: (url) => patchMaterial({ reflectionAtlas: url ? loadTexture(url) : null }) }) }), _jsx(Row, { label: "Reflection level", children: _jsx(NumberField, { value: system.getRendererSettings().reflectionLevel ?? 1, min: 0, step: 0.1, onChange: (v) => patchMaterial({ reflectionLevel: Math.max(0, v) }) }) })] })), _jsx(Row, { label: "Sort mode", children: _jsx(SelectField, { value: system.sortMode, options: [
550
+ { value: 'none', label: 'None' },
551
+ { value: 'distance', label: 'By distance' },
552
+ { value: 'oldestInFront', label: 'Oldest in front' },
553
+ { value: 'youngestInFront', label: 'Youngest in front' },
554
+ ], onChange: (mode) => binding.apply((s) => (s.sortMode = mode)) }) }), _jsx(Row, { label: "Limit screen size", children: _jsx(CheckboxField, { value: system.minParticleSize > 0 || system.maxParticleSize !== Infinity, onChange: (v) => binding.apply((s) => {
555
+ s.minParticleSize = 0;
556
+ s.maxParticleSize = v ? 0.5 : Infinity;
557
+ }) }) }), (system.minParticleSize > 0 || system.maxParticleSize !== Infinity) && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Min size (viewport)", children: _jsx(NumberField, { value: system.minParticleSize, step: 0.01, min: 0, onChange: (v) => binding.apply((s) => (s.minParticleSize = Math.max(0, v))) }) }), _jsx(Row, { label: "Max size (viewport)", children: _jsx(NumberField, { value: system.maxParticleSize === Infinity ? 0 : system.maxParticleSize, step: 0.05, min: 0, onChange: (v) => binding.apply((s) => (s.maxParticleSize = v > 0 ? v : Infinity)) }) })] })), _jsx(Row, { label: "Apply fog", children: _jsx(CheckboxField, { value: system.applyFog, onChange: (v) => binding.apply((s) => (s.applyFog = v)) }) }), _jsx(Row, { label: "Soft particles", children: _jsx(CheckboxField, { value: system.softParticles, onChange: (v) => binding.apply((s) => (s.softParticles = v)) }) }), system.softParticles && (_jsx(Row, { label: "Fade near/far", children: _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx(NumberField, { value: system.softNearFade, min: 0, onChange: (v) => binding.apply((s) => (s.softNearFade = v)) }), _jsx(NumberField, { value: system.softFarFade, min: 0, onChange: (v) => binding.apply((s) => (s.softFarFade = v)) })] }) }))] }));
507
558
  }
508
559
  function removeBehavior(system, type) {
509
560
  const behaviors = system.behaviors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks-editor",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Shuriken-style effect editor for babylon.quarks: headless edit-model core + React module-stack UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,7 +24,8 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "clean": "rimraf dist",
27
- "build": "npm run clean && tsc -p tsconfig.json"
27
+ "build": "npm run clean && tsc -p tsconfig.json",
28
+ "test": "jest"
28
29
  },
29
30
  "repository": {
30
31
  "type": "git",
@@ -59,7 +60,7 @@
59
60
  "react-dom": ">=18"
60
61
  },
61
62
  "devDependencies": {
62
- "@babylonjs/materials": "9.6.2",
63
+ "@babylonjs/materials": "9.31.0",
63
64
  "@heroicons/react": "^2.2.0",
64
65
  "@tanstack/react-virtual": "^3.14.6",
65
66
  "@types/react": "^19.0.0",
@@ -7,6 +7,9 @@ export interface RendererMaterialPatch {
7
7
  depthWrite?: boolean;
8
8
  alphaTest?: number;
9
9
  texture?: unknown;
10
+ /** A 3×2 cube-face atlas mesh particles reflect, or null for none. */
11
+ reflectionAtlas?: unknown;
12
+ reflectionLevel?: number;
10
13
  }
11
14
 
12
15
  function isEditableMaterialRecord(material: unknown): material is Record<string, unknown> {
@@ -35,6 +38,12 @@ export function applyRendererMaterial(system: ParticleSystem, patch: RendererMat
35
38
  if (patch.texture !== undefined) {
36
39
  system.texture = patch.texture as never;
37
40
  }
41
+ if (patch.reflectionAtlas !== undefined) {
42
+ settings.reflectionAtlas = patch.reflectionAtlas as never;
43
+ }
44
+ if (patch.reflectionLevel !== undefined) {
45
+ settings.reflectionLevel = patch.reflectionLevel;
46
+ }
38
47
 
39
48
  const material = system.material;
40
49
  if (isEditableMaterialRecord(material)) {
@@ -58,6 +67,12 @@ export function applyRendererMaterial(system: ParticleSystem, patch: RendererMat
58
67
  material.texture = patch.texture;
59
68
  material.map = patch.texture;
60
69
  }
70
+ if (patch.reflectionAtlas !== undefined) {
71
+ material.reflectionAtlas = patch.reflectionAtlas;
72
+ }
73
+ if (patch.reflectionLevel !== undefined) {
74
+ material.reflectionLevel = patch.reflectionLevel;
75
+ }
61
76
  }
62
77
 
63
78
  system.neededToUpdateRender = true;
@@ -1,9 +1,13 @@
1
1
  import type {Mesh} from '@babylonjs/core/Meshes/mesh';
2
2
  import type {EmitterShape} from 'babylon.quarks';
3
3
  import {
4
+ BoxEmitFrom,
5
+ BoxEmitter,
4
6
  CircleEmitter,
5
7
  ConeEmitter,
8
+ ConstantValue,
6
9
  DonutEmitter,
10
+ EdgeEmitter,
7
11
  EmitterMode,
8
12
  GridEmitter,
9
13
  HemisphereEmitter,
@@ -22,6 +26,8 @@ export type ShapeType =
22
26
  | 'donut'
23
27
  | 'rectangle'
24
28
  | 'grid'
29
+ | 'box'
30
+ | 'edge'
25
31
  | 'mesh_surface';
26
32
 
27
33
  export const SHAPE_TYPES: Array<{value: ShapeType; label: string}> = [
@@ -32,6 +38,8 @@ export const SHAPE_TYPES: Array<{value: ShapeType; label: string}> = [
32
38
  {value: 'donut', label: 'Donut'},
33
39
  {value: 'rectangle', label: 'Rectangle'},
34
40
  {value: 'grid', label: 'Grid'},
41
+ {value: 'box', label: 'Box'},
42
+ {value: 'edge', label: 'Edge'},
35
43
  {value: 'mesh_surface', label: 'Mesh surface'},
36
44
  {value: 'point', label: 'Point'},
37
45
  ];
@@ -48,6 +56,12 @@ export interface ShapeParams {
48
56
  spread: number;
49
57
  column: number;
50
58
  row: number;
59
+ /** Box depth (Z). */
60
+ depth: number;
61
+ /** Where a box emits from: its volume, its faces or its edges. */
62
+ emitFrom: BoxEmitFrom;
63
+ /** How fast an edge emitter's loop / ping-pong point travels, in lengths per second. */
64
+ speed: number;
51
65
  mesh?: Mesh;
52
66
  }
53
67
 
@@ -63,6 +77,9 @@ export const DEFAULT_SHAPE_PARAMS: ShapeParams = {
63
77
  spread: 0,
64
78
  column: 10,
65
79
  row: 10,
80
+ depth: 1,
81
+ emitFrom: BoxEmitFrom.Volume,
82
+ speed: 1,
66
83
  mesh: undefined,
67
84
  };
68
85
 
@@ -87,6 +104,9 @@ export function readShapeParams(shape: EmitterShape): ShapeParams {
87
104
  height: num('height', DEFAULT_SHAPE_PARAMS.height),
88
105
  column: num('column', DEFAULT_SHAPE_PARAMS.column),
89
106
  row: num('row', DEFAULT_SHAPE_PARAMS.row),
107
+ depth: num('depth', DEFAULT_SHAPE_PARAMS.depth),
108
+ emitFrom: num('emitFrom', DEFAULT_SHAPE_PARAMS.emitFrom) as BoxEmitFrom,
109
+ speed: anyShape.speed instanceof ConstantValue ? anyShape.speed.value : DEFAULT_SHAPE_PARAMS.speed,
90
110
  mesh: anyShape.mesh as Mesh | undefined,
91
111
  };
92
112
  }
@@ -102,6 +122,8 @@ export const SHAPE_PARAM_KEYS: {[K in ShapeType]: Array<keyof ShapeParams>} = {
102
122
  donut: ['radius', 'arc', 'thickness', 'donutRadius'],
103
123
  rectangle: ['width', 'height'],
104
124
  grid: ['width', 'height', 'column', 'row'],
125
+ box: ['width', 'height', 'depth'],
126
+ edge: ['radius', 'speed'],
105
127
  mesh_surface: [],
106
128
  };
107
129
 
@@ -165,6 +187,20 @@ export function createShape(type: ShapeType, params: ShapeParams): EmitterShape
165
187
  column: params.column,
166
188
  row: params.row,
167
189
  });
190
+ case 'box':
191
+ return new BoxEmitter({
192
+ width: params.width,
193
+ height: params.height,
194
+ depth: params.depth,
195
+ emitFrom: params.emitFrom,
196
+ });
197
+ case 'edge':
198
+ return new EdgeEmitter({
199
+ radius: params.radius,
200
+ mode: params.mode,
201
+ spread: params.spread,
202
+ speed: new ConstantValue(params.speed),
203
+ });
168
204
  case 'mesh_surface':
169
205
  return new MeshSurfaceEmitter(params.mesh);
170
206
  }
@@ -12,8 +12,10 @@ import {
12
12
  LimitSpeedOverLifeModule,
13
13
  NoiseModule,
14
14
  OrbitOverLifeModule,
15
+ Rotation3DOverLifeModule,
15
16
  RotationBySpeedModule,
16
17
  RotationOverLifeModule,
18
+ SequencesModule,
17
19
  SizeBySpeedModule,
18
20
  SpeedOverLifeModule,
19
21
  TextureSheetModule,
@@ -116,6 +118,7 @@ export const EffectEditor = React.memo(function EffectEditor(props: EffectEditor
116
118
  <SizeOverLifeModule binding={binding} />
117
119
  <SizeBySpeedModule binding={binding} />
118
120
  <RotationOverLifeModule binding={binding} />
121
+ <Rotation3DOverLifeModule binding={binding} />
119
122
  <RotationBySpeedModule binding={binding} />
120
123
  <NoiseModule binding={binding} />
121
124
  <TurbulenceModule binding={binding} />
@@ -123,6 +126,7 @@ export const EffectEditor = React.memo(function EffectEditor(props: EffectEditor
123
126
  <WidthOverTrailModule binding={binding} />
124
127
  <SubEmittersModule binding={binding} rootBinding={props.binding} />
125
128
  <TextureSheetModule binding={binding} />
129
+ <SequencesModule binding={binding} textureOptions={props.textureOptions} />
126
130
  <RendererModule
127
131
  binding={binding}
128
132
  textureOptions={props.textureOptions}
@@ -93,6 +93,21 @@ import {iconStyle, type HeroIcon} from './icons';
93
93
  import type {GeometryData, GeometryOption, TextureOption} from './modules';
94
94
  import {buttonStyle, globalEditorStyles, theme} from './theme';
95
95
 
96
+ /**
97
+ * The effect's JSON, with a warning when it points at object URLs: a texture
98
+ * dropped in from disk lives at a `blob:` URL that dies with the page, so the
99
+ * exported effect would load without it.
100
+ */
101
+ function exportEffectJSON(binding: EffectBinding, warn: (message: string) => void): string {
102
+ const json = binding.exportJSON('EditorEffect');
103
+ if (json.includes('"blob:')) {
104
+ warn(
105
+ 'This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.'
106
+ );
107
+ }
108
+ return json;
109
+ }
110
+
96
111
  export interface EffectEditorHostHandle {
97
112
  binding: EffectBinding;
98
113
  history: EffectHistory;
@@ -2139,7 +2154,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
2139
2154
  disabled={!binding}
2140
2155
  onClick={() => {
2141
2156
  if (!binding) return;
2142
- const blob = new Blob([binding.exportJSON('EditorEffect')], {
2157
+ const blob = new Blob([exportEffectJSON(binding, showToast)], {
2143
2158
  type: 'application/json',
2144
2159
  });
2145
2160
  const link = document.createElement('a');
@@ -2156,7 +2171,7 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
2156
2171
  className="qe-hover"
2157
2172
  style={buttonStyle}
2158
2173
  disabled={!binding}
2159
- onClick={() => binding && props.onSave!(binding.exportJSON('EditorEffect'))}
2174
+ onClick={() => binding && props.onSave!(exportEffectJSON(binding, showToast))}
2160
2175
  >
2161
2176
  Save
2162
2177
  </button>
@@ -69,6 +69,21 @@ export function SubEmittersModule(props: {binding: EffectBinding; rootBinding: E
69
69
  onChange={(v) => binding.apply(() => (behavior.useVelocityAsBasis = v))}
70
70
  />
71
71
  </Row>
72
+ {(['color', 'size', 'rotation', 'lifetime'] as const).map((property) => (
73
+ <Row key={property} label={`Inherit ${property}`}>
74
+ <CheckboxField
75
+ value={behavior.inherit.includes(property)}
76
+ onChange={(v) =>
77
+ binding.apply(
78
+ () =>
79
+ (behavior.inherit = v
80
+ ? [...behavior.inherit.filter((p) => p !== property), property]
81
+ : behavior.inherit.filter((p) => p !== property))
82
+ )
83
+ }
84
+ />
85
+ </Row>
86
+ ))}
72
87
  <p style={{margin: '8px 0 2px', color: theme.textDim, fontSize: 11.5}}>
73
88
  Spawns “{target?.emitter.name ?? 'sub-emitter'}” — select it in the hierarchy above to edit it.
74
89
  </p>
@@ -1,3 +1,4 @@
1
+ import React from 'react';
1
2
  import {ScalarGenerator, ScalarValueState, buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
2
3
  import {CurveEditor} from './CurveEditor';
3
4
  import {NumberField, Row, SelectField} from './fields';
@@ -6,6 +7,8 @@ import {NumberField, Row, SelectField} from './fields';
6
7
  * Unity-like value field: Constant | Random Between Two Constants | Curve,
7
8
  * bound to a quarks value generator.
8
9
  */
10
+ const captionStyle: React.CSSProperties = {fontSize: 10.5, opacity: 0.6, flex: 'none'};
11
+
9
12
  export function ValueField(props: {
10
13
  label: string;
11
14
  hint?: string;
@@ -41,8 +44,10 @@ export function ValueField(props: {
41
44
  )}
42
45
  {state.mode === 'random' && (
43
46
  <Row label="">
44
- <div style={{display: 'flex', gap: 6}}>
47
+ <div style={{display: 'flex', gap: 6, alignItems: 'center'}}>
48
+ <span style={captionStyle}>min</span>
45
49
  <NumberField value={state.min} min={props.min} onChange={(min) => commit({min})} />
50
+ <span style={captionStyle}>max</span>
46
51
  <NumberField value={state.max} min={props.min} onChange={(max) => commit({max})} />
47
52
  </div>
48
53
  </Row>