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.
- package/README.md +19 -2
- package/dist/cli.js +67 -4
- package/dist/graph/engine.js +14 -0
- package/dist/graph/types.d.ts +17 -0
- package/dist/host/artifacts.d.ts +21 -0
- package/dist/host/artifacts.js +58 -0
- package/dist/host/export-targets.d.ts +33 -0
- package/dist/host/export-targets.js +84 -0
- package/dist/io/targets.d.ts +31 -0
- package/dist/io/targets.js +75 -0
- package/dist/mcp/docs.d.ts +1 -1
- package/dist/mcp/docs.js +15 -2
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +87 -34
- package/dist/nodes/code.js +32 -6
- package/dist/viewer/app/camera.js +26 -6
- package/dist/viewer/app/export-popover.js +68 -0
- package/dist/viewer/app/image-view.js +114 -0
- package/dist/viewer/app/keymap.js +15 -3
- package/dist/viewer/app/layers.js +23 -4
- package/dist/viewer/app/main.js +70 -14
- package/dist/viewer/app/outliner.js +56 -15
- package/dist/viewer/app/outputs.css +73 -0
- package/dist/viewer/app/outputs.js +240 -0
- package/dist/viewer/app/select.js +13 -2
- package/dist/viewer/app/status.js +20 -0
- package/dist/viewer/app/style.css +1 -0
- package/dist/viewer/index.html +19 -5
- package/dist/viewer/outputs.d.ts +54 -0
- package/dist/viewer/outputs.js +436 -0
- package/dist/viewer/server.d.ts +5 -0
- package/dist/viewer/server.js +48 -0
- package/dist/workspace.d.ts +8 -2
- package/dist/workspace.js +37 -3
- package/package.json +4 -3
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
91
|
-
return `wrote ${list} (${format}, ${(total / 1024).toFixed(1)} KB): ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/nodes/code.js
CHANGED
|
@@ -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;
|
|
87
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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)
|
|
154
|
-
c.
|
|
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,
|
|
2
|
-
// Blender). Each row carries the text the overlay prints per preset and
|
|
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 = '
|
|
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',
|