@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,815 @@
1
+ import { checkParticleContract, } from "@field/cascade/contracts";
2
+ import { ParticleState, accumulateForce, clearForce, forceAttribute, checkpointAtOrBefore, offerCheckpoint, attract, buildTrails, flowField, drag, emptyParticleGeometry, gravity, noiseField, separation, step, trailFrame, } from "../../pop/index.js";
3
+ /**
4
+ * `cascade.pop.*` - the minimum useful particle set.
5
+ *
6
+ * Step 2 of `PLAN particles.md`, and the step that carries the risk: a source,
7
+ * a solver, and forces, on **re-simulation only**. There is no cache. The plan
8
+ * argues for that order - re-simulation from the start frame is the definition
9
+ * of correctness, and a cache is an accelerator holding nothing re-simulation
10
+ * would not have produced, so it is built second and can be cleared at any
11
+ * time without changing a result.
12
+ *
13
+ * The arithmetic lives in `packages/runtime/src/pop`, deliberately: these
14
+ * nodes are thin, the risk is in the stepping, and the stepping is testable
15
+ * without a graph.
16
+ */
17
+ const sourceDefinition = {
18
+ apiVersion: 1,
19
+ label: "POP Source",
20
+ description: "Birth particles from a geometry's points.",
21
+ icon: "Sparkles",
22
+ runsOn: "portable",
23
+ inputs: {
24
+ geometry: { kind: "data", type: "geometry" },
25
+ particles: { kind: "data", type: "geometry" },
26
+ },
27
+ outputs: { geometry: { kind: "data", type: "geometry" } },
28
+ props: {
29
+ impulse: { type: "int", default: 8, min: 0, max: 100000 },
30
+ life: { type: "float", default: 3, min: 0, max: 3600, step: 0.1 },
31
+ lifevar: { type: "float", default: 0.3, min: 0, max: 1, step: 0.01 },
32
+ velocity: { type: "vec2", default: [0, 0] },
33
+ seed: { type: "int", default: 7, min: 0, max: 999999 },
34
+ },
35
+ };
36
+ /** HSV in, RGB out. Written here rather than imported because a colour
37
+ * conversion is four lines and an import across package boundaries for four
38
+ * lines is worse. */
39
+ function hsvToRgb(h, s, v) {
40
+ const turn = ((h % 1) + 1) % 1;
41
+ const sector = turn * 6;
42
+ const c = v * s;
43
+ const x = c * (1 - Math.abs((sector % 2) - 1));
44
+ const m = v - c;
45
+ const rgb = sector < 1 ? [c, x, 0] :
46
+ sector < 2 ? [x, c, 0] :
47
+ sector < 3 ? [0, c, x] :
48
+ sector < 4 ? [0, x, c] :
49
+ sector < 5 ? [x, 0, c] : [c, 0, x];
50
+ return [rgb[0] + m, rgb[1] + m, rgb[2] + m];
51
+ }
52
+ /** A hash rather than a stream, so a birth is a pure function of `(seed, id)`
53
+ * and re-simulating reproduces the same particles. */
54
+ function birthRandom(seed, sample) {
55
+ const n = Math.sin(seed * 12.9898 + sample * 78.233) * 43758.5453;
56
+ return n - Math.floor(n);
57
+ }
58
+ export function executeSource(context) {
59
+ const props = context.props;
60
+ const incoming = context.inputs.particles;
61
+ const state = incoming
62
+ ? ParticleState.fromGeometry(incoming)
63
+ : ParticleState.fromGeometry(emptyParticleGeometry(2));
64
+ const from = context.inputs.geometry;
65
+ const available = from ? from.pointCount : 0;
66
+ const born = available === 0 ? 0 : Math.min(props.impulse, available);
67
+ if (born === 0) {
68
+ context.outputs.geometry.set(state.toGeometry());
69
+ return;
70
+ }
71
+ const size = state.size;
72
+ const position = new Float64Array(born * size);
73
+ const velocity = new Float32Array(born * size);
74
+ const life = new Float32Array(born);
75
+ const source = from.point.P;
76
+ for (let index = 0; index < born; index += 1) {
77
+ // Spread across the source's points rather than taking the first N, so a
78
+ // small impulse from a large source still samples the whole shape.
79
+ const pick = available === born ? index : Math.floor((index / born) * available);
80
+ for (let component = 0; component < size; component += 1) {
81
+ position[index * size + component] =
82
+ Number(source.data[pick * source.size + component] ?? 0);
83
+ velocity[index * size + component] = props.velocity[component] ?? 0;
84
+ }
85
+ // Keyed on the id this particle is about to get, so the same particle gets
86
+ // the same life however many times the frame is re-simulated.
87
+ const jitter = birthRandom(props.seed, state.nextId + index);
88
+ life[index] = props.life * (1 - props.lifevar * jitter);
89
+ }
90
+ context.outputs.geometry.set(state.born({ position, velocity, life }).toGeometry());
91
+ }
92
+ const solverDefinition = {
93
+ apiVersion: 1,
94
+ label: "POP Solver",
95
+ description: "Advance a particle system by one timestep.",
96
+ icon: "Play",
97
+ runsOn: "portable",
98
+ inputs: { particles: { kind: "data", type: "geometry" } },
99
+ outputs: { geometry: { kind: "data", type: "geometry" } },
100
+ props: {
101
+ /**
102
+ * Where the timestep comes from. Marcus's ruling, 2026-09-09: *"Let the
103
+ * user choose nodes fixed or variable."*
104
+ *
105
+ * `fixed` is the default on the ordinary grounds that the safer of two
106
+ * behaviours is the one you get without asking - a simulation whose result
107
+ * depends on a playback setting is not reproducible from the document
108
+ * alone. With `fps` the render rate becomes part of what determines the
109
+ * output, which is correct for a mode called variable and must not be
110
+ * silent: it belongs in the cache key, and `cascade check` should say so.
111
+ */
112
+ timestep_mode: {
113
+ type: "string",
114
+ default: "fixed",
115
+ control: "select",
116
+ options: [
117
+ { value: "fixed", label: "Fixed - reproducible from the document" },
118
+ { value: "fps", label: "Variable - derived from the render rate" },
119
+ ],
120
+ },
121
+ timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
122
+ maxspeed: { type: "float", default: 0, min: 0, max: 10000, step: 0.5 },
123
+ force: { type: "vec2", default: [0, 0] },
124
+ airresist: { type: "float", default: 0, min: 0, max: 20, step: 0.05 },
125
+ },
126
+ };
127
+ export function executeSolver(context) {
128
+ const props = context.props;
129
+ const incoming = context.inputs.particles;
130
+ if (!incoming) {
131
+ context.outputs.geometry.set(emptyParticleGeometry(2));
132
+ return;
133
+ }
134
+ const state = ParticleState.fromGeometry(incoming);
135
+ const forces = [];
136
+ /**
137
+ * Whatever the force nodes upstream accumulated, first.
138
+ *
139
+ * This is the half that makes a network of forces work: they add to a `force`
140
+ * attribute and move nothing, and integration happens **once**, here. The
141
+ * solver then clears the attribute, so a step never inherits the previous
142
+ * step's forces — a leak there would make a simulation accelerate for
143
+ * reasons nothing in the graph explains.
144
+ */
145
+ forces.push(forceAttribute(incoming));
146
+ if (props.force[0] !== 0 || props.force[1] !== 0)
147
+ forces.push(gravity(props.force));
148
+ if (props.airresist > 0)
149
+ forces.push(drag(props.airresist));
150
+ context.outputs.geometry.set(clearForce(step(state, forces, {
151
+ timestep: props.timestep,
152
+ ...(props.maxspeed > 0 ? { maxSpeed: props.maxspeed } : {}),
153
+ }).toGeometry()));
154
+ }
155
+ const noiseForceDefinition = {
156
+ apiVersion: 1,
157
+ label: "POP Noise Force",
158
+ description: "Push particles along a heading read from a noise field.",
159
+ icon: "Wind",
160
+ runsOn: "portable",
161
+ inputs: { particles: { kind: "data", type: "geometry" } },
162
+ outputs: { geometry: { kind: "data", type: "geometry" } },
163
+ props: {
164
+ amplitude: { type: "float", default: 6, min: 0, max: 500, step: 0.5 },
165
+ frequency: { type: "float", default: 0.3, min: 0.001, max: 10, step: 0.01 },
166
+ evolve: { type: "float", default: 0, min: -1000, max: 1000, step: 0.01 },
167
+ timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
168
+ seed: { type: "int", default: 7, min: 0, max: 999999 },
169
+ },
170
+ };
171
+ /**
172
+ * Accumulates into `force` and moves nothing — Houdini's POP model.
173
+ *
174
+ * Changed 2026-09-09, and it is a correctness fix rather than a refinement.
175
+ * This node used to call `step()`, which integrates. Chain two force nodes
176
+ * inside a `cascade.core.Feedback` and time then advances **twice per step**:
177
+ * the particles travel further than the timestep says, reordering nodes that
178
+ * ought to commute changes the result, and nothing reports anything. That is
179
+ * unreadable from a picture, so the model has to be right rather than tested.
180
+ */
181
+ export function executeNoiseForce(context) {
182
+ const incoming = context.inputs.particles;
183
+ if (!incoming) {
184
+ context.outputs.geometry.set(emptyParticleGeometry(2));
185
+ return;
186
+ }
187
+ const props = context.props;
188
+ const state = ParticleState.fromGeometry(incoming);
189
+ context.outputs.geometry.set(accumulateForce(incoming, noiseField({
190
+ seed: props.seed,
191
+ frequency: props.frequency,
192
+ amplitude: props.amplitude,
193
+ evolve: props.evolve,
194
+ }).accelerate(state, state.size), state.size));
195
+ }
196
+ const separateDefinition = {
197
+ apiVersion: 1,
198
+ label: "POP Separate",
199
+ description: "Push neighbours apart within a radius.",
200
+ icon: "Move3d",
201
+ runsOn: "portable",
202
+ inputs: { particles: { kind: "data", type: "geometry" } },
203
+ outputs: { geometry: { kind: "data", type: "geometry" } },
204
+ props: {
205
+ radius: { type: "float", default: 0.5, min: 0, max: 100, step: 0.01 },
206
+ strength: { type: "float", default: 4, min: 0, max: 500, step: 0.1 },
207
+ timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
208
+ },
209
+ };
210
+ export function executeSeparate(context) {
211
+ const incoming = context.inputs.particles;
212
+ if (!incoming) {
213
+ context.outputs.geometry.set(emptyParticleGeometry(2));
214
+ return;
215
+ }
216
+ const props = context.props;
217
+ const state = ParticleState.fromGeometry(incoming);
218
+ context.outputs.geometry.set(accumulateForce(incoming, separation({ radius: props.radius, strength: props.strength }).accelerate(state, state.size), state.size));
219
+ }
220
+ /**
221
+ * `cascade.pop.FieldForce` — the flow field, as a node.
222
+ *
223
+ * Extracted from `pop.Simulate`'s `field_*` props on 2026-09-09 so that the
224
+ * force which actually makes the calligraphy is a node you can see inside the
225
+ * container, reorder, and drive from a ramp. Two components, and they are the
226
+ * whole effect: `normal` pulls along the field's `N` toward the spine of the
227
+ * type, `tangential` pushes perpendicular to it, around the outline. The
228
+ * balance between them is what turns a hairball into a stroke.
229
+ *
230
+ * `level` and `hold` are the reason the lines follow the letters rather than
231
+ * collapsing onto them: instead of climbing to the field's peak, a particle
232
+ * holds a chosen contour. Marcus's own note on wanting this: *"we want the
233
+ * lines to extend and really flow around the letters, creating an elegant
234
+ * calligraphy… not hairballs or knots."*
235
+ */
236
+ const fieldForceDefinition = {
237
+ apiVersion: 1,
238
+ label: "POP Field Force",
239
+ description: "Pull particles along a field's gradient and around its contours. The calligraphy force.",
240
+ icon: "Waves",
241
+ runsOn: "portable",
242
+ inputs: {
243
+ particles: { kind: "data", type: "geometry" },
244
+ /** The field: points carrying `N` (the gradient) and optionally `level`. */
245
+ field: { kind: "data", type: "geometry" },
246
+ },
247
+ outputs: { geometry: { kind: "data", type: "geometry" } },
248
+ props: {
249
+ normal: {
250
+ type: "float",
251
+ default: 0,
252
+ min: -100000,
253
+ max: 100000,
254
+ label: "Normal",
255
+ description: "Along N, toward the spine. Negative pushes away.",
256
+ },
257
+ tangential: {
258
+ type: "float",
259
+ default: 2600,
260
+ min: -100000,
261
+ max: 100000,
262
+ label: "Tangential",
263
+ description: "Perpendicular to N, around the outline. Sign picks the direction.",
264
+ },
265
+ radius: {
266
+ type: "float",
267
+ default: 20,
268
+ min: 0,
269
+ max: 10000,
270
+ label: "Radius",
271
+ description: "How far a field sample reaches.",
272
+ },
273
+ level: {
274
+ type: "float",
275
+ default: 0.28,
276
+ min: 0,
277
+ max: 1,
278
+ step: 0.01,
279
+ label: "Level",
280
+ description: "The contour to hold, rather than climbing to the peak.",
281
+ },
282
+ hold: {
283
+ type: "float",
284
+ default: 5200,
285
+ min: 0,
286
+ max: 100000,
287
+ label: "Hold",
288
+ description: "How hard to hold that contour.",
289
+ },
290
+ },
291
+ };
292
+ export function executeFieldForce(context) {
293
+ const incoming = context.inputs.particles;
294
+ if (!incoming) {
295
+ context.outputs.geometry.set(emptyParticleGeometry(2));
296
+ return;
297
+ }
298
+ const flow = context.inputs.field;
299
+ const props = context.props;
300
+ // An absent or empty optional field contributes nothing. A present nonempty
301
+ // field without directions is malformed, not equivalent to no field.
302
+ if (!flow || flow.pointCount === 0 || (props.normal === 0 && props.tangential === 0)) {
303
+ context.outputs.geometry.set(incoming);
304
+ return;
305
+ }
306
+ if (!flow.point.N)
307
+ throw new Error(`${context.nodeId}.field requires point attribute N when the field contains points`);
308
+ const state = ParticleState.fromGeometry(incoming);
309
+ context.outputs.geometry.set(accumulateForce(incoming, flowField({
310
+ positions: flow.point.P.data,
311
+ positionSize: flow.point.P.size,
312
+ directions: flow.point.N.data,
313
+ ...(flow.point.level && flow.point.level.storage !== "string"
314
+ ? { levels: flow.point.level.data }
315
+ : {}),
316
+ count: flow.pointCount,
317
+ normal: props.normal,
318
+ tangential: props.tangential,
319
+ radius: props.radius,
320
+ level: props.level,
321
+ hold: props.hold,
322
+ }).accelerate(state, state.size), state.size));
323
+ }
324
+ /**
325
+ * `cascade.pop.Trail` — trails from a Feedback container's history.
326
+ *
327
+ * The node that makes the network version of the piece possible at all. A trail
328
+ * needs the *frames*, and a node downstream of a simulation sees one — which
329
+ * draws nothing. `cascade.core.Feedback` keeps a bounded history for exactly
330
+ * this, and this node consumes it.
331
+ *
332
+ * The parameter names are Houdini's Trail SOP: `result`, `length`,
333
+ * `increment`, `velocityscale`.
334
+ */
335
+ const trailDefinition = {
336
+ apiVersion: 1,
337
+ label: "POP Trail",
338
+ description: "Build trails from a Feedback container's history.",
339
+ icon: "Activity",
340
+ runsOn: "portable",
341
+ inputs: {
342
+ /** The container's `history` output: every kept step, oldest first. */
343
+ history: { kind: "data", type: "array" },
344
+ /** The current state, for `preserve` and for the newest point of each trail. */
345
+ particles: { kind: "data", type: "geometry" },
346
+ },
347
+ outputs: { geometry: { kind: "data", type: "geometry" } },
348
+ props: {
349
+ result: {
350
+ type: "string",
351
+ default: "polylines",
352
+ control: "select",
353
+ options: [
354
+ { value: "polylines", label: "Polylines - one line per particle" },
355
+ { value: "preserve", label: "Preserve - pass the points through" },
356
+ ],
357
+ label: "Result",
358
+ },
359
+ length: { type: "int", default: 12, min: 1, max: 100000, label: "Length" },
360
+ increment: { type: "int", default: 1, min: 1, max: 1000, label: "Increment" },
361
+ velocityscale: { type: "float", default: 1, min: 0, max: 100, step: 0.01, label: "Velocity Scale" },
362
+ },
363
+ };
364
+ export function executeTrail(context) {
365
+ const current = context.inputs.particles;
366
+ if (!current) {
367
+ context.outputs.geometry.set(emptyParticleGeometry(2));
368
+ return;
369
+ }
370
+ const history = (context.inputs.history ?? []);
371
+ const frames = [];
372
+ for (const [index, entry] of history.entries()) {
373
+ if (typeof entry !== "object" || entry === null || entry.kind !== "geometry")
374
+ throw new Error(`${context.nodeId}.history[${index}] must be particle geometry`);
375
+ const violations = checkParticleContract(entry);
376
+ if (violations.length > 0)
377
+ throw new Error(`${context.nodeId}.history[${index}] is not particle geometry: ${violations
378
+ .map((violation) => violation.reason)
379
+ .join("; ")}`);
380
+ frames.push(trailFrame(entry));
381
+ }
382
+ const props = context.props;
383
+ context.outputs.geometry.set(buildTrails(frames, current, {
384
+ result: props.result === "preserve" ? "preserve" : "polylines",
385
+ length: props.length,
386
+ increment: props.increment,
387
+ velocityscale: props.velocityscale,
388
+ }));
389
+ }
390
+ const simulateDefinition = {
391
+ apiVersion: 1,
392
+ label: "POP Simulate",
393
+ description: "Re-simulate a particle system from frame zero to this frame.",
394
+ icon: "Rewind",
395
+ runsOn: "portable",
396
+ inputs: {
397
+ /** Where particles are born. Its points are the birth positions. */
398
+ geometry: { kind: "data", type: "geometry" },
399
+ /** The frame to simulate up to. Drive it with `$F`. An input rather than
400
+ * a prop because the whole node is a function of it, and because a node
401
+ * that read the clock itself would be the thing `AGENTS.md` forbids. */
402
+ frame: { kind: "data", type: "float", default: 1 },
403
+ /**
404
+ * Points particles are drawn toward. Houdini's POP Attract.
405
+ *
406
+ * **Geometry rather than an image, and that was a correction.** The
407
+ * original piece renders its word to a canvas, blurs it, reads the pixels
408
+ * back and takes central differences — so its target really is a raster
409
+ * field, and an `image` input was the first thing tried here. It was wrong
410
+ * for this node: decoding an image needs a capability, the media
411
+ * capability hands back a host-specific lease rather than a portable
412
+ * raster, and a POP node doing IO stops being the pure arithmetic that
413
+ * makes it cook identically in both hosts.
414
+ *
415
+ * So the rasterising belongs upstream, in whatever node knows about text
416
+ * or photographs, and what reaches the solver is a set of target points.
417
+ * `fieldForce` in `pop/forces.ts` still takes a sampled raster for a node
418
+ * that has one; this input is the composable half.
419
+ */
420
+ attract: { kind: "data", type: "geometry" },
421
+ /**
422
+ * A flow field, as points carrying `N`.
423
+ *
424
+ * Marcus's description of the original: *"a delicate balance between a
425
+ * force pulling the particles towards the centre spine of the type and
426
+ * tangentially around their outlines."* This is that field — the gradient
427
+ * of a BLURRED letterform, whose ridge runs down each stroke's middle, so
428
+ * the gradient points at the spine and its perpendicular runs along the
429
+ * outline. `field_normal` and `field_tangential` are the two halves of the
430
+ * balance.
431
+ */
432
+ field: { kind: "data", type: "geometry" },
433
+ },
434
+ outputs: {
435
+ /** The particles, one point each. */
436
+ geometry: { kind: "data", type: "geometry" },
437
+ /**
438
+ * The trails, one open polyline per particle.
439
+ *
440
+ * Emitted here rather than by a standalone `Trail` node, and that is a
441
+ * consequence of re-simulation rather than a shortcut. Houdini's Trail SOP
442
+ * reads a rolling window the cook already holds, because Houdini's cook is
443
+ * stateful; Cascade's node is a pure function of its inputs, so a
444
+ * standalone `Trail` can only see the frame on its port — one frame, which
445
+ * draws nothing. This node replays every frame from zero, so it has the
446
+ * whole history for free, and a node that draws nothing would be worse than
447
+ * no node.
448
+ *
449
+ * When `CookScheduler` owns the rolling window the plan describes,
450
+ * `cascade.pop.Trail` becomes a real node and this output stays as the
451
+ * cheap path. The parameters are Houdini's either way.
452
+ */
453
+ trails: { kind: "data", type: "geometry" },
454
+ },
455
+ props: {
456
+ /** Frames of history in the trails. Houdini's Trail `length`. */
457
+ trail_length: { type: "int", default: 14, min: 1, max: 500 },
458
+ /** Use every nth frame. Houdini's `increment`. */
459
+ trail_increment: { type: "int", default: 1, min: 1, max: 32 },
460
+ /**
461
+ * Re-simulation, and why this is the first implementation rather than a
462
+ * placeholder for a cached one.
463
+ *
464
+ * `PLAN particles.md`: re-simulation from the start frame is the
465
+ * *definition* of correctness, and a cache is an accelerator holding
466
+ * nothing re-simulation would not have produced. So this node is the
467
+ * reference: it reads a frame, replays every step from zero, and depends on
468
+ * no state whatsoever. Scrubbing is O(frame) and that is the honest cost —
469
+ * a checkpoint cache makes it fast later without changing a single output,
470
+ * which is exactly the property a cache should have.
471
+ */
472
+ substeps: { type: "int", default: 1, min: 1, max: 32 },
473
+ timestep: { type: "float", default: 1 / 24, min: 1 / 240, max: 1, step: 1 / 240 },
474
+ /** Per frame, as on Source. */
475
+ impulse: { type: "int", default: 6, min: 0, max: 100000 },
476
+ life: { type: "float", default: 4, min: 0, max: 3600, step: 0.1 },
477
+ lifevar: { type: "float", default: 0.4, min: 0, max: 1, step: 0.01 },
478
+ force: { type: "vec2", default: [0, 0] },
479
+ airresist: { type: "float", default: 0.4, min: 0, max: 20, step: 0.05 },
480
+ noise_amplitude: { type: "float", default: 14, min: 0, max: 500, step: 0.5 },
481
+ noise_frequency: { type: "float", default: 0.02, min: 0.001, max: 10, step: 0.001 },
482
+ /** Turns per second of field evolution. */
483
+ noise_evolve: { type: "float", default: 0.08, min: -10, max: 10, step: 0.01 },
484
+ /** Toward the spine, up the field's gradient. */
485
+ field_normal: { type: "float", default: 0, min: -20000, max: 20000, step: 1 },
486
+ /** Around the outline, perpendicular to the gradient. Sign picks which way. */
487
+ field_tangential: { type: "float", default: 0, min: -20000, max: 20000, step: 1 },
488
+ /** Field samples beyond this are ignored, and it is the grid's cell size. */
489
+ field_radius: { type: "float", default: 24, min: 0.5, max: 2000, step: 0.5 },
490
+ /**
491
+ * The contour to hold, as a field level 0..1, with `field_hold` as how
492
+ * firmly.
493
+ *
494
+ * Marcus: *"the lines flow tangentially around the letters in a bundle …
495
+ * loosely describing the type."* A bundle is a contour, so holding a level
496
+ * and letting the tangent carry particles round it is the mechanism —
497
+ * pulling them to the spine instead collapses each letter to a point, and
498
+ * pure tangential flow lets every particle converge onto one level set.
499
+ */
500
+ field_level: { type: "float", default: 0.35, min: 0, max: 1, step: 0.01 },
501
+ field_hold: { type: "float", default: 0, min: 0, max: 40000, step: 10 },
502
+ /**
503
+ * Scatter births across this box instead of on the source geometry's
504
+ * points, `[minX, minY, maxX, maxY]`. All zero uses the points.
505
+ *
506
+ * Houdini's POP Source has the same choice — *Scatter onto Surface* against
507
+ * *Points* — and Marcus's note is that the original spawns *around* the
508
+ * type rather than on it: *"The particles should spawn in the area around
509
+ * the type … and then the forces should drag them around."* Birthing on
510
+ * the letterforms puts every particle where it is already going, so
511
+ * nothing travels and the field has nothing to reveal.
512
+ */
513
+ birth_area: { type: "vec4", default: [0, 0, 0, 0] },
514
+ /**
515
+ * A colour per particle, drawn at birth and fixed for its life.
516
+ *
517
+ * Hashed on the id, so the same particle gets the same colour however many
518
+ * times the frame is re-simulated — a colour drawn from a stream would
519
+ * change on every scrub, which reads as the render being unstable.
520
+ *
521
+ * `[from, to]` in turns around the wheel. Equal values give one hue; a
522
+ * narrow span is usually what a piece wants, because a full-spectrum
523
+ * random set reads as a test pattern rather than as a palette.
524
+ */
525
+ birth_hue: { type: "vec2", default: [0, 0], min: 0, max: 1, step: 0.01 },
526
+ birth_saturation: { type: "float", default: 0.6, min: 0, max: 1, step: 0.01 },
527
+ birth_value: { type: "float", default: 1, min: 0, max: 1, step: 0.01 },
528
+ /** How hard the targets pull. Houdini's POP Attract strength. */
529
+ attract_amplitude: { type: "float", default: 0, min: 0, max: 20000, step: 1 },
530
+ /** Beyond this distance a target is ignored, which is what keeps the pull
531
+ * local and the cost bounded. 0 means every target pulls every particle. */
532
+ attract_radius: { type: "float", default: 60, min: 0, max: 5000, step: 1 },
533
+ separate_radius: { type: "float", default: 0, min: 0, max: 500, step: 0.5 },
534
+ separate_strength: { type: "float", default: 12, min: 0, max: 500, step: 0.5 },
535
+ maxspeed: { type: "float", default: 60, min: 0, max: 10000, step: 1 },
536
+ /** `[minX, minY, maxX, maxY]`, or all zero for open space. The original
537
+ * piece is toroidal. */
538
+ wrap: { type: "vec4", default: [0, 0, 0, 0] },
539
+ seed: { type: "int", default: 7, min: 0, max: 999999 },
540
+ },
541
+ };
542
+ /**
543
+ * Everything that determines this simulation, as one string.
544
+ *
545
+ * **The whole safety argument for the checkpoint cache is here.** A key on
546
+ * anything less than everything serves a stale state that looks plausible, and
547
+ * a wrong particle system is indistinguishable from a different-looking one. So
548
+ * this covers every prop the stepping reads and the *content* of every geometry
549
+ * it reads, and it deliberately does not try to be clever: a fingerprint that
550
+ * is cheap and complete beats one that is minimal and subtle.
551
+ *
552
+ * The geometries are hashed rather than counted. Counting points would miss a
553
+ * field whose word changed from "Cascade" to "Cascada" at the same resolution —
554
+ * same point count, different letters, and the cache would happily replay the
555
+ * old word. Hashing is O(n) once per cook against O(n·frames) of stepping, so
556
+ * it is not the cost that matters.
557
+ *
558
+ * `frame` is NOT in it, because the frame is what the cache is indexed by.
559
+ * Putting it in the key would give every frame its own entry and no reuse,
560
+ * which is a cache that stores everything and accelerates nothing.
561
+ */
562
+ function simulationFingerprint(props, geometries) {
563
+ const parts = [];
564
+ for (const [name, value] of Object.entries(props)) {
565
+ // trail_length and trail_increment change what is DRAWN, not what is
566
+ // simulated, so they are excluded — including them would throw away a
567
+ // perfectly good simulation every time the trail length was dragged,
568
+ // which is exactly the parameter someone drags while watching.
569
+ if (name === "trail_length" || name === "trail_increment")
570
+ continue;
571
+ parts.push(`${name}=${JSON.stringify(value)}`);
572
+ }
573
+ for (const geometry of geometries) {
574
+ parts.push(geometry ? geometryFingerprint(geometry) : "none");
575
+ }
576
+ return parts.join("|");
577
+ }
578
+ /** A cheap content hash: counts, then every numeric attribute folded in. */
579
+ function geometryFingerprint(geometry) {
580
+ let hash = 2166136261;
581
+ const fold = (value) => {
582
+ hash ^= value | 0;
583
+ hash = Math.imul(hash, 16777619);
584
+ };
585
+ fold(geometry.pointCount);
586
+ fold(geometry.primitiveCount);
587
+ for (const [name, attribute] of Object.entries(geometry.point)) {
588
+ for (let index = 0; index < name.length; index += 1)
589
+ fold(name.charCodeAt(index));
590
+ if (attribute.storage === "string")
591
+ continue;
592
+ const data = attribute.data;
593
+ // Every value, because a field's whole meaning is its values — and this is
594
+ // the input that changes when the word does.
595
+ for (let index = 0; index < data.length; index += 1)
596
+ fold(Math.round(Number(data[index]) * 4096));
597
+ }
598
+ return (hash >>> 0).toString(36);
599
+ }
600
+ export function executeSimulate(context) {
601
+ const props = context.props;
602
+ const from = context.inputs.geometry;
603
+ const frames = Math.max(0, Math.floor(context.inputs.frame));
604
+ let state = ParticleState.fromGeometry(emptyParticleGeometry(2));
605
+ // Bounded by the trail length: the whole point of a window is that it is
606
+ // smaller than the simulation, so keeping every frame would give up the
607
+ // property that makes trails cheaper than a state cache.
608
+ const history = [];
609
+ let startFrame = 1;
610
+ const available = from ? from.pointCount : 0;
611
+ const source = from?.point.P;
612
+ // Read once for the whole replay: none of these change within a cook.
613
+ const targets = context.inputs.attract;
614
+ const flow = context.inputs.field;
615
+ const scatter = props.birth_area.some((value) => value !== 0)
616
+ ? props.birth_area
617
+ : null;
618
+ // Scattering needs no source points: the box is the source. Requiring a
619
+ // wired geometry to scatter inside a box would be a dependency on nothing.
620
+ const canBirth = scatter !== null || (available > 0 && source !== undefined);
621
+ const wrap = props.wrap.some((value) => value !== 0)
622
+ ? [props.wrap[0], props.wrap[1], props.wrap[2], props.wrap[3]]
623
+ : undefined;
624
+ /** Resume early enough to replay the complete bounded trail window. A
625
+ * checkpoint is state after its frame, so the lookup stops before the first
626
+ * frame the trail needs to retain. */
627
+ const fingerprint = simulationFingerprint(props, [from, targets, flow]);
628
+ const keep = props.trail_length * props.trail_increment;
629
+ const resumed = checkpointAtOrBefore(context.nodeId, fingerprint, Math.max(0, frames - keep));
630
+ if (resumed) {
631
+ state = resumed.state;
632
+ startFrame = resumed.frame + 1;
633
+ }
634
+ for (let frame = startFrame; frame <= frames; frame += 1) {
635
+ if (canBirth && props.impulse > 0) {
636
+ const born = scatter ? props.impulse : Math.min(props.impulse, available);
637
+ const position = new Float64Array(born * 2);
638
+ const life = new Float32Array(born);
639
+ // Only when a span is asked for: an all-white `Cd` would still be a `Cd`,
640
+ // and it would override the export's own stroke colour with white.
641
+ const colours = props.birth_hue[0] !== props.birth_hue[1]
642
+ || props.birth_saturation !== 0
643
+ ? new Float32Array(born * 4)
644
+ : null;
645
+ for (let index = 0; index < born; index += 1) {
646
+ const id = state.nextId + index;
647
+ if (scatter) {
648
+ // Hashed on the id, so a particle is born in the same place however
649
+ // many times the frame is re-simulated. Two draws from one hash
650
+ // stream would correlate x with y into a diagonal, so the second
651
+ // takes a different sample.
652
+ position[index * 2] = scatter[0] + birthRandom(props.seed, id * 2) * (scatter[2] - scatter[0]);
653
+ position[index * 2 + 1] = scatter[1] + birthRandom(props.seed, id * 2 + 1) * (scatter[3] - scatter[1]);
654
+ }
655
+ else {
656
+ // Walk the source's points across frames rather than restarting at
657
+ // zero, so a small impulse eventually covers the whole shape instead
658
+ // of birthing from the same handful of points forever.
659
+ const pick = (frame * born + index) % available;
660
+ const points = source.data;
661
+ position[index * 2] = Number(points[pick * source.size] ?? 0);
662
+ position[index * 2 + 1] = Number(points[pick * source.size + 1] ?? 0);
663
+ }
664
+ const jitter = birthRandom(props.seed, id);
665
+ life[index] = props.life * (1 - props.lifevar * jitter);
666
+ if (colours) {
667
+ // A third hash sample, distinct from the two the scatter uses and
668
+ // the one the life uses: drawing twice from the same sample would
669
+ // correlate a particle's colour with where it was born.
670
+ const hue = props.birth_hue[0]
671
+ + birthRandom(props.seed, id * 4 + 3) * (props.birth_hue[1] - props.birth_hue[0]);
672
+ const [r, g, b] = hsvToRgb(hue, props.birth_saturation, props.birth_value);
673
+ colours[index * 4] = r;
674
+ colours[index * 4 + 1] = g;
675
+ colours[index * 4 + 2] = b;
676
+ colours[index * 4 + 3] = 1;
677
+ }
678
+ }
679
+ state = state.born({ position, life, ...(colours ? { colour: colours } : {}) });
680
+ }
681
+ const forces = [];
682
+ if (props.force[0] !== 0 || props.force[1] !== 0)
683
+ forces.push(gravity(props.force));
684
+ if (props.airresist > 0)
685
+ forces.push(drag(props.airresist));
686
+ if (props.noise_amplitude > 0) {
687
+ forces.push(noiseField({
688
+ seed: props.seed,
689
+ frequency: props.noise_frequency,
690
+ amplitude: props.noise_amplitude,
691
+ // Derived from the frame the caller stated, not from a clock — which
692
+ // is what keeps the whole node a pure function of `frame`.
693
+ evolve: frame * props.timestep * props.noise_evolve,
694
+ }));
695
+ }
696
+ if (flow && flow.pointCount > 0 && flow.point.N
697
+ && (props.field_normal !== 0 || props.field_tangential !== 0)) {
698
+ forces.push(flowField({
699
+ positions: flow.point.P.data,
700
+ positionSize: flow.point.P.size,
701
+ directions: flow.point.N.data,
702
+ ...(flow.point.level && flow.point.level.storage !== "string"
703
+ ? { levels: flow.point.level.data }
704
+ : {}),
705
+ count: flow.pointCount,
706
+ normal: props.field_normal,
707
+ tangential: props.field_tangential,
708
+ radius: props.field_radius,
709
+ level: props.field_level,
710
+ hold: props.field_hold,
711
+ }));
712
+ }
713
+ if (targets && targets.pointCount > 0 && props.attract_amplitude > 0) {
714
+ forces.push(attract({
715
+ targets: targets.point.P.data,
716
+ targetSize: targets.point.P.size,
717
+ targetCount: targets.pointCount,
718
+ amplitude: props.attract_amplitude,
719
+ radius: props.attract_radius,
720
+ }));
721
+ }
722
+ if (props.separate_radius > 0) {
723
+ forces.push(separation({
724
+ radius: props.separate_radius,
725
+ strength: props.separate_strength,
726
+ }));
727
+ }
728
+ for (let substep = 0; substep < props.substeps; substep += 1) {
729
+ state = step(state, forces, {
730
+ timestep: props.timestep / props.substeps,
731
+ ...(props.maxspeed > 0 ? { maxSpeed: props.maxspeed } : {}),
732
+ ...(wrap ? { wrap } : {}),
733
+ });
734
+ }
735
+ history.push({
736
+ id: Int32Array.from(state.id),
737
+ position: Float64Array.from(state.position),
738
+ size: state.size,
739
+ });
740
+ if (history.length > keep)
741
+ history.splice(0, history.length - keep);
742
+ offerCheckpoint(context.nodeId, fingerprint, frame, state);
743
+ }
744
+ const geometry = state.toGeometry();
745
+ context.outputs.geometry.set(geometry);
746
+ context.outputs.trails.set(buildTrails(history, geometry, {
747
+ result: "polylines",
748
+ length: props.trail_length,
749
+ increment: props.trail_increment,
750
+ }));
751
+ }
752
+ export const simulateRegistration = {
753
+ kind: "definition-v1",
754
+ moduleId: "cascade.pop.Simulate",
755
+ definition: simulateDefinition,
756
+ loadExecute: async () => executeSimulate,
757
+ };
758
+ export const sourceRegistration = {
759
+ kind: "definition-v1",
760
+ moduleId: "cascade.pop.Source",
761
+ definition: sourceDefinition,
762
+ loadExecute: async () => executeSource,
763
+ };
764
+ export const solverRegistration = {
765
+ kind: "definition-v1",
766
+ moduleId: "cascade.pop.Solver",
767
+ definition: solverDefinition,
768
+ loadExecute: async () => executeSolver,
769
+ };
770
+ export const noiseForceRegistration = {
771
+ kind: "definition-v1",
772
+ moduleId: "cascade.pop.NoiseForce",
773
+ definition: noiseForceDefinition,
774
+ loadExecute: async () => executeNoiseForce,
775
+ };
776
+ export const separateRegistration = {
777
+ kind: "definition-v1",
778
+ moduleId: "cascade.pop.Separate",
779
+ definition: separateDefinition,
780
+ loadExecute: async () => executeSeparate,
781
+ };
782
+ export const fieldForceRegistration = {
783
+ kind: "definition-v1",
784
+ moduleId: "cascade.pop.FieldForce",
785
+ definition: fieldForceDefinition,
786
+ loadExecute: async () => executeFieldForce,
787
+ };
788
+ export const trailRegistration = {
789
+ kind: "definition-v1",
790
+ moduleId: "cascade.pop.Trail",
791
+ definition: trailDefinition,
792
+ loadExecute: async () => executeTrail,
793
+ };
794
+ export const popNodeRegistrations = Object.freeze([
795
+ simulateRegistration,
796
+ sourceRegistration,
797
+ solverRegistration,
798
+ noiseForceRegistration,
799
+ separateRegistration,
800
+ fieldForceRegistration,
801
+ trailRegistration,
802
+ ]);
803
+ /** `[moduleId, definition]` pairs, for a host building a node palette. The
804
+ * same shape `geoNodeDefinitions` has, so Studio's library builder needs no
805
+ * second branch. */
806
+ export const popNodeDefinitions = Object.freeze([
807
+ ["cascade.pop.Simulate", simulateDefinition],
808
+ ["cascade.pop.Source", sourceDefinition],
809
+ ["cascade.pop.Solver", solverDefinition],
810
+ ["cascade.pop.NoiseForce", noiseForceDefinition],
811
+ ["cascade.pop.Separate", separateDefinition],
812
+ ["cascade.pop.FieldForce", fieldForceDefinition],
813
+ ["cascade.pop.Trail", trailDefinition],
814
+ ]);
815
+ //# sourceMappingURL=definitions.js.map