topotopo 0.1.0 → 0.2.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 (66) hide show
  1. package/README.md +44 -12
  2. package/dist/cli.js +98 -23
  3. package/dist/graph/engine.js +4 -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 +11 -2
  7. package/dist/host/export_scene.py +200 -0
  8. package/dist/host/node.d.ts +8 -0
  9. package/dist/host/node.js +114 -0
  10. package/dist/host/types.d.ts +5 -0
  11. package/dist/inspect.js +5 -2
  12. package/dist/io/common.d.ts +6 -0
  13. package/dist/io/export.js +7 -4
  14. package/dist/io/gltf.d.ts +1 -1
  15. package/dist/io/gltf.js +167 -32
  16. package/dist/io/heightmap.d.ts +1 -1
  17. package/dist/io/import.d.ts +2 -2
  18. package/dist/io/import.js +10 -3
  19. package/dist/io/png-decode.d.ts +5 -2
  20. package/dist/io/png-decode.js +10 -3
  21. package/dist/io/textures.d.ts +31 -2
  22. package/dist/io/textures.js +66 -18
  23. package/dist/io/unreal.js +22 -5
  24. package/dist/mcp/docs.d.ts +9 -2
  25. package/dist/mcp/docs.js +113 -2
  26. package/dist/mcp/inputs.d.ts +28 -0
  27. package/dist/mcp/inputs.js +377 -0
  28. package/dist/mcp/server.js +4 -1
  29. package/dist/mcp/tools.js +32 -18
  30. package/dist/nodes/code.js +2 -0
  31. package/dist/nodes/copy.js +14 -4
  32. package/dist/nodes/flow.js +136 -35
  33. package/dist/nodes/heightfield.js +67 -11
  34. package/dist/nodes/texture.d.ts +1 -1
  35. package/dist/nodes/texture.js +61 -5
  36. package/dist/render/options.js +2 -1
  37. package/dist/render/raster.d.ts +2 -0
  38. package/dist/render/raster.js +3 -3
  39. package/dist/render/texview.d.ts +9 -1
  40. package/dist/render/texview.js +43 -2
  41. package/dist/viewer/app/api.js +64 -0
  42. package/dist/viewer/app/camera.js +230 -0
  43. package/dist/viewer/app/grid.js +217 -0
  44. package/dist/viewer/app/guides.js +402 -0
  45. package/dist/viewer/app/keymap.js +187 -0
  46. package/dist/viewer/app/layers.js +225 -0
  47. package/dist/viewer/app/main.js +590 -0
  48. package/dist/viewer/app/markers.js +206 -0
  49. package/dist/viewer/app/mesh.js +172 -0
  50. package/dist/viewer/app/outliner.js +184 -0
  51. package/dist/viewer/app/props.js +432 -0
  52. package/dist/viewer/app/scene.js +529 -0
  53. package/dist/viewer/app/select.js +142 -0
  54. package/dist/viewer/app/selection.js +79 -0
  55. package/dist/viewer/app/style.css +172 -0
  56. package/dist/viewer/app/undo.js +49 -0
  57. package/dist/viewer/app/units.js +51 -0
  58. package/dist/viewer/app/vars.js +64 -0
  59. package/dist/viewer/index.html +53 -184
  60. package/dist/viewer/server.d.ts +42 -1
  61. package/dist/viewer/server.js +432 -17
  62. package/dist/workspace.d.ts +52 -2
  63. package/dist/workspace.js +95 -3
  64. package/package.json +8 -3
  65. package/shelf/brick_wall.json +1 -1
  66. 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,13 @@ 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 view graph.json # live 3D viewer, re-cooks on save; drops go to inputs/ beside it
65
82
  npx topotopo mcp --workspace /absolute/path/to/workspace # the MCP server over stdio
66
83
  ```
67
84
 
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 …`.
85
+ v0.2 is the first tagged release ([release notes](docs/release/v0.2-release-notes.md)); `topotopo@0.1.0` was an untagged
86
+ preview. To try unreleased changes, build the tarball from a clone: `npm pack`, then
87
+ `npm install -g ./topotopo-<version>.tgz` (or into a project).
70
88
  `import { Engine } from 'topotopo'` gives the engine API (`src/index.ts`) with its type declarations.
71
89
 
72
90
  **From a clone.** Development runs the TypeScript sources directly (Node type stripping); there is no build step for
