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
@@ -0,0 +1,377 @@
1
+ // The human-input layer for agents: the files people drop into <workspace>/inputs (meshes, guide curves and points,
2
+ // PNG masks and heightmaps) and what each one holds: curves vs meshes vs points, bbox, names and roles, attributes,
3
+ // materials. Registered by makeTools in tools.ts; help topic "inputs" (docs.ts) explains the guide conventions.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { inputExt, inputKind } from '../workspace.js';
7
+ import { decodePNG } from '../io/png-decode.js';
8
+ import { imageNameOf } from '../io/textures.js';
9
+ import { readMaterials, MATERIAL_ATTRIB } from '../core/materials.js';
10
+ import { didYouMean } from '../graph/params.js';
11
+ import { fmtNum } from '../render/raster.js';
12
+ const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
13
+ /** Most distinct values of `name` / `role` listed per class. */
14
+ const MAX_VALUES = 50;
15
+ /** `inputs list` stops describing files it has not seen before after this long (the rest say so). */
16
+ const LIST_BUDGET_MS = 15_000;
17
+ /** name prefixes that mark a guide's role (help topic "inputs") */
18
+ export const ROLES = ['site', 'path', 'zone', 'volume', 'blockout'];
19
+ const n = (x) => x.toLocaleString('en-US');
20
+ const plural = (k, one, many = one + 's') => `${n(k)} ${k === 1 ? one : many}`;
21
+ const sizeText = (b) => (b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`);
22
+ const dateText = (ms) => {
23
+ const d = new Date(ms), p = (x) => String(x).padStart(2, '0');
24
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
25
+ };
26
+ const typeText = (a) => (a.isArray ? a.type : a.size === 1 ? a.type : `${a.type}${a.size}`);
27
+ /** where people get a readable file from formats we do not read */
28
+ const OTHER_HINTS = {
29
+ fbx: 'FBX is not read: export glTF (.glb) instead', dae: 'export glTF (.glb) instead', '3ds': 'export glTF (.glb) instead',
30
+ abc: 'Alembic is not read: export glTF (.glb) or .geo', usd: 'USD is written, not read: export glTF (.glb)', usda: 'USD is written, not read: export glTF (.glb)',
31
+ usdc: 'USD is written, not read: export glTF (.glb)', usdz: 'USD is written, not read: export glTF (.glb)',
32
+ jpg: 'only PNG images are read: save it as PNG', jpeg: 'only PNG images are read: save it as PNG', tif: 'only PNG images are read: save it as PNG (16-bit grey for heightmaps)',
33
+ tiff: 'only PNG images are read: save it as PNG (16-bit grey for heightmaps)', exr: 'only PNG images are read: save it as 16-bit PNG', hdr: 'only PNG images are read',
34
+ webp: 'only PNG images are read: save it as PNG', sc: 'compressed Houdini geometry is not read: save .geo or uncompressed .bgeo', gz: 'compressed files are not read: unpack it first',
35
+ blend1: 'a Blender backup file (the .blend next to it is the current one)',
36
+ };
37
+ const READABLE = 'geometry: obj stl ply glb gltf geo bgeo csv json points.json, and blend with Blender installed; images: png';
38
+ /** inputs/ file → its description, keyed by path and invalidated by the host's file stamp (mtime + size). */
39
+ export class InputDescriber {
40
+ ws;
41
+ cache = new Map();
42
+ constructor(ws) { this.ws = ws; }
43
+ /** Describe one file (cached while its stamp holds). */
44
+ describe(f) {
45
+ const rel = `inputs/${f.path}`;
46
+ const stamp = this.ws.engine.host.fileStamp(rel);
47
+ const hit = this.cache.get(f.path);
48
+ if (hit && hit.stamp === stamp)
49
+ return hit.d;
50
+ const d = (f.kind === 'geometry' ? this.geometry(rel, f.ext) : f.kind === 'image' ? this.image(rel) : Promise.resolve(other(f.ext)))
51
+ .catch((e) => ({ short: `cannot read: ${e?.message ?? e}`, long: `cannot read: ${e?.message ?? e}`, failed: true }));
52
+ this.cache.set(f.path, { stamp, d });
53
+ return d;
54
+ }
55
+ /** Whether describe() would answer from the cache. */
56
+ cached(f) {
57
+ const hit = this.cache.get(f.path);
58
+ return !!hit && hit.stamp === this.ws.engine.host.fileStamp(`inputs/${f.path}`);
59
+ }
60
+ /** Forget files that are gone. */
61
+ prune(keep) {
62
+ const live = new Set(keep.map((f) => f.path));
63
+ for (const k of [...this.cache.keys()])
64
+ if (!live.has(k))
65
+ this.cache.delete(k);
66
+ }
67
+ /** Cook the one-node graph a file node would be (never a workspace graph) and read the result. */
68
+ async geometry(rel, ext) {
69
+ const r = await this.ws.engine.cook({ nodes: { f: { type: 'file', params: { path: rel } } }, output: 'f' });
70
+ const warnings = r.warnings.map((w) => w.message);
71
+ if (!r.ok || !r.geo) {
72
+ const err = r.errors.map((e) => e.message + (e.hint ? ` (hint: ${e.hint})` : '')).join('; ') || 'no geometry';
73
+ let why = ext === 'blend' ? '\n.blend files are converted by a headless Blender: it must be installed (and found) for the file node to read them.' : '';
74
+ if (ext === 'json') {
75
+ try {
76
+ const j = JSON.parse(new TextDecoder().decode(await this.ws.engine.host.readFile(rel)));
77
+ if (j && typeof j === 'object' && !Array.isArray(j) && j.nodes && typeof j.nodes === 'object')
78
+ why = '\nThis JSON is a topotopo graph, not geometry: build it (build {graph}) instead of reading it with a file node.';
79
+ }
80
+ catch { /* not JSON at all: the error says so */ }
81
+ }
82
+ return { short: `cook failed: ${err}`, long: `the file node failed: ${err}${why}${warnings.length ? '\nwarnings:\n ' + warnings.join('\n ') : ''}`, failed: true };
83
+ }
84
+ return geometryText(r.geo, rel, ext, warnings);
85
+ }
86
+ async image(rel) {
87
+ const img = decodePNG(await this.ws.engine.host.readFile(rel));
88
+ const ch = img.channels, names = ['grey', 'grey + alpha', 'RGB', 'RGBA'][ch - 1];
89
+ const lo = new Array(ch).fill(Infinity), hi = new Array(ch).fill(-Infinity), sum = new Array(ch).fill(0);
90
+ const d = img.data, px = img.width * img.height;
91
+ for (let i = 0; i < px; i++)
92
+ for (let c = 0; c < ch; c++) {
93
+ const v = d[i * ch + c];
94
+ if (v < lo[c])
95
+ lo[c] = v;
96
+ if (v > hi[c])
97
+ hi[c] = v;
98
+ sum[c] += v;
99
+ }
100
+ const label = (c) => (ch <= 2 ? ['grey', 'alpha'][c] : 'rgba'[c]);
101
+ const ranges = lo.map((_, c) => `${label(c)} ${fmtNum(lo[c] / img.maxValue)}..${fmtNum(hi[c] / img.maxValue)} (mean ${fmtNum(sum[c] / px / img.maxValue)})`);
102
+ const short = `${img.width} × ${img.height} px, ${img.bitDepth}-bit ${names} (${plural(ch, 'channel')})`;
103
+ const [w, h] = [img.width, img.height];
104
+ const long = [
105
+ `image: ${short}`,
106
+ `values (0..1): ${ranges.join(', ')}`,
107
+ 'read it as:',
108
+ ` a heightmap {"type":"heightfield_from_image","params":{"path":"${rel}","size":[${w - 1},${h - 1}],"height_min":0,"height_max":20}}`,
109
+ ' (size = the extent in metres in X and Z; height_min/max = the heights of black and white; 16-bit greys keep the detail)',
110
+ ` a mask layer {"type":"heightfield_from_image","in":["terrain"],"params":{"path":"${rel}","layer":"mask","height_min":0,"height_max":1}}`,
111
+ ' (stretched over the connected heightfield; then heightfield_scatter, mask_layer, …)',
112
+ ` on points {"type":"attribute_from_map","in":["pts"],"params":{"path":"${rel}","projection":"xz","size":[100,100],"name":"mask"}}`,
113
+ ' (top view: column along +X, row 0 at min Z; then scatter density_mask, prune, copy_to_points scale_attrib)',
114
+ ` a texture {"type":"image_file","params":{"path":"${rel}"}}`,
115
+ ` (an image "${imageNameOf(rel)}" for the texture nodes and texture_to_material; a file node reads it the same way)`,
116
+ ].join('\n');
117
+ return { short, long, failed: false };
118
+ }
119
+ }
120
+ function other(ext) {
121
+ const hint = OTHER_HINTS[ext];
122
+ const what = ext ? `.${ext}` : 'a file without an extension';
123
+ return {
124
+ short: `not read${hint ? `: ${hint}` : ''}`,
125
+ long: `${what} is not a format topotopo reads${hint ? `: ${hint}` : ''}. Readable: ${READABLE}.`,
126
+ failed: false,
127
+ };
128
+ }
129
+ /** Distinct values of a string attribute with counts, sorted by value. */
130
+ function stringValues(a, count) {
131
+ const freq = new Map();
132
+ for (let i = 0; i < count; i++) {
133
+ const v = String(a.get(i));
134
+ freq.set(v, (freq.get(v) ?? 0) + 1);
135
+ }
136
+ return [...freq].sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
137
+ }
138
+ /** Prim kinds: 0 polygon, 1 open polyline, 2 packed, 3 spline curve. */
139
+ function primKinds(g) {
140
+ const k = new Uint8Array(g.numPrims);
141
+ for (let p = 0; p < g.numPrims; p++) {
142
+ const pk = g.primKind ? g.primKind[p] : 0;
143
+ k[p] = pk === 1 ? 2 : pk === 2 || pk === 3 ? 3 : g.primClosed[p] ? 0 : 1;
144
+ }
145
+ return k;
146
+ }
147
+ const KIND_NAMES = [['polygon', 'polygons'], ['polyline', 'polylines'], ['packed prim', 'packed prims'], ['spline curve', 'spline curves']];
148
+ const kindsText = (c) => c.map((x, i) => (x ? plural(x, KIND_NAMES[i][0], KIND_NAMES[i][1]) : '')).filter(Boolean).join(' + ');
149
+ /** A role value, or the role of a name by its prefix like the wrangle of help topic "inputs" ("Path.River.001" → path). */
150
+ const roleOf = (s, isName) => {
151
+ const r = (isName ? s.split('.')[0] : s).toLowerCase();
152
+ return ROLES.includes(r) ? r : null;
153
+ };
154
+ /** What a guide of this role should be made of, given the prims carrying it (counts per kind, see primKinds). */
155
+ function roleChecks(role, v, k) {
156
+ const curves = k[1] + k[3], what = kindsText(k);
157
+ if (role === 'path' && !curves)
158
+ return [`${v} is ${what}: a path is an open polyline (in Blender a curve, or a mesh of edges only)`];
159
+ if (role === 'zone' && !k[0] && curves)
160
+ return [`${v} is ${what}: a zone is a closed area; ends {mode:"close"} turns closed loops into polygons`];
161
+ if (role === 'site' && (k[0] || curves))
162
+ return [`${v} is ${what}: a site is a point; pack {by:"name", pivot:"bbox_center_bottom"} gives one anchor point per object`];
163
+ if ((role === 'volume' || role === 'blockout') && !k[0] && !k[2])
164
+ return [`${v} is ${what}: a ${role} is a mesh (a box or a closed solid)`];
165
+ return [];
166
+ }
167
+ function geometryText(g, rel, ext, warnings) {
168
+ const kinds = primKinds(g);
169
+ const counts = [0, 0, 0, 0];
170
+ for (let p = 0; p < g.numPrims; p++)
171
+ counts[kinds[p]]++;
172
+ const used = new Uint8Array(g.numPoints);
173
+ for (let v = 0; v < g.numVertices; v++)
174
+ used[g.vertexPoint[v]] = 1;
175
+ let loose = 0;
176
+ for (let i = 0; i < g.numPoints; i++)
177
+ if (!used[i])
178
+ loose++;
179
+ const lines = [];
180
+ const elems = [kindsText(counts), loose ? plural(loose, 'loose point') : ''].filter(Boolean).join(' + ') || 'empty';
181
+ lines.push(`elements: ${plural(g.numPoints, 'point')}, ${plural(g.numPrims, 'prim')} = ${elems}`);
182
+ lines.push(` (polygons are closed faces, polylines open curves, loose points belong to no prim, packed prims are instances${counts[3] ? ', spline curves Bezier/NURBS' : ''})`);
183
+ const vols = g.volumes.length ? g.volumes.map((v) => `${v.kind} "${v.name}"`).join(', ') : '';
184
+ if (vols)
185
+ lines.push(`volumes: ${vols}`);
186
+ const b = g.bbox(true);
187
+ const size = [0, 1, 2].map((a) => b.max[a] - b.min[a]);
188
+ const v3 = (a) => `(${a.map(fmtNum).join(', ')})`;
189
+ const bboxShort = b.empty ? 'no bbox' : `bbox ${size.map(fmtNum).join(' × ')} m`;
190
+ if (!b.empty)
191
+ lines.push(`bbox (metres, as read, Y up): min ${v3(b.min)} max ${v3(b.max)} size ${size.map(fmtNum).join(' × ')}`);
192
+ // attributes and groups
193
+ const attrs = [];
194
+ for (const cls of ['point', 'vertex', 'prim', 'detail']) {
195
+ const t = g.table(cls);
196
+ if (t.map.size)
197
+ attrs.push(` ${cls}: ${[...t.map].map(([k, a]) => `${k} ${typeText(a)}`).join(', ')}`);
198
+ }
199
+ if (attrs.length)
200
+ lines.push('attributes:', ...attrs);
201
+ const grp = [];
202
+ for (const cls of ['point', 'prim', 'vertex']) {
203
+ const m = g.groups(cls);
204
+ if (m.size)
205
+ grp.push(`${cls}: ${[...m].map(([k, mask]) => `${k} ${n(mask.reduce((s, x) => s + x, 0))}`).join(', ')}`);
206
+ }
207
+ if (g.edgeGroups.size)
208
+ grp.push(`edge: ${[...g.edgeGroups].map(([k, set]) => `${k} ${n(set.size)}`).join(', ')}`);
209
+ lines.push(grp.length ? `groups: ${grp.join(' ')}` : 'groups: none');
210
+ // name and role values, with the kind of prims that carry each
211
+ const isStr = (a) => !!a && a.type === 'string' && !a.isArray;
212
+ const kindsOf = (a) => {
213
+ const m = new Map();
214
+ for (let p = 0; p < g.numPrims; p++) {
215
+ const v = String(a.get(p));
216
+ let c = m.get(v);
217
+ if (!c)
218
+ m.set(v, (c = [0, 0, 0, 0]));
219
+ c[kinds[p]]++;
220
+ }
221
+ return m;
222
+ };
223
+ const shortNames = [];
224
+ for (const attr of ['name', 'role'])
225
+ for (const cls of ['prim', 'point']) {
226
+ const a = g.table(cls).get(attr);
227
+ if (!isStr(a))
228
+ continue;
229
+ const vals = stringValues(a, cls === 'prim' ? g.numPrims : g.numPoints);
230
+ const per = cls === 'prim' ? kindsOf(a) : null;
231
+ lines.push(`${cls} ${attr} (${plural(vals.length, 'value')}):`);
232
+ for (const [v, c] of vals.slice(0, MAX_VALUES))
233
+ lines.push(` ${JSON.stringify(v)} ${per ? kindsText(per.get(v)) : plural(c, 'point')}`);
234
+ if (vals.length > MAX_VALUES)
235
+ lines.push(` … ${plural(vals.length - MAX_VALUES, 'more value')}`);
236
+ if (attr === 'name' && !shortNames.length)
237
+ shortNames.push(...vals.map(([v]) => v));
238
+ }
239
+ // roles, per element as the wrangle of help topic "inputs" derives them: the role attribute, else the name prefix
240
+ const objects = new Map(), points = new Map(), checks = [];
241
+ let fromAttr = false, fromName = false;
242
+ for (const cls of ['prim', 'point']) {
243
+ const ra = g.table(cls).get('role'), na = g.table(cls).get('name');
244
+ const roleA = isStr(ra) ? ra : null, nameA = isStr(na) ? na : null;
245
+ if (!roleA && !nameA)
246
+ continue;
247
+ for (let i = 0, count = cls === 'prim' ? g.numPrims : g.numPoints; i < count; i++) {
248
+ const rv = roleA ? String(roleA.get(i)) : '', nv = nameA ? String(nameA.get(i)) : '';
249
+ const role = rv ? roleOf(rv, false) : roleOf(nv, true);
250
+ if (!role)
251
+ continue;
252
+ if (rv)
253
+ fromAttr = true;
254
+ else
255
+ fromName = true;
256
+ if (cls === 'point') {
257
+ points.set(role, (points.get(role) ?? 0) + 1);
258
+ continue;
259
+ }
260
+ let objs = objects.get(role);
261
+ if (!objs)
262
+ objects.set(role, (objs = new Map()));
263
+ const label = nv || rv;
264
+ let k = objs.get(label);
265
+ if (!k)
266
+ objs.set(label, (k = [0, 0, 0, 0]));
267
+ k[kinds[i]]++;
268
+ }
269
+ }
270
+ const roleNames = ROLES.filter((r) => objects.has(r) || points.has(r));
271
+ if (roleNames.length) {
272
+ const src = fromAttr && fromName ? 'the role attribute and name prefixes' : fromAttr ? 'the role attribute' : 'name prefixes';
273
+ lines.push(`roles (from ${src}): ${roleNames.map((r) => `${r} ${[objects.has(r) ? plural(objects.get(r).size, 'object') : '', points.has(r) ? plural(points.get(r), 'point') : ''].filter(Boolean).join(' + ')}`).join(', ')}`);
274
+ for (const [role, objs] of objects)
275
+ for (const [label, k] of objs)
276
+ checks.push(...roleChecks(role, JSON.stringify(label), k));
277
+ }
278
+ else
279
+ lines.push('roles: none marked (name objects site.* path.* zone.* volume.* blockout.*, or give them a string attribute "role"; help topic "inputs")');
280
+ for (const m of checks)
281
+ lines.push(` check: ${m}`);
282
+ // materials
283
+ const mat = g.table('prim').get(MATERIAL_ATTRIB);
284
+ const table = Object.keys(readMaterials(g));
285
+ if ((mat && mat.type === 'string') || table.length) {
286
+ const used = mat && mat.type === 'string' ? stringValues(mat, g.numPrims).filter(([v]) => v !== '') : [];
287
+ const unused = table.filter((t) => !used.some(([v]) => v === t));
288
+ lines.push(`materials: ${used.length ? used.slice(0, MAX_VALUES).map(([v, c]) => `${JSON.stringify(v)} ${plural(c, 'prim')}`).join(', ') : 'none assigned'}${used.length > MAX_VALUES ? `, … ${used.length - MAX_VALUES} more` : ''}${unused.length ? `; in the table only: ${unused.join(', ')}` : ''}`);
289
+ }
290
+ else
291
+ lines.push('materials: none');
292
+ if (warnings.length)
293
+ lines.push('warnings:', ...warnings.map((w) => ` ${w}`));
294
+ // up axis: OBJ/STL/PLY carry none
295
+ const flatY = !b.empty && size[2] < 0.1 * Math.max(size[0], size[1]) && size[1] > 0.5 * size[0] && size[1] > 0;
296
+ if (['obj', 'stl', 'ply'].includes(ext) && flatY)
297
+ lines.push(`up axis: this looks Z-up (flat in Z, spread in X and Y): read it with "up_axis":"z" if it should lie on the ground.`);
298
+ lines.push('up axis: OBJ/STL/PLY exports from Blender (with Up = Z) and from Unreal are Z-up and need "up_axis":"z" on the file node; glTF is +Y up already.');
299
+ lines.push(`read it: {"type":"file","params":{"path":"${rel}"}}`);
300
+ const names = shortNames.length ? `; name: ${shortNames.slice(0, 6).join(', ')}${shortNames.length > 6 ? ` (+${shortNames.length - 6})` : ''}` : '';
301
+ const short = `${plural(g.numPoints, 'point')}, ${plural(g.numPrims, 'prim')} = ${elems}; ${bboxShort}${names}${warnings.length ? `; ${plural(warnings.length, 'warning')}` : ''}`;
302
+ return { short, long: lines.join('\n'), failed: false };
303
+ }
304
+ /** "inputs/a.obj", "./a.obj", "a.obj" → "a.obj" (posix, inside inputs/), or an Error naming the files there. */
305
+ function resolveInput(ws, p, files) {
306
+ if (typeof p !== 'string' || !p.trim())
307
+ throw new Error('"path" is required: a file under inputs/ (inputs {action:"list"} lists them)');
308
+ const rel = p.trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '');
309
+ if (path.isAbsolute(rel) || /^[a-z]:\//i.test(rel) || rel.split('/').includes('..'))
310
+ throw new Error(`"${p}": give a path inside inputs/, relative to it (e.g. "guides.glb")`);
311
+ const byPath = new Map(files.map((f) => [f.path, f]));
312
+ const hit = byPath.get(rel) ?? (rel.startsWith('inputs/') ? byPath.get(rel.slice(7)) : undefined);
313
+ if (hit)
314
+ return hit;
315
+ // a file the walk skipped (dot-file) or that just arrived: describe it if it is there
316
+ const abs = path.join(ws.inputDir, rel.startsWith('inputs/') && !fs.existsSync(path.join(ws.inputDir, rel)) ? rel.slice(7) : rel);
317
+ try {
318
+ const st = fs.statSync(abs);
319
+ if (st.isFile()) {
320
+ const r = path.relative(ws.inputDir, abs).split(path.sep).join('/'), ext = inputExt(r);
321
+ return { path: r, kind: inputKind(ext), ext, size: st.size, mtime: Math.round(st.mtimeMs) };
322
+ }
323
+ }
324
+ catch { /* not there */ }
325
+ const s = didYouMean(rel.replace(/^inputs\//, ''), files.map((f) => f.path));
326
+ throw new Error(`no input "${rel}"${s.length ? ` — did you mean ${s.join(', ')}?` : ''}. ${files.length ? `Files: ${files.slice(0, 20).map((f) => f.path).join(', ')}${files.length > 20 ? ', …' : ''}` : `inputs/ is empty (${ws.inputDir})`}`);
327
+ }
328
+ export function inputsTool(ws) {
329
+ const describer = new InputDescriber(ws);
330
+ return {
331
+ name: 'inputs',
332
+ description: 'The human\'s own material: files people drop into the workspace inputs/ folder (meshes, guide curves and points from Blender/Houdini/Unreal, blockouts, PNG masks and heightmaps). list: every file with kind (geometry/image/other), size, date and a summary (counts, curves vs meshes, bbox, object names; PNG size and bit depth). describe {path}: what a file node reads from it: polygons vs open polylines vs loose points vs packed prims, bbox in metres, attributes, groups, every name and role value, materials, warnings. Call list FIRST when building a landscape or scene: guides mark sites, paths, zones, volumes and blockouts (help topic "inputs"). Read a file with {type:"file", params:{path:"inputs/<path>"}}.',
333
+ inputSchema: {
334
+ type: 'object',
335
+ required: ['action'],
336
+ properties: {
337
+ action: { type: 'string', enum: ['list', 'describe'] },
338
+ path: { type: 'string', description: 'describe: the file, relative to inputs/ (as list prints it)' },
339
+ },
340
+ },
341
+ run: async (a) => {
342
+ const files = ws.listInputs();
343
+ describer.prune(files);
344
+ if (a.action === 'describe') {
345
+ const f = resolveInput(ws, a.path, files);
346
+ const d = await describer.describe(f);
347
+ const head = `inputs/${f.path}: ${f.kind}${f.ext ? ` (.${f.ext})` : ''}, ${sizeText(f.size)}, modified ${dateText(f.mtime)}`;
348
+ return text(`${head}\n${d.long}`, d.failed);
349
+ }
350
+ if (a.action !== 'list')
351
+ return text(`unknown action "${a.action}": use list or describe {path}`, true);
352
+ if (!files.length)
353
+ return text(`no input files yet. People drop meshes, guide curves and points, blockouts and PNG masks into ${ws.inputDir} (or onto the live viewer); then inputs list shows them. Readable: ${READABLE}.`);
354
+ const t0 = Date.now();
355
+ const rows = [];
356
+ let skipped = 0;
357
+ for (const f of files) {
358
+ let summary;
359
+ if (f.kind === 'other' || describer.cached(f) || Date.now() - t0 < LIST_BUDGET_MS)
360
+ summary = (await describer.describe(f)).short;
361
+ else {
362
+ summary = 'not summarised yet (list time budget): inputs describe reads it';
363
+ skipped++;
364
+ }
365
+ rows.push(` ${f.path} ${f.kind} ${sizeText(f.size)} ${dateText(f.mtime)} ${summary}`);
366
+ }
367
+ const kinds = ['geometry', 'image', 'other'].map((k) => [k, files.filter((f) => f.kind === k).length]).filter(([, c]) => c);
368
+ return text([
369
+ `inputs/ (${ws.inputDir}): ${plural(files.length, 'file')} (${kinds.map(([k, c]) => `${c} ${k}`).join(', ')})`,
370
+ ...rows,
371
+ ...(skipped ? [`${plural(skipped, 'file')} not summarised within ${LIST_BUDGET_MS / 1000} s; list again (summaries are cached) or describe them one by one.`] : []),
372
+ 'A file node reads one: {"type":"file","params":{"path":"inputs/<path>"}} (paths are relative to the workspace). inputs {action:"describe", path} shows curves vs meshes, every name and role, attributes and materials; help {topic:"inputs"} explains the guide roles (site, path, zone, volume, blockout) and recipes.',
373
+ 'OBJ/STL from Blender (Up = Z) and Unreal are Z-up: add "up_axis":"z". A .blend needs Blender installed (converted headlessly).',
374
+ ].join('\n'));
375
+ },
376
+ };
377
+ }
@@ -4,6 +4,7 @@
4
4
  // topotopo mcp [--workspace <dir>] [--viewer] (installed package: src/cli.ts dispatches here)
5
5
  // Env: TOPOTOPO_WORKSPACE, TOPOTOPO_VIEWER=1, TOPOTOPO_PORT, TOPOTOPO_SCRIPT_TIMEOUT_MS, TOPOTOPO_SIM_TIMEOUT_MS, TOPOTOPO_WORKERS
6
6
  // stdout carries the MCP protocol — log only to stderr.
7
+ import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -41,7 +42,9 @@ const ensureViewer = async () => {
41
42
  if (argv.includes('--viewer') || process.env.TOPOTOPO_VIEWER === '1')
42
43
  await ensureViewer();
43
44
  const tools = makeTools(ws, { onPreview: ensureViewer });
44
- const server = new Server({ name: 'topotopo', version: '0.1.0' }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
45
+ // package.json sits two levels above both src/mcp/server.ts and dist/mcp/server.js
46
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
47
+ const server = new Server({ name: 'topotopo', version: pkg.version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
45
48
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
46
49
  tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
47
50
  }));
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';
@@ -16,6 +16,7 @@ import { exportFilesAsync } from '../io/files.js';
16
16
  import { evalGroup } from '../core/groups.js';
17
17
  import { Geometry } from '../core/geometry.js';
18
18
  import { verifyTools } from './verify.js';
19
+ import { inputsTool } from './inputs.js';
19
20
  const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
20
21
  /** " at frame N" when the cook was not at frame 1. */
21
22
  const atFrame = (r) => (r.frame !== 1 ? ` at frame ${r.frame}` : '');
@@ -131,6 +132,7 @@ const RENDER_PROPS = {
131
132
  glyph_attrib: { type: 'string', description: 'attribute for glyphs "custom": 3 floats = vector, 4 floats = quaternion' },
132
133
  glyph_scale: { type: 'number', description: 'glyph length in world units (default 5% of the bbox diagonal)' },
133
134
  max_glyphs: { type: 'number', description: 'evenly subsample to at most this many glyphs (default 400)' },
135
+ 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
136
  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
137
  label_pieces: { type: 'boolean', description: 'number connected pieces (#0, #1, … in connectivity order)' },
136
138
  label_attrib: { type: 'string', description: 'label each distinct value of a string/int attribute (e.g. "name") on its elements; colours match color_by' },
@@ -147,9 +149,8 @@ const RENDER_PROPS = {
147
149
  };
148
150
  export function makeTools(ws, opts = {}) {
149
151
  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);
152
+ /** The text (+ optional render) of a cook report. */
153
+ const reportResult = (name, r, a, extraNotes = []) => {
153
154
  const content = [{ type: 'text', text: reportText(name, r, extraNotes) }];
154
155
  if (r.geo && a.render) {
155
156
  const ro = renderOpts(typeof a.render === 'object' ? a.render : {}, r.geo, `${name}/${r.target}${atFrame(r)}`);
@@ -160,8 +161,13 @@ export function makeTools(ws, opts = {}) {
160
161
  }
161
162
  return { content, isError: !r.ok };
162
163
  };
164
+ const cookAndReport = async (name, a, extraNotes = []) => {
165
+ const r = await ws.cook(name, undefined, a.frame);
166
+ ws.bump(name);
167
+ return reportResult(name, r, a, extraNotes);
168
+ };
163
169
  const resolveNodeGeo = async (a) => {
164
- const name = a.graph ?? 'main';
170
+ const name = a.graph ?? a.name ?? 'main'; // `name` is what build/edit call it; accept both
165
171
  const g = ws.getGraph(name);
166
172
  const target = a.node || undefined;
167
173
  const r = await engine.cook(g, { target, frame: a.frame });
@@ -172,7 +178,7 @@ export function makeTools(ws, opts = {}) {
172
178
  const tools = [
173
179
  {
174
180
  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.',
181
+ 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
182
  inputSchema: {
177
183
  type: 'object',
178
184
  properties: {
@@ -181,7 +187,11 @@ export function makeTools(ws, opts = {}) {
181
187
  include_deprecated: { type: 'boolean', description: 'also list deprecated node types (hidden by default)' },
182
188
  },
183
189
  },
184
- run: async (a) => text(catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated })),
190
+ run: async (a) => {
191
+ const cat = catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated });
192
+ const nIn = ws.listInputs().length;
193
+ 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").`);
194
+ },
185
195
  },
