babylon.quarks-editor 0.17.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +84 -0
  4. package/dist/core/binding.d.ts +43 -0
  5. package/dist/core/binding.js +82 -0
  6. package/dist/core/collision.d.ts +20 -0
  7. package/dist/core/collision.js +39 -0
  8. package/dist/core/colors.d.ts +31 -0
  9. package/dist/core/colors.js +60 -0
  10. package/dist/core/effectTree.d.ts +25 -0
  11. package/dist/core/effectTree.js +115 -0
  12. package/dist/core/groups.d.ts +12 -0
  13. package/dist/core/groups.js +27 -0
  14. package/dist/core/history.d.ts +25 -0
  15. package/dist/core/history.js +69 -0
  16. package/dist/core/loadEffect.d.ts +8 -0
  17. package/dist/core/loadEffect.js +87 -0
  18. package/dist/core/scrub.d.ts +11 -0
  19. package/dist/core/scrub.js +24 -0
  20. package/dist/core/shapes.d.ts +31 -0
  21. package/dist/core/shapes.js +98 -0
  22. package/dist/core/systems.d.ts +25 -0
  23. package/dist/core/systems.js +76 -0
  24. package/dist/core/textureExport.d.ts +9 -0
  25. package/dist/core/textureExport.js +58 -0
  26. package/dist/core/timeline.d.ts +35 -0
  27. package/dist/core/timeline.js +69 -0
  28. package/dist/core/values.d.ts +31 -0
  29. package/dist/core/values.js +81 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.js +9 -0
  32. package/dist/react/CurveEditor.d.ts +12 -0
  33. package/dist/react/CurveEditor.js +106 -0
  34. package/dist/react/EffectEditor.d.ts +22 -0
  35. package/dist/react/EffectEditor.js +30 -0
  36. package/dist/react/EffectEditorHost.d.ts +42 -0
  37. package/dist/react/EffectEditorHost.js +247 -0
  38. package/dist/react/GradientEditor.d.ts +7 -0
  39. package/dist/react/GradientEditor.js +72 -0
  40. package/dist/react/ModuleSection.d.ts +9 -0
  41. package/dist/react/ModuleSection.js +24 -0
  42. package/dist/react/PromptDialog.d.ts +14 -0
  43. package/dist/react/PromptDialog.js +45 -0
  44. package/dist/react/SubEmittersModule.d.ts +10 -0
  45. package/dist/react/SubEmittersModule.js +39 -0
  46. package/dist/react/TimelinePanel.d.ts +32 -0
  47. package/dist/react/TimelinePanel.js +225 -0
  48. package/dist/react/TimelineTrackRow.d.ts +23 -0
  49. package/dist/react/TimelineTrackRow.js +107 -0
  50. package/dist/react/ValueField.d.ts +13 -0
  51. package/dist/react/ValueField.js +19 -0
  52. package/dist/react/behaviorModules.d.ts +23 -0
  53. package/dist/react/behaviorModules.js +139 -0
  54. package/dist/react/fields.d.ts +24 -0
  55. package/dist/react/fields.js +30 -0
  56. package/dist/react/index.d.ts +20 -0
  57. package/dist/react/index.js +14 -0
  58. package/dist/react/modules.d.ts +35 -0
  59. package/dist/react/modules.js +439 -0
  60. package/dist/react/theme.d.ts +26 -0
  61. package/dist/react/theme.js +78 -0
  62. package/package.json +63 -0
  63. package/src/core/binding.ts +105 -0
  64. package/src/core/collision.ts +40 -0
  65. package/src/core/colors.ts +84 -0
  66. package/src/core/effectTree.ts +143 -0
  67. package/src/core/groups.ts +29 -0
  68. package/src/core/history.ts +77 -0
  69. package/src/core/loadEffect.ts +102 -0
  70. package/src/core/scrub.ts +28 -0
  71. package/src/core/shapes.ts +133 -0
  72. package/src/core/systems.ts +98 -0
  73. package/src/core/textureExport.ts +63 -0
  74. package/src/core/timeline.ts +94 -0
  75. package/src/core/values.ts +113 -0
  76. package/src/index.ts +32 -0
  77. package/src/react/CurveEditor.tsx +168 -0
  78. package/src/react/EffectEditor.tsx +111 -0
  79. package/src/react/EffectEditorHost.tsx +377 -0
  80. package/src/react/GradientEditor.tsx +127 -0
  81. package/src/react/ModuleSection.tsx +54 -0
  82. package/src/react/PromptDialog.tsx +85 -0
  83. package/src/react/SubEmittersModule.tsx +80 -0
  84. package/src/react/TimelinePanel.tsx +354 -0
  85. package/src/react/TimelineTrackRow.tsx +267 -0
  86. package/src/react/ValueField.tsx +60 -0
  87. package/src/react/behaviorModules.tsx +658 -0
  88. package/src/react/fields.tsx +76 -0
  89. package/src/react/index.ts +43 -0
  90. package/src/react/modules.tsx +1034 -0
  91. package/src/react/theme.ts +85 -0
