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.
- package/README.md +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- package/dist/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +113 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.js +32 -18
- package/dist/nodes/code.js +2 -0
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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).
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 (
|
|
134
|
-
a[k] =
|
|
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 (
|
|
201
|
+
if (has(f))
|
|
137
202
|
a[k] = true;
|
|
138
|
-
if (
|
|
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
|
-
|
|
152
|
-
const ro = renderFlags();
|
|
153
|
-
const v =
|
|
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 (
|
|
230
|
+
if (get('--section') !== undefined)
|
|
156
231
|
views = !views && v === undefined ? ro.views : views && views.includes('section') ? views : [...(views ?? parseViews('quad')), 'section'];
|
|
157
|
-
if (
|
|
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:
|
|
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)));
|
package/dist/graph/engine.js
CHANGED
|
@@ -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();
|
package/dist/graph/params.d.ts
CHANGED
|
@@ -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
|
-
|
|
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) */
|
package/dist/graph/params.js
CHANGED
|
@@ -192,8 +192,13 @@ function toNum(v) {
|
|
|
192
192
|
}
|
|
193
193
|
export class ParamError extends Error {
|
|
194
194
|
}
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/graph/types.d.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
103
|
+
meta?: GraphMeta;
|
|
95
104
|
}
|
|
96
105
|
export interface CookContext {
|
|
97
106
|
node: string;
|