186
196
  {
187
197
  name: 'help',
@@ -213,12 +223,12 @@ export function makeTools(ws, opts = {}) {
213
223
  },
214
224
  {
215
225
  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.',
226
+ 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
227
  inputSchema: {
218
228
  type: 'object',
219
229
  required: ['graph'],
220
230
  properties: {
221
- graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}}' },
231
+ graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}, "params": {var: {"type", "desc", "min", "max", "options", "label", "step"}}}' },
222
232
  name: { type: 'string', description: 'graph name (default "main")' },
223
233
  mode: { type: 'string', enum: ['replace', 'merge'] },
224
234
  dry_run: { type: 'boolean' },
@@ -262,12 +272,16 @@ export function makeTools(ws, opts = {}) {
262
272
  },
263
273
  run: async (a) => {
264
274
  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);
275
+ let res;
276
+ try {
277
+ res = await ws.edit(name, a.ops, { frame: a.frame });
278
+ }
279
+ catch (e) {
280
+ if (e instanceof EditRejected)
281
+ return text(e.message, true);
282
+ throw e;
283
+ }
284
+ return reportResult(name, res.report, a, res.notes);
271
285
  },
272
286
  },
273
287
  {
@@ -300,7 +314,7 @@ export function makeTools(ws, opts = {}) {
300
314
  inputSchema: {
301
315
  type: 'object',
302
316
  properties: {
303
- graph: { type: 'string' }, node: { type: 'string' },
317
+ graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
304
318
  ...RENDER_PROPS,
305
319
  save_to: { type: 'string', description: 'also write the PNG to this path (relative to the workspace)' },
306
320
  ...FRAME_PROP,
@@ -331,7 +345,7 @@ export function makeTools(ws, opts = {}) {
331
345
  type: 'object',
332
346
  required: ['path'],
333
347
  properties: {
334
- graph: { type: 'string' }, node: { type: 'string' },
348
+ graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
335
349
  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)' },
336
350
  format: { type: 'string', enum: EXPORT_FORMATS, description: 'default: from the extension (.points.json = pointsjson)' },
337
351
  layer: { type: 'string', description: 'heightmaps: layer to write (png16/r16 default "height", mask_png default "mask")' },
@@ -483,7 +497,7 @@ export function makeTools(ws, opts = {}) {
483
497
  },
484
498
  },
485
499
  ];
486
- tools.push(...verifyTools(ws)); // explain, diff
500
+ tools.push(...verifyTools(ws), inputsTool(ws)); // explain, diff; the human's input files
487
501
  if (opts.onPreview) {
488
502
  tools.push({
489
503
  name: 'preview',
@@ -127,6 +127,8 @@ function scriptLib(ctx, wake, run) {
127
127
  const rp = resolveParams(def, params, ctx.vars, { time: ctx, host: engine.host, timeoutMs: engine.scriptTimeoutMs });
128
128
  if (rp.errors.length)
129
129
  throw new Error(`lib.run(${type}): ${rp.errors.join('; ')}`);
130
+ for (const w of rp.warnings)
131
+ ctx.warn(`lib.run(${type}): ${w}`);
130
132
  // inherit from ctx through the prototype so its time getters stay lazy
131
133
  const sub = Object.assign(Object.create(ctx), {
132
134
  node: `${ctx.node}/${type}`, type: def.type, p: rp.values, inputs,
@@ -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);