lecodes-cli 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +677 -230
  3. package/package.json +2 -2
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetMacro.ts +45 -0
  7. package/runtime/sdk/compile/bundler.ts +239 -0
  8. package/runtime/sdk/compile/compileProject.ts +104 -0
  9. package/runtime/sdk/compile/detectEntry.ts +125 -0
  10. package/runtime/sdk/compile/fontMacro.ts +459 -0
  11. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  12. package/runtime/sdk/compile/header.ts +51 -0
  13. package/runtime/sdk/compile/index.ts +38 -0
  14. package/runtime/sdk/compile/libraryImports.ts +52 -0
  15. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  16. package/runtime/sdk/compile/sfnt.ts +98 -0
  17. package/runtime/sdk/compile/sourcemap.ts +25 -0
  18. package/runtime/sdk/gl/Material.ts +52 -0
  19. package/runtime/sdk/gl/Particles.ts +477 -116
  20. package/runtime/sdk/inject.ts +1 -1
  21. package/runtime/sdk/ui/NativeView.ts +5 -5
  22. package/runtime/sdk/ui/UIButton.ts +5 -5
  23. package/runtime/sdk/ui/UIContainer.ts +13 -13
  24. package/runtime/sdk/ui/UIImage.ts +5 -5
  25. package/runtime/sdk/ui/UIInput.ts +9 -9
  26. package/runtime/sdk/ui/UINode.ts +114 -34
  27. package/runtime/sdk/ui/UIPager.ts +5 -9
  28. package/runtime/sdk/ui/UIScreen.ts +5 -5
  29. package/runtime/sdk/ui/UIScrollable.ts +5 -5
  30. package/runtime/sdk/ui/UISpacer.ts +5 -5
  31. package/runtime/sdk/ui/UIText.ts +5 -5
  32. package/runtime/sdk/ui/UIVideo.ts +5 -5
  33. package/runtime/sdk/ui/UIVirtualizedList.ts +5 -5
  34. package/runtime/sdk/ui/UIWidget.ts +5 -5
  35. package/runtime/sdk-types.json +1 -1
@@ -1,129 +1,450 @@
1
- // GPU particle system, as a Node. Created with a material; properties drive the native emitter.
2
- // Ported from worker/src/components/Particles.ts — folded into the node, so (unlike the worker
3
- // component) the system exists at construction and setters apply immediately.
1
+ // GPU particle system, as a Node. Draws with the default point-sprite material (Material.particles)
2
+ // unless a custom one is passed; properties drive the native emitter.
3
+ //
4
+ // All emitter/curve config crosses the bridge as ONE Float32Array of [tag, payloadLen, ...payload]
5
+ // records (_creator.setParticleSystemConfig) — the constructor batches every option into a single
6
+ // call, live setters send a one-record buffer. The tag values mirror PsTag in
7
+ // creator-gl/src/particles.h (guarded by tests/particles-tags.test.ts).
8
+ //
9
+ // Curves over a particle's lifetime are built with curve()/colorCurve() chains — see the builders
10
+ // below. A builder's whole state is the flat `_data` array (the exact record payload), so the
11
+ // chisel compiler can constant-fold literal chains into the buffer at compile time.
4
12
 
13
+ import { Color, type ColorInput } from "../core/color"
5
14
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
6
- import { Material } from "./Material"
15
+ import { Material, _sheetColsRows, type ParticlesMaterialOptions } from "./Material"
7
16
  import { Node } from "./Node"
8
17
 
9
18
  type Range<T> = T | { min: T, max: T }
10
19
 