@@ -0,0 +1,1034 @@
1
+ import React, {useState} from 'react';
2
+ import {Mesh} from '@babylonjs/core/Meshes/mesh';
3
+ import {
4
+ ColorOverLife,
5
+ ConstantColor,
6
+ ConstantValue,
7
+ EulerGenerator,
8
+ Gradient,
9
+ IntervalValue,
10
+ RandomColor,
11
+ RandomColorBetweenGradient,
12
+ RandomQuatGenerator,
13
+ RenderMode,
14
+ SizeOverLife,
15
+ Vector3Function,
16
+ Vector4,
17
+ } from 'babylon.quarks';
18
+ import type {Behavior, ParticleSystem} from 'babylon.quarks';
19
+ import {EffectBinding} from '../core/binding';
20
+ import {
21
+ DEFAULT_GRADIENT_STOPS,
22
+ GradientStop,
23
+ buildGradient,
24
+ findBehavior,
25
+ hexToRgb,
26
+ readGradientStops,
27
+ rgbToHex,
28
+ } from '../core/colors';
29
+ import {DEFAULT_SHAPE_PARAMS, SHAPE_PARAM_KEYS, SHAPE_TYPES, createShape, getShapeType, readShapeParams} from '../core/shapes';
30
+ import type {ShapeType} from '../core/shapes';
31
+ import {buildCurve, buildScalar, readPieces, readScalar} from '../core/values';
32
+ import {CurveEditor} from './CurveEditor';
33
+ import {GradientEditor} from './GradientEditor';
34
+ import {ModuleSection} from './ModuleSection';
35
+ import {PromptDialog} from './PromptDialog';
36
+ import {CheckboxField, NumberField, Row, SelectField} from './fields';
37
+ import {ValueField} from './ValueField';
38
+
39
+ interface ModuleProps {
40
+ binding: EffectBinding;
41
+ }
42
+
43
+ const MESH_QUAD_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
44
+ const MESH_QUAD_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
45
+ const MESH_QUAD_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
46
+ const MESH_CUBE_POSITIONS = new Float32Array([
47
+ -0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5,
48
+ -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
49
+ ]);
50
+ const MESH_CUBE_INDICES = new Uint32Array([
51
+ 0, 1, 2, 0, 2, 3, 4, 6, 5, 4, 7, 6, 0, 4, 5, 0, 5, 1,
52
+ 3, 2, 6, 3, 6, 7, 0, 3, 7, 0, 7, 4, 1, 5, 6, 1, 6, 2,
53
+ ]);
54
+
55
+ function arraysEqual(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
56
+ if (a.length !== b.length) {
57
+ return false;
58
+ }
59
+ for (let i = 0; i < a.length; i++) {
60
+ if (a[i] !== b[i]) {
61
+ return false;
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /** Detects whether the current mesh geometry is one of the editor's built-in presets or was imported. */
68
+ function detectGeometryPreset(positions: Float32Array): 'cube' | 'quad' | 'custom' {
69
+ if (arraysEqual(positions, MESH_CUBE_POSITIONS)) {
70
+ return 'cube';
71
+ }
72
+ if (arraysEqual(positions, MESH_QUAD_POSITIONS)) {
73
+ return 'quad';
74
+ }
75
+ return 'custom';
76
+ }
77
+
78
+ const PARAM_LABELS: {[key: string]: string} = {
79
+ radius: 'Radius',
80
+ arc: 'Arc',
81
+ thickness: 'Thickness',
82
+ angle: 'Angle',
83
+ donutRadius: 'Donut radius',
84
+ width: 'Width',
85
+ height: 'Height',
86
+ column: 'Columns',
87
+ row: 'Rows',
88
+ };
89
+
90
+ function ColorInput(props: {value: Vector4; onChange: (next: Vector4) => void}) {
91
+ const {value, onChange} = props;
92
+ return (
93
+ <div style={{display: 'flex', gap: 6, alignItems: 'center'}}>
94
+ <input
95
+ type="color"
96
+ className="qe-hover"
97
+ value={rgbToHex(value.x, value.y, value.z)}
98
+ onChange={(e) => {
99
+ const rgb = hexToRgb(e.target.value);
100
+ onChange(new Vector4(rgb.r, rgb.g, rgb.b, value.w));
101
+ }}
102
+ style={{width: 42, height: 24, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer'}}
103
+ />
104
+ <NumberField
105
+ value={value.w}
106
+ min={0}
107
+ step={0.05}
108
+ onChange={(a) => onChange(new Vector4(value.x, value.y, value.z, Math.min(1, a)))}
109
+ />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ export function MainModule({binding}: ModuleProps) {
115
+ const system = binding.system;
116
+ const startColor = system.startColor;
117
+ const colorMode = startColor instanceof RandomColor ? 'random' : 'constant';
118
+ const colorA =
119
+ startColor instanceof RandomColor ? (startColor as never as {a: Vector4}).a
120
+ : startColor instanceof ConstantColor ? startColor.color
121
+ : new Vector4(1, 1, 1, 1);
122
+ const colorB = startColor instanceof RandomColor ? (startColor as never as {b: Vector4}).b : new Vector4(1, 0.5, 0.2, 1);
123
+ const rotation = system.startRotation;
124
+ const isEuler = rotation instanceof EulerGenerator;
125
+ const rotationMode =
126
+ rotation instanceof RandomQuatGenerator ? '3d'
127
+ : isEuler ? 'euler'
128
+ : readScalar(rotation as never).mode === 'random' ? 'random'
129
+ : 'angle';
130
+ const rotationState = readScalar(rotation instanceof RandomQuatGenerator || isEuler ? undefined : (rotation as never));
131
+ const eulerAngles = {
132
+ x: isEuler ? readScalar((rotation as EulerGenerator).angleX as never).value : 0,
133
+ y: isEuler ? readScalar((rotation as EulerGenerator).angleY as never).value : 0,
134
+ z: isEuler ? readScalar((rotation as EulerGenerator).angleZ as never).value : 0,
135
+ };
136
+ const setEulerAxis = (axis: 'x' | 'y' | 'z', v: number) =>
137
+ binding.apply((s) => {
138
+ const cur = s.startRotation as EulerGenerator;
139
+ s.startRotation = new EulerGenerator(
140
+ axis === 'x' ? new ConstantValue(v) : cur.angleX,
141
+ axis === 'y' ? new ConstantValue(v) : cur.angleY,
142
+ axis === 'z' ? new ConstantValue(v) : cur.angleZ
143
+ );
144
+ });
145
+ const startSize = system.startSize as unknown as {type?: string; x?: never; y?: never; z?: never};
146
+ const size3D = startSize.type === 'vec3function';
147
+ const colorGenMode =
148
+ startColor instanceof RandomColorBetweenGradient ? 'gradient2'
149
+ : startColor instanceof Gradient ? 'gradient'
150
+ : colorMode;
151
+ return (
152
+ <ModuleSection title="Main">
153
+ <Row label="Duration">
154
+ <NumberField value={system.duration} min={0.05} onChange={(v) => binding.apply((s) => (s.duration = v))} />
155
+ </Row>
156
+ <Row label="Looping">
157
+ <CheckboxField value={system.looping} onChange={(v) => binding.apply((s) => (s.looping = v))} />
158
+ </Row>
159
+ <Row label="World space">
160
+ <CheckboxField value={system.worldSpace} onChange={(v) => binding.apply((s) => (s.worldSpace = v))} />
161
+ </Row>
162
+ <Row label="Prewarm">
163
+ <CheckboxField value={system.prewarm} onChange={(v) => binding.apply((s) => (s.prewarm = v))} />
164
+ </Row>
165
+ <ValueField
166
+ label="Start delay"
167
+ generator={system.startDelay}
168
+ min={0}
169
+ curveMax={5}
170
+ onChange={(g) =>
171
+ binding.apply((s) => {
172
+ s.startDelay = g;
173
+ s.restart();
174
+ })
175
+ }
176
+ />
177
+ <ValueField
178
+ label="Start lifetime"
179
+ generator={system.startLife}
180
+ min={0.01}
181
+ curveMax={10}
182
+ onChange={(g) => binding.apply((s) => (s.startLife = g))}
183
+ />
184
+ <ValueField
185
+ label="Start speed"
186
+ generator={system.startSpeed}
187
+ min={0}
188
+ curveMax={20}
189
+ onChange={(g) => binding.apply((s) => (s.startSpeed = g))}
190
+ />
191
+ <Row label="Start size">
192
+ <SelectField
193
+ value={size3D ? '3d' : 'uniform'}
194
+ options={[
195
+ {value: 'uniform', label: 'Uniform'},
196
+ {value: '3d', label: '3D (per axis)'},
197
+ ]}
198
+ onChange={(mode) =>
199
+ binding.apply((s) => {
200
+ const cur = s.startSize as never as {clone: () => never; x?: never};
201
+ if (mode === '3d') {
202
+ // Seed all three axes from the current uniform generator.
203
+ s.startSize = new Vector3Function(cur.clone(), cur.clone(), cur.clone());
204
+ } else {
205
+ // Collapse back to the X-axis generator.
206
+ s.startSize = (s.startSize as never as {x: never}).x;
207
+ }
208
+ })
209
+ }
210
+ />
211
+ </Row>
212
+ {!size3D && (
213
+ <ValueField
214
+ label=""
215
+ generator={system.startSize as never}
216
+ min={0}
217
+ curveMax={5}
218
+ onChange={(g) => binding.apply((s) => (s.startSize = g))}
219
+ />
220
+ )}
221
+ {size3D && (
222
+ <>
223
+ {(['x', 'y', 'z'] as const).map((axis) => (
224
+ <ValueField
225
+ key={axis}
226
+ label={axis.toUpperCase()}
227
+ generator={startSize[axis] as never}
228
+ min={0}
229
+ curveMax={5}
230
+ onChange={(g) =>
231
+ binding.apply((s) => {
232
+ const cur = s.startSize as Vector3Function;
233
+ s.startSize = new Vector3Function(
234
+ axis === 'x' ? (g as never) : cur.x,
235
+ axis === 'y' ? (g as never) : cur.y,
236
+ axis === 'z' ? (g as never) : cur.z
237
+ );
238
+ })
239
+ }
240
+ />
241
+ ))}
242
+ </>
243
+ )}
244
+ <Row label="Start color">
245
+ <SelectField
246
+ value={colorGenMode}
247
+ options={[
248
+ {value: 'constant', label: 'Constant'},
249
+ {value: 'random', label: 'Random between two'},
250
+ {value: 'gradient', label: 'Gradient'},
251
+ {value: 'gradient2', label: 'Random between gradients'},
252
+ ]}
253
+ onChange={(mode) =>
254
+ binding.apply((s) => {
255
+ if (mode === 'random') {
256
+ s.startColor = new RandomColor(colorA, colorB);
257
+ } else if (mode === 'gradient') {
258
+ s.startColor = buildGradient(DEFAULT_GRADIENT_STOPS);
259
+ } else if (mode === 'gradient2') {
260
+ s.startColor = new RandomColorBetweenGradient(
261
+ buildGradient(DEFAULT_GRADIENT_STOPS),
262
+ buildGradient(DEFAULT_GRADIENT_STOPS)
263
+ );
264
+ } else {
265
+ s.startColor = new ConstantColor(colorA);
266
+ }
267
+ })
268
+ }
269
+ />
270
+ </Row>
271
+ {(colorGenMode === 'constant' || colorGenMode === 'random') && (
272
+ <Row label="">
273
+ <ColorInput
274
+ value={colorA}
275
+ onChange={(next) =>
276
+ binding.apply((s) => {
277
+ s.startColor = colorGenMode === 'random' ? new RandomColor(next, colorB) : new ConstantColor(next);
278
+ })
279
+ }
280
+ />
281
+ </Row>
282
+ )}
283
+ {colorGenMode === 'random' && (
284
+ <Row label="">
285
+ <ColorInput
286
+ value={colorB}
287
+ onChange={(next) => binding.apply((s) => (s.startColor = new RandomColor(colorA, next)))}
288
+ />
289
+ </Row>
290
+ )}
291
+ {colorGenMode === 'gradient' && (
292
+ <div style={{marginTop: 6}}>
293
+ <GradientEditor
294
+ stops={readGradientStops(startColor as Gradient)}
295
+ onChange={(stops) => binding.apply((s) => (s.startColor = buildGradient(stops)))}
296
+ />
297
+ </div>
298
+ )}
299
+ {colorGenMode === 'gradient2' && (
300
+ <div style={{marginTop: 6, display: 'flex', flexDirection: 'column', gap: 6}}>
301
+ <GradientEditor
302
+ stops={readGradientStops((startColor as RandomColorBetweenGradient).gradient1)}
303
+ onChange={(stops) =>
304
+ binding.apply((s) => {
305
+ const g = s.startColor as RandomColorBetweenGradient;
306
+ s.startColor = new RandomColorBetweenGradient(buildGradient(stops), g.gradient2);
307
+ })
308
+ }
309
+ />
310
+ <GradientEditor
311
+ stops={readGradientStops((startColor as RandomColorBetweenGradient).gradient2)}
312
+ onChange={(stops) =>
313
+ binding.apply((s) => {
314
+ const g = s.startColor as RandomColorBetweenGradient;
315
+ s.startColor = new RandomColorBetweenGradient(g.gradient1, buildGradient(stops));
316
+ })
317
+ }
318
+ />
319
+ </div>
320
+ )}
321
+ <Row label="Start rotation">
322
+ <SelectField
323
+ value={rotationMode}
324
+ options={[
325
+ {value: 'angle', label: 'Angle (rad)'},
326
+ {value: 'random', label: 'Random angle'},
327
+ {value: 'euler', label: '3D (per axis)'},
328
+ {value: '3d', label: 'Random 3D'},
329
+ ]}
330
+ onChange={(mode) =>
331
+ binding.apply((s) => {
332
+ s.startRotation =
333
+ mode === '3d'
334
+ ? new RandomQuatGenerator()
335
+ : mode === 'euler'
336
+ ? new EulerGenerator(new ConstantValue(eulerAngles.x), new ConstantValue(eulerAngles.y), new ConstantValue(eulerAngles.z))
337
+ : mode === 'random'
338
+ ? new IntervalValue(0, Math.PI * 2)
339
+ : new ConstantValue(rotationState.value);
340
+ })
341
+ }
342
+ />
343
+ </Row>
344
+ {rotationMode === 'euler' && (
345
+ <>
346
+ {(['x', 'y', 'z'] as const).map((axis) => (
347
+ <Row key={axis} label={` ${axis.toUpperCase()} (rad)`}>
348
+ <NumberField value={eulerAngles[axis]} step={0.1} onChange={(v) => setEulerAxis(axis, v)} />
349
+ </Row>
350
+ ))}
351
+ </>
352
+ )}
353
+ {rotationMode === 'angle' && (
354
+ <Row label="">
355
+ <NumberField
356
+ value={rotationState.value}
357
+ step={0.1}
358
+ onChange={(v) => binding.apply((s) => (s.startRotation = new ConstantValue(v)))}
359
+ />
360
+ </Row>
361
+ )}
362
+ {rotationMode === 'random' && (
363
+ <Row label="">
364
+ <div style={{display: 'flex', gap: 6}}>
365
+ <NumberField
366
+ value={rotationState.min}
367
+ step={0.1}
368
+ onChange={(v) => binding.apply((s) => (s.startRotation = new IntervalValue(v, rotationState.max)))}
369
+ />
370
+ <NumberField
371
+ value={rotationState.max}
372
+ step={0.1}
373
+ onChange={(v) => binding.apply((s) => (s.startRotation = new IntervalValue(rotationState.min, v)))}
374
+ />
375
+ </div>
376
+ </Row>
377
+ )}
378
+ </ModuleSection>
379
+ );
380
+ }
381
+
382
+ export function EmissionModule({binding}: ModuleProps) {
383
+ const system = binding.system;
384
+ const bursts = system.emissionBursts ?? [];
385
+ return (
386
+ <ModuleSection title="Emission">
387
+ <ValueField
388
+ label="Rate over time"
389
+ generator={system.emissionOverTime}
390
+ min={0}
391
+ curveMax={500}
392
+ onChange={(g) => binding.apply((s) => (s.emissionOverTime = g))}
393
+ />
394
+ <ValueField
395
+ label="Rate over distance"
396
+ generator={system.emissionOverDistance}
397
+ min={0}
398
+ curveMax={50}
399
+ onChange={(g) => binding.apply((s) => (s.emissionOverDistance = g))}
400
+ />
401
+ {bursts.map((burst, i) => (
402
+ <div key={i} style={{marginTop: 8, padding: '6px 8px', border: '1px solid #22305c', borderRadius: 8}}>
403
+ <div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
404
+ <span style={{fontSize: 11.5, color: '#9eb9ff'}}>Burst {i + 1}</span>
405
+ <button
406
+ className="qe-hover"
407
+ style={{background: 'none', border: 'none', color: '#e08c8c', cursor: 'pointer', fontSize: 13}}
408
+ title="Remove burst"
409
+ onClick={() => binding.apply((s) => s.emissionBursts.splice(i, 1))}
410
+ >
411
+ ✕
412
+ </button>
413
+ </div>
414
+ <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 4}}>
415
+ <label style={{fontSize: 10.5, color: '#b7c6ea'}}>
416
+ Time
417
+ <NumberField value={burst.time} min={0} onChange={(time) => binding.apply(() => (burst.time = time))} />
418
+ </label>
419
+ <label style={{fontSize: 10.5, color: '#b7c6ea'}}>
420
+ Count
421
+ <NumberField
422
+ value={readScalar(burst.count as never).value}
423
+ min={0}
424
+ step={1}
425
+ onChange={(count) => binding.apply(() => (burst.count = new ConstantValue(Math.round(count))))}
426
+ />
427
+ </label>
428
+ <label style={{fontSize: 10.5, color: '#b7c6ea'}}>
429
+ Cycles
430
+ <NumberField
431
+ value={burst.cycle}
432
+ min={1}
433
+ step={1}
434
+ onChange={(v) => binding.apply(() => (burst.cycle = Math.round(v)))}
435
+ />
436
+ </label>
437
+ <label style={{fontSize: 10.5, color: '#b7c6ea'}}>
438
+ Interval
439
+ <NumberField value={burst.interval} min={0.01} onChange={(v) => binding.apply(() => (burst.interval = v))} />
440
+ </label>
441
+ <label style={{fontSize: 10.5, color: '#b7c6ea'}}>
442
+ Probability
443
+ <NumberField
444
+ value={burst.probability}
445
+ min={0}
446
+ step={0.05}
447
+ onChange={(v) => binding.apply(() => (burst.probability = Math.min(1, v)))}
448
+ />
449
+ </label>
450
+ </div>
451
+ </div>
452
+ ))}
453
+ <button
454
+ className="qe-hover"
455
+ style={{marginTop: 8, background: 'none', border: '1px dashed #34477f', color: '#9eb9ff', borderRadius: 8, padding: '5px 10px', cursor: 'pointer', fontSize: 12.5}}
456
+ onClick={() =>
457
+ binding.apply((s) =>
458
+ s.emissionBursts.push({time: 0, count: new ConstantValue(20), cycle: 1, interval: 0.01, probability: 1})
459
+ )
460
+ }
461
+ >
462
+ + Add burst
463
+ </button>
464
+ </ModuleSection>
465
+ );
466
+ }
467
+
468
+ // Shapes whose constructor doesn't accept `mode`/`spread` at all — showing those controls
469
+ // for them would edit a value the shape silently ignores, just like the old "default to
470
+ // first texture" bug: a control that looks live but does nothing.
471
+ const SHAPES_WITHOUT_MODE_SPREAD = new Set<ShapeType>(['point', 'grid', 'mesh_surface']);
472
+
473
+ export function ShapeModule({binding}: ModuleProps) {
474
+ const system = binding.system;
475
+ const type = getShapeType(system.emitterShape);
476
+ const params = {...DEFAULT_SHAPE_PARAMS, ...readShapeParams(system.emitterShape)};
477
+ const keys = SHAPE_PARAM_KEYS[type];
478
+ const scene = system.emitter.getScene();
479
+ // 'vfxBatch' is the renderer's own internal instancing mesh — never a valid emission source.
480
+ const meshOptions = scene.meshes.filter(
481
+ (m): m is Mesh => m instanceof Mesh && m.name !== 'vfxBatch' && m.getTotalVertices() > 0
482
+ );
483
+ return (
484
+ <ModuleSection title="Shape">
485
+ <Row label="Shape">
486
+ <SelectField
487
+ value={type}
488
+ options={SHAPE_TYPES}
489
+ onChange={(next) => binding.apply((s) => (s.emitterShape = createShape(next, params)))}
490
+ />
491
+ </Row>
492
+ {keys.map((key) => (
493
+ <Row key={key} label={PARAM_LABELS[key]}>
494
+ <NumberField
495
+ value={params[key] as number}
496
+ min={0}
497
+ onChange={(v) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, [key]: v})))}
498
+ />
499
+ </Row>
500
+ ))}
501
+ {type === 'mesh_surface' && (
502
+ <Row label="Source mesh">
503
+ <SelectField
504
+ value={params.mesh?.uniqueId != null ? String(params.mesh.uniqueId) : '__none'}
505
+ options={[
506
+ {value: '__none', label: '(none)'},
507
+ ...meshOptions.map((m) => ({value: String(m.uniqueId), label: m.name})),
508
+ ]}
509
+ onChange={(id) => {
510
+ const mesh = id === '__none' ? undefined : meshOptions.find((m) => String(m.uniqueId) === id);
511
+ binding.apply((s) => (s.emitterShape = createShape('mesh_surface', {...params, mesh})));
512
+ }}
513
+ />
514
+ </Row>
515
+ )}
516
+ {!SHAPES_WITHOUT_MODE_SPREAD.has(type) && (
517
+ <>
518
+ <Row label="Emit mode">
519
+ <SelectField
520
+ value={params.mode}
521
+ options={[
522
+ {value: 0, label: 'Random'},
523
+ {value: 1, label: 'Loop'},
524
+ {value: 2, label: 'Ping-pong'},
525
+ {value: 3, label: 'Burst spread'},
526
+ ]}
527
+ onChange={(mode) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, mode})))}
528
+ />
529
+ </Row>
530
+ <Row label="Spread">
531
+ <NumberField
532
+ value={params.spread}
533
+ min={0}
534
+ step={0.05}
535
+ onChange={(spread) => binding.apply((s) => (s.emitterShape = createShape(type, {...params, spread})))}
536
+ />
537
+ </Row>
538
+ </>
539
+ )}
540
+ </ModuleSection>
541
+ );
542
+ }
543
+
544
+ export function SizeOverLifeModule({binding}: ModuleProps) {
545
+ const system = binding.system;
546
+ const behavior = findBehavior<SizeOverLife>(system.behaviors, 'SizeOverLife');
547
+ const curve = behavior ? readScalar(behavior.size as never).curve : ([0.2, 1, 1, 0.4] as [number, number, number, number]);
548
+ return (
549
+ <ModuleSection
550
+ title="Size over Lifetime"
551
+ enabled={!!behavior}
552
+ onToggle={(enabled) =>
553
+ binding.apply((s) => {
554
+ removeBehavior(s, 'SizeOverLife');
555
+ if (enabled) {
556
+ s.addBehavior(new SizeOverLife(buildScalar({mode: 'curve', value: 1, min: 0, max: 1, curve}) as never));
557
+ }
558
+ })
559
+ }
560
+ >
561
+ <div style={{marginTop: 6}}>
562
+ <CurveEditor
563
+ pieces={behavior ? readPieces(behavior.size as never) : [{start: 0, p: curve}]}
564
+ maxValue={2}
565
+ onChange={(pieces) =>
566
+ binding.apply((s) => {
567
+ removeBehavior(s, 'SizeOverLife');
568
+ s.addBehavior(new SizeOverLife(buildCurve(pieces) as never));
569
+ })
570
+ }
571
+ />
572
+ </div>
573
+ </ModuleSection>
574
+ );
575
+ }
576
+
577
+ export function ColorOverLifeModule({binding}: ModuleProps) {
578
+ const system = binding.system;
579
+ const behavior = findBehavior<ColorOverLife>(system.behaviors, 'ColorOverLife');
580
+ const stops: GradientStop[] =
581
+ behavior && behavior.color instanceof Gradient ? readGradientStops(behavior.color) : DEFAULT_GRADIENT_STOPS;
582
+ const write = (nextStops: GradientStop[]) =>
583
+ binding.apply((s) => {
584
+ removeBehavior(s, 'ColorOverLife');
585
+ s.addBehavior(new ColorOverLife(buildGradient(nextStops)));
586
+ });
587
+ return (
588
+ <ModuleSection
589
+ title="Color over Lifetime"
590
+ enabled={!!behavior}
591
+ onToggle={(enabled) =>
592
+ binding.apply((s) => {
593
+ removeBehavior(s, 'ColorOverLife');
594
+ if (enabled) {
595
+ s.addBehavior(new ColorOverLife(buildGradient(stops)));
596
+ }
597
+ })
598
+ }
599
+ >
600
+ <div style={{marginTop: 6}}>
601
+ <GradientEditor stops={stops} onChange={write} />
602
+ </div>
603
+ </ModuleSection>
604
+ );
605
+ }
606
+
607
+ export interface TextureOption {
608
+ label: string;
609
+ url: string;
610
+ }
611
+
612
+ /**
613
+ * A host-supplied mesh for the Mesh render mode (e.g. asset meshes from BabylonJS Editor),
614
+ * analogous to TextureOption. The host provides ready vertex buffers; selecting one copies
615
+ * them into the system's instancing geometry.
616
+ */
617
+ export interface GeometryOption {
618
+ label: string;
619
+ positions: Float32Array | number[];
620
+ indices?: Uint32Array | number[];
621
+ uvs?: Float32Array | number[];
622
+ normals?: Float32Array | number[];
623
+ }
624
+
625
+ /** Vertex buffers a host loader (e.g. a GLB importer) returns for the Mesh render mode. */
626
+ export type GeometryData = Omit<GeometryOption, 'label'>;
627
+
628
+ /**
629
+ * Tiny dependency-free wireframe thumbnail of a mesh geometry. Projects positions with a fixed
630
+ * isometric rotation, scales them to fit, and strokes the triangle edges on a 2D canvas — enough
631
+ * for the user to recognise which mesh they picked without spinning up a second Babylon scene.
632
+ */
633
+ function MeshPreview({positions, indices, size = 96}: {positions: Float32Array; indices?: ArrayLike<number>; size?: number}) {
634
+ const canvasRef = React.useRef<HTMLCanvasElement>(null);
635
+ React.useEffect(() => {
636
+ const canvas = canvasRef.current;
637
+ if (!canvas || positions.length < 9) {
638
+ return;
639
+ }
640
+ const ctx = canvas.getContext('2d');
641
+ if (!ctx) {
642
+ return;
643
+ }
644
+ const n = positions.length / 3;
645
+ // Fixed iso rotation (yaw ~35°, pitch ~30°) so depth reads clearly.
646
+ const cy = Math.cos(0.6), sy = Math.sin(0.6), cx = Math.cos(0.52), sx = Math.sin(0.52);
647
+ const pts: Array<[number, number]> = [];
648
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
649
+ for (let i = 0; i < n; i++) {
650
+ const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2];
651
+ const rx = x * cy - z * sy;
652
+ const rz = x * sy + z * cy;
653
+ const ry = y * cx - rz * sx;
654
+ pts.push([rx, ry]);
655
+ if (rx < minX) minX = rx;
656
+ if (rx > maxX) maxX = rx;
657
+ if (ry < minY) minY = ry;
658
+ if (ry > maxY) maxY = ry;
659
+ }
660
+ const pad = 8;
661
+ const span = Math.max(maxX - minX, maxY - minY, 1e-6);
662
+ const scale = (size - pad * 2) / span;
663
+ const ox = pad + (size - pad * 2 - (maxX - minX) * scale) / 2;
664
+ const oy = pad + (size - pad * 2 - (maxY - minY) * scale) / 2;
665
+ const px = (i: number) => ox + (pts[i][0] - minX) * scale;
666
+ // Flip Y so +Y points up on screen.
667
+ const py = (i: number) => size - (oy + (pts[i][1] - minY) * scale);
668
+
669
+ ctx.clearRect(0, 0, size, size);
670
+ ctx.strokeStyle = 'rgba(158,185,255,0.85)';
671
+ ctx.lineWidth = 1;
672
+ const edge = (a: number, b: number) => {
673
+ ctx.beginPath();
674
+ ctx.moveTo(px(a), py(a));
675
+ ctx.lineTo(px(b), py(b));
676
+ ctx.stroke();
677
+ };
678
+ const tris = indices && indices.length ? indices.length : n;
679
+ for (let i = 0; i + 2 < tris; i += 3) {
680
+ const a = indices && indices.length ? indices[i] : i;
681
+ const b = indices && indices.length ? indices[i + 1] : i + 1;
682
+ const c = indices && indices.length ? indices[i + 2] : i + 2;
683
+ edge(a, b);
684
+ edge(b, c);
685
+ edge(c, a);
686
+ }
687
+ }, [positions, indices, size]);
688
+ return (
689
+ <canvas
690
+ ref={canvasRef}
691
+ width={size}
692
+ height={size}
693
+ style={{width: size, height: size, border: '1px solid #2b3761', borderRadius: 6, background: '#0c1330'}}
694
+ />
695
+ );
696
+ }
697
+
698
+ export function RendererModule({
699
+ binding,
700
+ textureOptions,
701
+ geometryOptions,
702
+ resolveTexture,
703
+ resolveGeometry,
704
+ }: ModuleProps & {
705
+ textureOptions?: TextureOption[];
706
+ geometryOptions?: GeometryOption[];
707
+ resolveTexture?: (url: string) => unknown;
708
+ resolveGeometry?: (file: File) => Promise<GeometryData>;
709
+ }) {
710
+ const system = binding.system;
711
+ const currentTextureUrl = (system.texture as {url?: string} | null)?.url ?? '';
712
+ const [promptingTextureUrl, setPromptingTextureUrl] = useState(false);
713
+ return (
714
+ <ModuleSection title="Renderer">
715
+ <Row label="Render mode">
716
+ <SelectField
717
+ value={system.renderMode}
718
+ options={[
719
+ {value: RenderMode.BillBoard, label: 'Billboard'},
720
+ {value: RenderMode.StretchedBillBoard, label: 'Stretched billboard'},
721
+ {value: RenderMode.HorizontalBillBoard, label: 'Horizontal billboard'},
722
+ {value: RenderMode.VerticalBillBoard, label: 'Vertical billboard'},
723
+ {value: RenderMode.Trail, label: 'Trail'},
724
+ {value: RenderMode.Mesh, label: 'Mesh'},
725
+ ]}
726
+ onChange={(mode) => binding.apply((s) => (s.renderMode = mode))}
727
+ />
728
+ </Row>
729
+ {system.renderMode === RenderMode.Trail && (
730
+ <>
731
+ <Row label="Trail length">
732
+ <NumberField
733
+ value={readScalar((system.rendererEmitterSettings as {startLength?: never}).startLength).value}
734
+ min={1}
735
+ step={1}
736
+ onChange={(v) =>
737
+ binding.apply((s) => {
738
+ (s.rendererEmitterSettings as {startLength?: unknown}).startLength = new ConstantValue(Math.round(v));
739
+ })
740
+ }
741
+ />
742
+ </Row>
743
+ <Row label="Follow origin">
744
+ <CheckboxField
745
+ value={!!(system.rendererEmitterSettings as {followLocalOrigin?: boolean}).followLocalOrigin}
746
+ onChange={(v) =>
747
+ binding.apply((s) => {
748
+ (s.rendererEmitterSettings as {followLocalOrigin?: boolean}).followLocalOrigin = v;
749
+ })
750
+ }
751
+ />
752
+ </Row>
753
+ </>
754
+ )}
755
+ {system.renderMode === RenderMode.StretchedBillBoard && (
756
+ <>
757
+ <Row label="Speed factor">
758
+ <NumberField
759
+ value={(system.rendererEmitterSettings as {speedFactor?: number}).speedFactor ?? 0}
760
+ step={0.1}
761
+ onChange={(v) => binding.apply((s) => ((s.rendererEmitterSettings as {speedFactor?: number}).speedFactor = v))}
762
+ />
763
+ </Row>
764
+ <Row label="Length factor">
765
+ <NumberField
766
+ value={(system.rendererEmitterSettings as {lengthFactor?: number}).lengthFactor ?? 2}
767
+ step={0.1}
768
+ onChange={(v) => binding.apply((s) => ((s.rendererEmitterSettings as {lengthFactor?: number}).lengthFactor = v))}
769
+ />
770
+ </Row>
771
+ </>
772
+ )}
773
+ {system.renderMode === RenderMode.Mesh &&
774
+ (() => {
775
+ const preset = detectGeometryPreset(system.instancingGeometry);
776
+ const vertexCount = system.instancingGeometry.length / 3;
777
+ const triangleCount = (system.getRendererSettings().instancingIndices?.length ?? 0) / 3;
778
+ const hostGeometry = geometryOptions ?? [];
779
+ const applyGeometry = (g: GeometryData) =>
780
+ binding.apply((s) => {
781
+ const settings = s.getRendererSettings();
782
+ settings.instancingIndices = (g.indices ? new Uint32Array(g.indices) : undefined) as never;
783
+ settings.instancingUVs = (g.uvs ? new Float32Array(g.uvs) : undefined) as never;
784
+ settings.instancingNormals = (g.normals ? new Float32Array(g.normals) : undefined) as never;
785
+ s.instancingGeometry = new Float32Array(g.positions);
786
+ });
787
+ return (
788
+ <Row label="Geometry">
789
+ <div style={{display: 'flex', flexDirection: 'column', gap: 6}}>
790
+ <SelectField<string>
791
+ value={preset}
792
+ options={[
793
+ ...(preset === 'custom' ? [{value: 'custom', label: 'Custom (imported)'}] : []),
794
+ {value: 'quad', label: 'Quad'},
795
+ {value: 'cube', label: 'Cube'},
796
+ ...hostGeometry.map((g, i) => ({value: `host:${i}`, label: g.label})),
797
+ ...(resolveGeometry ? [{value: '__file', label: 'Load from file…'}] : []),
798
+ ]}
799
+ onChange={(next) => {
800
+ if (next === 'custom') {
801
+ return;
802
+ }
803
+ if (next === '__file') {
804
+ const input = document.createElement('input');
805
+ input.type = 'file';
806
+ input.accept = '.glb,.gltf,model/gltf-binary,model/gltf+json';
807
+ input.onchange = () => {
808
+ const file = input.files?.[0];
809
+ if (file) {
810
+ resolveGeometry!(file).then(applyGeometry);
811
+ }
812
+ };
813
+ input.click();
814
+ return;
815
+ }
816
+ if (next.startsWith('host:')) {
817
+ applyGeometry(hostGeometry[Number(next.slice(5))]);
818
+ return;
819
+ }
820
+ binding.apply((s) => {
821
+ const settings = s.getRendererSettings();
822
+ if (next === 'cube') {
823
+ settings.instancingIndices = MESH_CUBE_INDICES;
824
+ settings.instancingUVs = undefined;
825
+ settings.instancingNormals = undefined;
826
+ s.instancingGeometry = MESH_CUBE_POSITIONS;
827
+ } else {
828
+ settings.instancingIndices = MESH_QUAD_INDICES;
829
+ settings.instancingUVs = MESH_QUAD_UVS;
830
+ s.instancingGeometry = MESH_QUAD_POSITIONS;
831
+ }
832
+ });
833
+ }}
834
+ />
835
+ <MeshPreview
836
+ positions={system.instancingGeometry}
837
+ indices={system.getRendererSettings().instancingIndices}
838
+ />
839
+ {preset === 'custom' && (
840
+ <span style={{fontSize: 10.5, color: '#9eb9ff'}}>
841
+ {vertexCount} verts / {triangleCount} tris — picking a preset replaces this mesh
842
+ </span>
843
+ )}
844
+ </div>
845
+ </Row>
846
+ );
847
+ })()}
848
+ <div
849
+ style={{
850
+ marginTop: 10,
851
+ marginBottom: 2,
852
+ fontSize: 11,
853
+ fontWeight: 600,
854
+ color: '#7c8db5',
855
+ textTransform: 'uppercase',
856
+ letterSpacing: 0.4,
857
+ }}
858
+ >
859
+ Material
860
+ </div>
861
+ {system.material != null && (
862
+ <div style={{fontSize: 10.5, color: '#9eb9ff', marginBottom: 2}}>
863
+ Imported material: {(system.material as {name?: string}).name ?? 'unnamed'} — settings below were derived from it
864
+ </div>
865
+ )}
866
+ <Row label="Blend mode">
867
+ <SelectField
868
+ value={system.blending}
869
+ options={[
870
+ {value: 1, label: 'Additive'},
871
+ {value: 2, label: 'Alpha blend'},
872
+ {value: 3, label: 'Subtract'},
873
+ {value: 4, label: 'Multiply'},
874
+ ]}
875
+ onChange={(blend) => binding.apply((s) => (s.blending = blend))}
876
+ />
877
+ </Row>
878
+ <Row label="Transparent">
879
+ <CheckboxField
880
+ value={!!system.getRendererSettings().materialTransparent}
881
+ onChange={(v) =>
882
+ binding.apply((s) => {
883
+ s.getRendererSettings().materialTransparent = v;
884
+ s.neededToUpdateRender = true;
885
+ })
886
+ }
887
+ />
888
+ </Row>
889
+ {resolveTexture && (
890
+ <Row label="Texture">
891
+ <SelectField
892
+ value={
893
+ !currentTextureUrl
894
+ ? '__none'
895
+ : (textureOptions ?? []).some((o) => o.url === currentTextureUrl)
896
+ ? currentTextureUrl
897
+ : '__current'
898
+ }
899
+ options={[
900
+ {value: '__none', label: '(none)'},
901
+ ...(currentTextureUrl && !(textureOptions ?? []).some((o) => o.url === currentTextureUrl)
902
+ ? [{value: '__current', label: `(current) ${currentTextureUrl.slice(-24)}`}]
903
+ : []),
904
+ ...(textureOptions ?? []).map((o) => ({value: o.url, label: o.label})),
905
+ {value: '__url', label: 'Custom URL…'},
906
+ {value: '__file', label: 'Load from file…'},
907
+ ]}
908
+ onChange={(url) => {
909
+ if (url === '__current') {
910
+ return;
911
+ }
912
+ if (url === '__none') {
913
+ binding.apply((s) => (s.texture = null as never));
914
+ return;
915
+ }
916
+ if (url === '__url') {
917
+ setPromptingTextureUrl(true);
918
+ return;
919
+ }
920
+ if (url === '__file') {
921
+ const input = document.createElement('input');
922
+ input.type = 'file';
923
+ input.accept = 'image/*';
924
+ input.onchange = () => {
925
+ const file = input.files?.[0];
926
+ if (!file) {
927
+ return;
928
+ }
929
+ // Read as a data URI so the texture survives Export/Save/reimport;
930
+ // an object URL (blob:) would be dead after a reload.
931
+ const reader = new FileReader();
932
+ reader.onload = () => {
933
+ binding.apply((s) => (s.texture = resolveTexture(reader.result as string) as never));
934
+ };
935
+ reader.readAsDataURL(file);
936
+ };
937
+ input.click();
938
+ return;
939
+ }
940
+ binding.apply((s) => (s.texture = resolveTexture(url) as never));
941
+ }}
942
+ />
943
+ {promptingTextureUrl && (
944
+ <PromptDialog
945
+ title="Texture URL"
946
+ defaultValue={currentTextureUrl}
947
+ placeholder="https://…"
948
+ onSubmit={(url) => {
949
+ binding.apply((s) => (s.texture = resolveTexture(url) as never));
950
+ setPromptingTextureUrl(false);
951
+ }}
952
+ onCancel={() => setPromptingTextureUrl(false)}
953
+ />
954
+ )}
955
+ {currentTextureUrl && (
956
+ <img
957
+ src={currentTextureUrl}
958
+ alt="texture preview"
959
+ style={{
960
+ width: 64,
961
+ height: 64,
962
+ marginTop: 6,
963
+ objectFit: 'contain',
964
+ borderRadius: 6,
965
+ border: '1px solid #2b3761',
966
+ // Checkerboard so texture transparency is visible.
967
+ background:
968
+ 'repeating-conic-gradient(#2a3252 0% 25%, #171d33 0% 50%) 0 0 / 12px 12px',
969
+ }}
970
+ />
971
+ )}
972
+ </Row>
973
+ )}
974
+ <Row label="Render order">
975
+ <NumberField value={system.renderOrder} step={1} onChange={(v) => binding.apply((s) => (s.renderOrder = Math.round(v)))} />
976
+ </Row>
977
+ <Row label="Soft particles">
978
+ <CheckboxField value={system.softParticles} onChange={(v) => binding.apply((s) => (s.softParticles = v))} />
979
+ </Row>
980
+ {system.softParticles && (
981
+ <Row label="Fade near/far">
982
+ <div style={{display: 'flex', gap: 6}}>
983
+ <NumberField value={system.softNearFade} min={0} onChange={(v) => binding.apply((s) => (s.softNearFade = v))} />
984
+ <NumberField value={system.softFarFade} min={0} onChange={(v) => binding.apply((s) => (s.softFarFade = v))} />
985
+ </div>
986
+ </Row>
987
+ )}
988
+ <Row label="Alpha test">
989
+ <NumberField
990
+ value={system.getRendererSettings().materialAlphaTest ?? 0}
991
+ min={0}
992
+ step={0.05}
993
+ onChange={(v) =>
994
+ binding.apply((s) => {
995
+ s.getRendererSettings().materialAlphaTest = Math.min(1, v);
996
+ s.neededToUpdateRender = true;
997
+ })
998
+ }
999
+ />
1000
+ </Row>
1001
+ <Row label="Depth write">
1002
+ <CheckboxField
1003
+ value={system.getRendererSettings().materialDepthWrite}
1004
+ onChange={(v) =>
1005
+ binding.apply((s) => {
1006
+ s.getRendererSettings().materialDepthWrite = v;
1007
+ s.neededToUpdateRender = true;
1008
+ })
1009
+ }
1010
+ />
1011
+ </Row>
1012
+ <Row label="Depth test">
1013
+ <CheckboxField
1014
+ value={system.getRendererSettings().materialDepthTest}
1015
+ onChange={(v) =>
1016
+ binding.apply((s) => {
1017
+ s.getRendererSettings().materialDepthTest = v;
1018
+ s.neededToUpdateRender = true;
1019
+ })
1020
+ }
1021
+ />
1022
+ </Row>
1023
+ </ModuleSection>
1024
+ );
1025
+ }
1026
+
1027
+ function removeBehavior(system: ParticleSystem, type: string): void {
1028
+ const behaviors = system.behaviors as Behavior[];
1029
+ for (let i = behaviors.length - 1; i >= 0; i--) {
1030
+ if ((behaviors[i] as {type?: string}).type === type) {
1031
+ behaviors.splice(i, 1);
1032
+ }
1033
+ }
1034
+ }