@field/cascade 0.3.1 → 0.5.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 (129) hide show
  1. package/AGENTS.md +32 -9
  2. package/ARCHITECTURE.md +75 -13
  3. package/CHANGELOG.md +98 -1
  4. package/CONTRIBUTING.md +104 -0
  5. package/DESIGN.md +24 -15
  6. package/README.md +121 -14
  7. package/dist/assets/{CodeEditor-D22fVi-4.js → CodeEditor-dOrU3aQg.js} +7 -7
  8. package/dist/assets/{CodePanel-DPNEYfcR.js → CodePanel-Bqy85qen.js} +2 -2
  9. package/dist/assets/{CustomNodeDialog-DeHCAvgb.js → CustomNodeDialog-DkZlOgjs.js} +1 -1
  10. package/dist/assets/{ProjectSettings-DWpVtWEx.js → ProjectSettings-D192mr6g.js} +2 -2
  11. package/dist/assets/{SettingsDialog-za00IS3L.js → SettingsDialog-BTeeIE3T.js} +1 -1
  12. package/dist/assets/{VersionHistoryDialog-DTMGhI0_.js → VersionHistoryDialog-TZQKkn3Y.js} +1 -1
  13. package/dist/assets/{core-JsyKMO1k.js → core-DwbcGL0o.js} +2 -2
  14. package/dist/assets/{cssMode-emdRheFK.js → cssMode-Dcjn356x.js} +1 -1
  15. package/dist/assets/definitions-C_SY6Bhg.js +1 -0
  16. package/dist/assets/{editor.api-xHX-fMSo.js → editor.api-D7Q83606.js} +1 -1
  17. package/dist/assets/{freemarker2-C3M8_Gs3.js → freemarker2-DCCiHlV6.js} +1 -1
  18. package/dist/assets/geo-CT-wtvaE.js +1 -0
  19. package/dist/assets/{handlebars-CBMurtFk.js → handlebars-BCxC8xu1.js} +1 -1
  20. package/dist/assets/{html-xbLQwknw.js → html-COwepzSd.js} +1 -1
  21. package/dist/assets/{htmlMode-2wKdDTon.js → htmlMode-Dpg7H0rx.js} +1 -1
  22. package/dist/assets/{image-i1j6nWKe.js → image-CMfMMxlC.js} +3 -3
  23. package/dist/assets/index-De1cIZ9N.js +212 -0
  24. package/dist/assets/index-ilgbOu6G.css +1 -0
  25. package/dist/assets/{javascript-DJaOo_2I.js → javascript-CSwWxfLr.js} +1 -1
  26. package/dist/assets/{jsonMode-Bheas8_0.js → jsonMode-BXRriTpj.js} +1 -1
  27. package/dist/assets/{liquid-BCj_BGbM.js → liquid-Cmkfyogc.js} +1 -1
  28. package/dist/assets/{lspLanguageFeatures-BfIsx_xz.js → lspLanguageFeatures-xH9JbZGO.js} +1 -1
  29. package/dist/assets/{mdx-DwiQDiVO.js → mdx-BzrNjNvK.js} +1 -1
  30. package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
  31. package/dist/assets/pop-BdKEHslJ.js +1 -0
  32. package/dist/assets/{python-BjrB5nqj.js → python-D4E9jbx7.js} +1 -1
  33. package/dist/assets/{razor-CfoBmGDs.js → razor-B3x9cIEM.js} +1 -1
  34. package/dist/assets/{tsMode-BcEoMSxp.js → tsMode-dhQYPNMZ.js} +1 -1
  35. package/dist/assets/{typescript-DIg7TEmQ.js → typescript-CITkDxAE.js} +1 -1
  36. package/dist/assets/{workers-vXYuwtPw.js → workers-BQvF9bbO.js} +1 -1
  37. package/dist/assets/{xml-DexLQIQy.js → xml-DvHlU3A8.js} +1 -1
  38. package/dist/assets/{yaml-DUnD_1dE.js → yaml-uznsLgs8.js} +1 -1
  39. package/dist/cli/index.js +16465 -12096
  40. package/dist/cli/runtime/gpu.ts +71 -0
  41. package/dist/cli/runtime/io.ts +31 -25
  42. package/dist/contracts/camera.d.ts +70 -0
  43. package/dist/contracts/camera.js +27 -0
  44. package/dist/contracts/capabilities.d.ts +6 -6
  45. package/dist/contracts/definition.d.ts +62 -4
  46. package/dist/contracts/index.d.ts +4 -0
  47. package/dist/contracts/index.js +4 -0
  48. package/dist/contracts/particles.d.ts +54 -0
  49. package/dist/contracts/particles.js +167 -0
  50. package/dist/contracts/scene.d.ts +129 -0
  51. package/dist/contracts/scene.js +73 -0
  52. package/dist/contracts/schema.js +26 -3
  53. package/dist/contracts/series.d.ts +75 -0
  54. package/dist/contracts/series.js +2 -0
  55. package/dist/contracts/validate.js +23 -5
  56. package/dist/contracts/values.d.ts +4 -2
  57. package/dist/contracts/values.js +10 -0
  58. package/dist/gpu.d.ts +17 -0
  59. package/dist/gpu.js +61 -1
  60. package/dist/index.html +3 -3
  61. package/dist/io.d.ts +4 -16
  62. package/dist/io.js +26 -8
  63. package/dist/player/app.js +810 -0
  64. package/dist/player/embed.d.ts +4 -0
  65. package/dist/player/embed.js +77 -0
  66. package/dist/player/types.d.ts +35 -0
  67. package/dist/runtime/builtins/core/camera.d.ts +252 -0
  68. package/dist/runtime/builtins/core/camera.js +149 -0
  69. package/dist/runtime/builtins/core/index.d.ts +4 -2
  70. package/dist/runtime/builtins/core/index.js +8 -2
  71. package/dist/runtime/builtins/core/structural.d.ts +168 -0
  72. package/dist/runtime/builtins/core/structural.js +175 -0
  73. package/dist/runtime/builtins/core/time.d.ts +165 -0
  74. package/dist/runtime/builtins/core/time.js +110 -0
  75. package/dist/runtime/builtins/geo/definitions.d.ts +247 -0
  76. package/dist/runtime/builtins/geo/definitions.js +70 -0
  77. package/dist/runtime/builtins/geo/index.d.ts +2 -1
  78. package/dist/runtime/builtins/geo/index.js +3 -1
  79. package/dist/runtime/builtins/geo/render.d.ts +87 -0
  80. package/dist/runtime/builtins/geo/render.js +87 -0
  81. package/dist/runtime/builtins/geo/svgExport.d.ts +11 -0
  82. package/dist/runtime/builtins/geo/svgExport.js +10 -0
  83. package/dist/runtime/builtins/index.d.ts +5 -2
  84. package/dist/runtime/builtins/index.js +13 -3
  85. package/dist/runtime/builtins/pop/definitions.d.ts +2120 -0
  86. package/dist/runtime/builtins/pop/definitions.js +815 -0
  87. package/dist/runtime/builtins/pop/index.d.ts +2 -0
  88. package/dist/runtime/builtins/pop/index.js +2 -0
  89. package/dist/runtime/builtins/scene/definitions.d.ts +356 -0
  90. package/dist/runtime/builtins/scene/definitions.js +224 -0
  91. package/dist/runtime/builtins/scene/index.d.ts +2 -0
  92. package/dist/runtime/builtins/scene/index.js +2 -0
  93. package/dist/runtime/camera/index.d.ts +60 -0
  94. package/dist/runtime/camera/index.js +234 -0
  95. package/dist/runtime/definition/architecture.js +36 -1
  96. package/dist/runtime/geometry/render.d.ts +109 -0
  97. package/dist/runtime/geometry/render.js +145 -0
  98. package/dist/runtime/geometry/svg.d.ts +29 -0
  99. package/dist/runtime/geometry/svg.js +11 -5
  100. package/dist/runtime/gpu.d.ts +33 -0
  101. package/dist/runtime/gpu.js +218 -0
  102. package/dist/runtime/index.d.ts +4 -0
  103. package/dist/runtime/index.js +4 -0
  104. package/dist/runtime/pop/cache.d.ts +27 -0
  105. package/dist/runtime/pop/cache.js +123 -0
  106. package/dist/runtime/pop/forces.d.ts +202 -0
  107. package/dist/runtime/pop/forces.js +519 -0
  108. package/dist/runtime/pop/index.d.ts +5 -0
  109. package/dist/runtime/pop/index.js +5 -0
  110. package/dist/runtime/pop/state.d.ts +136 -0
  111. package/dist/runtime/pop/state.js +283 -0
  112. package/dist/runtime/pop/trail.d.ts +62 -0
  113. package/dist/runtime/pop/trail.js +142 -0
  114. package/dist/runtime/runtime.js +284 -9
  115. package/dist/runtime/scene/index.d.ts +50 -0
  116. package/dist/runtime/scene/index.js +94 -0
  117. package/dist/runtime/series/index.d.ts +41 -0
  118. package/dist/runtime/series/index.js +167 -0
  119. package/dist/runtime/types.d.ts +1 -1
  120. package/dist/studio/panel.d.ts +68 -1
  121. package/doc/HEADLESS_GPU.md +160 -0
  122. package/doc/NODE_REFERENCE.md +408 -1
  123. package/doc/PROJECT_AUTHORING.md +118 -20
  124. package/doc/WEB_PLAYER.md +131 -0
  125. package/package.json +25 -3
  126. package/dist/assets/geo-BAR6lQp5.js +0 -1
  127. package/dist/assets/index-BDV5xGBZ.css +0 -1
  128. package/dist/assets/index-CMcH8AgP.js +0 -212
  129. package/dist/assets/nodeTypeUtils-BBA1JqHx.js +0 -1
