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/dist/mcp/tools.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import zlib from 'node:zlib';
5
- import { Workspace } from '../workspace.js';
5
+ import { Workspace, EditRejected } from '../workspace.js';
6
6
  import { allNodeDefs } from '../graph/registry.js';
7
7
  import { didYouMean } from '../graph/params.js';
8
8
  import { migrateGraph } from '../graph/migrate.js';
@@ -12,10 +12,13 @@ import { renderGeometry } from '../render/raster.js';
12
12
  import { renderArgs } from '../render/options.js';
13
13
  import { encodePNG } from '../render/png.js';
14
14
  import { EXPORT_FORMATS, exportFormatFromPath } from '../io/export.js';
15
- import { exportFilesAsync } from '../io/files.js';
15
+ import { publishedOutputs } from '../io/targets.js';
16
+ import { ArtifactStore } from '../host/artifacts.js';
17
+ import { prepareTarget, serializeExport, writeExportFiles, writeTargets } from '../host/export-targets.js';
16
18
  import { evalGroup } from '../core/groups.js';
17
19
  import { Geometry } from '../core/geometry.js';
18
20
  import { verifyTools } from './verify.js';
21
+ import { inputsTool } from './inputs.js';
19
22
  const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
20
23
  /** " at frame N" when the cook was not at frame 1. */
21
24
  const atFrame = (r) => (r.frame !== 1 ? ` at frame ${r.frame}` : '');
@@ -64,30 +67,19 @@ function pngContent(g, o) {
64
67
  * Landscape bundle, or the single file of other formats). Textures are read through the host; USD texture paths are
65
68
  * written relative to the output file. Returns the report text.
66
69
  */
