@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,234 @@
1
+ /**
2
+ * Everything derived from a `camera`, so that no renderer derives it twice.
3
+ *
4
+ * The type stores intent — a transform, a lens, a frame — and every consumer
5
+ * wants something else: a rasteriser wants matrices, a raymarcher wants the
6
+ * basis vectors directly, and a UI wants the angle in degrees. Deriving those
7
+ * here is the same argument as `resolvePropBinding`: one implementation, or the
8
+ * second one disagrees with the first about handedness in a way that takes an
9
+ * afternoon to find.
10
+ *
11
+ * Houdini's conventions throughout: the camera looks down its own **-Z** with
12
+ * **+Y** up, rotation is degrees applied in **XYZ** order, and the vertical
13
+ * aperture is derived from the horizontal one and the frame shape rather than
14
+ * stored.
15
+ *
16
+ * **Matching a frame is one number, never two.** Both angles come from the same
17
+ * `focal`, `aperture` and resolution ratio, so a `focal` chosen to reproduce a
18
+ * known horizontal angle reproduces the vertical one at the same time. Worked
19
+ * example, from converting `cloud-volumes` off its hand-rolled projection on
20
+ * 2026-09-08: that sketch pinned its **vertical** half-tangent at `0.36` and
21
+ * scaled the horizontal by aspect, which on a 1098×1512 page gave a 29.3°
22
+ * horizontal angle; `focal = (aperture / 2) / 0.261 ≈ 79.2 mm` reproduces it,
23
+ * and the vertical lands back on `0.36` without being asked. Which is also the
24
+ * reason that sketch's `fit` parameter had to go — it backed the camera off to
25
+ * win back horizontal coverage a portrait page had lost, and that is the same
26
+ * compensation this module already does in the projection.
27
+ */
28
+ const DEG = Math.PI / 180;
29
+ function cross(a, b) {
30
+ return [
31
+ a[1] * b[2] - a[2] * b[1],
32
+ a[2] * b[0] - a[0] * b[2],
33
+ a[0] * b[1] - a[1] * b[0],
34
+ ];
35
+ }
36
+ function normalize(v) {
37
+ const length = Math.hypot(v[0], v[1], v[2]);
38
+ if (length === 0)
39
+ return [0, 0, 0];
40
+ return [v[0] / length, v[1] / length, v[2] / length];
41
+ }
42
+ function subtract(a, b) {
43
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
44
+ }
45
+ function dot(a, b) {
46
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
47
+ }
48
+ /**
49
+ * The rotation the camera needs to look at a point, as Houdini's look-at does.
50
+ *
51
+ * Returned as degrees in XYZ order so it can be written straight into
52
+ * `camera.rotate` — which is what makes look-at an override of the transform
53
+ * rather than a second, parallel way to orient a camera.
54
+ *
55
+ * **`up` chooses the roll, and only the roll.** Yaw and pitch are fixed the
56
+ * moment a direction is given; all an up vector can still decide is the
57
+ * rotation about that direction. The default `[0, 1, 0]` is Houdini's own
58
+ * `upvector` default and gives roll zero for any horizontal up, which is why
59
+ * every ordinary orbit never needs to think about it.
60
+ *
61
+ * Written first with `roll = 0` and the parameter ignored, which was wrong in
62
+ * the way this codebase keeps finding: the node exposes `up` as an input, so a
63
+ * value set there did nothing at all and nothing said so.
64
+ */
65
+ export function lookAtRotation(from, to, up = [0, 1, 0]) {
66
+ const forward = normalize(subtract(to, from));
67
+ if (forward[0] === 0 && forward[1] === 0 && forward[2] === 0)
68
+ return [0, 0, 0];
69
+ // Pitch first, because yaw is measured in the horizontal plane and the
70
+ // horizontal length is what is left of the vector once pitch is taken out.
71
+ const horizontal = Math.hypot(forward[0], forward[2]);
72
+ const pitch = Math.atan2(forward[1], horizontal);
73
+ // Looking down -Z at rest, so a forward of (0, 0, -1) must give a yaw of 0.
74
+ const yaw = Math.atan2(-forward[0], -forward[2]);
75
+ return [pitch / DEG, yaw / DEG, rollTowards(forward, pitch, yaw, up) / DEG];
76
+ }
77
+ /**
78
+ * The rotation about `forward` that takes the world-up-locked up vector onto
79
+ * the requested one.
80
+ *
81
+ * Only the component of `up` perpendicular to `forward` can be honoured — the
82
+ * parallel component is asking the camera to look along its own up, which is
83
+ * not a rotation. So it is projected out, and an up parallel to forward leaves
84
+ * nothing to aim at and returns zero rather than a NaN.
85
+ */
86
+ function rollTowards(forward, pitch, yaw, up) {
87
+ const desired = normalize([
88
+ up[0] - forward[0] * dot(up, forward),
89
+ up[1] - forward[1] * dot(up, forward),
90
+ up[2] - forward[2] * dot(up, forward),
91
+ ]);
92
+ if (desired[0] === 0 && desired[1] === 0 && desired[2] === 0)
93
+ return 0;
94
+ // The up this camera has with no roll, taken from the basis rather than
95
+ // rebuilt, so the two cannot drift apart.
96
+ const locked = cameraBasis({
97
+ ...CAMERA_ROTATION_ONLY,
98
+ rotate: [pitch / DEG, yaw / DEG, 0],
99
+ }).up;
100
+ // `cross(desired, locked)` rather than the other way round: a positive
101
+ // `rotate.z` carries the camera's up toward its own -X, so the angle that
102
+ // takes `locked` onto `desired` runs the opposite way.
103
+ return Math.atan2(dot(cross(desired, locked), forward), dot(locked, desired));
104
+ }
105
+ /** Only `rotate` is read by `cameraBasis`, so the rest is filler rather than a
106
+ * claim about any real camera. */
107
+ const CAMERA_ROTATION_ONLY = {
108
+ translate: [0, 0, 0],
109
+ focal: 50,
110
+ aperture: 41.4214,
111
+ near: 0.001,
112
+ far: 10000,
113
+ resolution: [1, 1],
114
+ aspect: 1,
115
+ projection: "perspective",
116
+ orthowidth: 2,
117
+ };
118
+ /** The camera's axes, applying Houdini's XYZ rotation order to a camera that
119
+ * looks down -Z at rest. */
120
+ export function cameraBasis(camera) {
121
+ const [rx, ry, rz] = camera.rotate;
122
+ const sx = Math.sin(rx * DEG);
123
+ const cx = Math.cos(rx * DEG);
124
+ const sy = Math.sin(ry * DEG);
125
+ const cy = Math.cos(ry * DEG);
126
+ const sz = Math.sin(rz * DEG);
127
+ const cz = Math.cos(rz * DEG);
128
+ /**
129
+ * `R = Ry * Rx * Rz` on a column vector: yaw about world +Y, then pitch
130
+ * about the camera's own right, then roll about its own view direction.
131
+ *
132
+ * This is the third order tried and the reason is worth writing down, because
133
+ * two of them are indistinguishable by almost every test. With `rotate.z` at
134
+ * zero all three agree exactly — so yaw, pitch, look-at and orthonormality
135
+ * passed under each, and only the roll separates them. Under `Rz * Ry * Rx`,
136
+ * written first, `rotate.z` turns the camera about the WORLD z axis, which
137
+ * looks like a roll for as long as the camera faces -Z and stops being one
138
+ * the moment it does not. Under `Rx * Ry * Rz` the yaw stops being a yaw
139
+ * about world up, which is what an orbit needs. This order is the one every
140
+ * camera rig uses, and it is what banking a camera means.
141
+ */
142
+ const m00 = cy * cz + sy * sx * sz;
143
+ const m01 = -cy * sz + sy * sx * cz;
144
+ const m02 = sy * cx;
145
+ const m10 = cx * sz;
146
+ const m11 = cx * cz;
147
+ const m12 = -sx;
148
+ const m20 = -sy * cz + cy * sx * sz;
149
+ const m21 = sy * sz + cy * sx * cz;
150
+ const m22 = cy * cx;
151
+ const right = [m00, m10, m20];
152
+ const up = [m01, m11, m21];
153
+ // Its own -Z, which is the whole convention in one line.
154
+ const forward = [-m02, -m12, -m22];
155
+ return { eye: camera.translate, forward, right, up };
156
+ }
157
+ /**
158
+ * The horizontal field of view in degrees, from the lens rather than stored.
159
+ *
160
+ * Houdini's defaults of 50 mm and 41.4214 mm are exactly 45°, which is the
161
+ * check worth having in a test: it is the one value that proves the formula and
162
+ * the defaults agree.
163
+ */
164
+ export function horizontalFov(camera) {
165
+ if (camera.focal === 0)
166
+ return 0;
167
+ return (2 * Math.atan(camera.aperture / (2 * camera.focal))) / DEG;
168
+ }
169
+ /**
170
+ * The VERTICAL field of view in degrees.
171
+ *
172
+ * Houdini does not store a vertical aperture; it derives one from the
173
+ * horizontal aperture, the resolution ratio and the pixel aspect. Which is why
174
+ * `resolution` is on the camera at all — without it there is no vertical angle
175
+ * to be had.
176
+ */
177
+ export function verticalFov(camera) {
178
+ if (camera.focal === 0)
179
+ return 0;
180
+ const [width, height] = camera.resolution;
181
+ if (width === 0 || camera.aspect === 0)
182
+ return 0;
183
+ const verticalAperture = (camera.aperture * height) / (width * camera.aspect);
184
+ return (2 * Math.atan(verticalAperture / (2 * camera.focal))) / DEG;
185
+ }
186
+ /** Frame aspect — the shape of the picture, pixel aspect included. Distinct
187
+ * from `camera.aspect`, which is the aspect of one pixel. */
188
+ export function frameAspect(camera) {
189
+ const [width, height] = camera.resolution;
190
+ if (height === 0)
191
+ return 1;
192
+ return (width * camera.aspect) / height;
193
+ }
194
+ /** Column-major, GLSL convention, matching `MATRIX_TYPES` in Studio: element 0
195
+ * starts the first COLUMN. */
196
+ export function viewMatrix(camera) {
197
+ const { eye, forward, right, up } = cameraBasis(camera);
198
+ // The view matrix is the inverse of the camera's transform, and for a
199
+ // rotation that inverse is the transpose — so the basis vectors become rows.
200
+ const back = [-forward[0], -forward[1], -forward[2]];
201
+ return [
202
+ right[0], up[0], back[0], 0,
203
+ right[1], up[1], back[1], 0,
204
+ right[2], up[2], back[2], 0,
205
+ -(right[0] * eye[0] + right[1] * eye[1] + right[2] * eye[2]),
206
+ -(up[0] * eye[0] + up[1] * eye[1] + up[2] * eye[2]),
207
+ -(back[0] * eye[0] + back[1] * eye[1] + back[2] * eye[2]),
208
+ 1,
209
+ ];
210
+ }
211
+ /** Column-major, and orthographic when the camera says so — `orthowidth` is a
212
+ * width in world units, so the height comes from the frame aspect. */
213
+ export function projectionMatrix(camera) {
214
+ const { near, far } = camera;
215
+ const aspect = frameAspect(camera);
216
+ if (camera.projection === "orthographic") {
217
+ const halfWidth = camera.orthowidth / 2;
218
+ const halfHeight = aspect === 0 ? halfWidth : halfWidth / aspect;
219
+ return [
220
+ 1 / halfWidth, 0, 0, 0,
221
+ 0, 1 / halfHeight, 0, 0,
222
+ 0, 0, -2 / (far - near), 0,
223
+ 0, 0, -(far + near) / (far - near), 1,
224
+ ];
225
+ }
226
+ const focalScale = (2 * camera.focal) / camera.aperture;
227
+ return [
228
+ focalScale, 0, 0, 0,
229
+ 0, focalScale * aspect, 0, 0,
230
+ 0, 0, -(far + near) / (far - near), -1,
231
+ 0, 0, (-2 * far * near) / (far - near), 0,
232
+ ];
233
+ }
234
+ //# sourceMappingURL=index.js.map
@@ -16,9 +16,24 @@ const AMBIENT_STATE = new Set([
16
16
  export function validateNodeModuleArchitecture(source, fileName, ts) {
17
17
  const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
18
18
  const diagnostics = [];
19
+ const exportsDefinition = file.statements.some((statement) => ts.isVariableStatement(statement) && isDefinition(statement));
19
20
  for (const statement of file.statements) {
20
21
  if (ts.isVariableStatement(statement) && !isDefinition(statement)) {
21
- add(statement, "module-state", "Node modules may not declare module-level values; use a pure function or pass state through ports, props, and declared capabilities");
22
+ // `icon` and `runsOn` get their own sentence, because the general
23
+ // message sends you looking for the wrong thing. A dynamic node declares
24
+ // them at module level and must — they are read by a pass over the
25
+ // source. A definition-v1 node declares them *inside* the definition,
26
+ // and the moment it does, the module-level copies become exactly the
27
+ // module state this rule forbids.
28
+ //
29
+ // Found the hard way on 2026-09-08: the first literal stub written for a
30
+ // converted shared node failed here, and the message talks about pure
31
+ // functions and capabilities while the actual fix is to move two lines
32
+ // into the object above them. That is not guessable from the error.
33
+ const superseded = supersededByDefinition(statement);
34
+ add(statement, "module-state", superseded
35
+ ? `\`${superseded}\` belongs inside the definition, not beside it — a definition-v1 node declares it as \`${superseded}: ...\` in \`export const definition\`, and a module-level copy is the module state this rule forbids. A dynamic node without a definition still declares it here.`
36
+ : "Node modules may not declare module-level values; use a pure function or pass state through ports, props, and declared capabilities");
22
37
  }
23
38
  else if (ts.isClassDeclaration(statement) ||
24
39
  ts.isEnumDeclaration(statement) ||
@@ -62,6 +77,26 @@ export function validateNodeModuleArchitecture(source, fileName, ts) {
62
77
  (ts.isMethodDeclaration(parent) && parent.name === node) ||
63
78
  (ts.isPropertyDeclaration(parent) && parent.name === node));
64
79
  }
80
+ /**
81
+ * The name of a declaration a `definition` has taken over, or null.
82
+ *
83
+ * Only reported when the file actually exports a definition: without one,
84
+ * `export const icon` is the correct and only way to state it, and telling
85
+ * somebody to move it into an object that does not exist would be worse than
86
+ * the general message.
87
+ */
88
+ function supersededByDefinition(statement) {
89
+ if (!exportsDefinition)
90
+ return null;
91
+ const declaration = statement.declarationList.declarations[0];
92
+ if (statement.declarationList.declarations.length !== 1)
93
+ return null;
94
+ if (!declaration || !ts.isIdentifier(declaration.name))
95
+ return null;
96
+ return declaration.name.text === "icon" || declaration.name.text === "runsOn"
97
+ ? declaration.name.text
98
+ : null;
99
+ }
65
100
  function isDefinition(statement) {
66
101
  return Boolean(statement.declarationList.flags & ts.NodeFlags.Const) &&
67
102
  Boolean(statement.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) &&
@@ -0,0 +1,109 @@
1
+ import { type Camera, type Color, type Geometry } from "@field/cascade/contracts";
2
+ /**
3
+ * Geometry to a raster, through the camera the render node was given.
4
+ *
5
+ * Step 2 of `PLAN viewport.md`, and the half that matters for correctness. The
6
+ * plan's central rule: **a scene view and a render node must agree, and the
7
+ * only way to guarantee that is both going through `packages/runtime/src/camera`.**
8
+ * If a viewport implements its own matrices, the two disagree about framing and
9
+ * every composition made in one is wrong in the other.
10
+ *
11
+ * So this file owns no projection maths at all. `viewMatrix` and
12
+ * `projectionMatrix` came from the camera module this morning, with Houdini's
13
+ * conventions already in them — down -Z, +Y up, `focal` and `aperture` rather
14
+ * than a field of view. All that happens here is the multiply and the raster.
15
+ *
16
+ * **Wireframe and points, deliberately, and not because shading is hard.** Every
17
+ * piece in this vault is lines: `cloud-volumes` is a volume, `particle-type` is
18
+ * trails, `cloud-plots` is marks. A shaded mode needs normals and a light and
19
+ * nothing here has a surface to put them on, so it would be a week spent on a
20
+ * mode with no caller.
21
+ */
22
+ export interface RenderOptions {
23
+ readonly camera: Camera;
24
+ /** Pixels. Independent of the camera's own `resolution`, which describes the
25
+ * frame the projection assumes — a preview renders small and frames the
26
+ * same. */
27
+ readonly size: readonly [number, number];
28
+ readonly background?: Color;
29
+ /** Fallback stroke for a primitive with no `Cd`. */
30
+ readonly stroke: Color;
31
+ readonly strokeWidth: number;
32
+ readonly opacity: number;
33
+ /** Radius in pixels for a point with no primitive to belong to. */
34
+ readonly pointRadius: number;
35
+ /** Draw unprimitived points at all. A trail system has thousands and they
36
+ * are usually noise beside the strokes. */
37
+ readonly drawPoints: boolean;
38
+ /** How to make a canvas. The caller has a host; this file does not. */
39
+ readonly surface: SurfaceFactory;
40
+ }
41
+ /**
42
+ * The canvas surface this needs, declared rather than imported.
43
+ *
44
+ * `packages/runtime` compiles with `lib: ["ES2022"]` and no DOM, deliberately:
45
+ * it is the host-agnostic half of the system and a `dom` lib would let a DOM
46
+ * assumption into it by accident. So rather than widening that, this states the
47
+ * dozen members it actually uses. Both hosts satisfy it — the browser natively,
48
+ * the headless host through @napi-rs/canvas — and the narrow shape is also the
49
+ * documentation of what a future host would have to provide.
50
+ */
51
+ export interface DrawingSurface {
52
+ readonly width: number;
53
+ readonly height: number;
54
+ getContext(kind: "2d"): DrawingContext | null;
55
+ /** How the raster leaves: both hosts return a PNG blob, and the asset
56
+ * capability takes bytes. */
57
+ convertToBlob(options?: {
58
+ type?: string;
59
+ }): Promise<{
60
+ arrayBuffer(): Promise<ArrayBuffer>;
61
+ }>;
62
+ }
63
+ export interface DrawingContext {
64
+ fillStyle: string;
65
+ strokeStyle: string;
66
+ lineWidth: number;
67
+ lineCap: string;
68
+ lineJoin: string;
69
+ globalAlpha: number;
70
+ fillRect(x: number, y: number, width: number, height: number): void;
71
+ beginPath(): void;
72
+ moveTo(x: number, y: number): void;
73
+ lineTo(x: number, y: number): void;
74
+ arc(x: number, y: number, radius: number, from: number, to: number): void;
75
+ stroke(): void;
76
+ fill(): void;
77
+ }
78
+ /**
79
+ * Where a surface comes from, injected rather than reached for.
80
+ *
81
+ * The first version called `new OffscreenCanvas(...)` directly and it was the
82
+ * wrong structure twice over. `packages/runtime` is the host-agnostic half of
83
+ * the system, so a global is exactly the assumption it should not make — and
84
+ * the test environment proved it immediately with *"OffscreenCanvas is not
85
+ * defined"*, because the global is installed by the CLI host and by the
86
+ * browser, not by node.
87
+ *
88
+ * So the host assumption lives in the node that has a host, and this file
89
+ * takes a factory. Which also makes the renderer testable, and a renderer that
90
+ * cannot be tested without a browser is the thing `PLAN viewport` argues
91
+ * against building.
92
+ */
93
+ export type SurfaceFactory = (width: number, height: number) => DrawingSurface;
94
+ /**
95
+ * A scene onto one surface: the background once, then every geometry through
96
+ * the same camera.
97
+ *
98
+ * Separate from `renderGeometry` only in that it draws N of them. The surface,
99
+ * the background and the matrices are set up once, which is the reason this is
100
+ * a function rather than a loop at the call site — a loop there would produce a
101
+ * surface per geometry and composite them, and compositing straight alpha over
102
+ * straight alpha darkens every overlap.
103
+ */
104
+ export declare function renderScene(geometry: readonly Geometry[], options: RenderOptions): DrawingSurface;
105
+ /** One geometry through a camera, on its own surface. */
106
+ export declare function renderGeometry(geometry: Geometry, options: RenderOptions): DrawingSurface;
107
+ /** The raster as PNG bytes, for the asset capability. */
108
+ export declare function renderToPng(surface: DrawingSurface): Promise<Uint8Array>;
109
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1,145 @@
1
+ import { POSITION_ATTRIBUTE, } from "@field/cascade/contracts";
2
+ import { projectionMatrix, viewMatrix } from "../camera/index.js";
3
+ import { numericAttribute, readElement } from "./attributes.js";
4
+ /**
5
+ * A canvas the host already has.
6
+ *
7
+ * Both hosts supply `OffscreenCanvas` — the browser natively, the headless host
8
+ * through @napi-rs/canvas — which is why the node is `portable` with no
9
+ * capability for drawing and only needs `assets` to write the result.
10
+ */
11
+ function css(colour) {
12
+ const [r, g, b, a = 1] = colour;
13
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
14
+ }
15
+ /** Column-major, GLSL convention, matching the camera module's own output. */
16
+ function multiply(matrix, x, y, z) {
17
+ return [0, 1, 2, 3].map((row) => matrix[row] * x + matrix[4 + row] * y + matrix[8 + row] * z + matrix[12 + row]);
18
+ }
19
+ /**
20
+ * A scene onto one surface: the background once, then every geometry through
21
+ * the same camera.
22
+ *
23
+ * Separate from `renderGeometry` only in that it draws N of them. The surface,
24
+ * the background and the matrices are set up once, which is the reason this is
25
+ * a function rather than a loop at the call site — a loop there would produce a
26
+ * surface per geometry and composite them, and compositing straight alpha over
27
+ * straight alpha darkens every overlap.
28
+ */
29
+ export function renderScene(geometry, options) {
30
+ const [width, height] = options.size;
31
+ const canvas = options.surface(Math.max(1, Math.round(width)), Math.max(1, Math.round(height)));
32
+ const context = canvas.getContext("2d");
33
+ if (!context)
34
+ throw new Error("no 2D context on this surface");
35
+ if (options.background && options.background[3] > 0) {
36
+ context.fillStyle = css(options.background);
37
+ context.fillRect(0, 0, canvas.width, canvas.height);
38
+ }
39
+ for (const one of geometry)
40
+ drawGeometry(context, canvas, one, options);
41
+ return canvas;
42
+ }
43
+ /** One geometry through a camera, on its own surface. */
44
+ export function renderGeometry(geometry, options) {
45
+ return renderScene([geometry], options);
46
+ }
47
+ /**
48
+ * Draw one geometry onto a context that is already sized and cleared.
49
+ *
50
+ * The matrices are rebuilt per geometry rather than hoisted, and that is
51
+ * deliberate: it costs two small matrix builds per geometry and it keeps this
52
+ * function a pure function of its arguments, so a caller cannot pass a view
53
+ * matrix that disagrees with the camera it also passed.
54
+ */
55
+ function drawGeometry(context, canvas, geometry, options) {
56
+ const view = viewMatrix(options.camera);
57
+ const projection = projectionMatrix(options.camera);
58
+ const position = geometry.point[POSITION_ATTRIBUTE];
59
+ if (!position || position.storage === "string")
60
+ return;
61
+ const size = position.size;
62
+ const points = position.data;
63
+ const projected = [];
64
+ for (let index = 0; index < geometry.pointCount; index += 1) {
65
+ const world = multiply(view, Number(points[index * size]), Number(points[index * size + 1]), size >= 3 ? Number(points[index * size + 2]) : 0);
66
+ const clip = multiply(projection, world[0], world[1], world[2]);
67
+ // The perspective divide. `w` is kept so a segment with one vertex behind
68
+ // the camera can be dropped rather than drawn mirrored — which is what
69
+ // dividing by a negative w produces, and it looks like a plausible line.
70
+ const w = clip[3];
71
+ projected.push({
72
+ x: (clip[0] / w + 1) * 0.5 * canvas.width,
73
+ y: (1 - (clip[1] / w + 1) * 0.5) * canvas.height,
74
+ w,
75
+ });
76
+ }
77
+ // Validated to the sizes the style vocabulary declares, so a `Cd` with three
78
+ // components is refused rather than read as a colour with a missing channel.
79
+ // That rule is PLAN geometry's and SvgExport already enforces it; a renderer
80
+ // that was laxer would draw what the exporter refuses.
81
+ const colours = numericAttribute(geometry.primitive.Cd, "primitive.Cd", [4]);
82
+ const widths = numericAttribute(geometry.primitive.width, "primitive.width", [1]);
83
+ context.lineCap = "round";
84
+ context.lineJoin = "round";
85
+ context.globalAlpha = options.opacity;
86
+ const { vertexPoints, offsets, closed } = geometry.topology;
87
+ const drawn = new Set();
88
+ for (let primitive = 0; primitive < geometry.primitiveCount; primitive += 1) {
89
+ const from = offsets[primitive];
90
+ const to = offsets[primitive + 1];
91
+ if (to - from < 2)
92
+ continue;
93
+ context.strokeStyle = colours
94
+ ? css(readElement(colours, primitive))
95
+ : css(options.stroke);
96
+ context.lineWidth = widths ? readElement(widths, primitive)[0] : options.strokeWidth;
97
+ context.beginPath();
98
+ let open = false;
99
+ for (let vertex = from; vertex < to; vertex += 1) {
100
+ const point = vertexPoints[vertex];
101
+ drawn.add(point);
102
+ const at = projected[point];
103
+ if (!at || at.w <= 0) {
104
+ // Behind the camera: break the run rather than joining across it. A
105
+ // line from an on-screen vertex to a mirrored one is the failure this
106
+ // avoids, and it draws as a plausible stroke to the wrong place.
107
+ open = false;
108
+ continue;
109
+ }
110
+ if (open)
111
+ context.lineTo(at.x, at.y);
112
+ else {
113
+ context.moveTo(at.x, at.y);
114
+ open = true;
115
+ }
116
+ }
117
+ if (closed[primitive] === 1 && open) {
118
+ const first = projected[vertexPoints[from]];
119
+ if (first && first.w > 0)
120
+ context.lineTo(first.x, first.y);
121
+ }
122
+ context.stroke();
123
+ }
124
+ if (options.drawPoints && options.pointRadius > 0) {
125
+ context.fillStyle = css(options.stroke);
126
+ for (let index = 0; index < projected.length; index += 1) {
127
+ // Only points no primitive claimed. A trail system has thousands of
128
+ // vertices and drawing a dot on every one buries the strokes.
129
+ if (drawn.has(index))
130
+ continue;
131
+ const at = projected[index];
132
+ if (at.w <= 0)
133
+ continue;
134
+ context.beginPath();
135
+ context.arc(at.x, at.y, options.pointRadius, 0, Math.PI * 2);
136
+ context.fill();
137
+ }
138
+ }
139
+ }
140
+ /** The raster as PNG bytes, for the asset capability. */
141
+ export async function renderToPng(surface) {
142
+ const blob = await surface.convertToBlob({ type: "image/png" });
143
+ return new Uint8Array(await blob.arrayBuffer());
144
+ }
145
+ //# sourceMappingURL=render.js.map
@@ -53,6 +53,35 @@ export interface SvgExportOptions {
53
53
  readonly height?: number;
54
54
  /** Added around the derived bounds, in geometry units. */
55
55
  readonly margin?: number;
56
+ /**
57
+ * An explicit world rectangle, `[minX, minY, maxX, maxY]`, replacing the
58
+ * derived bounds.
59
+ *
60
+ * **A frame sequence needs this and cannot work without it.** The bounds are
61
+ * derived per export, so in an animation every frame gets its own viewBox —
62
+ * and as the drawing grows the content appears to scale and drift, which
63
+ * reads as a camera move nobody asked for. Rendering 110 frames of a
64
+ * particle system is what surfaced it: the early frames are sparse, so their
65
+ * bounds are tight, so the first strokes fill the screen and then zoom out
66
+ * for the rest of the sequence.
67
+ *
68
+ * `margin` still applies, so a fixed rectangle can breathe the same way a
69
+ * derived one does.
70
+ */
71
+ readonly bounds?: readonly [number, number, number, number];
72
+ /**
73
+ * A ground behind the drawing, or absent for a transparent document.
74
+ *
75
+ * Absent by default, because plotter work wants paper and a filled rectangle
76
+ * is a rectangle a pen would draw. It exists for the other case: a piece
77
+ * framed on screen has a ground, and every preview of one was otherwise
78
+ * hand-editing a `<rect>` into the file afterwards — which is the sort of
79
+ * step that says a prop is missing.
80
+ *
81
+ * Emitted at the viewBox rather than at the document size, so it fills the
82
+ * frame however the drawing is fitted inside it.
83
+ */
84
+ readonly background?: Color;
56
85
  /** Fallback stroke colour for a primitive with no `Cd`. */
57
86
  readonly stroke?: Color;
58
87
  /** Fallback stroke width for a primitive with no `width`. */
@@ -7,11 +7,14 @@ const BLACK = [0, 0, 0, 1];
7
7
  export function geometryToSvg(geometry, options = {}) {
8
8
  validateOptions(options);
9
9
  const margin = options.margin ?? 0;
10
- const bounds = geometryBounds(geometry);
11
- const minX = bounds === undefined ? 0 : bounds.min[0] - margin;
12
- const minY = bounds === undefined ? 0 : bounds.min[1] - margin;
13
- const maxX = bounds === undefined ? 1 : bounds.max[0] + margin;
14
- const maxY = bounds === undefined ? 1 : bounds.max[1] + margin;
10
+ // An explicit rectangle wins over the geometry's own, because the caller
11
+ // stating a frame is a stronger signal than the content filling one.
12
+ const explicit = options.bounds;
13
+ const bounds = explicit ? undefined : geometryBounds(geometry);
14
+ const minX = explicit ? explicit[0] - margin : bounds === undefined ? 0 : bounds.min[0] - margin;
15
+ const minY = explicit ? explicit[1] - margin : bounds === undefined ? 0 : bounds.min[1] - margin;
16
+ const maxX = explicit ? explicit[2] + margin : bounds === undefined ? 1 : bounds.max[0] + margin;
17
+ const maxY = explicit ? explicit[3] + margin : bounds === undefined ? 1 : bounds.max[1] + margin;
15
18
  const spanX = Math.max(maxX - minX, Number.EPSILON);
16
19
  const spanY = Math.max(maxY - minY, Number.EPSILON);
17
20
  const width = options.width && options.width > 0 ? options.width : spanX;
@@ -91,6 +94,9 @@ export function geometryToSvg(geometry, options = {}) {
91
94
  }
92
95
  const root = [
93
96
  `<svg xmlns="http://www.w3.org/2000/svg" width="${number(width)}" height="${number(height)}" viewBox="${number(minX)} ${number(-maxY)} ${number(spanX)} ${number(spanY)}">`,
97
+ ...(options.background
98
+ ? [indent(`<rect x="${number(minX)}" y="${number(-maxY)}" width="${number(spanX)}" height="${number(spanY)}" fill="${Color.toHex(options.background)}" />`, 1)]
99
+ : []),
94
100
  indent("<!-- Geometry is +Y up; SVG is +Y down. This is the one flip. -->", 1),
95
101
  indent(`<g transform="scale(1,-1)" fill="${filled ? Color.toHex(fill) : "none"}"${filled && Color.alpha(fill) < 1
96
102
  ? ` fill-opacity="${number(Color.alpha(fill))}"`
@@ -0,0 +1,33 @@
1
+ import type { GpuCapability } from "@field/cascade/contracts";
2
+ export interface GpuHostAcquisition {
3
+ readonly device: GpuCapability["device"];
4
+ readonly info?: Partial<GpuCapability["adapterInfo"]>;
5
+ /** May be a getter-backed `GPUSupportedLimits`; normalized by the host. */
6
+ readonly limits: unknown;
7
+ /**
8
+ * Release provider-owned references after the device is no longer usable.
9
+ * Native providers need this in addition to `GPUDevice.destroy()` so their
10
+ * bindings can stop background work and allow the process to exit.
11
+ */
12
+ readonly release?: () => void | Promise<void>;
13
+ }
14
+ export interface GpuHostOptions {
15
+ readonly acquire: () => Promise<GpuHostAcquisition>;
16
+ }
17
+ /**
18
+ * A host-owned GPU device with page/process-wide and per-node cache scopes.
19
+ *
20
+ * The runtime can await `ensure()` before a cook while node implementations
21
+ * continue to receive the synchronous `GpuCapability` contract. Ownership
22
+ * remains with the injecting host: runtimes and graphs never dispose it.
23
+ */
24
+ export interface GpuHost extends GpuCapability {
25
+ ensure(): Promise<void>;
26
+ forNode(nodeId: string): GpuCapability;
27
+ dispose(): Promise<void>;
28
+ }
29
+ export declare function isGpuHost(value: unknown): value is GpuHost;
30
+ export declare function createGpuHost(options: GpuHostOptions): GpuHost;
31
+ /** Getter-backed supported-limits objects cannot be normalized with spread. */
32
+ export declare function readGpuLimits(source: unknown): Record<string, number>;
33
+ //# sourceMappingURL=gpu.d.ts.map