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
@@ -1,16 +1,28 @@
1
- // Live preview web server: serves a three.js viewer that shows any graph/node of the
2
- // workspace and refreshes over Server-Sent Events whenever a graph is rebuilt/edited.
3
- // Bound to 127.0.0.1 only.
1
+ // Live preview web server: serves the viewer page (index.html, a shell loading the ES modules of app/ and three.js
2
+ // from /vendor/three/; docs/design/viewer.md) that shows any graph/node of the workspace and refreshes over
3
+ // Server-Sent Events whenever a graph is rebuilt/edited.
4
+ // It is also the human's control surface: files dropped on the page are stored in the workspace's inputs/ (under
5
+ // `topotopo view`, the inputs/ beside the graph file) and shown as reference layers, the graph's exposed vars are set
6
+ // from generated controls (POST /api/vars), and markers clicked into the view (sites, paths, zones, volumes) become
7
+ // inputs/markers.json (POST /api/markers).
8
+ // Bound to 127.0.0.1 only; requests must name this server as their Host, and writes must come from its own page.
4
9
  import http from 'node:http';
5
10
  import fs from 'node:fs';
6
11
  import path from 'node:path';
12
+ import { Transform } from 'node:stream';
13
+ import { pipeline } from 'node:stream/promises';
7
14
  import { fileURLToPath } from 'node:url';
8
- import { exportGeometryAsync } from '../io/export.js';
15
+ import { GeoBuilder } from '../core/geometry.js';
16
+ import { exportGeometry, exportGeometryAsync } from '../io/export.js';
9
17
  import { summarize } from '../inspect.js';
10
18
  import { withHeightfieldPreview } from '../core/heightfield.js';
19
+ import { withImagePreview } from '../render/texview.js';
11
20
  import { wiringOf } from '../graph/engine.js';
12
- // index.html sits next to this module (the build copies it to dist/viewer/)
21
+ // the page's guide rules, shared so the page and this server check a marker list the same way
22
+ import { MARKER_KINDS as KINDS, MarkerError, parseMarkers as parseMarkerList } from './app/markers.js';
23
+ // index.html and app/ sit next to this module (the build copies them to dist/viewer/)
13
24
  const here = path.dirname(fileURLToPath(import.meta.url));
25
+ const appDir = path.join(here, 'app');
14
26
  // three.js where Node resolves it: this checkout's node_modules, or the hoisted copy next to an installed package
