@field/cascade 0.4.0 → 0.5.2

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 (110) hide show
  1. package/AGENTS.md +18 -0
  2. package/ARCHITECTURE.md +53 -7
  3. package/CHANGELOG.md +68 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/DESIGN.md +9 -6
  6. package/README.md +80 -4
  7. package/dist/assets/{CodeEditor-BNOJIDzL.js → CodeEditor-BHej8kM9.js} +7 -7
  8. package/dist/assets/{CodePanel-2wp2G0Fj.js → CodePanel-CT3eirK8.js} +2 -2
  9. package/dist/assets/{CustomNodeDialog-DNqaVBqA.js → CustomNodeDialog-BG0m7_Rv.js} +1 -1
  10. package/dist/assets/{ProjectSettings-DcdYF2E1.js → ProjectSettings-BxoLWJG6.js} +2 -2
  11. package/dist/assets/{SettingsDialog-D2xVvyiR.js → SettingsDialog-r9PzZ22z.js} +1 -1
  12. package/dist/assets/{VersionHistoryDialog-DW288ykF.js → VersionHistoryDialog-B6zUV6Dy.js} +1 -1
  13. package/dist/assets/{core-B1y38i95.js → core-wwx5881P.js} +2 -2
  14. package/dist/assets/{cssMode-BLucnaln.js → cssMode-DY5T2Or0.js} +1 -1
  15. package/dist/assets/definitions-BgCvRHyR.js +1 -0
  16. package/dist/assets/{editor.api-B3Et3Pph.js → editor.api-mkORHEMm.js} +1 -1
  17. package/dist/assets/{freemarker2-DVlPoano.js → freemarker2-CIWlk0sw.js} +1 -1
  18. package/dist/assets/geo-CIxgs0oE.js +1 -0
  19. package/dist/assets/{handlebars-B5FZBAMR.js → handlebars-t4jbj3Br.js} +1 -1
  20. package/dist/assets/{html-FodD8KbR.js → html-Ctx7UOlY.js} +1 -1
  21. package/dist/assets/{htmlMode-CsG57VCD.js → htmlMode-BKNZxVw7.js} +1 -1
  22. package/dist/assets/{image-B3F-nq25.js → image-BCBFz0gD.js} +3 -3
  23. package/dist/assets/index-CeqTfMbr.js +212 -0
  24. package/dist/assets/{index-Y2ock1cw.css → index-ilgbOu6G.css} +1 -1
  25. package/dist/assets/{javascript-B3gonF_H.js → javascript-Da_TJBzH.js} +1 -1
  26. package/dist/assets/{jsonMode-Y3w1ahZg.js → jsonMode-Ftt5d1nV.js} +1 -1
  27. package/dist/assets/{liquid-CTpWXIYO.js → liquid-D5kWYG57.js} +1 -1
  28. package/dist/assets/{lspLanguageFeatures-CODBdKEZ.js → lspLanguageFeatures-B-93LC_L.js} +1 -1
  29. package/dist/assets/{mdx-CDAQJZIz.js → mdx-DGT3C_qs.js} +1 -1
  30. package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
  31. package/dist/assets/pop-BJGN5Ac4.js +1 -0
  32. package/dist/assets/{python-LOIlxJU-.js → python-Bz3Eew5S.js} +1 -1
  33. package/dist/assets/{razor-CllPQSHo.js → razor-CFDdKpKI.js} +1 -1
  34. package/dist/assets/{tsMode-CO28ut8t.js → tsMode-BaCINWBI.js} +1 -1
  35. package/dist/assets/{typescript-ruU9wKQ_.js → typescript-BoOWspVs.js} +1 -1
  36. package/dist/assets/{workers-Bc1Ywp4-.js → workers-dSWyDxsQ.js} +1 -1
  37. package/dist/assets/{xml-DoXP2Dh1.js → xml-eFi1_vYt.js} +1 -1
  38. package/dist/assets/{yaml-D9YPyGiJ.js → yaml-a7xrJHxm.js} +1 -1
  39. package/dist/cli/index.js +16266 -12653
  40. package/dist/cli/runtime/io.ts +31 -25
  41. package/dist/contracts/definition.d.ts +21 -2
  42. package/dist/contracts/index.d.ts +2 -0
  43. package/dist/contracts/index.js +2 -0
  44. package/dist/contracts/particles.d.ts +54 -0
  45. package/dist/contracts/particles.js +167 -0
  46. package/dist/contracts/scene.d.ts +129 -0
  47. package/dist/contracts/scene.js +73 -0
  48. package/dist/contracts/schema.js +1 -1
  49. package/dist/contracts/validate.js +4 -2
  50. package/dist/contracts/values.d.ts +3 -2
  51. package/dist/contracts/values.js +9 -0
  52. package/dist/index.html +3 -3
  53. package/dist/io.d.ts +4 -16
  54. package/dist/io.js +26 -8
  55. package/dist/player/app.js +810 -0
  56. package/dist/player/embed.d.ts +4 -0
  57. package/dist/player/embed.js +77 -0
  58. package/dist/player/types.d.ts +35 -0
  59. package/dist/runtime/builtins/core/index.d.ts +3 -2
  60. package/dist/runtime/builtins/core/index.js +6 -2
  61. package/dist/runtime/builtins/core/structural.d.ts +168 -0
  62. package/dist/runtime/builtins/core/structural.js +175 -0
  63. package/dist/runtime/builtins/core/time.d.ts +165 -0
  64. package/dist/runtime/builtins/core/time.js +110 -0
  65. package/dist/runtime/builtins/geo/definitions.d.ts +247 -0
  66. package/dist/runtime/builtins/geo/definitions.js +70 -0
  67. package/dist/runtime/builtins/geo/index.d.ts +2 -1
  68. package/dist/runtime/builtins/geo/index.js +3 -1
  69. package/dist/runtime/builtins/geo/render.d.ts +87 -0
  70. package/dist/runtime/builtins/geo/render.js +87 -0
  71. package/dist/runtime/builtins/geo/svgExport.d.ts +11 -0
  72. package/dist/runtime/builtins/geo/svgExport.js +10 -0
  73. package/dist/runtime/builtins/index.d.ts +5 -2
  74. package/dist/runtime/builtins/index.js +13 -3
  75. package/dist/runtime/builtins/pop/definitions.d.ts +2120 -0
  76. package/dist/runtime/builtins/pop/definitions.js +815 -0
  77. package/dist/runtime/builtins/pop/index.d.ts +2 -0
  78. package/dist/runtime/builtins/pop/index.js +2 -0
  79. package/dist/runtime/builtins/scene/definitions.d.ts +356 -0
  80. package/dist/runtime/builtins/scene/definitions.js +224 -0
  81. package/dist/runtime/builtins/scene/index.d.ts +2 -0
  82. package/dist/runtime/builtins/scene/index.js +2 -0
  83. package/dist/runtime/geometry/render.d.ts +109 -0
  84. package/dist/runtime/geometry/render.js +145 -0
  85. package/dist/runtime/geometry/svg.d.ts +29 -0
  86. package/dist/runtime/geometry/svg.js +11 -5
  87. package/dist/runtime/index.d.ts +1 -0
  88. package/dist/runtime/index.js +1 -0
  89. package/dist/runtime/pop/cache.d.ts +27 -0
  90. package/dist/runtime/pop/cache.js +123 -0
  91. package/dist/runtime/pop/forces.d.ts +202 -0
  92. package/dist/runtime/pop/forces.js +519 -0
  93. package/dist/runtime/pop/index.d.ts +5 -0
  94. package/dist/runtime/pop/index.js +5 -0
  95. package/dist/runtime/pop/state.d.ts +136 -0
  96. package/dist/runtime/pop/state.js +283 -0
  97. package/dist/runtime/pop/trail.d.ts +62 -0
  98. package/dist/runtime/pop/trail.js +142 -0
  99. package/dist/runtime/runtime.js +221 -3
  100. package/dist/runtime/scene/index.d.ts +50 -0
  101. package/dist/runtime/scene/index.js +94 -0
  102. package/dist/studio/panel.d.ts +68 -1
  103. package/doc/HEADLESS_GPU.md +4 -4
  104. package/doc/NODE_REFERENCE.md +372 -1
  105. package/doc/PROJECT_AUTHORING.md +16 -4
  106. package/doc/WEB_PLAYER.md +131 -0
  107. package/package.json +16 -2
  108. package/dist/assets/geo-DhyFdBXX.js +0 -1
  109. package/dist/assets/index-CC6YQw2n.js +0 -212
  110. package/dist/assets/nodeTypeUtils-BBA1JqHx.js +0 -1
