topotopo 0.1.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 +61 -12
- package/dist/cli.js +161 -23
- package/dist/graph/engine.js +18 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +28 -2
- 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/host/export_scene.py +200 -0
- package/dist/host/node.d.ts +8 -0
- package/dist/host/node.js +114 -0
- package/dist/host/types.d.ts +5 -0
- package/dist/inspect.js +5 -2
- package/dist/io/common.d.ts +6 -0
- package/dist/io/export.js +7 -4
- package/dist/io/gltf.d.ts +1 -1
- package/dist/io/gltf.js +167 -32
- package/dist/io/heightmap.d.ts +1 -1
- package/dist/io/import.d.ts +2 -2
- package/dist/io/import.js +10 -3
- package/dist/io/png-decode.d.ts +5 -2
- package/dist/io/png-decode.js +10 -3
- package/dist/io/targets.d.ts +31 -0
- package/dist/io/targets.js +75 -0
- package/dist/io/textures.d.ts +31 -2
- package/dist/io/textures.js +66 -18
- package/dist/io/unreal.js +22 -5
- package/dist/mcp/docs.d.ts +9 -2
- package/dist/mcp/docs.js +126 -2
- package/dist/mcp/inputs.d.ts +28 -0
- package/dist/mcp/inputs.js +377 -0
- package/dist/mcp/server.js +4 -1
- package/dist/mcp/tools.d.ts +1 -1
- package/dist/mcp/tools.js +118 -51
- package/dist/nodes/code.js +34 -6
- package/dist/nodes/copy.js +14 -4
- package/dist/nodes/flow.js +136 -35
- package/dist/nodes/heightfield.js +67 -11
- package/dist/nodes/texture.d.ts +1 -1
- package/dist/nodes/texture.js +61 -5
- package/dist/render/options.js +2 -1
- package/dist/render/raster.d.ts +2 -0
- package/dist/render/raster.js +3 -3
- package/dist/render/texview.d.ts +9 -1
- package/dist/render/texview.js +43 -2
- package/dist/viewer/app/api.js +64 -0
- package/dist/viewer/app/camera.js +250 -0
- package/dist/viewer/app/export-popover.js +68 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/image-view.js +114 -0
- package/dist/viewer/app/keymap.js +199 -0
- package/dist/viewer/app/layers.js +244 -0
- package/dist/viewer/app/main.js +646 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +225 -0
- package/dist/viewer/app/outputs.css +73 -0
- package/dist/viewer/app/outputs.js +240 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +153 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/status.js +20 -0
- package/dist/viewer/app/style.css +173 -0
- package/dist/viewer/app/undo.js +49 -0
- package/dist/viewer/app/units.js +51 -0
- package/dist/viewer/app/vars.js +64 -0
- package/dist/viewer/index.html +67 -184
- package/dist/viewer/outputs.d.ts +54 -0
- package/dist/viewer/outputs.js +436 -0
- package/dist/viewer/server.d.ts +47 -1
- package/dist/viewer/server.js +480 -17
- package/dist/workspace.d.ts +60 -4
- package/dist/workspace.js +131 -5
- package/package.json +9 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
package/dist/mcp/tools.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import zlib from 'node:zlib';
|
|
5
|
-
import { Workspace } from '../workspace.js';
|
|
5
|
+
import { Workspace, EditRejected } from '../workspace.js';
|
|
6
6
|
import { allNodeDefs } from '../graph/registry.js';
|
|
7
7
|
import { didYouMean } from '../graph/params.js';
|
|
8
8
|
import { migrateGraph } from '../graph/migrate.js';
|
|
@@ -12,10 +12,13 @@ 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';
|
|
21
|
+
import { inputsTool } from './inputs.js';
|
|
19
22
|
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
|
|
20
23
|
/** " at frame N" when the cook was not at frame 1. */
|
|
21
24
|
const atFrame = (r) => (r.frame !== 1 ? ` at frame ${r.frame}` : '');
|
|
@@ -64,30 +67,19 @@ function pngContent(g, o) {
|
|
|
64
67
|
* Landscape bundle, or the single file of other formats). Textures are read through the host; USD texture paths are
|
|
65
68
|
* written relative to the output file. Returns the report text.
|
|
66
69
|
*/
|
|
67
|
-
export async function writeExport(geo, format, p, a, host) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
texturePath: (t) => {
|
|
78
|
-
const abs = host.resolvePath(t);
|
|
79
|
-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(abs) ? abs : path.relative(outDir, abs).split(path.sep).join('/');
|
|
80
|
-
},
|
|
81
|
-
warn: (m) => warnings.push(m),
|
|
82
|
-
});
|
|
83
|
-
for (const f of res.files) {
|
|
84
|
-
fs.mkdirSync(path.dirname(f.path), { recursive: true });
|
|
85
|
-
fs.writeFileSync(f.path, f.bytes);
|
|
86
|
-
}
|
|
87
|
-
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);
|
|
88
80
|
const rel = (f) => path.relative(outDir, f).split(path.sep).join('/');
|
|
89
|
-
const list =
|
|
90
|
-
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 ') : ''}`;
|
|
91
83
|
}
|
|
92
84
|
function renderOpts(a, g, label) {
|
|
93
85
|
const ro = renderArgs(a); // views (presets, persp@az, section), overlays, shading, labels … (src/render/options.ts)
|
|
@@ -131,6 +123,7 @@ const RENDER_PROPS = {
|
|
|
131
123
|
glyph_attrib: { type: 'string', description: 'attribute for glyphs "custom": 3 floats = vector, 4 floats = quaternion' },
|
|
132
124
|
glyph_scale: { type: 'number', description: 'glyph length in world units (default 5% of the bbox diagonal)' },
|
|
133
125
|
max_glyphs: { type: 'number', description: 'evenly subsample to at most this many glyphs (default 400)' },
|
|
126
|
+
max_triangles: { type: 'number', description: 'packed instances: draw up to this many expanded triangles (default 600000, max 50M); above it an evenly spread subset is drawn and a note says so' },
|
|
134
127
|
label_groups: { type: 'boolean', description: 'name each point/prim group on a visible member near its centroid, outline prim groups / mark group points in the label colour, legend with counts' },
|
|
135
128
|
label_pieces: { type: 'boolean', description: 'number connected pieces (#0, #1, … in connectivity order)' },
|
|
136
129
|
label_attrib: { type: 'string', description: 'label each distinct value of a string/int attribute (e.g. "name") on its elements; colours match color_by' },
|
|
@@ -146,22 +139,33 @@ const RENDER_PROPS = {
|
|
|
146
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' },
|
|
147
140
|
};
|
|
148
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');
|
|
149
145
|
const engine = ws.engine;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
ws.bump(name);
|
|
146
|
+
/** The text (+ optional render) of a cook report. */
|
|
147
|
+
const reportResult = (name, r, a, extraNotes = []) => {
|
|
153
148
|
const content = [{ type: 'text', text: reportText(name, r, extraNotes) }];
|
|
154
149
|
if (r.geo && a.render) {
|
|
155
150
|
const ro = renderOpts(typeof a.render === 'object' ? a.render : {}, r.geo, `${name}/${r.target}${atFrame(r)}`);
|
|
156
|
-
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' });
|
|
157
156
|
content.push(img);
|
|
158
157
|
if (notes.length)
|
|
159
158
|
content.push({ type: 'text', text: 'render: ' + notes.join('; ') });
|
|
160
159
|
}
|
|
161
160
|
return { content, isError: !r.ok };
|
|
162
161
|
};
|
|
162
|
+
const cookAndReport = async (name, a, extraNotes = []) => {
|
|
163
|
+
const r = await ws.cook(name, undefined, a.frame);
|
|
164
|
+
ws.bump(name);
|
|
165
|
+
return reportResult(name, r, a, extraNotes);
|
|
166
|
+
};
|
|
163
167
|
const resolveNodeGeo = async (a) => {
|
|
164
|
-
const name = a.graph ?? 'main';
|
|
168
|
+
const name = a.graph ?? a.name ?? 'main'; // `name` is what build/edit call it; accept both
|
|
165
169
|
const g = ws.getGraph(name);
|
|
166
170
|
const target = a.node || undefined;
|
|
167
171
|
const r = await engine.cook(g, { target, frame: a.frame });
|
|
@@ -169,10 +173,39 @@ export function makeTools(ws, opts = {}) {
|
|
|
169
173
|
throw new Error(reportText(name, r));
|
|
170
174
|
return { r, name, geo: r.geo };
|
|
171
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
|
+
};
|
|
172
205
|
const tools = [
|
|
173
206
|
{
|
|
174
207
|
name: 'catalog',
|
|
175
|
-
description: 'List available node types grouped by category (generate, curve, transform, deform, attribute, measure, group, topology, copy, solid, sdf, heightfield, uv, code, flow, io, pcg, shelf = shipped graph assets such as stairs or fence, asset = workspace assets). Optional filter by category or search text.',
|
|
208
|
+
description: 'List available node types grouped by category (generate, curve, transform, deform, attribute, measure, group, topology, copy, solid, sdf, heightfield, uv, code, flow, io, pcg, shelf = shipped graph assets such as stairs or fence, asset = workspace assets). Optional filter by category or search text. Building a landscape or scene for a person? Call inputs {action:"list"} first: the meshes, guide curves and masks they dropped into the workspace.',
|
|
176
209
|
inputSchema: {
|
|
177
210
|
type: 'object',
|
|
178
211
|
properties: {
|
|
@@ -181,7 +214,11 @@ export function makeTools(ws, opts = {}) {
|
|
|
181
214
|
include_deprecated: { type: 'boolean', description: 'also list deprecated node types (hidden by default)' },
|
|
182
215
|
},
|
|
183
216
|
},
|
|
184
|
-
run: async (a) =>
|
|
217
|
+
run: async (a) => {
|
|
218
|
+
const cat = catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated });
|
|
219
|
+
const nIn = ws.listInputs().length;
|
|
220
|
+
return text(`${cat}\nHUMAN INPUT: ${nIn ? `${nIn} file${nIn === 1 ? '' : 's'} in` : 'nothing yet in'} the workspace inputs/ folder (meshes, guide curves and points, blockouts, PNG masks people drop in). Building a landscape or scene: inputs {action:"list"} first, then read them with file nodes (help topic "inputs").`);
|
|
221
|
+
},
|
|
185
222
|
},
|
|
186
223
|
{
|
|
187
224
|
name: 'help',
|
|
@@ -213,12 +250,12 @@ export function makeTools(ws, opts = {}) {
|
|
|
213
250
|
},
|
|
214
251
|
{
|
|
215
252
|
name: 'build',
|
|
216
|
-
description: 'Create or replace a whole node graph and cook it. Returns per-node errors with hints, warnings, and a summary of the output geometry (counts, bbox, attributes, groups). mode "merge" upserts nodes into the existing graph. dry_run validates and resolves params without cooking. render:true (or render options) also returns an image.',
|
|
253
|
+
description: 'Create or replace a whole node graph and cook it. Returns per-node errors with hints, warnings, and a summary of the output geometry (counts, bbox, attributes, groups). mode "merge" upserts nodes into the existing graph. dry_run validates and resolves params without cooking. render:true (or render options) also returns an image. Expose the 3–8 vars a human would tune: declare "params": {var: {type, desc, min, max, options, label, step}}; the live viewer shows them as controls. Humans may change vars at any time, so read the graph (graphs {action:"get"}) before assuming a var\'s value.',
|
|
217
254
|
inputSchema: {
|
|
218
255
|
type: 'object',
|
|
219
256
|
required: ['graph'],
|
|
220
257
|
properties: {
|
|
221
|
-
graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}}' },
|
|
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}.' },
|
|
222
259
|
name: { type: 'string', description: 'graph name (default "main")' },
|
|
223
260
|
mode: { type: 'string', enum: ['replace', 'merge'] },
|
|
224
261
|
dry_run: { type: 'boolean' },
|
|
@@ -249,7 +286,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
249
286
|
},
|
|
250
287
|
{
|
|
251
288
|
name: 'edit',
|
|
252
|
-
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).',
|
|
253
290
|
inputSchema: {
|
|
254
291
|
type: 'object',
|
|
255
292
|
required: ['ops'],
|
|
@@ -262,12 +299,16 @@ export function makeTools(ws, opts = {}) {
|
|
|
262
299
|
},
|
|
263
300
|
run: async (a) => {
|
|
264
301
|
const name = a.name ?? 'main';
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
302
|
+
let res;
|
|
303
|
+
try {
|
|
304
|
+
res = await ws.edit(name, a.ops, { frame: a.frame });
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
if (e instanceof EditRejected)
|
|
308
|
+
return text(e.message, true);
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
311
|
+
return reportResult(name, res.report, a, res.notes);
|
|
271
312
|
},
|
|
272
313
|
},
|
|
273
314
|
{
|
|
@@ -300,7 +341,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
300
341
|
inputSchema: {
|
|
301
342
|
type: 'object',
|
|
302
343
|
properties: {
|
|
303
|
-
graph: { type: 'string' }, node: { type: 'string' },
|
|
344
|
+
graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
|
|
304
345
|
...RENDER_PROPS,
|
|
305
346
|
save_to: { type: 'string', description: 'also write the PNG to this path (relative to the workspace)' },
|
|
306
347
|
...FRAME_PROP,
|
|
@@ -311,10 +352,11 @@ export function makeTools(ws, opts = {}) {
|
|
|
311
352
|
const { img, notes, png } = pngContent(geo, renderOpts(a, geo, `${name}/${r.target}${atFrame(r)}`));
|
|
312
353
|
const content = [img];
|
|
313
354
|
const info = [];
|
|
314
|
-
|
|
315
|
-
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);
|
|
316
357
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
317
358
|
fs.writeFileSync(p, png);
|
|
359
|
+
new ArtifactStore(ws.exportDir).record([p], { graph: name, node: r.target, format: 'png' });
|
|
318
360
|
info.push(`saved ${p}`);
|
|
319
361
|
}
|
|
320
362
|
if (notes.length)
|
|
@@ -329,10 +371,11 @@ export function makeTools(ws, opts = {}) {
|
|
|
329
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 ["*"]).',
|
|
330
372
|
inputSchema: {
|
|
331
373
|
type: 'object',
|
|
332
|
-
required: ['path'],
|
|
333
374
|
properties: {
|
|
334
|
-
graph: { type: 'string' }, node: { type: 'string' },
|
|
335
|
-
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)' },
|
|
375
|
+
graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
|
|
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' },
|
|
336
379
|
format: { type: 'string', enum: EXPORT_FORMATS, description: 'default: from the extension (.points.json = pointsjson)' },
|
|
337
380
|
layer: { type: 'string', description: 'heightmaps: layer to write (png16/r16 default "height", mask_png default "mask")' },
|
|
338
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)' },
|
|
@@ -358,13 +401,15 @@ export function makeTools(ws, opts = {}) {
|
|
|
358
401
|
},
|
|
359
402
|
},
|
|
360
403
|
run: async (a) => {
|
|
404
|
+
if (a.path === undefined)
|
|
405
|
+
return exportTargets(a);
|
|
361
406
|
const { geo, name, r } = await resolveNodeGeo(a);
|
|
362
407
|
const p = path.resolve(ws.dir, a.path);
|
|
363
408
|
const fmt0 = String(a.format ?? exportFormatFromPath(p)).toLowerCase();
|
|
364
409
|
const ext = ({ png: 'png16', raw: 'r16' }[fmt0] ?? fmt0);
|
|
365
410
|
if (!EXPORT_FORMATS.includes(ext))
|
|
366
411
|
throw new Error(`unknown format "${ext}"; use ${EXPORT_FORMATS.map((f) => (f === 'pointsjson' ? '.points.json' : '.' + f)).join(' ')}`);
|
|
367
|
-
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 }));
|
|
368
413
|
return text(`${report}\n(from ${name}/${r.target}${atFrame(r)}: ${geo.numPoints} points, ${geo.numPrims} prims)`);
|
|
369
414
|
},
|
|
370
415
|
},
|
|
@@ -483,14 +528,36 @@ export function makeTools(ws, opts = {}) {
|
|
|
483
528
|
},
|
|
484
529
|
},
|
|
485
530
|
];
|
|
486
|
-
tools.push(...verifyTools(ws)); // explain, diff
|
|
531
|
+
tools.push(...verifyTools(ws), inputsTool(ws)); // explain, diff; the human's input files
|
|
487
532
|
if (opts.onPreview) {
|
|
488
533
|
tools.push({
|
|
489
534
|
name: 'preview',
|
|
490
|
-
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.',
|
|
491
536
|
inputSchema: { type: 'object', properties: {} },
|
|
492
|
-
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)`),
|
|
493
538
|
});
|
|
494
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
|
+
}
|
|
495
562
|
return tools;
|
|
496
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 },
|
|
@@ -127,15 +128,42 @@ function scriptLib(ctx, wake, run) {
|
|
|
127
128
|
const rp = resolveParams(def, params, ctx.vars, { time: ctx, host: engine.host, timeoutMs: engine.scriptTimeoutMs });
|
|
128
129
|
if (rp.errors.length)
|
|
129
130
|
throw new Error(`lib.run(${type}): ${rp.errors.join('; ')}`);
|
|
131
|
+
for (const w of rp.warnings)
|
|
132
|
+
ctx.warn(`lib.run(${type}): ${w}`);
|
|
130
133
|
// inherit from ctx through the prototype so its time getters stay lazy
|
|
131
134
|
const sub = Object.assign(Object.create(ctx), {
|
|
132
135
|
node: `${ctx.node}/${type}`, type: def.type, p: rp.values, inputs,
|
|
133
136
|
input: (i) => inputs[i] ?? new Geometry(), hasInput: (i) => !!inputs[i], inputSigs: inputs.map(() => 'script'),
|
|
134
137
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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;
|
|
139
167
|
}),
|
|
140
168
|
warn: (m) => ctx.warn(String(m)),
|
|
141
169
|
};
|
package/dist/nodes/copy.js
CHANGED
|
@@ -950,7 +950,7 @@ defineNode({
|
|
|
950
950
|
variants: { type: 'enum', options: ['none', 'name', 'piece'], default: 'none', desc: 'split the source into variants: name = by prim "name", piece = by connectivity (none = only when piece_attrib is set)' },
|
|
951
951
|
piece_attrib: { type: 'string', default: '', desc: 'variants: source prim attribute that splits the geometry into pieces (e.g. "name" or "class")' },
|
|
952
952
|
variant_attrib: { type: 'string', default: 'variant', desc: 'variants: template point attribute choosing a piece (int index into sorted piece values, or the exact value)' },
|
|
953
|
-
variant_weights: { type: 'json', default: [], desc: 'variants: weights
|
|
953
|
+
variant_weights: { type: 'json', default: [], desc: 'variants: weights for a seeded weighted random pick when the template has no variant_attrib attribute: by piece value, e.g. {"tree": 3, "bush": 1}, or a list in piece order (names sorted alphabetically, connectivity pieces in prim order), e.g. [3, 1, 1]; missing weights are 0', aliases: ['weights', 'variant_probabilities'] },
|
|
954
954
|
seed: { type: 'float', default: 0, desc: 'variant_weights: random seed' },
|
|
955
955
|
scale_attrib: { type: 'string', default: '', desc: 'template point attribute multiplying the copy scale (float = uniform, vec3 = per axis), on top of pscale and scale', aliases: ['scale_by'] },
|
|
956
956
|
rotate_attrib: { type: 'string', default: '', desc: 'template point attribute adding a local rotation after orient/N/rot: float = degrees about +Y (yaw), vec3 = Euler degrees (XYZ), vec4 = quaternion', aliases: ['rotate_by'] },
|
|
@@ -1010,12 +1010,22 @@ defineNode({
|
|
|
1010
1010
|
subsets.set(v, src.subsetPrims(Uint8Array.from({ length: src.numPrims }, (_, i) => (pa.get(i) === v ? 1 : 0))));
|
|
1011
1011
|
}
|
|
1012
1012
|
const va = p.variant_attrib ? t.points.get(p.variant_attrib) : undefined;
|
|
1013
|
-
const
|
|
1013
|
+
const wv = p.variant_weights;
|
|
1014
|
+
// {piece value: weight} (dogfood 2026-10-15: a list follows the sorted piece order, which surprised a user) or a list
|
|
1015
|
+
const byValue = wv && typeof wv === 'object' && !Array.isArray(wv) ? wv : null;
|
|
1016
|
+
const wts = byValue
|
|
1017
|
+
? values.map((v) => Math.max(0, Number(byValue[String(v)]) || 0))
|
|
1018
|
+
: Array.isArray(wv) ? wv.map((x) => Math.max(0, Number(x) || 0)) : [];
|
|
1019
|
+
if (byValue) {
|
|
1020
|
+
const unknown = Object.keys(byValue).filter((k) => !values.some((v) => String(v) === k));
|
|
1021
|
+
if (unknown.length)
|
|
1022
|
+
warn(`variant_weights: no variant named ${unknown.map((k) => `"${k}"`).join(', ')} (variants: ${values.join(', ')})`);
|
|
1023
|
+
}
|
|
1014
1024
|
let wcdf = null;
|
|
1015
1025
|
if (wts.length && va)
|
|
1016
1026
|
warn(`variant_weights ignored: the template points have "${p.variant_attrib}", which picks the variants`);
|
|
1017
1027
|
else if (wts.length && values.length) {
|
|
1018
|
-
if (wts.length !== values.length)
|
|
1028
|
+
if (!byValue && wts.length !== values.length)
|
|
1019
1029
|
warn(`variant_weights has ${wts.length} weight(s) for ${values.length} variant(s) (${values.join(', ')}); missing weights are 0`);
|
|
1020
1030
|
wcdf = new Float64Array(values.length);
|
|
1021
1031
|
let acc = 0;
|
|
@@ -1047,7 +1057,7 @@ defineNode({
|
|
|
1047
1057
|
};
|
|
1048
1058
|
pieceOf = (i) => subsets.get(pieceKey(i)) ?? new Geometry();
|
|
1049
1059
|
}
|
|
1050
|
-
else if (
|
|
1060
|
+
else if (p.variant_weights && typeof p.variant_weights === 'object' && Object.keys(p.variant_weights).length)
|
|
1051
1061
|
warn('variant_weights needs variants ("name" or "piece") or piece_attrib to split the source into variants');
|
|
1052
1062
|
const copyNames = new Set();
|
|
1053
1063
|
const pats = String(p.copy_attribs).split(/[\s,]+/).filter(Boolean);
|