@field/cascade 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/ARCHITECTURE.md +53 -7
- package/CHANGELOG.md +68 -0
- package/CONTRIBUTING.md +33 -0
- package/DESIGN.md +9 -6
- package/README.md +80 -4
- package/dist/assets/{CodeEditor-BNOJIDzL.js → CodeEditor-BHej8kM9.js} +7 -7
- package/dist/assets/{CodePanel-2wp2G0Fj.js → CodePanel-CT3eirK8.js} +2 -2
- package/dist/assets/{CustomNodeDialog-DNqaVBqA.js → CustomNodeDialog-BG0m7_Rv.js} +1 -1
- package/dist/assets/{ProjectSettings-DcdYF2E1.js → ProjectSettings-BxoLWJG6.js} +2 -2
- package/dist/assets/{SettingsDialog-D2xVvyiR.js → SettingsDialog-r9PzZ22z.js} +1 -1
- package/dist/assets/{VersionHistoryDialog-DW288ykF.js → VersionHistoryDialog-B6zUV6Dy.js} +1 -1
- package/dist/assets/{core-B1y38i95.js → core-wwx5881P.js} +2 -2
- package/dist/assets/{cssMode-BLucnaln.js → cssMode-DY5T2Or0.js} +1 -1
- package/dist/assets/definitions-BgCvRHyR.js +1 -0
- package/dist/assets/{editor.api-B3Et3Pph.js → editor.api-mkORHEMm.js} +1 -1
- package/dist/assets/{freemarker2-DVlPoano.js → freemarker2-CIWlk0sw.js} +1 -1
- package/dist/assets/geo-CIxgs0oE.js +1 -0
- package/dist/assets/{handlebars-B5FZBAMR.js → handlebars-t4jbj3Br.js} +1 -1
- package/dist/assets/{html-FodD8KbR.js → html-Ctx7UOlY.js} +1 -1
- package/dist/assets/{htmlMode-CsG57VCD.js → htmlMode-BKNZxVw7.js} +1 -1
- package/dist/assets/{image-B3F-nq25.js → image-BCBFz0gD.js} +3 -3
- package/dist/assets/index-CeqTfMbr.js +212 -0
- package/dist/assets/{index-Y2ock1cw.css → index-ilgbOu6G.css} +1 -1
- package/dist/assets/{javascript-B3gonF_H.js → javascript-Da_TJBzH.js} +1 -1
- package/dist/assets/{jsonMode-Y3w1ahZg.js → jsonMode-Ftt5d1nV.js} +1 -1
- package/dist/assets/{liquid-CTpWXIYO.js → liquid-D5kWYG57.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-CODBdKEZ.js → lspLanguageFeatures-B-93LC_L.js} +1 -1
- package/dist/assets/{mdx-CDAQJZIz.js → mdx-DGT3C_qs.js} +1 -1
- package/dist/assets/nodeTypeUtils-BDJsc2Ks.js +1 -0
- package/dist/assets/pop-BJGN5Ac4.js +1 -0
- package/dist/assets/{python-LOIlxJU-.js → python-Bz3Eew5S.js} +1 -1
- package/dist/assets/{razor-CllPQSHo.js → razor-CFDdKpKI.js} +1 -1
- package/dist/assets/{tsMode-CO28ut8t.js → tsMode-BaCINWBI.js} +1 -1
- package/dist/assets/{typescript-ruU9wKQ_.js → typescript-BoOWspVs.js} +1 -1
- package/dist/assets/{workers-Bc1Ywp4-.js → workers-dSWyDxsQ.js} +1 -1
- package/dist/assets/{xml-DoXP2Dh1.js → xml-eFi1_vYt.js} +1 -1
- package/dist/assets/{yaml-D9YPyGiJ.js → yaml-a7xrJHxm.js} +1 -1
- package/dist/cli/index.js +16266 -12653
- package/dist/cli/runtime/io.ts +31 -25
- package/dist/contracts/definition.d.ts +21 -2
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.js +2 -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 +1 -1
- package/dist/contracts/validate.js +4 -2
- package/dist/contracts/values.d.ts +3 -2
- package/dist/contracts/values.js +9 -0
- 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/index.d.ts +3 -2
- package/dist/runtime/builtins/core/index.js +6 -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/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/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -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 +221 -3
- package/dist/runtime/scene/index.d.ts +50 -0
- package/dist/runtime/scene/index.js +94 -0
- package/dist/studio/panel.d.ts +68 -1
- package/doc/HEADLESS_GPU.md +4 -4
- package/doc/NODE_REFERENCE.md +372 -1
- package/doc/PROJECT_AUTHORING.md +16 -4
- package/doc/WEB_PLAYER.md +131 -0
- package/package.json +16 -2
- package/dist/assets/geo-DhyFdBXX.js +0 -1
- package/dist/assets/index-CC6YQw2n.js +0 -212
- package/dist/assets/nodeTypeUtils-BBA1JqHx.js +0 -1
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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))}"`
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./expressions/index.js";
|
|
|
6
6
|
export * from "./geometry/index.js";
|
|
7
7
|
export * from "./gpu.js";
|
|
8
8
|
export * from "./params/index.js";
|
|
9
|
+
export * from "./scene/index.js";
|
|
9
10
|
export { createRuntime, PREFLIGHT_WARNING_CODES } from "./runtime.js";
|
|
10
11
|
export type * from "./types.js";
|
|
11
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/runtime/index.js
CHANGED
|
@@ -6,5 +6,6 @@ export * from "./expressions/index.js";
|
|
|
6
6
|
export * from "./geometry/index.js";
|
|
7
7
|
export * from "./gpu.js";
|
|
8
8
|
export * from "./params/index.js";
|
|
9
|
+
export * from "./scene/index.js";
|
|
9
10
|
export { createRuntime, PREFLIGHT_WARNING_CODES } from "./runtime.js";
|
|
10
11
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ParticleState } from "./state.js";
|
|
2
|
+
interface Checkpoint {
|
|
3
|
+
readonly frame: number;
|
|
4
|
+
readonly state: ParticleState;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The best checkpoint at or before `frame`, or null to start from scratch.
|
|
8
|
+
*
|
|
9
|
+
* Never *after* the frame: a state from frame 90 says nothing about frame 40,
|
|
10
|
+
* and the temptation to interpolate backwards is how a cache stops being an
|
|
11
|
+
* accelerator and becomes a second implementation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkpointAtOrBefore(key: string, fingerprint: string, frame: number): Checkpoint | null;
|
|
14
|
+
/** Offer a state for keeping. Ignored unless it lands on the stride, so a
|
|
15
|
+
* caller can offer every frame without thinking about it. */
|
|
16
|
+
export declare function offerCheckpoint(key: string, fingerprint: string, frame: number, state: ParticleState, stride?: number): void;
|
|
17
|
+
/** Forget everything. For a test that needs a cold seek, and for a host that
|
|
18
|
+
* wants to reclaim the memory. */
|
|
19
|
+
export declare function clearCheckpoints(): void;
|
|
20
|
+
/** What is held, for a test or a panel. Not for making decisions with: a caller
|
|
21
|
+
* that branched on the cache's contents would be depending on an accelerator. */
|
|
22
|
+
export declare function checkpointStats(): {
|
|
23
|
+
keys: number;
|
|
24
|
+
checkpoints: number;
|
|
25
|
+
};
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ParticleState } from "./state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bounded, process-local simulation checkpoints. Clearing them must not change
|
|
4
|
+
* outputs: cold simulation is the reference, including the full trail window.
|
|
5
|
+
* The caller fingerprints every stepping prop and wired geometry value; a
|
|
6
|
+
* mismatch invalidates that entry. Copies on both boundaries isolate mutable
|
|
7
|
+
* particle arrays. Studio and CLI frame sequences reuse these checkpoints;
|
|
8
|
+
* separate CLI processes do not share them.
|
|
9
|
+
*/
|
|
10
|
+
/** Every `stride` frames, plus the newest. Sparse on purpose: a checkpoint per
|
|
11
|
+
* frame would hold the whole history, and the point of a checkpoint is that it
|
|
12
|
+
* is cheaper than the thing it replaces. */
|
|
13
|
+
const DEFAULT_STRIDE = 8;
|
|
14
|
+
/** Total checkpoints kept across all keys. A simulation of a few thousand
|
|
15
|
+
* particles is a few hundred kilobytes a frame, so this is the memory bound
|
|
16
|
+
* rather than a count anyone should tune. */
|
|
17
|
+
const MAX_CHECKPOINTS = 64;
|
|
18
|
+
const entries = new Map();
|
|
19
|
+
let clock = 0;
|
|
20
|
+
function cloneState(state) {
|
|
21
|
+
return ParticleState.of({
|
|
22
|
+
count: state.count,
|
|
23
|
+
size: state.size,
|
|
24
|
+
position: Float64Array.from(state.position),
|
|
25
|
+
velocity: Float32Array.from(state.velocity),
|
|
26
|
+
age: Float32Array.from(state.age),
|
|
27
|
+
life: Float32Array.from(state.life),
|
|
28
|
+
id: Int32Array.from(state.id),
|
|
29
|
+
...(state.colour ? { colour: Float32Array.from(state.colour) } : {}),
|
|
30
|
+
nextId: state.nextId,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The best checkpoint at or before `frame`, or null to start from scratch.
|
|
35
|
+
*
|
|
36
|
+
* Never *after* the frame: a state from frame 90 says nothing about frame 40,
|
|
37
|
+
* and the temptation to interpolate backwards is how a cache stops being an
|
|
38
|
+
* accelerator and becomes a second implementation.
|
|
39
|
+
*/
|
|
40
|
+
export function checkpointAtOrBefore(key, fingerprint, frame) {
|
|
41
|
+
const entry = entries.get(key);
|
|
42
|
+
if (!entry)
|
|
43
|
+
return null;
|
|
44
|
+
if (entry.fingerprint !== fingerprint) {
|
|
45
|
+
// Cleared, not reconciled. Anything that changed the fingerprint changed
|
|
46
|
+
// what the simulation is.
|
|
47
|
+
entries.delete(key);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
entry.touched = clock += 1;
|
|
51
|
+
let best = null;
|
|
52
|
+
for (const checkpoint of entry.checkpoints) {
|
|
53
|
+
if (checkpoint.frame > frame)
|
|
54
|
+
break;
|
|
55
|
+
best = checkpoint;
|
|
56
|
+
}
|
|
57
|
+
return best ? { frame: best.frame, state: cloneState(best.state) } : null;
|
|
58
|
+
}
|
|
59
|
+
/** Offer a state for keeping. Ignored unless it lands on the stride, so a
|
|
60
|
+
* caller can offer every frame without thinking about it. */
|
|
61
|
+
export function offerCheckpoint(key, fingerprint, frame, state, stride = DEFAULT_STRIDE) {
|
|
62
|
+
if (frame % stride !== 0)
|
|
63
|
+
return;
|
|
64
|
+
let entry = entries.get(key);
|
|
65
|
+
if (!entry || entry.fingerprint !== fingerprint) {
|
|
66
|
+
entry = { fingerprint, checkpoints: [], touched: clock += 1 };
|
|
67
|
+
entries.set(key, entry);
|
|
68
|
+
}
|
|
69
|
+
if (entry.checkpoints.some((checkpoint) => checkpoint.frame === frame))
|
|
70
|
+
return;
|
|
71
|
+
entry.checkpoints.push({ frame, state: cloneState(state) });
|
|
72
|
+
entry.checkpoints.sort((a, b) => a.frame - b.frame);
|
|
73
|
+
entry.touched = clock += 1;
|
|
74
|
+
evict();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Drop the least recently used key, then the oldest checkpoints in the one
|
|
78
|
+
* remaining key, until the total is under the cap.
|
|
79
|
+
*
|
|
80
|
+
* Whole-key eviction preserves the most useful simulation when several are
|
|
81
|
+
* active. The final key is trimmed from its oldest edge rather than exempted:
|
|
82
|
+
* checkpoints are independent snapshots and lookup already accepts gaps, so
|
|
83
|
+
* retaining the newest bounded window is correct and useful.
|
|
84
|
+
*/
|
|
85
|
+
function evict() {
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const entry of entries.values())
|
|
88
|
+
total += entry.checkpoints.length;
|
|
89
|
+
while (total > MAX_CHECKPOINTS && entries.size > 1) {
|
|
90
|
+
let oldest = null;
|
|
91
|
+
let oldestTouched = Infinity;
|
|
92
|
+
for (const [key, entry] of entries) {
|
|
93
|
+
if (entry.touched < oldestTouched) {
|
|
94
|
+
oldestTouched = entry.touched;
|
|
95
|
+
oldest = key;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (oldest === null)
|
|
99
|
+
return;
|
|
100
|
+
total -= entries.get(oldest).checkpoints.length;
|
|
101
|
+
entries.delete(oldest);
|
|
102
|
+
}
|
|
103
|
+
if (total > MAX_CHECKPOINTS) {
|
|
104
|
+
const remaining = entries.values().next().value;
|
|
105
|
+
if (!remaining)
|
|
106
|
+
return;
|
|
107
|
+
remaining.checkpoints.splice(0, total - MAX_CHECKPOINTS);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Forget everything. For a test that needs a cold seek, and for a host that
|
|
111
|
+
* wants to reclaim the memory. */
|
|
112
|
+
export function clearCheckpoints() {
|
|
113
|
+
entries.clear();
|
|
114
|
+
}
|
|
115
|
+
/** What is held, for a test or a panel. Not for making decisions with: a caller
|
|
116
|
+
* that branched on the cache's contents would be depending on an accelerator. */
|
|
117
|
+
export function checkpointStats() {
|
|
118
|
+
let checkpoints = 0;
|
|
119
|
+
for (const entry of entries.values())
|
|
120
|
+
checkpoints += entry.checkpoints.length;
|
|
121
|
+
return { keys: entries.size, checkpoints };
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=cache.js.map
|