67
- export async function writeExport(geo, format, p, a, host) {
68
- const warnings = [];
69
- const outDir = format === 'unreal_landscape' && !/\.json$/i.test(p) ? p : path.dirname(p);
70
- const res = await exportFilesAsync(geo, format, p, {
71
- upAxis: a.up_axis, scale: a.scale, splitByName: a.split_by_name ?? true, custom: a.custom_attribs,
72
- materials: a.materials, compress: a.compress, quantize: a.quantize, attributes: a.attributes, pcg: a.pcg || a.pcg_struct, pcgStruct: a.pcg_struct,
73
- precision: a.precision, instancing: a.instancing, instances: a.instances, meshes: a.meshes,
74
- layer: a.layer, layers: a.layers, heightMin: a.height_min, heightMax: a.height_max, heightfield: a.heightfield, tile_size: a.tile_size,
75
- deflate: (d) => zlib.deflateSync(d, { level: 6 }),
76
- readFile: (t) => host.readFile(t),
77
- texturePath: (t) => {
78
- const abs = host.resolvePath(t);
79
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(abs) ? abs : path.relative(outDir, abs).split(path.sep).join('/');
80
- },
81
- warn: (m) => warnings.push(m),
82
- });
83
- for (const f of res.files) {
84
- fs.mkdirSync(path.dirname(f.path), { recursive: true });
85
- fs.writeFileSync(f.path, f.bytes);
86
- }
87
- const total = res.files.reduce((s, f) => s + f.bytes.byteLength, 0);
70
+ export async function writeExport(geo, format, p, a, host, record) {
71
+ const res = await serializeExport(geo, format, p, a, host);
72
+ writeExportFiles(res.files);
73
+ if (res.files.length)
74
+ record?.(res.files.map((f) => f.path));
75
+ return exportReport(res.files, res.outDir, format, res.summary, res.warnings);
76
+ }
77
+ /** "wrote <file or N files in dir> (format, KB): summary", then any warnings. */
78
+ function exportReport(files, outDir, format, summary, warnings) {
79
+ const total = files.reduce((s, f) => s + f.bytes.byteLength, 0);
88
80
  const rel = (f) => path.relative(outDir, f).split(path.sep).join('/');
89
- const list = res.files.length === 1 ? res.files[0].path : `${res.files.length} files in ${outDir}: ${res.files.slice(0, 12).map((f) => rel(f.path)).join(', ')}${res.files.length > 12 ? ', …' : ''}`;
90
- return `wrote ${list} (${format}, ${(total / 1024).toFixed(1)} KB): ${res.summary}${warnings.length ? '\nwarnings:\n ' + [...new Set(warnings)].join('\n ') : ''}`;
81
+ const list = files.length === 1 ? files[0].path : `${files.length} files in ${outDir}: ${files.slice(0, 12).map((f) => rel(f.path)).join(', ')}${files.length > 12 ? ', …' : ''}`;
82
+ return `wrote ${list} (${format}, ${(total / 1024).toFixed(1)} KB): ${summary}${warnings.length ? '\nwarnings:\n ' + warnings.join('\n ') : ''}`;
91
83
  }
92
84
  function renderOpts(a, g, label) {
93
85
  const ro = renderArgs(a); // views (presets, persp@az, section), overlays, shading, labels … (src/render/options.ts)
@@ -131,6 +123,7 @@ const RENDER_PROPS = {
131
123
  glyph_attrib: { type: 'string', description: 'attribute for glyphs "custom": 3 floats = vector, 4 floats = quaternion' },
132
124
  glyph_scale: { type: 'number', description: 'glyph length in world units (default 5% of the bbox diagonal)' },
133
125
  max_glyphs: { type: 'number', description: 'evenly subsample to at most this many glyphs (default 400)' },
126
+ max_triangles: { type: 'number', description: 'packed instances: draw up to this many expanded triangles (default 600000, max 50M); above it an evenly spread subset is drawn and a note says so' },
134
127
  label_groups: { type: 'boolean', description: 'name each point/prim group on a visible member near its centroid, outline prim groups / mark group points in the label colour, legend with counts' },
135
128
  label_pieces: { type: 'boolean', description: 'number connected pieces (#0, #1, … in connectivity order)' },
136
129
  label_attrib: { type: 'string', description: 'label each distinct value of a string/int attribute (e.g. "name") on its elements; colours match color_by' },
@@ -146,22 +139,33 @@ const RENDER_PROPS = {
146
139
  texture: { type: 'string', description: 'adds the "texture" view and picks what it shows: an image name, "image/layer", or a material name (its maps as a labelled strip); "" = every image. A geometry holding only images renders as the texture view by default' },
147
140
  };
148
141
  export function makeTools(ws, opts = {}) {
142
+ // One file per graph node, overwritten by each render: agents render after most edits, so a new file per render
143
+ // would pile up in exports/renders (save_to keeps a copy elsewhere).
144
+ const renderDestination = (name, node) => path.join(ws.exportDir, 'renders', `${name}-${node}`.replace(/[^A-Za-z0-9_.-]/g, '_') + '.png');
149
145
  const engine = ws.engine;
150
- const cookAndReport = async (name, a, extraNotes = []) => {
151
- const r = await ws.cook(name, undefined, a.frame);
152
- ws.bump(name);
146
+ /** The text (+ optional render) of a cook report. */
147
+ const reportResult = (name, r, a, extraNotes = []) => {
153
148
  const content = [{ type: 'text', text: reportText(name, r, extraNotes) }];
154
149
  if (r.geo && a.render) {
155
150
  const ro = renderOpts(typeof a.render === 'object' ? a.render : {}, r.geo, `${name}/${r.target}${atFrame(r)}`);
156
- const { img, notes } = pngContent(r.geo, ro);
151
+ const { img, notes, png } = pngContent(r.geo, ro);
152
+ const renderPath = renderDestination(name, r.target);
153
+ fs.mkdirSync(path.dirname(renderPath), { recursive: true });
154
+ fs.writeFileSync(renderPath, png);
155
+ new ArtifactStore(ws.exportDir).record([renderPath], { graph: name, node: r.target, format: 'png' });
157
156
  content.push(img);
158
157
  if (notes.length)
159
158
  content.push({ type: 'text', text: 'render: ' + notes.join('; ') });
160
159
  }
161
160
  return { content, isError: !r.ok };
162
161
  };
162
+ const cookAndReport = async (name, a, extraNotes = []) => {
163
+ const r = await ws.cook(name, undefined, a.frame);
164
+ ws.bump(name);
165
+ return reportResult(name, r, a, extraNotes);
166
+ };
163
167
  const resolveNodeGeo = async (a) => {
164
- const name = a.graph ?? 'main';
168
+ const name = a.graph ?? a.name ?? 'main'; // `name` is what build/edit call it; accept both
165
169
  const g = ws.getGraph(name);
166
170
  const target = a.node || undefined;
167
171
  const r = await engine.cook(g, { target, frame: a.frame });
@@ -169,10 +173,39 @@ export function makeTools(ws, opts = {}) {
169
173
  throw new Error(reportText(name, r));
170
174
  return { r, name, geo: r.geo };
171
175
  };
176
+ /** export without a path: the graph's targets (the default output's, a named one's, or all), inside exports/ */
177
+ const exportTargets = async (a) => {
178
+ const name = a.graph ?? a.name ?? 'main', spec = ws.getGraph(name);
179
+ const published = publishedOutputs(name, spec, engine.findOutput(spec).name);
180
+ const hint = (o) => `edit {ops:[{op:"export"${o === undefined ? '' : `, output:"${o}"`}, export:"glb"}]}`;
181
+ let chosen = published;
182
+ if (!a.all) {
183
+ const p = a.output === undefined ? published[0] : published.find((x) => x.output === a.output);
184
+ if (!p)
185
+ return text(`graph "${name}" publishes no output "${a.output}" (outputs: ${published.slice(1).map((x) => x.output).join(', ') || 'none'})`, true);
186
+ if (!p.target)
187
+ return text(`${a.output === undefined ? 'the default output' : `output "${a.output}"`} of "${name}" has no export target: set one with ${hint(a.output)}, or pass path`, true);
188
+ chosen = [p];
189
+ }
190
+ const targets = chosen.filter((p) => p.target);
191
+ if (!targets.length)
192
+ return text(`no output of "${name}" has an export target: set one with ${hint(undefined)}, or pass path`, true);
193
+ const prepared = [];
194
+ for (const p of targets) {
195
+ const r = await ws.cook(name, p.node, a.frame);
196
+ if (!r.ok || !r.geo)
197
+ return text(reportText(name, r), true);
198
+ prepared.push(await prepareTarget(r.geo, name, p, ws.exportDir, engine.host));
199
+ }
200
+ writeTargets(prepared, ws.exportDir);
201
+ const skipped = chosen.filter((p) => !p.target).map((p) => p.label);
202
+ return text(prepared.map((p) => exportReport(p.files, ws.exportDir, p.target.format, `${p.label}: ${p.summary}`, p.warnings)).join('\n')
203
+ + (skipped.length ? `\nno export target (skipped): ${skipped.join(', ')}` : ''));
204
+ };
172
205
  const tools = [
173
206
  {
174
207
  name: 'catalog',
175
- description: 'List available node types grouped by category (generate, curve, transform, deform, attribute, measure, group, topology, copy, solid, sdf, heightfield, uv, code, flow, io, pcg, shelf = shipped graph assets such as stairs or fence, asset = workspace assets). Optional filter by category or search text.',
208
+ description: 'List available node types grouped by category (generate, curve, transform, deform, attribute, measure, group, topology, copy, solid, sdf, heightfield, uv, code, flow, io, pcg, shelf = shipped graph assets such as stairs or fence, asset = workspace assets). Optional filter by category or search text. Building a landscape or scene for a person? Call inputs {action:"list"} first: the meshes, guide curves and masks they dropped into the workspace.',
176
209
  inputSchema: {
177
210
  type: 'object',
178
211
  properties: {
@@ -181,7 +214,11 @@ export function makeTools(ws, opts = {}) {
181
214
  include_deprecated: { type: 'boolean', description: 'also list deprecated node types (hidden by default)' },
182
215
  },
183
216
  },
184
- run: async (a) => text(catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated })),
217
+ run: async (a) => {
218
+ const cat = catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated });
219
+ const nIn = ws.listInputs().length;
220
+ return text(`${cat}\nHUMAN INPUT: ${nIn ? `${nIn} file${nIn === 1 ? '' : 's'} in` : 'nothing yet in'} the workspace inputs/ folder (meshes, guide curves and points, blockouts, PNG masks people drop in). Building a landscape or scene: inputs {action:"list"} first, then read them with file nodes (help topic "inputs").`);
221
+ },
185
222
  },
186
223
  {
187
224
  name: 'help',
@@ -213,12 +250,12 @@ export function makeTools(ws, opts = {}) {
213
250
  },
214
251
  {
215
252
  name: 'build',
216
- description: 'Create or replace a whole node graph and cook it. Returns per-node errors with hints, warnings, and a summary of the output geometry (counts, bbox, attributes, groups). mode "merge" upserts nodes into the existing graph. dry_run validates and resolves params without cooking. render:true (or render options) also returns an image.',
253
+ description: 'Create or replace a whole node graph and cook it. Returns per-node errors with hints, warnings, and a summary of the output geometry (counts, bbox, attributes, groups). mode "merge" upserts nodes into the existing graph. dry_run validates and resolves params without cooking. render:true (or render options) also returns an image. Expose the 3–8 vars a human would tune: declare "params": {var: {type, desc, min, max, options, label, step}}; the live viewer shows them as controls. Humans may change vars at any time, so read the graph (graphs {action:"get"}) before assuming a var\'s value.',
217
254
  inputSchema: {
218
255
  type: 'object',
219
256
  required: ['graph'],
220
257
  properties: {
221
- graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}}' },
258
+ graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "outputs": {id: {"node": name, "label": "Published result", "export": "glb"}}, "export": "glb", "vars": {...}, "params": {var: {"type", "desc", "min", "max", "options", "label", "step"}}}. Publish intended deliverables in outputs; output remains the default. "export" (on the graph for the default output, or on a named output) is where it exports: a format, or {format, path?, ...export options}, written inside exports/ by the viewer\'s Export buttons and export {output} / {all:true}.' },
222
259
  name: { type: 'string', description: 'graph name (default "main")' },
223
260
  mode: { type: 'string', enum: ['replace', 'merge'] },
224
261
  dry_run: { type: 'boolean' },
@@ -249,7 +286,7 @@ export function makeTools(ws, opts = {}) {
249
286
  },
250
287
  {
251
288
  name: 'edit',
252
- description: 'Incrementally edit a graph, then re-cook (only affected nodes recompute). ops: {op:"set",node,params} (null removes a param) · {op:"add",node,type,in,params} · {op:"insert",node,type,params,after} (splice after a node) · {op:"remove",node} (rewires consumers) · {op:"connect",node,in:[...]} or {op:"connect",node,input:1,from:"x"} · {op:"rename",node,to} · {op:"replace",node,type?,params?,in?} · {op:"output",node} · {op:"vars",vars:{k:v}} · {op:"bypass",node,value}.',
289
+ description: 'Incrementally edit a graph, then re-cook (only affected nodes recompute). ops: {op:"set",node,params} (null removes a param) · {op:"add",node,type,in,params} · {op:"insert",node,type,params,after} (splice after a node) · {op:"remove",node} (rewires consumers) · {op:"connect",node,in:[...]} or {op:"connect",node,input:1,from:"x"} · {op:"rename",node,to} · {op:"replace",node,type?,params?,in?} · {op:"output",node} · {op:"vars",vars:{k:v}} · {op:"bypass",node,value} · {op:"export",output?,export} (the export target of a named output, or of the default output without "output": a format "glb" or {format, path?, ...export options}; null removes it).',
253
290
  inputSchema: {
254
291
  type: 'object',
255
292
  required: ['ops'],
@@ -262,12 +299,16 @@ export function makeTools(ws, opts = {}) {
262
299
  },
263
300
  run: async (a) => {
264
301
  const name = a.name ?? 'main';
265
- const { graph, notes } = ws.applyEdits(name, a.ops);
266
- const v = engine.validate(graph);
267
- if (v.errors.length)
268
- return text(`✗ edits rejected (graph unchanged) — ${v.errors.length} error(s):\n` + v.errors.map((e) => ` [${e.node}] ${e.message}`).join('\n'), true);
269
- ws.setGraph(name, graph);
270
- return cookAndReport(name, a, notes);
302
+ let res;
303
+ try {
304
+ res = await ws.edit(name, a.ops, { frame: a.frame });
305
+ }
306
+ catch (e) {
307
+ if (e instanceof EditRejected)
308
+ return text(e.message, true);
309
+ throw e;
310
+ }
311
+ return reportResult(name, res.report, a, res.notes);
271
312
  },
272
313
  },
273
314
  {
@@ -300,7 +341,7 @@ export function makeTools(ws, opts = {}) {
300
341
  inputSchema: {
301
342
  type: 'object',
302
343
  properties: {
303
- graph: { type: 'string' }, node: { type: 'string' },
344
+ graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
304
345
  ...RENDER_PROPS,
305
346
  save_to: { type: 'string', description: 'also write the PNG to this path (relative to the workspace)' },
306
347
  ...FRAME_PROP,
@@ -311,10 +352,11 @@ export function makeTools(ws, opts = {}) {
311
352
  const { img, notes, png } = pngContent(geo, renderOpts(a, geo, `${name}/${r.target}${atFrame(r)}`));
312
353
  const content = [img];
313
354
  const info = [];
314
- if (a.save_to) {
315
- const p = path.resolve(ws.dir, a.save_to);
355
+ {
356
+ const p = a.save_to ? path.resolve(ws.dir, a.save_to) : renderDestination(name, r.target);
316
357
  fs.mkdirSync(path.dirname(p), { recursive: true });
317
358
  fs.writeFileSync(p, png);
359
+ new ArtifactStore(ws.exportDir).record([p], { graph: name, node: r.target, format: 'png' });
318
360
  info.push(`saved ${p}`);
319
361
  }
320
362
  if (notes.length)
@@ -329,10 +371,11 @@ export function makeTools(ws, opts = {}) {
329
371
  description: 'Write a node\'s output to a file: .glb/.gltf (glTF via gltf-transform: materials + texture maps, vertex colours, uv, custom attributes, EXT_mesh_gpu_instancing with per-instance attributes, meshopt compression; best for Unreal/Blender/web), .obj, .usda (PointInstancer with per-instance primvars, UsdPreviewSurface materials), .ply, .stl, .geo (Houdini JSON geometry, every attribute and group, packed prims as PackedGeometry), .csv (point stream; pcg:true for Unreal PCG columns; packed instances = one row per instance + Mesh column + .meshes.json sidecar + one GLB per source), .points.json, .json (lossless topotopo). Relative paths go into the workspace. help {topic:"export"} for details. Heightfields: png16 (.png, 16-bit Unreal Landscape heightmap) or r16 (.r16/.raw) plus a .json sidecar with the height range and the Unreal scale/location, mask_png (8-bit weight layers; param layers), unreal_landscape (a directory: heightmap, every layer, manifest with the exact Landscape import settings, objects.glb and the instance stream). Textures: glb/gltf embed the image textures of materials (texture_to_material) as PNG, usda writes them to <name>_textures/ next to the file, format textures writes every image as <path>/<image>.png (layers: extra layers, or ["*"]).',
330
372
  inputSchema: {
331
373
  type: 'object',
332
- required: ['path'],
333
374
  properties: {
334
- graph: { type: 'string' }, node: { type: 'string' },
335
- path: { type: 'string', description: 'e.g. "exports/rock.glb", "exports/trees.csv", "exports/pts.points.json", "exports/terrain.png" (heightmap), "exports/level" (unreal_landscape: a directory), "exports/maps" (textures: a directory)' },
375
+ graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
376
+ path: { type: 'string', description: 'e.g. "exports/rock.glb", "exports/trees.csv", "exports/pts.points.json", "exports/terrain.png" (heightmap), "exports/level" (unreal_landscape: a directory), "exports/maps" (textures: a directory). Without path: the graph\'s export targets (output / all), written inside exports/' },
377
+ output: { type: 'string', description: 'without path: export this named output (graph.outputs) to its target; omitted = the default output (graph.export)' },
378
+ all: { type: 'boolean', description: 'without path: export every output of the graph that has an export target' },
336
379
  format: { type: 'string', enum: EXPORT_FORMATS, description: 'default: from the extension (.points.json = pointsjson)' },
337
380
  layer: { type: 'string', description: 'heightmaps: layer to write (png16/r16 default "height", mask_png default "mask")' },
338
381
  layers: { type: 'array', items: { type: 'string' }, description: 'mask_png: layers to write, one PNG each (name_<layer>.png); textures: extra image layers to write as <image>_<layer>.png ("*" = all)' },
@@ -358,13 +401,15 @@ export function makeTools(ws, opts = {}) {
358
401
  },
359
402
  },
360
403
  run: async (a) => {
404
+ if (a.path === undefined)
405
+ return exportTargets(a);
361
406
  const { geo, name, r } = await resolveNodeGeo(a);
362
407
  const p = path.resolve(ws.dir, a.path);
363
408
  const fmt0 = String(a.format ?? exportFormatFromPath(p)).toLowerCase();
364
409
  const ext = ({ png: 'png16', raw: 'r16' }[fmt0] ?? fmt0);
365
410
  if (!EXPORT_FORMATS.includes(ext))
366
411
  throw new Error(`unknown format "${ext}"; use ${EXPORT_FORMATS.map((f) => (f === 'pointsjson' ? '.points.json' : '.' + f)).join(' ')}`);
367
- const report = await writeExport(geo, ext, p, a, engine.host);
412
+ const report = await writeExport(geo, ext, p, a, engine.host, (files) => new ArtifactStore(ws.exportDir).record(files, { graph: name, node: r.target, format: ext }));
368
413
  return text(`${report}\n(from ${name}/${r.target}${atFrame(r)}: ${geo.numPoints} points, ${geo.numPrims} prims)`);
369
414
  },
370
415
  },
@@ -483,14 +528,36 @@ export function makeTools(ws, opts = {}) {
483
528
  },
484
529
  },
485
530
  ];
486
- tools.push(...verifyTools(ws)); // explain, diff
531
+ tools.push(...verifyTools(ws), inputsTool(ws)); // explain, diff; the human's input files
487
532
  if (opts.onPreview) {
488
533
  tools.push({
489
534
  name: 'preview',
490
- description: 'Start (or get the URL of) the live 3D preview web page for humans: orbit view of any graph/node that auto-refreshes after every build/edit. Returns the URL to open in a browser.',
535
+ description: 'Start (or get the URL of) the live viewer for humans: an editor viewport of the workspace (graph menu, an outliner with the graph\'s nodes, single and quad views, the graph\'s params as Controls, input layers and guides) that updates after every build/edit. Returns the URL to open in a browser.',
491
536
  inputSchema: { type: 'object', properties: {} },
492
- run: async () => text(`live preview: ${await opts.onPreview()} (updates automatically on every build/edit; click nodes in the sidebar to view intermediate results)`),
537
+ run: async () => text(`live preview: ${await opts.onPreview()} (updates automatically on every build/edit; click a node in the outliner to see its result)`),
493
538
  });
494
539
  }
540
+ // A person can add files to inputs/ at any time (the viewer's Add input, a drop, the folder). The first answer says
541
+ // what is there, later answers what is new or changed since the last one; the inputs tool's own answer counts.
542
+ let heard = null;
543
+ const inputsNote = (tool) => {
544
+ const files = ws.listInputs(), now = new Map(files.map((f) => [f.path, `${f.size}:${f.mtime}`]));
545
+ const first = heard === null, fresh = files.filter((f) => heard?.get(f.path) !== now.get(f.path));
546
+ heard = now;
547
+ if (tool === 'inputs' || !fresh.length)
548
+ return '';
549
+ const shown = fresh.slice(0, 8).map((f) => `inputs/${f.path} (${f.kind}, ${(f.size / 1024).toFixed(0)} KB)`).join(', ') + (fresh.length > 8 ? `, and ${fresh.length - 8} more` : '');
550
+ return `${first ? `inputs/ holds ${files.length} file(s) from the person` : 'new or changed in inputs/ since your last call'}: ${shown}. inputs {action:"describe", path} tells what one holds.`;
551
+ };
552
+ for (const t of tools) {
553
+ const run = t.run;
554
+ t.run = async (args) => {
555
+ const result = await run(args);
556
+ const note = inputsNote(t.name);
557
+ if (note)
558
+ result.content.push({ type: 'text', text: note });
559
+ return result;
560
+ };
561
+ }
495
562
  return tools;
496
563
  }
@@ -83,8 +83,9 @@ defineNode({
83
83
  function scriptLib(ctx, wake, run) {
84
84
  const engine = ctx.engine;
85
85
  // compiled in the script realm: a synchronous node's result resolves a realm promise, so `await lib.run(...)`
86
- // continues inside the same time-limited call; only async nodes hand back an outside promise (then: wake)
87
- const realmAsync = engine.host.compile('(function(f){ return async function run(...a) { return f(...a); }; })');
86
+ // continues inside the same time-limited call; an async node hands back its outcome (see run below), which the
87
+ // realm awaits and turns back into the node's value or error
88
+ const realmAsync = engine.host.compile('(function(f){ return async function run(...a) { const r = f(...a); if (r && r.libRunOutcome) { const o = await r.libRunOutcome; if (o.failed) throw o.error; return o.value; } return r; }; })');
88
89
  return {
89
90
  Geometry, GeoBuilder, math, topology, random,
90
91
  noise: { makeNoise, fractal, makeCurl },
@@ -127,15 +128,42 @@ function scriptLib(ctx, wake, run) {
127
128
  const rp = resolveParams(def, params, ctx.vars, { time: ctx, host: engine.host, timeoutMs: engine.scriptTimeoutMs });
128
129
  if (rp.errors.length)
129
130
  throw new Error(`lib.run(${type}): ${rp.errors.join('; ')}`);
131
+ for (const w of rp.warnings)
132
+ ctx.warn(`lib.run(${type}): ${w}`);
130
133
  // inherit from ctx through the prototype so its time getters stay lazy
131
134
  const sub = Object.assign(Object.create(ctx), {
132
135
  node: `${ctx.node}/${type}`, type: def.type, p: rp.values, inputs,
133
136
  input: (i) => inputs[i] ?? new Geometry(), hasInput: (i) => !!inputs[i], inputSigs: inputs.map(() => 'script'),
134
137
  });
135
- const r = def.cook(sub);
136
- if (r && typeof r.then === 'function')
137
- r.then(wake, wake);
138
- return r;
138
+ // The script's time limit can stop it anywhere, also between starting an async node and listening to it: that
139
+ // node's promise would then reject later with nobody listening, which ends the process (MCP server included).
140
+ // So an async node's promise is awaited right inside `outcome`, with no call in between that the limit could
141
+ // stop, and `outcome` never rejects. A synchronous node returns (or throws) here, as before.
142
+ let started, thrownNow = null;
143
+ const isAsync = () => !!started && typeof started.then === 'function';
144
+ const outcome = (async () => {
145
+ try {
146
+ started = def.cook(sub);
147
+ // property reads only from here to the await: a function call is where the limit could stop the script
148
+ if (!started || typeof started.then !== 'function')
149
+ return null;
150
+ return { value: await started };
151
+ }
152
+ catch (error) {
153
+ if (!isAsync()) {
154
+ thrownNow = { error };
155
+ return null;
156
+ }
157
+ return { failed: true, error };
158
+ }
159
+ finally {
160
+ if (isAsync())
161
+ wake();
162
+ }
163
+ })();
164
+ if (thrownNow)
165
+ throw thrownNow.error;
166
+ return isAsync() ? { libRunOutcome: outcome } : started;
139
167
  }),
140
168
  warn: (m) => ctx.warn(String(m)),
141
169
  };
@@ -950,7 +950,7 @@ defineNode({
950
950
  variants: { type: 'enum', options: ['none', 'name', 'piece'], default: 'none', desc: 'split the source into variants: name = by prim "name", piece = by connectivity (none = only when piece_attrib is set)' },
951
951
  piece_attrib: { type: 'string', default: '', desc: 'variants: source prim attribute that splits the geometry into pieces (e.g. "name" or "class")' },
952
952
  variant_attrib: { type: 'string', default: 'variant', desc: 'variants: template point attribute choosing a piece (int index into sorted piece values, or the exact value)' },
953
- variant_weights: { type: 'json', default: [], desc: 'variants: weights per piece (in piece order) for a seeded weighted random pick when the template has no variant_attrib attribute, e.g. [3, 1, 1]; missing weights are 0', aliases: ['weights', 'variant_probabilities'] },
953
+ variant_weights: { type: 'json', default: [], desc: 'variants: weights for a seeded weighted random pick when the template has no variant_attrib attribute: by piece value, e.g. {"tree": 3, "bush": 1}, or a list in piece order (names sorted alphabetically, connectivity pieces in prim order), e.g. [3, 1, 1]; missing weights are 0', aliases: ['weights', 'variant_probabilities'] },
954
954
  seed: { type: 'float', default: 0, desc: 'variant_weights: random seed' },
955
955
  scale_attrib: { type: 'string', default: '', desc: 'template point attribute multiplying the copy scale (float = uniform, vec3 = per axis), on top of pscale and scale', aliases: ['scale_by'] },
956
956
  rotate_attrib: { type: 'string', default: '', desc: 'template point attribute adding a local rotation after orient/N/rot: float = degrees about +Y (yaw), vec3 = Euler degrees (XYZ), vec4 = quaternion', aliases: ['rotate_by'] },
@@ -1010,12 +1010,22 @@ defineNode({
1010
1010
  subsets.set(v, src.subsetPrims(Uint8Array.from({ length: src.numPrims }, (_, i) => (pa.get(i) === v ? 1 : 0))));
1011
1011
  }
1012
1012
  const va = p.variant_attrib ? t.points.get(p.variant_attrib) : undefined;
1013
- const wts = Array.isArray(p.variant_weights) ? p.variant_weights.map((x) => Math.max(0, Number(x) || 0)) : [];
1013
+ const wv = p.variant_weights;
1014
+ // {piece value: weight} (dogfood 2026-10-15: a list follows the sorted piece order, which surprised a user) or a list
1015
+ const byValue = wv && typeof wv === 'object' && !Array.isArray(wv) ? wv : null;
1016
+ const wts = byValue
1017
+ ? values.map((v) => Math.max(0, Number(byValue[String(v)]) || 0))
1018
+ : Array.isArray(wv) ? wv.map((x) => Math.max(0, Number(x) || 0)) : [];
1019
+ if (byValue) {
1020
+ const unknown = Object.keys(byValue).filter((k) => !values.some((v) => String(v) === k));
1021
+ if (unknown.length)
1022
+ warn(`variant_weights: no variant named ${unknown.map((k) => `"${k}"`).join(', ')} (variants: ${values.join(', ')})`);
1023
+ }
1014
1024
  let wcdf = null;
1015
1025
  if (wts.length && va)
1016
1026
  warn(`variant_weights ignored: the template points have "${p.variant_attrib}", which picks the variants`);
1017
1027
  else if (wts.length && values.length) {
1018
- if (wts.length !== values.length)
1028
+ if (!byValue && wts.length !== values.length)
1019
1029
  warn(`variant_weights has ${wts.length} weight(s) for ${values.length} variant(s) (${values.join(', ')}); missing weights are 0`);
1020
1030
  wcdf = new Float64Array(values.length);
1021
1031
  let acc = 0;
@@ -1047,7 +1057,7 @@ defineNode({
1047
1057
  };
1048
1058
  pieceOf = (i) => subsets.get(pieceKey(i)) ?? new Geometry();
1049
1059
  }
1050
- else if (Array.isArray(p.variant_weights) && p.variant_weights.length)
1060
+ else if (p.variant_weights && typeof p.variant_weights === 'object' && Object.keys(p.variant_weights).length)
1051
1061
  warn('variant_weights needs variants ("name" or "piece") or piece_attrib to split the source into variants');
1052
1062
  const copyNames = new Set();
1053
1063
  const pats = String(p.copy_attribs).split(/[\s,]+/).filter(Boolean);