topotopo 0.1.0 → 0.3.0

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 (80) hide show
  1. package/README.md +61 -12
  2. package/dist/cli.js +161 -23
  3. package/dist/graph/engine.js +18 -1
  4. package/dist/graph/params.d.ts +7 -1
  5. package/dist/graph/params.js +21 -8
  6. package/dist/graph/types.d.ts +28 -2
  7. package/dist/host/artifacts.d.ts +21 -0
  8. package/dist/host/artifacts.js +58 -0
  9. package/dist/host/export-targets.d.ts +33 -0
  10. package/dist/host/export-targets.js +84 -0
  11. package/dist/host/export_scene.py +200 -0
  12. package/dist/host/node.d.ts +8 -0
  13. package/dist/host/node.js +114 -0
  14. package/dist/host/types.d.ts +5 -0
  15. package/dist/inspect.js +5 -2
  16. package/dist/io/common.d.ts +6 -0
  17. package/dist/io/export.js +7 -4
  18. package/dist/io/gltf.d.ts +1 -1
  19. package/dist/io/gltf.js +167 -32
  20. package/dist/io/heightmap.d.ts +1 -1
  21. package/dist/io/import.d.ts +2 -2
  22. package/dist/io/import.js +10 -3
  23. package/dist/io/png-decode.d.ts +5 -2
  24. package/dist/io/png-decode.js +10 -3
  25. package/dist/io/targets.d.ts +31 -0
  26. package/dist/io/targets.js +75 -0
  27. package/dist/io/textures.d.ts +31 -2
  28. package/dist/io/textures.js +66 -18
  29. package/dist/io/unreal.js +22 -5
  30. package/dist/mcp/docs.d.ts +9 -2
  31. package/dist/mcp/docs.js +126 -2
  32. package/dist/mcp/inputs.d.ts +28 -0
  33. package/dist/mcp/inputs.js +377 -0
  34. package/dist/mcp/server.js +4 -1
  35. package/dist/mcp/tools.d.ts +1 -1
  36. package/dist/mcp/tools.js +118 -51
  37. package/dist/nodes/code.js +34 -6
  38. package/dist/nodes/copy.js +14 -4
  39. package/dist/nodes/flow.js +136 -35
  40. package/dist/nodes/heightfield.js +67 -11
  41. package/dist/nodes/texture.d.ts +1 -1
  42. package/dist/nodes/texture.js +61 -5
  43. package/dist/render/options.js +2 -1
  44. package/dist/render/raster.d.ts +2 -0
  45. package/dist/render/raster.js +3 -3
  46. package/dist/render/texview.d.ts +9 -1
  47. package/dist/render/texview.js +43 -2
  48. package/dist/viewer/app/api.js +64 -0
  49. package/dist/viewer/app/camera.js +250 -0
  50. package/dist/viewer/app/export-popover.js +68 -0
  51. package/dist/viewer/app/grid.js +217 -0
  52. package/dist/viewer/app/guides.js +402 -0
  53. package/dist/viewer/app/image-view.js +114 -0
  54. package/dist/viewer/app/keymap.js +199 -0
  55. package/dist/viewer/app/layers.js +244 -0
  56. package/dist/viewer/app/main.js +646 -0
  57. package/dist/viewer/app/markers.js +206 -0
  58. package/dist/viewer/app/mesh.js +172 -0
  59. package/dist/viewer/app/outliner.js +225 -0
  60. package/dist/viewer/app/outputs.css +73 -0
  61. package/dist/viewer/app/outputs.js +240 -0
  62. package/dist/viewer/app/props.js +432 -0
  63. package/dist/viewer/app/scene.js +529 -0
  64. package/dist/viewer/app/select.js +153 -0
  65. package/dist/viewer/app/selection.js +79 -0
  66. package/dist/viewer/app/status.js +20 -0
  67. package/dist/viewer/app/style.css +173 -0
  68. package/dist/viewer/app/undo.js +49 -0
  69. package/dist/viewer/app/units.js +51 -0
  70. package/dist/viewer/app/vars.js +64 -0
  71. package/dist/viewer/index.html +67 -184
  72. package/dist/viewer/outputs.d.ts +54 -0
  73. package/dist/viewer/outputs.js +436 -0
  74. package/dist/viewer/server.d.ts +47 -1
  75. package/dist/viewer/server.js +480 -17
  76. package/dist/workspace.d.ts +60 -4
  77. package/dist/workspace.js +131 -5
  78. package/package.json +9 -3
  79. package/shelf/brick_wall.json +1 -1
  80. package/shelf/wall_segment.json +2 -2
package/README.md CHANGED
@@ -1,10 +1,11 @@
1
- # TopoTopo
1
+ # <img src="docs/brand/mark.svg" alt="" width="44" height="44" align="top"> TopoTopo
2
2
 
3
3
  *Formerly Polyforge; renamed before the first tagged release. Files written as `polyforge-*` still load.*
4
4
 
5
- A procedural geometry engine: Houdini-style node graphs, headless, deterministic, exports to Unreal, Blender and
6
- Houdini. You build JSON node graphs; the engine cooks them into geometry. It runs on Node and WebAssembly with no
7
- GPU, every cook is reproducible from the graph and its seeds, and the core is browser-ready.
5
+ TopoTopo is an open-source procedural geometry engine. You describe geometry as a node graph in JSON; the engine
6
+ cooks it headless and deterministic, and exports it to Unreal, Blender and Houdini. Built for LLM agents through MCP,
7
+ and for people through the CLI. It runs on Node and WebAssembly with no GPU, every cook is reproducible from the
8
+ graph and its seeds, and the core is browser-ready.
8
9
 
9
10
  - **200+ node types**: generators, curves and splines (Bezier/NURBS), sweeps, deformers, attributes and groups, polygon
10
11
  modelling (bevel, knife, bridge, quad fill), UVs (xatlas unwrap, LSCM), remeshing (isotropic, quad, voxel) and LOD,
@@ -13,6 +14,8 @@ GPU, every cook is reproducible from the graph and its seeds, and the core is br
13
14
  cloth draping and inflation (XPBD), L-systems, text, loops, a frame solver, in-graph checks, and code nodes. A shelf of ready-made graph assets (walls,
14
15
  roofs, stairs, props, trees, rocks, PCG helpers) is used like nodes. Every node documents its params and carries an
15
16
  example that is cooked in the tests.