@@ -0,0 +1,283 @@
1
+ import { POSITION_ATTRIBUTE, checkParticleContract, } from "@field/cascade/contracts";
2
+ import { createAttribute } from "../geometry/attributes.js";
3
+ import { GeometryBuilder } from "../geometry/builder.js";
4
+ /**
5
+ * The state, read through accessors rather than as raw arrays.
6
+ *
7
+ * A class rather than a bag of typed arrays because every operator needs the
8
+ * same four reads and the same bounds discipline, and because `size` — whether
9
+ * this is a 2D or 3D system — has to be answered once rather than inferred by
10
+ * each force from the length of an array it was handed.
11
+ */
12
+ export class ParticleState {
13
+ count;
14
+ size;
15
+ position;
16
+ velocity;
17
+ age;
18
+ life;
19
+ id;
20
+ /**
21
+ * `Cd`, four components, or null for a system with no colour.
22
+ *
23
+ * Null rather than a default white array, because the absence is meaningful:
24
+ * `SvgExport` and every other consumer falls back to its own stroke prop
25
+ * when a primitive carries no `Cd`, and filling one in here would take that
26
+ * choice away from the graph.
27
+ */
28
+ colour;
29
+ nextId;
30
+ /**
31
+ * Internal, and taking the arrays by reference rather than copying.
32
+ *
33
+ * `@internal` rather than `private` because `step` and `select` are module
34
+ * functions rather than methods — they are the arithmetic, and putting them
35
+ * on the class would make a state object that can step itself, which is one
36
+ * short hop from a state object that holds a clock.
37
+ *
38
+ * @internal
39
+ */
40
+ static of(fields) {
41
+ return new ParticleState(fields);
42
+ }
43
+ constructor(fields) {
44
+ this.count = fields.count;
45
+ this.size = fields.size;
46
+ this.position = fields.position;
47
+ this.velocity = fields.velocity;
48
+ this.age = fields.age;
49
+ this.life = fields.life;
50
+ this.id = fields.id;
51
+ this.colour = fields.colour ?? null;
52
+ this.nextId = fields.nextId;
53
+ }
54
+ /** An empty system. `nextId` starts at 0 and only ever increases. */
55
+ static empty(size = 2) {
56
+ return new ParticleState({
57
+ count: 0, size,
58
+ position: new Float64Array(0), velocity: new Float32Array(0),
59
+ age: new Float32Array(0), life: new Float32Array(0), id: new Int32Array(0),
60
+ colour: null,
61
+ nextId: 0,
62
+ });
63
+ }
64
+ /**
65
+ * Read a state out of a geometry.
66
+ *
67
+ * Refuses rather than repairs. `checkParticleContract` returns every
68
+ * violation, and a solver handed a geometry without `id` cannot invent one:
69
+ * inventing ids per frame is precisely the fault the mandatory `id` exists to
70
+ * prevent, because it would silently make every particle new every step.
71
+ */
72
+ static fromGeometry(geometry) {
73
+ const violations = checkParticleContract(geometry);
74
+ if (violations.length > 0) {
75
+ throw new Error(`not a particle geometry: ${violations.map((v) => v.reason).join("; ")}`);
76
+ }
77
+ const position = geometry.point[POSITION_ATTRIBUTE];
78
+ const velocity = geometry.point.v;
79
+ return new ParticleState({
80
+ count: geometry.pointCount,
81
+ size: position.size,
82
+ position: Float64Array.from(position.data),
83
+ velocity: Float32Array.from(velocity.data),
84
+ age: Float32Array.from(geometry.point.age.data),
85
+ life: Float32Array.from(geometry.point.life.data),
86
+ id: Int32Array.from(geometry.point.id.data),
87
+ colour: geometry.point.Cd && geometry.point.Cd.storage !== "string"
88
+ ? Float32Array.from(geometry.point.Cd.data)
89
+ : null,
90
+ nextId: Number(geometry.detail.nextid ?? geometry.pointCount),
91
+ });
92
+ }
93
+ /** Write the state back out as geometry, one point per particle. */
94
+ toGeometry() {
95
+ const builder = new GeometryBuilder({ positionSize: this.size });
96
+ for (let index = 0; index < this.count; index += 1) {
97
+ builder.addPoint(this.position[index * this.size], this.position[index * this.size + 1], this.size === 3 ? this.position[index * this.size + 2] : 0);
98
+ }
99
+ builder.setAttribute("point", "v", { storage: "f32", size: this.size, data: this.velocity });
100
+ builder.setAttribute("point", "age", { storage: "f32", size: 1, data: this.age });
101
+ builder.setAttribute("point", "life", { storage: "f32", size: 1, data: this.life });
102
+ builder.setAttribute("point", "id", { storage: "i32", size: 1, data: this.id });
103
+ if (this.colour) {
104
+ builder.setAttribute("point", "Cd", { storage: "f32", size: 4, data: this.colour });
105
+ }
106
+ builder.setDetail("nextid", this.nextId);
107
+ return builder.build();
108
+ }
109
+ /** Add particles, handing each a fresh id. Returns the new state; the
110
+ * receiver is not mutated, so a source can be applied to a cached frame. */
111
+ born(points) {
112
+ const added = points.life.length;
113
+ if (added === 0)
114
+ return this;
115
+ const count = this.count + added;
116
+ const position = new Float64Array(count * this.size);
117
+ const velocity = new Float32Array(count * this.size);
118
+ const age = new Float32Array(count);
119
+ const life = new Float32Array(count);
120
+ const id = new Int32Array(count);
121
+ const wantsColour = this.colour !== null || points.colour !== undefined;
122
+ const colour = wantsColour ? new Float32Array(count * 4) : null;
123
+ if (colour) {
124
+ // White for anything already present without a colour: a ragged
125
+ // attribute is not representable, and white is what a fallback stroke
126
+ // multiplies to itself.
127
+ colour.fill(1, 0, this.count * 4);
128
+ if (this.colour)
129
+ colour.set(this.colour);
130
+ }
131
+ position.set(this.position);
132
+ velocity.set(this.velocity);
133
+ age.set(this.age);
134
+ life.set(this.life);
135
+ id.set(this.id);
136
+ for (let index = 0; index < added; index += 1) {
137
+ const at = this.count + index;
138
+ for (let component = 0; component < this.size; component += 1) {
139
+ position[at * this.size + component] = points.position[index * this.size + component] ?? 0;
140
+ velocity[at * this.size + component] = points.velocity?.[index * this.size + component] ?? 0;
141
+ }
142
+ life[at] = points.life[index];
143
+ if (colour) {
144
+ for (let channel = 0; channel < 4; channel += 1) {
145
+ colour[at * 4 + channel] = points.colour
146
+ ? Number(points.colour[index * 4 + channel] ?? 1)
147
+ : 1;
148
+ }
149
+ }
150
+ // Monotonic, and never reused: a trail keyed on an id that came back
151
+ // would join two unrelated particles into one stroke.
152
+ id[at] = this.nextId + index;
153
+ }
154
+ return ParticleState.of({
155
+ count, size: this.size, position, velocity, age, life, id, colour,
156
+ nextId: this.nextId + added,
157
+ });
158
+ }
159
+ }
160
+ /**
161
+ * One step. Forces, then integrate, then age, then kill.
162
+ *
163
+ * The order is Houdini's and it matters: a force that read a position already
164
+ * advanced by this step's velocity would be integrating twice. So every force
165
+ * sees the same state, their accelerations sum, and integration happens once.
166
+ */
167
+ export function step(state, forces, options) {
168
+ const { timestep, maxSpeed = 0, wrap } = options;
169
+ const { count, size } = state;
170
+ if (count === 0)
171
+ return state;
172
+ const acceleration = new Float32Array(count * size);
173
+ for (const force of forces) {
174
+ const contribution = force.accelerate(state, size);
175
+ if (contribution.length !== count * size) {
176
+ throw new Error(`force ${force.label} returned ${contribution.length} values for ${count} particles of size ${size}`);
177
+ }
178
+ for (let index = 0; index < acceleration.length; index += 1) {
179
+ acceleration[index] = acceleration[index] + contribution[index];
180
+ }
181
+ }
182
+ const position = Float64Array.from(state.position);
183
+ const velocity = Float32Array.from(state.velocity);
184
+ const age = Float32Array.from(state.age);
185
+ for (let index = 0; index < count; index += 1) {
186
+ // Semi-implicit Euler: velocity first, then position from the NEW velocity.
187
+ // Explicit Euler drifts visibly on a curl field at any timestep a sketch
188
+ // would use, and the cost of the better one is nothing.
189
+ let speedSquared = 0;
190
+ for (let component = 0; component < size; component += 1) {
191
+ const at = index * size + component;
192
+ velocity[at] = velocity[at] + acceleration[at] * timestep;
193
+ speedSquared += velocity[at] * velocity[at];
194
+ }
195
+ if (maxSpeed > 0 && speedSquared > maxSpeed * maxSpeed) {
196
+ const scale = maxSpeed / Math.sqrt(speedSquared);
197
+ for (let component = 0; component < size; component += 1) {
198
+ velocity[index * size + component] = velocity[index * size + component] * scale;
199
+ }
200
+ }
201
+ for (let component = 0; component < size; component += 1) {
202
+ const at = index * size + component;
203
+ position[at] = position[at] + velocity[at] * timestep;
204
+ }
205
+ if (wrap) {
206
+ position[index * size] = wrapInto(position[index * size], wrap[0], wrap[2]);
207
+ position[index * size + 1] = wrapInto(position[index * size + 1], wrap[1], wrap[3]);
208
+ }
209
+ // Seconds, not frames — the one place this departs from the sketch, and
210
+ // the reason the contract states a unit.
211
+ age[index] = age[index] + timestep;
212
+ }
213
+ return kill(ParticleState.of({
214
+ count, size: state.size, position, velocity, age,
215
+ life: Float32Array.from(state.life), id: Int32Array.from(state.id),
216
+ colour: state.colour ? Float32Array.from(state.colour) : null,
217
+ nextId: state.nextId,
218
+ }));
219
+ }
220
+ function wrapInto(value, low, high) {
221
+ const span = high - low;
222
+ if (span <= 0)
223
+ return value;
224
+ return low + (((value - low) % span) + span) % span;
225
+ }
226
+ /**
227
+ * Remove particles whose age has reached their life.
228
+ *
229
+ * Compacts in place order, which reorders the arrays — and is exactly why `id`
230
+ * is mandatory. A `life` of zero or less means immortal, following Houdini,
231
+ * because a source that does not set a life should not birth particles that
232
+ * die on their first step.
233
+ */
234
+ export function kill(state) {
235
+ const keep = [];
236
+ for (let index = 0; index < state.count; index += 1) {
237
+ const life = state.life[index];
238
+ if (life <= 0 || state.age[index] < life)
239
+ keep.push(index);
240
+ }
241
+ if (keep.length === state.count)
242
+ return state;
243
+ return select(state, keep);
244
+ }
245
+ /** A new state holding only these particles, in this order. */
246
+ export function select(state, indices) {
247
+ const count = indices.length;
248
+ const { size } = state;
249
+ const position = new Float64Array(count * size);
250
+ const velocity = new Float32Array(count * size);
251
+ const age = new Float32Array(count);
252
+ const life = new Float32Array(count);
253
+ const id = new Int32Array(count);
254
+ const colour = state.colour ? new Float32Array(count * 4) : null;
255
+ indices.forEach((from, to) => {
256
+ for (let component = 0; component < size; component += 1) {
257
+ position[to * size + component] = state.position[from * size + component];
258
+ velocity[to * size + component] = state.velocity[from * size + component];
259
+ }
260
+ age[to] = state.age[from];
261
+ life[to] = state.life[from];
262
+ id[to] = state.id[from];
263
+ if (colour && state.colour) {
264
+ for (let channel = 0; channel < 4; channel += 1) {
265
+ colour[to * 4 + channel] = state.colour[from * 4 + channel];
266
+ }
267
+ }
268
+ });
269
+ return ParticleState.of({
270
+ count, size, position, velocity, age, life, id, colour, nextId: state.nextId,
271
+ });
272
+ }
273
+ /** A blank particle geometry, for a source to add to. */
274
+ export function emptyParticleGeometry(size = 2) {
275
+ const builder = new GeometryBuilder({ positionSize: size });
276
+ builder.setAttribute("point", "v", createAttribute(0, size, "f32"));
277
+ builder.setAttribute("point", "age", createAttribute(0, 1, "f32"));
278
+ builder.setAttribute("point", "life", createAttribute(0, 1, "f32"));
279
+ builder.setAttribute("point", "id", createAttribute(0, 1, "i32"));
280
+ builder.setDetail("nextid", 0);
281
+ return builder.build();
282
+ }
283
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,62 @@
1
+ import { type Geometry } from "@field/cascade/contracts";
2
+ /**
3
+ * Trails, following Houdini's Trail SOP.
4
+ *
5
+ * Marcus, 2026-09-09: *"particle trails as geometry is neater"*, and then
6
+ * *"Caching just needs to be considered. Houdini has a nice trail node for
7
+ * this."* Both are load-bearing. Trails being geometry rather than a faded
8
+ * canvas is what keeps one feedback loop instead of two and makes the result
9
+ * printable; Houdini's Trail is the precedent for how the history is held.
10
+ *
11
+ * **The window is passed in, not held here.** Houdini's Trail keeps a rolling
12
+ * window of previous frames rather than re-cooking backwards to find them, and
13
+ * that is the shape — but a module-level buffer is exactly the per-cook state
14
+ * `cascade check` refuses, and it is what would make a cook depend on how many
15
+ * times it had run. So this function takes the history and returns geometry,
16
+ * and whoever owns the cache passes it.
17
+ *
18
+ * A trail needs `length` frames of positions, not the whole simulation state,
19
+ * so the window is bounded where a state cache is not — which is why the
20
+ * plan's *one cache mechanism, two clients* holds.
21
+ */
22
+ /** One frame of history: a particle id and where it was. */
23
+ export interface TrailFrame {
24
+ readonly id: Int32Array;
25
+ readonly position: Float64Array;
26
+ readonly size: number;
27
+ }
28
+ export type TrailResult =
29
+ /** The input points, untouched. Houdini's default. */
30
+ "preserve"
31
+ /** One open polyline per particle, oldest to newest. */
32
+ | "polylines"
33
+ /** Velocity derived from successive positions, written to `v`, no lines. */
34
+ | "velocity";
35
+ export interface TrailOptions {
36
+ readonly result?: TrailResult;
37
+ /** Frames of history to use. Houdini's `length`. */
38
+ readonly length?: number;
39
+ /** Use every nth frame. Houdini's `increment`. */
40
+ readonly increment?: number;
41
+ /** Weights the derived velocity. Houdini's `velocityscale`. */
42
+ readonly velocityscale?: number;
43
+ }
44
+ /**
45
+ * Read one frame of history out of a particle geometry.
46
+ *
47
+ * Positions only. A trail does not need velocity, age or life, and copying
48
+ * them would make the window several times larger for nothing — the size of
49
+ * the window is the whole reason this is cheaper than a state cache.
50
+ */
51
+ export declare function trailFrame(geometry: Geometry): TrailFrame;
52
+ /**
53
+ * Build trails from a history, oldest frame first.
54
+ *
55
+ * **Joined on `id`, never on index.** A particle array reorders on every kill,
56
+ * so index 3 in one frame is not index 3 in the next — joining by position in
57
+ * the array would draw a stroke from one particle to an unrelated one, which is
58
+ * the exact fault the mandatory `id` exists to prevent and would look like a
59
+ * plausible tangle rather than an error.
60
+ */
61
+ export declare function buildTrails(history: readonly TrailFrame[], current: Geometry, options?: TrailOptions): Geometry;
62
+ //# sourceMappingURL=trail.d.ts.map
@@ -0,0 +1,142 @@
1
+ import { POSITION_ATTRIBUTE } from "@field/cascade/contracts";
2
+ import { GeometryBuilder } from "../geometry/builder.js";
3
+ /**
4
+ * Read one frame of history out of a particle geometry.
5
+ *
6
+ * Positions only. A trail does not need velocity, age or life, and copying
7
+ * them would make the window several times larger for nothing — the size of
8
+ * the window is the whole reason this is cheaper than a state cache.
9
+ */
10
+ export function trailFrame(geometry) {
11
+ const position = geometry.point[POSITION_ATTRIBUTE];
12
+ return {
13
+ id: Int32Array.from((geometry.point.id?.data ?? new Int32Array(0))),
14
+ position: Float64Array.from(position.data),
15
+ size: position.size,
16
+ };
17
+ }
18
+ /**
19
+ * Build trails from a history, oldest frame first.
20
+ *
21
+ * **Joined on `id`, never on index.** A particle array reorders on every kill,
22
+ * so index 3 in one frame is not index 3 in the next — joining by position in
23
+ * the array would draw a stroke from one particle to an unrelated one, which is
24
+ * the exact fault the mandatory `id` exists to prevent and would look like a
25
+ * plausible tangle rather than an error.
26
+ */
27
+ export function buildTrails(history, current, options = {}) {
28
+ const { result = "polylines", length = 12, increment = 1, velocityscale = 1 } = options;
29
+ if (result === "preserve")
30
+ return current;
31
+ const frames = sampled(history, length, increment);
32
+ const size = frames[0]?.size ?? 2;
33
+ // id -> the positions it held, oldest first.
34
+ const paths = new Map();
35
+ for (const frame of frames) {
36
+ for (let index = 0; index < frame.id.length; index += 1) {
37
+ const id = frame.id[index];
38
+ const path = paths.get(id) ?? [];
39
+ path.push(frame.position[index * frame.size], frame.position[index * frame.size + 1]);
40
+ paths.set(id, path);
41
+ }
42
+ }
43
+ if (result === "velocity") {
44
+ return withDerivedVelocity(current, paths, velocityscale);
45
+ }
46
+ const builder = new GeometryBuilder({ positionSize: 2 });
47
+ const trailIds = [];
48
+ for (const [id, path] of paths) {
49
+ // A single position is a point, not a line. Houdini's Trail emits nothing
50
+ // for a particle born this frame, and a one-point polyline would be a
51
+ // primitive that draws as nothing while counting as something.
52
+ if (path.length < 4)
53
+ continue;
54
+ const points = [];
55
+ for (let at = 0; at < path.length; at += 2) {
56
+ points.push(builder.addPoint(path[at], path[at + 1]));
57
+ }
58
+ // `poly`, open. `PRIMITIVE_KINDS` has no separate polyline: an open poly
59
+ // IS one, which is Houdini's model too.
60
+ builder.addPrimitive(points, { closed: false, kind: "poly" });
61
+ trailIds.push(id);
62
+ }
63
+ if (trailIds.length > 0) {
64
+ // Carried to the primitive level so a downstream node can still tell which
65
+ // particle a stroke belongs to — a trail that loses its id is back to
66
+ // being addressed by position in an array.
67
+ builder.setNumericAttribute("primitive", "id", Int32Array.from(trailIds), 1, "i32");
68
+ /**
69
+ * And its colour, looked up by id rather than by index.
70
+ *
71
+ * `SvgExport` reads a primitive's `Cd` and falls back to its own stroke
72
+ * prop without one, so promoting the particle's colour to the stroke is
73
+ * what makes a per-particle colour visible at all. Looked up in `current`
74
+ * by id because a trail's history may include a particle that has since
75
+ * died: that stroke gets white and the export's fallback applies, which is
76
+ * better than a wrong colour and better than dropping the stroke.
77
+ */
78
+ const currentColour = current.point.Cd;
79
+ if (currentColour && currentColour.storage !== "string") {
80
+ const byId = new Map();
81
+ const ids = current.point.id?.data;
82
+ if (ids) {
83
+ for (let index = 0; index < current.pointCount; index += 1) {
84
+ byId.set(Number(ids[index]), index);
85
+ }
86
+ }
87
+ const source = currentColour.data;
88
+ const colours = new Float32Array(trailIds.length * 4);
89
+ colours.fill(1);
90
+ trailIds.forEach((id, primitive) => {
91
+ const at = byId.get(id);
92
+ if (at === undefined)
93
+ return;
94
+ for (let channel = 0; channel < 4; channel += 1) {
95
+ colours[primitive * 4 + channel] = Number(source[at * 4 + channel] ?? 1);
96
+ }
97
+ });
98
+ builder.setNumericAttribute("primitive", "Cd", colours, 4, "f32");
99
+ }
100
+ }
101
+ void size;
102
+ return builder.build();
103
+ }
104
+ /** The frames a trail actually uses: the last `length`, every `increment`th,
105
+ * oldest first. */
106
+ function sampled(history, length, increment) {
107
+ const step = Math.max(1, Math.floor(increment));
108
+ const window = history.slice(Math.max(0, history.length - length * step));
109
+ if (step === 1)
110
+ return window;
111
+ // Counted back from the newest, so the current frame is always included —
112
+ // a trail whose head lags the particle reads as the whole system trailing.
113
+ const picked = [];
114
+ for (let index = window.length - 1; index >= 0; index -= step)
115
+ picked.unshift(window[index]);
116
+ return picked;
117
+ }
118
+ /**
119
+ * Houdini's *Compute Velocity* result: `v` from successive positions.
120
+ *
121
+ * A genuinely separate use of the same history, which is why the plan says the
122
+ * extra modes cost almost nothing once the window exists — and why `Trail` is
123
+ * more than a node that draws lines.
124
+ */
125
+ function withDerivedVelocity(current, paths, velocityscale) {
126
+ const size = current.point[POSITION_ATTRIBUTE].size;
127
+ const ids = current.point.id?.data;
128
+ const velocity = new Float32Array(current.pointCount * size);
129
+ for (let index = 0; index < current.pointCount; index += 1) {
130
+ const path = ids ? paths.get(Number(ids[index])) : undefined;
131
+ if (!path || path.length < 4)
132
+ continue;
133
+ const last = path.length;
134
+ velocity[index * size] = (path[last - 2] - path[last - 4]) * velocityscale;
135
+ velocity[index * size + 1] = (path[last - 1] - path[last - 3]) * velocityscale;
136
+ }
137
+ return {
138
+ ...current,
139
+ point: { ...current.point, v: { storage: "f32", size, data: velocity } },
140
+ };
141
+ }
142
+ //# sourceMappingURL=trail.js.map