@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.
- package/AGENTS.md +32 -9
- package/ARCHITECTURE.md +75 -13
- package/CHANGELOG.md +98 -1
- package/CONTRIBUTING.md +104 -0
- package/DESIGN.md +24 -15
- package/README.md +121 -14
- package/dist/assets/{CodeEditor-D22fVi-4.js → CodeEditor-dOrU3aQg.js} +7 -7
- package/dist/assets/{CodePanel-DPNEYfcR.js → CodePanel-Bqy85qen.js} +2 -2
- package/dist/assets/{CustomNodeDialog-DeHCAvgb.js → CustomNodeDialog-DkZlOgjs.js} +1 -1
- package/dist/assets/{ProjectSettings-DWpVtWEx.js → ProjectSettings-D192mr6g.js} +2 -2
- package/dist/assets/{SettingsDialog-za00IS3L.js → SettingsDialog-BTeeIE3T.js} +1 -1
- package/dist/assets/{VersionHistoryDialog-DTMGhI0_.js → VersionHistoryDialog-TZQKkn3Y.js} +1 -1
- package/dist/assets/{core-JsyKMO1k.js → core-DwbcGL0o.js} +2 -2
- package/dist/assets/{cssMode-emdRheFK.js → cssMode-Dcjn356x.js} +1 -1
- package/dist/assets/definitions-C_SY6Bhg.js +1 -0
- package/dist/assets/{editor.api-xHX-fMSo.js → editor.api-D7Q83606.js} +1 -1
- package/dist/assets/{freemarker2-C3M8_Gs3.js → freemarker2-DCCiHlV6.js} +1 -1
- package/dist/assets/geo-CT-wtvaE.js +1 -0
- package/dist/assets/{handlebars-CBMurtFk.js → handlebars-BCxC8xu1.js} +1 -1
- package/dist/assets/{html-xbLQwknw.js → html-COwepzSd.js} +1 -1
- package/dist/assets/{htmlMode-2wKdDTon.js → htmlMode-Dpg7H0rx.js} +1 -1
- package/dist/assets/{image-i1j6nWKe.js → image-CMfMMxlC.js} +3 -3
- package/dist/assets/index-De1cIZ9N.js +212 -0
- package/dist/assets/index-ilgbOu6G.css +1 -0
- package/dist/assets/{javascript-DJaOo_2I.js → javascript-CSwWxfLr.js} +1 -1
- package/dist/assets/{jsonMode-Bheas8_0.js → jsonMode-BXRriTpj.js} +1 -1
- package/dist/assets/{liquid-BCj_BGbM.js → liquid-Cmkfyogc.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-BfIsx_xz.js → lspLanguageFeatures-xH9JbZGO.js} +1 -1
- package/dist/assets/{mdx-DwiQDiVO.js → mdx-BzrNjNvK.js} +1 -1
- package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
- package/dist/assets/pop-BdKEHslJ.js +1 -0
- package/dist/assets/{python-BjrB5nqj.js → python-D4E9jbx7.js} +1 -1
- package/dist/assets/{razor-CfoBmGDs.js → razor-B3x9cIEM.js} +1 -1
- package/dist/assets/{tsMode-BcEoMSxp.js → tsMode-dhQYPNMZ.js} +1 -1
- package/dist/assets/{typescript-DIg7TEmQ.js → typescript-CITkDxAE.js} +1 -1
- package/dist/assets/{workers-vXYuwtPw.js → workers-BQvF9bbO.js} +1 -1
- package/dist/assets/{xml-DexLQIQy.js → xml-DvHlU3A8.js} +1 -1
- package/dist/assets/{yaml-DUnD_1dE.js → yaml-uznsLgs8.js} +1 -1
- package/dist/cli/index.js +16465 -12096
- package/dist/cli/runtime/gpu.ts +71 -0
- package/dist/cli/runtime/io.ts +31 -25
- package/dist/contracts/camera.d.ts +70 -0
- package/dist/contracts/camera.js +27 -0
- package/dist/contracts/capabilities.d.ts +6 -6
- package/dist/contracts/definition.d.ts +62 -4
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/particles.d.ts +54 -0
- package/dist/contracts/particles.js +167 -0
- package/dist/contracts/scene.d.ts +129 -0
- package/dist/contracts/scene.js +73 -0
- package/dist/contracts/schema.js +26 -3
- package/dist/contracts/series.d.ts +75 -0
- package/dist/contracts/series.js +2 -0
- package/dist/contracts/validate.js +23 -5
- package/dist/contracts/values.d.ts +4 -2
- package/dist/contracts/values.js +10 -0
- package/dist/gpu.d.ts +17 -0
- package/dist/gpu.js +61 -1
- package/dist/index.html +3 -3
- package/dist/io.d.ts +4 -16
- package/dist/io.js +26 -8
- package/dist/player/app.js +810 -0
- package/dist/player/embed.d.ts +4 -0
- package/dist/player/embed.js +77 -0
- package/dist/player/types.d.ts +35 -0
- package/dist/runtime/builtins/core/camera.d.ts +252 -0
- package/dist/runtime/builtins/core/camera.js +149 -0
- package/dist/runtime/builtins/core/index.d.ts +4 -2
- package/dist/runtime/builtins/core/index.js +8 -2
- package/dist/runtime/builtins/core/structural.d.ts +168 -0
- package/dist/runtime/builtins/core/structural.js +175 -0
- package/dist/runtime/builtins/core/time.d.ts +165 -0
- package/dist/runtime/builtins/core/time.js +110 -0
- package/dist/runtime/builtins/geo/definitions.d.ts +247 -0
- package/dist/runtime/builtins/geo/definitions.js +70 -0
- package/dist/runtime/builtins/geo/index.d.ts +2 -1
- package/dist/runtime/builtins/geo/index.js +3 -1
- package/dist/runtime/builtins/geo/render.d.ts +87 -0
- package/dist/runtime/builtins/geo/render.js +87 -0
- package/dist/runtime/builtins/geo/svgExport.d.ts +11 -0
- package/dist/runtime/builtins/geo/svgExport.js +10 -0
- package/dist/runtime/builtins/index.d.ts +5 -2
- package/dist/runtime/builtins/index.js +13 -3
- package/dist/runtime/builtins/pop/definitions.d.ts +2120 -0
- package/dist/runtime/builtins/pop/definitions.js +815 -0
- package/dist/runtime/builtins/pop/index.d.ts +2 -0
- package/dist/runtime/builtins/pop/index.js +2 -0
- package/dist/runtime/builtins/scene/definitions.d.ts +356 -0
- package/dist/runtime/builtins/scene/definitions.js +224 -0
- package/dist/runtime/builtins/scene/index.d.ts +2 -0
- package/dist/runtime/builtins/scene/index.js +2 -0
- package/dist/runtime/camera/index.d.ts +60 -0
- package/dist/runtime/camera/index.js +234 -0
- package/dist/runtime/definition/architecture.js +36 -1
- package/dist/runtime/geometry/render.d.ts +109 -0
- package/dist/runtime/geometry/render.js +145 -0
- package/dist/runtime/geometry/svg.d.ts +29 -0
- package/dist/runtime/geometry/svg.js +11 -5
- package/dist/runtime/gpu.d.ts +33 -0
- package/dist/runtime/gpu.js +218 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/pop/cache.d.ts +27 -0
- package/dist/runtime/pop/cache.js +123 -0
- package/dist/runtime/pop/forces.d.ts +202 -0
- package/dist/runtime/pop/forces.js +519 -0
- package/dist/runtime/pop/index.d.ts +5 -0
- package/dist/runtime/pop/index.js +5 -0
- package/dist/runtime/pop/state.d.ts +136 -0
- package/dist/runtime/pop/state.js +283 -0
- package/dist/runtime/pop/trail.d.ts +62 -0
- package/dist/runtime/pop/trail.js +142 -0
- package/dist/runtime/runtime.js +284 -9
- package/dist/runtime/scene/index.d.ts +50 -0
- package/dist/runtime/scene/index.js +94 -0
- package/dist/runtime/series/index.d.ts +41 -0
- package/dist/runtime/series/index.js +167 -0
- package/dist/runtime/types.d.ts +1 -1
- package/dist/studio/panel.d.ts +68 -1
- package/doc/HEADLESS_GPU.md +160 -0
- package/doc/NODE_REFERENCE.md +408 -1
- package/doc/PROJECT_AUTHORING.md +118 -20
- package/doc/WEB_PLAYER.md +131 -0
- package/package.json +25 -3
- package/dist/assets/geo-BAR6lQp5.js +0 -1
- package/dist/assets/index-BDV5xGBZ.css +0 -1
- package/dist/assets/index-CMcH8AgP.js +0 -212
- 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,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
|