topotopo 0.1.0 → 0.2.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 +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- 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/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 +113 -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.js +32 -18
- package/dist/nodes/code.js +2 -0
- 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 +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -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 +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- package/shelf/wall_segment.json +2 -2
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// The human-input layer for agents: the files people drop into <workspace>/inputs (meshes, guide curves and points,
|
|
2
|
+
// PNG masks and heightmaps) and what each one holds: curves vs meshes vs points, bbox, names and roles, attributes,
|
|
3
|
+
// materials. Registered by makeTools in tools.ts; help topic "inputs" (docs.ts) explains the guide conventions.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { inputExt, inputKind } from '../workspace.js';
|
|
7
|
+
import { decodePNG } from '../io/png-decode.js';
|
|
8
|
+
import { imageNameOf } from '../io/textures.js';
|
|
9
|
+
import { readMaterials, MATERIAL_ATTRIB } from '../core/materials.js';
|
|
10
|
+
import { didYouMean } from '../graph/params.js';
|
|
11
|
+
import { fmtNum } from '../render/raster.js';
|
|
12
|
+
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
|
|
13
|
+
/** Most distinct values of `name` / `role` listed per class. */
|
|
14
|
+
const MAX_VALUES = 50;
|
|
15
|
+
/** `inputs list` stops describing files it has not seen before after this long (the rest say so). */
|
|
16
|
+
const LIST_BUDGET_MS = 15_000;
|
|
17
|
+
/** name prefixes that mark a guide's role (help topic "inputs") */
|
|
18
|
+
export const ROLES = ['site', 'path', 'zone', 'volume', 'blockout'];
|
|
19
|
+
const n = (x) => x.toLocaleString('en-US');
|
|
20
|
+
const plural = (k, one, many = one + 's') => `${n(k)} ${k === 1 ? one : many}`;
|
|
21
|
+
const sizeText = (b) => (b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${(b / 1024).toFixed(1)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`);
|
|
22
|
+
const dateText = (ms) => {
|
|
23
|
+
const d = new Date(ms), p = (x) => String(x).padStart(2, '0');
|
|
24
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
25
|
+
};
|
|
26
|
+
const typeText = (a) => (a.isArray ? a.type : a.size === 1 ? a.type : `${a.type}${a.size}`);
|
|
27
|
+
/** where people get a readable file from formats we do not read */
|
|
28
|
+
const OTHER_HINTS = {
|
|
29
|
+
fbx: 'FBX is not read: export glTF (.glb) instead', dae: 'export glTF (.glb) instead', '3ds': 'export glTF (.glb) instead',
|
|
30
|
+
abc: 'Alembic is not read: export glTF (.glb) or .geo', usd: 'USD is written, not read: export glTF (.glb)', usda: 'USD is written, not read: export glTF (.glb)',
|
|
31
|
+
usdc: 'USD is written, not read: export glTF (.glb)', usdz: 'USD is written, not read: export glTF (.glb)',
|
|
32
|
+
jpg: 'only PNG images are read: save it as PNG', jpeg: 'only PNG images are read: save it as PNG', tif: 'only PNG images are read: save it as PNG (16-bit grey for heightmaps)',
|
|
33
|
+
tiff: 'only PNG images are read: save it as PNG (16-bit grey for heightmaps)', exr: 'only PNG images are read: save it as 16-bit PNG', hdr: 'only PNG images are read',
|
|
34
|
+
webp: 'only PNG images are read: save it as PNG', sc: 'compressed Houdini geometry is not read: save .geo or uncompressed .bgeo', gz: 'compressed files are not read: unpack it first',
|
|
35
|
+
blend1: 'a Blender backup file (the .blend next to it is the current one)',
|
|
36
|
+
};
|
|
37
|
+
const READABLE = 'geometry: obj stl ply glb gltf geo bgeo csv json points.json, and blend with Blender installed; images: png';
|
|
38
|
+
/** inputs/ file → its description, keyed by path and invalidated by the host's file stamp (mtime + size). */
|
|
39
|
+
export class InputDescriber {
|
|
40
|
+
ws;
|
|
41
|
+
cache = new Map();
|
|
42
|
+
constructor(ws) { this.ws = ws; }
|
|
43
|
+
/** Describe one file (cached while its stamp holds). */
|
|
44
|
+
describe(f) {
|
|
45
|
+
const rel = `inputs/${f.path}`;
|
|
46
|
+
const stamp = this.ws.engine.host.fileStamp(rel);
|
|
47
|
+
const hit = this.cache.get(f.path);
|
|
48
|
+
if (hit && hit.stamp === stamp)
|
|
49
|
+
return hit.d;
|
|
50
|
+
const d = (f.kind === 'geometry' ? this.geometry(rel, f.ext) : f.kind === 'image' ? this.image(rel) : Promise.resolve(other(f.ext)))
|
|
51
|
+
.catch((e) => ({ short: `cannot read: ${e?.message ?? e}`, long: `cannot read: ${e?.message ?? e}`, failed: true }));
|
|
52
|
+
this.cache.set(f.path, { stamp, d });
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
55
|
+
/** Whether describe() would answer from the cache. */
|
|
56
|
+
cached(f) {
|
|
57
|
+
const hit = this.cache.get(f.path);
|
|
58
|
+
return !!hit && hit.stamp === this.ws.engine.host.fileStamp(`inputs/${f.path}`);
|
|
59
|
+
}
|
|
60
|
+
/** Forget files that are gone. */
|
|
61
|
+
prune(keep) {
|
|
62
|
+
const live = new Set(keep.map((f) => f.path));
|
|
63
|
+
for (const k of [...this.cache.keys()])
|
|
64
|
+
if (!live.has(k))
|
|
65
|
+
this.cache.delete(k);
|
|
66
|
+
}
|
|
67
|
+
/** Cook the one-node graph a file node would be (never a workspace graph) and read the result. */
|
|
68
|
+
async geometry(rel, ext) {
|
|
69
|
+
const r = await this.ws.engine.cook({ nodes: { f: { type: 'file', params: { path: rel } } }, output: 'f' });
|
|
70
|
+
const warnings = r.warnings.map((w) => w.message);
|
|
71
|
+
if (!r.ok || !r.geo) {
|
|
72
|
+
const err = r.errors.map((e) => e.message + (e.hint ? ` (hint: ${e.hint})` : '')).join('; ') || 'no geometry';
|
|
73
|
+
let why = ext === 'blend' ? '\n.blend files are converted by a headless Blender: it must be installed (and found) for the file node to read them.' : '';
|
|
74
|
+
if (ext === 'json') {
|
|
75
|
+
try {
|
|
76
|
+
const j = JSON.parse(new TextDecoder().decode(await this.ws.engine.host.readFile(rel)));
|
|
77
|
+
if (j && typeof j === 'object' && !Array.isArray(j) && j.nodes && typeof j.nodes === 'object')
|
|
78
|
+
why = '\nThis JSON is a topotopo graph, not geometry: build it (build {graph}) instead of reading it with a file node.';
|
|
79
|
+
}
|
|
80
|
+
catch { /* not JSON at all: the error says so */ }
|
|
81
|
+
}
|
|
82
|
+
return { short: `cook failed: ${err}`, long: `the file node failed: ${err}${why}${warnings.length ? '\nwarnings:\n ' + warnings.join('\n ') : ''}`, failed: true };
|
|
83
|
+
}
|
|
84
|
+
return geometryText(r.geo, rel, ext, warnings);
|
|
85
|
+
}
|
|
86
|
+
async image(rel) {
|
|
87
|
+
const img = decodePNG(await this.ws.engine.host.readFile(rel));
|
|
88
|
+
const ch = img.channels, names = ['grey', 'grey + alpha', 'RGB', 'RGBA'][ch - 1];
|
|
89
|
+
const lo = new Array(ch).fill(Infinity), hi = new Array(ch).fill(-Infinity), sum = new Array(ch).fill(0);
|
|
90
|
+
const d = img.data, px = img.width * img.height;
|
|
91
|
+
for (let i = 0; i < px; i++)
|
|
92
|
+
for (let c = 0; c < ch; c++) {
|
|
93
|
+
const v = d[i * ch + c];
|
|
94
|
+
if (v < lo[c])
|
|
95
|
+
lo[c] = v;
|
|
96
|
+
if (v > hi[c])
|
|
97
|
+
hi[c] = v;
|
|
98
|
+
sum[c] += v;
|
|
99
|
+
}
|
|
100
|
+
const label = (c) => (ch <= 2 ? ['grey', 'alpha'][c] : 'rgba'[c]);
|
|
101
|
+
const ranges = lo.map((_, c) => `${label(c)} ${fmtNum(lo[c] / img.maxValue)}..${fmtNum(hi[c] / img.maxValue)} (mean ${fmtNum(sum[c] / px / img.maxValue)})`);
|
|
102
|
+
const short = `${img.width} × ${img.height} px, ${img.bitDepth}-bit ${names} (${plural(ch, 'channel')})`;
|
|
103
|
+
const [w, h] = [img.width, img.height];
|
|
104
|
+
const long = [
|
|
105
|
+
`image: ${short}`,
|
|
106
|
+
`values (0..1): ${ranges.join(', ')}`,
|
|
107
|
+
'read it as:',
|
|
108
|
+
` a heightmap {"type":"heightfield_from_image","params":{"path":"${rel}","size":[${w - 1},${h - 1}],"height_min":0,"height_max":20}}`,
|
|
109
|
+
' (size = the extent in metres in X and Z; height_min/max = the heights of black and white; 16-bit greys keep the detail)',
|
|
110
|
+
` a mask layer {"type":"heightfield_from_image","in":["terrain"],"params":{"path":"${rel}","layer":"mask","height_min":0,"height_max":1}}`,
|
|
111
|
+
' (stretched over the connected heightfield; then heightfield_scatter, mask_layer, …)',
|
|
112
|
+
` on points {"type":"attribute_from_map","in":["pts"],"params":{"path":"${rel}","projection":"xz","size":[100,100],"name":"mask"}}`,
|
|
113
|
+
' (top view: column along +X, row 0 at min Z; then scatter density_mask, prune, copy_to_points scale_attrib)',
|
|
114
|
+
` a texture {"type":"image_file","params":{"path":"${rel}"}}`,
|
|
115
|
+
` (an image "${imageNameOf(rel)}" for the texture nodes and texture_to_material; a file node reads it the same way)`,
|
|
116
|
+
].join('\n');
|
|
117
|
+
return { short, long, failed: false };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function other(ext) {
|
|
121
|
+
const hint = OTHER_HINTS[ext];
|
|
122
|
+
const what = ext ? `.${ext}` : 'a file without an extension';
|
|
123
|
+
return {
|
|
124
|
+
short: `not read${hint ? `: ${hint}` : ''}`,
|
|
125
|
+
long: `${what} is not a format topotopo reads${hint ? `: ${hint}` : ''}. Readable: ${READABLE}.`,
|
|
126
|
+
failed: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Distinct values of a string attribute with counts, sorted by value. */
|
|
130
|
+
function stringValues(a, count) {
|
|
131
|
+
const freq = new Map();
|
|
132
|
+
for (let i = 0; i < count; i++) {
|
|
133
|
+
const v = String(a.get(i));
|
|
134
|
+
freq.set(v, (freq.get(v) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
return [...freq].sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
|
|
137
|
+
}
|
|
138
|
+
/** Prim kinds: 0 polygon, 1 open polyline, 2 packed, 3 spline curve. */
|
|
139
|
+
function primKinds(g) {
|
|
140
|
+
const k = new Uint8Array(g.numPrims);
|
|
141
|
+
for (let p = 0; p < g.numPrims; p++) {
|
|
142
|
+
const pk = g.primKind ? g.primKind[p] : 0;
|
|
143
|
+
k[p] = pk === 1 ? 2 : pk === 2 || pk === 3 ? 3 : g.primClosed[p] ? 0 : 1;
|
|
144
|
+
}
|
|
145
|
+
return k;
|
|
146
|
+
}
|
|
147
|
+
const KIND_NAMES = [['polygon', 'polygons'], ['polyline', 'polylines'], ['packed prim', 'packed prims'], ['spline curve', 'spline curves']];
|
|
148
|
+
const kindsText = (c) => c.map((x, i) => (x ? plural(x, KIND_NAMES[i][0], KIND_NAMES[i][1]) : '')).filter(Boolean).join(' + ');
|
|
149
|
+
/** A role value, or the role of a name by its prefix like the wrangle of help topic "inputs" ("Path.River.001" → path). */
|
|
150
|
+
const roleOf = (s, isName) => {
|
|
151
|
+
const r = (isName ? s.split('.')[0] : s).toLowerCase();
|
|
152
|
+
return ROLES.includes(r) ? r : null;
|
|
153
|
+
};
|
|
154
|
+
/** What a guide of this role should be made of, given the prims carrying it (counts per kind, see primKinds). */
|
|
155
|
+
function roleChecks(role, v, k) {
|
|
156
|
+
const curves = k[1] + k[3], what = kindsText(k);
|
|
157
|
+
if (role === 'path' && !curves)
|
|
158
|
+
return [`${v} is ${what}: a path is an open polyline (in Blender a curve, or a mesh of edges only)`];
|
|
159
|
+
if (role === 'zone' && !k[0] && curves)
|
|
160
|
+
return [`${v} is ${what}: a zone is a closed area; ends {mode:"close"} turns closed loops into polygons`];
|
|
161
|
+
if (role === 'site' && (k[0] || curves))
|
|
162
|
+
return [`${v} is ${what}: a site is a point; pack {by:"name", pivot:"bbox_center_bottom"} gives one anchor point per object`];
|
|
163
|
+
if ((role === 'volume' || role === 'blockout') && !k[0] && !k[2])
|
|
164
|
+
return [`${v} is ${what}: a ${role} is a mesh (a box or a closed solid)`];
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
function geometryText(g, rel, ext, warnings) {
|
|
168
|
+
const kinds = primKinds(g);
|
|
169
|
+
const counts = [0, 0, 0, 0];
|
|
170
|
+
for (let p = 0; p < g.numPrims; p++)
|
|
171
|
+
counts[kinds[p]]++;
|
|
172
|
+
const used = new Uint8Array(g.numPoints);
|
|
173
|
+
for (let v = 0; v < g.numVertices; v++)
|
|
174
|
+
used[g.vertexPoint[v]] = 1;
|
|
175
|
+
let loose = 0;
|
|
176
|
+
for (let i = 0; i < g.numPoints; i++)
|
|
177
|
+
if (!used[i])
|
|
178
|
+
loose++;
|
|
179
|
+
const lines = [];
|
|
180
|
+
const elems = [kindsText(counts), loose ? plural(loose, 'loose point') : ''].filter(Boolean).join(' + ') || 'empty';
|
|
181
|
+
lines.push(`elements: ${plural(g.numPoints, 'point')}, ${plural(g.numPrims, 'prim')} = ${elems}`);
|
|
182
|
+
lines.push(` (polygons are closed faces, polylines open curves, loose points belong to no prim, packed prims are instances${counts[3] ? ', spline curves Bezier/NURBS' : ''})`);
|
|
183
|
+
const vols = g.volumes.length ? g.volumes.map((v) => `${v.kind} "${v.name}"`).join(', ') : '';
|
|
184
|
+
if (vols)
|
|
185
|
+
lines.push(`volumes: ${vols}`);
|
|
186
|
+
const b = g.bbox(true);
|
|
187
|
+
const size = [0, 1, 2].map((a) => b.max[a] - b.min[a]);
|
|
188
|
+
const v3 = (a) => `(${a.map(fmtNum).join(', ')})`;
|
|
189
|
+
const bboxShort = b.empty ? 'no bbox' : `bbox ${size.map(fmtNum).join(' × ')} m`;
|
|
190
|
+
if (!b.empty)
|
|
191
|
+
lines.push(`bbox (metres, as read, Y up): min ${v3(b.min)} max ${v3(b.max)} size ${size.map(fmtNum).join(' × ')}`);
|
|
192
|
+
// attributes and groups
|
|
193
|
+
const attrs = [];
|
|
194
|
+
for (const cls of ['point', 'vertex', 'prim', 'detail']) {
|
|
195
|
+
const t = g.table(cls);
|
|
196
|
+
if (t.map.size)
|
|
197
|
+
attrs.push(` ${cls}: ${[...t.map].map(([k, a]) => `${k} ${typeText(a)}`).join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
if (attrs.length)
|
|
200
|
+
lines.push('attributes:', ...attrs);
|
|
201
|
+
const grp = [];
|
|
202
|
+
for (const cls of ['point', 'prim', 'vertex']) {
|
|
203
|
+
const m = g.groups(cls);
|
|
204
|
+
if (m.size)
|
|
205
|
+
grp.push(`${cls}: ${[...m].map(([k, mask]) => `${k} ${n(mask.reduce((s, x) => s + x, 0))}`).join(', ')}`);
|
|
206
|
+
}
|
|
207
|
+
if (g.edgeGroups.size)
|
|
208
|
+
grp.push(`edge: ${[...g.edgeGroups].map(([k, set]) => `${k} ${n(set.size)}`).join(', ')}`);
|
|
209
|
+
lines.push(grp.length ? `groups: ${grp.join(' ')}` : 'groups: none');
|
|
210
|
+
// name and role values, with the kind of prims that carry each
|
|
211
|
+
const isStr = (a) => !!a && a.type === 'string' && !a.isArray;
|
|
212
|
+
const kindsOf = (a) => {
|
|
213
|
+
const m = new Map();
|
|
214
|
+
for (let p = 0; p < g.numPrims; p++) {
|
|
215
|
+
const v = String(a.get(p));
|
|
216
|
+
let c = m.get(v);
|
|
217
|
+
if (!c)
|
|
218
|
+
m.set(v, (c = [0, 0, 0, 0]));
|
|
219
|
+
c[kinds[p]]++;
|
|
220
|
+
}
|
|
221
|
+
return m;
|
|
222
|
+
};
|
|
223
|
+
const shortNames = [];
|
|
224
|
+
for (const attr of ['name', 'role'])
|
|
225
|
+
for (const cls of ['prim', 'point']) {
|
|
226
|
+
const a = g.table(cls).get(attr);
|
|
227
|
+
if (!isStr(a))
|
|
228
|
+
continue;
|
|
229
|
+
const vals = stringValues(a, cls === 'prim' ? g.numPrims : g.numPoints);
|
|
230
|
+
const per = cls === 'prim' ? kindsOf(a) : null;
|
|
231
|
+
lines.push(`${cls} ${attr} (${plural(vals.length, 'value')}):`);
|
|
232
|
+
for (const [v, c] of vals.slice(0, MAX_VALUES))
|
|
233
|
+
lines.push(` ${JSON.stringify(v)} ${per ? kindsText(per.get(v)) : plural(c, 'point')}`);
|
|
234
|
+
if (vals.length > MAX_VALUES)
|
|
235
|
+
lines.push(` … ${plural(vals.length - MAX_VALUES, 'more value')}`);
|
|
236
|
+
if (attr === 'name' && !shortNames.length)
|
|
237
|
+
shortNames.push(...vals.map(([v]) => v));
|
|
238
|
+
}
|
|
239
|
+
// roles, per element as the wrangle of help topic "inputs" derives them: the role attribute, else the name prefix
|
|
240
|
+
const objects = new Map(), points = new Map(), checks = [];
|
|
241
|
+
let fromAttr = false, fromName = false;
|
|
242
|
+
for (const cls of ['prim', 'point']) {
|
|
243
|
+
const ra = g.table(cls).get('role'), na = g.table(cls).get('name');
|
|
244
|
+
const roleA = isStr(ra) ? ra : null, nameA = isStr(na) ? na : null;
|
|
245
|
+
if (!roleA && !nameA)
|
|
246
|
+
continue;
|
|
247
|
+
for (let i = 0, count = cls === 'prim' ? g.numPrims : g.numPoints; i < count; i++) {
|
|
248
|
+
const rv = roleA ? String(roleA.get(i)) : '', nv = nameA ? String(nameA.get(i)) : '';
|
|
249
|
+
const role = rv ? roleOf(rv, false) : roleOf(nv, true);
|
|
250
|
+
if (!role)
|
|
251
|
+
continue;
|
|
252
|
+
if (rv)
|
|
253
|
+
fromAttr = true;
|
|
254
|
+
else
|
|
255
|
+
fromName = true;
|
|
256
|
+
if (cls === 'point') {
|
|
257
|
+
points.set(role, (points.get(role) ?? 0) + 1);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
let objs = objects.get(role);
|
|
261
|
+
if (!objs)
|
|
262
|
+
objects.set(role, (objs = new Map()));
|
|
263
|
+
const label = nv || rv;
|
|
264
|
+
let k = objs.get(label);
|
|
265
|
+
if (!k)
|
|
266
|
+
objs.set(label, (k = [0, 0, 0, 0]));
|
|
267
|
+
k[kinds[i]]++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const roleNames = ROLES.filter((r) => objects.has(r) || points.has(r));
|
|
271
|
+
if (roleNames.length) {
|
|
272
|
+
const src = fromAttr && fromName ? 'the role attribute and name prefixes' : fromAttr ? 'the role attribute' : 'name prefixes';
|
|
273
|
+
lines.push(`roles (from ${src}): ${roleNames.map((r) => `${r} ${[objects.has(r) ? plural(objects.get(r).size, 'object') : '', points.has(r) ? plural(points.get(r), 'point') : ''].filter(Boolean).join(' + ')}`).join(', ')}`);
|
|
274
|
+
for (const [role, objs] of objects)
|
|
275
|
+
for (const [label, k] of objs)
|
|
276
|
+
checks.push(...roleChecks(role, JSON.stringify(label), k));
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
lines.push('roles: none marked (name objects site.* path.* zone.* volume.* blockout.*, or give them a string attribute "role"; help topic "inputs")');
|
|
280
|
+
for (const m of checks)
|
|
281
|
+
lines.push(` check: ${m}`);
|
|
282
|
+
// materials
|
|
283
|
+
const mat = g.table('prim').get(MATERIAL_ATTRIB);
|
|
284
|
+
const table = Object.keys(readMaterials(g));
|
|
285
|
+
if ((mat && mat.type === 'string') || table.length) {
|
|
286
|
+
const used = mat && mat.type === 'string' ? stringValues(mat, g.numPrims).filter(([v]) => v !== '') : [];
|
|
287
|
+
const unused = table.filter((t) => !used.some(([v]) => v === t));
|
|
288
|
+
lines.push(`materials: ${used.length ? used.slice(0, MAX_VALUES).map(([v, c]) => `${JSON.stringify(v)} ${plural(c, 'prim')}`).join(', ') : 'none assigned'}${used.length > MAX_VALUES ? `, … ${used.length - MAX_VALUES} more` : ''}${unused.length ? `; in the table only: ${unused.join(', ')}` : ''}`);
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
lines.push('materials: none');
|
|
292
|
+
if (warnings.length)
|
|
293
|
+
lines.push('warnings:', ...warnings.map((w) => ` ${w}`));
|
|
294
|
+
// up axis: OBJ/STL/PLY carry none
|
|
295
|
+
const flatY = !b.empty && size[2] < 0.1 * Math.max(size[0], size[1]) && size[1] > 0.5 * size[0] && size[1] > 0;
|
|
296
|
+
if (['obj', 'stl', 'ply'].includes(ext) && flatY)
|
|
297
|
+
lines.push(`up axis: this looks Z-up (flat in Z, spread in X and Y): read it with "up_axis":"z" if it should lie on the ground.`);
|
|
298
|
+
lines.push('up axis: OBJ/STL/PLY exports from Blender (with Up = Z) and from Unreal are Z-up and need "up_axis":"z" on the file node; glTF is +Y up already.');
|
|
299
|
+
lines.push(`read it: {"type":"file","params":{"path":"${rel}"}}`);
|
|
300
|
+
const names = shortNames.length ? `; name: ${shortNames.slice(0, 6).join(', ')}${shortNames.length > 6 ? ` (+${shortNames.length - 6})` : ''}` : '';
|
|
301
|
+
const short = `${plural(g.numPoints, 'point')}, ${plural(g.numPrims, 'prim')} = ${elems}; ${bboxShort}${names}${warnings.length ? `; ${plural(warnings.length, 'warning')}` : ''}`;
|
|
302
|
+
return { short, long: lines.join('\n'), failed: false };
|
|
303
|
+
}
|
|
304
|
+
/** "inputs/a.obj", "./a.obj", "a.obj" → "a.obj" (posix, inside inputs/), or an Error naming the files there. */
|
|
305
|
+
function resolveInput(ws, p, files) {
|
|
306
|
+
if (typeof p !== 'string' || !p.trim())
|
|
307
|
+
throw new Error('"path" is required: a file under inputs/ (inputs {action:"list"} lists them)');
|
|
308
|
+
const rel = p.trim().replace(/\\/g, '/').replace(/^(\.\/)+/, '');
|
|
309
|
+
if (path.isAbsolute(rel) || /^[a-z]:\//i.test(rel) || rel.split('/').includes('..'))
|
|
310
|
+
throw new Error(`"${p}": give a path inside inputs/, relative to it (e.g. "guides.glb")`);
|
|
311
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
312
|
+
const hit = byPath.get(rel) ?? (rel.startsWith('inputs/') ? byPath.get(rel.slice(7)) : undefined);
|
|
313
|
+
if (hit)
|
|
314
|
+
return hit;
|
|
315
|
+
// a file the walk skipped (dot-file) or that just arrived: describe it if it is there
|
|
316
|
+
const abs = path.join(ws.inputDir, rel.startsWith('inputs/') && !fs.existsSync(path.join(ws.inputDir, rel)) ? rel.slice(7) : rel);
|
|
317
|
+
try {
|
|
318
|
+
const st = fs.statSync(abs);
|
|
319
|
+
if (st.isFile()) {
|
|
320
|
+
const r = path.relative(ws.inputDir, abs).split(path.sep).join('/'), ext = inputExt(r);
|
|
321
|
+
return { path: r, kind: inputKind(ext), ext, size: st.size, mtime: Math.round(st.mtimeMs) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch { /* not there */ }
|
|
325
|
+
const s = didYouMean(rel.replace(/^inputs\//, ''), files.map((f) => f.path));
|
|
326
|
+
throw new Error(`no input "${rel}"${s.length ? ` — did you mean ${s.join(', ')}?` : ''}. ${files.length ? `Files: ${files.slice(0, 20).map((f) => f.path).join(', ')}${files.length > 20 ? ', …' : ''}` : `inputs/ is empty (${ws.inputDir})`}`);
|
|
327
|
+
}
|
|
328
|
+
export function inputsTool(ws) {
|
|
329
|
+
const describer = new InputDescriber(ws);
|
|
330
|
+
return {
|
|
331
|
+
name: 'inputs',
|
|
332
|
+
description: 'The human\'s own material: files people drop into the workspace inputs/ folder (meshes, guide curves and points from Blender/Houdini/Unreal, blockouts, PNG masks and heightmaps). list: every file with kind (geometry/image/other), size, date and a summary (counts, curves vs meshes, bbox, object names; PNG size and bit depth). describe {path}: what a file node reads from it: polygons vs open polylines vs loose points vs packed prims, bbox in metres, attributes, groups, every name and role value, materials, warnings. Call list FIRST when building a landscape or scene: guides mark sites, paths, zones, volumes and blockouts (help topic "inputs"). Read a file with {type:"file", params:{path:"inputs/<path>"}}.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: 'object',
|
|
335
|
+
required: ['action'],
|
|
336
|
+
properties: {
|
|
337
|
+
action: { type: 'string', enum: ['list', 'describe'] },
|
|
338
|
+
path: { type: 'string', description: 'describe: the file, relative to inputs/ (as list prints it)' },
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
run: async (a) => {
|
|
342
|
+
const files = ws.listInputs();
|
|
343
|
+
describer.prune(files);
|
|
344
|
+
if (a.action === 'describe') {
|
|
345
|
+
const f = resolveInput(ws, a.path, files);
|
|
346
|
+
const d = await describer.describe(f);
|
|
347
|
+
const head = `inputs/${f.path}: ${f.kind}${f.ext ? ` (.${f.ext})` : ''}, ${sizeText(f.size)}, modified ${dateText(f.mtime)}`;
|
|
348
|
+
return text(`${head}\n${d.long}`, d.failed);
|
|
349
|
+
}
|
|
350
|
+
if (a.action !== 'list')
|
|
351
|
+
return text(`unknown action "${a.action}": use list or describe {path}`, true);
|
|
352
|
+
if (!files.length)
|
|
353
|
+
return text(`no input files yet. People drop meshes, guide curves and points, blockouts and PNG masks into ${ws.inputDir} (or onto the live viewer); then inputs list shows them. Readable: ${READABLE}.`);
|
|
354
|
+
const t0 = Date.now();
|
|
355
|
+
const rows = [];
|
|
356
|
+
let skipped = 0;
|
|
357
|
+
for (const f of files) {
|
|
358
|
+
let summary;
|
|
359
|
+
if (f.kind === 'other' || describer.cached(f) || Date.now() - t0 < LIST_BUDGET_MS)
|
|
360
|
+
summary = (await describer.describe(f)).short;
|
|
361
|
+
else {
|
|
362
|
+
summary = 'not summarised yet (list time budget): inputs describe reads it';
|
|
363
|
+
skipped++;
|
|
364
|
+
}
|
|
365
|
+
rows.push(` ${f.path} ${f.kind} ${sizeText(f.size)} ${dateText(f.mtime)} ${summary}`);
|
|
366
|
+
}
|
|
367
|
+
const kinds = ['geometry', 'image', 'other'].map((k) => [k, files.filter((f) => f.kind === k).length]).filter(([, c]) => c);
|
|
368
|
+
return text([
|
|
369
|
+
`inputs/ (${ws.inputDir}): ${plural(files.length, 'file')} (${kinds.map(([k, c]) => `${c} ${k}`).join(', ')})`,
|
|
370
|
+
...rows,
|
|
371
|
+
...(skipped ? [`${plural(skipped, 'file')} not summarised within ${LIST_BUDGET_MS / 1000} s; list again (summaries are cached) or describe them one by one.`] : []),
|
|
372
|
+
'A file node reads one: {"type":"file","params":{"path":"inputs/<path>"}} (paths are relative to the workspace). inputs {action:"describe", path} shows curves vs meshes, every name and role, attributes and materials; help {topic:"inputs"} explains the guide roles (site, path, zone, volume, blockout) and recipes.',
|
|
373
|
+
'OBJ/STL from Blender (Up = Z) and Unreal are Z-up: add "up_axis":"z". A .blend needs Blender installed (converted headlessly).',
|
|
374
|
+
].join('\n'));
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// topotopo mcp [--workspace <dir>] [--viewer] (installed package: src/cli.ts dispatches here)
|
|
5
5
|
// Env: TOPOTOPO_WORKSPACE, TOPOTOPO_VIEWER=1, TOPOTOPO_PORT, TOPOTOPO_SCRIPT_TIMEOUT_MS, TOPOTOPO_SIM_TIMEOUT_MS, TOPOTOPO_WORKERS
|
|
6
6
|
// stdout carries the MCP protocol — log only to stderr.
|
|
7
|
+
import fs from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -41,7 +42,9 @@ const ensureViewer = async () => {
|
|
|
41
42
|
if (argv.includes('--viewer') || process.env.TOPOTOPO_VIEWER === '1')
|
|
42
43
|
await ensureViewer();
|
|
43
44
|
const tools = makeTools(ws, { onPreview: ensureViewer });
|
|
44
|
-
|
|
45
|
+
// package.json sits two levels above both src/mcp/server.ts and dist/mcp/server.js
|
|
46
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
47
|
+
const server = new Server({ name: 'topotopo', version: pkg.version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
45
48
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
46
49
|
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
47
50
|
}));
|
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';
|
|
@@ -16,6 +16,7 @@ import { exportFilesAsync } from '../io/files.js';
|
|
|
16
16
|
import { evalGroup } from '../core/groups.js';
|
|
17
17
|
import { Geometry } from '../core/geometry.js';
|
|
18
18
|
import { verifyTools } from './verify.js';
|
|
19
|
+
import { inputsTool } from './inputs.js';
|
|
19
20
|
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError } : {}) });
|
|
20
21
|
/** " at frame N" when the cook was not at frame 1. */
|
|
21
22
|
const atFrame = (r) => (r.frame !== 1 ? ` at frame ${r.frame}` : '');
|
|
@@ -131,6 +132,7 @@ const RENDER_PROPS = {
|
|
|
131
132
|
glyph_attrib: { type: 'string', description: 'attribute for glyphs "custom": 3 floats = vector, 4 floats = quaternion' },
|
|
132
133
|
glyph_scale: { type: 'number', description: 'glyph length in world units (default 5% of the bbox diagonal)' },
|
|
133
134
|
max_glyphs: { type: 'number', description: 'evenly subsample to at most this many glyphs (default 400)' },
|
|
135
|
+
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
136
|
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
137
|
label_pieces: { type: 'boolean', description: 'number connected pieces (#0, #1, … in connectivity order)' },
|
|
136
138
|
label_attrib: { type: 'string', description: 'label each distinct value of a string/int attribute (e.g. "name") on its elements; colours match color_by' },
|
|
@@ -147,9 +149,8 @@ const RENDER_PROPS = {
|
|
|
147
149
|
};
|
|
148
150
|
export function makeTools(ws, opts = {}) {
|
|
149
151
|
const engine = ws.engine;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
ws.bump(name);
|
|
152
|
+
/** The text (+ optional render) of a cook report. */
|
|
153
|
+
const reportResult = (name, r, a, extraNotes = []) => {
|
|
153
154
|
const content = [{ type: 'text', text: reportText(name, r, extraNotes) }];
|
|
154
155
|
if (r.geo && a.render) {
|
|
155
156
|
const ro = renderOpts(typeof a.render === 'object' ? a.render : {}, r.geo, `${name}/${r.target}${atFrame(r)}`);
|
|
@@ -160,8 +161,13 @@ export function makeTools(ws, opts = {}) {
|
|
|
160
161
|
}
|
|
161
162
|
return { content, isError: !r.ok };
|
|
162
163
|
};
|
|
164
|
+
const cookAndReport = async (name, a, extraNotes = []) => {
|
|
165
|
+
const r = await ws.cook(name, undefined, a.frame);
|
|
166
|
+
ws.bump(name);
|
|
167
|
+
return reportResult(name, r, a, extraNotes);
|
|
168
|
+
};
|
|
163
169
|
const resolveNodeGeo = async (a) => {
|
|
164
|
-
const name = a.graph ?? 'main';
|
|
170
|
+
const name = a.graph ?? a.name ?? 'main'; // `name` is what build/edit call it; accept both
|
|
165
171
|
const g = ws.getGraph(name);
|
|
166
172
|
const target = a.node || undefined;
|
|
167
173
|
const r = await engine.cook(g, { target, frame: a.frame });
|
|
@@ -172,7 +178,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
172
178
|
const tools = [
|
|
173
179
|
{
|
|
174
180
|
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.',
|
|
181
|
+
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
182
|
inputSchema: {
|
|
177
183
|
type: 'object',
|
|
178
184
|
properties: {
|
|
@@ -181,7 +187,11 @@ export function makeTools(ws, opts = {}) {
|
|
|
181
187
|
include_deprecated: { type: 'boolean', description: 'also list deprecated node types (hidden by default)' },
|
|
182
188
|
},
|
|
183
189
|
},
|
|
184
|
-
run: async (a) =>
|
|
190
|
+
run: async (a) => {
|
|
191
|
+
const cat = catalogText([...allNodeDefs(), ...[...engine.assets.values()].map((x) => x.def)], { category: a.category, search: a.search, deprecated: a.include_deprecated });
|
|
192
|
+
const nIn = ws.listInputs().length;
|
|
193
|
+
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").`);
|
|
194
|
+
},
|
|
185
195
|
},
|
|
186
196
|
{
|
|
187
197
|
name: 'help',
|
|
@@ -213,12 +223,12 @@ export function makeTools(ws, opts = {}) {
|
|
|
213
223
|
},
|
|
214
224
|
{
|
|
215
225
|
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.',
|
|
226
|
+
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
227
|
inputSchema: {
|
|
218
228
|
type: 'object',
|
|
219
229
|
required: ['graph'],
|
|
220
230
|
properties: {
|
|
221
|
-
graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}}' },
|
|
231
|
+
graph: { type: 'object', description: '{"nodes": {name: {"type", "in", "params"}}, "output": name, "vars": {...}, "params": {var: {"type", "desc", "min", "max", "options", "label", "step"}}}' },
|
|
222
232
|
name: { type: 'string', description: 'graph name (default "main")' },
|
|
223
233
|
mode: { type: 'string', enum: ['replace', 'merge'] },
|
|
224
234
|
dry_run: { type: 'boolean' },
|
|
@@ -262,12 +272,16 @@ export function makeTools(ws, opts = {}) {
|
|
|
262
272
|
},
|
|
263
273
|
run: async (a) => {
|
|
264
274
|
const name = a.name ?? 'main';
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
let res;
|
|
276
|
+
try {
|
|
277
|
+
res = await ws.edit(name, a.ops, { frame: a.frame });
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
if (e instanceof EditRejected)
|
|
281
|
+
return text(e.message, true);
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
return reportResult(name, res.report, a, res.notes);
|
|
271
285
|
},
|
|
272
286
|
},
|
|
273
287
|
{
|
|
@@ -300,7 +314,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
300
314
|
inputSchema: {
|
|
301
315
|
type: 'object',
|
|
302
316
|
properties: {
|
|
303
|
-
graph: { type: 'string' }, node: { type: 'string' },
|
|
317
|
+
graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
|
|
304
318
|
...RENDER_PROPS,
|
|
305
319
|
save_to: { type: 'string', description: 'also write the PNG to this path (relative to the workspace)' },
|
|
306
320
|
...FRAME_PROP,
|
|
@@ -331,7 +345,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
331
345
|
type: 'object',
|
|
332
346
|
required: ['path'],
|
|
333
347
|
properties: {
|
|
334
|
-
graph: { type: 'string' }, node: { type: 'string' },
|
|
348
|
+
graph: { type: 'string', description: 'graph name (default "main")' }, node: { type: 'string', description: 'node to look at (default: the graph output)' },
|
|
335
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)' },
|
|
336
350
|
format: { type: 'string', enum: EXPORT_FORMATS, description: 'default: from the extension (.points.json = pointsjson)' },
|
|
337
351
|
layer: { type: 'string', description: 'heightmaps: layer to write (png16/r16 default "height", mask_png default "mask")' },
|
|
@@ -483,7 +497,7 @@ export function makeTools(ws, opts = {}) {
|
|
|
483
497
|
},
|
|
484
498
|
},
|
|
485
499
|
];
|
|
486
|
-
tools.push(...verifyTools(ws)); // explain, diff
|
|
500
|
+
tools.push(...verifyTools(ws), inputsTool(ws)); // explain, diff; the human's input files
|
|
487
501
|
if (opts.onPreview) {
|
|
488
502
|
tools.push({
|
|
489
503
|
name: 'preview',
|
package/dist/nodes/code.js
CHANGED
|
@@ -127,6 +127,8 @@ function scriptLib(ctx, wake, run) {
|
|
|
127
127
|
const rp = resolveParams(def, params, ctx.vars, { time: ctx, host: engine.host, timeoutMs: engine.scriptTimeoutMs });
|
|
128
128
|
if (rp.errors.length)
|
|
129
129
|
throw new Error(`lib.run(${type}): ${rp.errors.join('; ')}`);
|
|
130
|
+
for (const w of rp.warnings)
|
|
131
|
+
ctx.warn(`lib.run(${type}): ${w}`);
|
|
130
132
|
// inherit from ctx through the prototype so its time getters stay lazy
|
|
131
133
|
const sub = Object.assign(Object.create(ctx), {
|
|
132
134
|
node: `${ctx.node}/${type}`, type: def.type, p: rp.values, inputs,
|
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);
|