@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
package/dist/cli/runtime/gpu.ts
CHANGED
|
@@ -74,3 +74,74 @@ export const ColorWrite = {
|
|
|
74
74
|
ALPHA: 0x8,
|
|
75
75
|
ALL: 0xf,
|
|
76
76
|
} as const;
|
|
77
|
+
|
|
78
|
+
/** Read one single-sample rgba8unorm 2D texture into owned, tightly packed
|
|
79
|
+
* top-left RGBA bytes. No colour conversion, PNG encoding or graph transport.
|
|
80
|
+
* The caller owns the source texture and must give it COPY_SRC usage.
|
|
81
|
+
*/
|
|
82
|
+
export async function readTexture(
|
|
83
|
+
device: GPUDevice,
|
|
84
|
+
texture: GPUTexture,
|
|
85
|
+
options: { signal?: {
|
|
86
|
+
readonly aborted: boolean;
|
|
87
|
+
addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void;
|
|
88
|
+
removeEventListener(type: 'abort', listener: () => void): void;
|
|
89
|
+
} } = {},
|
|
90
|
+
): Promise<{ data: Uint8Array<ArrayBuffer>; width: number; height: number }> {
|
|
91
|
+
const { signal } = options;
|
|
92
|
+
if (signal?.aborted) throw new Error('GPU readback aborted');
|
|
93
|
+
if (texture.format !== 'rgba8unorm' || texture.dimension !== '2d'
|
|
94
|
+
|| texture.depthOrArrayLayers !== 1 || texture.sampleCount !== 1
|
|
95
|
+
|| !(texture.usage & TextureUsage.COPY_SRC)) {
|
|
96
|
+
throw new Error('GPU readback requires a single-sample rgba8unorm 2D texture with COPY_SRC usage');
|
|
97
|
+
}
|
|
98
|
+
const { width, height } = texture;
|
|
99
|
+
const rowBytes = width * 4;
|
|
100
|
+
const bytesPerRow = Math.ceil(rowBytes / 256) * 256;
|
|
101
|
+
let buffer: GPUBuffer | undefined;
|
|
102
|
+
let mapped = false;
|
|
103
|
+
let abort: (() => void) | undefined;
|
|
104
|
+
let errorScopeOpen = false;
|
|
105
|
+
try {
|
|
106
|
+
device.pushErrorScope('validation');
|
|
107
|
+
errorScopeOpen = true;
|
|
108
|
+
buffer = device.createBuffer({
|
|
109
|
+
label: 'Cascade RGBA readback', size: bytesPerRow * height,
|
|
110
|
+
usage: BufferUsage.COPY_DST | BufferUsage.MAP_READ,
|
|
111
|
+
});
|
|
112
|
+
const encoder = device.createCommandEncoder();
|
|
113
|
+
encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);
|
|
114
|
+
device.queue.submit([encoder.finish()]);
|
|
115
|
+
const validation = device.popErrorScope();
|
|
116
|
+
errorScopeOpen = false;
|
|
117
|
+
const completion = Promise.allSettled([buffer.mapAsync(MapMode.READ), validation]);
|
|
118
|
+
let completed: Awaited<typeof completion>;
|
|
119
|
+
if (signal) {
|
|
120
|
+
completed = await Promise.race([completion, new Promise<never>((_, reject) => {
|
|
121
|
+
abort = () => reject(new Error('GPU readback aborted'));
|
|
122
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
123
|
+
if (signal.aborted) abort();
|
|
124
|
+
})]);
|
|
125
|
+
} else completed = await completion;
|
|
126
|
+
const mappingResult = completed[0];
|
|
127
|
+
const validationResult = completed[1];
|
|
128
|
+
if (validationResult.status === 'rejected') throw validationResult.reason;
|
|
129
|
+
const validationError = validationResult.value;
|
|
130
|
+
if (validationError) throw new Error(`GPU readback validation failed: ${validationError.message}`);
|
|
131
|
+
if (mappingResult.status === 'rejected') throw mappingResult.reason;
|
|
132
|
+
mapped = true;
|
|
133
|
+
if (signal?.aborted) throw new Error('GPU readback aborted');
|
|
134
|
+
const source = new Uint8Array(buffer.getMappedRange());
|
|
135
|
+
const data = new Uint8Array(rowBytes * height);
|
|
136
|
+
for (let y = 0; y < height; y++) data.set(source.subarray(y * bytesPerRow, y * bytesPerRow + rowBytes), y * rowBytes);
|
|
137
|
+
return { data, width, height };
|
|
138
|
+
} finally {
|
|
139
|
+
if (abort) signal?.removeEventListener('abort', abort);
|
|
140
|
+
if (errorScopeOpen) {
|
|
141
|
+
try { await device.popErrorScope(); }
|
|
142
|
+
catch { /* Preserve the operation's original failure. */ }
|
|
143
|
+
}
|
|
144
|
+
if (mapped) buffer?.unmap();
|
|
145
|
+
buffer?.destroy();
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/cli/runtime/io.ts
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* The interop rests on one rule: an image port carries a project-relative PATH,
|
|
5
|
-
* never pixels. A Python node writes a file and emits its path; a WebGL node
|
|
6
|
-
* reads that path as a texture, renders, writes its result back, and emits a
|
|
7
|
-
* path of the same kind. Neither knows or cares which side produced its input,
|
|
8
|
-
* which is what makes the two interchangeable — and it lets the heavy stages
|
|
9
|
-
* stay in Python while the interactive ones run in the page.
|
|
10
|
-
*
|
|
11
|
-
* The counterpart on the server side is the project's own exec bridge
|
|
12
|
-
* (`runStage`), which is per-project because the stages are.
|
|
13
|
-
*
|
|
14
|
-
* This file is compiled into any node module that imports 'cascade/io'; it is
|
|
15
|
-
* not published to npm and has no dependencies.
|
|
16
|
-
*/
|
|
1
|
+
/** Shared `cascade/io` helpers. Studio uses project HTTP routes; other hosts
|
|
2
|
+
* install a bridge. Image ports carry paths rather than ambient pixel buffers. */
|
|
17
3
|
|
|
18
4
|
export interface MediaOptions {
|
|
19
5
|
/** Ask for a resized copy. Never upscales past the file's own resolution. */
|
|
@@ -30,6 +16,8 @@ export interface MediaOptions {
|
|
|
30
16
|
/** Build the URL that serves a project file to the page. */
|
|
31
17
|
export function mediaUrl(path: string, options: MediaOptions = {}): string {
|
|
32
18
|
if (/^(https?:|data:|blob:)/.test(path)) return path;
|
|
19
|
+
const bridge = localIo();
|
|
20
|
+
if (bridge?.url) return bridge.url(path, options);
|
|
33
21
|
const clean = path.replace(/^\.?\//, '');
|
|
34
22
|
const encoded = clean.split('/').map(encodeURIComponent).join('/');
|
|
35
23
|
const params = new URLSearchParams();
|
|
@@ -61,6 +49,8 @@ export interface IoBridge {
|
|
|
61
49
|
read(path: string, options?: MediaOptions): Promise<Uint8Array>;
|
|
62
50
|
/** Write bytes into the project cache. Returns the stored project-relative path. */
|
|
63
51
|
write(path: string, data: Uint8Array): Promise<string>;
|
|
52
|
+
/** Host-owned display URL, valid until the host releases its stored asset. */
|
|
53
|
+
url?(path: string, options?: MediaOptions): string;
|
|
64
54
|
}
|
|
65
55
|
|
|
66
56
|
const IO_BRIDGE_KEY = '__cascadeIoBridge';
|
|
@@ -108,20 +98,36 @@ export async function loadBytes(path: string, options: MediaOptions = { raw: tru
|
|
|
108
98
|
export async function loadImage(path: string, options: MediaOptions = { raw: true }): Promise<HTMLImageElement> {
|
|
109
99
|
const image = new Image();
|
|
110
100
|
const bridge = localIo();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
101
|
+
let temporaryUrl: string | undefined;
|
|
102
|
+
try {
|
|
103
|
+
if (bridge?.url) {
|
|
104
|
+
image.crossOrigin = 'anonymous';
|
|
105
|
+
image.src = bridge.url(path, options);
|
|
106
|
+
} else if (bridge) {
|
|
107
|
+
const bytes = await loadBytes(path, options);
|
|
108
|
+
if (typeof document === 'undefined') {
|
|
109
|
+
// The Node canvas Image decoder accepts bytes, not browser Blob URLs.
|
|
110
|
+
(image as unknown as { src: unknown }).src = new Uint8Array(bytes);
|
|
111
|
+
} else {
|
|
112
|
+
temporaryUrl = URL.createObjectURL(new Blob([bytes]));
|
|
113
|
+
image.src = temporaryUrl;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
image.crossOrigin = 'anonymous';
|
|
117
|
+
image.src = mediaUrl(path, options);
|
|
118
|
+
}
|
|
119
|
+
await image.decode();
|
|
120
|
+
return image;
|
|
121
|
+
} finally {
|
|
122
|
+
if (temporaryUrl) URL.revokeObjectURL(temporaryUrl);
|
|
117
123
|
}
|
|
118
|
-
await image.decode();
|
|
119
|
-
return image;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** Same, as an ImageBitmap — the right input for `texImage2D`. */
|
|
123
127
|
export async function loadBitmap(path: string, options: MediaOptions = { raw: true }): Promise<ImageBitmap> {
|
|
124
|
-
|
|
128
|
+
const bridge = localIo();
|
|
129
|
+
if (bridge?.url) return createImageBitmap(await loadImage(path, options));
|
|
130
|
+
if (bridge) return createImageBitmap(new Blob([await loadBytes(path, options)]));
|
|
125
131
|
const response = await fetch(mediaUrl(path, options));
|
|
126
132
|
if (!response.ok) throw new Error(`cascade/io: cannot load ${path} (${response.status})`);
|
|
127
133
|
return createImageBitmap(await response.blob());
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Vec2i, Vec3 } from "./values.js";
|
|
2
|
+
/**
|
|
3
|
+
* A camera, following Houdini's `/obj/cam`.
|
|
4
|
+
*
|
|
5
|
+
* `AGENTS.md` already required following Houdini's parameter names where
|
|
6
|
+
* Houdini has an equivalent node, and Marcus restated it for this type
|
|
7
|
+
* specifically on 2026-09-08. It is worth writing down what that costs, because
|
|
8
|
+
* the obvious design is not the Houdini one and it was proposed first here:
|
|
9
|
+
*
|
|
10
|
+
* **There is no field of view.** Houdini has `focal` (a focal length in
|
|
11
|
+
* millimetres) and `aperture` (the horizontal aperture, also in millimetres),
|
|
12
|
+
* and the angle is derived from the two. The defaults — 50 mm on a 41.4214 mm
|
|
13
|
+
* aperture — are a 45° horizontal field of view, which is where that odd
|
|
14
|
+
* aperture number comes from. Carrying an `fov` instead would be one number
|
|
15
|
+
* rather than two, and it would also make every lens value a photographer knows
|
|
16
|
+
* untypeable.
|
|
17
|
+
*
|
|
18
|
+
* **The orientation is `translate` and `rotate`, not eye-and-target.** A
|
|
19
|
+
* Houdini camera is an object with an ordinary transform, looking down its own
|
|
20
|
+
* **-Z** with **+Y** up, and look-at is a second mechanism layered on top
|
|
21
|
+
* (`lookatpath` and `upvector`) rather than the primitive. So `lookat` and `up`
|
|
22
|
+
* are here as the optional override they are in Houdini, and `rotate` is what
|
|
23
|
+
* the type actually carries.
|
|
24
|
+
*
|
|
25
|
+
* **Rotation is degrees, XYZ order** — Houdini's convention, and already the
|
|
26
|
+
* one `cascade.geo.Transform` uses for its own `rotate`. Note that this is
|
|
27
|
+
* deliberately unlike expressions, where bare trigonometry is radians; that
|
|
28
|
+
* divergence is recorded in `AGENTS.md` and is not new here.
|
|
29
|
+
*
|
|
30
|
+
* The one place this departs from Houdini's names is **`resolution` in place of
|
|
31
|
+
* `resx` and `resy`**, because the vault's more specific rule wins: anything
|
|
32
|
+
* with an x and a y is one `vec2`, and resolutions are named in that rule
|
|
33
|
+
* explicitly. Two floats that are really one vector cost two Inspector rows and
|
|
34
|
+
* two keyframes and let a graph carry a width without its height.
|
|
35
|
+
*
|
|
36
|
+
* Resolution belongs to the camera rather than to the render target for a
|
|
37
|
+
* Houdini reason that is easy to miss: the **vertical** aperture is not stored,
|
|
38
|
+
* it is derived from the horizontal one and the resolution ratio. So the frame
|
|
39
|
+
* shape participates in the projection, and a camera that did not know it could
|
|
40
|
+
* not produce a vertical angle at all.
|
|
41
|
+
*/
|
|
42
|
+
export interface Camera {
|
|
43
|
+
/** World position. Houdini `t`. */
|
|
44
|
+
readonly translate: Vec3;
|
|
45
|
+
/** Degrees, XYZ order. Houdini `r`. The camera looks down its own -Z. */
|
|
46
|
+
readonly rotate: Vec3;
|
|
47
|
+
/** Focal length in millimetres. Houdini `focal`, default 50. */
|
|
48
|
+
readonly focal: number;
|
|
49
|
+
/** Horizontal aperture in millimetres. Houdini `aperture`, default 41.4214. */
|
|
50
|
+
readonly aperture: number;
|
|
51
|
+
/** Near clipping plane. Houdini `near`, default 0.001. */
|
|
52
|
+
readonly near: number;
|
|
53
|
+
/** Far clipping plane. Houdini `far`, default 10000. */
|
|
54
|
+
readonly far: number;
|
|
55
|
+
/** Frame size in pixels. Houdini `resx`/`resy` as one `vec2i`. */
|
|
56
|
+
readonly resolution: Vec2i;
|
|
57
|
+
/** PIXEL aspect ratio, not the frame's. Houdini `aspect`, default 1. */
|
|
58
|
+
readonly aspect: number;
|
|
59
|
+
/** Houdini `projection`. */
|
|
60
|
+
readonly projection: "perspective" | "orthographic";
|
|
61
|
+
/** Width of the orthographic frame in world units. Houdini `orthowidth`. */
|
|
62
|
+
readonly orthowidth: number;
|
|
63
|
+
}
|
|
64
|
+
/** Houdini's own defaults, so a camera with nothing set matches a fresh
|
|
65
|
+
* `/obj/cam` rather than something invented here. */
|
|
66
|
+
export declare const CAMERA_DEFAULTS: Camera;
|
|
67
|
+
/** True for any object carrying the fields this type is identified by. Used at
|
|
68
|
+
* the document boundary, where a value arrives as parsed JSON. */
|
|
69
|
+
export declare function isCamera(value: unknown): value is Camera;
|
|
70
|
+
//# sourceMappingURL=camera.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Houdini's own defaults, so a camera with nothing set matches a fresh
|
|
2
|
+
* `/obj/cam` rather than something invented here. */
|
|
3
|
+
export const CAMERA_DEFAULTS = Object.freeze({
|
|
4
|
+
translate: [0, 0, 0],
|
|
5
|
+
rotate: [0, 0, 0],
|
|
6
|
+
focal: 50,
|
|
7
|
+
aperture: 41.4214,
|
|
8
|
+
near: 0.001,
|
|
9
|
+
far: 10000,
|
|
10
|
+
resolution: [1280, 720],
|
|
11
|
+
aspect: 1,
|
|
12
|
+
projection: "perspective",
|
|
13
|
+
orthowidth: 2,
|
|
14
|
+
});
|
|
15
|
+
/** True for any object carrying the fields this type is identified by. Used at
|
|
16
|
+
* the document boundary, where a value arrives as parsed JSON. */
|
|
17
|
+
export function isCamera(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const candidate = value;
|
|
22
|
+
return (Array.isArray(candidate.translate) &&
|
|
23
|
+
Array.isArray(candidate.rotate) &&
|
|
24
|
+
typeof candidate.focal === "number" &&
|
|
25
|
+
typeof candidate.aperture === "number");
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=camera.js.map
|
|
@@ -77,13 +77,13 @@ export interface PythonCapability {
|
|
|
77
77
|
* anything a graph author would recognise. So a shared device is not a
|
|
78
78
|
* preference, it is the precondition for a chain of GPU nodes at all.
|
|
79
79
|
*
|
|
80
|
-
* **
|
|
81
|
-
* pass `image
|
|
80
|
+
* **The capability carries a device/cache contract, not graph textures.** Ports
|
|
81
|
+
* still pass `image`; what this buys is
|
|
82
82
|
* one device instead of one per node module, an owner for `device.lost`, and
|
|
83
83
|
* somewhere legitimate to keep a pipeline — which is what lets the
|
|
84
84
|
* `architecture/ambient-state` rule stay strict. The texture store, the
|
|
85
|
-
* generation-stamped handles
|
|
86
|
-
*
|
|
85
|
+
* generation-stamped handles remain future work. Explicit RGBA8 readback is a
|
|
86
|
+
* helper in `cascade/gpu`, shared by browser and native hosts without encoding.
|
|
87
87
|
*
|
|
88
88
|
* The `GPUDevice` type comes from `@webgpu/types`, added to this package's
|
|
89
89
|
* `tsconfig.json` as its only `types` entry. Contracts compiles with
|
|
@@ -102,7 +102,7 @@ export interface PythonCapability {
|
|
|
102
102
|
*/
|
|
103
103
|
export interface GpuCapability {
|
|
104
104
|
/**
|
|
105
|
-
* The one device for this
|
|
105
|
+
* The one device for this host. Shared by every node declaring `gpu`, which
|
|
106
106
|
* is the whole point — see the note above on why two would be useless.
|
|
107
107
|
*/
|
|
108
108
|
readonly device: GPUDevice;
|
|
@@ -128,7 +128,7 @@ export interface GpuCapability {
|
|
|
128
128
|
* right answer again.
|
|
129
129
|
*
|
|
130
130
|
* `create` runs once per key. `destroy` is called when the entry is dropped:
|
|
131
|
-
* when the device is lost, or when the
|
|
131
|
+
* when the device is lost, or when the owning host disposes. This is the member
|
|
132
132
|
* that makes `architecture/module-state` keepable — it was previously
|
|
133
133
|
* forbidding the only available way to hold a pipeline.
|
|
134
134
|
*/
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CapabilityMap, CascadeAbortSignal, NodeCapabilityName, ProgressReporter } from "./capabilities.js";
|
|
2
2
|
import type { CascadeType, CoreType, JsonValue, NamespacedType, SerializableValueForType, ValueForType } from "./values.js";
|
|
3
3
|
export type RuntimeEnvironment = "portable" | "browser" | "server";
|
|
4
|
-
export type CapabilityForEnvironment<E extends RuntimeEnvironment> = E extends "portable" ? "assets" | "media" : E extends "browser" ? "assets" | "media" | "gpu" : "files" | "assets" | "media" | "python" | "shell";
|
|
4
|
+
export type CapabilityForEnvironment<E extends RuntimeEnvironment> = E extends "portable" ? "assets" | "media" | "gpu" : E extends "browser" ? "assets" | "media" | "gpu" : "files" | "assets" | "media" | "python" | "shell" | "gpu";
|
|
5
5
|
export type DataTypeForEnvironment<E extends RuntimeEnvironment> = E extends "browser" ? CascadeType : Exclude<CascadeType, "texture">;
|
|
6
6
|
type ControlFor<T extends CascadeType> = T extends "float" ? "number" | "slider" | "range" : T extends "int" ? "int" | "slider" | "range" : T extends "bool" ? "boolean" : T extends "string" ? "text" | "textarea" | "select" : T extends "vec2" | "vec3" | "vec4" | "vec2i" | "vec3i" | "vec4i" ? "vector" : T extends "mat2" | "mat3" | "mat4" ? "matrix" : T extends "color" ? "color" : T extends "image" ? "image" : T extends "asset" ? "asset" | "select" : T extends "texture" ? never : "select";
|
|
7
7
|
/**
|
|
@@ -41,12 +41,34 @@ type AcceptMetadata<T extends CascadeType> = T extends "image" | "asset" ? {
|
|
|
41
41
|
} : {
|
|
42
42
|
readonly accept?: never;
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* One choice on a select control: the bare value, or the value with the words
|
|
46
|
+
* a person reads.
|
|
47
|
+
*
|
|
48
|
+
* The labelled form exists because losing it cost something real. Converting
|
|
49
|
+
* `image-superres` turned *"Off (pass through) / Local — Real-ESRGAN /
|
|
50
|
+
* Magnific — costs money"* into `off` / `local` / `magnific`, and that third
|
|
51
|
+
* label carried **the only warning that one of the three engines spends money
|
|
52
|
+
* per call**. A lost display name is a papercut; a lost cost warning is a
|
|
53
|
+
* bill. Six props across the sketches had lost their labels by the time this
|
|
54
|
+
* landed, and that one is why it was not deferred any longer.
|
|
55
|
+
*
|
|
56
|
+
* A bare value stays legal, and reads as "the value is the label" — which is
|
|
57
|
+
* true of most enums and is what every existing definition means.
|
|
58
|
+
*/
|
|
59
|
+
export type SelectOption<T extends CascadeType> = SerializableValueForType<T> | Readonly<{
|
|
60
|
+
value: SerializableValueForType<T>;
|
|
61
|
+
label: string;
|
|
62
|
+
/** Greyed out but visible: an option that exists and cannot be picked
|
|
63
|
+
* here, which is more informative than one that is simply absent. */
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
}>;
|
|
44
66
|
type SelectMetadata<T extends CascadeType> = {
|
|
45
67
|
readonly control?: Exclude<ControlFor<T>, "select">;
|
|
46
68
|
readonly options?: never;
|
|
47
69
|
} | {
|
|
48
70
|
readonly control: Extract<ControlFor<T>, "select">;
|
|
49
|
-
readonly options?: readonly
|
|
71
|
+
readonly options?: readonly SelectOption<T>[];
|
|
50
72
|
};
|
|
51
73
|
export interface TriggerInputDefinition {
|
|
52
74
|
readonly kind: "trigger";
|
|
@@ -100,6 +122,23 @@ export type PropDefinition<T extends PropDataType> = {
|
|
|
100
122
|
* number came from.
|
|
101
123
|
*/
|
|
102
124
|
readonly expression?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Render this prop as a button that fires a named action, rather than as an
|
|
127
|
+
* editable field.
|
|
128
|
+
*
|
|
129
|
+
* It exists because converting a node removed a button. `observatory-moment`
|
|
130
|
+
* declared `action: 'panel:observatory-moments'` as a dynamic node, which the
|
|
131
|
+
* Inspector renders from `parameter.options.action`; the v1 adapter built its
|
|
132
|
+
* options from `label`, `min`, `max`, `step` and `options` only, so the
|
|
133
|
+
* Moment picker quietly became a two-step job through the panel menu instead
|
|
134
|
+
* of one click on the node. Nothing broke — the panel falls back to the
|
|
135
|
+
* selected node and reads the same prop — which is exactly why nobody would
|
|
136
|
+
* have noticed from a test.
|
|
137
|
+
*
|
|
138
|
+
* The value is the action name the host dispatches. Cascade does not
|
|
139
|
+
* interpret it; a `panel:` prefix is a Studio convention, not a contract.
|
|
140
|
+
*/
|
|
141
|
+
readonly action?: string;
|
|
103
142
|
} & NumericMetadata<T> & AcceptMetadata<T> & SelectMetadata<T>;
|
|
104
143
|
export type AnyPropDefinition = {
|
|
105
144
|
[T in PropDataType]: PropDefinition<T>;
|
|
@@ -107,8 +146,27 @@ export type AnyPropDefinition = {
|
|
|
107
146
|
export interface NodeDefinitionForEnvironment<E extends RuntimeEnvironment> {
|
|
108
147
|
readonly apiVersion: 1;
|
|
109
148
|
readonly runsOn: E;
|
|
110
|
-
/**
|
|
111
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Declares that authored nodes may use this node as their parent.
|
|
151
|
+
*
|
|
152
|
+
* `subnet` is a **grouping**: the children are ordinary nodes in the one flat
|
|
153
|
+
* graph, cooked once each in the usual dependency order, and the container is
|
|
154
|
+
* a boundary for display and for `cascade.core.Input`/`Output` to bind to.
|
|
155
|
+
*
|
|
156
|
+
* `feedback` is an **evaluation construct**: the children are removed from the
|
|
157
|
+
* main order and re-run once per step by the container, with the previous
|
|
158
|
+
* step's result entering through `cascade.core.Previous`. It exists because a
|
|
159
|
+
* simulation is the one thing a pure DAG cannot express — which is why Houdini
|
|
160
|
+
* made its Solver SOP special too — and Marcus asked for it on 2026-09-09,
|
|
161
|
+
* wanting to dive into a particle node and see its forces as nodes.
|
|
162
|
+
*
|
|
163
|
+
* The distinction is load-bearing for correctness, not just for scheduling: a
|
|
164
|
+
* `subnet` child's output is read once and is a function of the graph, while a
|
|
165
|
+
* `feedback` child's output is read N times and is a function of the graph
|
|
166
|
+
* *and* the step. Anything that caches on a node's inputs has to know which
|
|
167
|
+
* kind it is looking at.
|
|
168
|
+
*/
|
|
169
|
+
readonly container?: "subnet" | "feedback";
|
|
112
170
|
readonly capabilities?: readonly CapabilityForEnvironment<E>[];
|
|
113
171
|
readonly label?: string;
|
|
114
172
|
readonly description?: string;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export * from "./animation.js";
|
|
2
|
+
export * from "./camera.js";
|
|
2
3
|
export * from "./capabilities.js";
|
|
3
4
|
export * from "./color.js";
|
|
4
5
|
export * from "./definition.js";
|
|
5
6
|
export * from "./document.js";
|
|
6
7
|
export * from "./geometry.js";
|
|
8
|
+
export * from "./particles.js";
|
|
9
|
+
export * from "./scene.js";
|
|
10
|
+
export * from "./series.js";
|
|
7
11
|
export * from "./validate.js";
|
|
8
12
|
export * from "./values.js";
|
|
9
13
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/contracts/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export * from "./animation.js";
|
|
2
|
+
export * from "./camera.js";
|
|
2
3
|
export * from "./capabilities.js";
|
|
3
4
|
export * from "./color.js";
|
|
4
5
|
export * from "./definition.js";
|
|
5
6
|
export * from "./document.js";
|
|
6
7
|
export * from "./geometry.js";
|
|
8
|
+
export * from "./particles.js";
|
|
9
|
+
export * from "./scene.js";
|
|
10
|
+
export * from "./series.js";
|
|
7
11
|
export * from "./validate.js";
|
|
8
12
|
export * from "./values.js";
|
|
9
13
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AttributeLevel, type AttributeStorage, type Geometry } from "./geometry.js";
|
|
2
|
+
/**
|
|
3
|
+
* The attribute contract a particle system reads and writes.
|
|
4
|
+
*
|
|
5
|
+
* Step 1 of `PLAN particles.md`, and it is deliberately first: **renaming an
|
|
6
|
+
* attribute after three operators read it is the expensive change.** So the
|
|
7
|
+
* names, levels and storage are fixed before any node exists, and nothing here
|
|
8
|
+
* is a node — this file declares a vocabulary and the checks that hold it.
|
|
9
|
+
*
|
|
10
|
+
* Houdini's names throughout, per the standing rule. Where Houdini and Cascade
|
|
11
|
+
* disagree the reason is written down beside the entry rather than left as a
|
|
12
|
+
* preference.
|
|
13
|
+
*
|
|
14
|
+
* **A particle system is geometry.** There is no particle type: points with
|
|
15
|
+
* these attributes on them are what a solver reads and what `CopyToPoints`,
|
|
16
|
+
* `SvgExport` and every other geometry node already understand. That is the
|
|
17
|
+
* whole reason `PLAN geometry` retires `points` to an alias — four names for
|
|
18
|
+
* one concept, and a fifth for particles would be worse.
|
|
19
|
+
*/
|
|
20
|
+
export interface ParticleAttributeSpec {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly level: AttributeLevel;
|
|
23
|
+
readonly storage: AttributeStorage;
|
|
24
|
+
/** Components. `P` is 2 or 3, so it is stated as a range rather than a size. */
|
|
25
|
+
readonly size: number | readonly [number, number];
|
|
26
|
+
/** Whether a solver may run without it. */
|
|
27
|
+
readonly required: boolean;
|
|
28
|
+
readonly units?: "seconds" | "turns";
|
|
29
|
+
readonly note: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const PARTICLE_ATTRIBUTES: readonly ParticleAttributeSpec[];
|
|
32
|
+
export declare function particleAttribute(name: string): ParticleAttributeSpec | undefined;
|
|
33
|
+
/** The attributes a solver cannot run without. */
|
|
34
|
+
export declare function requiredParticleAttributes(): readonly ParticleAttributeSpec[];
|
|
35
|
+
export interface ParticleContractViolation {
|
|
36
|
+
readonly attribute: string;
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether a geometry carries the attributes a solver reads, in the shapes it
|
|
41
|
+
* reads them in.
|
|
42
|
+
*
|
|
43
|
+
* Returns every violation rather than the first, because the answer a caller
|
|
44
|
+
* wants is *what is wrong with this geometry* and not *what is the first thing
|
|
45
|
+
* wrong with it* — a source node missing three attributes should say so once.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately not a throw: `cascade check` wants to report this statically
|
|
48
|
+
* alongside its other findings, and a solver wants to fail with a sentence. The
|
|
49
|
+
* shared thing between them is the list.
|
|
50
|
+
*/
|
|
51
|
+
export declare function checkParticleContract(geometry: Geometry): readonly ParticleContractViolation[];
|
|
52
|
+
/** True when this geometry could be stepped by a solver. */
|
|
53
|
+
export declare function isParticleGeometry(geometry: Geometry): boolean;
|
|
54
|
+
//# sourceMappingURL=particles.d.ts.map
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { POSITION_ATTRIBUTE, } from "./geometry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Position. Already `POSITION_ATTRIBUTE`, and `f64` because the Python bridge
|
|
4
|
+
* writes points as float64 and narrowing would add a lossy step to a boundary
|
|
5
|
+
* that is lossless today.
|
|
6
|
+
*/
|
|
7
|
+
const P = {
|
|
8
|
+
name: POSITION_ATTRIBUTE,
|
|
9
|
+
level: "point",
|
|
10
|
+
storage: "f64",
|
|
11
|
+
size: [2, 3],
|
|
12
|
+
required: true,
|
|
13
|
+
note: "Position. Shared with every other geometry node, not a particle attribute.",
|
|
14
|
+
};
|
|
15
|
+
export const PARTICLE_ATTRIBUTES = Object.freeze([
|
|
16
|
+
P,
|
|
17
|
+
{
|
|
18
|
+
name: "v",
|
|
19
|
+
level: "point",
|
|
20
|
+
storage: "f32",
|
|
21
|
+
size: [2, 3],
|
|
22
|
+
required: true,
|
|
23
|
+
note: "Velocity, in units per second. f32 rather than f64: a velocity is not a coordinate, so the precision argument for P does not carry over.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "age",
|
|
27
|
+
level: "point",
|
|
28
|
+
storage: "f32",
|
|
29
|
+
size: 1,
|
|
30
|
+
required: true,
|
|
31
|
+
units: "seconds",
|
|
32
|
+
// The one place this departs from the sketch it is modelled on.
|
|
33
|
+
note: "Seconds, not frames. A frame count changes meaning with fps, and a graph whose particles die at a different point when rendered at 60 is not deterministic in any useful sense.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "life",
|
|
37
|
+
level: "point",
|
|
38
|
+
storage: "f32",
|
|
39
|
+
size: 1,
|
|
40
|
+
required: true,
|
|
41
|
+
units: "seconds",
|
|
42
|
+
note: "Life expectancy in seconds. A particle dies when age >= life.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "id",
|
|
46
|
+
level: "point",
|
|
47
|
+
storage: "i32",
|
|
48
|
+
size: 1,
|
|
49
|
+
required: true,
|
|
50
|
+
note: "Stable across frames and across kills. Mandatory: a particle array reorders on every kill, so anything durable and per-particle must key on this rather than on an index.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "nextid",
|
|
54
|
+
level: "detail",
|
|
55
|
+
storage: "i32",
|
|
56
|
+
size: 1,
|
|
57
|
+
required: true,
|
|
58
|
+
note: "The next id to hand out. Houdini's own mechanism for keeping id monotonic, and what stops an id being reused after a kill.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "Cd",
|
|
62
|
+
level: "point",
|
|
63
|
+
storage: "f32",
|
|
64
|
+
size: 4,
|
|
65
|
+
required: false,
|
|
66
|
+
note: "Colour. Owned by the style vocabulary in PLAN geometry, which refuses a Cd that is a string or numeric with any size but four.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "pscale",
|
|
70
|
+
level: "point",
|
|
71
|
+
storage: "f32",
|
|
72
|
+
size: 1,
|
|
73
|
+
required: false,
|
|
74
|
+
note: "Not a POP attribute in Houdini's sense. Writing it is what makes CopyToPoints scale its instances, which is how oriented marks get drawn without a new node.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "N",
|
|
78
|
+
level: "point",
|
|
79
|
+
storage: "f32",
|
|
80
|
+
size: [2, 3],
|
|
81
|
+
required: false,
|
|
82
|
+
note: "Orientation, read by CopyToPoints alongside pscale.",
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
const BY_NAME = new Map(PARTICLE_ATTRIBUTES.map((spec) => [spec.name, spec]));
|
|
86
|
+
export function particleAttribute(name) {
|
|
87
|
+
return BY_NAME.get(name);
|
|
88
|
+
}
|
|
89
|
+
/** The attributes a solver cannot run without. */
|
|
90
|
+
export function requiredParticleAttributes() {
|
|
91
|
+
return PARTICLE_ATTRIBUTES.filter((spec) => spec.required);
|
|
92
|
+
}
|
|
93
|
+
function sizeMatches(spec, size) {
|
|
94
|
+
return typeof spec.size === "number"
|
|
95
|
+
? size === spec.size
|
|
96
|
+
: size >= spec.size[0] && size <= spec.size[1];
|
|
97
|
+
}
|
|
98
|
+
function describeSize(spec) {
|
|
99
|
+
return typeof spec.size === "number" ? String(spec.size) : `${spec.size[0]} or ${spec.size[1]}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether a geometry carries the attributes a solver reads, in the shapes it
|
|
103
|
+
* reads them in.
|
|
104
|
+
*
|
|
105
|
+
* Returns every violation rather than the first, because the answer a caller
|
|
106
|
+
* wants is *what is wrong with this geometry* and not *what is the first thing
|
|
107
|
+
* wrong with it* — a source node missing three attributes should say so once.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately not a throw: `cascade check` wants to report this statically
|
|
110
|
+
* alongside its other findings, and a solver wants to fail with a sentence. The
|
|
111
|
+
* shared thing between them is the list.
|
|
112
|
+
*/
|
|
113
|
+
export function checkParticleContract(geometry) {
|
|
114
|
+
const violations = [];
|
|
115
|
+
for (const spec of PARTICLE_ATTRIBUTES) {
|
|
116
|
+
if (spec.level === "detail") {
|
|
117
|
+
const value = geometry.detail[spec.name];
|
|
118
|
+
if (value === undefined) {
|
|
119
|
+
if (spec.required) {
|
|
120
|
+
violations.push({
|
|
121
|
+
attribute: spec.name,
|
|
122
|
+
reason: `missing detail attribute ${spec.name} — ${spec.note}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value !== "number") {
|
|
128
|
+
violations.push({
|
|
129
|
+
attribute: spec.name,
|
|
130
|
+
reason: `detail ${spec.name} must be a number, not ${typeof value}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const attribute = geometry.point[spec.name];
|
|
136
|
+
if (attribute === undefined) {
|
|
137
|
+
if (spec.required) {
|
|
138
|
+
violations.push({
|
|
139
|
+
attribute: spec.name,
|
|
140
|
+
reason: `missing point attribute ${spec.name} — ${spec.note}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// A string where a number belongs is the failure that would otherwise
|
|
146
|
+
// reach arithmetic and produce NaN silently.
|
|
147
|
+
if (attribute.storage === "string") {
|
|
148
|
+
violations.push({
|
|
149
|
+
attribute: spec.name,
|
|
150
|
+
reason: `${spec.name} is a string attribute; a solver reads it as ${spec.storage}`,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!sizeMatches(spec, attribute.size)) {
|
|
155
|
+
violations.push({
|
|
156
|
+
attribute: spec.name,
|
|
157
|
+
reason: `${spec.name} has size ${attribute.size}; expected ${describeSize(spec)}`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return violations;
|
|
162
|
+
}
|
|
163
|
+
/** True when this geometry could be stepped by a solver. */
|
|
164
|
+
export function isParticleGeometry(geometry) {
|
|
165
|
+
return checkParticleContract(geometry).length === 0;
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=particles.js.map
|