15
27
  const threeDir = (() => {
16
28
  try {
@@ -21,22 +33,358 @@ const threeDir = (() => {
21
33
  }
22
34
  })();
23
35
  const MIME = { '.js': 'text/javascript', '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.json': 'application/json', '.glb': 'model/gltf-binary' };
24
- export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ?? 7341)) {
36
+ /** largest file POST /api/inputs accepts */
37
+ export const MAX_UPLOAD = 512 * 1024 * 1024;
38
+ /** uploads in progress are written next to their target as `.<name>.<n>.part`, then renamed */
39
+ const PART_RE = /(^|\/)\.[^/]*\.part$/;
40
+ class HttpError extends Error {
41
+ status;
42
+ constructor(status, msg) { super(msg); this.status = status; }
43
+ }
44
+ /** The shape every geometry route serves: the same GLB options as /api/geo; images alone show as textured quads. */
45
+ const toGLB = (g) => exportGeometryAsync(withImagePreview(withHeightfieldPreview(g)), 'glb', { splitByName: false, lineMode: 'lines', custom: false });
46
+ const issueText = (e) => `[${e.node}] ${e.message}`;
47
+ /**
48
+ * A dropped file's name as stored in inputs/: a plain basename with its extension. Path separators and ".." are
49
+ * rejected; characters Windows cannot store become "_".
50
+ */
51
+ export function inputFileName(raw) {
52
+ const s = raw.normalize('NFC').trim();
53
+ if (!s || /[\\/]/.test(s) || s.includes('..') || s.includes('\0'))
54
+ throw new HttpError(400, `bad file name ${JSON.stringify(raw)}: give a plain file name, without folders or ".."`);
55
+ let n = s.replace(/[<>:"|?*\u0000-\u001f]/g, '_').replace(/[. ]+$/, '');
56
+ if (!n || n.startsWith('.'))
57
+ throw new HttpError(400, `bad file name ${JSON.stringify(raw)}: it must not start with "."`);
58
+ if (/^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(n))
59
+ n = '_' + n;
60
+ if (n.length > 200)
61
+ throw new HttpError(400, 'file name longer than 200 characters');
62
+ return n;
63
+ }
64
+ // ---------------------------------------------------------------- markers
65
+ // What a person clicks into the view to direct the agent: "village here" (site), "road along here" (path), "keep this
66
+ // area empty" (zone, volume). The page sends the editable list; the server stores it and writes the geometry agents
67
+ // read as inputs/markers.json, in the conventions of help topic "inputs" (string attributes name and role).
68
+ export const MARKER_KINDS = KINDS;
69
+ /**
70
+ * A marker list (the POST /api/markers body, or the stored list) checked and normalised by the page's own rules
71
+ * (app/markers.js): names trimmed and unique (ignoring case), the defaults filled in, ids given to markers without
72
+ * one. Throws HttpError 400 naming the problem.
73
+ */
74
+ export function parseMarkers(body) {
75
+ try {
76
+ return parseMarkerList(body);
77
+ }
78
+ catch (e) {
79
+ throw e instanceof MarkerError ? new HttpError(400, e.message) : e;
80
+ }
81
+ }
82
+ /**
83
+ * The geometry of inputs/markers.json. Sites are points with name, role "site", pscale = radius and orient (identity);
84
+ * paths are open polylines, zones closed ones facing up, volumes closed boxes of 6 quads facing out; those carry prim
85
+ * name and role.
86
+ */
87
+ export function markersGeometry(list) {
88
+ const b = new GeoBuilder();
89
+ for (const m of list) {
90
+ if (m.kind === 'site') {
91
+ const [x, y, z] = m.points[0];
92
+ const p = b.addPoint(x, y, z);
93
+ b.attrib('point', 'name', 1, 'string').set(p, m.name);
94
+ b.attrib('point', 'role', 1, 'string').set(p, 'site');
95
+ b.attrib('point', 'pscale').set(p, m.radius ?? 5);
96
+ b.attrib('point', 'orient', 4).set(p, [0, 0, 0, 1]);
97
+ continue;
98
+ }
99
+ const prims = [];
100
+ if (m.kind === 'volume') {
101
+ const [a, c] = m.points;
102
+ const lo = [Math.min(a[0], c[0]), Math.min(a[1], c[1]), Math.min(a[2], c[2])];
103
+ const hi = [Math.max(a[0], c[0]), lo[1] + (m.height ?? 10), Math.max(a[2], c[2])];
104
+ // corner (i, j, k) is point base + 4i + 2j + k, at the low (0) or high (1) side of x, y, z
105
+ const base = b.numPoints;
106
+ for (let i = 0; i < 2; i++)
107
+ for (let j = 0; j < 2; j++)
108
+ for (let k = 0; k < 2; k++)
109
+ b.addPoint(i ? hi[0] : lo[0], j ? hi[1] : lo[1], k ? hi[2] : lo[2]);
110
+ // the box node's winding: counter-clockwise seen from outside
111
+ for (let ax = 0; ax < 3; ax++)
112
+ for (const s of [0, 1]) {
113
+ const u = (ax + 1) % 3, v = (ax + 2) % 3;
114
+ const quad = [[0, 0], [1, 0], [1, 1], [0, 1]];
115
+ if (!s)
116
+ quad.reverse();
117
+ prims.push(b.addPrim(quad.map(([cu, cv]) => { const c3 = [0, 0, 0]; c3[ax] = s; c3[u] = cu; c3[v] = cv; return base + 4 * c3[0] + 2 * c3[1] + c3[2]; })));
118
+ }
119
+ }
120
+ else {
121
+ let pts = m.points;
122
+ if (m.kind === 'zone') {
123
+ // drawn either way round; stored counter-clockwise seen from above, so the polygon faces up (+Y)
124
+ let ny = 0;
125
+ for (let k = 0; k < pts.length; k++) {
126
+ const p = pts[k], q = pts[(k + 1) % pts.length];
127
+ ny += (p[2] - q[2]) * (p[0] + q[0]);
128
+ }
129
+ if (ny < 0)
130
+ pts = pts.slice().reverse();
131
+ }
132
+ prims.push(b.addPrim(pts.map(([x, y, z]) => b.addPoint(x, y, z)), m.kind === 'zone'));
133
+ }
134
+ for (const pr of prims) {
135
+ b.attrib('prim', 'name', 1, 'string').set(pr, m.name);
136
+ b.attrib('prim', 'role', 1, 'string').set(pr, m.kind);
137
+ }
138
+ }
139
+ return b.build();
140
+ }
141
+ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ?? 7341), opts = {}) {
25
142
  const clients = new Set();
26
- ws.listeners.add((ev) => {
143
+ const broadcast = (ev) => {
27
144
  const msg = `data: ${JSON.stringify(ev)}\n\n`;
28
145
  for (const c of clients)
29
146
  c.write(msg);
147
+ };
148
+ ws.listeners.add(broadcast);
149
+ // ---------------------------------------------------------------- inputs/
150
+ let inputsVersion = 0;
151
+ const inputsChanged = () => broadcast({ inputs: ++inputsVersion });
152
+ const listInputs = () => ws.listInputs().filter((f) => !PART_RE.test(f.path));
153
+ const inputsWhere = opts.inputsWhere ?? (path.dirname(ws.inputDir) === ws.dir ? 'in the workspace' : `in ${path.dirname(ws.inputDir)}`);
154
+ // Files copied into inputs/ by other means (file manager, an agent) show up too, once the folder exists. The viewer
155
+ // does not make it just to watch it (`topotopo view`'s inputs/ beside the graph file appears with the first drop):
156
+ // uploads and listings start the watch when the folder is there by then.
157
+ let watcher = null, watchTimer, closed = false;
158
+ const changed = () => { clearTimeout(watchTimer); watchTimer = setTimeout(inputsChanged, 250); };
159
+ const unwatch = (w = watcher) => { w?.close(); if (watcher === w)
160
+ watcher = null; };
161
+ const watchInputs = () => {
162
+ if (watcher || closed)
163
+ return;
164
+ try {
165
+ if (!fs.statSync(ws.inputDir, { throwIfNoEntry: false })?.isDirectory())
166
+ return;
167
+ const w = fs.watch(ws.inputDir, { recursive: true }, (_ev, f) => {
168
+ const rel = f ? String(f) : '';
169
+ // The folder itself went away. Windows says so with no error but a stream of events naming it by absolute path,
170
+ // which goes on after the folder is made again. Stop; the listing this change prompts watches a new folder.
171
+ if (path.isAbsolute(rel) || !fs.existsSync(ws.inputDir)) {
172
+ unwatch(w);
173
+ changed();
174
+ return;
175
+ }
176
+ if (!PART_RE.test(rel.split(path.sep).join('/')))
177
+ changed();
178
+ });
179
+ w.on('error', () => { unwatch(w); changed(); });
180
+ w.unref();
181
+ watcher = w;
182
+ }
183
+ catch { /* no watch: uploads through the viewer still notify */ }
184
+ };
185
+ watchInputs();
186
+ /** `path` query of /api/input*: a file below inputs/ (posix, relative). */
187
+ const inputPath = (raw) => {
188
+ const rel = (raw ?? '').replace(/\\/g, '/');
189
+ const parts = rel.split('/');
190
+ if (!rel || rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || parts.some((s) => s === '' || s === '.' || s === '..'))
191
+ throw new HttpError(400, `bad input path ${JSON.stringify(raw)}: give a path relative to inputs/ as GET /api/inputs lists it`);
192
+ const root = path.resolve(ws.inputDir);
193
+ const abs = path.resolve(root, ...parts);
194
+ if (!abs.startsWith(root + path.sep))
195
+ throw new HttpError(400, `input path ${JSON.stringify(raw)} leaves inputs/`);
196
+ if (!fs.statSync(abs, { throwIfNoEntry: false })?.isFile())
197
+ throw new HttpError(404, `no input "${rel}" (GET /api/inputs lists them)`);
198
+ return { rel, abs };
199
+ };
200
+ /** The one-node graph that loads an input. */
201
+ const inputGraph = (rel, abs) => {
202
+ // "inputs/<rel>", the path a graph's file node uses, resolves against the host's base directory: the folder that
203
+ // holds inputs/ under the MCP server (the workspace) and under `topotopo view` (the graph file's folder). A
204
+ // workspace whose engine resolves paths elsewhere (an embedding app, a test) gets the absolute path.
205
+ const ref = `inputs/${rel}`;
206
+ const p = path.resolve(ws.engine.host.resolvePath(ref)) === abs ? ref : abs;
207
+ return { nodes: { f: { type: 'file', params: { path: p } } }, output: 'f' };
208
+ };
209
+ const upload = async (req, u) => {
210
+ const name = inputFileName(u.searchParams.get('name') ?? '');
211
+ const overwrite = u.searchParams.get('overwrite') === '1';
212
+ const root = path.resolve(ws.inputDir);
213
+ const dest = path.join(root, name);
214
+ if (path.dirname(dest) !== root)
215
+ throw new HttpError(400, `bad file name "${name}"`);
216
+ const len = Number(req.headers['content-length']);
217
+ if (len > MAX_UPLOAD)
218
+ throw new HttpError(413, `${name} is ${(len / 2 ** 20).toFixed(0)} MB; uploads are limited to ${MAX_UPLOAD / 2 ** 20} MB`);
219
+ const exists = () => fs.existsSync(dest);
220
+ const conflict = { status: 409, body: { ok: false, exists: true, path: name, error: `inputs/${name} exists (overwrite=1 replaces it)` } };
221
+ if (!overwrite && exists())
222
+ return conflict;
223
+ // the first drop makes the folder (again, after it was deleted): a watch from before is on a folder that is gone
224
+ if (fs.mkdirSync(root, { recursive: true }) !== undefined)
225
+ unwatch();
226
+ const part = path.join(root, `.${name}.${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.part`);
227
+ let n = 0;
228
+ const limit = new Transform({
229
+ transform(chunk, _enc, cb) { n += chunk.length; cb(n > MAX_UPLOAD ? new HttpError(413, `uploads are limited to ${MAX_UPLOAD / 2 ** 20} MB`) : null, chunk); },
230
+ });
231
+ try {
232
+ await pipeline(req, limit, fs.createWriteStream(part, { flags: 'wx' }));
233
+ if (!overwrite && exists()) {
234
+ fs.rmSync(part, { force: true });
235
+ return conflict;
236
+ }
237
+ fs.renameSync(part, dest);
238
+ }
239
+ catch (e) {
240
+ fs.rmSync(part, { force: true });
241
+ throw e;
242
+ }
243
+ watchInputs();
244
+ inputsChanged();
245
+ return { status: 200, body: { ok: true, path: name, size: n } };
246
+ };
247
+ // ---------------------------------------------------------------- markers
248
+ // The editable list is inputs/.markers.json (a dot-file: not listed, not read by agents); the geometry is
249
+ // inputs/markers.json. An empty list removes both.
250
+ const markersListFile = () => path.join(ws.inputDir, '.markers.json');
251
+ const markersGeoFile = () => path.join(ws.inputDir, 'markers.json');
252
+ /** Written as a dot-file next to the target and renamed, so readers never see half a file. */
253
+ const writeAtomic = (file, data) => {
254
+ const part = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.part`);
255
+ try {
256
+ fs.writeFileSync(part, data);
257
+ fs.renameSync(part, file);
258
+ }
259
+ catch (e) {
260
+ fs.rmSync(part, { force: true });
261
+ throw e;
262
+ }
263
+ };
264
+ const loadMarkers = () => {
265
+ let text;
266
+ try {
267
+ text = fs.readFileSync(markersListFile(), 'utf8');
268
+ }
269
+ catch {
270
+ return { markers: [] };
271
+ }
272
+ try {
273
+ return { markers: parseMarkers(JSON.parse(text)) };
274
+ }
275
+ catch (e) {
276
+ return { markers: [], error: `inputs/.markers.json is unreadable (${e.message}); saving markers replaces it` };
277
+ }
278
+ };
279
+ const saveMarkers = (list) => {
280
+ let counts = { points: 0, prims: 0 };
281
+ if (!list.length)
282
+ for (const f of [markersGeoFile(), markersListFile()])
283
+ fs.rmSync(f, { force: true });
284
+ else {
285
+ const g = markersGeometry(list);
286
+ counts = { points: g.numPoints, prims: g.numPrims };
287
+ // as for an upload: the first marker makes the folder (again, after it was deleted), and a watch from before is
288
+ // on a folder that is gone
289
+ if (fs.mkdirSync(ws.inputDir, { recursive: true }) !== undefined)
290
+ unwatch();
291
+ writeAtomic(markersGeoFile(), exportGeometry(g, 'json'));
292
+ writeAtomic(markersListFile(), JSON.stringify({ markers: list }, null, 1));
293
+ }
294
+ watchInputs();
295
+ inputsChanged();
296
+ return { ok: true, count: list.length, path: list.length ? 'markers.json' : null, ...counts };
297
+ };
298
+ // ---------------------------------------------------------------- POST /api/vars
299
+ // One edit + cook at a time per graph. Requests that arrive while one runs are merged (the latest value of each
300
+ // var wins) and applied by a single further cook; every request is answered once its values are applied.
301
+ const varQueues = new Map();
302
+ const applyBatch = async (name, b) => {
303
+ const prev = ws.graphs.get(name);
304
+ if (!prev)
305
+ return { status: 404, body: { ok: false, ms: 0, errors: [`no graph ${name}`] } };
306
+ try {
307
+ const { graph, report } = await ws.edit(name, [{ op: 'vars', vars: b.vars }]);
308
+ return { status: 200, body: { ok: report.ok, ms: report.ms, errors: report.errors.map(issueText), vars: graph.vars ?? {} } };
309
+ }
310
+ catch (e) {
311
+ // rejected edits carry their issues (one string each); op errors are plain messages
312
+ return { status: 400, body: { ok: false, ms: 0, errors: Array.isArray(e?.errors) ? e.errors.map(issueText) : [String(e?.message ?? e)] } };
313
+ }
314
+ };
315
+ const applyVars = (name, vars) => new Promise((resolve) => {
316
+ let q = varQueues.get(name);
317
+ if (!q)
318
+ varQueues.set(name, q = { busy: false, next: null });
319
+ if (q.next) {
320
+ Object.assign(q.next.vars, vars);
321
+ q.next.waiters.push(resolve);
322
+ }
323
+ else
324
+ q.next = { vars: { ...vars }, waiters: [resolve] };
325
+ if (q.busy)
326
+ return;
327
+ q.busy = true;
328
+ (async () => {
329
+ try {
330
+ while (q.next) {
331
+ const b = q.next;
332
+ q.next = null;
333
+ const r = await applyBatch(name, b).catch((e) => ({ status: 500, body: { ok: false, ms: 0, errors: [String(e?.message ?? e)] } }));
334
+ for (const w of b.waiters)
335
+ w(r);
336
+ }
337
+ }
338
+ finally {
339
+ q.busy = false;
340
+ }
341
+ })();
30
342
  });
343
+ const readJSON = (req, max = 4 << 20) => new Promise((resolve, reject) => {
344
+ const chunks = [];
345
+ let n = 0;
346
+ req.on('data', (c) => { n += c.length; if (n <= max)
347
+ chunks.push(c); });
348
+ req.on('end', () => {
349
+ if (n > max)
350
+ return reject(new HttpError(413, `request body over ${max >> 20} MB`));
351
+ const s = Buffer.concat(chunks).toString('utf8');
352
+ try {
353
+ resolve(s.trim() ? JSON.parse(s) : {});
354
+ }
355
+ catch (e) {
356
+ reject(new HttpError(400, `body is not JSON: ${e.message}`));
357
+ }
358
+ });
359
+ req.on('error', reject);
360
+ });
361
+ // ---------------------------------------------------------------- routes
31
362
  const server = http.createServer(async (req, res) => {
363
+ const send = (code, body, type = 'text/plain; charset=utf-8') => {
364
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
365
+ res.end(body);
366
+ };
367
+ const json = (code, body) => send(code, JSON.stringify(body), MIME['.json']);
368
+ let route = '';
32
369
  try {
370
+ // DNS rebinding: only requests addressed to this server; cross-site writes: only from this server's own page
371
+ const host = String(req.headers.host ?? '');
372
+ if (!/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host))
373
+ return json(403, { error: `unexpected Host "${host}"` });
374
+ const origin = req.headers.origin;
375
+ if (req.method !== 'GET' && req.method !== 'HEAD' && origin !== undefined && origin !== `http://${host}`)
376
+ return json(403, { error: `cross-origin ${req.method} refused (${origin})` });
33
377
  const u = new URL(req.url ?? '/', 'http://localhost');
34
- const send = (code, body, type = 'text/plain; charset=utf-8') => {
35
- res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
36
- res.end(body);
37
- };
378
+ route = u.pathname;
38
379
  if (u.pathname === '/' || u.pathname === '/index.html')
39
380
  return send(200, fs.readFileSync(path.join(here, 'index.html')), MIME['.html']);
381
+ // the page's modules and style sheet
382
+ if (u.pathname.startsWith('/app/')) {
383
+ const p = path.resolve(appDir, decodeURIComponent(u.pathname.slice('/app/'.length)));
384
+ if (!p.startsWith(appDir + path.sep) || !fs.statSync(p, { throwIfNoEntry: false })?.isFile())
385
+ return send(404, 'not found');
386
+ return send(200, fs.readFileSync(p), MIME[path.extname(p)] ?? 'application/octet-stream');
387
+ }
40
388
  if (u.pathname.startsWith('/vendor/three/')) {
41
389
  const rel = decodeURIComponent(u.pathname.slice('/vendor/three/'.length));
42
390
  const p = path.resolve(threeDir, rel);
@@ -56,6 +404,7 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
56
404
  const last = ws.lastReport.get(name);
57
405
  return {
58
406
  name, output: last?.target ?? g.output ?? null, version: ws.versions.get(name) ?? 0,
407
+ vars: g.vars ?? {}, params: g.params ?? {},
59
408
  nodes: Object.entries(g.nodes).map(([n, spec]) => {
60
409
  const def = ws.engine.resolveDef(spec.type);
61
410
  const r = last?.results.get(n);
@@ -63,7 +412,57 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
63
412
  }),
64
413
  };
65
414
  });
66
- return send(200, JSON.stringify(out), MIME['.json']);
415
+ return json(200, out);
416
+ }
417
+ // the last cook of a graph, for the page's status bar: cook time, errors and warnings
418
+ if (u.pathname === '/api/report') {
419
+ const name = u.searchParams.get('graph') ?? 'main';
420
+ if (!ws.graphs.has(name))
421
+ return json(404, { error: `no graph ${name}` });
422
+ const r = ws.lastReport.get(name), version = ws.versions.get(name) ?? 0;
423
+ if (!r)
424
+ return json(200, { graph: name, version, cooked: false });
425
+ return json(200, { graph: name, version, cooked: true, target: r.target, ok: r.ok, ms: r.ms, errors: r.errors.map(issueText), warnings: r.warnings.map(issueText) });
426
+ }
427
+ if (u.pathname === '/api/vars') {
428
+ if (req.method !== 'POST')
429
+ return json(405, { ok: false, ms: 0, errors: ['POST {"vars": {...}}'] });
430
+ const name = u.searchParams.get('graph') ?? 'main';
431
+ const body = await readJSON(req);
432
+ const vars = body?.vars ?? {};
433
+ if (!vars || typeof vars !== 'object' || Array.isArray(vars))
434
+ throw new HttpError(400, '"vars" must be an object {name: value}');
435
+ if (!ws.graphs.has(name))
436
+ return json(404, { ok: false, ms: 0, errors: [`no graph ${name}`] });
437
+ const r = await applyVars(name, vars);
438
+ return json(r.status, r.body);
439
+ }
440
+ if (u.pathname === '/api/inputs') {
441
+ if (req.method === 'POST') {
442
+ const r = await upload(req, u);
443
+ return json(r.status, r.body);
444
+ }
445
+ watchInputs();
446
+ return json(200, listInputs());
447
+ }
448
+ // where dropped files go: the absolute folder, and in words for the page ("beside house.json")
449
+ if (u.pathname === '/api/inputs_dir')
450
+ return json(200, { dir: ws.inputDir, where: inputsWhere });
451
+ if (u.pathname === '/api/markers') {
452
+ if (req.method === 'POST')
453
+ return json(200, saveMarkers(parseMarkers(await readJSON(req))));
454
+ if (req.method !== 'GET' && req.method !== 'HEAD')
455
+ return json(405, { ok: false, error: 'GET, or POST {"markers": [...]}' });
456
+ return json(200, loadMarkers());
457
+ }
458
+ if (u.pathname === '/api/input' || u.pathname === '/api/input_info') {
459
+ const { rel, abs } = inputPath(u.searchParams.get('path'));
460
+ const r = await ws.engine.cook(inputGraph(rel, abs));
461
+ if (!r.ok || !r.geo)
462
+ return json(500, { error: r.errors.map(issueText).join('\n') });
463
+ if (u.pathname === '/api/input_info')
464
+ return json(200, { path: rel, summary: summarize(r.geo), ms: r.ms });
465
+ return send(200, await toGLB(r.geo), MIME['.glb']);
67
466
  }
68
467
  if (u.pathname === '/api/geo' || u.pathname === '/api/info') {
69
468
  const name = u.searchParams.get('graph') ?? 'main';
@@ -73,16 +472,20 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
73
472
  return send(404, JSON.stringify({ error: `no graph ${name}` }), MIME['.json']);
74
473
  const r = await ws.engine.cook(g, { target: node });
75
474
  if (!r.ok || !r.geo)
76
- return send(500, JSON.stringify({ error: r.errors.map((e) => `[${e.node}] ${e.message}`).join('\n') }), MIME['.json']);
475
+ return send(500, JSON.stringify({ error: r.errors.map(issueText).join('\n') }), MIME['.json']);
77
476
  if (u.pathname === '/api/info')
78
477
  return send(200, JSON.stringify({ target: r.target, summary: summarize(r.geo), ms: r.ms }), MIME['.json']);
79
- return send(200, await exportGeometryAsync(withHeightfieldPreview(r.geo), 'glb', { splitByName: false, lineMode: 'lines', custom: false }), MIME['.glb']);
478
+ return send(200, await toGLB(r.geo), MIME['.glb']);
80
479
  }
81
480
  send(404, 'not found');
82
481
  }
83
482
  catch (e) {
84
- res.writeHead(500, { 'content-type': 'application/json' });
85
- res.end(JSON.stringify({ error: String(e?.message ?? e) }));
483
+ if (res.headersSent || res.destroyed)
484
+ return;
485
+ const status = e instanceof HttpError ? e.status : 500;
486
+ const msg = String(e?.message ?? e);
487
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...(status === 413 ? { connection: 'close' } : {}) });
488
+ res.end(JSON.stringify(route === '/api/vars' ? { ok: false, ms: 0, errors: [msg] } : { ok: false, error: msg }));
86
489
  }
