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/README.md
CHANGED
|
@@ -78,12 +78,13 @@ Requires Node ≥ 22.18.
|
|
|
78
78
|
npx topotopo --catalog # node types by category
|
|
79
79
|
npx topotopo --help-node scatter # one node's params, defaults and example
|
|
80
80
|
npx topotopo graph.json --render out.png --export out.glb # cook, render, export
|
|
81
|
+
npx topotopo graph.json --export-all # every output's export target, into exports/ beside it
|
|
81
82
|
npx topotopo view graph.json # live 3D viewer, re-cooks on save; drops go to inputs/ beside it
|
|
82
83
|
npx topotopo mcp --workspace /absolute/path/to/workspace # the MCP server over stdio
|
|
83
84
|
```
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
The current release is v0.3 ([release notes](docs/release/v0.3-release-notes.md)); v0.2 was the first tagged release
|
|
87
|
+
([release notes](docs/release/v0.2-release-notes.md)). To try unreleased changes, build the tarball from a clone: `npm pack`, then
|
|
87
88
|
`npm install -g ./topotopo-<version>.tgz` (or into a project).
|
|
88
89
|
`import { Engine } from 'topotopo'` gives the engine API (`src/index.ts`) with its type declarations.
|
|
89
90
|
|
|
@@ -113,6 +114,22 @@ To browse the examples in the live 3D viewer without an MCP client:
|
|
|
113
114
|
npm run preview
|
|
114
115
|
```
|
|
115
116
|
|
|
117
|
+
The viewer's left panel has three tabs: **Scene** (the graph's nodes and guides), **Outputs** and **Inputs**.
|
|
118
|
+
**Outputs**, the default, shows all graphs' main results, named deliverables and their textures; click one to
|
|
119
|
+
inspect it alone, and **Scene** in the top bar restores the scene. Publish extra results in graph JSON with
|
|
120
|
+
`"outputs": {"rotor": {"node": "rotor_final", "label": "Rotor"}}`, keeping `"output"` as the default. Each result
|
|
121
|
+
has an **Export** button: the first click asks for a format and a file inside `exports/` and stores that target in
|
|
122
|
+
the graph (`"export": "glb"`, or `{"format": "obj", "path": "parts/rotor.obj", "up_axis": "z"}`, on the graph or on an
|
|
123
|
+
output); later clicks overwrite the same file, and **Export all** writes every target. MCP `export {output}` and the
|
|
124
|
+
CLI's `--export-all` write the same targets. **Inputs** lists your files in `inputs/`, with **Add input** (or drop
|
|
125
|
+
files anywhere on the page); the agent is told about new ones in its next tool answer. Images open in a 2D viewer
|
|
126
|
+
with layers and channels (generated textures default to RGB; use A to inspect packed alpha/AO); JPEG/WebP are for
|
|
127
|
+
viewing only. For `topotopo view graph.json`, `inputs/` and `exports/` are beside that graph.
|
|
128
|
+
|
|
129
|
+
The default **TopoTopo** keymap uses right-drag orbit around the surface beneath the cursor, middle-drag pan and
|
|
130
|
+
wheel zoom toward the cursor. F/A frame, 1–4 select cameras, 5–7 change shading, O toggles orthographic and Space
|
|
131
|
+
toggles quad view. Maya, Houdini and Blender presets, including previously saved choices, remain available.
|
|
132
|
+
|
|
116
133
|
**Threads.** Heavy per-element work (noise displacement, attribute noise, occlusion rays, point wrangles) runs in
|
|
117
134
|
chunks on a pool of worker threads, and independent graph branches cook concurrently. The output is the same bit for
|
|
118
135
|
bit with or without it. `TOPOTOPO_WORKERS` sets the number of threads (default: CPU threads − 1; `0` turns the pool
|
package/dist/cli.js
CHANGED
|
@@ -33,11 +33,16 @@ import { renderArgs } from './render/options.js';
|
|
|
33
33
|
import { encodePNG } from './render/png.js';
|
|
34
34
|
import { EXPORT_FORMATS, exportFormatFromPath } from './io/export.js';
|
|
35
35
|
import { exportFilesAsync } from './io/files.js';
|
|
36
|
+
import { ArtifactStore } from './host/artifacts.js';
|
|
37
|
+
import { prepareTarget, writeTargets } from './host/export-targets.js';
|
|
38
|
+
import { publishedOutputs } from './io/targets.js';
|
|
36
39
|
import { inspect } from './inspect.js';
|
|
37
40
|
import { catalogText, nodeDoc } from './mcp/docs.js';
|
|
38
41
|
const argv = process.argv.slice(2);
|
|
39
42
|
const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
40
43
|
const fail = (msg) => { console.error(msg); process.exit(1); };
|
|
44
|
+
/** A graph file's name as a graph (view mode) and in receipts and default export file names. */
|
|
45
|
+
const graphName = (file) => path.basename(file).replace(/\.json$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^[^A-Za-z]/, 'g$&') || 'graph';
|
|
41
46
|
// the command as the user typed it: the sources from a clone, or the package's bin (dist/cli.js)
|
|
42
47
|
const prog = import.meta.url.endsWith('.ts') ? 'node src/cli.ts' : 'topotopo';
|
|
43
48
|
if (argv.includes('-v') || argv.includes('-V') || argv.includes('--version')) {
|
|
@@ -50,6 +55,7 @@ if (!argv.length || argv.includes('-h') || argv.includes('--help')) {
|
|
|
50
55
|
console.log([
|
|
51
56
|
`usage: ${prog} <graph.json> [--node n] [--frame N | --frames A-B[:step]] [--fps 24]`,
|
|
52
57
|
' [--render out.png] [--views quad|quad_uv|turntable|persp|front,top|persp,uv|persp,section] [--checker] [--color-by attr] [--export out.glb] [--inspect all]',
|
|
58
|
+
' [--export-all] (every output with an export target in the graph, into exports/ beside it)',
|
|
53
59
|
' render overlays: [--wireframe all|hard|boundary|groups] [--hard-angle 30] [--section y:0.5] [--clip x:0.5[:abs]]',
|
|
54
60
|
' [--glyphs N|up|orient|v] [--glyph-attrib a] [--labels groups,pieces,points,prims,attrib:name] [--legend] [--points]',
|
|
55
61
|
' [--shading flat|matcap|ao] [--size 800] [--render-scale 2] [--azimuth 35] [--elevation 25] [--zoom 1] [--look-at x,y,z] [--ortho-scale w]',
|
|
@@ -97,12 +103,12 @@ if (argv[0] === 'view') {
|
|
|
97
103
|
const dir = path.dirname(path.resolve(file));
|
|
98
104
|
const engine = new Engine(createNodeHost({ baseDir: dir }));
|
|
99
105
|
await engine.init();
|
|
100
|
-
// A scratch workspace holds the graph
|
|
106
|
+
// A scratch workspace holds the graph and assets: the graph file stays the only source. Files dropped on the
|
|
101
107
|
// page go to inputs/ beside the graph file instead, made on the first drop: its file nodes read them as
|
|
102
108
|
// "inputs/<name>" (the path an MCP workspace uses too), and they outlive the viewer.
|
|
103
|
-
const ws = new Workspace(fs.mkdtempSync(path.join(os.tmpdir(), 'topotopo-view-')), engine, { inputs: path.join(dir, 'inputs') });
|
|
109
|
+
const ws = new Workspace(fs.mkdtempSync(path.join(os.tmpdir(), 'topotopo-view-')), engine, { inputs: path.join(dir, 'inputs'), exports: path.join(dir, 'exports') });
|
|
104
110
|
ws.load();
|
|
105
|
-
const name =
|
|
111
|
+
const name = graphName(file);
|
|
106
112
|
const load = async () => {
|
|
107
113
|
let spec;
|
|
108
114
|
try {
|
|
@@ -118,7 +124,26 @@ if (argv[0] === 'view') {
|
|
|
118
124
|
ws.bump(name);
|
|
119
125
|
};
|
|
120
126
|
await load();
|
|
121
|
-
|
|
127
|
+
// an export target set on the page goes into the graph file, the only source here: only "export" keys change
|
|
128
|
+
const persistGraph = (g) => {
|
|
129
|
+
if (g !== name)
|
|
130
|
+
return;
|
|
131
|
+
const spec = ws.getGraph(name), raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
132
|
+
if (spec.export === undefined)
|
|
133
|
+
delete raw.export;
|
|
134
|
+
else
|
|
135
|
+
raw.export = spec.export;
|
|
136
|
+
for (const [id, o] of Object.entries(spec.outputs ?? {})) {
|
|
137
|
+
if (!raw.outputs?.[id])
|
|
138
|
+
continue;
|
|
139
|
+
if (o.export === undefined)
|
|
140
|
+
delete raw.outputs[id].export;
|
|
141
|
+
else
|
|
142
|
+
raw.outputs[id].export = o.export;
|
|
143
|
+
}
|
|
144
|
+
fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
|
|
145
|
+
};
|
|
146
|
+
const v = await startViewer(ws, opt('--port') !== undefined ? Number(opt('--port')) : undefined, { inputsWhere: `beside ${path.basename(file)}`, persistGraph });
|
|
122
147
|
console.log(`view: ${v.url} (watching ${file}; files dropped on the page go to ${path.join(path.dirname(file), 'inputs') + path.sep}; Ctrl+C to stop)`);
|
|
123
148
|
let timer;
|
|
124
149
|
fs.watch(file, () => { clearTimeout(timer); timer = setTimeout(() => { load().catch((e) => console.error(String(e))); }, 150); });
|
|
@@ -179,6 +204,8 @@ async function cook() {
|
|
|
179
204
|
const frameArg = opt('--frame');
|
|
180
205
|
if (framesArg && frameArg)
|
|
181
206
|
fail('use either --frame or --frames, not both');
|
|
207
|
+
if (framesArg && argv.includes('--export-all'))
|
|
208
|
+
fail('--export-all writes one frame: use --frame N, not --frames');
|
|
182
209
|
const single = frameArg !== undefined ? Number(frameArg) : undefined;
|
|
183
210
|
if (single !== undefined && !Number.isFinite(single))
|
|
184
211
|
fail(`--frame expects a number, got "${frameArg}"`);
|
|
@@ -223,6 +250,14 @@ async function cook() {
|
|
|
223
250
|
async function writeOutputs(r, frame) {
|
|
224
251
|
const geo = r.geo;
|
|
225
252
|
const at = (p) => (frame === undefined ? p : framePath(p, frame));
|
|
253
|
+
// Receipts for the viewer's saved files, only for files inside exports/ beside the graph: a run whose output goes
|
|
254
|
+
// elsewhere leaves no exports/ folder behind (the viewer lists that folder's files without receipts too).
|
|
255
|
+
const exportsDir = path.join(path.dirname(path.resolve(file)), 'exports');
|
|
256
|
+
const record = (paths, format) => {
|
|
257
|
+
const inside = paths.every((p) => { const rel = path.relative(exportsDir, path.resolve(p)); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); });
|
|
258
|
+
if (inside)
|
|
259
|
+
new ArtifactStore(exportsDir).record(paths, { graph: graphName(file), node: r.target, format });
|
|
260
|
+
};
|
|
226
261
|
for (const { out, get, has } of renders) {
|
|
227
262
|
const ro = renderFlags(get, has);
|
|
228
263
|
const v = get('--views');
|
|
@@ -236,6 +271,7 @@ async function cook() {
|
|
|
236
271
|
const p = at(out);
|
|
237
272
|
fs.mkdirSync(path.dirname(path.resolve(p)), { recursive: true });
|
|
238
273
|
fs.writeFileSync(p, encodePNG(img.rgba, img.width, img.height, (d) => zlib.deflateSync(d)));
|
|
274
|
+
record([p], 'png');
|
|
239
275
|
console.log(`rendered ${p} (${img.width}x${img.height})${img.notes.length ? ' — ' + img.notes.join('; ') : ''}`);
|
|
240
276
|
}
|
|
241
277
|
const ex = opt('--export');
|
|
@@ -273,6 +309,8 @@ async function cook() {
|
|
|
273
309
|
fs.mkdirSync(path.dirname(path.resolve(f.path)), { recursive: true });
|
|
274
310
|
fs.writeFileSync(f.path, f.bytes);
|
|
275
311
|
}
|
|
312
|
+
if (files.length)
|
|
313
|
+
record(files.map((f) => f.path), fmt);
|
|
276
314
|
console.log(`exported ${files.length === 1 ? files[0].path : files.map((f) => f.path).join(', ')}${files.length > 1 || fmt !== 'glb' ? ` — ${summary}` : ''}`);
|
|
277
315
|
}
|
|
278
316
|
}
|
|
@@ -300,6 +338,31 @@ async function cook() {
|
|
|
300
338
|
console.log(`cooked ${file} → ${r.target}${r.frame !== 1 ? ` at frame ${r.frame}` : ''} in ${r.ms.toFixed(0)} ms`);
|
|
301
339
|
console.log(inspect(r.geo, { detail: opt('--inspect') ?? 'summary' }));
|
|
302
340
|
await writeOutputs(r);
|
|
341
|
+
if (argv.includes('--export-all'))
|
|
342
|
+
await exportAll();
|
|
343
|
+
}
|
|
344
|
+
/** --export-all: every published output with an export target (io/targets.ts), into exports/ beside the graph. */
|
|
345
|
+
async function exportAll() {
|
|
346
|
+
const name = graphName(file), exportsDir = path.join(path.dirname(path.resolve(file)), 'exports');
|
|
347
|
+
const published = publishedOutputs(name, graph, engine.findOutput(graph).name);
|
|
348
|
+
if (!published.some((p) => p.target))
|
|
349
|
+
fail(`--export-all: no output of ${file} has an export target; add "export": "glb" to the graph (default output) or to an entry of "outputs"`);
|
|
350
|
+
const prepared = [];
|
|
351
|
+
for (const p of published) {
|
|
352
|
+
if (!p.target)
|
|
353
|
+
continue;
|
|
354
|
+
const r = await engine.cook(graph, { target: p.node, frame: single, fps });
|
|
355
|
+
report(r);
|
|
356
|
+
if (!r.ok || !r.geo)
|
|
357
|
+
fail(`--export-all: "${p.label}" did not cook; nothing was written`);
|
|
358
|
+
prepared.push(await prepareTarget(r.geo, name, p, exportsDir, engine.host));
|
|
359
|
+
}
|
|
360
|
+
writeTargets(prepared, exportsDir);
|
|
361
|
+
for (const p of prepared)
|
|
362
|
+
console.log(`exported ${p.label} → ${path.join('exports', ...p.target.path.split('/'))} — ${p.summary}`);
|
|
363
|
+
const skipped = published.filter((p) => !p.target).map((p) => p.label);
|
|
364
|
+
if (skipped.length)
|
|
365
|
+
console.log(`no export target (skipped): ${skipped.join(', ')}`);
|
|
303
366
|
}
|
|
304
367
|
// stop the kernel worker threads (idle ones would not hold the process, but a clean exit should not wait on them)
|
|
305
368
|
await engine.host.pool?.close();
|
package/dist/graph/engine.js
CHANGED
|
@@ -26,6 +26,7 @@ import { Geometry, isGeometry } from '../core/geometry.js';
|
|
|
26
26
|
import { DEFAULT_TRANSFER, expandPacked } from '../core/packed.js';
|
|
27
27
|
import { splinesToPolylines } from '../core/splines.js';
|
|
28
28
|
import { hashString } from '../core/random.js';
|
|
29
|
+
import { targetError } from '../io/targets.js';
|
|
29
30
|
import { allAliases, getNodeDef } from './registry.js';
|
|
30
31
|
import { DeferredExpr, REF_FUNCS, didYouMean, hasRefs, resolveParamName, resolveParams, scanNodeRefs, scanValue, vecOut, } from './params.js';
|
|
31
32
|
import { migrateGraphDetailed } from './migrate.js';
|
|
@@ -453,6 +454,19 @@ export class Engine {
|
|
|
453
454
|
return { errors, warnings };
|
|
454
455
|
}
|
|
455
456
|
const names = Object.keys(graph.nodes);
|
|
457
|
+
if (graph.outputs !== undefined) {
|
|
458
|
+
if (!graph.outputs || typeof graph.outputs !== 'object' || Array.isArray(graph.outputs))
|
|
459
|
+
errors.push({ node: path || '(graph)', message: 'outputs must be a map of names to {node, label?}' });
|
|
460
|
+
else
|
|
461
|
+
for (const [id, out] of Object.entries(graph.outputs)) {
|
|
462
|
+
if (!id || !out || typeof out !== 'object' || typeof out.node !== 'string' || !Object.hasOwn(graph.nodes, out.node) || (out.label !== undefined && typeof out.label !== 'string'))
|
|
463
|
+
errors.push({ node: path || '(graph)', message: `named output "${id}" must reference an existing node and have an optional string label` });
|
|
464
|
+
else if (out.export !== undefined && targetError(out.export))
|
|
465
|
+
errors.push({ node: path || '(graph)', message: `named output "${id}": ${targetError(out.export)}` });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (graph.export !== undefined && targetError(graph.export))
|
|
469
|
+
errors.push({ node: path || '(graph)', message: targetError(graph.export) });
|
|
456
470
|
if (!names.length)
|
|
457
471
|
errors.push({ node: path || '(graph)', message: 'graph has no nodes' });
|
|
458
472
|
if (graph.output && !graph.nodes[graph.output])
|
package/dist/graph/types.d.ts
CHANGED
|
@@ -91,11 +91,28 @@ export interface VarDef {
|
|
|
91
91
|
export interface GraphMeta {
|
|
92
92
|
[key: string]: any;
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Where a published output exports to: a format name ("glb"), written as exports/<output>.<ext>, or an object with
|
|
96
|
+
* the format, an optional path inside exports/ and the export tool's options ({format: "obj", up_axis: "z"}).
|
|
97
|
+
*/
|
|
98
|
+
export type ExportTarget = string | {
|
|
99
|
+
format: string;
|
|
100
|
+
path?: string;
|
|
101
|
+
[option: string]: unknown;
|
|
102
|
+
};
|
|
94
103
|
export interface GraphSpec {
|
|
95
104
|
/** graph format version (see GRAPH_VERSION); missing = 0, migrated on load */
|
|
96
105
|
version?: number;
|
|
97
106
|
nodes: Record<string, NodeSpec>;
|
|
98
107
|
output?: string;
|
|
108
|
+
/** where the default output exports to (io/targets.ts): a format ("glb"), or {format, path?, ...export options} */
|
|
109
|
+
export?: ExportTarget;
|
|
110
|
+
/** Named deliverables for the viewer. `output` remains the default cook target. */
|
|
111
|
+
outputs?: Record<string, {
|
|
112
|
+
node: string;
|
|
113
|
+
label?: string;
|
|
114
|
+
export?: ExportTarget;
|
|
115
|
+
}>;
|
|
99
116
|
vars?: Record<string, any>;
|
|
100
117
|
/** metadata for exposed vars: asset parameters, and the controls the live viewer shows for them */
|
|
101
118
|
params?: Record<string, VarDef>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Artifact {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
graph?: string;
|
|
5
|
+
node?: string;
|
|
6
|
+
format?: string;
|
|
7
|
+
sourceRevision?: string;
|
|
8
|
+
/** the published output a target export wrote ('' = the graph's default output) */
|
|
9
|
+
output?: string;
|
|
10
|
+
files: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const artifactId: (p: string) => string;
|
|
13
|
+
export declare class ArtifactStore {
|
|
14
|
+
dir: string;
|
|
15
|
+
constructor(dir: string);
|
|
16
|
+
get manifest(): string;
|
|
17
|
+
records(): Artifact[];
|
|
18
|
+
record(files: string[], info?: Partial<Omit<Artifact, 'id' | 'files'>>): Artifact;
|
|
19
|
+
/** Discover unregistered agent-written files too. Never follow symlinks/junctions while scanning. */
|
|
20
|
+
list(): Artifact[];
|
|
21
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Persistent output receipts shared by the CLI, MCP and viewer. Paths are registered by trusted host code;
|
|
2
|
+
// HTTP callers only receive identifiers, never a route that reads an arbitrary absolute path.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
export const artifactId = (p) => createHash('sha256').update(path.resolve(p)).digest('hex').slice(0, 24);
|
|
7
|
+
export class ArtifactStore {
|
|
8
|
+
dir;
|
|
9
|
+
constructor(dir) { this.dir = path.resolve(dir); }
|
|
10
|
+
get manifest() { return path.join(this.dir, '.outputs.json'); }
|
|
11
|
+
records() {
|
|
12
|
+
try {
|
|
13
|
+
const j = JSON.parse(fs.readFileSync(this.manifest, 'utf8'));
|
|
14
|
+
return Array.isArray(j) ? j.filter((a) => a && typeof a.id === 'string' && Array.isArray(a.files) && a.files.every((p) => typeof p === 'string')) : [];
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
record(files, info = {}) {
|
|
21
|
+
if (!files.length)
|
|
22
|
+
throw new Error('an output needs at least one file');
|
|
23
|
+
files = files.map((p) => path.resolve(p));
|
|
24
|
+
const a = { id: artifactId(files[0]), label: path.basename(files[0]), ...info, files };
|
|
25
|
+
const all = this.records().filter((x) => x.id !== a.id);
|
|
26
|
+
all.push(a);
|
|
27
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
28
|
+
const temp = this.manifest + '.part';
|
|
29
|
+
fs.writeFileSync(temp, JSON.stringify(all, null, 2) + '\n');
|
|
30
|
+
fs.renameSync(temp, this.manifest);
|
|
31
|
+
return a;
|
|
32
|
+
}
|
|
33
|
+
/** Discover unregistered agent-written files too. Never follow symlinks/junctions while scanning. */
|
|
34
|
+
list() {
|
|
35
|
+
const all = this.records().filter((a) => a.files.some((p) => fs.statSync(p, { throwIfNoEntry: false })?.isFile()));
|
|
36
|
+
const known = new Set(all.flatMap((a) => a.files.map((p) => path.resolve(p))));
|
|
37
|
+
const walk = (dir) => {
|
|
38
|
+
// a folder can be missing, or vanish between listing its parent and reading it
|
|
39
|
+
const entries = (() => { try {
|
|
40
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
} })();
|
|
45
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
|
|
46
|
+
if (e.name.startsWith('.') || e.isSymbolicLink())
|
|
47
|
+
continue;
|
|
48
|
+
const p = path.join(dir, e.name);
|
|
49
|
+
if (e.isDirectory())
|
|
50
|
+
walk(p);
|
|
51
|
+
else if (e.isFile() && !known.has(p))
|
|
52
|
+
all.push({ id: artifactId(p), label: path.relative(this.dir, p).split(path.sep).join('/'), files: [p] });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(this.dir);
|
|
56
|
+
return all;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Geometry } from '../core/geometry.ts';
|
|
2
|
+
import type { ExportFormat } from '../io/export.ts';
|
|
3
|
+
import type { ExportFile } from '../io/files.ts';
|
|
4
|
+
import type { PublishedOutput, ResolvedTarget } from '../io/targets.ts';
|
|
5
|
+
import type { Host } from './types.ts';
|
|
6
|
+
/** The files an export writes, nothing written yet; `a` holds the export tool's snake_case options. */
|
|
7
|
+
export declare function serializeExport(geo: Geometry, format: ExportFormat, p: string, a: Record<string, any>, host: Host): Promise<{
|
|
8
|
+
files: ExportFile[];
|
|
9
|
+
summary: string;
|
|
10
|
+
warnings: string[];
|
|
11
|
+
outDir: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** `p` resolved inside `root`, crossing no symbolic link or junction on the way; throws otherwise. */
|
|
14
|
+
export declare function insideRoot(root: string, p: string): string;
|
|
15
|
+
/** Write files, each through a .part renamed into place; with `root`, each must stay inside it. Returns the count. */
|
|
16
|
+
export declare function writeExportFiles(files: ExportFile[], root?: string): number;
|
|
17
|
+
/** A published output serialized to its target, ready to write. */
|
|
18
|
+
export interface PreparedTarget {
|
|
19
|
+
graph: string;
|
|
20
|
+
output: string | null;
|
|
21
|
+
label: string;
|
|
22
|
+
node: string;
|
|
23
|
+
target: ResolvedTarget;
|
|
24
|
+
files: ExportFile[];
|
|
25
|
+
summary: string;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Serialize one published output to its target inside `exportsDir` (nothing is written yet). */
|
|
29
|
+
export declare function prepareTarget(geo: Geometry, graph: string, p: PublishedOutput & {
|
|
30
|
+
target: ResolvedTarget;
|
|
31
|
+
}, exportsDir: string, host: Host): Promise<PreparedTarget>;
|
|
32
|
+
/** Write prepared targets and record their receipts; two targets writing one file are refused before any write. */
|
|
33
|
+
export declare function writeTargets(prepared: PreparedTarget[], exportsDir: string): number;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Writing exports for the CLI, the MCP server and the viewer: serialize with the export tool's options, then write
|
|
2
|
+
// each file through a .part beside it, renamed into place. Published targets (io/targets.ts) write inside exports/
|
|
3
|
+
// only, and every target is serialized before any file is written, so a failed cook or format changes nothing.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import zlib from 'node:zlib';
|
|
7
|
+
import { exportFilesAsync } from '../io/files.js';
|
|
8
|
+
import { ArtifactStore } from './artifacts.js';
|
|
9
|
+
/** The files an export writes, nothing written yet; `a` holds the export tool's snake_case options. */
|
|
10
|
+
export async function serializeExport(geo, format, p, a, host) {
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const outDir = format === 'unreal_landscape' && !/\.json$/i.test(p) ? p : path.dirname(p);
|
|
13
|
+
const res = await exportFilesAsync(geo, format, p, {
|
|
14
|
+
upAxis: a.up_axis, scale: a.scale, splitByName: a.split_by_name ?? true, custom: a.custom_attribs,
|
|
15
|
+
materials: a.materials, compress: a.compress, quantize: a.quantize, attributes: a.attributes, pcg: a.pcg || a.pcg_struct, pcgStruct: a.pcg_struct,
|
|
16
|
+
precision: a.precision, instancing: a.instancing, instances: a.instances, meshes: a.meshes,
|
|
17
|
+
layer: a.layer, layers: a.layers, heightMin: a.height_min, heightMax: a.height_max, heightfield: a.heightfield, tile_size: a.tile_size,
|
|
18
|
+
deflate: (d) => zlib.deflateSync(d, { level: 6 }),
|
|
19
|
+
readFile: (t) => host.readFile(t),
|
|
20
|
+
texturePath: (t) => {
|
|
21
|
+
const abs = host.resolvePath(t);
|
|
22
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(abs) ? abs : path.relative(outDir, abs).split(path.sep).join('/');
|
|
23
|
+
},
|
|
24
|
+
warn: (m) => warnings.push(m),
|
|
25
|
+
});
|
|
26
|
+
return { files: res.files, summary: res.summary, warnings: [...new Set(warnings)], outDir };
|
|
27
|
+
}
|
|
28
|
+
/** `p` resolved inside `root`, crossing no symbolic link or junction on the way; throws otherwise. */
|
|
29
|
+
export function insideRoot(root, p) {
|
|
30
|
+
const abs = path.resolve(p), rel = path.relative(path.resolve(root), abs);
|
|
31
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
|
|
32
|
+
throw new Error(`${p} is outside ${root}`);
|
|
33
|
+
let current = path.resolve(root);
|
|
34
|
+
for (const part of rel.split(path.sep)) {
|
|
35
|
+
current = path.join(current, part);
|
|
36
|
+
if (fs.lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
37
|
+
throw new Error(`export paths cannot pass through the link ${current}`);
|
|
38
|
+
}
|
|
39
|
+
return abs;
|
|
40
|
+
}
|
|
41
|
+
/** Write files, each through a .part renamed into place; with `root`, each must stay inside it. Returns the count. */
|
|
42
|
+
export function writeExportFiles(files, root) {
|
|
43
|
+
let written = 0;
|
|
44
|
+
try {
|
|
45
|
+
for (const f of files) {
|
|
46
|
+
const abs = root ? insideRoot(root, f.path) : path.resolve(f.path);
|
|
47
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
48
|
+
fs.writeFileSync(abs + '.part', f.bytes);
|
|
49
|
+
fs.renameSync(abs + '.part', abs);
|
|
50
|
+
written++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new Error(`export stopped after writing ${written} of ${files.length} files: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
return written;
|
|
57
|
+
}
|
|
58
|
+
/** Serialize one published output to its target inside `exportsDir` (nothing is written yet). */
|
|
59
|
+
export async function prepareTarget(geo, graph, p, exportsDir, host) {
|
|
60
|
+
const dest = insideRoot(exportsDir, path.join(exportsDir, ...p.target.path.split('/')));
|
|
61
|
+
const r = await serializeExport(geo, p.target.format, dest, p.target.options, host);
|
|
62
|
+
for (const f of r.files)
|
|
63
|
+
insideRoot(exportsDir, f.path);
|
|
64
|
+
return { graph, output: p.output, label: p.label, node: p.node, target: p.target, files: r.files, summary: r.summary, warnings: r.warnings };
|
|
65
|
+
}
|
|
66
|
+
/** Write prepared targets and record their receipts; two targets writing one file are refused before any write. */
|
|
67
|
+
export function writeTargets(prepared, exportsDir) {
|
|
68
|
+
const owner = new Map();
|
|
69
|
+
for (const p of prepared)
|
|
70
|
+
for (const f of p.files) {
|
|
71
|
+
const key = path.resolve(f.path).toLowerCase(), other = owner.get(key);
|
|
72
|
+
// status: the viewer answers 409 (the request conflicts with itself); the CLI and MCP print the message
|
|
73
|
+
if (other && other !== p)
|
|
74
|
+
throw Object.assign(new Error(`"${other.label}" (${other.graph}) and "${p.label}" (${p.graph}) both write ${path.relative(exportsDir, f.path)}: give one a different export path`), { status: 409 });
|
|
75
|
+
owner.set(key, p);
|
|
76
|
+
}
|
|
77
|
+
const store = new ArtifactStore(exportsDir);
|
|
78
|
+
let written = 0;
|
|
79
|
+
for (const p of prepared) {
|
|
80
|
+
written += writeExportFiles(p.files, exportsDir);
|
|
81
|
+
store.record(p.files.map((f) => f.path), { label: p.target.path, graph: p.graph, node: p.node, format: p.target.format, output: p.output ?? '' });
|
|
82
|
+
}
|
|
83
|
+
return written;
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExportFormat } from './export.ts';
|
|
2
|
+
import type { ExportTarget, GraphSpec } from '../graph/types.ts';
|
|
3
|
+
/** Every export format with the label the viewer shows, in the order it offers them (a Record, so none is missing). */
|
|
4
|
+
export declare const FORMAT_LABELS: Record<ExportFormat, string>;
|
|
5
|
+
export declare const TARGET_FORMATS: ExportFormat[];
|
|
6
|
+
/** The extension a target's file gets: '' for unreal_landscape and textures, which write a folder. */
|
|
7
|
+
export declare const targetExtension: (format: ExportFormat) => string;
|
|
8
|
+
/** A format name in canonical form ("USD" → usda), or null. */
|
|
9
|
+
export declare function targetFormat(f: unknown): ExportFormat | null;
|
|
10
|
+
/** Why a target is invalid, or null when it is valid. */
|
|
11
|
+
export declare function targetError(t: unknown): string | null;
|
|
12
|
+
export interface ResolvedTarget {
|
|
13
|
+
format: ExportFormat;
|
|
14
|
+
/** posix path inside exports/: a file, or a folder for unreal_landscape and textures */
|
|
15
|
+
path: string;
|
|
16
|
+
/** the export tool's options (up_axis, scale, …) */
|
|
17
|
+
options: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
/** A file name stem from an output id or graph name. */
|
|
20
|
+
export declare const fileStem: (s: string) => string;
|
|
21
|
+
/** A valid target with its path filled in: <stem><extension> unless it names one. */
|
|
22
|
+
export declare function resolveTarget(t: ExportTarget, stem: string): ResolvedTarget;
|
|
23
|
+
export interface PublishedOutput {
|
|
24
|
+
/** null for the graph's default output (graph.export), else the id in graph.outputs */
|
|
25
|
+
output: string | null;
|
|
26
|
+
node: string;
|
|
27
|
+
label: string;
|
|
28
|
+
target: ResolvedTarget | null;
|
|
29
|
+
}
|
|
30
|
+
/** A graph's default output (its files named after the graph) and its named outputs, each with its target if valid. */
|
|
31
|
+
export declare function publishedOutputs(graph: string, spec: GraphSpec, defaultNode: string): PublishedOutput[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Every export format with the label the viewer shows, in the order it offers them (a Record, so none is missing). */
|
|
2
|
+
export const FORMAT_LABELS = {
|
|
3
|
+
glb: 'GLB (one file, textures inside)', gltf: 'glTF + texture files', usda: 'USD (.usda)', obj: 'OBJ', geo: 'Houdini .geo',
|
|
4
|
+
unreal_landscape: 'Unreal Landscape bundle (folder)', csv: 'CSV points (Unreal PCG)', pointsjson: 'Points JSON',
|
|
5
|
+
textures: 'PNG textures (folder)', png16: 'Heightmap, 16-bit PNG', r16: 'Heightmap, RAW .r16', mask_png: 'Masks, PNG',
|
|
6
|
+
ply: 'PLY', stl: 'STL', json: 'TopoTopo JSON',
|
|
7
|
+
};
|
|
8
|
+
export const TARGET_FORMATS = Object.keys(FORMAT_LABELS);
|
|
9
|
+
const EXTENSIONS = { unreal_landscape: '', textures: '', png16: '.png', mask_png: '.png', pointsjson: '.points.json' };
|
|
10
|
+
/** The extension a target's file gets: '' for unreal_landscape and textures, which write a folder. */
|
|
11
|
+
export const targetExtension = (format) => EXTENSIONS[format] ?? '.' + format;
|
|
12
|
+
/** A format name in canonical form ("USD" → usda), or null. */
|
|
13
|
+
export function targetFormat(f) {
|
|
14
|
+
const s = typeof f === 'string' ? f.trim().toLowerCase() : '';
|
|
15
|
+
const c = s === 'usd' ? 'usda' : s;
|
|
16
|
+
return Object.hasOwn(FORMAT_LABELS, c) ? c : null;
|
|
17
|
+
}
|
|
18
|
+
// the export tool's options (src/mcp/tools.ts), by value type
|
|
19
|
+
const BOOLEAN = ['split_by_name', 'materials', 'custom_attribs', 'quantize', 'pcg', 'pcg_struct', 'meshes'];
|
|
20
|
+
const CHOICES = { up_axis: ['y', 'z'], compress: ['none', 'meshopt', 'draco'], instancing: ['packed', 'expand'], instances: ['rows', 'expand'], precision: [32, 64] };
|
|
21
|
+
const NUMBER = ['scale', 'height_min', 'height_max', 'tile_size', 'heightfield'];
|
|
22
|
+
const NAMES = ['attributes', 'layers'];
|
|
23
|
+
const OPTIONS = [...BOOLEAN, ...Object.keys(CHOICES), ...NUMBER, ...NAMES, 'layer'];
|
|
24
|
+
/** A path segment Windows and POSIX both accept, and that cannot climb out of exports/. */
|
|
25
|
+
const segmentOk = (s) => !!s && s !== '.' && s !== '..' && !/[<>:"|?*\x00-\x1f]/.test(s) && !/[. ]$/.test(s) && !/^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(s);
|
|
26
|
+
const pathOk = (p) => typeof p === 'string' && p.length <= 240 && !/^[A-Za-z]:|^[\\/]/.test(p) && p.replace(/\\/g, '/').split('/').every(segmentOk);
|
|
27
|
+
/** Why a target is invalid, or null when it is valid. */
|
|
28
|
+
export function targetError(t) {
|
|
29
|
+
const o = typeof t === 'string' ? { format: t } : t;
|
|
30
|
+
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
31
|
+
return 'export must be a format name ("glb") or {format, path?, ...options}';
|
|
32
|
+
if (!targetFormat(o.format))
|
|
33
|
+
return `export format "${o.format}" is not one of ${TARGET_FORMATS.join(', ')} (or usd)`;
|
|
34
|
+
if (o.path !== undefined && !pathOk(o.path))
|
|
35
|
+
return `export path "${o.path}" must stay inside exports/: relative, without "..", a drive or a leading slash`;
|
|
36
|
+
for (const [k, v] of Object.entries(o)) {
|
|
37
|
+
if (k === 'format' || k === 'path')
|
|
38
|
+
continue;
|
|
39
|
+
if (!OPTIONS.includes(k))
|
|
40
|
+
return `unknown export option "${k}" (options: ${OPTIONS.join(', ')})`;
|
|
41
|
+
if (BOOLEAN.includes(k) && typeof v !== 'boolean')
|
|
42
|
+
return `export option ${k} must be true or false`;
|
|
43
|
+
if (k in CHOICES && !CHOICES[k].includes(v))
|
|
44
|
+
return `export option ${k} must be one of ${CHOICES[k].join(', ')}`;
|
|
45
|
+
if (NUMBER.includes(k) && !(typeof v === 'number' && Number.isFinite(v)))
|
|
46
|
+
return `export option ${k} must be a number`;
|
|
47
|
+
if (k === 'scale' && !(v > 0))
|
|
48
|
+
return 'export option scale must be positive';
|
|
49
|
+
if (NAMES.includes(k) && !(Array.isArray(v) && v.every((s) => typeof s === 'string')))
|
|
50
|
+
return `export option ${k} must be a list of names`;
|
|
51
|
+
if (k === 'layer' && typeof v !== 'string')
|
|
52
|
+
return 'export option layer must be a name';
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** A file name stem from an output id or graph name. */
|
|
57
|
+
export const fileStem = (s) => s.replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^\.+|[. ]+$/g, '') || 'output';
|
|
58
|
+
/** A valid target with its path filled in: <stem><extension> unless it names one. */
|
|
59
|
+
export function resolveTarget(t, stem) {
|
|
60
|
+
const { format, path, ...options } = typeof t === 'string' ? { format: t } : t;
|
|
61
|
+
const f = targetFormat(format);
|
|
62
|
+
return { format: f, path: (typeof path === 'string' ? path : fileStem(stem) + targetExtension(f)).replace(/\\/g, '/'), options };
|
|
63
|
+
}
|
|
64
|
+
/** A graph's default output (its files named after the graph) and its named outputs, each with its target if valid. */
|
|
65
|
+
export function publishedOutputs(graph, spec, defaultNode) {
|
|
66
|
+
const valid = (t) => (t !== undefined && t !== null && !targetError(t) ? t : null);
|
|
67
|
+
const main = valid(spec.export);
|
|
68
|
+
return [
|
|
69
|
+
{ output: null, node: defaultNode, label: 'Main output', target: main ? resolveTarget(main, graph) : null },
|
|
70
|
+
...Object.entries(spec.outputs ?? {}).map(([id, o]) => {
|
|
71
|
+
const t = valid(o?.export);
|
|
72
|
+
return { output: id, node: o.node, label: o.label ?? id, target: t ? resolveTarget(t, id) : null };
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
}
|
package/dist/mcp/docs.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphSpec, NodeDef } from '../graph/types.ts';
|
|
2
|
-
export declare const INSTRUCTIONS = "TopoTopo: a procedural geometry engine (Houdini-SOP style) you drive with JSON node graphs.\n\nWORKFLOW\n0. inputs {action:\"list\"} \u2192 the person's own files in the workspace inputs/ folder (guide curves, sites, zones, blockouts, PNG masks). Building a landscape or scene: look there first and build on them (help topic \"inputs\").\n1. catalog \u2192 see node types (100+, Houdini names work as aliases: copytopoints, attribwrangle, polyextrude, mountain\u2026).\n2. help {nodes:[...]} for exact params before using unfamiliar nodes; help {topic:\"wrangle\"|\"graph\"|\"recipes\"\u2026} for concepts.\n3. build {graph} the WHOLE network in one call (dry_run:true validates without cooking). Every response reports errors per node with did-you-mean hints.\n4. render to SEE it (quad view: persp/front/right/top; backfaces are tinted blue = flipped normals; views \"quad_uv\" + checker:true for UVs; overlays for specific questions: wireframe \"hard\"/\"boundary\", views \"section\", glyphs \"N\", label_groups, shading \"matcap\", see help topic \"render\"). inspect for numbers (bbox, attributes, watertightness).\n5. edit {ops} for incremental changes (set params, insert nodes, rewire) \u2014 only downstream nodes re-cook.\n6. export to glb/gltf (materials, textures, meshopt), geo (Houdini), csv (Unreal PCG points / instance rows), points.json, usda, obj/ply/stl/json for Unreal, Blender, Houdini; unreal_landscape = a whole terrain scene for Unreal.\n7. verify: assert nodes guard invariants in the graph (watertight, counts, bbox, expr); explain {graph} reads a result in words; diff {a, b} shows what an edit changed. See help topic \"verify\".\nINSTANCING: copy_to_points {pack:true} / pack make packed instances (millions stay cheap; glb, usda and geo export them as instances, csv/points.json as one row per instance). See help topic \"instancing\".\nPCG: mask \u2192 scatter {density_mask} \u2192 orient \u2192 prune \u2192 copy_to_points {pack, variants, variant_weights}; points_along_curve, cluster, attribute_from_map. See help topic \"pcg\".\nCURVES: bezier / nurbs make spline prims (CVs + order, exact everywhere); convert_curve, resample, fillet_curve, offset_curve_3d, project_curve (roads on terrain), spline_fit, curve_attribs (curveu, frames, ramps). See help topic \"curves\".\nCLOTH: drape {steps} simulates a mesh as cloth over a collider (input 1) and the floor, from pins (pin_group / input 2) and in wind; inflate {pressure} puffs a closed mesh into a cushion or balloon. See help topic \"cloth\".\n\nCONVENTIONS: Y-up, metres, angles in degrees (wrangle trig uses radians). Houdini attribute names (P N Cd uv pscale orient up scale width name class piece). Polygons are closed prims, polylines are open prims.\nGRAPH: {\"nodes\": {\"name\": {\"type\": \"...\", \"in\": [\"upstream\", ...], \"params\": {...}}}, \"output\": \"name\", \"vars\": {\"k\": 1}}. Params accept \"=expr\" using vars (e.g. \"=height*0.5\"), time (frame, time) and other nodes (npoints('pts'), bbox('base').size.y, ch('box1/size')).\nHUMANS TUNE VARS: declare \"params\" for the 3\u20138 vars a person would tune ({\"k\": {type, desc, min, max, options, label, step}}); the live viewer (preview) shows them as sliders and menus. People change vars at any time, so read the graph (graphs {action:\"get\"}) before assuming a var's value. See help topic \"graph\".\nTIME: build/edit/render/inspect/export take \"frame\" (default 1). solver = per-frame feedback (growth, accumulation). See help topic \"time\".\nPARTICLES: particles(emitter points, collision mesh, field) = Houdini POPs in one node: emission, gravity/drag/wind, curl noise, fields, floor and mesh collisions, trails as polylines; steps with the frame. See help topic \"particles\".\nESCAPE HATCHES: wrangle (per-element JS with @P/@Cd syntax), script (whole-geometry JS with the engine library), sdf_expr (signed distance functions). If a node is missing, write it with these.\nTERRAIN: heightfield \u2192 heightfield_noise \u2192 heightfield_erode \u2192 masks / heightfield_path \u2192 heightfield_scatter + copy_to_points, heightfield_to_mesh; export png16 for an Unreal Landscape, or unreal_landscape for the landscape + layers + instances bundle. See help topic \"heightfield\".\nPHYSICS: rbd_settle drops pieces (packed instances) onto a terrain/container/ground until they rest, so scattered rocks and props sit in contact; rbd_stack drops them one at a time into piles. See help topic \"rbd\".";
|
|
2
|
+
export declare const INSTRUCTIONS = "TopoTopo: a procedural geometry engine (Houdini-SOP style) you drive with JSON node graphs.\n\nWORKFLOW\n0. inputs {action:\"list\"} \u2192 the person's own files in the workspace inputs/ folder (guide curves, sites, zones, blockouts, PNG masks). Building a landscape or scene: look there first and build on them (help topic \"inputs\").\n1. catalog \u2192 see node types (100+, Houdini names work as aliases: copytopoints, attribwrangle, polyextrude, mountain\u2026).\n2. help {nodes:[...]} for exact params before using unfamiliar nodes; help {topic:\"wrangle\"|\"graph\"|\"recipes\"\u2026} for concepts.\n3. build {graph} the WHOLE network in one call (dry_run:true validates without cooking). Every response reports errors per node with did-you-mean hints.\n4. render to SEE it (quad view: persp/front/right/top; backfaces are tinted blue = flipped normals; views \"quad_uv\" + checker:true for UVs; overlays for specific questions: wireframe \"hard\"/\"boundary\", views \"section\", glyphs \"N\", label_groups, shading \"matcap\", see help topic \"render\"). inspect for numbers (bbox, attributes, watertightness).\n5. edit {ops} for incremental changes (set params, insert nodes, rewire) \u2014 only downstream nodes re-cook.\n6. export to glb/gltf (materials, textures, meshopt), geo (Houdini), csv (Unreal PCG points / instance rows), points.json, usda, obj/ply/stl/json for Unreal, Blender, Houdini; unreal_landscape = a whole terrain scene for Unreal.\n7. verify: assert nodes guard invariants in the graph (watertight, counts, bbox, expr); explain {graph} reads a result in words; diff {a, b} shows what an edit changed. See help topic \"verify\".\nOUTPUTS: publish intended deliverables in graph.outputs: {\"rotor\":{\"node\":\"rotor_final\",\"label\":\"Rotor\"}}. The default graph.output stays unchanged. The viewer lists every graph and named result, its generated textures and image layers; clicking a result isolates it. Rendered images and exports are saved and visible too. Use descriptive names; keep intermediate work in the node outliner. Rename/remove edits maintain published references.\nINSTANCING: copy_to_points {pack:true} / pack make packed instances (millions stay cheap; glb, usda and geo export them as instances, csv/points.json as one row per instance). See help topic \"instancing\".\nPCG: mask \u2192 scatter {density_mask} \u2192 orient \u2192 prune \u2192 copy_to_points {pack, variants, variant_weights}; points_along_curve, cluster, attribute_from_map. See help topic \"pcg\".\nCURVES: bezier / nurbs make spline prims (CVs + order, exact everywhere); convert_curve, resample, fillet_curve, offset_curve_3d, project_curve (roads on terrain), spline_fit, curve_attribs (curveu, frames, ramps). See help topic \"curves\".\nCLOTH: drape {steps} simulates a mesh as cloth over a collider (input 1) and the floor, from pins (pin_group / input 2) and in wind; inflate {pressure} puffs a closed mesh into a cushion or balloon. See help topic \"cloth\".\n\nCONVENTIONS: Y-up, metres, angles in degrees (wrangle trig uses radians). Houdini attribute names (P N Cd uv pscale orient up scale width name class piece). Polygons are closed prims, polylines are open prims.\nGRAPH: {\"nodes\": {\"name\": {\"type\": \"...\", \"in\": [\"upstream\", ...], \"params\": {...}}}, \"output\": \"name\", \"vars\": {\"k\": 1}}. Params accept \"=expr\" using vars (e.g. \"=height*0.5\"), time (frame, time) and other nodes (npoints('pts'), bbox('base').size.y, ch('box1/size')).\nHUMANS TUNE VARS: declare \"params\" for the 3\u20138 vars a person would tune ({\"k\": {type, desc, min, max, options, label, step}}); the live viewer (preview) shows them as sliders and menus. People change vars at any time, so read the graph (graphs {action:\"get\"}) before assuming a var's value. See help topic \"graph\".\nTIME: build/edit/render/inspect/export take \"frame\" (default 1). solver = per-frame feedback (growth, accumulation). See help topic \"time\".\nPARTICLES: particles(emitter points, collision mesh, field) = Houdini POPs in one node: emission, gravity/drag/wind, curl noise, fields, floor and mesh collisions, trails as polylines; steps with the frame. See help topic \"particles\".\nESCAPE HATCHES: wrangle (per-element JS with @P/@Cd syntax), script (whole-geometry JS with the engine library), sdf_expr (signed distance functions). If a node is missing, write it with these.\nTERRAIN: heightfield \u2192 heightfield_noise \u2192 heightfield_erode \u2192 masks / heightfield_path \u2192 heightfield_scatter + copy_to_points, heightfield_to_mesh; export png16 for an Unreal Landscape, or unreal_landscape for the landscape + layers + instances bundle. See help topic \"heightfield\".\nPHYSICS: rbd_settle drops pieces (packed instances) onto a terrain/container/ground until they rest, so scattered rocks and props sit in contact; rbd_stack drops them one at a time into piles. See help topic \"rbd\".";
|
|
3
3
|
/**
|
|
4
4
|
* The complete example of help topic "inputs": guides.glb's path.* curves cut roads into a heightfield and its zone.*
|
|
5
5
|
* areas mask a tree scatter; the vars a person tunes are exposed as params. test/mcp.ts cooks it on a real guides.glb.
|
package/dist/mcp/docs.js
CHANGED
|
@@ -10,6 +10,7 @@ WORKFLOW
|
|
|
10
10
|
5. edit {ops} for incremental changes (set params, insert nodes, rewire) — only downstream nodes re-cook.
|
|
11
11
|
6. export to glb/gltf (materials, textures, meshopt), geo (Houdini), csv (Unreal PCG points / instance rows), points.json, usda, obj/ply/stl/json for Unreal, Blender, Houdini; unreal_landscape = a whole terrain scene for Unreal.
|
|
12
12
|
7. verify: assert nodes guard invariants in the graph (watertight, counts, bbox, expr); explain {graph} reads a result in words; diff {a, b} shows what an edit changed. See help topic "verify".
|
|
13
|
+
OUTPUTS: publish intended deliverables in graph.outputs: {"rotor":{"node":"rotor_final","label":"Rotor"}}. The default graph.output stays unchanged. The viewer lists every graph and named result, its generated textures and image layers; clicking a result isolates it. Rendered images and exports are saved and visible too. Use descriptive names; keep intermediate work in the node outliner. Rename/remove edits maintain published references.
|
|
13
14
|
INSTANCING: copy_to_points {pack:true} / pack make packed instances (millions stay cheap; glb, usda and geo export them as instances, csv/points.json as one row per instance). See help topic "instancing".
|
|
14
15
|
PCG: mask → scatter {density_mask} → orient → prune → copy_to_points {pack, variants, variant_weights}; points_along_curve, cluster, attribute_from_map. See help topic "pcg".
|
|
15
16
|
CURVES: bezier / nurbs make spline prims (CVs + order, exact everywhere); convert_curve, resample, fillet_curve, offset_curve_3d, project_curve (roads on terrain), spline_fit, curve_attribs (curveu, frames, ramps). See help topic "curves".
|
|
@@ -82,14 +83,24 @@ export const TOPICS = {
|
|
|
82
83
|
keep a value, keep it.
|
|
83
84
|
- "in": ordered input list (null = unconnected), a single name, or {"inputName": node} (names from help).
|
|
84
85
|
- Output node: "output", else a node named out/OUT, else the last unconnected node.
|
|
86
|
+
- Optional "outputs": {"rotor":{"node":"rotor_final","label":"Rotor"}} publishes additional deliverables beside
|
|
87
|
+
the default. Publish complete assets and useful separate parts; the viewer isolates each on click and lists
|
|
88
|
+
its textures/layers. Rename/remove edits maintain references. The latest render of each graph node is saved as
|
|
89
|
+
exports/renders/<graph>-<node>.png (each render overwrites it) unless save_to is supplied; explicit render/export
|
|
90
|
+
destinations are registered in the viewer too.
|
|
91
|
+
- Optional "export" (on the graph for the default output, or on an entry of "outputs") is where that output
|
|
92
|
+
exports: a format ("glb" → exports/<graph or output id>.glb) or {"format":"obj","path":"parts/a.obj","up_axis":"z"}
|
|
93
|
+
(the export tool's options), always inside exports/. The person's Export buttons in the viewer, export {output} /
|
|
94
|
+
export {all:true} and the CLI's --export-all write these targets; set one with edit {op:"export", output?, export}.
|
|
85
95
|
- Node names: letters, digits, _ . - Node types & params are case/underscore-insensitive and accept Houdini-style aliases.
|
|
86
96
|
- "bypass": true passes input 0 through. "note": free text. "ui": {x,y} reserved for the future GUI.
|
|
87
97
|
- Vectors: a single number broadcasts ("size": 2 → [2,2,2]). Colours accept [r,g,b], "#ff8800" or names.
|
|
88
98
|
- build mode "merge" upserts nodes into the existing graph; edit ops: set, add, remove (auto-rewires), connect, rename, output, vars, bypass, replace, insert (after a node, rewiring its consumers).
|
|
89
99
|
- Several graphs can coexist (name param); graph_ref composes them. Graphs persist as JSON in the workspace.`,
|
|
90
100
|
inputs: `HUMAN INPUT (guides, blockouts and masks in the workspace inputs/ folder)
|
|
91
|
-
People feed in their own material by dropping files into <workspace>/inputs/ (or onto the live viewer
|
|
92
|
-
curves and points drawn in Blender, Houdini or Unreal, placeholder blockouts, PNG masks and
|
|
101
|
+
People feed in their own material by dropping files into <workspace>/inputs/ (or onto the live viewer, or with its Add
|
|
102
|
+
input button): meshes, guide curves and points drawn in Blender, Houdini or Unreal, placeholder blockouts, PNG masks and
|
|
103
|
+
heightmaps. Every tool answer ends with a note when inputs/ has files new or changed since your last call. Before building a
|
|
93
104
|
landscape or scene, call inputs {action:"list"}; inputs {action:"describe", path} tells what one holds (polygons, open
|
|
94
105
|
polylines, loose points, packed prims; bbox in metres; attributes; every name and role value; materials). Read a file
|
|
95
106
|
with a file node, the path relative to the workspace: {"type":"file","params":{"path":"inputs/guides.glb"}}.
|
|
@@ -705,6 +716,8 @@ Examples: render {views:["persp","section"], section_axis:"z", wireframe:"hard",
|
|
|
705
716
|
CLI: --wireframe all --section y:0.5 --clip x:0.5 --glyphs N --labels groups,pieces --shading matcap --legend --size 1600 --scale 2
|
|
706
717
|
--no-view-labels`,
|
|
707
718
|
export: `EXPORT
|
|
719
|
+
export {output:"crate"} / export {} (the default output) / export {all:true}: the graph's export targets (help topic
|
|
720
|
+
"graph": "export" on the graph or an output), written inside exports/, the same files the viewer's Export buttons write.
|
|
708
721
|
export {path:"exports/thing.glb"} — format from the extension:
|
|
709
722
|
glb / gltf glTF 2.0 via gltf-transform (recommended for Unreal, Blender, web)
|
|
710
723
|
geo Houdini JSON geometry: every attribute (all classes), group and polyline, lossless (precision 64 or 32)
|