topotopo 0.2.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.
@@ -0,0 +1,436 @@
1
+ // Output catalogue, previews, downloads and exports. Geometry stays server-side; cards and previews are lazy.
2
+ // A published output exports to its target in the graph (io/targets.ts): its Export button writes it, Export all
3
+ // writes every target, and a first export sets the target in the graph so the next click just writes again.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import zlib from 'node:zlib';
7
+ import { createHash } from 'node:crypto';
8
+ import { zipSync } from 'fflate';
9
+ import { ArtifactStore, artifactId } from '../host/artifacts.js';
10
+ import { prepareTarget, writeTargets } from '../host/export-targets.js';
11
+ import { Image, referenceableImages, imageToBytes, resizeLayer } from '../core/image.js';
12
+ import { expandPacked } from '../core/packed.js';
13
+ import { withHeightfieldPreview } from '../core/heightfield.js';
14
+ import { withImagePreview } from '../render/texview.js';
15
+ import { renderGeometry } from '../render/raster.js';
16
+ import { encodePNG } from '../render/png.js';
17
+ import { exportGeometryAsync } from '../io/export.js';
18
+ import { FORMAT_LABELS, TARGET_FORMATS, publishedOutputs, targetError, targetExtension } from '../io/targets.js';
19
+ import { summarize } from '../inspect.js';
20
+ const digest = (x) => createHash('sha256').update(x).digest('hex');
21
+ const imageExt = /\.(png|jpe?g|webp)$/i;
22
+ const meshExt = /\.(glb|gltf|obj|stl|ply|geo|bgeo|json|csv)$/i;
23
+ const mime = (p) => ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.glb': 'model/gltf-binary' })[path.extname(p).toLowerCase()] ?? 'application/octet-stream';
24
+ const deflate = (d) => zlib.deflateSync(d, { level: 6 });
25
+ const stamp = (p) => { const s = fs.statSync(p, { throwIfNoEntry: false }); return s ? `${s.size}:${s.mtimeMs}` : 'missing'; };
26
+ function err(status, message) { throw Object.assign(new Error(message), { status }); }
27
+ const idFor = (...parts) => digest(JSON.stringify(parts)).slice(0, 24);
28
+ const inside = (dir, p) => { const rel = path.relative(dir, p); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
29
+ /** Predictable ZIP bytes, retaining the bundle directory structure. */
30
+ export function outputZip(files) {
31
+ const entries = Object.create(null);
32
+ for (const f of [...files].sort((a, b) => a.name.localeCompare(b.name, 'en')))
33
+ entries[f.name] = [f.bytes, { mtime: new Date(1980, 0, 1), level: 6 }];
34
+ return zipSync(entries);
35
+ }
36
+ export function createOutputs(ws, changed, opts = {}) {
37
+ const store = new ArtifactStore(ws.exportDir);
38
+ const items = new Map(), previewCache = new Map();
39
+ let queue = Promise.resolve(), closed = false, lastFiles = '', exportQueue = Promise.resolve();
40
+ const cache = async (key, make) => {
41
+ if (previewCache.has(key))
42
+ return previewCache.get(key);
43
+ const bytes = await make();
44
+ previewCache.set(key, bytes);
45
+ // Bound retained image/GLB bytes, independent of the engine's own geometry cache.
46
+ let total = [...previewCache.values()].reduce((n, b) => n + b.length, 0);
47
+ for (const [k, b] of previewCache) {
48
+ if (total <= 64 * 1024 * 1024)
49
+ break;
50
+ previewCache.delete(k);
51
+ total -= b.length;
52
+ }
53
+ return bytes;
54
+ };
55
+ const addImages = (item) => {
56
+ for (const [id, v] of items)
57
+ if (v.owner === item)
58
+ items.delete(id);
59
+ if (!item.geo)
60
+ return;
61
+ for (const [name, img] of referenceableImages(item.geo)) {
62
+ const id = idFor(item.entry.id, name);
63
+ items.set(id, { owner: item, img, entry: { id, origin: item.entry.origin, parent: item.entry.id, graph: item.entry.graph, node: item.entry.node,
64
+ label: name, kind: 'image', generated: true, status: 'ready', revision: item.entry.revision,
65
+ width: img.width, height: img.height, layers: [...img.layers.keys()], layer: img.mainLayer, image: name } });
66
+ }
67
+ };
68
+ const cook = (item) => {
69
+ if (item.pending)
70
+ return item.pending;
71
+ const key = item.key, e = item.entry;
72
+ item.pending = queue = queue.catch(() => { }).then(async () => {
73
+ // Yield between outputs so navigation and metadata requests remain responsive.
74
+ await new Promise((resolve) => setImmediate(resolve));
75
+ if (closed || items.get(e.id) !== item)
76
+ return;
77
+ try {
78
+ const r = item.file
79
+ ? await ws.engine.cook({ nodes: { file: { type: 'file', params: { path: item.file } } }, output: 'file' })
80
+ : await ws.cook(e.graph, e.node);
81
+ if (item.key !== key || items.get(e.id) !== item)
82
+ return;
83
+ if (!r.ok || !r.geo)
84
+ throw new Error(r.errors.map((x) => `${x.node}: ${x.message}`).join('\n') || 'no geometry');
85
+ item.geo = r.geo;
86
+ e.status = 'ready';
87
+ delete e.error;
88
+ const firstImage = referenceableImages(r.geo).values().next().value;
89
+ if (!r.geo.numPoints && !r.geo.numPrims && firstImage) {
90
+ item.img = firstImage;
91
+ e.kind = 'image';
92
+ e.width = firstImage.width;
93
+ e.height = firstImage.height;
94
+ e.layers = [...firstImage.layers.keys()];
95
+ e.layer = firstImage.mainLayer;
96
+ }
97
+ if (!item.file)
98
+ e.revision = digest(`${key}:${r.results.get(r.target)?.sig ?? ''}`);
99
+ e.summary = summarize(r.geo);
100
+ addImages(item);
101
+ }
102
+ catch (error) {
103
+ e.status = 'error';
104
+ e.error = String(error.message);
105
+ // Keep the last valid preview but mark its image children stale as well.
106
+ for (const v of items.values())
107
+ if (v.owner === item) {
108
+ v.entry.status = 'error';
109
+ v.entry.error = e.error;
110
+ }
111
+ }
112
+ finally {
113
+ item.pending = undefined;
114
+ if (!closed)
115
+ changed();
116
+ }
117
+ });
118
+ return item.pending;
119
+ };
120
+ /** The last export of a published output to its current target, from the receipts. */
121
+ const lastSaved = (records, graph, p) => {
122
+ if (!p.target)
123
+ return undefined;
124
+ const dest = path.join(ws.exportDir, ...p.target.path.split('/'));
125
+ let best;
126
+ for (const r of records) {
127
+ if (r.graph !== graph || r.output !== (p.output ?? '') || !r.files.some((f) => inside(dest, f)))
128
+ continue;
129
+ const stats = r.files.map((f) => fs.statSync(f, { throwIfNoEntry: false })).filter((s) => s?.isFile());
130
+ if (!stats.length)
131
+ continue;
132
+ const at = Math.max(...stats.map((s) => s.mtimeMs));
133
+ if (!best || at > best.at)
134
+ best = { at: Math.round(at), size: stats.reduce((n, s) => n + s.size, 0), files: stats.length };
135
+ }
136
+ return best;
137
+ };
138
+ const refresh = () => {
139
+ const alive = new Set();
140
+ const inputStamp = JSON.stringify(ws.listInputs().map((f) => [f.path, f.size, f.mtime]));
141
+ // A referenced graph or a file outside inputs/ can change an otherwise unchanged output.
142
+ const sourceFiles = new Map();
143
+ for (const spec of ws.graphs.values())
144
+ for (const node of Object.values(spec.nodes)) {
145
+ const p = node.params?.path ?? node.params?.file ?? node.params?.filename;
146
+ if (typeof p !== 'string' || !p || p.startsWith('='))
147
+ continue;
148
+ sourceFiles.set(p, ws.engine.host.fileStamp(p));
149
+ try {
150
+ if (ws.engine.host.isDirectory?.(p))
151
+ for (const f of ws.engine.host.listDir?.(p) ?? []) {
152
+ const child = path.join(p, f);
153
+ sourceFiles.set(child, ws.engine.host.fileStamp(child));
154
+ }
155
+ }
156
+ catch { /* a disappearing file is reported by the next cook */ }
157
+ }
158
+ const workspaceRevision = digest(JSON.stringify([...ws.graphs]) + inputStamp + JSON.stringify([...sourceFiles]));
159
+ const records = store.records();
160
+ for (const [graph, spec] of ws.graphs) {
161
+ for (const p of publishedOutputs(graph, spec, ws.engine.findOutput(spec).name)) {
162
+ const id = idFor('graph', graph, p.output === null ? 'default' : 'named', p.output ?? 'main');
163
+ alive.add(id);
164
+ const key = workspaceRevision, old = items.get(id);
165
+ const meta = { label: p.label, output: p.output, target: p.target ? { format: p.target.format, path: p.target.path, options: p.target.options } : null, saved: lastSaved(records, graph, p) };
166
+ if (old?.key === key) {
167
+ Object.assign(old.entry, meta);
168
+ if (!meta.saved)
169
+ delete old.entry.saved;
170
+ continue;
171
+ }
172
+ const item = { geo: old?.geo, key, entry: { id, origin: 'published', graph, node: p.node, kind: 'geometry', generated: true, exportable: true, status: 'loading', revision: key, ...meta } };
173
+ if (!meta.saved)
174
+ delete item.entry.saved;
175
+ // Carry last valid images until the replacement cook completes.
176
+ if (old)
177
+ for (const v of items.values())
178
+ if (v.owner === old) {
179
+ v.owner = item;
180
+ v.entry.status = 'loading';
181
+ }
182
+ items.set(id, item);
183
+ void cook(item);
184
+ }
185
+ }
186
+ const saved = store.list();
187
+ for (const a of saved) {
188
+ const id = 'file-' + a.id;
189
+ alive.add(id);
190
+ const files = a.files.filter((p) => fs.statSync(p, { throwIfNoEntry: false })?.isFile());
191
+ if (!files.length) {
192
+ alive.delete(id);
193
+ continue;
194
+ }
195
+ const revision = digest(files.map((p) => p + stamp(p)).join('|'));
196
+ if (items.get(id)?.entry.revision === revision)
197
+ continue;
198
+ const file = files[0];
199
+ const kind = files.length > 1 ? 'bundle' : imageExt.test(file) ? 'image' : meshExt.test(file) ? 'geometry' : 'file';
200
+ items.set(id, { file, files, entry: { id, origin: 'saved', label: a.label, graph: a.graph, node: a.node, kind, status: 'ready', revision,
201
+ summary: a.format === 'reference' ? 'Reference image' : `Saved ${a.format ?? path.extname(file).slice(1)} output`,
202
+ files: files.map((p) => ({ id: artifactId(p), name: path.relative(path.dirname(file), p).split(path.sep).join('/'), size: fs.statSync(p, { throwIfNoEntry: false })?.size ?? 0 })) } });
203
+ }
204
+ // Bundle contents are individual inspectable outputs as well as downloads.
205
+ for (const item of [...items.values()])
206
+ if (alive.has(item.entry.id) && item.files && item.files.length > 1)
207
+ for (const file of item.files) {
208
+ const stat = fs.statSync(file, { throwIfNoEntry: false });
209
+ if (!stat?.isFile())
210
+ continue;
211
+ const id = item.entry.id + '-' + artifactId(file);
212
+ alive.add(id);
213
+ const revision = digest(stamp(file));
214
+ if (items.get(id)?.entry.revision === revision)
215
+ continue;
216
+ items.set(id, { file, files: [file], entry: { id, origin: item.entry.origin, parent: item.entry.id, graph: item.entry.graph, label: path.basename(file),
217
+ kind: imageExt.test(file) ? 'image' : meshExt.test(file) ? 'geometry' : 'file', status: 'ready', revision,
218
+ files: [{ id: artifactId(file), name: path.basename(file), size: stat.size }] } });
219
+ }
220
+ for (const [id, item] of items)
221
+ if (!alive.has(id) && !item.owner) {
222
+ items.delete(id);
223
+ for (const [child, v] of items)
224
+ if (v.owner === item)
225
+ items.delete(child);
226
+ }
227
+ const signature = JSON.stringify([...items.values()].filter((v) => v.files || v.entry.exportable).map((v) => [v.entry.id, v.entry.revision, v.entry.target, v.entry.saved]));
228
+ if (signature !== lastFiles) {
229
+ lastFiles = signature;
230
+ changed();
231
+ }
232
+ };
233
+ const get = (id) => items.get(id ?? '') ?? err(404, 'output no longer exists');
234
+ const geometry = async (item) => { if (item.owner)
235
+ item = item.owner; if (!item.geo || item.pending)
236
+ await cook(item); return item.geo ?? err(422, item.entry.error ?? 'no geometry'); };
237
+ /** The published output behind a card, as the graph now has it. */
238
+ const published = (entry) => {
239
+ const spec = ws.getGraph(entry.graph);
240
+ return publishedOutputs(entry.graph, spec, ws.engine.findOutput(spec).name).find((p) => p.output === entry.output) ?? err(404, 'output no longer exists');
241
+ };
242
+ /** Cook now and serialize to the target: the export writes the current graph, whatever the preview last showed. */
243
+ const prepare = async (graph, p) => {
244
+ const r = await ws.cook(graph, p.node);
245
+ if (!r.ok || !r.geo)
246
+ err(422, `${p.label} (${graph}): ` + (r.errors.map((e) => `${e.node}: ${e.message}`).join('; ') || 'no geometry'));
247
+ return prepareTarget(r.geo, graph, p, ws.exportDir, ws.engine.host);
248
+ };
249
+ /** One export at a time, so two clicks never interleave their writes. */
250
+ const exclusive = async (work) => {
251
+ let unlock;
252
+ const prior = exportQueue;
253
+ exportQueue = new Promise((r) => { unlock = r; });
254
+ await prior;
255
+ try {
256
+ return await work();
257
+ }
258
+ finally {
259
+ unlock();
260
+ }
261
+ };
262
+ // A file can vanish while a scan stats it. The next tick or request rescans; neither callback may throw (an
263
+ // exception in the timer would end the viewer's process, the MCP server's too when it hosts the viewer).
264
+ const rescan = () => { try {
265
+ refresh();
266
+ }
267
+ catch { /* rescanned on the next tick */ } };
268
+ const listener = () => { rescan(); changed(); };
269
+ ws.listeners.add(listener);
270
+ const timer = setInterval(() => { if (!closed)
271
+ rescan(); }, 1500);
272
+ timer.unref();
273
+ return {
274
+ close() { closed = true; clearInterval(timer); ws.listeners.delete(listener); items.clear(); previewCache.clear(); },
275
+ async handle(req, res, u, readJSON) {
276
+ if (!u.pathname.startsWith('/api/output'))
277
+ return false;
278
+ const send = (body, type, name) => {
279
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...(name ? { 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}` } : {}) });
280
+ res.end(body);
281
+ };
282
+ const json = (j) => send(JSON.stringify(j), 'application/json');
283
+ const posts = ['/api/output/export', '/api/output/export-all'];
284
+ try {
285
+ if (posts.includes(u.pathname) ? req.method !== 'POST' : req.method !== 'GET')
286
+ err(405, posts.includes(u.pathname) ? 'POST required' : 'method not allowed');
287
+ refresh();
288
+ if (u.pathname === '/api/outputs') {
289
+ json({ items: [...items.values()].map((v) => v.entry), directory: ws.exportDir,
290
+ formats: TARGET_FORMATS.map((f) => ({ format: f, label: FORMAT_LABELS[f], extension: targetExtension(f) })) });
291
+ return true;
292
+ }
293
+ if (u.pathname === '/api/output/export') {
294
+ // {id} writes the output to its target; {id, export} first sets the target ({id, export: null} only removes it)
295
+ const body = await readJSON(req), item = get(body?.id);
296
+ if (!item.entry.exportable)
297
+ err(400, 'only a published output (a card with an Export button) exports');
298
+ return await exclusive(async () => {
299
+ const graph = item.entry.graph;
300
+ if (body.export !== undefined) {
301
+ if (body.export !== null && targetError(body.export))
302
+ err(400, targetError(body.export));
303
+ try {
304
+ await ws.edit(graph, [{ op: 'export', ...(item.entry.output === null ? {} : { output: item.entry.output }), export: body.export }]);
305
+ }
306
+ catch (e) {
307
+ err(400, e.message);
308
+ }
309
+ opts.persistGraph?.(graph);
310
+ if (body.export === null) {
311
+ rescan();
312
+ changed();
313
+ json({ ok: true, cleared: true });
314
+ return true;
315
+ }
316
+ }
317
+ const p = published(item.entry);
318
+ if (!p.target)
319
+ err(400, 'no export target yet: choose a format');
320
+ const prepared = await prepare(graph, p);
321
+ writeTargets([prepared], ws.exportDir);
322
+ rescan();
323
+ changed();
324
+ json({ ok: true, path: p.target.path, files: prepared.files.length, summary: prepared.summary, warnings: prepared.warnings });
325
+ return true;
326
+ });
327
+ }
328
+ if (u.pathname === '/api/output/export-all') {
329
+ return await exclusive(async () => {
330
+ const targets = [], skipped = [];
331
+ for (const [graph, spec] of ws.graphs)
332
+ for (const p of publishedOutputs(graph, spec, ws.engine.findOutput(spec).name))
333
+ if (p.target)
334
+ targets.push({ graph, p });
335
+ else
336
+ skipped.push({ graph, label: p.label });
337
+ if (!targets.length)
338
+ err(400, 'no output has an export target yet: export one with its Export button first');
339
+ // every target cooks and serializes before any file is written
340
+ const prepared = [];
341
+ for (const t of targets)
342
+ prepared.push(await prepare(t.graph, t.p));
343
+ writeTargets(prepared, ws.exportDir);
344
+ rescan();
345
+ changed();
346
+ json({ ok: true, written: prepared.map((x) => ({ graph: x.graph, label: x.label, path: x.target.path, warnings: x.warnings })), skipped });
347
+ return true;
348
+ });
349
+ }
350
+ const item = get(u.searchParams.get('id'));
351
+ const mode = u.searchParams.get('mode') ?? 'RGB', layer = u.searchParams.get('layer') ?? item.entry.layer ?? '';
352
+ if (u.pathname === '/api/output/file') {
353
+ const p = (item.files ?? []).find((p) => artifactId(p) === u.searchParams.get('file')) ?? (u.searchParams.has('file') ? undefined : item.file);
354
+ if (!p || !fs.statSync(p, { throwIfNoEntry: false })?.isFile())
355
+ err(404, 'file no longer exists');
356
+ send(fs.readFileSync(p), mime(p), u.searchParams.has('download') ? path.basename(p) : undefined);
357
+ return true;
358
+ }
359
+ if (u.pathname === '/api/output/zip') {
360
+ if (!item.files?.length)
361
+ err(400, 'no saved files');
362
+ // names relative to the folder every file shares, so no entry climbs out with "../"
363
+ const common = (a, b) => { while (a !== path.dirname(a) && path.relative(a, b).startsWith('..'))
364
+ a = path.dirname(a); return a; };
365
+ const root = item.files.map((p) => path.dirname(p)).reduce(common);
366
+ send(outputZip(item.files.map((p) => ({ name: path.relative(root, p).split(path.sep).join('/'), bytes: fs.readFileSync(p) }))), 'application/zip', item.entry.label + '.zip');
367
+ return true;
368
+ }
369
+ if (u.pathname === '/api/output/image' || u.pathname === '/api/output/thumb' && item.img) {
370
+ if (!item.img)
371
+ err(400, 'this output is not a generated image');
372
+ let img = item.img;
373
+ if (layer && !img.layers.has(layer))
374
+ err(404, 'image layer no longer exists');
375
+ const thumb = u.pathname.endsWith('/thumb');
376
+ const bytes = await cache(`${item.entry.id}:${item.entry.revision}:${layer}:${mode}:${thumb}`, () => {
377
+ if (thumb) {
378
+ const s = Math.min(1, 160 / Math.max(img.width, img.height)), w = Math.max(1, Math.round(img.width * s)), h = Math.max(1, Math.round(img.height * s));
379
+ const l = layer || img.mainLayer;
380
+ const small = new Image(w, h, img.name);
381
+ small.set(l, resizeLayer(img.layer(l), img.channels(l), img.width, img.height, w, h, true));
382
+ img = small;
383
+ }
384
+ const data = imageToBytes(img, layer, true), rgba = new Uint8Array(img.count * 4);
385
+ for (let i = 0; i < img.count; i++) {
386
+ const rgb = data.channels === 1 ? [data.bytes[i], data.bytes[i], data.bytes[i], 255] : data.bytes.subarray(i * 4, i * 4 + 4);
387
+ const channel = ['R', 'G', 'B', 'A'].indexOf(mode);
388
+ rgba.set(channel >= 0 ? [rgb[channel], rgb[channel], rgb[channel], 255] : [rgb[0], rgb[1], rgb[2], mode === 'RGBA' ? rgb[3] : 255], i * 4);
389
+ }
390
+ return encodePNG(rgba, img.width, img.height, deflate);
391
+ });
392
+ // a texture card's Download: the image as a PNG named after it
393
+ send(bytes, 'image/png', u.searchParams.has('download') ? `${item.entry.label}${layer && layer !== img.mainLayer ? '_' + layer : ''}.png` : undefined);
394
+ return true;
395
+ }
396
+ if (u.pathname === '/api/output/uv') {
397
+ const g = expandPacked(await geometry(item)).geo, uv = g.vertices.get('uv') ?? g.points.get('uv'), segments = [];
398
+ if (uv)
399
+ for (let p = 0; p < g.numPrims; p++) {
400
+ const start = g.primStart[p], end = g.primStart[p + 1];
401
+ for (let v = start; v < end; v++) {
402
+ const next = v + 1 === end ? start : v + 1;
403
+ const a = uv.get(g.vertices.get('uv') ? v : g.vertexPoint[v]), b = uv.get(g.vertices.get('uv') ? next : g.vertexPoint[next]);
404
+ segments.push([a[0], a[1], b[0], b[1]]);
405
+ }
406
+ }
407
+ json({ segments });
408
+ return true;
409
+ }
410
+ if (u.pathname === '/api/output/geo' || u.pathname === '/api/output/thumb') {
411
+ if (item.file && imageExt.test(item.file)) {
412
+ send(fs.readFileSync(item.file), mime(item.file));
413
+ return true;
414
+ }
415
+ const g = await geometry(item), thumb = u.pathname.endsWith('/thumb');
416
+ const bytes = await cache(`${item.entry.id}:${item.entry.revision}:${thumb}`, async () => {
417
+ if (!thumb)
418
+ return exportGeometryAsync(withImagePreview(withHeightfieldPreview(g)), 'glb', { custom: false, splitByName: false, readFile: (p) => ws.engine.host.readFile(p) });
419
+ const r = renderGeometry(g, { size: 160, views: ['persp'], viewLabels: false });
420
+ return encodePNG(r.rgba.subarray(0, r.width * 160 * 4), r.width, 160, deflate);
421
+ });
422
+ send(bytes, thumb ? 'image/png' : 'model/gltf-binary');
423
+ return true;
424
+ }
425
+ err(404, 'unknown output route');
426
+ }
427
+ catch (error) {
428
+ if (error?.name === 'EditRejected')
429
+ error.status = 400;
430
+ res.writeHead(error.status ?? 500, { 'content-type': 'application/json', 'cache-control': 'no-store' });
431
+ res.end(JSON.stringify({ ok: false, error: String(error.message) }));
432
+ }
433
+ return true;
434
+ },
435
+ };
436
+ }
@@ -39,6 +39,11 @@ export interface ViewerOptions {
39
39
  * Default: "in the workspace" (or the folder that holds it, when the workspace was given another inputs folder).
40
40
  */
41
41
  inputsWhere?: string;
42
+ /**
43
+ * Called after the page sets an export target in a graph: `topotopo view` writes it back into the graph file, which
44
+ * is the only source there (an MCP workspace stores the graph itself).
45
+ */
46
+ persistGraph?: (graph: string) => void;
42
47
  }
43
48
  export declare function startViewer(ws: Workspace, port?: number, opts?: ViewerOptions): Promise<{
44
49
  url: string;
@@ -9,6 +9,7 @@
9
9
  import http from 'node:http';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
+ import zlib from 'node:zlib';
12
13
  import { Transform } from 'node:stream';
13
14
  import { pipeline } from 'node:stream/promises';
14
15
  import { fileURLToPath } from 'node:url';
@@ -18,6 +19,10 @@ import { summarize } from '../inspect.js';
18
19
  import { withHeightfieldPreview } from '../core/heightfield.js';
19
20
  import { withImagePreview } from '../render/texview.js';
20
21
  import { wiringOf } from '../graph/engine.js';
22
+ import { createOutputs } from './outputs.js';
23
+ import { renderGeometry } from '../render/raster.js';
24
+ import { encodePNG } from '../render/png.js';
25
+ import { imageFromPNGFile } from '../io/textures.js';
21
26
  // the page's guide rules, shared so the page and this server check a marker list the same way
22
27
  import { MARKER_KINDS as KINDS, MarkerError, parseMarkers as parseMarkerList } from './app/markers.js';
23
28
  // index.html and app/ sit next to this module (the build copies them to dist/viewer/)
@@ -146,6 +151,7 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
146
151
  c.write(msg);
147
152
  };
148
153
  ws.listeners.add(broadcast);
154
+ const outputs = createOutputs(ws, () => broadcast({ outputs: true }), { persistGraph: opts.persistGraph });
149
155
  // ---------------------------------------------------------------- inputs/
150
156
  let inputsVersion = 0;
151
157
  const inputsChanged = () => broadcast({ inputs: ++inputsVersion });
@@ -195,8 +201,13 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
195
201
  throw new HttpError(400, `input path ${JSON.stringify(raw)} leaves inputs/`);
196
202
  if (!fs.statSync(abs, { throwIfNoEntry: false })?.isFile())
197
203
  throw new HttpError(404, `no input "${rel}" (GET /api/inputs lists them)`);
204
+ const realRelative = path.relative(fs.realpathSync(root), fs.realpathSync(abs));
205
+ if (realRelative.startsWith('..') || path.isAbsolute(realRelative))
206
+ throw new HttpError(400, 'input path leaves inputs/');
198
207
  return { rel, abs };
199
208
  };
209
+ const inputThumbs = new Map();
210
+ const imageType = (p) => ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' })[path.extname(p).toLowerCase()];
200
211
  /** The one-node graph that loads an input. */
201
212
  const inputGraph = (rel, abs) => {
202
213
  // "inputs/<rel>", the path a graph's file node uses, resolves against the host's base directory: the folder that
@@ -376,6 +387,8 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
376
387
  return json(403, { error: `cross-origin ${req.method} refused (${origin})` });
377
388
  const u = new URL(req.url ?? '/', 'http://localhost');
378
389
  route = u.pathname;
390
+ if (await outputs.handle(req, res, u, readJSON))
391
+ return;
379
392
  if (u.pathname === '/' || u.pathname === '/index.html')
380
393
  return send(200, fs.readFileSync(path.join(here, 'index.html')), MIME['.html']);
381
394
  // the page's modules and style sheet
@@ -455,8 +468,42 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
455
468
  return json(405, { ok: false, error: 'GET, or POST {"markers": [...]}' });
456
469
  return json(200, loadMarkers());
457
470
  }
471
+ if (u.pathname === '/api/input_image' || u.pathname === '/api/input_thumb') {
472
+ if (req.method !== 'GET')
473
+ throw new HttpError(405, 'GET required');
474
+ const { rel, abs } = inputPath(u.searchParams.get('path')), mime = imageType(abs);
475
+ if (mime)
476
+ return send(200, fs.readFileSync(abs), mime);
477
+ if (u.pathname === '/api/input_image')
478
+ throw new HttpError(400, 'choose a PNG, JPEG or WebP image');
479
+ const stat = fs.statSync(abs), key = `${abs}:${stat.size}:${stat.mtimeMs}`;
480
+ if (!inputThumbs.has(key)) {
481
+ if (inputThumbs.size >= 128)
482
+ inputThumbs.delete(inputThumbs.keys().next().value);
483
+ const pending = (async () => {
484
+ await new Promise((resolve) => setImmediate(resolve));
485
+ const r = await ws.engine.cook(inputGraph(rel, abs));
486
+ if (!r.ok || !r.geo)
487
+ throw new HttpError(422, r.errors.map(issueText).join('\n'));
488
+ const image = renderGeometry(withHeightfieldPreview(r.geo), { size: 160, views: ['persp'], viewLabels: false });
489
+ return encodePNG(image.rgba.subarray(0, image.width * 160 * 4), image.width, 160, (d) => zlib.deflateSync(d));
490
+ })();
491
+ inputThumbs.set(key, pending);
492
+ }
493
+ return send(200, await inputThumbs.get(key), 'image/png');
494
+ }
458
495
  if (u.pathname === '/api/input' || u.pathname === '/api/input_info') {
459
496
  const { rel, abs } = inputPath(u.searchParams.get('path'));
497
+ if (u.pathname === '/api/input_info' && imageType(abs)) {
498
+ let summary = `${imageType(abs)} · ${fs.statSync(abs).size} bytes`;
499
+ // Preserve the existing PNG summary contract without cooking a file-node graph.
500
+ if (path.extname(abs).toLowerCase() === '.png') {
501
+ const geo = new GeoBuilder().build();
502
+ geo.volumes.push(imageFromPNGFile(fs.readFileSync(abs), abs));
503
+ summary = summarize(geo);
504
+ }
505
+ return json(200, { path: rel, summary, image: true });
506
+ }
460
507
  const r = await ws.engine.cook(inputGraph(rel, abs));
461
508
  if (!r.ok || !r.geo)
462
509
  return json(500, { error: r.errors.map(issueText).join('\n') });
@@ -512,6 +559,7 @@ export async function startViewer(ws, port = Number(process.env.TOPOTOPO_PORT ??
512
559
  url: `http://127.0.0.1:${actual}/`,
513
560
  close: () => {
514
561
  closed = true;
562
+ outputs.close();
515
563
  clearInterval(keepAlive);
516
564
  clearTimeout(watchTimer);
517
565
  unwatch();
@@ -1,9 +1,12 @@
1
1
  import { Engine } from './graph/engine.ts';
2
2
  import type { CookReport, Issue } from './graph/engine.ts';
3
- import type { GraphSpec, NodeSpec } from './graph/types.ts';
3
+ import type { ExportTarget, GraphSpec, NodeSpec } from './graph/types.ts';
4
4
  export interface EditOp {
5
- op: 'set' | 'add' | 'remove' | 'connect' | 'rename' | 'output' | 'vars' | 'bypass' | 'replace' | 'insert';
5
+ op: 'set' | 'add' | 'remove' | 'connect' | 'rename' | 'output' | 'vars' | 'bypass' | 'replace' | 'insert' | 'export';
6
6
  node?: string;
7
+ /** op export: the named output (omitted: the default output) and its target (null removes it) */
8
+ output?: string;
9
+ export?: ExportTarget | null;
7
10
  params?: Record<string, any>;
8
11
  spec?: NodeSpec;
9
12
  type?: string;
@@ -38,6 +41,8 @@ export interface WorkspaceOptions {
38
41
  * graph that never gets a drop leaves no empty inputs/ beside it.
39
42
  */
40
43
  inputs?: string;
44
+ /** Persistent exports directory; view mode places it beside the source graph. */
45
+ exports?: string;
41
46
  }
42
47
  /** Thrown by Workspace.edit when the edited graph does not validate; the stored graph is unchanged. */
43
48
  export declare class EditRejected extends Error {
@@ -56,6 +61,7 @@ export declare class Workspace {
56
61
  }) => void>;
57
62
  /** the inputs folder given to the constructor (WorkspaceOptions.inputs), if any */
58
63
  private inputsAt;
64
+ private exportsAt;
59
65
  constructor(dir: string, engine: Engine, opts?: WorkspaceOptions);
60
66
  get graphDir(): string;
61
67
  get assetDir(): string;