87
490
  });
88
491
  const listen = (p) => new Promise((resolve, reject) => {
@@ -105,5 +508,17 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
105
508
  const keepAlive = setInterval(() => { for (const c of clients)
106
509
  c.write(': ping\n\n'); }, 20000);
107
510
  keepAlive.unref();
108
- return { url: `http://127.0.0.1:${actual}/`, close: () => { clearInterval(keepAlive); server.close(); } };
511
+ return {
512
+ url: `http://127.0.0.1:${actual}/`,
513
+ close: () => {
514
+ closed = true;
515
+ clearInterval(keepAlive);
516
+ clearTimeout(watchTimer);
517
+ unwatch();
518
+ ws.listeners.delete(broadcast);
519
+ for (const c of clients)
520
+ c.end();
521
+ server.close();
522
+ },
523
+ };
109
524
  }
@@ -1,5 +1,5 @@
1
1
  import { Engine } from './graph/engine.ts';
2
- import type { CookReport } from './graph/engine.ts';
2
+ import type { CookReport, Issue } from './graph/engine.ts';
3
3
  import type { GraphSpec, NodeSpec } from './graph/types.ts';
4
4
  export interface EditOp {
5
5
  op: 'set' | 'add' | 'remove' | 'connect' | 'rename' | 'output' | 'vars' | 'bypass' | 'replace' | 'insert';
@@ -16,6 +16,34 @@ export interface EditOp {
16
16
  value?: boolean;
17
17
  note?: string;
18
18
  }
19
+ /** A file under <workspace>/inputs. */
20
+ export interface InputFile {
21
+ /** posix path relative to inputs/ (a file node reads it as "inputs/" + path) */
22
+ path: string;
23
+ kind: 'geometry' | 'image' | 'other';
24
+ /** lower-case extension without the dot ("points.json" counts as one) */
25
+ ext: string;
26
+ size: number;
27
+ /** modification time, ms since the epoch */
28
+ mtime: number;
29
+ }
30
+ /** Lower-case extension of a file name without the dot; ".points.json" is one extension, dot-files have none. */
31
+ export declare function inputExt(file: string): string;
32
+ export declare function inputKind(ext: string): InputFile['kind'];
33
+ export interface WorkspaceOptions {
34
+ /**
35
+ * The inputs folder when it is not <dir>/inputs. `topotopo view` keeps its workspace in a scratch folder but puts
36
+ * inputs/ beside the graph file, where the graph's file nodes read "inputs/<name>" and the files outlive the viewer.
37
+ * load() does not create a folder given here: the first file written into it does (the viewer's upload route), so a
38
+ * graph that never gets a drop leaves no empty inputs/ beside it.
39
+ */
40
+ inputs?: string;
41
+ }
42
+ /** Thrown by Workspace.edit when the edited graph does not validate; the stored graph is unchanged. */
43
+ export declare class EditRejected extends Error {
44
+ errors: Issue[];
45
+ constructor(errors: Issue[]);
46
+ }
19
47
  export declare class Workspace {
20
48
  dir: string;
21
49
  engine: Engine;
@@ -26,10 +54,17 @@ export declare class Workspace {
26
54
  graph: string;
27
55
  version: number;
28
56
  }) => void>;
29
- constructor(dir: string, engine: Engine);
57
+ /** the inputs folder given to the constructor (WorkspaceOptions.inputs), if any */
58
+ private inputsAt;
59
+ constructor(dir: string, engine: Engine, opts?: WorkspaceOptions);
30
60
  get graphDir(): string;
31
61
  get assetDir(): string;
32
62
  get exportDir(): string;
63
+ /**
64
+ * files people drop in: meshes, guide curves and points, PNG masks and heightmaps (read as "inputs/<path>");
65
+ * <dir>/inputs unless the constructor was given another folder
66
+ */
67
+ get inputDir(): string;
33
68
  load(): {
34
69
  graphs: number;
35
70
  assets: number;
@@ -41,6 +76,8 @@ export declare class Workspace {
41
76
  getGraph(name: string): GraphSpec;
42
77
  setGraph(name: string, spec: GraphSpec): void;
43
78
  deleteGraph(name: string): void;
79
+ /** Every file under inputs/, recursively: posix paths relative to it, sorted. Dot-files and dot-folders are skipped. */
80
+ listInputs(): InputFile[];
44
81
  /** Merge nodes/vars/output of `patch` into an existing graph (upsert). */
45
82
  mergeGraph(name: string, patch: GraphSpec): GraphSpec;
46
83
  /** Apply edit operations; returns the new graph (not yet stored) and notes. */
@@ -48,5 +85,18 @@ export declare class Workspace {
48
85
  graph: GraphSpec;
49
86
  notes: string[];
50
87
  };
88
+ /**
89
+ * The one path for an edit, whoever makes it (the MCP edit tool, the viewer's controls): apply the ops, validate
90
+ * (an invalid result throws EditRejected listing the per-node errors and stores nothing), store, cook at `frame`,
91
+ * then notify the listeners. They hear after the cook so that what they read (lastReport, cached geometry) is the
92
+ * new version's; a cook that throws still notifies, since the graph was stored.
93
+ */
94
+ edit(name: string, ops: EditOp[], opts?: {
95
+ frame?: number;
96
+ }): Promise<{
97
+ graph: GraphSpec;
98
+ notes: string[];
99
+ report: CookReport;
100
+ }>;
51
101
  cook(name: string, target?: string, frame?: number): Promise<CookReport>;
52
102
  }