@@ -133,14 +151,21 @@ From a clone, run the sources instead: `"command": "node"`, `"args": ["C:/path/t
133
151
  Options: `--viewer` (start the live preview immediately), or set `TOPOTOPO_PORT`, `TOPOTOPO_WORKSPACE`,
134
152
  `TOPOTOPO_SCRIPT_TIMEOUT_MS`, `TOPOTOPO_SIM_TIMEOUT_MS` and `TOPOTOPO_WORKERS`. The server exits when the client closes its stdin.
135
153
 
154
+ The workspace folder holds `graphs/` (one JSON file per graph), `assets/` (saved assets), `inputs/` and `exports/`.
155
+ `inputs/` is where you put your own material: meshes, guide curves and points, blockouts and PNG masks from Blender,
156
+ Houdini or Unreal (glTF, OBJ, STL, PLY, .geo, CSV, PNG). The agent's `inputs` tool lists and describes them, and a
157
+ `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
158
+ `blockout.house` tells the agent what they mean (help topic `inputs`).
159
+
136
160
  ## MCP tools
137
161
 
138
162
  | tool | purpose |
139
163
  |---|---|
140
164
  | `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) |
165
+ | `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 |
166
+ | `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
167
  | `edit` | incremental ops: set, add, insert-after, remove (auto-rewire), connect, rename, replace, output, vars, bypass |
168
+ | `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
169
  | `inspect` | summary, attribute stats, topology health (watertight, pieces, volume, genus), element listings, images (size and per-layer ranges) |
145
170
  | `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
171
  | `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 +242,7 @@ boundary. Do not cook graphs from untrusted sources in a privileged process.
217
242
  | 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
243
  | 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
244
  | 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) |
245
+ | 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
246
  | 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
247
  | code | `wrangle` `script` |
223
248
  | flow | `merge` `switch` `null` `input` `foreach` `solver` `graph_ref` (plus saved assets) |
@@ -263,6 +288,7 @@ node src/cli.ts examples/csg_bracket.json --render out/bracket.png --section x:0
263
288
  node src/cli.ts examples/terrain_erosion.json --node road --export out/terrain.png # 16-bit heightmap + terrain.json (Unreal)
264
289
  node src/cli.ts examples/unreal_demo.json --node landscape --format unreal_landscape --export out/level # Landscape bundle
265
290
  node src/cli.ts examples/forest.json --node forest --export out/trees.csv --pcg # instance rows + trees.meshes.json + trees_meshes/*.glb
291
+ 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
292
  ```
267
293
 
268
294
  Animated graphs cook at a frame (`--frame 40`) or over a range. With `--frames`, output paths may contain `{frame}` or
@@ -412,6 +438,12 @@ the node library. The renderer is in `src/render`, import/export in `src/io`, an
412
438
  described in [docs/DESIGN.md](docs/DESIGN.md). `dist/` (not in git) is the compiled package: `npm run build` mirrors
413
439
  `src/` there one to one, so the shelf, fonts, kernels and worker entry are found relative to either.
414
440
 
441
+ ## Support
442
+
443
+ TopoTopo is free and stays free. If it saves you time, you can sponsor its maintenance through
444
+ [GitHub Sponsors](https://github.com/sponsors/faxlab). Sponsorship funds maintainer time and comes with no
445
+ obligations either way.
446
+
415
447
  ## Licence
416
448
 
417
449
  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';
@@ -38,6 +40,12 @@ const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : un
38
40
  const fail = (msg) => { console.error(msg); process.exit(1); };
39
41
  // the command as the user typed it: the sources from a clone, or the package's bin (dist/cli.js)
40
42
  const prog = import.meta.url.endsWith('.ts') ? 'node src/cli.ts' : 'topotopo';
43
+ if (argv.includes('-v') || argv.includes('-V') || argv.includes('--version')) {
44
+ // package.json sits one level above both src/cli.ts and dist/cli.js
45
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
46
+ console.log(`topotopo ${pkg.version}`);
47
+ process.exit(0);
48
+ }
41
49
  if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
42
50
  console.log([
43
51
  `usage: ${prog} <graph.json> [--node n] [--frame N | --frames A-B[:step]] [--fps 24]`,
@@ -46,6 +54,9 @@ if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
46
54
  ' [--glyphs N|up|orient|v] [--glyph-attrib a] [--labels groups,pieces,points,prims,attrib:name] [--legend] [--points]',
47
55
  ' [--shading flat|matcap|ao] [--size 800] [--render-scale 2] [--azimuth 35] [--elevation 25] [--zoom 1] [--look-at x,y,z] [--ortho-scale w]',
48
56
  ' [--textures] (base colour maps on meshes) [--texture name|image/layer|material] (adds the texture view)',
57
+ ' [--max-triangles 600000] (packed instances drawn; above it an evenly spread subset)',
58
+ ' several renders per cook: repeat --render; options after a --render apply to it, options before the first apply to all',
59
+ ' e.g. --views top --render h.png --color-by height --render r.png --color-by river',
49
60
  ' [--no-view-labels] (no view name labels or axis gizmo, e.g. for thumbnails)',
50
61
  ' export options: [--compress none|meshopt|draco] [--quantize] [--pcg | --pcg-struct] [--attributes P,N,pscale] [--up-axis y|z] [--scale 100]',
51
62
  ' [--instancing packed|expand] (packed instances: glb/usda/geo keep them by default)',
@@ -54,21 +65,65 @@ if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
54
65
  ' --format mask_png --layers a,b, --format unreal_landscape (a directory bundle; --tile-size 1008 for tiled Landscape);',
55
66
  ' images: --format textures --export out/maps (one PNG per image; --layers a,b or * for extra layers)',
56
67
  ' with --frames, output paths may use {frame} or {frame:04}; otherwise out.0001.png style names are written',
57
- ` ${prog} --catalog | --help-node <type>`,
68
+ ` ${prog} --catalog | --help-node <type> | --version`,
58
69
  ` ${prog} mcp [--workspace dir] [--viewer] (the MCP server over stdio, for MCP clients)`,
70
+ ` ${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
71
  ].join('\n'));