20
+ // --- config record tags (keep in sync with PsTag in creator-gl/src/particles.h) -----------------
21
+
22
+ const TAG_SHAPE = 1
23
+ const TAG_LIFETIME = 2
24
+ const TAG_RATE = 3
25
+ const TAG_VELOCITY = 4
26
+ const TAG_VELOCITY_RND = 5
27
+ const TAG_ACCELERATION = 6
28
+ const TAG_DRAG = 7
29
+ const TAG_NOISE = 8
30
+ const TAG_SEED = 9
31
+ const TAG_PARAM_CURVE = 10
32
+ const TAG_COLOR_CURVE = 11
33
+ const TAG_SPACE = 12
34
+ const TAG_INHERIT_VELOCITY = 13
35
+ const TAG_RATE_DISTANCE = 14
36
+ const TAG_RENDER_MODE = 15
37
+
38
+ /** @internal — exported for the tag-sync test only. */
39
+ export const _particleTags = {
40
+ SHAPE: TAG_SHAPE, LIFETIME: TAG_LIFETIME, RATE: TAG_RATE, VELOCITY: TAG_VELOCITY,
41
+ VELOCITY_RND: TAG_VELOCITY_RND, ACCELERATION: TAG_ACCELERATION, DRAG: TAG_DRAG,
42
+ NOISE: TAG_NOISE, SEED: TAG_SEED, PARAM_CURVE: TAG_PARAM_CURVE, COLOR_CURVE: TAG_COLOR_CURVE,
43
+ SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
44
+ RENDER_MODE: TAG_RENDER_MODE,
45
+ }
46
+
47
+ // --- curve builders -----------------------------------------------------------------------------
48
+
49
+ // A value curve reads as a journey over the particle's life:
50
+ // curve(0.3).from(0).via(0.15, 1).via(0.6, 1).to(0) // fade in, hold, fade out — or:
51
+ // curve(0.3).fade(0.15, 0.4)
52
+ // The curve multiplies the base value (or adds to it with mode 'add'); the base and any stop can be
53
+ // a { min, max } range, randomized per particle. With no `.from` the curve starts at the identity
54
+ // (1 for multiply, 0 for add); with no `.to` it holds its last value to end of life.
55
+ //
56
+ // chisel constant-folds a literal chain into its `_data` buffer at compile time and validates it
57
+ // (bad stop order, >8 stops, unparsable color → a build diagnostic): chisel-core/src/curve.rs mirrors
58
+ // these method bodies op-for-op, against a verbatim copy of them in chisel's fixtures/curve-sdk/. Keep
59
+ // the two in step — a change here means a change there (chisel's scripts/curve-exact.mjs compares the
60
+ // fused payload against the real builder, buffer for buffer).
61
+
62
+ const lohi = (v: Range<number>): [number, number] =>
63
+ typeof v === "number" ? [ v, v ] : [ v.min, v.max ]
64
+
65
+ export class CurveBuilder {
66
+ private _kind: 0 | 1
67
+ private _lo: number
68
+ private _hi: number
69
+ private _stops: number[] = [] // flat (t, lo, hi)
70
+
71
+ constructor(base: Range<number>, mode: "multiply" | "add") {
72
+ this._kind = mode === "add" ? 1 : 0
73
+ ;[ this._lo, this._hi ] = lohi(base)
74
+ }
75
+
76
+ /** Curve value at birth (t = 0). Must come before any other stop. */
77
+ from(v: Range<number>): this {
78
+ if (this._stops.length > 0) throw new Error("curve: .from() must come first")
79
+ return this.via(0, v)
80
+ }
81
+
82
+ /** Curve value at `t` (0..1 of the particle's life). Stops must be added in ascending t order. */
83
+ via(t: number, v: Range<number>): this {
84
+ const [ lo, hi ] = lohi(v)
85
+ this._stops.push(t, lo, hi)
86
+ return this
87
+ }
88
+
89
+ /** Curve value at death (t = 1). */
90
+ to(v: Range<number>): this {
91
+ return this.via(1, v)
92
+ }
93
+
94
+ /** The classic opacity envelope: rise over the first `fadeIn`, fall over the last `fadeOut`. */
95
+ fade(fadeIn = 0.15, fadeOut = fadeIn): this {
96
+ const up = Math.min(Math.max(fadeIn, 0), 1)
97
+ const down = Math.min(Math.max(fadeOut, 0), 1 - up)
98
+ if (up > 0) this.via(0, 0).via(up, 1)
99
+ else this.via(0, 1)
100
+ if (down > 0) {
101
+ if (1 - down > up) this.via(1 - down, 1)
102
+ this.via(1, 0)
103
+ }
104
+ return this
105
+ }
106
+
107
+ /** The PARAM_CURVE record payload (sans slot): [kind, baseLo, baseHi, n, (t, lo, hi)×n]. */
108
+ get _data(): Float32Array {
109
+ const stops = this._stops
110
+ const prepend = stops.length > 0 && stops[0] > 0
111
+ const n = stops.length / 3 + (prepend ? 1 : 0)
112
+ const out = new Float32Array(4 + n * 3)
113
+ out[0] = this._kind
114
+ out[1] = this._lo
115
+ out[2] = this._hi
116
+ out[3] = n
117
+ let o = 4
118
+ if (prepend) {
119
+ const identity = this._kind === 1 ? 0 : 1
120
+ out[o + 1] = identity
121
+ out[o + 2] = identity
122
+ o += 3
123
+ }
124
+ out.set(stops, o)
125
+ return out
126
+ }
127
+ }
128
+
129
+ export class ColorCurveBuilder {
130
+ private _lo: [number, number, number, number]
131
+ private _hi: [number, number, number, number]
132
+ private _stops: number[] = [] // flat (t, lo rgba, hi rgba)
133
+
134
+ constructor(base: Range<ColorInput>) {
135
+ if (isColorRange(base)) {
136
+ this._lo = Color.toRgba01(base.min)
137
+ this._hi = Color.toRgba01(base.max)
138
+ } else {
139
+ this._lo = Color.toRgba01(base)
140
+ this._hi = this._lo
141
+ }
142
+ }
143
+
144
+ /** Curve color at birth (t = 0). Must come first. The curve multiplies the base color. */
145
+ from(c: Range<ColorInput>): this {
146
+ if (this._stops.length > 0) throw new Error("colorCurve: .from() must come first")
147
+ return this.via(0, c)
148
+ }
149
+
150
+ /** Curve color at `t` (0..1 of the particle's life), in ascending t order. */
151
+ via(t: number, c: Range<ColorInput>): this {
152
+ let lo: number[], hi: number[]
153
+ if (isColorRange(c)) {
154
+ lo = Color.toRgba01(c.min)
155
+ hi = Color.toRgba01(c.max)
156
+ } else {
157
+ lo = hi = Color.toRgba01(c)
158
+ }
159
+ this._stops.push(t, ...lo, ...hi)
160
+ return this
161
+ }
162
+
163
+ /** Curve color at death (t = 1). */
164
+ to(c: Range<ColorInput>): this {
165
+ return this.via(1, c)
166
+ }
167
+
168
+ /** The COLOR_CURVE record payload: [baseLo rgba, baseHi rgba, n, (t, lo rgba, hi rgba)×n]. */
169
+ get _data(): Float32Array {
170
+ const stops = this._stops
171
+ const prepend = stops.length > 0 && stops[0] > 0
172
+ const n = stops.length / 9 + (prepend ? 1 : 0)
173
+ const out = new Float32Array(9 + n * 9)
174
+ out.set(this._lo, 0)
175
+ out.set(this._hi, 4)
176
+ out[8] = n
177
+ let o = 9
178
+ if (prepend) {
179
+ for (let i = 1; i < 9; i++) out[o + i] = 1 // identity multiplier: white, t = 0
180
+ o += 9
181
+ }
182
+ out.set(stops, o)
183
+ return out
184
+ }
185
+ }
186
+
187
+ /** A value curve over each particle's lifetime. Default mode `multiply` (the curve scales the
188
+ * base); `add` offsets it instead. */
189
+ export const curve = (base: Range<number> = 1, mode: "multiply" | "add" = "multiply"): CurveBuilder =>
190
+ new CurveBuilder(base, mode)
191
+
192
+ /** A color curve over each particle's lifetime — always multiplies the base color. */
193
+ export const colorCurve = (base: Range<ColorInput> = "#ffffff"): ColorCurveBuilder =>
194
+ new ColorCurveBuilder(base)
195
+
196
+ // --- option types -------------------------------------------------------------------------------
197
+
198
+ export type ParticleValue = Range<number> | CurveBuilder | { _data: Float32Array }
199
+ export type ParticleColor = ColorInput | { min: ColorInput, max: ColorInput } | ColorCurveBuilder | { _data: Float32Array }
200
+
11
201
  type VelocityValue = {
12
202
  speed?: Range<number>
203
+ /** Cone half-angle in radians — jitter the launch direction uniformly within it. */
204
+ spread?: number
205
+ /** Asymmetric jitter: two angles (u, v — the x/y components) in radians. */
13
206
  randomizeAngle?: { min: Vec3Like, max: Vec3Like }
14
207
  } & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})