17
+ - **Familiar to Houdini users**: SOP-style nodes, attributes, groups and `.geo` export; Houdini node names work as
18
+ aliases. Blender Geometry Nodes and Unreal PCG users will recognise the rest.
16
19
  - **Rust/WASM kernels**: a straight skeleton (pitched roofs and polygon offsets from any footprint, holes included),
17
20
  sampled SDF grids with CSG, morphology and sharp dual contouring (`voxel_remesh`), field-aligned quad remeshing
18
21
  (`remesh_quad`), and heightfield erosion and drainage (virtual-pipe and stream-power models, D8/D-infinity flow) are
@@ -29,6 +32,13 @@ GPU, every cook is reproducible from the graph and its seeds, and the core is br
29
32
  with Unreal PCG columns, points JSON, USDA (UsdPreviewSurface materials), OBJ, PLY, STL and lossless JSON, with
30
33
  Y/Z-up and scale. Mesh splitting follows the `name` attribute, for Unreal, Blender and Houdini. The `file` node reads
31
34
  the same formats back (glTF with meshopt and external buffers, `.geo` and uncompressed `.bgeo`, CSV).
35
+
36
+ **Scene and folder import.** Give the `file` node a GLB/glTF scene, a `.blend` file, or a folder of models.
37
+ Scene empties become points, shared meshes stay packed, and object names, hierarchy paths and custom properties
38
+ arrive as attributes. `.blend` uses Blender's own headless exporter (install Blender; `TOPOTOPO_BLENDER` selects
39
+ the executable). Folders load supported files in name order; `name` is the file stem, or `<stem>/<name>` for files
40
+ with multiple object names, ready for `copy_to_points {variants: 'name'}`.
41
+
32
42
  - **Instancing**: packed primitives (Houdini's packed prims) keep a million copies as a million points plus one shared
33
43
  mesh: `copy_to_points {pack: true}` (with variants), `pack`, `unpack`. Transforms, merges, deletes and attribute nodes
34
44
  work on the instances; other nodes unpack them automatically with a warning. GLB export writes
@@ -41,9 +51,15 @@ GPU, every cook is reproducible from the graph and its seeds, and the core is br
41
51
  with the exact Landscape import settings, the meshes and the instance stream.
42
52
  - **Textures**: a Substance-Designer-style image node family (noise, patterns, cells, shapes, blends, levels, warps,
43
53
  normal and AO from height, channel packing) and bakes from meshes in uv space (AO, curvature, thickness, normals and
44
- height from a high-res mesh, island ids, masks, vertex colour). Images travel with the geometry, materials reference
45
- them as `image:<name>`, and glTF/USD exports carry them, so assets ship with textures whose licence is the graph's.
46
- - **Live preview**: a local three.js page that refreshes whenever a graph changes. Click any node to see its result.
54
+ height from a high-res mesh, island ids, masks, vertex colour). Your own PNGs (8 or 16 bit, grey or colour) load as
55
+ images with `image_file` and go through the same nodes. Images travel with the geometry, materials reference them as
56
+ `image:<name>`, and glTF/USD exports carry them, so assets ship with textures whose licence is the graph's.
57
+ - **Live preview and controls**: a local three.js page that refreshes whenever a graph changes. Click any node to
58
+ see its result. A graph's exposed `params` appear as sliders, checkboxes, selects and colour pickers; dragging one
59
+ re-cooks only the nodes that read it. Drop your own files
60
+ anywhere on the page: they land in the workspace `inputs/` folder (with `topotopo view`, the `inputs/` folder beside
61
+ the graph file), show as grey reference layers (a PNG as a flat picture), and `file` nodes read them as
62
+ `inputs/<name>`.
47
63
 
48
64
  **Documentation:** [topotopo.dev](https://topotopo.dev) has the node reference, the shelf and example galleries, the
49
65
  design notes and the release notes, generated from this repository (`npm run site`).
@@ -62,11 +78,14 @@ Requires Node ≥ 22.18.
62
78
  npx topotopo --catalog # node types by category
63
79
  npx topotopo --help-node scatter # one node's params, defaults and example
64
80
  npx topotopo graph.json --render out.png --export out.glb # cook, render, export
81
+ npx topotopo graph.json --export-all # every output's export target, into exports/ beside it
82
+ npx topotopo view graph.json # live 3D viewer, re-cooks on save; drops go to inputs/ beside it
65
83
  npx topotopo mcp --workspace /absolute/path/to/workspace # the MCP server over stdio
66
84
  ```
67
85
 
68
- The package is not on npm yet. Until it is, build the tarball from a clone and install that:
69
- `npm pack`, then `npm install -g ./topotopo-0.1.0.tgz` (or into a project), and run `topotopo …`.
86
+ The current release is v0.3 ([release notes](docs/release/v0.3-release-notes.md)); v0.2 was the first tagged release
87
+ ([release notes](docs/release/v0.2-release-notes.md)). To try unreleased changes, build the tarball from a clone: `npm pack`, then
88
+ `npm install -g ./topotopo-<version>.tgz` (or into a project).
70
89
  `import { Engine } from 'topotopo'` gives the engine API (`src/index.ts`) with its type declarations.
71
90
 
72
91
  **From a clone.** Development runs the TypeScript sources directly (Node type stripping); there is no build step for
@@ -95,6 +114,22 @@ To browse the examples in the live 3D viewer without an MCP client:
95
114
  npm run preview
96
115
  ```
97
116
 
117
+ The viewer's left panel has three tabs: **Scene** (the graph's nodes and guides), **Outputs** and **Inputs**.
118
+ **Outputs**, the default, shows all graphs' main results, named deliverables and their textures; click one to
119
+ inspect it alone, and **Scene** in the top bar restores the scene. Publish extra results in graph JSON with
120
+ `"outputs": {"rotor": {"node": "rotor_final", "label": "Rotor"}}`, keeping `"output"` as the default. Each result
121
+ has an **Export** button: the first click asks for a format and a file inside `exports/` and stores that target in
122
+ the graph (`"export": "glb"`, or `{"format": "obj", "path": "parts/rotor.obj", "up_axis": "z"}`, on the graph or on an
123
+ output); later clicks overwrite the same file, and **Export all** writes every target. MCP `export {output}` and the
124
+ CLI's `--export-all` write the same targets. **Inputs** lists your files in `inputs/`, with **Add input** (or drop
125
+ files anywhere on the page); the agent is told about new ones in its next tool answer. Images open in a 2D viewer
126
+ with layers and channels (generated textures default to RGB; use A to inspect packed alpha/AO); JPEG/WebP are for
127
+ viewing only. For `topotopo view graph.json`, `inputs/` and `exports/` are beside that graph.
128
+
129
+ The default **TopoTopo** keymap uses right-drag orbit around the surface beneath the cursor, middle-drag pan and
130
+ wheel zoom toward the cursor. F/A frame, 1–4 select cameras, 5–7 change shading, O toggles orthographic and Space
131
+ toggles quad view. Maya, Houdini and Blender presets, including previously saved choices, remain available.
132
+
98
133
  **Threads.** Heavy per-element work (noise displacement, attribute noise, occlusion rays, point wrangles) runs in
