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
@@ -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
  }));
@@ -25,7 +25,7 @@ export interface ToolDef {
25
25
  * Landscape bundle, or the single file of other formats). Textures are read through the host; USD texture paths are
26
26
  * written relative to the output file. Returns the report text.
27
27
  */
28
- export declare function writeExport(geo: Geometry, format: ExportFormat, p: string, a: any, host: Host): Promise<string>;
28
+ export declare function writeExport(geo: Geometry, format: ExportFormat, p: string, a: any, host: Host, record?: (files: string[]) => void): Promise<string>;
29
29
  export declare function makeTools(ws: Workspace, opts?: {
30
30
  onPreview?: () => Promise<string>;
31
31
  }): ToolDef[];