15
208
 
16
- export type DynamicValue =
17
- | Range<number>
18
- | { value?: Range<number>, inc?: Float32Array, inc_b?: Float32Array, mul?: Float32Array, mul_b?: Float32Array }
19
-
20
- export type DynamicColor =
21
- | Range<string>
22
- | { value?: Range<string>, mul: string, mul_b?: string, t: Float32Array }
23
-
24
- type Shape = { type: "point", v: Vec3Like } | { type: "box", min: Vec3Like, max: Vec3Like }
209
+ type Shape =
210
+ | { type: "point", v: Vec3Like }
211
+ | { type: "box", min: Vec3Like, max: Vec3Like }
212
+ | { type: "circle", center: Vec3Like, radius: number }
25
213
  type Noise = { strength?: number, frequency?: number, speed?: number }
26
214
 
27
- export type ParticlesOptions = {
28
- material: Material
29
- rate?: Range<number>
215
+ export type ParticlesOptions = ParticlesMaterialOptions & {
216
+ /** Custom draw material. Unset = the default point-sprite material, configured by the
217
+ * `ParticlesMaterialOptions` sugar (map/sheet/emissive/blend/soft) — which is ignored
218
+ * when an explicit material is passed. */
219
+ material?: Material
220
+ /** Particle pool capacity (default 1000). */
221
+ maxParticles?: number
222
+ /** Particles emitted per second. */
223
+ rate?: number
224
+ /** Simulation space. `'local'` (default) — particles ride the node's transform; `'world'` —
225
+ * each particle stays where it was born as the emitter moves on (trails: drift smoke, mud,
226
+ * wake). Switching at runtime resets live particles. */
227
+ space?: "local" | "world"
228
+ /** Fraction (usually 0..1) of the emitter's own velocity added to each particle at spawn —
229
+ * smoke "thrown" off a moving car. Takes effect with `space: 'world'`. */
230
+ inheritVelocity?: number
231
+ /** Particles emitted per world unit the emitter MOVES, on top of `rate`, with spawn points
232
+ * spread evenly along the path — trail density independent of speed, no per-frame clumps.
233
+ * Meant for `space: 'world'`. */
234
+ rateOverDistance?: number
30
235
  shape?: Shape
31
236
  startVelocity?: Vec3Like | VelocityValue
237
+ /** Downward acceleration, world units/s² — positive pulls particles DOWN (sugar for
238
+ * `acceleration: [0, -g, 0]`). */
32
239
  gravity?: number
240
+ /** Constant acceleration vector (gravity, wind, …). Wins over `gravity` when both are set. */
241
+ acceleration?: Vec3Like
33
242
  drag?: number
34
243
  lifetime?: Range<number>
35
- color?: DynamicColor
36
- size?: DynamicValue
37
- rotation?: DynamicValue
244
+ color?: ParticleColor
245
+ /** World-unit diameter (alias of custom[0]). Default 1. */
246
+ size?: ParticleValue
247
+ /** Radians (alias of custom[1]). */
248
+ rotation?: ParticleValue
249
+ /** Per-particle opacity 0..1 (alias of custom[2]). Default 1. */
250
+ opacity?: ParticleValue
251
+ /** Flipbook frame index (alias of custom[3]). With `sheet` set and no explicit frame, the
252
+ * default is one full sheet cycle over each particle's lifetime, starting at `startFrame`. */
253
+ frame?: ParticleValue
254
+ /** Where the default flipbook cycle begins: `'random'` (default — desynchronized loops like
255
+ * fire/smoke) or a frame index (`0` plays the sheet in order — explosions, one-shot bursts).
256
+ * Only shapes the default; an explicit `frame` wins. */
257
+ startFrame?: number | "random"
38
258
  noise?: Noise | null
259
+ /** Fixed random seed for deterministic tests. */
260
+ seed?: number
261
+ }
262
+
263
+ // --- record assembly ----------------------------------------------------------------------------
264
+
265
+ const isColorRange = (v: unknown): v is { min: ColorInput, max: ColorInput } =>
266
+ typeof v === "object" && v !== null && !Array.isArray(v) && "min" in v
267
+
268
+ const hasData = (v: unknown): v is { _data: Float32Array } =>
269
+ typeof v === "object" && v !== null && "_data" in v
270
+
271
+ const pushParam = (out: number[], slot: number, value: ParticleValue): void => {
272
+ if (typeof value === "number") {
273
+ out.push(TAG_PARAM_CURVE, 5, slot, 0, value, value, 0)
274
+ } else if (hasData(value)) {
275
+ const d = value._data
276
+ out.push(TAG_PARAM_CURVE, d.length + 1, slot)
277
+ for (let i = 0; i < d.length; i++) out.push(d[i])
278
+ } else {
279
+ out.push(TAG_PARAM_CURVE, 5, slot, 0, value.min, value.max, 0)
280
+ }
39
281
  }
40
282
 
283
+ const pushColor = (out: number[], value: ParticleColor): void => {
284
+ let d: Float32Array
285
+ if (hasData(value)) {
286
+ d = value._data
287
+ } else if (isColorRange(value)) {
288
+ d = new ColorCurveBuilder(value)._data
289
+ } else {
290
+ d = new ColorCurveBuilder(value as ColorInput)._data
291
+ }
292
+ out.push(TAG_COLOR_CURVE, d.length)
293
+ for (let i = 0; i < d.length; i++) out.push(d[i])
294
+ }
295
+
296
+ const pushShape = (out: number[], shape: Shape): void => {
297
+ if (shape.type === "point") out.push(TAG_SHAPE, 4, 0, cx(shape.v), cy(shape.v), cz(shape.v))
298
+ else if (shape.type === "box") out.push(TAG_SHAPE, 7, 1, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
299
+ else out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
300
+ }
301
+
302
+ const pushVelocity = (out: number[], val: Vec3Like | VelocityValue): void => {
303
+ if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
304
+ // a plain vector: launch along it, its length is the speed (native derives it)
305
+ out.push(TAG_VELOCITY, 6, 1, cx(val), cy(val), cz(val), 0, 0)
306
+ out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
307
+ return
308
+ }
309
+ const v = val as VelocityValue
310
+ let mode = 0
311
+ let p: Vec3Like = [ 0, 0, 0 ]
312
+ if ("dir" in v) { mode = 1; p = v.dir }
313
+ else if ("from" in v) { mode = 2; p = v.from }
314
+ else if ("to" in v) { mode = 3; p = v.to }
315
+ let lo = 0, hi = 0
316
+ if (v.speed !== undefined) {
317
+ if (typeof v.speed === "number") lo = hi = v.speed
318
+ else { lo = v.speed.min; hi = v.speed.max }
319
+ }
320
+ out.push(TAG_VELOCITY, 6, mode, cx(p), cy(p), cz(p), lo, hi)
321
+ if (v.spread !== undefined) {
322
+ out.push(TAG_VELOCITY_RND, 5, 1, -v.spread, -v.spread, v.spread, v.spread)
323
+ } else if (v.randomizeAngle) {
324
+ const a = v.randomizeAngle
325
+ out.push(TAG_VELOCITY_RND, 5, 1, cx(a.min), cy(a.min), cx(a.max), cy(a.max))
326
+ } else {
327
+ out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
328
+ }
329
+ }
330
+
331
+ // --- the node -----------------------------------------------------------------------------------
332
+
41
333
  export class Particles extends Node {
42
334
  private _material: Material
43
335
 
44
- /** Custom curve params (slots 0..3); size/rotation alias slots 0/1. */
45
- readonly custom: DynamicValue[]
336
+ /** Custom curve-param slots (0..3); size/rotation/opacity/frame alias slots 0-3. */
337
+ readonly custom: ParticleValue[]
46
338
 
47
- constructor(options: ParticlesOptions) {
339
+ constructor(options: ParticlesOptions = {}) {
48
340
  super()
49
- this._material = options.material
50
- _creator.createParticleSystem(this.id, Material.idOf(options.material))
341
+ this._material = options.material ?? Material.particles(options)
342
+ _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)
51
343
 
52
344
  const id = this.id
53
- this.custom = new Proxy([] as DynamicValue[], {
54
- set(target, prop: string, value: DynamicValue) {
345
+ this.custom = new Proxy([] as ParticleValue[], {
346
+ set(target, prop: string, value: ParticleValue) {
55
347
  const i = parseInt(prop)
56
348
  if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
57
349
  target[i] = value
58
- applyParam(id, i, value)
350
+ const out: number[] = []
351
+ pushParam(out, i, value)
352
+ _creator.setParticleSystemConfig(id, Float32Array.from(out))
59
353
  return true
60
354
  },
61
355
  })
62
356
 
63
- if (options.rate !== undefined) this.rate = options.rate
64
- if (options.shape !== undefined) this.shape = options.shape
65
- if (options.startVelocity !== undefined) this.startVelocity = options.startVelocity
66
- if (options.gravity !== undefined) this.gravity = options.gravity
67
- if (options.drag !== undefined) this.drag = options.drag
68
- if (options.lifetime !== undefined) this.lifetime = options.lifetime
69
- if (options.color !== undefined) this.color = options.color
70
- if (options.size !== undefined) this.custom[0] = options.size
71
- if (options.rotation !== undefined) this.custom[1] = options.rotation
72
- if (options.noise) this.noise = options.noise
357
+ const out: number[] = []
358
+ if (options.render !== undefined && options.render !== "point") {
359
+ out.push(TAG_RENDER_MODE, 1, options.render === "ribbon" ? 2 : 1)
360
+ }
361
+ if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
362
+ if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
363
+ if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
364
+ if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
365
+ if (options.shape !== undefined) pushShape(out, options.shape)
366
+ if (options.startVelocity !== undefined) pushVelocity(out, options.startVelocity)
367
+ if (options.acceleration !== undefined) {
368
+ const a = options.acceleration
369
+ out.push(TAG_ACCELERATION, 3, cx(a), cy(a), cz(a))
370
+ } else if (options.gravity !== undefined) {
371
+ out.push(TAG_ACCELERATION, 3, 0, -options.gravity, 0)
372
+ }
373
+ if (options.drag !== undefined) out.push(TAG_DRAG, 1, options.drag)
374
+ if (options.lifetime !== undefined) {
375
+ const [ lo, hi ] = lohi(options.lifetime)
376
+ out.push(TAG_LIFETIME, 2, lo, hi)
377
+ }
378
+ if (options.color !== undefined) pushColor(out, options.color)
379
+ if (options.size !== undefined) pushParam(out, 0, options.size)
380
+ if (options.rotation !== undefined) pushParam(out, 1, options.rotation)
381
+ if (options.opacity !== undefined) pushParam(out, 2, options.opacity)
382
+ if (options.frame !== undefined) {
383
+ pushParam(out, 3, options.frame)
384
+ } else if (options.material === undefined) {
385
+ const [ cols, rows ] = _sheetColsRows(options.sheet)
386
+ const frames = cols * rows
387
+ if (frames > 1) {
388
+ const start = options.startFrame ?? "random"
389
+ const base = start === "random" ? { min: 0, max: frames - 1 } : start
390
+ pushParam(out, 3, curve(base, "add").from(0).to(frames))
391
+ }
392
+ }
393
+ if (options.noise !== undefined) {
394
+ const n = options.noise
395
+ out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
396
+ }
397
+ if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
398
+
399
+ if (out.length > 0) _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
73
400
  }
74
401
 
75
402
  /** Emit `count` particles immediately (a burst). */
76
403
  spawn(count: number): this { _creator.spawnParticles(this.id, count); return this }
77
404
 
78
- set rate(val: Range<number>) {
79
- if (typeof val === "number") _creator.setParticleSystemRateValue(this.id, val)
405
+ private _send(out: number[]): void {
406
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
80
407
  }
81
408
 
409
+ set rate(val: number) { this._send([ TAG_RATE, 1, val ]) }
410
+
411
+ /** `'world'` leaves particles behind where they were born (trails). Switching resets live ones. */
412
+ set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
413
+ set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
414
+ set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
415
+
82
416
  set shape(shape: Shape) {
83
- if (shape.type === "point") _creator.setParticleSystemShapePoint(this.id, cx(shape.v), cy(shape.v), cz(shape.v))
84
- else _creator.setParticleSystemShapeBox(this.id, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
417
+ const out: number[] = []
418
+ pushShape(out, shape)
419
+ this._send(out)
85
420
  }
86
421
 
87
422
  set startVelocity(val: Vec3Like | VelocityValue) {
88
- if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
89
- _creator.setParticleSystemVelocityValue(this.id, 1, cx(val), cy(val), cz(val))
90
- return
91
- }
92
- const v = val as VelocityValue
93
- if ("from" in v) _creator.setParticleSystemVelocityValue(this.id, 2, cx(v.from), cy(v.from), cz(v.from))
94
- else if ("to" in v) _creator.setParticleSystemVelocityValue(this.id, 3, cx(v.to), cy(v.to), cz(v.to))
95
- else if ("dir" in v) _creator.setParticleSystemVelocityValue(this.id, 1, cx(v.dir), cy(v.dir), cz(v.dir))
96
- else _creator.setParticleSystemVelocityValue(this.id, 0, 0, 0, 0)
97
- if ("speed" in v && v.speed !== undefined) {
98
- if (typeof v.speed === "number") _creator.setParticleSystemVelocitySpeedValue(this.id, v.speed)
99
- else _creator.setParticleSystemVelocitySpeedRange(this.id, v.speed.min, v.speed.max)
100
- }
101
- if (v.randomizeAngle) {
102
- const a = v.randomizeAngle
103
- _creator.setParticleSystemVelocityRndAngle(this.id, cx(a.min), cy(a.min), cz(a.min), cx(a.max), cy(a.max), cz(a.max))
104
- }
423
+ const out: number[] = []
424
+ pushVelocity(out, val)
425
+ this._send(out)
105
426
  }
106
427
 
107
- set gravity(val: number) { _creator.setParticleSystemGravityValue(this.id, val) }
108
- set drag(val: number) { _creator.setParticleSystemDragValue(this.id, val) }
428
+ /** Positive pulls particles down — sugar for `acceleration = [0, -g, 0]`. */
429
+ set gravity(val: number) { this._send([ TAG_ACCELERATION, 3, 0, -val, 0 ]) }
430
+ set acceleration(a: Vec3Like) { this._send([ TAG_ACCELERATION, 3, cx(a), cy(a), cz(a) ]) }
431
+ set drag(val: number) { this._send([ TAG_DRAG, 1, val ]) }
432
+ set seed(val: number) { this._send([ TAG_SEED, 1, val ]) }
109
433
 
110
434
  set lifetime(val: Range<number>) {
111
- if (typeof val === "number") _creator.setParticleSystemLifetimeValue(this.id, val)
112
- else _creator.setParticleSystemLifetimeRange(this.id, val.min, val.max)
435
+ const [ lo, hi ] = lohi(val)
436
+ this._send([ TAG_LIFETIME, 2, lo, hi ])
113
437
  }
114
438
 
115
- set size(val: DynamicValue) { this.custom[0] = val }
116
- set rotation(val: DynamicValue) { this.custom[1] = val }
439
+ set size(val: ParticleValue) { this.custom[0] = val }
440
+ set rotation(val: ParticleValue) { this.custom[1] = val }
441
+ set opacity(val: ParticleValue) { this.custom[2] = val }
442
+ set frame(val: ParticleValue) { this.custom[3] = val }
117
443
 
118
- set color(value: DynamicColor) {
119
- if (typeof value === "string") _creator.setParticleSystemColorValue(this.id, value)
120
- else if ("min" in value) _creator.setParticleSystemColorRange(this.id, value.min, value.max)
121
- else {
122
- if (typeof value.value === "string") _creator.setParticleSystemColorValue(this.id, value.value)
123
- else if (value.value) _creator.setParticleSystemColorRange(this.id, value.value.min, value.value.max)
124
- if (value.mul) _creator.setParticleSystemColorCurveA(this.id, value.mul, value.t)
125
- if (value.mul_b) _creator.setParticleSystemColorCurveB(this.id, value.mul_b, value.t)
126
- }
444
+ set color(value: ParticleColor) {
445
+ const out: number[] = []
446
+ pushColor(out, value)
447
+ this._send(out)
127
448
  }
128
449
 
129
450
  get material(): Material { return this._material }
@@ -133,53 +454,93 @@ export class Particles extends Node {
133
454
  }
134
455
 
135
456
  set noise(noise: Noise | null) {
136
- if (noise) _creator.setParticleSystemNoise(this.id, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1)
137
- else _creator.disableParticleSystemNoise(this.id)
457
+ this._send(noise
458
+ ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
459
+ : [ TAG_NOISE, 3, 0, 0, 0 ])
138
460
  }
139
461
  }
140
462
 
141
- const applyParam = (id: number, i: number, value: DynamicValue): void => {
142
- if (typeof value === "number") {
143
- _creator.setParticleSystemParamValue(id, i, value)
144
- } else if ("min" in value) {
145
- _creator.setParticleSystemParamRange(id, i, value.min, value.max)
146
- } else {
147
- if (typeof value.value === "number") _creator.setParticleSystemParamValue(id, i, value.value)
148
- else if (value.value) _creator.setParticleSystemParamRange(id, i, value.value.min, value.value.max)
149
- if (value.inc) _creator.setParticleSystemParamCurveA(id, i, value.inc, 1)
150
- if (value.inc_b) _creator.setParticleSystemParamCurveB(id, i, value.inc_b)
151
- if (value.mul) _creator.setParticleSystemParamCurveA(id, i, value.mul, 0)
152
- if (value.mul_b) _creator.setParticleSystemParamCurveB(id, i, value.mul_b)
153
- }
154
- }
463
+ // --- ribbon trail -------------------------------------------------------------------------------
155
464
 
156
- // --- curve builders (unchanged from worker) ---
157
-
158
- export const dynamicColor = (baseValue: Range<string>, ...args: (number | string | { min: string, max: string })[]): DynamicColor => {
159
- const t = new Float32Array(args.length / 2)
160
- const mul: string[] = []
161
- let mul_b: string[] | undefined
162
- for (let i = 0; i < args.length / 2; i++) {
163
- if (typeof args[i * 2] !== "number") throw new Error(`dynamicColor: arg ${i * 2} must be a number`)
164
- t[i] = args[i * 2] as number
165
- const val = args[i * 2 + 1]
166
- if (typeof val === "string") { mul[i] = val; if (mul_b) mul_b[i] = val }
167
- else if (typeof val === "number") throw new Error(`dynamicColor: arg ${i * 2 + 1} must be a string`)
168
- else { mul[i] = val.min; if (!mul_b) mul_b = [ ...mul ]; mul_b[i] = val.max }
169
- }
170
- return { value: baseValue, mul: mul.join("\0"), mul_b: mul_b?.join("\0"), t }
465
+ /** `Trail` options. The look options (map/sheet/emissive/blend/soft) come from
466
+ * `ParticlesMaterialOptions`, same as `Particles`. */
467
+ export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch"> & {
468
+ /** Seconds each trail point lives — the trail's length in time. Default 0.5. */
469
+ time?: number
470
+ /** Strip width in world units; a curve tapers it over each point's life (head → tail).
471
+ * Default 0.1. */
472
+ width?: ParticleValue
473
+ /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
474
+ minDistance?: number
475
+ /** Point capacity (default 128). */
476
+ maxPoints?: number
477
+ color?: ParticleColor
478
+ /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
479
+ opacity?: ParticleValue
171
480
  }
172
481
 
173
- export const dynamic = (baseValue: Range<number>, type: "multiply" | "inc", ...args: (number | { min: number, max: number })[]): DynamicValue => {
174
- const inc = new Float32Array(args.length)
175
- let inc_b: Float32Array | undefined
176
- for (let i = 0; i < args.length; i += 2) {
177
- if (typeof args[i] !== "number") throw new Error(`dynamic: arg ${i} must be a number`)
178
- inc[i] = args[i] as number
179
- if (inc_b) inc_b[i] = args[i] as number
180
- const val = args[i + 1]
181
- if (typeof val === "number") { inc[i + 1] = val; if (inc_b) inc_b[i + 1] = val }
182
- else { inc[i + 1] = val.min; if (!inc_b) inc_b = new Float32Array(inc); inc_b[i + 1] = val.max }
183
- }
184
- return type === "inc" ? { value: baseValue, inc, inc_b } : { value: baseValue, mul: inc, mul_b: inc_b }
482
+ /** A ribbon strip that follows this node through the world — sword swings, skid marks, missile
483
+ * trails. Attach it to (or under) the moving node and just move; points are laid down per
484
+ * `minDistance` of movement, live for `time` seconds, and the strip stays glued to the node at
485
+ * its head. Width/opacity/color accept the same curves as `Particles`, evaluated over each
486
+ * point's life — i.e. along the trail from head (fresh) to tail (dying). */
487
+ export class Trail extends Node {
488
+ private _material: Material
489
+ private _rateDistance: number
490
+
491
+ constructor(options: TrailOptions = {}) {
492
+ super()
493
+ this._material = Material.particles({ ...options, render: "ribbon" })
494
+ _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxPoints ?? 128)
495
+
496
+ const time = options.time ?? 0.5
497
+ this._rateDistance = 1 / Math.max(options.minDistance ?? 0.05, 1e-3)
498
+ const out: number[] = [
499
+ TAG_RENDER_MODE, 1, 2,
500
+ TAG_SPACE, 1, 1, // a trail is only meaningful in world space
501
+ TAG_RATE, 1, 0, // points come from movement, never from time
502
+ TAG_RATE_DISTANCE, 1, this._rateDistance,
503
+ TAG_LIFETIME, 2, time, time,
504
+ ]
505
+ pushParam(out, 0, options.width ?? 0.1)
506
+ pushParam(out, 2, options.opacity ?? curve().to(0))
507
+ if (options.color !== undefined) pushColor(out, options.color)
508
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
509
+ }
510
+
511
+ private _send(out: number[]): void {
512
+ _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
513
+ }
514
+
515
+ /** Trail length in seconds — how long each laid-down point lives. */
516
+ set time(val: number) { this._send([ TAG_LIFETIME, 2, val, val ]) }
517
+
518
+ set width(val: ParticleValue) {
519
+ const out: number[] = []
520
+ pushParam(out, 0, val)
521
+ this._send(out)
522
+ }
523
+
524
+ set opacity(val: ParticleValue) {
525
+ const out: number[] = []
526
+ pushParam(out, 2, val)
527
+ this._send(out)
528
+ }
529
+
530
+ set color(value: ParticleColor) {
531
+ const out: number[] = []
532
+ pushColor(out, value)
533
+ this._send(out)
534
+ }
535
+
536
+ set minDistance(val: number) {
537
+ this._rateDistance = 1 / Math.max(val, 1e-3)
538
+ this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
539
+ }
540
+
541
+ /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
542
+ * after e.g. a sword swing ends. */
543
+ set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }
544
+
545
+ get material(): Material { return this._material }
185
546
  }