99
134
  chunks on a pool of worker threads, and independent graph branches cook concurrently. The output is the same bit for
100
135
  bit with or without it. `TOPOTOPO_WORKERS` sets the number of threads (default: CPU threads − 1; `0` turns the pool
@@ -133,14 +168,21 @@ From a clone, run the sources instead: `"command": "node"`, `"args": ["C:/path/t
133
168
  Options: `--viewer` (start the live preview immediately), or set `TOPOTOPO_PORT`, `TOPOTOPO_WORKSPACE`,
134
169
  `TOPOTOPO_SCRIPT_TIMEOUT_MS`, `TOPOTOPO_SIM_TIMEOUT_MS` and `TOPOTOPO_WORKERS`. The server exits when the client closes its stdin.
135
170
 
171
+ The workspace folder holds `graphs/` (one JSON file per graph), `assets/` (saved assets), `inputs/` and `exports/`.
172
+ `inputs/` is where you put your own material: meshes, guide curves and points, blockouts and PNG masks from Blender,
173
+ Houdini or Unreal (glTF, OBJ, STL, PLY, .geo, CSV, PNG). The agent's `inputs` tool lists and describes them, and a
174
+ `file` node reads one as `inputs/<file>` (a PNG as an image for the texture nodes). Naming objects `path.river`, `zone.park`, `site.well`, `volume.no_trees` or
175
+ `blockout.house` tells the agent what they mean (help topic `inputs`).
176
+
136
177
  ## MCP tools
137
178
 
138
179
  | tool | purpose |
139
180
  |---|---|
140
181
  | `catalog` | node types by category (filter/search) |
141
- | `help` | exact node docs (params, defaults, ranges, aliases, examples) and topics: graph, expressions, time, wrangle, groups, attributes, sdf, heightfield, textures, loops, instancing, rbd, pcg, curves, particles, assets, uv, render, export, verify, recipes |
142
- | `build` | create/replace a graph and cook it; `dry_run`, `mode: "merge"`, optional `render`, `frame` (also on edit, inspect, render, export) |
182
+ | `help` | exact node docs (params, defaults, ranges, aliases, examples) and topics: graph, inputs, expressions, time, wrangle, groups, attributes, sdf, heightfield, textures, loops, instancing, rbd, pcg, curves, particles, assets, uv, render, export, verify, cloth, recipes |
183
+ | `build` | create/replace a graph and cook it; `dry_run`, `mode: "merge"`, optional `render`, `frame` (also on edit, inspect, render, export); `params` declare the vars a person tunes (type, desc, min, max, options, label, step), shown as controls in the viewer |
143
184
  | `edit` | incremental ops: set, add, insert-after, remove (auto-rewire), connect, rename, replace, output, vars, bypass |
185
+ | `inputs` | `list` the files in the workspace `inputs/` folder (kind, size, date, counts, bbox, object names; PNG size and bit depth) and `describe` one: polygons vs open polylines vs loose points vs packed prims, bbox, attributes, groups, every `name` / `role` value, materials |
144
186
  | `inspect` | summary, attribute stats, topology health (watertight, pieces, volume, genus), element listings, images (size and per-layer ranges) |
145
187
  | `render` | PNG with persp/front/right/top views, `turntable`, a `uv` view (`quad_uv`), uv checker, a `texture` view of images and `textures: true` for base colour maps on meshes, colour by attribute (+ `legend`), group highlight, flipped normals in blue; opt-in overlays: `section` view and `clip` plane with filled caps, edge overlays (`wireframe` all / hard / boundary / groups), `glyphs` (N, up, orient, v), group / piece / attribute labels and point / prim numbers, `shading` flat / matcap / ao, `scale: 2`, `view_labels: false` (no view labels or axis gizmo, as in the shelf thumbnails); a footer line names what is shown (help topic `render`) |
146
188
  | `export` | glb / gltf / obj / usda / ply / stl / json / geo / csv / points.json (packed instances: instance rows + meshes sidecar + one GLB per source); heightfields: png16 / r16 (+ JSON sidecar with the Unreal import numbers) / mask_png / unreal_landscape (a directory bundle); images: textures (one PNG per image; glb/gltf embed and usda writes the textures materials use) |
@@ -217,7 +259,7 @@ boundary. Do not cook graphs from untrusted sources in a privileged process.
217
259
  | sdf | `sdf_shape` `sdf_expr` `sdf_combine` `sdf_modify` `sdf_from_mesh` `sdf_to_mesh` (implicit, lazily sampled); sampled grids (Rust/WASM): `sdf_grid` `grid_combine` `grid_offset` `grid_blur` `grid_dilate` `grid_erode` `grid_resample` `grid_to_mesh` (sharp dual contouring or marching cubes) `grid_to_sdf` `voxel_remesh` |
218
260
  | heightfield | `heightfield` `heightfield_noise` `heightfield_from_mesh` `heightfield_from_image` `heightfield_to_mesh` `heightfield_to_sdf` `heightfield_remap` `heightfield_blur` `heightfield_terrace` `heightfield_clip` `heightfield_cutout` `heightfield_layer` `heightfield_mask_by_feature` `heightfield_mask_by_object` `heightfield_mask_noise` `heightfield_mask_blur` `heightfield_mask_expand` `heightfield_erode` `heightfield_fill_sinks` `heightfield_flow` `heightfield_project` `heightfield_path` `heightfield_scatter` `heightfield_resample` `heightfield_transform` (grids in XZ with named layers; export `png16`/`r16`/`mask_png` for Unreal Landscape) |
219
261
  | uv | `uv_project` `uv_transform` `uv_unwrap` (xatlas) `uv_flatten` (LSCM) `uv_layout` `uv_autoseam` |
220
- | texture | generators `tex_noise` `tex_pattern` `tex_cells` `tex_gradient` `tex_shape` `tex_splatter` `tex_scratches` `tex_text`; filters `tex_blend` `tex_levels` `tex_curves` `tex_blur` `tex_warp` `tex_transform` `tex_edge_detect` `tex_invert` `tex_threshold` `tex_histogram_scan` `tex_normal_from_height` `tex_height_to_ao` `tex_channel` (ORM packing) `tex_colorize` `tex_tile_sampler` `tex_output`; bakes in uv space `bake_ao` `bake_curvature` `bake_thickness` `bake_position` `bake_normal` `bake_world_normal` `bake_height` `bake_island_id` `bake_mask` `bake_vertex_color`; `texture_to_material` `tex_to_heightfield` `heightfield_to_tex` (images are 2D float layers carried on the geometry; glb/usda embed them, export format `textures` writes PNGs) |
262
+ | texture | generators `tex_noise` `tex_pattern` `tex_cells` `tex_gradient` `tex_shape` `tex_splatter` `tex_scratches` `tex_text`; PNG files `image_file`; filters `tex_blend` `tex_levels` `tex_curves` `tex_blur` `tex_warp` `tex_transform` `tex_edge_detect` `tex_invert` `tex_threshold` `tex_histogram_scan` `tex_normal_from_height` `tex_height_to_ao` `tex_channel` (ORM packing) `tex_colorize` `tex_tile_sampler` `tex_output`; bakes in uv space `bake_ao` `bake_curvature` `bake_thickness` `bake_position` `bake_normal` `bake_world_normal` `bake_height` `bake_island_id` `bake_mask` `bake_vertex_color`; `texture_to_material` `tex_to_heightfield` `heightfield_to_tex` (images are 2D float layers carried on the geometry; glb/usda embed them, export format `textures` writes PNGs) |
221
263
  | sim | `particles` (Houdini POPs in one node: emission from points, gravity, drag, wind, curl noise, point/heightfield/SDF fields, attraction, floor and mesh collisions with die/slide/bounce, trails as polylines; steps with the frame, each frame's state cached; bit-identical on the worker pool) `rbd_settle` (drop pieces onto a terrain, container or ground until they rest; Rapier rigid bodies, packed instances stay packed) `rbd_stack` (drop them one at a time into piles) `drape` (XPBD cloth: colliders, floor, pins, wind, self collision) `inflate` (pressure: cushions, balloons) |
222
264
  | code | `wrangle` `script` |
223
265
  | flow | `merge` `switch` `null` `input` `foreach` `solver` `graph_ref` (plus saved assets) |
@@ -263,6 +305,7 @@ node src/cli.ts examples/csg_bracket.json --render out/bracket.png --section x:0
263
305
  node src/cli.ts examples/terrain_erosion.json --node road --export out/terrain.png # 16-bit heightmap + terrain.json (Unreal)
264
306
  node src/cli.ts examples/unreal_demo.json --node landscape --format unreal_landscape --export out/level # Landscape bundle
265
307
  node src/cli.ts examples/forest.json --node forest --export out/trees.csv --pcg # instance rows + trees.meshes.json + trees_meshes/*.glb
308
+ node src/cli.ts view examples/unreal_demo.json # live viewer at http://127.0.0.1:7341: node list, per-node geometry, re-cook on save
266
309
  ```
267
310
 
268
311
  Animated graphs cook at a frame (`--frame 40`) or over a range. With `--frames`, output paths may contain `{frame}` or
@@ -412,6 +455,12 @@ the node library. The renderer is in `src/render`, import/export in `src/io`, an
412
455
  described in [docs/DESIGN.md](docs/DESIGN.md). `dist/` (not in git) is the compiled package: `npm run build` mirrors
413
456
  `src/` there one to one, so the shelf, fonts, kernels and worker entry are found relative to either.
414
457
 
458
+ ## Support
459
+
460
+ TopoTopo is free and stays free. If it saves you time, you can sponsor its maintenance through
461
+ [GitHub Sponsors](https://github.com/sponsors/faxlab). Sponsorship funds maintainer time and comes with no
462
+ obligations either way.
463
+
415
464
  ## Licence
416
465
 
417
466
  Apache-2.0 (see [LICENSE](LICENSE)). Third-party components and their licence texts are listed in [NOTICE](NOTICE).
package/dist/cli.js CHANGED
@@ -15,8 +15,10 @@
15
15
  // instance stream).
16
16
  // With --frames, --render/--export paths may contain {frame} or {frame:04} (zero-padded);
17
17
  // without a pattern a .0001-style frame number is inserted before the extension.
18
- // node src/cli.ts --catalog | --help-node scatter
18
+ // node src/cli.ts --catalog | --help-node scatter | --version
19
19
  // node src/cli.ts mcp [--workspace dir] [--viewer] the MCP server (src/mcp/server.ts) over stdio
20
+ // node src/cli.ts view <graph.json> [--port 7341] live 3D viewer in the browser, re-cooks on every save;
21
+ // files dropped on the page go to inputs/ beside the graph file
20
22
  // Installed from npm the same commands are `topotopo …` (package.json bin → dist/cli.js, built by npm run build).
21
23
  import fs from 'node:fs';
22
24
  import path from 'node:path';
@@ -31,21 +33,36 @@ import { renderArgs } from './render/options.js';
31
33
  import { encodePNG } from './render/png.js';
32
34
  import { EXPORT_FORMATS, exportFormatFromPath } from './io/export.js';
33
35
  import { exportFilesAsync } from './io/files.js';
36
+ import { ArtifactStore } from './host/artifacts.js';
37
+ import { prepareTarget, writeTargets } from './host/export-targets.js';
38
+ import { publishedOutputs } from './io/targets.js';
34
39
  import { inspect } from './inspect.js';
35
40
  import { catalogText, nodeDoc } from './mcp/docs.js';
36
41
  const argv = process.argv.slice(2);
37
42
  const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
38
43
  const fail = (msg) => { console.error(msg); process.exit(1); };
44
+ /** A graph file's name as a graph (view mode) and in receipts and default export file names. */
45
+ const graphName = (file) => path.basename(file).replace(/\.json$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^[^A-Za-z]/, 'g$&') || 'graph';
39
46
  // the command as the user typed it: the sources from a clone, or the package's bin (dist/cli.js)
40
47
  const prog = import.meta.url.endsWith('.ts') ? 'node src/cli.ts' : 'topotopo';
48
+ if (argv.includes('-v') || argv.includes('-V') || argv.includes('--version')) {
49
+ // package.json sits one level above both src/cli.ts and dist/cli.js
50
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
51
+ console.log(`topotopo ${pkg.version}`);
52
+ process.exit(0);
53
+ }
41
54
  if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
42
55
  console.log([
43
56
  `usage: ${prog} <graph.json> [--node n] [--frame N | --frames A-B[:step]] [--fps 24]`,
44
57
  ' [--render out.png] [--views quad|quad_uv|turntable|persp|front,top|persp,uv|persp,section] [--checker] [--color-by attr] [--export out.glb] [--inspect all]',
58
+ ' [--export-all] (every output with an export target in the graph, into exports/ beside it)',
45
59
  ' render overlays: [--wireframe all|hard|boundary|groups] [--hard-angle 30] [--section y:0.5] [--clip x:0.5[:abs]]',
46
60
  ' [--glyphs N|up|orient|v] [--glyph-attrib a] [--labels groups,pieces,points,prims,attrib:name] [--legend] [--points]',
47
61
  ' [--shading flat|matcap|ao] [--size 800] [--render-scale 2] [--azimuth 35] [--elevation 25] [--zoom 1] [--look-at x,y,z] [--ortho-scale w]',
48
62
  ' [--textures] (base colour maps on meshes) [--texture name|image/layer|material] (adds the texture view)',
63
+ ' [--max-triangles 600000] (packed instances drawn; above it an evenly spread subset)',
64
+ ' several renders per cook: repeat --render; options after a --render apply to it, options before the first apply to all',
65
+ ' e.g. --views top --render h.png --color-by height --render r.png --color-by river',
49
66
  ' [--no-view-labels] (no view name labels or axis gizmo, e.g. for thumbnails)',
50
67
  ' export options: [--compress none|meshopt|draco] [--quantize] [--pcg | --pcg-struct] [--attributes P,N,pscale] [--up-axis y|z] [--scale 100]',
51
68
  ' [--instancing packed|expand] (packed instances: glb/usda/geo keep them by default)',
@@ -54,21 +71,84 @@ if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
54
71
  ' --format mask_png --layers a,b, --format unreal_landscape (a directory bundle; --tile-size 1008 for tiled Landscape);',
55
72
  ' images: --format textures --export out/maps (one PNG per image; --layers a,b or * for extra layers)',
56
73
  ' with --frames, output paths may use {frame} or {frame:04}; otherwise out.0001.png style names are written',
57
- ` ${prog} --catalog | --help-node <type>`,
74
+ ` ${prog} --catalog | --help-node <type> | --version`,
58
75
  ` ${prog} mcp [--workspace dir] [--viewer] (the MCP server over stdio, for MCP clients)`,
76
+ ` ${prog} view <graph.json> [--port 7341] (live 3D viewer in the browser; re-cooks when the file is saved; files dropped on the page go to inputs/ beside it)`,
59
77
  ].join('\n'));
60
78
  process.exit(0);
61
79
  }
62
- if (argv.includes('--catalog')) {
63
- console.log(catalogText(allNodeDefs()));
80
+ if (argv.includes('--catalog') || opt('--help-node')) {
81
+ // node types plus the shelf assets (house, stairs, …), which the engine registers when it starts
82
+ const engine = new Engine(createNodeHost({ baseDir: process.cwd() }));
83
+ await engine.init();
84
+ const defs = [...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)];
85
+ if (argv.includes('--catalog'))
86
+ console.log(catalogText(defs));
87
+ else {
88
+ const d = defs.find((x) => x.type === opt('--help-node'));
89
+ console.log(d ? nodeDoc(d) : `unknown node "${opt('--help-node')}" (see --catalog)`);
90
+ }
64
91
  process.exit(0);
65
92
  }
66
- if (opt('--help-node')) {
67
- const d = allNodeDefs().find((x) => x.type === opt('--help-node'));
68
- console.log(d ? nodeDoc(d) : 'unknown node');
69
- process.exit(0);
93
+ if (argv[0] === 'view') {
94
+ // `topotopo view <graph.json> [--port 7341]`: cook the file, serve the live 3D viewer, re-cook on every save
95
+ const file = argv[1];
96
+ if (!file || file.startsWith('-'))
97
+ fail(`usage: ${prog} view <graph.json> [--port 7341]`);
98
+ if (!fs.existsSync(file))
99
+ fail(`${prog}: no such graph file "${file}"`);
100
+ const os = await import('node:os');
101
+ const { Workspace } = await import('./workspace.js');
102
+ const { startViewer } = await import('./viewer/server.js');
103
+ const dir = path.dirname(path.resolve(file));
104
+ const engine = new Engine(createNodeHost({ baseDir: dir }));
105
+ await engine.init();
106
+ // A scratch workspace holds the graph and assets: the graph file stays the only source. Files dropped on the
107
+ // page go to inputs/ beside the graph file instead, made on the first drop: its file nodes read them as
108
+ // "inputs/<name>" (the path an MCP workspace uses too), and they outlive the viewer.
109
+ const ws = new Workspace(fs.mkdtempSync(path.join(os.tmpdir(), 'topotopo-view-')), engine, { inputs: path.join(dir, 'inputs'), exports: path.join(dir, 'exports') });
110
+ ws.load();
111
+ const name = graphName(file);
112
+ const load = async () => {
113
+ let spec;
114
+ try {
115
+ spec = JSON.parse(fs.readFileSync(file, 'utf8'));
116
+ }
117
+ catch (e) {
118
+ console.error(`${file}: ${e.message} (keeping the last good version)`);
119
+ return;
120
+ }
121
+ ws.setGraph(name, spec);
122
+ const r = await ws.cook(name);
123
+ console.log(`${r.ok ? 'cooked' : 'FAILED'} ${name} in ${r.ms.toFixed(0)} ms${r.ok ? '' : ': ' + r.errors.map((e) => `${e.node}: ${e.message}`).join('; ')}`);
124
+ ws.bump(name);
125
+ };
126
+ await load();
127
+ // an export target set on the page goes into the graph file, the only source here: only "export" keys change
128
+ const persistGraph = (g) => {
129
+ if (g !== name)
130
+ return;
131
+ const spec = ws.getGraph(name), raw = JSON.parse(fs.readFileSync(file, 'utf8'));
132
+ if (spec.export === undefined)
133
+ delete raw.export;
134
+ else
135
+ raw.export = spec.export;
136
+ for (const [id, o] of Object.entries(spec.outputs ?? {})) {
137
+ if (!raw.outputs?.[id])
138
+ continue;
139
+ if (o.export === undefined)
140
+ delete raw.outputs[id].export;
141
+ else
142
+ raw.outputs[id].export = o.export;
143
+ }
144
+ fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
145
+ };
146
+ const v = await startViewer(ws, opt('--port') !== undefined ? Number(opt('--port')) : undefined, { inputsWhere: `beside ${path.basename(file)}`, persistGraph });
147
+ console.log(`view: ${v.url} (watching ${file}; files dropped on the page go to ${path.join(path.dirname(file), 'inputs') + path.sep}; Ctrl+C to stop)`);
148
+ let timer;
149
+ fs.watch(file, () => { clearTimeout(timer); timer = setTimeout(() => { load().catch((e) => console.error(String(e))); }, 150); });
70
150
  }
71
- if (argv[0] === 'mcp') {
151
+ else if (argv[0] === 'mcp') {
72
152
  // `topotopo mcp [--workspace dir] [--viewer]`: the MCP server over stdio; src/mcp/server.ts reads the remaining
73
153
  // arguments from process.argv and runs until the client closes stdin
74
154
  process.argv.splice(2, 1);
@@ -105,7 +185,17 @@ async function cook() {
105
185
  return p.slice(0, p.length - ext.length) + '.' + frameText(frame, 4) + ext;
106
186
  }
107
187
  const file = argv[0];
108
- const graph = JSON.parse(fs.readFileSync(file, 'utf8'));
188
+ if (file.startsWith('-'))
189
+ fail(`${prog}: unknown option "${file}" (the first argument is the graph file; see --help)`);
190
+ if (!fs.existsSync(file))
191
+ fail(`${prog}: no such graph file "${file}"`);
192
+ let graph;
193
+ try {
194
+ graph = JSON.parse(fs.readFileSync(file, 'utf8'));
195
+ }
196
+ catch (e) {
197
+ fail(`${prog}: ${file} is not valid JSON: ${e.message}`);
198
+ }
109
199
  const engine = new Engine(createNodeHost({ baseDir: path.dirname(path.resolve(file)) }));
110
200
  await engine.init();
111
201
  const target = opt('--node');
@@ -114,6 +204,8 @@ async function cook() {
114
204
  const frameArg = opt('--frame');
115
205
  if (framesArg && frameArg)
116
206
  fail('use either --frame or --frames, not both');
207
+ if (framesArg && argv.includes('--export-all'))
208
+ fail('--export-all writes one frame: use --frame N, not --frames');
117
209
  const single = frameArg !== undefined ? Number(frameArg) : undefined;
118
210
  if (single !== undefined && !Number.isFinite(single))
119
211
  fail(`--frame expects a number, got "${frameArg}"`);
@@ -124,18 +216,18 @@ async function cook() {
124
216
  console.error(`warning [${w.node}] ${w.message}`);
125
217
  }
126
218
  /** Render overlay flags → render tool arguments → RenderOptions (src/render/options.ts). */
127
- function renderFlags() {
219
+ function renderFlags(get, has) {
128
220
  const a = {};
129
221
  for (const [f, k] of [['--wireframe', 'wireframe'], ['--hard-angle', 'hard_angle'], ['--section', 'section'], ['--clip', 'clip'], ['--glyphs', 'glyphs'],
130
222
  ['--glyph-attrib', 'glyph_attrib'], ['--glyph-scale', 'glyph_scale'], ['--max-glyphs', 'max_glyphs'], ['--labels', 'labels'], ['--max-labels', 'max_labels'],
131
223
  ['--shading', 'shading'], ['--ao-radius', 'ao_radius'], ['--size', 'size'], ['--render-scale', 'scale'], ['--azimuth', 'azimuth'], ['--elevation', 'elevation'],
132
- ['--zoom', 'zoom'], ['--look-at', 'look_at'], ['--ortho-scale', 'ortho_scale'], ['--texture', 'texture']])
133
- if (opt(f) !== undefined)
134
- a[k] = opt(f);
224
+ ['--zoom', 'zoom'], ['--look-at', 'look_at'], ['--ortho-scale', 'ortho_scale'], ['--texture', 'texture'], ['--max-triangles', 'max_triangles']])
225
+ if (get(f) !== undefined)
226
+ a[k] = get(f);
135
227
  for (const [f, k] of [['--legend', 'legend'], ['--points', 'points'], ['--annotate', 'annotate'], ['--textures', 'textures']])
136
- if (argv.includes(f))
228
+ if (has(f))
137
229
  a[k] = true;
138
- if (argv.includes('--no-view-labels'))
230
+ if (has('--no-view-labels'))
139
231
  a.view_labels = false;
140
232
  try {
141
233
  return renderArgs(a);
@@ -144,23 +236,42 @@ async function cook() {
144
236
  return fail(e.message);
145
237
  }
146
238
  }
239
+ // Several renders per run (one cook): every --render takes the render options that follow it, up to the next
240
+ // --render; options before the first --render apply to all of them. With one --render this is the old behaviour.
241
+ const renderAt = argv.flatMap((x, i) => (x === '--render' ? [i] : []));
242
+ const sharedArgs = argv.slice(0, renderAt.length ? renderAt[0] : argv.length);
243
+ const renders = renderAt.map((start, n) => {
244
+ const seg = argv.slice(start, n + 1 < renderAt.length ? renderAt[n + 1] : argv.length);
245
+ if (!seg[1] || seg[1].startsWith('--'))
246
+ fail('--render needs an output path (e.g. --render out.png)');
247
+ const from = (args, f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
248
+ return { out: seg[1], get: (f) => from(seg, f) ?? from(sharedArgs, f), has: (f) => seg.includes(f) || sharedArgs.includes(f) };
249
+ });
147
250
  async function writeOutputs(r, frame) {
148
251
  const geo = r.geo;
149
- const out = opt('--render');
150
252
  const at = (p) => (frame === undefined ? p : framePath(p, frame));
151
- if (out) {
152
- const ro = renderFlags();
153
- const v = opt('--views');
253
+ // Receipts for the viewer's saved files, only for files inside exports/ beside the graph: a run whose output goes
254
+ // elsewhere leaves no exports/ folder behind (the viewer lists that folder's files without receipts too).
255
+ const exportsDir = path.join(path.dirname(path.resolve(file)), 'exports');
256
+ const record = (paths, format) => {
257
+ const inside = paths.every((p) => { const rel = path.relative(exportsDir, path.resolve(p)); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); });
258
+ if (inside)
259
+ new ArtifactStore(exportsDir).record(paths, { graph: graphName(file), node: r.target, format });
260
+ };
261
+ for (const { out, get, has } of renders) {
262
+ const ro = renderFlags(get, has);
263
+ const v = get('--views');
154
264
  let views = v === undefined || v === 'quad' ? undefined : parseViews(v);
155
- if (opt('--section') !== undefined)
265
+ if (get('--section') !== undefined)
156
266
  views = !views && v === undefined ? ro.views : views && views.includes('section') ? views : [...(views ?? parseViews('quad')), 'section'];
157
- if (opt('--texture') !== undefined)
267
+ if (get('--texture') !== undefined)
158
268
  views = !views && v === undefined ? ro.views : views && views.includes('texture') ? views : [...(views ?? parseViews('quad')), 'texture'];
159
269
  const title = path.basename(file, '.json') + (r.frame !== 1 ? ` @ frame ${r.frame}` : '');
160
- const img = renderGeometry(geo, { ...ro, views, colorBy: opt('--color-by') ?? null, checker: argv.includes('--checker'), title });
270
+ const img = renderGeometry(geo, { ...ro, views, colorBy: get('--color-by') ?? null, checker: has('--checker'), title });
161
271
  const p = at(out);
162
272
  fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true });
163
273
  fs.writeFileSync(p, encodePNG(img.rgba, img.width, img.height, (d) => zlib.deflateSync(d)));
274
+ record([p], 'png');
164
275
  console.log(`rendered ${p} (${img.width}x${img.height})${img.notes.length ? ' — ' + img.notes.join('; ') : ''}`);
165
276
  }
166
277
  const ex = opt('--export');
@@ -198,6 +309,8 @@ async function cook() {
198
309
  fs.mkdirSync(path.dirname(path.resolve(f.path)), { recursive: true });
199
310
  fs.writeFileSync(f.path, f.bytes);
200
311
  }
312
+ if (files.length)
313
+ record(files.map((f) => f.path), fmt);
201
314
  console.log(`exported ${files.length === 1 ? files[0].path : files.map((f) => f.path).join(', ')}${files.length > 1 || fmt !== 'glb' ? ` — ${summary}` : ''}`);
202
315
  }
203
316
  }
@@ -225,6 +338,31 @@ async function cook() {
225
338
  console.log(`cooked ${file} → ${r.target}${r.frame !== 1 ? ` at frame ${r.frame}` : ''} in ${r.ms.toFixed(0)} ms`);
226
339
  console.log(inspect(r.geo, { detail: opt('--inspect') ?? 'summary' }));
227
340
  await writeOutputs(r);
341
+ if (argv.includes('--export-all'))
342
+ await exportAll();
343
+ }
344
+ /** --export-all: every published output with an export target (io/targets.ts), into exports/ beside the graph. */
345
+ async function exportAll() {
346
+ const name = graphName(file), exportsDir = path.join(path.dirname(path.resolve(file)), 'exports');
347
+ const published = publishedOutputs(name, graph, engine.findOutput(graph).name);
348
+ if (!published.some((p) => p.target))
349
+ fail(`--export-all: no output of ${file} has an export target; add "export": "glb" to the graph (default output) or to an entry of "outputs"`);
350
+ const prepared = [];
351
+ for (const p of published) {
352
+ if (!p.target)
353
+ continue;
354
+ const r = await engine.cook(graph, { target: p.node, frame: single, fps });
355
+ report(r);
356
+ if (!r.ok || !r.geo)
357
+ fail(`--export-all: "${p.label}" did not cook; nothing was written`);
358
+ prepared.push(await prepareTarget(r.geo, name, p, exportsDir, engine.host));
359
+ }
360
+ writeTargets(prepared, exportsDir);
361
+ for (const p of prepared)
362
+ console.log(`exported ${p.label} → ${path.join('exports', ...p.target.path.split('/'))} — ${p.summary}`);
363
+ const skipped = published.filter((p) => !p.target).map((p) => p.label);
364
+ if (skipped.length)
365
+ console.log(`no export target (skipped): ${skipped.join(', ')}`);
228
366
  }
229
367
  // stop the kernel worker threads (idle ones would not hold the process, but a clean exit should not wait on them)
230
368
  await engine.host.pool?.close();
@@ -26,6 +26,7 @@ import { Geometry, isGeometry } from '../core/geometry.js';
26
26
  import { DEFAULT_TRANSFER, expandPacked } from '../core/packed.js';
27
27
  import { splinesToPolylines } from '../core/splines.js';
28
28
  import { hashString } from '../core/random.js';
29
+ import { targetError } from '../io/targets.js';
29
30
  import { allAliases, getNodeDef } from './registry.js';
30
31
  import { DeferredExpr, REF_FUNCS, didYouMean, hasRefs, resolveParamName, resolveParams, scanNodeRefs, scanValue, vecOut, } from './params.js';
31
32
  import { migrateGraphDetailed } from './migrate.js';
@@ -453,6 +454,19 @@ export class Engine {
453
454
  return { errors, warnings };
454
455
  }
455
456
  const names = Object.keys(graph.nodes);
457
+ if (graph.outputs !== undefined) {
458
+ if (!graph.outputs || typeof graph.outputs !== 'object' || Array.isArray(graph.outputs))
459
+ errors.push({ node: path || '(graph)', message: 'outputs must be a map of names to {node, label?}' });
460
+ else
461
+ for (const [id, out] of Object.entries(graph.outputs)) {
462
+ if (!id || !out || typeof out !== 'object' || typeof out.node !== 'string' || !Object.hasOwn(graph.nodes, out.node) || (out.label !== undefined && typeof out.label !== 'string'))
463
+ errors.push({ node: path || '(graph)', message: `named output "${id}" must reference an existing node and have an optional string label` });
464
+ else if (out.export !== undefined && targetError(out.export))
465
+ errors.push({ node: path || '(graph)', message: `named output "${id}": ${targetError(out.export)}` });
466
+ }
467
+ }
468
+ if (graph.export !== undefined && targetError(graph.export))
469
+ errors.push({ node: path || '(graph)', message: targetError(graph.export) });
456
470
  if (!names.length)
457
471
  errors.push({ node: path || '(graph)', message: 'graph has no nodes' });
458
472
  if (graph.output && !graph.nodes[graph.output])
@@ -620,6 +634,8 @@ export class Engine {
620
634
  for (const e of r.errors)
621
635
  if (!e.startsWith('unknown param'))
622
636
  v.errors.push({ node: name, message: e });
637
+ for (const w of r.warnings)
638
+ v.warnings.push({ node: name, message: w });
623
639
  for (const n of r.notes)
624
640
  notes.push(`${name}: ${n}`);
625
641
  if (r.deferred.length)
@@ -762,7 +778,7 @@ export class Engine {
762
778
  return { rp, refSig: '', usesTime: rp.usesTime, depsTimeDependent: false };
763
779
  }
764
780
  const fail = (error, upstream = false) => ({
765
- rp: { values: {}, errors: [], notes: [], usesTime: false, deferred: [] }, refSig: '', usesTime: false, depsTimeDependent: false, error, upstream,
781
+ rp: { values: {}, errors: [], notes: [], warnings: [], usesTime: false, deferred: [] }, refSig: '', usesTime: false, depsTimeDependent: false, error, upstream,
766
782
  });
767
783
  const geo = new Map();
768
784
  const parts = [];
@@ -859,6 +875,7 @@ export class Engine {
859
875
  result.error = { node: name, message: rp.errors.join('; ') };
860
876
  return result;
861
877
  }
878
+ result.warnings.push(...rp.warnings); // values clamped to a param range: said every cook, cache hits included
862
879
  depTime ||= np.depsTimeDependent;
863
880
  if (spec.bypass) {
864
881
  result.geo = inputs[0] ?? new Geometry();
@@ -34,11 +34,17 @@ export declare function vecOut<T extends any[]>(a: T): T;
34
34
  export declare function parseColor(v: any): [number, number, number] | null;
35
35
  export declare class ParamError extends Error {
36
36
  }
37
- export declare function coerce(name: string, pd: ParamDef, v: any): any;
37
+ /**
38
+ * Coerce a raw value to the param's type. Numbers outside the declared min/max are clamped, and `note` (when given)
39
+ * is told, so a cook can warn: a silent clamp hides mistakes such as an asset range narrower than its caller's.
40
+ */
41
+ export declare function coerce(name: string, pd: ParamDef, v: any, note?: (msg: string) => void): any;
38
42
  export interface ResolvedParams {
39
43
  values: Record<string, any>;
40
44
  errors: string[];
41
45
  notes: string[];
46
+ /** values changed on the way in (clamped to the param range); a cook reports them as node warnings */
47
+ warnings: string[];
42
48
  /** an expression read frame, time, fps or timeInc */
43
49
  usesTime: boolean;
44
50
  /** params whose value is only known at cook time (dry run with reference functions) */
@@ -192,8 +192,13 @@ function toNum(v) {
192
192
  }
193
193
  export class ParamError extends Error {
194
194
  }
195
- export function coerce(name, pd, v) {
195
+ /**
196
+ * Coerce a raw value to the param's type. Numbers outside the declared min/max are clamped, and `note` (when given)
197
+ * is told, so a cook can warn: a silent clamp hides mistakes such as an asset range narrower than its caller's.
198
+ */
199
+ export function coerce(name, pd, v, note) {
196
200
  const bad = (expect) => new ParamError(`param "${name}" expects ${expect}, got ${JSON.stringify(v)?.slice(0, 80)}`);
201
+ const range = () => `${pd.min ?? '…'}..${pd.max ?? '…'}`;
197
202
  switch (pd.type) {
198
203
  case 'float':
199
204
  case 'int': {
@@ -201,10 +206,14 @@ export function coerce(name, pd, v) {
201
206
  if (n === null)
202
207
  throw bad(pd.type === 'int' ? 'an integer' : 'a number');
203
208
  let x = pd.type === 'int' ? Math.round(n) : n;
204
- if (pd.min !== undefined && x < pd.min)
209
+ if (pd.min !== undefined && x < pd.min) {
210
+ note?.(`param "${name}" ${x} clamped to ${pd.min} (range ${range()})`);
205
211
  x = pd.min;
206
- if (pd.max !== undefined && x > pd.max)
212
+ }
213
+ if (pd.max !== undefined && x > pd.max) {
214
+ note?.(`param "${name}" ${x} clamped to ${pd.max} (range ${range()})`);
207
215
  x = pd.max;
216
+ }
208
217
  return x;
209
218
  }
210
219
  case 'bool': {
@@ -278,10 +287,14 @@ export function coerce(name, pd, v) {
278
287
  if (nn === null)
279
288
  throw new ParamError(`param "${name}"[${i}] is not a number: ${JSON.stringify(x)}`);
280
289
  let r = isInt ? Math.round(nn) : nn;
281
- if (pd.min !== undefined && r < pd.min)
290
+ if (pd.min !== undefined && r < pd.min) {
291
+ note?.(`param "${name}"[${i}] ${r} clamped to ${pd.min} (range ${range()})`);
282
292
  r = pd.min;
283
- if (pd.max !== undefined && r > pd.max)
293
+ }
294
+ if (pd.max !== undefined && r > pd.max) {
295
+ note?.(`param "${name}"[${i}] ${r} clamped to ${pd.max} (range ${range()})`);
284
296
  r = pd.max;
297
+ }
285
298
  return r;
286
299
  });
287
300
  }
@@ -312,7 +325,7 @@ export function coerce(name, pd, v) {
312
325
  /** Validate names, evaluate expressions and coerce types. */
313
326
  export function resolveParams(def, raw, vars, env = {}) {
314
327
  const values = {};
315
- const errors = [], notes = [], deferred = [];
328
+ const errors = [], notes = [], warnings = [], deferred = [];
316
329
  let usesTime = false;
317
330
  const ev = { ...env, onTime: () => { usesTime = true; env.onTime?.(); } };
318
331
  let scope;
@@ -351,7 +364,7 @@ export function resolveParams(def, raw, vars, env = {}) {
351
364
  try {
352
365
  if (pd.type !== 'code' && pd.type !== 'graph')
353
366
  v = evalValue(v, getScope, `"${name}"`);
354
- values[name] = v === undefined ? undefined : coerce(name, pd, v);
367
+ values[name] = v === undefined ? undefined : coerce(name, pd, v, (m) => warnings.push(m));
355
368
  }
356
369
  catch (e) {
357
370
  if (e instanceof DeferredExpr)
@@ -361,7 +374,7 @@ export function resolveParams(def, raw, vars, env = {}) {
361
374
  values[name] = pd.default;
362
375
  }
363
376
  }
364
- return { values, errors, notes, usesTime, deferred };
377
+ return { values, errors, notes, warnings, usesTime, deferred };
365
378
  }
366
379
  /** Evaluate a ramp at t. Values may be numbers or arrays. */
367
380
  export function evalRamp(keys, t) {