60
72
  process.exit(0);
61
73
  }
62
- if (argv.includes('--catalog')) {
63
- console.log(catalogText(allNodeDefs()));
74
+ if (argv.includes('--catalog') || opt('--help-node')) {
75
+ // node types plus the shelf assets (house, stairs, …), which the engine registers when it starts
76
+ const engine = new Engine(createNodeHost({ baseDir: process.cwd() }));
77
+ await engine.init();
78
+ const defs = [...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)];
79
+ if (argv.includes('--catalog'))
80
+ console.log(catalogText(defs));
81
+ else {
82
+ const d = defs.find((x) => x.type === opt('--help-node'));
83
+ console.log(d ? nodeDoc(d) : `unknown node "${opt('--help-node')}" (see --catalog)`);
84
+ }
64
85
  process.exit(0);
65
86
  }
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);
87
+ if (argv[0] === 'view') {
88
+ // `topotopo view <graph.json> [--port 7341]`: cook the file, serve the live 3D viewer, re-cook on every save
89
+ const file = argv[1];
90
+ if (!file || file.startsWith('-'))
91
+ fail(`usage: ${prog} view <graph.json> [--port 7341]`);
92
+ if (!fs.existsSync(file))
93
+ fail(`${prog}: no such graph file "${file}"`);
94
+ const os = await import('node:os');
95
+ const { Workspace } = await import('./workspace.js');
96
+ const { startViewer } = await import('./viewer/server.js');
97
+ const dir = path.dirname(path.resolve(file));
98
+ const engine = new Engine(createNodeHost({ baseDir: dir }));
99
+ await engine.init();
100
+ // A scratch workspace holds the graph, assets and exports: the graph file stays the only source. Files dropped on the
101
+ // page go to inputs/ beside the graph file instead, made on the first drop: its file nodes read them as
102
+ // "inputs/<name>" (the path an MCP workspace uses too), and they outlive the viewer.
103
+ const ws = new Workspace(fs.mkdtempSync(path.join(os.tmpdir(), 'topotopo-view-')), engine, { inputs: path.join(dir, 'inputs') });
104
+ ws.load();
105
+ const name = path.basename(file).replace(/\.json$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^[^A-Za-z]/, 'g$&') || 'graph';
106
+ const load = async () => {
107
+ let spec;
108
+ try {
109
+ spec = JSON.parse(fs.readFileSync(file, 'utf8'));
110
+ }
111
+ catch (e) {
112
+ console.error(`${file}: ${e.message} (keeping the last good version)`);
113
+ return;
114
+ }
115
+ ws.setGraph(name, spec);
116
+ const r = await ws.cook(name);
117
+ console.log(`${r.ok ? 'cooked' : 'FAILED'} ${name} in ${r.ms.toFixed(0)} ms${r.ok ? '' : ': ' + r.errors.map((e) => `${e.node}: ${e.message}`).join('; ')}`);
118
+ ws.bump(name);
119
+ };
120
+ await load();
121
+ const v = await startViewer(ws, opt('--port') !== undefined ? Number(opt('--port')) : undefined, { inputsWhere: `beside ${path.basename(file)}` });
122
+ 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)`);
123
+ let timer;
124
+ fs.watch(file, () => { clearTimeout(timer); timer = setTimeout(() => { load().catch((e) => console.error(String(e))); }, 150); });
70
125
  }
71
- if (argv[0] === 'mcp') {
126
+ else if (argv[0] === 'mcp') {
72
127
  // `topotopo mcp [--workspace dir] [--viewer]`: the MCP server over stdio; src/mcp/server.ts reads the remaining
73
128
  // arguments from process.argv and runs until the client closes stdin
74
129
  process.argv.splice(2, 1);
@@ -105,7 +160,17 @@ async function cook() {
105
160
  return p.slice(0, p.length - ext.length) + '.' + frameText(frame, 4) + ext;
106
161
  }
107
162
  const file = argv[0];
108
- const graph = JSON.parse(fs.readFileSync(file, 'utf8'));
163
+ if (file.startsWith('-'))
164
+ fail(`${prog}: unknown option "${file}" (the first argument is the graph file; see --help)`);
165
+ if (!fs.existsSync(file))
166
+ fail(`${prog}: no such graph file "${file}"`);
167
+ let graph;
168
+ try {
169
+ graph = JSON.parse(fs.readFileSync(file, 'utf8'));
170
+ }
171
+ catch (e) {
172
+ fail(`${prog}: ${file} is not valid JSON: ${e.message}`);
173
+ }
109
174
  const engine = new Engine(createNodeHost({ baseDir: path.dirname(path.resolve(file)) }));
110
175
  await engine.init();
111
176
  const target = opt('--node');
@@ -124,18 +189,18 @@ async function cook() {
124
189
  console.error(`warning [${w.node}] ${w.message}`);
125
190
  }
126
191
  /** Render overlay flags → render tool arguments → RenderOptions (src/render/options.ts). */
127
- function renderFlags() {
192
+ function renderFlags(get, has) {
128
193
  const a = {};
129
194
  for (const [f, k] of [['--wireframe', 'wireframe'], ['--hard-angle', 'hard_angle'], ['--section', 'section'], ['--clip', 'clip'], ['--glyphs', 'glyphs'],
130
195
  ['--glyph-attrib', 'glyph_attrib'], ['--glyph-scale', 'glyph_scale'], ['--max-glyphs', 'max_glyphs'], ['--labels', 'labels'], ['--max-labels', 'max_labels'],
131
196
  ['--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);
197
+ ['--zoom', 'zoom'], ['--look-at', 'look_at'], ['--ortho-scale', 'ortho_scale'], ['--texture', 'texture'], ['--max-triangles', 'max_triangles']])
198
+ if (get(f) !== undefined)
199
+ a[k] = get(f);
135
200
  for (const [f, k] of [['--legend', 'legend'], ['--points', 'points'], ['--annotate', 'annotate'], ['--textures', 'textures']])
136
- if (argv.includes(f))
201
+ if (has(f))
137
202
  a[k] = true;
138
- if (argv.includes('--no-view-labels'))
203
+ if (has('--no-view-labels'))
139
204
  a.view_labels = false;
140
205
  try {
141
206
  return renderArgs(a);
@@ -144,20 +209,30 @@ async function cook() {
144
209
  return fail(e.message);
145
210
  }
146
211
  }
212
+ // Several renders per run (one cook): every --render takes the render options that follow it, up to the next
213
+ // --render; options before the first --render apply to all of them. With one --render this is the old behaviour.
214
+ const renderAt = argv.flatMap((x, i) => (x === '--render' ? [i] : []));
215
+ const sharedArgs = argv.slice(0, renderAt.length ? renderAt[0] : argv.length);
216
+ const renders = renderAt.map((start, n) => {
217
+ const seg = argv.slice(start, n + 1 < renderAt.length ? renderAt[n + 1] : argv.length);
218
+ if (!seg[1] || seg[1].startsWith('--'))
219
+ fail('--render needs an output path (e.g. --render out.png)');
220
+ const from = (args, f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
221
+ return { out: seg[1], get: (f) => from(seg, f) ?? from(sharedArgs, f), has: (f) => seg.includes(f) || sharedArgs.includes(f) };
222
+ });
147
223
  async function writeOutputs(r, frame) {
148
224
  const geo = r.geo;
149
- const out = opt('--render');
150
225
  const at = (p) => (frame === undefined ? p : framePath(p, frame));
151
- if (out) {
152
- const ro = renderFlags();
153
- const v = opt('--views');
226
+ for (const { out, get, has } of renders) {
227
+ const ro = renderFlags(get, has);
228
+ const v = get('--views');
154
229
  let views = v === undefined || v === 'quad' ? undefined : parseViews(v);
155
- if (opt('--section') !== undefined)
230
+ if (get('--section') !== undefined)
156
231
  views = !views && v === undefined ? ro.views : views && views.includes('section') ? views : [...(views ?? parseViews('quad')), 'section'];
157
- if (opt('--texture') !== undefined)
232
+ if (get('--texture') !== undefined)
158
233
  views = !views && v === undefined ? ro.views : views && views.includes('texture') ? views : [...(views ?? parseViews('quad')), 'texture'];
159
234
  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 });
235
+ const img = renderGeometry(geo, { ...ro, views, colorBy: get('--color-by') ?? null, checker: has('--checker'), title });
161
236
  const p = at(out);
162
237
  fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true });
163
238
  fs.writeFileSync(p, encodePNG(img.rgba, img.width, img.height, (d) => zlib.deflateSync(d)));
@@ -620,6 +620,8 @@ export class Engine {
620
620
  for (const e of r.errors)
621
621
  if (!e.startsWith('unknown param'))
622
622
  v.errors.push({ node: name, message: e });
623
+ for (const w of r.warnings)
624
+ v.warnings.push({ node: name, message: w });
623
625
  for (const n of r.notes)
624
626
  notes.push(`${name}: ${n}`);
625
627
  if (r.deferred.length)
@@ -762,7 +764,7 @@ export class Engine {
762
764
  return { rp, refSig: '', usesTime: rp.usesTime, depsTimeDependent: false };
763
765
  }
764
766
  const fail = (error, upstream = false) => ({
765
- rp: { values: {}, errors: [], notes: [], usesTime: false, deferred: [] }, refSig: '', usesTime: false, depsTimeDependent: false, error, upstream,
767
+ rp: { values: {}, errors: [], notes: [], warnings: [], usesTime: false, deferred: [] }, refSig: '', usesTime: false, depsTimeDependent: false, error, upstream,
766
768
  });
767
769
  const geo = new Map();
768
770
  const parts = [];
@@ -859,6 +861,7 @@ export class Engine {
859
861
  result.error = { node: name, message: rp.errors.join('; ') };
860
862
  return result;
861
863
  }
864
+ result.warnings.push(...rp.warnings); // values clamped to a param range: said every cook, cache hits included
862
865
  depTime ||= np.depsTimeDependent;
863
866
  if (spec.bypass) {
864
867
  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) {
@@ -74,6 +74,7 @@ export interface NodeSpec {
74
74
  note?: string;
75
75
  ui?: any;
76
76
  }
77
+ /** An exposed var: asset parameter docs and range, and the control the live viewer draws for it. */
77
78
  export interface VarDef {
78
79
  type?: string;
79
80
  default?: any;
@@ -81,6 +82,14 @@ export interface VarDef {
81
82
  min?: number;
82
83
  max?: number;
83
84
  options?: string[];
85
+ /** control label for humans (default: the var name) */
86
+ label?: string;
87
+ /** slider step (default: 1 for ints, else derived from the range) */
88
+ step?: number;
89
+ }
90
+ /** Free-form graph metadata (showcase render presets, editor layout, …). */
91
+ export interface GraphMeta {
92
+ [key: string]: any;
84
93
  }
85
94
  export interface GraphSpec {
86
95
  /** graph format version (see GRAPH_VERSION); missing = 0, migrated on load */
@@ -88,10 +97,10 @@ export interface GraphSpec {
88
97
  nodes: Record<string, NodeSpec>;
89
98
  output?: string;
90
99
  vars?: Record<string, any>;
91
- /** metadata for exposed vars when the graph is used as an asset */
100
+ /** metadata for exposed vars: asset parameters, and the controls the live viewer shows for them */
92
101
  params?: Record<string, VarDef>;
93
102
  description?: string;
94
- meta?: any;
103
+ meta?: GraphMeta;
95
104
  }
96
105
  export interface CookContext {
97
106
  node: string;