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.
@@ -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[];
package/dist/mcp/tools.js CHANGED
@@ -12,7 +12,9 @@ import { renderGeometry } from '../render/raster.js';
12
12
  import { renderArgs } from '../render/options.js';
13
13
  import { encodePNG } from '../render/png.js';
14
14
  import { EXPORT_FORMATS, exportFormatFromPath } from '../io/export.js';
15
- import { exportFilesAsync } from '../io/files.js';
15
+ import { publishedOutputs } from '../io/targets.js';
16
+ import { ArtifactStore } from '../host/artifacts.js';
17
+ import { prepareTarget, serializeExport, writeExportFiles, writeTargets } from '../host/export-targets.js';
16
18
  import { evalGroup } from '../core/groups.js';
17
19
  import { Geometry } from '../core/geometry.js';
18
20
  import { verifyTools } from './verify.js';
@@ -65,30 +67,19 @@ function pngContent(g, o) {
65
67
  * Landscape bundle, or the single file of other formats). Textures are read through the host; USD texture paths are
66
68
  * written relative to the output file. Returns the report text.
67
69
  */
68
- export async function writeExport(geo, format, p, a, host) {
69
- const warnings = [];
70
- const outDir = format === 'unreal_landscape' && !/\.json$/i.test(p) ? p : path.dirname(p);
71
- const res = await exportFilesAsync(geo, format, p, {
72
- upAxis: a.up_axis, scale: a.scale, splitByName: a.split_by_name ?? true, custom: a.custom_attribs,
73
- materials: a.materials, compress: a.compress, quantize: a.quantize, attributes: a.attributes, pcg: a.pcg || a.pcg_struct, pcgStruct: a.pcg_struct,
74
- precision: a.precision, instancing: a.instancing, instances: a.instances, meshes: a.meshes,
75
- layer: a.layer, layers: a.layers, heightMin: a.height_min, heightMax: a.height_max, heightfield: a.heightfield, tile_size: a.tile_size,
76
- deflate: (d) => zlib.deflateSync(d, { level: 6 }),
77
- readFile: (t) => host.readFile(t),
78
- texturePath: (t) => {
79
- const abs = host.resolvePath(t);
80
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(abs) ? abs : path.relative(outDir, abs).split(path.sep).join('/');
81
- },
82
- warn: (m) => warnings.push(m),
83
- });
84
- for (const f of res.files) {
85
- fs.mkdirSync(path.dirname(f.path), { recursive: true });
86
- fs.writeFileSync(f.path, f.bytes);
87
- }
88
- const total = res.files.reduce((s, f) => s + f.bytes.byteLength, 0);
70
+ export async function writeExport(geo, format, p, a, host, record) {
71
+ const res = await serializeExport(geo, format, p, a, host);
72
+ writeExportFiles(res.files);
73
+ if (res.files.length)
74
+ record?.(res.files.map((f) => f.path));
75
+ return exportReport(res.files, res.outDir, format, res.summary, res.warnings);
76
+ }
77
+ /** "wrote <file or N files in dir> (format, KB): summary", then any warnings. */
78
+ function exportReport(files, outDir, format, summary, warnings) {
79
+ const total = files.reduce((s, f) => s + f.bytes.byteLength, 0);
89
80
  const rel = (f) => path.relative(outDir, f).split(path.sep).join('/');
90
- const list = res.files.length === 1 ? res.files[0].path : `${res.files.length} files in ${outDir}: ${res.files.slice(0, 12).map((f) => rel(f.path)).join(', ')}${res.files.length > 12 ? ', …' : ''}`;
91
- return `wrote ${list} (${format}, ${(total / 1024).toFixed(1)} KB): ${res.summary}${warnings.length ? '\nwarnings:\n ' + [...new Set(warnings)].join('\n ') : ''}`;
81
+ const list = files.length === 1 ? files[0].path : `${files.length} files in ${outDir}: ${files.slice(0, 12).map((f) => rel(f.path)).join(', ')}${files.length > 12 ? ', …' : ''}`;
82
+ return `wrote ${list} (${format}, ${(total / 1024).toFixed(1)} KB): ${summary}${warnings.length ? '\nwarnings:\n ' + warnings.join('\n ') : ''}`;
92
83
  }
93
84
  function renderOpts(a, g, label) {
94
85
  const ro = renderArgs(a); // views (presets, persp@az, section), overlays, shading, labels … (src/render/options.ts)
@@ -148,13 +139,20 @@ const RENDER_PROPS = {
148
139
  texture: { type: 'string', description: 'adds the "texture" view and picks what it shows: an image name, "image/layer", or a material name (its maps as a labelled strip); "" = every image. A geometry holding only images renders as the texture view by default' },
149
140
  };
150
141
  export function makeTools(ws, opts = {}) {
142
+ // One file per graph node, overwritten by each render: agents render after most edits, so a new file per render
143
+ // would pile up in exports/renders (save_to keeps a copy elsewhere).
144
+ const renderDestination = (name, node) => path.join(ws.exportDir, 'renders', `${name}-${node}`.replace(/[^A-Za-z0-9_.-]/g, '_') + '.png');
151
145
  const engine = ws.engine;
152
146
  /** The text (+ optional render) of a cook report. */
153
147
  const reportResult = (name, r, a, extraNotes = []) => {
154
148
  const content = [{ type: 'text', text: reportText(name, r, extraNotes) }];
155
149
  if (r.geo && a.render) {
156
150
  const ro = renderOpts(typeof a.render === 'object' ? a.render : {}, r.geo, `${name}/${r.target}${atFrame(r)}`);
157
- const { img, notes } = pngContent(r.geo, ro);
151
+ const { img, notes, png } = pngContent(r.geo, ro);
152
+ const renderPath = renderDestination(name, r.target);
153
+ fs.mkdirSync(path.dirname(renderPath), { recursive: true });
154
+ fs.writeFileSync(renderPath, png);
155
+ new ArtifactStore(ws.exportDir).record([renderPath], { graph: name, node: r.target, format: 'png' });
158
156
  content.push(img);
159
157
  if (notes.length)
160
158
  content.push({ type: 'text', text: 'render: ' + notes.join('; ') });
@@ -175,6 +173,35 @@ export function makeTools(ws, opts = {}) {
175
173
  throw new Error(reportText(name, r));
176
174
  return { r, name, geo: r.geo };
177
175
  };
176
+ /** export without a path: the graph's targets (the default output's, a named one's, or all), inside exports/ */
177
+ const exportTargets = async (a) => {
178
+ const name = a.graph ?? a.name ?? 'main', spec = ws.getGraph(name);
179
+ const published = publishedOutputs(name, spec, engine.findOutput(spec).name);
180
+ const hint = (o) => `edit {ops:[{op:"export"${o === undefined ? '' : `, output:"${o}"`}, export:"glb"}]}`;
181
+ let chosen = published;
182
+ if (!a.all) {
183
+ const p = a.output === undefined ? published[0] : published.find((x) => x.output === a.output);
184
+ if (!p)
185
+ return text(`graph "${name}" publishes no output "${a.output}" (outputs: ${published.slice(1).map((x) => x.output).join(', ') || 'none'})`, true);
186
+ if (!p.target)
187
+ return text(`${a.output === undefined ? 'the default output' : `output "${a.output}"`} of "${name}" has no export target: set one with ${hint(a.output)}, or pass path`, true);
188
+ chosen = [p];
189
+ }
190
+ const targets = chosen.filter((p) => p.target);
191
+ if (!targets.length)
192
+ return text(`no output of "${name}" has an export target: set one with ${hint(undefined)}, or pass path`, true);
193
+ const prepared = [];
194
+ for (const p of targets) {
195
+ const r = await ws.cook(name, p.node, a.frame);
196
+ if (!r.ok || !r.geo)
197
+ return text(reportText(name, r), true);
198
+ prepared.push(await prepareTarget(r.geo, name, p, ws.exportDir, engine.host));
199
+ }
200
+ writeTargets(prepared, ws.exportDir);
201
+ const skipped = chosen.filter((p) => !p.target).map((p) => p.label);
202
+ return text(prepared.map((p) => exportReport(p.files, ws.exportDir, p.target.format, `${p.label}: ${p.summary}`, p.warnings)).join('\n')
203
+ + (skipped.length ? `\nno export target (skipped): ${skipped.join(', ')}` : ''));
204
+ };
178
205
  const tools = [
179
206
  {
180
207
  name: 'catalog',
@@ -228,7 +255,7 @@ export function makeTools(ws, opts = {}) {
228
255
  type: 'object',
229
256
  required: ['graph'],
230
257
  properties: {
231
- graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}, "params": {var: {"type", "desc", "min", "max", "options", "label", "step"}}}' },
258
+ graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "outputs": {id: {"node": name, "label": "Published result", "export": "glb"}}, "export": "glb", "vars": {...}, "params": {var: {"type", "desc", "min", "max", "options", "label", "step"}}}. Publish intended deliverables in outputs; output remains the default. "export" (on the graph for the default output, or on a named output) is where it exports: a format, or {format, path?, ...export options}, written inside exports/ by the viewer\'s Export buttons and export {output} / {all:true}.' },
232
259
  name: { type: 'string', description: 'graph name (default "main")' },
233
260
  mode: { type: 'string', enum: ['replace', 'merge'] },
234
261
  dry_run: { type: 'boolean' },
@@ -259,7 +286,7 @@ export function makeTools(ws, opts = {}) {
259
286
  },
260
287
  {
261
288
  name: 'edit',
262
- description: 'Incrementally edit a graph, then re-cook (only affected nodes recompute). ops: {op:"set",node,params} (null removes a param) · {op:"add",node,type,in,params} · {op:"insert",node,type,params,after} (splice after a node) · {op:"remove",node} (rewires consumers) · {op:"connect",node,in:[...]} or {op:"connect",node,input:1,from:"x"} · {op:"rename",node,to} · {op:"replace",node,type?,params?,in?} · {op:"output",node} · {op:"vars",vars:{k:v}} · {op:"bypass",node,value}.',
289
+ description: 'Incrementally edit a graph, then re-cook (only affected nodes recompute). ops: {op:"set",node,params} (null removes a param) · {op:"add",node,type,in,params} · {op:"insert",node,type,params,after} (splice after a node) · {op:"remove",node} (rewires consumers) · {op:"connect",node,in:[...]} or {op:"connect",node,input:1,from:"x"} · {op:"rename",node,to} · {op:"replace",node,type?,params?,in?} · {op:"output",node} · {op:"vars",vars:{k:v}} · {op:"bypass",node,value} · {op:"export",output?,export} (the export target of a named output, or of the default output without "output": a format "glb" or {format, path?, ...export options}; null removes it).',
263
290
  inputSchema: {
264
291
  type: 'object',
265
292
  required: ['ops'],
@@ -325,10 +352,11 @@ export function makeTools(ws, opts = {}) {
325
352
  const { img, notes, png } = pngContent(geo, renderOpts(a, geo, `${name}/${r.target}${atFrame(r)}`));
326
353
  const content = [img];
327
354
  const info = [];
328
- if (a.save_to) {
329
- const p = path.resolve(ws.dir, a.save_to);
355
+ {
356
+ const p = a.save_to ? path.resolve(ws.dir, a.save_to) : renderDestination(name, r.target);
330
357
  fs.mkdirSync(path.dirname(p), { recursive: true });
331
358
  fs.writeFileSync(p, png);
359
+ new ArtifactStore(ws.exportDir).record([p], { graph: name, node: r.target, format: 'png' });
332
360
  info.push(`saved ${p}`);
333
361
  }
334
362
  if (notes.length)
@@ -343,10 +371,11 @@ export function makeTools(ws, opts = {}) {
343
371
  description: 'Write a node\'s output to a file: .glb/.gltf (glTF via gltf-transform: materials + texture maps, vertex colours, uv, custom attributes, EXT_mesh_gpu_instancing with per-instance attributes, meshopt compression; best for Unreal/Blender/web), .obj, .usda (PointInstancer with per-instance primvars, UsdPreviewSurface materials), .ply, .stl, .geo (Houdini JSON geometry, every attribute and group, packed prims as PackedGeometry), .csv (point stream; pcg:true for Unreal PCG columns; packed instances = one row per instance + Mesh column + .meshes.json sidecar + one GLB per source), .points.json, .json (lossless topotopo). Relative paths go into the workspace. help {topic:"export"} for details. Heightfields: png16 (.png, 16-bit Unreal Landscape heightmap) or r16 (.r16/.raw) plus a .json sidecar with the height range and the Unreal scale/location, mask_png (8-bit weight layers; param layers), unreal_landscape (a directory: heightmap, every layer, manifest with the exact Landscape import settings, objects.glb and the instance stream). Textures: glb/gltf embed the image textures of materials (texture_to_material) as PNG, usda writes them to <name>_textures/ next to the file, format textures writes every image as <path>/<image>.png (layers: extra layers, or ["*"]).',
344
372
  inputSchema: {
345
373
  type: 'object',
346
- required: ['path'],
347
374
  properties: {
348
375
  graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
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)' },
376
+ path: { type: 'string', description: 'e.g. "exports/rock.glb", "exports/trees.csv", "exports/pts.points.json", "exports/terrain.png" (heightmap), "exports/level" (unreal_landscape: a directory), "exports/maps" (textures: a directory). Without path: the graph\'s export targets (output / all), written inside exports/' },
377
+ output: { type: 'string', description: 'without path: export this named output (graph.outputs) to its target; omitted = the default output (graph.export)' },
378
+ all: { type: 'boolean', description: 'without path: export every output of the graph that has an export target' },
350
379
  format: { type: 'string', enum: EXPORT_FORMATS, description: 'default: from the extension (.points.json = pointsjson)' },
351
380
  layer: { type: 'string', description: 'heightmaps: layer to write (png16/r16 default "height", mask_png default "mask")' },
352
381
  layers: { type: 'array', items: { type: 'string' }, description: 'mask_png: layers to write, one PNG each (name_<layer>.png); textures: extra image layers to write as <image>_<layer>.png ("*" = all)' },
@@ -372,13 +401,15 @@ export function makeTools(ws, opts = {}) {
372
401
  },
373
402
  },
374
403
  run: async (a) => {
404
+ if (a.path === undefined)
405
+ return exportTargets(a);
375
406
  const { geo, name, r } = await resolveNodeGeo(a);
376
407
  const p = path.resolve(ws.dir, a.path);
377
408
  const fmt0 = String(a.format ?? exportFormatFromPath(p)).toLowerCase();
378
409
  const ext = ({ png: 'png16', raw: 'r16' }[fmt0] ?? fmt0);
379
410
  if (!EXPORT_FORMATS.includes(ext))
380
411
  throw new Error(`unknown format "${ext}"; use ${EXPORT_FORMATS.map((f) => (f === 'pointsjson' ? '.points.json' : '.' + f)).join(' ')}`);
381
- const report = await writeExport(geo, ext, p, a, engine.host);
412
+ const report = await writeExport(geo, ext, p, a, engine.host, (files) => new ArtifactStore(ws.exportDir).record(files, { graph: name, node: r.target, format: ext }));
382
413
  return text(`${report}\n(from ${name}/${r.target}${atFrame(r)}: ${geo.numPoints} points, ${geo.numPrims} prims)`);
383
414
  },
384
415
  },
@@ -501,10 +532,32 @@ export function makeTools(ws, opts = {}) {
501
532
  if (opts.onPreview) {
502
533
  tools.push({
503
534
  name: 'preview',
504
- description: 'Start (or get the URL of) the live 3D preview web page for humans: orbit view of any graph/node that auto-refreshes after every build/edit. Returns the URL to open in a browser.',
535
+ description: 'Start (or get the URL of) the live viewer for humans: an editor viewport of the workspace (graph menu, an outliner with the graph\'s nodes, single and quad views, the graph\'s params as Controls, input layers and guides) that updates after every build/edit. Returns the URL to open in a browser.',
505
536
  inputSchema: { type: 'object', properties: {} },
506
- run: async () => text(`live preview: ${await opts.onPreview()} (updates automatically on every build/edit; click nodes in the sidebar to view intermediate results)`),
537
+ run: async () => text(`live preview: ${await opts.onPreview()} (updates automatically on every build/edit; click a node in the outliner to see its result)`),
507
538
  });
508
539
  }
540
+ // A person can add files to inputs/ at any time (the viewer's Add input, a drop, the folder). The first answer says
541
+ // what is there, later answers what is new or changed since the last one; the inputs tool's own answer counts.
542
+ let heard = null;
543
+ const inputsNote = (tool) => {
544
+ const files = ws.listInputs(), now = new Map(files.map((f) => [f.path, `${f.size}:${f.mtime}`]));
545
+ const first = heard === null, fresh = files.filter((f) => heard?.get(f.path) !== now.get(f.path));
546
+ heard = now;
547
+ if (tool === 'inputs' || !fresh.length)
548
+ return '';
549
+ const shown = fresh.slice(0, 8).map((f) => `inputs/${f.path} (${f.kind}, ${(f.size / 1024).toFixed(0)} KB)`).join(', ') + (fresh.length > 8 ? `, and ${fresh.length - 8} more` : '');
550
+ return `${first ? `inputs/ holds ${files.length} file(s) from the person` : 'new or changed in inputs/ since your last call'}: ${shown}. inputs {action:"describe", path} tells what one holds.`;
551
+ };
552
+ for (const t of tools) {
553
+ const run = t.run;
554
+ t.run = async (args) => {
555
+ const result = await run(args);
556
+ const note = inputsNote(t.name);
557
+ if (note)
558
+ result.content.push({ type: 'text', text: note });
559
+ return result;
560
+ };
561
+ }
509
562
  return tools;
510
563
  }
@@ -83,8 +83,9 @@ defineNode({
83
83
  function scriptLib(ctx, wake, run) {
84
84
  const engine = ctx.engine;
85
85
  // compiled in the script realm: a synchronous node's result resolves a realm promise, so `await lib.run(...)`
86
- // continues inside the same time-limited call; only async nodes hand back an outside promise (then: wake)
87
- const realmAsync = engine.host.compile('(function(f){ return async function run(...a) { return f(...a); }; })');
86
+ // continues inside the same time-limited call; an async node hands back its outcome (see run below), which the
87
+ // realm awaits and turns back into the node's value or error
88
+ const realmAsync = engine.host.compile('(function(f){ return async function run(...a) { const r = f(...a); if (r && r.libRunOutcome) { const o = await r.libRunOutcome; if (o.failed) throw o.error; return o.value; } return r; }; })');
88
89
  return {
89
90
  Geometry, GeoBuilder, math, topology, random,
90
91
  noise: { makeNoise, fractal, makeCurl },
@@ -134,10 +135,35 @@ function scriptLib(ctx, wake, run) {
134
135
  node: `${ctx.node}/${type}`, type: def.type, p: rp.values, inputs,
135
136
  input: (i) => inputs[i] ?? new Geometry(), hasInput: (i) => !!inputs[i], inputSigs: inputs.map(() => 'script'),
136
137
  });
137
- const r = def.cook(sub);
138
- if (r && typeof r.then === 'function')
139
- r.then(wake, wake);
140
- return r;
138
+ // The script's time limit can stop it anywhere, also between starting an async node and listening to it: that
139
+ // node's promise would then reject later with nobody listening, which ends the process (MCP server included).
140
+ // So an async node's promise is awaited right inside `outcome`, with no call in between that the limit could
141
+ // stop, and `outcome` never rejects. A synchronous node returns (or throws) here, as before.
142
+ let started, thrownNow = null;
143
+ const isAsync = () => !!started && typeof started.then === 'function';
144
+ const outcome = (async () => {
145
+ try {
146
+ started = def.cook(sub);
147
+ // property reads only from here to the await: a function call is where the limit could stop the script
148
+ if (!started || typeof started.then !== 'function')
149
+ return null;
150
+ return { value: await started };
151
+ }
152
+ catch (error) {
153
+ if (!isAsync()) {
154
+ thrownNow = { error };
155
+ return null;
156
+ }
157
+ return { failed: true, error };
158
+ }
159
+ finally {
160
+ if (isAsync())
161
+ wake();
162
+ }
163
+ })();
164
+ if (thrownNow)
165
+ throw thrownNow.error;
166
+ return isAsync() ? { libRunOutcome: outcome } : started;
141
167
  }),
142
168
  warn: (m) => ctx.warn(String(m)),
143
169
  };
@@ -37,6 +37,7 @@ class Cam {
37
37
  this.name = name;
38
38
  this.ortho = name !== 'persp';
39
39
  this.target = new THREE.Vector3();
40
+ this.pivot = new THREE.Vector3();
40
41
  this.quat = name === 'persp' ? perspQuat() : quatOf(AXIS_VIEWS[name].f, AXIS_VIEWS[name].u);
41
42
  this.dist = 12;
42
43
  this.halfH = 6;
@@ -45,12 +46,14 @@ class Cam {
45
46
  get right() { return new THREE.Vector3(1, 0, 0).applyQuaternion(this.quat); }
46
47
  get up() { return new THREE.Vector3(0, 1, 0).applyQuaternion(this.quat); }
47
48
  get eye() { return this.target.clone().addScaledVector(this.back, this.dist); }
48
- toJSON() { return { ortho: this.ortho, target: this.target.toArray(), quat: this.quat.toArray(), dist: this.dist, halfH: this.halfH }; }
49
+ toJSON() { return { ortho: this.ortho, target: this.target.toArray(), pivot: this.pivot.toArray(), quat: this.quat.toArray(), dist: this.dist, halfH: this.halfH }; }
49
50
  load(j) {
50
51
  if (!j) return;
51
52
  const ok = (a, n) => Array.isArray(a) && a.length === n && a.every(Number.isFinite);
52
53
  if (typeof j.ortho === 'boolean') this.ortho = j.ortho;
53
54
  if (ok(j.target, 3)) this.target.fromArray(j.target);
55
+ this.pivot.copy(this.target);
56
+ if (ok(j.pivot, 3)) this.pivot.fromArray(j.pivot);
54
57
  if (ok(j.quat, 4)) this.quat.fromArray(j.quat).normalize();
55
58
  if (j.dist > 0) this.dist = Math.min(MAX_D, Math.max(MIN_D, j.dist));
56
59
  if (j.halfH > 0) this.halfH = Math.min(MAX_D, Math.max(MIN_D, j.halfH));
@@ -126,10 +129,10 @@ export function createViews(scene) {
126
129
  return { x: r.x + ((q.x + 1) / 2) * r.w, y: r.y + ((1 - q.y) / 2) * r.h };
127
130
  },
128
131
  /** The point under a viewport point on the plane through the slot's target facing the camera. */
129
- targetPlanePoint(i, x, y) {
132
+ targetPlanePoint(i, x, y, at) {
130
133
  const c = v.cam(i), rc = v.ray(i, x, y), n = c.back;
131
134
  const p = new THREE.Vector3();
132
- return rc.ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(n, c.target), p) ? p : c.target.clone();
135
+ return rc.ray.intersectPlane(new THREE.Plane().setFromNormalAndCoplanarPoint(n, at ?? c.target), p) ? p : (at ?? c.target).clone();
133
136
  },
134
137
 
135
138
  // ---------------------------------------------------------------- navigation
@@ -149,11 +152,26 @@ export function createViews(scene) {
149
152
  return true;
150
153
  },
151
154
  /** Track: the point at the target's depth follows the mouse. */
152
- track(i, dx, dy) {
153
- const c = v.cam(i), k = v.worldPerPixel(i);
154
- c.target.addScaledVector(c.right, -dx * k).addScaledVector(c.up, dy * k);
155
+ track(i, dx, dy, at) {
156
+ const c = v.cam(i);
157
+ const depth = at && !c.ortho ? Math.max(MIN_D, c.eye.clone().sub(at).dot(c.back)) : c.dist;
158
+ const k = v.worldPerPixel(i) * (c.ortho ? 1 : depth / c.dist);
159
+ const delta = c.right.multiplyScalar(-dx * k).addScaledVector(c.up, dy * k);
160
+ c.target.add(delta); c.pivot.add(delta);
155
161
  v.placed = true;
156
162
  },
163
+ /** Pick a navigation pivot without changing the camera's eye, aim or distance. */
164
+ anchor(i, hit) {
165
+ const c = v.cam(i);
166
+ if (hit && hit.toArray().every(Number.isFinite) && c.eye.clone().sub(hit).dot(c.back) > MIN_D) c.pivot.copy(hit);
167
+ return c.pivot.clone();
168
+ },
169
+ zoomAt(i, factor, at) {
170
+ const c = v.cam(i), before = c.ortho ? c.halfH : c.dist;
171
+ v.dolly(i, factor, at);
172
+ const scale = (c.ortho ? c.halfH : c.dist) / before;
173
+ c.pivot.sub(at).multiplyScalar(scale).add(at);
174
+ },
157
175
  /** Dolly by factor s (< 1 closer) towards `at` (a world point on the target plane; default the target). */
158
176
  dolly(i, s, at) {
159
177
  const c = v.cam(i);
@@ -171,6 +189,7 @@ export function createViews(scene) {
171
189
  const center = box.getCenter(new THREE.Vector3()), size = box.getSize(new THREE.Vector3());
172
190
  const radius = Math.max(size.length() / 2, 1e-3);
173
191
  c.target.copy(center);
192
+ c.pivot.copy(center);
174
193
  const half = (vfov(aspect) * Math.PI) / 360, halfW = Math.atan(Math.tan(half) * aspect);
175
194
  c.dist = Math.min(MAX_D, Math.max(MIN_D, (radius / Math.sin(Math.min(half, halfW))) * 1.05));
176
195
  // ortho: the box's extent across the view
@@ -201,6 +220,7 @@ export function createViews(scene) {
201
220
  // the new view looks at the same place at the same scale
202
221
  const n = cams.get(name);
203
222
  n.target.copy(c.target); n.halfH = c.ortho ? c.halfH : c.dist * 0.3;
223
+ n.pivot.copy(c.pivot);
204
224
  slots[i].cam = name;
205
225
  }
206
226
  },
@@ -0,0 +1,68 @@
1
+ // The export target popover: a format and a file name inside exports/, opened by an output's Export button when it
2
+ // has no target yet, or to change it. Submitting stores the target in the graph and exports.
3
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
4
+
5
+ /** A file name stem like the server's (io/targets.ts fileStem). */
6
+ export const fileStem = (s) => String(s).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^\.+|[. ]+$/g, '') || 'output';
7
+
8
+ /** The path a format writes to by default: <stem><extension>, a folder for formats without one. */
9
+ export function defaultPath(stem, format, formats) {
10
+ return fileStem(stem) + (formats.find((f) => f.format === format)?.extension ?? '');
11
+ }
12
+
13
+ /** The path with its extension swapped from one format's to another's (a custom name keeps its stem). */
14
+ export function swapExtension(p, from, to, formats) {
15
+ const ext = (f) => formats.find((x) => x.format === f)?.extension ?? '';
16
+ const a = ext(from), b = ext(to);
17
+ const stem = a && p.toLowerCase().endsWith(a.toLowerCase()) ? p.slice(0, -a.length) : p;
18
+ return stem + b;
19
+ }
20
+
21
+ /**
22
+ * Open the popover under `anchor`. item: an output entry (label, kind, graph, output, target); formats: [{format, label,
23
+ * extension}] from /api/outputs; directory: the exports folder. Resolves to {format, path} or null (cancelled).
24
+ */
25
+ export function exportPopover({ anchor, item, formats, directory }) {
26
+ document.querySelector('.export-popover')?.dispatchEvent(new Event('cancel'));
27
+ return new Promise((resolve) => {
28
+ const stem = item.output ?? item.graph ?? 'output';
29
+ const startFormat = item.target?.format ?? (item.kind === 'image' ? 'textures' : 'glb');
30
+ const box = el('form', 'export-popover');
31
+ box.setAttribute('role', 'dialog'); box.setAttribute('aria-label', `Export ${item.label}`);
32
+ const title = el('div', 'export-title', item.target ? `Export target of ${item.label}` : `Export ${item.label}`);
33
+ const format = el('select'); format.setAttribute('aria-label', 'Format');
34
+ for (const f of formats) format.append(new Option(f.label, f.format));
35
+ format.value = startFormat;
36
+ const file = el('input'); file.type = 'text'; file.spellcheck = false; file.setAttribute('aria-label', 'File in exports/');
37
+ file.value = item.target?.path ?? defaultPath(stem, startFormat, formats);
38
+ const where = el('div', 'export-where dim');
39
+ const error = el('div', 'export-error err');
40
+ const cancel = el('button', '', 'Cancel'), go = el('button', 'primary', 'Export');
41
+ cancel.type = 'button'; go.type = 'submit';
42
+ const field = (label, input) => { const l = el('label', 'export-field'); l.append(el('span', 'dim', label), input); return l; };
43
+ const actions = el('div', 'export-actions'); actions.append(cancel, go);
44
+ box.append(title, field('Format', format), field('File', file), where, error, actions);
45
+ let previous = format.value;
46
+ const sync = () => { where.textContent = `${directory}/${file.value.trim() || '…'}`; error.textContent = ''; };
47
+ format.onchange = () => { file.value = swapExtension(file.value.trim(), previous, format.value, formats); previous = format.value; sync(); };
48
+ file.oninput = sync;
49
+ const close = (value) => { box.remove(); removeEventListener('pointerdown', outside, true); anchor?.focus?.(); resolve(value); };
50
+ const outside = (e) => { if (!box.contains(e.target)) close(null); };
51
+ box.addEventListener('cancel', () => close(null));
52
+ cancel.onclick = () => close(null);
53
+ box.onkeydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(null); } };
54
+ box.onsubmit = (e) => {
55
+ e.preventDefault();
56
+ const p = file.value.trim().replace(/\\/g, '/');
57
+ if (!p || p.startsWith('/') || /^[A-Za-z]:/.test(p) || p.split('/').some((s) => !s || s === '.' || s === '..')) { error.textContent = 'a path inside exports/, without ".." or a drive'; file.focus(); return; }
58
+ close({ format: format.value, path: p });
59
+ };
60
+ sync();
61
+ document.body.append(box);
62
+ const r = anchor.getBoundingClientRect();
63
+ box.style.left = Math.max(6, Math.min(r.left, innerWidth - box.offsetWidth - 6)) + 'px';
64
+ box.style.top = (r.bottom + 4 + box.offsetHeight < innerHeight ? r.bottom + 4 : Math.max(6, r.top - box.offsetHeight - 4)) + 'px';
65
+ addEventListener('pointerdown', outside, true);
66
+ format.focus();
67
+ });
68
+ }
@@ -0,0 +1,114 @@
1
+ // A 2D image inspector, independent of the 3D camera. View state is remembered per output.
2
+ export function channelPixels(source, mode) {
3
+ const pixels = new Uint8ClampedArray(source);
4
+ const c = ['R', 'G', 'B', 'A'].indexOf(mode);
5
+ for (let i = 0; i < pixels.length; i += 4) {
6
+ if (c >= 0) pixels[i] = pixels[i + 1] = pixels[i + 2] = source[i + c];
7
+ if (mode !== 'RGBA') pixels[i + 3] = 255;
8
+ }
9
+ return pixels;
10
+ }
11
+ export function zoomImage(state, x, y, factor) {
12
+ const scale = Math.max(0.01, Math.min(64, state.scale * factor)), ratio = scale / state.scale;
13
+ return { scale, x: x - (x - state.x) * ratio, y: y - (y - state.y) * ratio };
14
+ }
15
+
16
+ export function createImageView(host, message) {
17
+ const panel = document.createElement('section'); panel.id = 'image-view'; panel.hidden = true;
18
+ panel.innerHTML = `<div class="image-tools"><button data-act="fit">Fit</button><button data-act="actual">100%</button>
19
+ <select aria-label="Image channel">${['RGB', 'RGBA', 'R', 'G', 'B', 'A'].map((m) => `<option>${m}</option>`).join('')}</select>
20
+ <select aria-label="Image layer"></select><label><input type="checkbox" data-act="checker" checked> Checkerboard</label>
21
+ <label><input type="checkbox" data-act="uv"> UV</label><span class="image-dim"></span></div>
22
+ <div class="image-stage checker"><canvas aria-label="Image preview"></canvas><div class="image-error" hidden></div></div>`;
23
+ host.append(panel);
24
+ const stage = panel.querySelector('.image-stage'), canvas = panel.querySelector('canvas'), context = canvas.getContext('2d');
25
+ const mode = panel.querySelector('[aria-label="Image channel"]'), layers = panel.querySelector('[aria-label="Image layer"]');
26
+ const uvToggle = panel.querySelector('[data-act="uv"]'), dim = panel.querySelector('.image-dim'), error = panel.querySelector('.image-error');
27
+ const memory = new Map();
28
+ let item = null, bitmap = null, data = null, raster = document.createElement('canvas'), segments = [], sequence = 0;
29
+ let state = { scale: 1, x: 0, y: 0 }, drag = null, fitNew = true;
30
+ function remember() { if (item) memory.set(item.id, { ...state, mode: mode.value, layer: layers.value }); }
31
+ function draw() {
32
+ if (panel.hidden) return;
33
+ const dpr = Math.min(2, devicePixelRatio || 1), w = stage.clientWidth, h = stage.clientHeight;
34
+ canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
35
+ context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, w, h);
36
+ if (!bitmap) return;
37
+ context.save(); context.translate(state.x, state.y); context.scale(state.scale, state.scale);
38
+ context.imageSmoothingEnabled = state.scale < 1;
39
+ context.drawImage(raster, 0, 0);
40
+ if (uvToggle.checked && segments.length) {
41
+ context.beginPath();
42
+ for (const [u, v, s, t] of segments) { context.moveTo(u * raster.width, (1 - v) * raster.height); context.lineTo(s * raster.width, (1 - t) * raster.height); }
43
+ context.strokeStyle = '#f0a030'; context.lineWidth = 1 / state.scale; context.stroke();
44
+ }
45
+ context.restore(); dim.textContent = `${raster.width} × ${raster.height} · ${Math.round(state.scale * 100)}%`;
46
+ }
47
+ function fit() {
48
+ if (!bitmap) return;
49
+ state.scale = Math.min((stage.clientWidth - 32) / raster.width, (stage.clientHeight - 32) / raster.height);
50
+ state.scale = Math.max(0.01, state.scale);
51
+ state.x = (stage.clientWidth - raster.width * state.scale) / 2; state.y = (stage.clientHeight - raster.height * state.scale) / 2;
52
+ remember(); draw();
53
+ }
54
+ function applyChannels() {
55
+ if (!data) return;
56
+ raster.getContext('2d').putImageData(new ImageData(channelPixels(data.data, item.generated ? 'RGBA' : mode.value), data.width, data.height), 0, 0);
57
+ draw();
58
+ }
59
+ async function load() {
60
+ if (!item) return;
61
+ const seq = ++sequence, current = item;
62
+ error.hidden = true;
63
+ const q = new URLSearchParams({ id: current.id, layer: layers.value, mode: mode.value, rev: current.revision });
64
+ const url = current.sourceURL ?? (current.generated ? '/api/output/image?' + q : '/api/output/file?' + q);
65
+ try {
66
+ const response = await fetch(url);
67
+ if (!response.ok) throw new Error((await response.json()).error);
68
+ const next = await createImageBitmap(await response.blob(), { premultiplyAlpha: 'none' });
69
+ if (seq !== sequence) { next.close(); return; }
70
+ bitmap?.close(); bitmap = next;
71
+ raster.width = next.width; raster.height = next.height;
72
+ const c = raster.getContext('2d', { willReadFrequently: true }); c.drawImage(next, 0, 0);
73
+ data = c.getImageData(0, 0, next.width, next.height); applyChannels();
74
+ if (fitNew) { fitNew = false; fit(); }
75
+ if (uvToggle.checked) await loadUV();
76
+ } catch (e) { if (seq === sequence) { error.textContent = e.message; error.hidden = false; message(e.message, 'err'); } }
77
+ }
78
+ async function loadUV() {
79
+ if (!item?.generated || !uvToggle.checked) { segments = []; draw(); return; }
80
+ const id = item.id;
81
+ try {
82
+ const r = await fetch('/api/output/uv?' + new URLSearchParams({ id })), j = await r.json();
83
+ if (item?.id !== id) return;
84
+ if (!r.ok) throw new Error(j.error);
85
+ segments = j.segments; draw();
86
+ } catch (e) { message(e.message, 'err'); }
87
+ }
88
+ panel.querySelector('[data-act="fit"]').onclick = fit;
89
+ panel.querySelector('[data-act="actual"]').onclick = () => { state = zoomImage(state, stage.clientWidth / 2, stage.clientHeight / 2, 1 / state.scale); remember(); draw(); };
90
+ panel.querySelector('[data-act="checker"]').onchange = (e) => stage.classList.toggle('checker', e.target.checked);
91
+ mode.onchange = () => { remember(); if (item?.generated) load(); else applyChannels(); };
92
+ layers.onchange = () => { remember(); load(); };
93
+ uvToggle.onchange = loadUV;
94
+ canvas.onpointerdown = (e) => { if (![0, 1, 2].includes(e.button)) return; e.preventDefault(); canvas.setPointerCapture(e.pointerId); drag = { id: e.pointerId, x: e.clientX, y: e.clientY }; };
95
+ canvas.onpointermove = (e) => { if (!drag || e.pointerId !== drag.id) return; state.x += e.clientX - drag.x; state.y += e.clientY - drag.y; drag.x = e.clientX; drag.y = e.clientY; draw(); };
96
+ const end = () => { drag = null; remember(); };
97
+ canvas.onpointerup = end; canvas.onpointercancel = end; canvas.onlostpointercapture = end; addEventListener('blur', end);
98
+ canvas.oncontextmenu = (e) => e.preventDefault();
99
+ canvas.addEventListener('wheel', (e) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); const delta = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); state = zoomImage(state, e.clientX - r.left, e.clientY - r.top, Math.exp(Math.max(-1, Math.min(1, -delta * 0.0015)))); remember(); draw(); }, { passive: false });
100
+ new ResizeObserver(draw).observe(stage);
101
+ return {
102
+ show(next) {
103
+ const same = item?.id === next.id; if (!same) remember(); item = next; panel.hidden = false; host.classList.add('image-mode');
104
+ if (!same) { bitmap?.close(); bitmap = null; data = null; segments = []; uvToggle.checked = false; dim.textContent = 'Loading…'; }
105
+ const saved = memory.get(next.id); if (!same) { state = saved ? { scale: saved.scale, x: saved.x, y: saved.y } : { scale: 1, x: 0, y: 0 }; fitNew = !saved; }
106
+ layers.replaceChildren(...(next.layers ?? ['Image']).map((l) => new Option(l, l)));
107
+ layers.value = next.layers?.includes(saved?.layer) ? saved.layer : next.layer ?? 'Image'; layers.hidden = !next.layers?.length;
108
+ mode.value = saved?.mode ?? (next.generated ? 'RGB' : 'RGBA'); uvToggle.disabled = !next.generated;
109
+ load();
110
+ },
111
+ hide() { remember(); sequence++; item = null; bitmap?.close(); bitmap = null; data = null; panel.hidden = true; host.classList.remove('image-mode'); },
112
+ get visible() { return !panel.hidden; },
113
+ };
114
+ }
@@ -1,14 +1,15 @@
1
- // The keymap of docs/design/viewer.md section 9: one table of actions, three presets (Maya, the default; Houdini;
2
- // Blender). Each row carries the text the overlay prints per preset and, for the actions phase 1 builds, the bindings:
1
+ // The keymap of docs/design/viewer.md section 9: one table of actions, four presets (TopoTopo, the default;
2
+ // Maya, Houdini, Blender). Each row carries the text the overlay prints per preset and the built actions' bindings:
3
3
  // chord → action. Transform and snapping rows (phase 2) ship their text but no bindings, so a preset never changes
4
4
  // shape later. Plain data and functions: the overlay is the only DOM code, and it runs only when called.
5
5
 
6
6
  export const PRESETS = [
7
+ { id: 'topotopo', label: 'TopoTopo' },
7
8
  { id: 'maya', label: 'Maya' },
8
9
  { id: 'houdini', label: 'Houdini' },
9
10
  { id: 'blender', label: 'Blender' },
10
11
  ];
11
- export const DEFAULT_PRESET = 'maya';
12
+ export const DEFAULT_PRESET = 'topotopo';
12
13
 
13
14
  const place = { 'Shift+1': 'placeNull', 'Shift+2': 'placeCurve', 'Shift+3': 'placeArea', 'Shift+4': 'placeVolume' };
14
15
  const curve = { Enter: 'finish', Backspace: 'removeLast', Esc: 'cancel' };
@@ -57,6 +58,17 @@ export const ROWS = [
57
58
  { label: 'keymap overlay', maya: ['?', { keys: { '?': 'overlay' } }], houdini: ['?', { keys: { '?': 'overlay' } }], blender: ['?', { keys: { '?': 'overlay' } }] },
58
59
  ];
59
60
 
61
+ // Editing shortcuts follow Maya; navigation is available without held modifiers.
62
+ const topo = {
63
+ tumble: ['Right drag (pan in orthographic)', { drag: { R: 'tumble' } }],
64
+ track: ['Middle drag', { drag: { M: 'track' } }],
65
+ dolly: ['Wheel toward cursor'],
66
+ perspective: ['1', { keys: { 1: 'viewPersp' } }],
67
+ 'top / front / right': ['2 / 3 / 4', { keys: { 2: 'viewTop', 3: 'viewFront', 4: 'viewRight' } }],
68
+ 'wire / shaded / shaded + wire': ['5 / 6 / 7', { keys: { 5: 'displayWire', 6: 'displayShaded', 7: 'displayShadedWire' } }],
69
+ };
70
+ for (const row of ROWS) row.topotopo = topo[row.label] ?? row.maya;
71
+
60
72
  /** Every action a binding may name (main.js handles each). */
61
73
  export const ACTIONS = [
62
74
  'tumble', 'track', 'dolly', 'frameSelection', 'frameAll', 'quad', 'viewPersp', 'viewTop', 'viewFront', 'viewRight', 'ortho',