@@ -0,0 +1,519 @@
1
+ /**
2
+ * The forces, as plain objects rather than nodes.
3
+ *
4
+ * A force reads the state and returns an acceleration. It never moves
5
+ * anything — integration happens once, in `step`, because two forces that each
6
+ * integrated would disagree about the order of operations and the result would
7
+ * depend on wiring order rather than on physics.
8
+ *
9
+ * Houdini's names for the parameters, per the standing rule.
10
+ */
11
+ /** Constant acceleration. Houdini's POP Force, whose parameter is `force`. */
12
+ export function gravity(force) {
13
+ return {
14
+ label: "gravity",
15
+ accelerate(state, size) {
16
+ const out = new Float32Array(state.count * size);
17
+ for (let index = 0; index < state.count; index += 1) {
18
+ for (let component = 0; component < size; component += 1) {
19
+ out[index * size + component] = force[component] ?? 0;
20
+ }
21
+ }
22
+ return out;
23
+ },
24
+ };
25
+ }
26
+ /**
27
+ * Velocity-opposing drag. Houdini's POP Drag, parameter `airresist`.
28
+ *
29
+ * Returned as an acceleration rather than applied as a multiplier on velocity,
30
+ * which is the same choice as everywhere else here: a drag that scaled velocity
31
+ * directly would be integrating, and its result would then depend on whether it
32
+ * ran before or after a force that also scaled velocity.
33
+ */
34
+ export function drag(airresist) {
35
+ return {
36
+ label: "drag",
37
+ accelerate(state, size) {
38
+ const out = new Float32Array(state.count * size);
39
+ for (let index = 0; index < out.length; index += 1) {
40
+ out[index] = -state.velocity[index] * airresist;
41
+ }
42
+ return out;
43
+ },
44
+ };
45
+ }
46
+ /**
47
+ * A heading read from a noise field — the first of the four forces the
48
+ * living-identity piece applies.
49
+ *
50
+ * Value noise on a hashed lattice rather than a gradient noise: it is a few
51
+ * lines, it is deterministic from `(seed, cell)` with no state, and at the
52
+ * scales this is used at the difference is not visible. A curl noise belongs in
53
+ * the same file later and wants the same hash.
54
+ *
55
+ * `frequency` is in turns per unit, `amplitude` in units per second squared.
56
+ */
57
+ export function noiseField(options) {
58
+ const { seed, frequency, amplitude, evolve = 0 } = options;
59
+ return {
60
+ label: "noiseField",
61
+ accelerate(state, size) {
62
+ const out = new Float32Array(state.count * size);
63
+ for (let index = 0; index < state.count; index += 1) {
64
+ const x = state.position[index * size] * frequency;
65
+ const y = state.position[index * size + 1] * frequency;
66
+ // One noise value read as an angle, which is what the original does:
67
+ // a heading rather than a vector, so the field never has still points
68
+ // where two components happen to cancel.
69
+ const heading = valueNoise(seed, x, y, evolve) * Math.PI * 2;
70
+ out[index * size] = Math.cos(heading) * amplitude;
71
+ out[index * size + 1] = Math.sin(heading) * amplitude;
72
+ }
73
+ return out;
74
+ },
75
+ };
76
+ }
77
+ /**
78
+ * Neighbour separation — the third force, and the expensive one.
79
+ *
80
+ * A uniform grid rebuilt every step, which is what the original does and what
81
+ * `PLAN geometry` says about spatial indexes: they live inside the node that
82
+ * needs them rather than becoming a type. O(n) for the scales here; a system
83
+ * large enough to need something better should say so with a measurement.
84
+ */
85
+ export function separation(options) {
86
+ const { radius, strength } = options;
87
+ return {
88
+ label: "separation",
89
+ accelerate(state, size) {
90
+ const out = new Float32Array(state.count * size);
91
+ if (radius <= 0 || strength === 0)
92
+ return out;
93
+ const cell = radius;
94
+ const buckets = new Map();
95
+ const key = (x, y) => `${Math.floor(x / cell)}:${Math.floor(y / cell)}`;
96
+ for (let index = 0; index < state.count; index += 1) {
97
+ const k = key(state.position[index * size], state.position[index * size + 1]);
98
+ const bucket = buckets.get(k);
99
+ if (bucket)
100
+ bucket.push(index);
101
+ else
102
+ buckets.set(k, [index]);
103
+ }
104
+ for (let index = 0; index < state.count; index += 1) {
105
+ const px = state.position[index * size];
106
+ const py = state.position[index * size + 1];
107
+ const cx = Math.floor(px / cell);
108
+ const cy = Math.floor(py / cell);
109
+ let ax = 0;
110
+ let ay = 0;
111
+ for (let ox = -1; ox <= 1; ox += 1) {
112
+ for (let oy = -1; oy <= 1; oy += 1) {
113
+ const bucket = buckets.get(`${cx + ox}:${cy + oy}`);
114
+ if (!bucket)
115
+ continue;
116
+ for (const other of bucket) {
117
+ if (other === index)
118
+ continue;
119
+ const dx = px - state.position[other * size];
120
+ const dy = py - state.position[other * size + 1];
121
+ const distanceSquared = dx * dx + dy * dy;
122
+ if (distanceSquared === 0 || distanceSquared > radius * radius)
123
+ continue;
124
+ // Falls off linearly to the radius. Inverse-square would be
125
+ // physical and is wrong here: two coincident particles produce an
126
+ // unbounded acceleration and the step explodes.
127
+ const distance = Math.sqrt(distanceSquared);
128
+ const falloff = (1 - distance / radius) / distance;
129
+ ax += dx * falloff;
130
+ ay += dy * falloff;
131
+ }
132
+ }
133
+ }
134
+ out[index * size] = ax * strength;
135
+ out[index * size + 1] = ay * strength;
136
+ }
137
+ return out;
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Deterministic value noise. A hash, not a PRNG: the same seed and cell always
143
+ * give the same number, so nothing carries between particles or frames — the
144
+ * property that makes re-simulation from frame zero reproduce exactly.
145
+ */
146
+ export function valueNoise(seed, x, y, z = 0) {
147
+ const x0 = Math.floor(x);
148
+ const y0 = Math.floor(y);
149
+ const z0 = Math.floor(z);
150
+ const fx = smooth(x - x0);
151
+ const fy = smooth(y - y0);
152
+ const fz = smooth(z - z0);
153
+ let value = 0;
154
+ for (let dz = 0; dz <= 1; dz += 1) {
155
+ for (let dy = 0; dy <= 1; dy += 1) {
156
+ for (let dx = 0; dx <= 1; dx += 1) {
157
+ const weight = (dx === 0 ? 1 - fx : fx) *
158
+ (dy === 0 ? 1 - fy : fy) *
159
+ (dz === 0 ? 1 - fz : fz);
160
+ value += hash(seed, x0 + dx, y0 + dy, z0 + dz) * weight;
161
+ }
162
+ }
163
+ }
164
+ return value;
165
+ }
166
+ function smooth(t) {
167
+ return t * t * (3 - 2 * t);
168
+ }
169
+ function hash(seed, x, y, z) {
170
+ const n = Math.sin(seed * 12.9898 + x * 78.233 + y * 37.719 + z * 19.371) * 43758.5453;
171
+ return n - Math.floor(n);
172
+ }
173
+ /**
174
+ * A force read from a scalar field — the second of the four the
175
+ * living-identity piece applies, and the one that makes it say something.
176
+ *
177
+ * The original renders its word to an offscreen canvas, blurs it, reads the
178
+ * pixels back and takes central differences to get a gradient. So the target is
179
+ * a **raster field** rather than a path or an SDF, and that matters: particles
180
+ * are pulled toward wherever the field is bright, whatever drew it. Text is one
181
+ * source; a photograph or a noise image would work identically.
182
+ *
183
+ * The field arrives as luminance already sampled into a grid, because reading
184
+ * an `image` port means decoding a file and that belongs in a node rather than
185
+ * in the arithmetic.
186
+ */
187
+ export function fieldForce(options) {
188
+ const { field, width, height, bounds, amplitude } = options;
189
+ const [minX, minY, maxX, maxY] = bounds;
190
+ const spanX = maxX - minX;
191
+ const spanY = maxY - minY;
192
+ const sample = (column, row) => {
193
+ const c = Math.min(width - 1, Math.max(0, column));
194
+ const r = Math.min(height - 1, Math.max(0, row));
195
+ return field[r * width + c] ?? 0;
196
+ };
197
+ return {
198
+ label: "fieldForce",
199
+ accelerate(state, size) {
200
+ const out = new Float32Array(state.count * size);
201
+ if (amplitude === 0 || spanX <= 0 || spanY <= 0)
202
+ return out;
203
+ for (let index = 0; index < state.count; index += 1) {
204
+ const x = state.position[index * size];
205
+ const y = state.position[index * size + 1];
206
+ // The field's rows run top-down and geometry is +Y up, so the row is
207
+ // flipped here. This is the same single flip `SvgExport` documents,
208
+ // and getting it wrong mirrors the word without erroring.
209
+ const column = Math.round(((x - minX) / spanX) * (width - 1));
210
+ const row = Math.round((1 - (y - minY) / spanY) * (height - 1));
211
+ // Central differences, as the original does. The gradient points
212
+ // uphill — toward brightness — which is the direction a particle
213
+ // should be pulled, so no negation.
214
+ const dx = sample(column + 1, row) - sample(column - 1, row);
215
+ const dy = sample(column, row - 1) - sample(column, row + 1);
216
+ out[index * size] = dx * amplitude;
217
+ out[index * size + 1] = dy * amplitude;
218
+ }
219
+ return out;
220
+ },
221
+ };
222
+ }
223
+ /**
224
+ * Pull particles toward a set of target points. Houdini's POP Attract.
225
+ *
226
+ * The composable half of the field idea. `fieldForce` above takes a sampled
227
+ * raster, which is what the original piece uses; this takes geometry, which is
228
+ * what a graph can wire without any node doing IO — and turning a word, a
229
+ * photograph or a shape into target points is upstream work belonging to
230
+ * whatever node knows about words, photographs or shapes.
231
+ *
232
+ * `radius` is what keeps this affordable: beyond it a target is ignored, so
233
+ * the cost is proportional to nearby targets rather than to all of them. A
234
+ * radius of 0 means every target pulls every particle, which is occasionally
235
+ * what you want and is quadratic.
236
+ */
237
+ export function attract(options) {
238
+ const { targets, targetSize, targetCount, amplitude, radius } = options;
239
+ /**
240
+ * A grid over the targets, built once when the force is made rather than
241
+ * per step.
242
+ *
243
+ * The first version looped every target for every particle and let `radius`
244
+ * skip the accumulation — which reads as bounded and is not: the comment
245
+ * claimed the radius kept the cost proportional to nearby targets while the
246
+ * loop was still `particles × targets`. On the word demo that is roughly
247
+ * 2,300 particles against 3,000 letterform points, seven million distance
248
+ * checks a frame, over a hundred frames — and the render went from seconds
249
+ * to minutes. Measured, not predicted: the first full render did not finish
250
+ * inside two minutes.
251
+ *
252
+ * Built here and not in `accelerate` because the targets do not change
253
+ * between steps, so rebuilding it every frame would be the same mistake one
254
+ * level up. The index lives inside the force, which is what `PLAN geometry`
255
+ * says about spatial indexes: they belong to the node that needs them rather
256
+ * than becoming a type.
257
+ */
258
+ const cell = radius > 0 ? radius : 0;
259
+ const buckets = new Map();
260
+ if (cell > 0) {
261
+ for (let target = 0; target < targetCount; target += 1) {
262
+ const key = bucketKey(Number(targets[target * targetSize]), Number(targets[target * targetSize + 1]), cell);
263
+ const bucket = buckets.get(key);
264
+ if (bucket)
265
+ bucket.push(target);
266
+ else
267
+ buckets.set(key, [target]);
268
+ }
269
+ }
270
+ return {
271
+ label: "attract",
272
+ accelerate(state, size) {
273
+ const out = new Float32Array(state.count * size);
274
+ if (amplitude === 0 || targetCount === 0)
275
+ return out;
276
+ const limit = radius > 0 ? radius * radius : Infinity;
277
+ for (let index = 0; index < state.count; index += 1) {
278
+ const px = state.position[index * size];
279
+ const py = state.position[index * size + 1];
280
+ let ax = 0;
281
+ let ay = 0;
282
+ let pulls = 0;
283
+ const consider = (target) => {
284
+ const dx = Number(targets[target * targetSize]) - px;
285
+ const dy = Number(targets[target * targetSize + 1]) - py;
286
+ const distanceSquared = dx * dx + dy * dy;
287
+ if (distanceSquared === 0 || distanceSquared > limit)
288
+ return;
289
+ // Falls off linearly to the radius, for the same reason separation
290
+ // does: inverse-square is physical and gives an unbounded pull at
291
+ // zero distance, which explodes the step.
292
+ const distance = Math.sqrt(distanceSquared);
293
+ const falloff = radius > 0 ? (1 - distance / radius) / distance : 1 / distance;
294
+ ax += dx * falloff;
295
+ ay += dy * falloff;
296
+ pulls += 1;
297
+ };
298
+ if (cell > 0) {
299
+ // Nine cells: a target further than one cell away is further than
300
+ // the radius, because the cell size IS the radius.
301
+ const cx = Math.floor(px / cell);
302
+ const cy = Math.floor(py / cell);
303
+ for (let ox = -1; ox <= 1; ox += 1) {
304
+ for (let oy = -1; oy <= 1; oy += 1) {
305
+ const bucket = buckets.get(`${cx + ox}:${cy + oy}`);
306
+ if (!bucket)
307
+ continue;
308
+ for (const target of bucket)
309
+ consider(target);
310
+ }
311
+ }
312
+ }
313
+ else {
314
+ // No radius means every target pulls every particle, which is
315
+ // occasionally what you want and is quadratic. Stated rather than
316
+ // prevented.
317
+ for (let target = 0; target < targetCount; target += 1)
318
+ consider(target);
319
+ }
320
+ if (pulls === 0)
321
+ continue;
322
+ // Averaged rather than summed: a particle near a dense patch of
323
+ // targets would otherwise be pulled far harder than one near a sparse
324
+ // patch, which turns the word's thick strokes into black holes.
325
+ out[index * size] = (ax / pulls) * amplitude;
326
+ out[index * size + 1] = (ay / pulls) * amplitude;
327
+ }
328
+ return out;
329
+ },
330
+ };
331
+ }
332
+ function bucketKey(x, y, cell) {
333
+ return `${Math.floor(x / cell)}:${Math.floor(y / cell)}`;
334
+ }
335
+ /**
336
+ * A flow field carried as geometry: points with a direction on them.
337
+ *
338
+ * Marcus's description of the original, 2026-09-09: *"a delicate balance
339
+ * between a force pulling the particles towards the centre spine of the type
340
+ * and tangentially around their outlines."* Two components of one field, and
341
+ * that sentence is the whole specification.
342
+ *
343
+ * **Why a blurred field gives you a spine.** A hard letterform's gradient
344
+ * points at the nearest edge, so attracting up it pins particles to outlines.
345
+ * Blur the letterform first and each stroke becomes a ridge whose peak runs
346
+ * down its middle — so the gradient points at the *spine*, and the level sets
347
+ * run *along* the outline. Which is why the original blurs its text before
348
+ * reading pixels back: the blur is not softening, it is what turns an outline
349
+ * into a skeleton.
350
+ *
351
+ * So `normal` pulls up the gradient toward the spine, and `tangential` pushes
352
+ * along the perpendicular — around the outline rather than across it. Balance
353
+ * those two and you get the original's behaviour; take either away and you get
354
+ * a clump or a drift.
355
+ *
356
+ * The field arrives as points carrying `N`, because that is a vector field in
357
+ * Cascade's existing vocabulary and it keeps the rasterising upstream in
358
+ * whatever node knows about type. A core force doing image IO is the thing
359
+ * that stopped being pure earlier.
360
+ */
361
+ export function flowField(options) {
362
+ const { positions, positionSize, directions, levels, count, normal, tangential, radius, level = 0, hold = 0, } = options;
363
+ // Built once, for the reason the attract index exists: the field does not
364
+ // change between steps, and rebuilding it per step is the same mistake one
365
+ // level up.
366
+ const cell = radius > 0 ? radius : 1;
367
+ const buckets = new Map();
368
+ for (let sample = 0; sample < count; sample += 1) {
369
+ const key = bucketKey(Number(positions[sample * positionSize]), Number(positions[sample * positionSize + 1]), cell);
370
+ const bucket = buckets.get(key);
371
+ if (bucket)
372
+ bucket.push(sample);
373
+ else
374
+ buckets.set(key, [sample]);
375
+ }
376
+ return {
377
+ label: "flowField",
378
+ accelerate(state, size) {
379
+ const out = new Float32Array(state.count * size);
380
+ if (count === 0 || (normal === 0 && tangential === 0))
381
+ return out;
382
+ const limit = radius * radius;
383
+ for (let index = 0; index < state.count; index += 1) {
384
+ const px = state.position[index * size];
385
+ const py = state.position[index * size + 1];
386
+ const cx = Math.floor(px / cell);
387
+ const cy = Math.floor(py / cell);
388
+ // Distance-weighted mean of the nearby gradients, so a particle
389
+ // between samples gets a smooth direction rather than snapping to the
390
+ // nearest one — snapping is visible as the sampling grid.
391
+ let gx = 0;
392
+ let gy = 0;
393
+ let levelSum = 0;
394
+ let weightSum = 0;
395
+ for (let ox = -1; ox <= 1; ox += 1) {
396
+ for (let oy = -1; oy <= 1; oy += 1) {
397
+ const bucket = buckets.get(`${cx + ox}:${cy + oy}`);
398
+ if (!bucket)
399
+ continue;
400
+ for (const sample of bucket) {
401
+ const dx = Number(positions[sample * positionSize]) - px;
402
+ const dy = Number(positions[sample * positionSize + 1]) - py;
403
+ const distanceSquared = dx * dx + dy * dy;
404
+ if (distanceSquared > limit)
405
+ continue;
406
+ const weight = 1 - Math.sqrt(distanceSquared) / radius;
407
+ if (weight <= 0)
408
+ continue;
409
+ gx += Number(directions[sample * 2]) * weight;
410
+ gy += Number(directions[sample * 2 + 1]) * weight;
411
+ if (levels)
412
+ levelSum += Number(levels[sample]) * weight;
413
+ weightSum += weight;
414
+ }
415
+ }
416
+ }
417
+ if (weightSum === 0)
418
+ continue;
419
+ gx /= weightSum;
420
+ gy /= weightSum;
421
+ // Held toward a level: uphill when below it, downhill when above, so a
422
+ // particle settles onto that contour and the tangent then carries it
423
+ // around. The sign of the error IS the direction, which is why this is
424
+ // one multiply rather than a branch.
425
+ const climb = levels && hold !== 0
426
+ ? normal + (level - levelSum / weightSum) * hold
427
+ : normal;
428
+ // Rotating the gradient by a quarter turn gives the tangent, which is
429
+ // the direction the field's level sets run — along the outline.
430
+ out[index * size] = gx * climb - gy * tangential;
431
+ out[index * size + 1] = gy * climb + gx * tangential;
432
+ }
433
+ return out;
434
+ },
435
+ };
436
+ }
437
+ /**
438
+ * Houdini's POP convention: a force node **accumulates into a `force`
439
+ * attribute**, and the solver integrates once.
440
+ *
441
+ * Added 2026-09-09, when `pop.Simulate` was rebuilt as a network Marcus can
442
+ * dive into. It is not a refinement — it is the difference between a chain of
443
+ * force nodes that works and one that is silently wrong.
444
+ *
445
+ * The nodes as first written each called `step()` themselves, which integrates.
446
+ * Chain two of them inside a container and time advances **twice per step**:
447
+ * the particles move further than the timestep says, the result changes when
448
+ * you reorder nodes that should commute, and nothing reports an error. Reading
449
+ * that from a picture is close to impossible, which is why the model has to be
450
+ * right rather than tested.
451
+ *
452
+ * So: `accumulateForce` adds an acceleration to the geometry's `force`
453
+ * attribute and moves nothing. `forceAttribute` is the force the solver reads
454
+ * back. The solver integrates and clears it, exactly as a POP network does.
455
+ */
456
+ export function accumulateForce(geometry, contribution, size) {
457
+ const count = geometry.pointCount;
458
+ if (contribution.length !== count * size)
459
+ throw new Error(`force contribution has ${contribution.length} values for ${count} particles of size ${size}`);
460
+ const existing = geometry.point.force;
461
+ const total = new Float32Array(count * size);
462
+ if (existing && existing.storage !== "string" && existing.size === size)
463
+ for (let index = 0; index < total.length; index += 1)
464
+ total[index] = Number(existing.data[index] ?? 0);
465
+ for (let index = 0; index < total.length; index += 1)
466
+ total[index] = total[index] + contribution[index];
467
+ return {
468
+ ...geometry,
469
+ point: {
470
+ ...geometry.point,
471
+ force: { storage: "f32", size, data: total },
472
+ },
473
+ };
474
+ }
475
+ /**
476
+ * The accumulated `force` on a geometry, as a force the solver can sum with the
477
+ * rest.
478
+ *
479
+ * Takes the geometry rather than reading through `ParticleState`, because the
480
+ * state deliberately carries only the attributes the contract makes mandatory —
481
+ * adding a slot for every attribute a force node might invent is how a state
482
+ * object becomes a bag. The solver reads this off the geometry it was handed.
483
+ *
484
+ * Zero when the attribute is absent, which is the correct reading of "no force
485
+ * node ran": a solver with nothing wired into it should hold the particles
486
+ * still rather than refuse to cook.
487
+ */
488
+ export function forceAttribute(geometry) {
489
+ const stored = geometry.point.force;
490
+ const data = stored && stored.storage !== "string"
491
+ ? stored.data
492
+ : undefined;
493
+ const storedSize = stored?.size ?? 0;
494
+ return {
495
+ label: "force",
496
+ accelerate: (state, size) => {
497
+ const out = new Float32Array(state.count * size);
498
+ if (!data)
499
+ return out;
500
+ // Component-wise rather than a straight copy, so a 3-component `force`
501
+ // on a 2D system contributes its x and y instead of throwing or
502
+ // scrambling the stride.
503
+ const shared = Math.min(size, storedSize);
504
+ for (let point = 0; point < state.count; point += 1)
505
+ for (let axis = 0; axis < shared; axis += 1)
506
+ out[point * size + axis] = Number(data[point * storedSize + axis] ?? 0);
507
+ return out;
508
+ },
509
+ };
510
+ }
511
+ /** Drop the accumulated force. The solver calls this after integrating, so a
512
+ * step never inherits the previous step's forces. */
513
+ export function clearForce(geometry) {
514
+ if (!geometry.point.force)
515
+ return geometry;
516
+ const { force: _dropped, ...rest } = geometry.point;
517
+ return { ...geometry, point: rest };
518
+ }
519
+ //# sourceMappingURL=forces.js.map
@@ -0,0 +1,5 @@
1
+ export * from "./cache.js";
2
+ export * from "./forces.js";
3
+ export * from "./state.js";
4
+ export * from "./trail.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export * from "./cache.js";
2
+ export * from "./forces.js";
3
+ export * from "./state.js";
4
+ export * from "./trail.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,136 @@
1
+ import { type Geometry } from "@field/cascade/contracts";
2
+ /**
3
+ * A particle state, and the arithmetic for stepping it.
4
+ *
5
+ * Step 2 of `PLAN particles.md`. Deliberately not nodes: the nodes are thin
6
+ * wrappers and this is where the risk lives, so it is testable without a graph.
7
+ *
8
+ * **The state is a `Geometry` and nothing else.** Marcus ruled that trails are
9
+ * geometry rather than a faded canvas, which means points carrying the
10
+ * attribute contract *are* the simulation — there is no particle type, no
11
+ * parallel store, and every geometry node downstream already understands the
12
+ * result. `Trail`, `CopyToPoints` and `SvgExport` need nothing new.
13
+ *
14
+ * **The step is a pure function.** `step(previous, forces, options)` returns a
15
+ * new state and reads no clock. That is what lets the solver be re-simulated
16
+ * from frame zero for correctness and cached only as an accelerator — the
17
+ * ruling in the plan's central section — and it is why `cascade run --frames`
18
+ * needs nothing added.
19
+ */
20
+ /** A force reads the state and returns an acceleration per particle. It may
21
+ * not move anything: integration happens once, in `step`, or two forces
22
+ * disagree about the order of operations. */
23
+ export interface ParticleForce {
24
+ readonly label: string;
25
+ /** Acceleration in units per second squared, `size` components per particle,
26
+ * flat and parallel to the point arrays. */
27
+ accelerate(state: ParticleState, size: number): Float32Array;
28
+ }
29
+ export interface ParticleStepOptions {
30
+ /** Seconds. Stated by the caller, never derived from a clock here — the
31
+ * solver node decides whether it came from a fixed value or from fps, which
32
+ * is Marcus's ruling of 2026-09-09, and the difference is recorded in the
33
+ * cache key rather than in this function. */
34
+ readonly timestep: number;
35
+ /** Speed clamp in units per second, or 0 for none. The original piece has
36
+ * one, and a clamp is also what stops a stiff force exploding a step. */
37
+ readonly maxSpeed?: number;
38
+ /** Wrap positions into this box, `[minX, minY, maxX, maxY]`. The original is
39
+ * toroidal; omit for open space. */
40
+ readonly wrap?: readonly [number, number, number, number];
41
+ }
42
+ /**
43
+ * The state, read through accessors rather than as raw arrays.
44
+ *
45
+ * A class rather than a bag of typed arrays because every operator needs the
46
+ * same four reads and the same bounds discipline, and because `size` — whether
47
+ * this is a 2D or 3D system — has to be answered once rather than inferred by
48
+ * each force from the length of an array it was handed.
49
+ */
50
+ export declare class ParticleState {
51
+ readonly count: number;
52
+ readonly size: number;
53
+ readonly position: Float64Array;
54
+ readonly velocity: Float32Array;
55
+ readonly age: Float32Array;
56
+ readonly life: Float32Array;
57
+ readonly id: Int32Array;
58
+ /**
59
+ * `Cd`, four components, or null for a system with no colour.
60
+ *
61
+ * Null rather than a default white array, because the absence is meaningful:
62
+ * `SvgExport` and every other consumer falls back to its own stroke prop
63
+ * when a primitive carries no `Cd`, and filling one in here would take that
64
+ * choice away from the graph.
65
+ */
66
+ readonly colour: Float32Array | null;
67
+ nextId: number;
68
+ /**
69
+ * Internal, and taking the arrays by reference rather than copying.
70
+ *
71
+ * `@internal` rather than `private` because `step` and `select` are module
72
+ * functions rather than methods — they are the arithmetic, and putting them
73
+ * on the class would make a state object that can step itself, which is one
74
+ * short hop from a state object that holds a clock.
75
+ *
76
+ * @internal
77
+ */
78
+ static of(fields: {
79
+ count: number;
80
+ size: number;
81
+ position: Float64Array;
82
+ velocity: Float32Array;
83
+ age: Float32Array;
84
+ life: Float32Array;
85
+ id: Int32Array;
86
+ colour?: Float32Array | null;
87
+ nextId: number;
88
+ }): ParticleState;
89
+ private constructor();
90
+ /** An empty system. `nextId` starts at 0 and only ever increases. */
91
+ static empty(size?: 2 | 3): ParticleState;
92
+ /**
93
+ * Read a state out of a geometry.
94
+ *
95
+ * Refuses rather than repairs. `checkParticleContract` returns every
96
+ * violation, and a solver handed a geometry without `id` cannot invent one:
97
+ * inventing ids per frame is precisely the fault the mandatory `id` exists to
98
+ * prevent, because it would silently make every particle new every step.
99
+ */
100
+ static fromGeometry(geometry: Geometry): ParticleState;
101
+ /** Write the state back out as geometry, one point per particle. */
102
+ toGeometry(): Geometry;
103
+ /** Add particles, handing each a fresh id. Returns the new state; the
104
+ * receiver is not mutated, so a source can be applied to a cached frame. */
105
+ born(points: {
106
+ readonly position: ArrayLike<number>;
107
+ readonly velocity?: ArrayLike<number>;
108
+ readonly life: ArrayLike<number>;
109
+ /** `Cd` per new particle, four components. Once any particle has a colour
110
+ * the whole system carries the attribute, so the ones born before it get
111
+ * opaque white rather than the array being ragged. */
112
+ readonly colour?: ArrayLike<number>;
113
+ }): ParticleState;
114
+ }
115
+ /**
116
+ * One step. Forces, then integrate, then age, then kill.
117
+ *
118
+ * The order is Houdini's and it matters: a force that read a position already
119
+ * advanced by this step's velocity would be integrating twice. So every force
120
+ * sees the same state, their accelerations sum, and integration happens once.
121
+ */
122
+ export declare function step(state: ParticleState, forces: readonly ParticleForce[], options: ParticleStepOptions): ParticleState;
123
+ /**
124
+ * Remove particles whose age has reached their life.
125
+ *
126
+ * Compacts in place order, which reorders the arrays — and is exactly why `id`
127
+ * is mandatory. A `life` of zero or less means immortal, following Houdini,
128
+ * because a source that does not set a life should not birth particles that
129
+ * die on their first step.
130
+ */
131
+ export declare function kill(state: ParticleState): ParticleState;
132
+ /** A new state holding only these particles, in this order. */
133
+ export declare function select(state: ParticleState, indices: readonly number[]): ParticleState;
134
+ /** A blank particle geometry, for a source to add to. */
135
+ export declare function emptyParticleGeometry(size?: 2 | 3): Geometry;
136
+ //# sourceMappingURL=state.d.ts.map