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.
Files changed (80) hide show
  1. package/README.md +61 -12
  2. package/dist/cli.js +161 -23
  3. package/dist/graph/engine.js +18 -1
  4. package/dist/graph/params.d.ts +7 -1
  5. package/dist/graph/params.js +21 -8
  6. package/dist/graph/types.d.ts +28 -2
  7. package/dist/host/artifacts.d.ts +21 -0
  8. package/dist/host/artifacts.js +58 -0
  9. package/dist/host/export-targets.d.ts +33 -0
  10. package/dist/host/export-targets.js +84 -0
  11. package/dist/host/export_scene.py +200 -0
  12. package/dist/host/node.d.ts +8 -0
  13. package/dist/host/node.js +114 -0
  14. package/dist/host/types.d.ts +5 -0
  15. package/dist/inspect.js +5 -2
  16. package/dist/io/common.d.ts +6 -0
  17. package/dist/io/export.js +7 -4
  18. package/dist/io/gltf.d.ts +1 -1
  19. package/dist/io/gltf.js +167 -32
  20. package/dist/io/heightmap.d.ts +1 -1
  21. package/dist/io/import.d.ts +2 -2
  22. package/dist/io/import.js +10 -3
  23. package/dist/io/png-decode.d.ts +5 -2
  24. package/dist/io/png-decode.js +10 -3
  25. package/dist/io/targets.d.ts +31 -0
  26. package/dist/io/targets.js +75 -0
  27. package/dist/io/textures.d.ts +31 -2
  28. package/dist/io/textures.js +66 -18
  29. package/dist/io/unreal.js +22 -5
  30. package/dist/mcp/docs.d.ts +9 -2
  31. package/dist/mcp/docs.js +126 -2
  32. package/dist/mcp/inputs.d.ts +28 -0
  33. package/dist/mcp/inputs.js +377 -0
  34. package/dist/mcp/server.js +4 -1
  35. package/dist/mcp/tools.d.ts +1 -1
  36. package/dist/mcp/tools.js +118 -51
  37. package/dist/nodes/code.js +34 -6
  38. package/dist/nodes/copy.js +14 -4
  39. package/dist/nodes/flow.js +136 -35
  40. package/dist/nodes/heightfield.js +67 -11
  41. package/dist/nodes/texture.d.ts +1 -1
  42. package/dist/nodes/texture.js +61 -5
  43. package/dist/render/options.js +2 -1
  44. package/dist/render/raster.d.ts +2 -0
  45. package/dist/render/raster.js +3 -3
  46. package/dist/render/texview.d.ts +9 -1
  47. package/dist/render/texview.js +43 -2
  48. package/dist/viewer/app/api.js +64 -0
  49. package/dist/viewer/app/camera.js +250 -0
  50. package/dist/viewer/app/export-popover.js +68 -0
  51. package/dist/viewer/app/grid.js +217 -0
  52. package/dist/viewer/app/guides.js +402 -0
  53. package/dist/viewer/app/image-view.js +114 -0
  54. package/dist/viewer/app/keymap.js +199 -0
  55. package/dist/viewer/app/layers.js +244 -0
  56. package/dist/viewer/app/main.js +646 -0
  57. package/dist/viewer/app/markers.js +206 -0
  58. package/dist/viewer/app/mesh.js +172 -0
  59. package/dist/viewer/app/outliner.js +225 -0
  60. package/dist/viewer/app/outputs.css +73 -0
  61. package/dist/viewer/app/outputs.js +240 -0
  62. package/dist/viewer/app/props.js +432 -0
  63. package/dist/viewer/app/scene.js +529 -0
  64. package/dist/viewer/app/select.js +153 -0
  65. package/dist/viewer/app/selection.js +79 -0
  66. package/dist/viewer/app/status.js +20 -0
  67. package/dist/viewer/app/style.css +173 -0
  68. package/dist/viewer/app/undo.js +49 -0
  69. package/dist/viewer/app/units.js +51 -0
  70. package/dist/viewer/app/vars.js +64 -0
  71. package/dist/viewer/index.html +67 -184
  72. package/dist/viewer/outputs.d.ts +54 -0
  73. package/dist/viewer/outputs.js +436 -0
  74. package/dist/viewer/server.d.ts +47 -1
  75. package/dist/viewer/server.js +480 -17
  76. package/dist/workspace.d.ts +60 -4
  77. package/dist/workspace.js +131 -5
  78. package/package.json +9 -3
  79. package/shelf/brick_wall.json +1 -1
  80. package/shelf/wall_segment.json +2 -2
package/dist/io/export.js CHANGED
@@ -168,6 +168,9 @@ export function exportPLY(g, o = {}) {
168
168
  const usdName = (s) => (s.replace(/[^A-Za-z0-9_]/g, '_').replace(/^(\d)/, '_$1') || 'mesh');
169
169
  const fmt = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(6).replace(/0+$/, '').replace(/\.$/, ''));
170
170
  const tup = (a, k, n) => '(' + Array.from({ length: n }, (_, c) => fmt(a[k * n + c])).join(', ') + ')';
171
+ /** A colour tuple for USD: TopoTopo colours are display values, USD colour inputs and primvars are linear. */
172
+ const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
173
+ const tupColor = (a, k, n) => '(' + Array.from({ length: n }, (_, c) => fmt(linear(a[k * n + c]))).join(', ') + ')';
171
174
  /**
172
175
  * USDA stage: meshes, curves and points under /root, split by prim name. Packed instances (instancing 'packed', the
173
176
  * default) become one PointInstancer /root/instances whose prototypes (one per packed source, under its
@@ -226,10 +229,10 @@ function usdaMaterials(table, o, out) {
226
229
  const I = ' ';
227
230
  out.push(` def Material "${id}"`, ' {', ` token outputs:surface.connect = <${mp}/surface.outputs:surface>`, '');
228
231
  out.push(`${I}def Shader "surface"`, `${I}{`, `${I} uniform token info:id = "UsdPreviewSurface"`);
229
- out.push(`${I} color3f inputs:diffuseColor = ${tup(m.baseColor, 0, 3)}`);
232
+ out.push(`${I} color3f inputs:diffuseColor = ${tupColor(m.baseColor, 0, 3)}`);
230
233
  if (tex.baseColor)
231
234
  out.push(`${I} color3f inputs:diffuseColor.connect = <${mp}/base_color_map.outputs:rgb>`);
232
- out.push(`${I} color3f inputs:emissiveColor = ${tup(m.emissive, 0, 3)}`);
235
+ out.push(`${I} color3f inputs:emissiveColor = ${tupColor(m.emissive, 0, 3)}`);
233
236
  out.push(`${I} float inputs:metallic = ${fmt(m.metallic)}`);
234
237
  if (tex.orm)
235
238
  out.push(`${I} float inputs:metallic.connect = <${mp}/orm_map.outputs:b>`);
@@ -343,7 +346,7 @@ function usdaInstancer(g, o, uniq, out, mats = null) {
343
346
  if ((a.name === 'Cd' || a.name === 'Alpha') && t === 'float' && num.every((v) => v.every((x) => x === 1)))
344
347
  continue;
345
348
  if (a.name === 'Cd' && t === 'float' && sz >= 3) {
346
- out.push(` color3f[] primvars:displayColor = [${num.map((v) => tup(v, 0, 3)).join(', ')}] (`, ...vertex);
349
+ out.push(` color3f[] primvars:displayColor = [${num.map((v) => tupColor(v, 0, 3)).join(', ')}] (`, ...vertex);
347
350
  continue;
348
351
  }
349
352
  if (a.name === 'Alpha' && t === 'float' && sz === 1) {
@@ -419,7 +422,7 @@ function usdaPrims(g, o, uniq, out, mats = null) {
419
422
  const c = cd.attr.get(i);
420
423
  return Array.isArray(c) ? c.slice(0, 3) : [c, c, c];
421
424
  });
422
- out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) => tup(cols, k, 3)).join(', ')}] (`, ' interpolation = "faceVarying"', ' )');
425
+ out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) => tupColor(cols, k, 3)).join(', ')}] (`, ' interpolation = "faceVarying"', ' )');
423
426
  }
424
427
  if (o.custom)
425
428
  for (const [n, a] of geo.points.map) {
package/dist/io/gltf.d.ts CHANGED
@@ -32,7 +32,7 @@ export interface GLTFImportOptions {
32
32
  /** EXT_mesh_gpu_instancing: 'packed' (default) = packed prims sharing one source per mesh; 'expand' = real geometry */
33
33
  instancing?: 'packed' | 'expand';
34
34
  }
35
- /** Geometry from a gltf-transform Document (all scenes' nodes flattened into world space). */
35
+ /** Geometry from the default (or first) scene, traversed in scene order with world transforms. */
36
36
  export declare function documentToGeometry(doc: Document, warn?: (m: string) => void, instancing?: 'packed' | 'expand', base?: string): Geometry;
37
37
  /** Import GLB or .gltf bytes (external buffers through opts.readFile). */
38
38
  export declare function importGLTFAsync(bytes: Uint8Array, opts?: GLTFImportOptions): Promise<Geometry>;
package/dist/io/gltf.js CHANGED
@@ -17,8 +17,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
17
17
  // or the smallest integer type that holds an int attribute). The original names and types are kept in mesh
18
18
  // extras (topotopo.attributes), so imports restore them exactly. POINTS always carry pscale as `_PSCALE`.
19
19
  // Materials: the prim "material" attribute names a material of the detail table "materials" (core/materials.ts).
20
+ // Colour space: TopoTopo colours (Cd, material base/emissive colours) are display values; glTF defines
21
+ // baseColorFactor, emissiveFactor and COLOR_0 as linear, so they are converted on export and back on import.
20
22
  // Vertex colours win: a primitive with Cd gets baseColorFactor white × COLOR_0, the material supplies the PBR
21
23
  // scalars; the original material is kept in material extras (topotopo.material) for lossless re-import.
24
+ // A primitive whose Cd is one constant colour writes it as the baseColorFactor instead of COLOR_0 (the same
25
+ // colour under glTF's factor × COLOR_0, and visible in engines whose default materials ignore vertex colours,
26
+ // such as Unreal's Interchange); primitive extras (topotopo.Cd) restore it as point Cd on import.
22
27
  // Options: quantize (KHR_mesh_quantization), compress 'meshopt' (EXT_meshopt_compression, implies quantize),
23
28
  // compress 'draco' (KHR_draco_mesh_compression, only when the optional draco3dgltf package is installed).
24
29
  // Packed instances (instancing 'packed', the default): one mesh + node per packed source with EXT_mesh_gpu_instancing
@@ -54,7 +59,7 @@ import { resolveVertexNormals, triangulate } from '../core/topology.js';
54
59
  import { DEFAULT_MATERIAL, mergeMaterialTables, normalizeMaterial, primMaterials, texturePaths, writeMaterials } from '../core/materials.js';
55
60
  import { m4apply, m4applyDir, m4fromQuat, m4mul, m4normalMatrix, m4scale, m4translate } from '../core/math.js';
56
61
  import { INSTANCE_ATTRIB_SKIP, anchorAttribs, anchorValue, baseOf, decomposeXform, isolatedMask, packedSourceNames, pieces, xf, xfDir, xfLinear } from './common.js';
57
- import { DEFAULT_TRANSFER, expandPacked, flattenSource, packedFromMatrices, stripPacked } from '../core/packed.js';
62
+ import { DEFAULT_TRANSFER, expandPacked, flattenSource, packedFromMatrices, stripPacked, xform3 } from '../core/packed.js';
58
63
  import { hasSplinesDeep, splinesToPolylinesDeep } from '../core/splines.js';
59
64
  import { imageFromPNG, imageTextureData } from './textures.js';
60
65
  import { decodePNG } from './png-decode.js';
@@ -270,8 +275,8 @@ function textureFor(st, path) {
270
275
  st.textures.set(path, t);
271
276
  return t;
272
277
  }
273
- function getMaterial(st, name, vertexColors, blend) {
274
- const key = `${name}\u0000${vertexColors ? 1 : 0}${blend ? 1 : 0}`;
278
+ function getMaterial(st, name, vertexColors, blend, tint) {
279
+ const key = `${name}\u0000${vertexColors ? 1 : 0}${blend ? 1 : 0}${tint ? `\u0000${tint.join(',')}` : ''}`;
275
280
  let m = st.cache.get(key);
276
281
  if (m)
277
282
  return m;
@@ -286,9 +291,10 @@ function getMaterial(st, name, vertexColors, blend) {
286
291
  st.warn?.(`glTF: material "${name}" has an orm and a roughness map; the orm map's green channel is used`);
287
292
  // a map replaces its factor: white base colour under a base colour map, roughness 1 under a roughness map,
288
293
  // metallic 1 under an ORM map
289
- const rgb = vertexColors || baseTex ? [1, 1, 1] : mat.baseColor;
294
+ // a folded constant vertex colour multiplies a base colour map exactly as COLOR_0 would have
295
+ const rgb = tint ?? (vertexColors || baseTex ? [1, 1, 1] : mat.baseColor);
290
296
  m = st.doc.createMaterial(isDefault ? 'default' : name)
291
- .setBaseColorFactor([rgb[0], rgb[1], rgb[2], mat.alpha])
297
+ .setBaseColorFactor([toLinear(rgb[0]), toLinear(rgb[1]), toLinear(rgb[2]), mat.alpha]) // glTF factors are linear
292
298
  .setMetallicFactor(ormTex ? 1 : mat.metallic)
293
299
  .setRoughnessFactor(roughTex ? 1 : mat.roughness)
294
300
  .setDoubleSided(mat.doubleSided);
@@ -302,12 +308,12 @@ function getMaterial(st, name, vertexColors, blend) {
302
308
  m.setOcclusionTexture(ormTex);
303
309
  const emax = Math.max(...mat.emissive);
304
310
  if (emax > 1) {
305
- m.setEmissiveFactor([mat.emissive[0] / emax, mat.emissive[1] / emax, mat.emissive[2] / emax]);
311
+ m.setEmissiveFactor([toLinear(mat.emissive[0] / emax), toLinear(mat.emissive[1] / emax), toLinear(mat.emissive[2] / emax)]);
306
312
  const ext = st.doc.createExtension(KHRMaterialsEmissiveStrength);
307
313
  m.setExtension('KHR_materials_emissive_strength', ext.createEmissiveStrength().setEmissiveStrength(emax));
308
314
  }
309
315
  else
310
- m.setEmissiveFactor([mat.emissive[0], mat.emissive[1], mat.emissive[2]]);
316
+ m.setEmissiveFactor([toLinear(mat.emissive[0]), toLinear(mat.emissive[1]), toLinear(mat.emissive[2])]);
311
317
  if (mat.alpha < 1 || blend)
312
318
  m.setAlphaMode('BLEND');
313
319
  if (mat.unlit)
@@ -316,6 +322,17 @@ function getMaterial(st, name, vertexColors, blend) {
316
322
  st.cache.set(key, m);
317
323
  return m;
318
324
  }
325
+ /** Display (sRGB-encoded) value to linear and back: glTF's factors and COLOR_0 are linear, TopoTopo colours are display
326
+ * values (docs/DESIGN.md, colour). */
327
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
328
+ const toDisplay = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
329
+ /** True when every element of a colour stream has the same rgb. */
330
+ const constantRGB = (d, size) => {
331
+ for (let i = size; i < d.length; i += size)
332
+ if (d[i] !== d[0] || d[i + 1] !== d[1] || d[i + 2] !== d[2])
333
+ return false;
334
+ return true;
335
+ };
319
336
  /** True when every rgb component of a colour stream is 1 (Houdini's default Cd). */
320
337
  const rgbAllOnes = (d, size) => {
321
338
  for (let i = 0; i < d.length; i++)
@@ -382,18 +399,27 @@ export function gltfDocument(g, o = {}) {
382
399
  const finishColor = (out, mat) => {
383
400
  // vertex colours win where they are set; all-white Cd lets the material colour through
384
401
  const ci = color ? out.channels.indexOf(color) : -1;
385
- let vc = false, blend = false;
402
+ let vc = false, blend = false, tint;
386
403
  if (ci >= 0) {
387
404
  const ch = out.channels[ci], d = out.data[ci];
388
405
  const rgbSet = !!geo.findAttrib('Cd') && !rgbAllOnes(d, ch.size);
389
406
  blend = ch.size === 4 && d.some((x, i) => i % 4 === 3 && x < 1);
390
407
  vc = rgbSet;
391
- if (!rgbSet && !blend) {
408
+ // one colour over the whole primitive: the factor carries it (identical under glTF's factor × COLOR_0), so
409
+ // engines that ignore vertex colours in their default materials (Unreal Interchange) still show it
410
+ if (rgbSet && !blend && d.length >= ch.size && constantRGB(d, ch.size)) {
411
+ tint = [d[0], d[1], d[2]].map((x) => Math.max(0, Math.min(1, x)));
412
+ out.foldedColor = tint;
413
+ vc = false;
414
+ }
415
+ else if (rgbSet && o.materialColorWins && mat && st.table[mat])
416
+ vc = false; // keeps COLOR_0, factor = material colour
417
+ if ((!rgbSet || tint) && !blend) {
392
418
  out.channels.splice(ci, 1);
393
419
  out.data.splice(ci, 1);
394
420
  }
395
421
  }
396
- out.material = getMaterial(st, mat, vc, blend);
422
+ out.material = getMaterial(st, mat, vc, blend, tint);
397
423
  if (out.material.getBaseColorTexture() || out.material.getNormalTexture() || out.material.getMetallicRoughnessTexture()) {
398
424
  if (!out.channels.some((c) => c.semantic === 'TEXCOORD_0'))
399
425
  noUV++;
@@ -699,6 +725,8 @@ function writeMesh(doc, buffer, scene, name, outs, info, quantize) {
699
725
  const acc = (arr, size, normalized = false) => doc.createAccessor().setArray(arr).setType(TYPES[size - 1]).setBuffer(buffer).setNormalized(normalized);
700
726
  for (const out of outs) {
701
727
  const prim = doc.createPrimitive().setMode(out.mode);
728
+ if (out.foldedColor)
729
+ prim.setExtras({ topotopo: { Cd: out.foldedColor } });
702
730
  out.channels.forEach((ch, ci) => {
703
731
  const d = out.data[ci];
704
732
  let a;
@@ -715,10 +743,11 @@ function writeMesh(doc, buffer, scene, name, outs, info, quantize) {
715
743
  a = acc(Uint16Array.from(d, (x) => Math.round(x * 65535)), 2, true);
716
744
  }
717
745
  else if (ch.semantic === 'COLOR_0' && quantize && d.every((x) => x >= 0 && x <= 1)) {
718
- a = acc(Uint16Array.from(d, (x) => Math.round(x * 65535)), ch.size, true);
746
+ // COLOR_0 is linear: rgb from display values, alpha as it is
747
+ a = acc(Uint16Array.from(d, (x, i) => Math.round((i % ch.size < 3 ? toLinear(x) : x) * 65535)), ch.size, true);
719
748
  }
720
749
  else if (ch.semantic === 'COLOR_0') {
721
- a = acc(Float32Array.from(d, (x) => Math.max(0, Math.min(1, x))), ch.size);
750
+ a = acc(Float32Array.from(d, (x, i) => { const c = Math.max(0, Math.min(1, x)); return i % ch.size < 3 ? toLinear(c) : c; }), ch.size);
722
751
  }
723
752
  else if (ch.int) {
724
753
  let mn = Infinity, mx = -Infinity;
@@ -882,9 +911,10 @@ function materialEntry(m, base = '', index = () => 0) {
882
911
  return normalizeMaterial(ex);
883
912
  const bc = m.getBaseColorFactor(), em = m.getEmissiveFactor();
884
913
  const strength = m.getExtension('KHR_materials_emissive_strength')?.getEmissiveStrength() ?? 1;
914
+ // glTF factors are linear; the material table holds display values
885
915
  return normalizeMaterial({
886
- baseColor: [bc[0], bc[1], bc[2]], metallic: m.getMetallicFactor(), roughness: m.getRoughnessFactor(),
887
- emissive: [em[0] * strength, em[1] * strength, em[2] * strength], alpha: bc[3],
916
+ baseColor: [toDisplay(bc[0]), toDisplay(bc[1]), toDisplay(bc[2])], metallic: m.getMetallicFactor(), roughness: m.getRoughnessFactor(),
917
+ emissive: [toDisplay(em[0]) * strength, toDisplay(em[1]) * strength, toDisplay(em[2]) * strength], alpha: bc[3],
888
918
  doubleSided: m.getDoubleSided(), unlit: !!m.getExtension('KHR_materials_unlit'),
889
919
  textures: {
890
920
  baseColor: texturePathOf(m.getBaseColorTexture(), base, index),
@@ -970,7 +1000,8 @@ function primitiveGeometry(prim, attrInfo, warn) {
970
1000
  else if (sem === 'TEXCOORD_0' || sem === 'TEXCOORD_1')
971
1001
  setPt(sem === 'TEXCOORD_0' ? 'uv' : 'uv2', a, 3, 'float', (s, i, out) => { out[i * 3] = s[i * es]; out[i * 3 + 1] = 1 - s[i * es + 1]; out[i * 3 + 2] = 0; });
972
1002
  else if (sem === 'COLOR_0') {
973
- setPt('Cd', a, 3, 'float', (s, i, out) => { out[i * 3] = s[i * es]; out[i * 3 + 1] = s[i * es + 1]; out[i * 3 + 2] = s[i * es + 2]; });
1003
+ // COLOR_0 is linear; Cd holds display values
1004
+ setPt('Cd', a, 3, 'float', (s, i, out) => { out[i * 3] = toDisplay(s[i * es]); out[i * 3 + 1] = toDisplay(s[i * es + 1]); out[i * 3 + 2] = toDisplay(s[i * es + 2]); });
974
1005
  if (es === 4)
975
1006
  setPt('Alpha', a, 1, 'float', (s, i, out) => { out[i] = s[i * 4 + 3]; });
976
1007
  }
@@ -981,6 +1012,18 @@ function primitiveGeometry(prim, attrInfo, warn) {
981
1012
  }
982
1013
  // TANGENT, JOINTS_n, WEIGHTS_n, TEXCOORD_2+, COLOR_1+ are not imported
983
1014
  }
1015
+ // a constant Cd that the exporter folded into the material factor comes back as the point colour it was
1016
+ const folded = prim.getExtras()?.topotopo?.Cd;
1017
+ if (!prim.getAttribute('COLOR_0') && Array.isArray(folded) && folded.length === 3 && folded.every((x) => typeof x === 'number')) {
1018
+ const cd = new Attribute('float', 3, new Float64Array(n * 3), defaultValueFor('Cd', 3, 'float'));
1019
+ const d = cd.data;
1020
+ for (let i = 0; i < n; i++) {
1021
+ d[i * 3] = folded[0];
1022
+ d[i * 3 + 1] = folded[1];
1023
+ d[i * 3 + 2] = folded[2];
1024
+ }
1025
+ g.points.set('Cd', cd);
1026
+ }
984
1027
  return g;
985
1028
  }
986
1029
  function transformGeo(local, m) {
@@ -1015,7 +1058,35 @@ function instanceMatrices(inst) {
1015
1058
  }
1016
1059
  return out;
1017
1060
  }
1018
- /** Geometry from a gltf-transform Document (all scenes' nodes flattened into world space). */
1061
+ const RESERVED_EXTRAS = new Set('P N uv uv2 Cd Alpha material name path orient scale pscale'.split(' '));
1062
+ function constantAttribute(table, name, type, size, value) {
1063
+ const a = table.add(name, type, size);
1064
+ for (let i = 0; i < table.count; i++)
1065
+ a.set(i, value);
1066
+ }
1067
+ /** Object extras only; topotopo's private round-trip metadata keeps its existing readers. */
1068
+ function objectExtras(table, extras, collisions) {
1069
+ // Explicit key order keeps schemas stable even when producers serialize extras in a different order.
1070
+ for (const key of Object.keys(extras).sort()) {
1071
+ if (key === 'topotopo')
1072
+ continue;
1073
+ if (RESERVED_EXTRAS.has(key)) {
1074
+ collisions.add(key);
1075
+ continue;
1076
+ }
1077
+ const value = extras[key];
1078
+ if (typeof value === 'string')
1079
+ constantAttribute(table, key, 'string', 1, value);
1080
+ else if (typeof value === 'boolean')
1081
+ constantAttribute(table, key, 'int', 1, value ? 1 : 0);
1082
+ else if (typeof value === 'number' && Number.isFinite(value))
1083
+ constantAttribute(table, key, Number.isInteger(value) ? 'int' : 'float', 1, value);
1084
+ else if (Array.isArray(value) && value.length >= 2 && value.length <= 4 && value.every((v) => typeof v === 'number' && Number.isFinite(v))) {
1085
+ constantAttribute(table, key, 'float', value.length, value);
1086
+ }
1087
+ }
1088
+ }
1089
+ /** Geometry from the default (or first) scene, traversed in scene order with world transforms. */
1019
1090
  export function documentToGeometry(doc, warn = () => { }, instancing = 'packed', base = '') {
1020
1091
  const root = doc.getRoot();
1021
1092
  const texIndex = (t) => root.listTextures().indexOf(t);
@@ -1040,24 +1111,62 @@ export function documentToGeometry(doc, warn = () => { }, instancing = 'packed',
1040
1111
  const parts = [];
1041
1112
  const localCache = new Map();
1042
1113
  const sourceCache = new Map();
1114
+ const sharedSources = new Map();
1115
+ const nodes = [];
1116
+ const scene = root.getDefaultScene() ?? root.listScenes()[0] ?? null;
1117
+ if (scene)
1118
+ scene.traverse((n) => { nodes.push(n); });
1119
+ else
1120
+ for (const n of root.listNodes())
1121
+ if (!n.getParentNode())
1122
+ n.traverse((n) => { nodes.push(n); });
1123
+ const meshRefs = new Map();
1124
+ for (const node of nodes) {
1125
+ const mesh = node.getMesh();
1126
+ if (mesh && !node.getExtension('EXT_mesh_gpu_instancing') && !node.getCamera() && !node.getExtension('KHR_lights_punctual'))
1127
+ meshRefs.set(mesh, (meshRefs.get(mesh) ?? 0) + 1);
1128
+ }
1129
+ const collisions = new Set();
1130
+ let skipped = 0;
1043
1131
  let instanced = false;
1044
1132
  const visitNode = (node) => {
1045
- const mesh = node.getMesh();
1046
- if (!mesh)
1133
+ if (node.getCamera() || node.getExtension('KHR_lights_punctual')) {
1134
+ skipped++;
1047
1135
  return;
1136
+ }
1137
+ const mesh = node.getMesh();
1048
1138
  const world = Float64Array.from(node.getWorldMatrix());
1139
+ const ancestors = [];
1140
+ for (let p = node.getParentNode(); p; p = p.getParentNode())
1141
+ ancestors.push(p.getName());
1142
+ const path = ancestors.reverse().join('/');
1143
+ const extras = { ...mesh?.getExtras(), ...node.getExtras() };
1144
+ if (!mesh) {
1145
+ const g = new Geometry({ positions: [world[12], world[13], world[14]] });
1146
+ const { q, s } = decomposeXform(xform3(world));
1147
+ constantAttribute(g.points, 'name', 'string', 1, node.getName());
1148
+ constantAttribute(g.points, 'path', 'string', 1, path);
1149
+ constantAttribute(g.points, 'orient', 'float', 4, q);
1150
+ constantAttribute(g.points, 'scale', 'float', 3, s);
1151
+ constantAttribute(g.points, 'pscale', 'float', 1, 1);
1152
+ objectExtras(g.points, extras, collisions);
1153
+ parts.push(g);
1154
+ return;
1155
+ }
1049
1156
  const inst = node.getExtension('EXT_mesh_gpu_instancing');
1050
1157
  const mats = inst ? instanceMatrices(inst).map((m) => m4mul(world, m)) : [world];
1051
1158
  const info = (mesh.getExtras()?.topotopo?.attributes ?? {});
1052
1159
  const label = node.getName() || mesh.getName();
1053
- if (inst && instancing === 'packed') {
1054
- // one packed source per mesh (and node name): the mesh in its local space, the instances as packed prims
1160
+ const shared = !inst && (meshRefs.get(mesh) ?? 0) > 1;
1161
+ if ((inst || shared) && instancing === 'packed') {
1162
+ // Shared-node sources omit name: the packed prim owns it, so unpack's normal transfer keeps each label.
1163
+ // Existing EXT sources retain their labels for round trips and source naming in instance-stream exports.
1055
1164
  let byLabel = sourceCache.get(mesh);
1056
1165
  if (!byLabel) {
1057
1166
  byLabel = new Map();
1058
1167
  sourceCache.set(mesh, byLabel);
1059
1168
  }
1060
- let src = byLabel.get(label);
1169
+ let src = shared ? sharedSources.get(mesh) : byLabel.get(label);
1061
1170
  if (!src) {
1062
1171
  const ps = [];
1063
1172
  for (const prim of mesh.listPrimitives()) {
@@ -1070,7 +1179,7 @@ export function documentToGeometry(doc, warn = () => { }, instancing = 'packed',
1070
1179
  continue;
1071
1180
  const g = local.clone();
1072
1181
  const mname = matName(prim.getMaterial());
1073
- if (label) {
1182
+ if (!shared && label) {
1074
1183
  const a = g.prims.add('name', 'string', 1);
1075
1184
  for (let k = 0; k < g.numPrims; k++)
1076
1185
  a.set(k, label);
@@ -1083,10 +1192,23 @@ export function documentToGeometry(doc, warn = () => { }, instancing = 'packed',
1083
1192
  ps.push(g);
1084
1193
  }
1085
1194
  src = Geometry.merge(ps);
1086
- byLabel.set(label, src);
1195
+ if (shared)
1196
+ sharedSources.set(mesh, src);
1197
+ else
1198
+ byLabel.set(label, src);
1087
1199
  }
1088
1200
  const part = packedFromMatrices(src, mats);
1089
- applyInstanceAttribs(part, inst, (node.getExtras()?.topotopo?.instanceAttributes ?? {}), warn);
1201
+ constantAttribute(part.prims, 'name', 'string', 1, label);
1202
+ constantAttribute(part.prims, 'path', 'string', 1, path);
1203
+ objectExtras(part.prims, extras, collisions);
1204
+ if (inst)
1205
+ applyInstanceAttribs(part, inst, (node.getExtras()?.topotopo?.instanceAttributes ?? {}), warn);
1206
+ // Native instance metadata can carry these in point class; expose the effective label on the prim too.
1207
+ for (const key of ['name', 'path']) {
1208
+ const a = part.points.get(key);
1209
+ if (a?.type === 'string')
1210
+ part.prims.set(key, a.clone());
1211
+ }
1090
1212
  parts.push(part);
1091
1213
  return;
1092
1214
  }
@@ -1113,19 +1235,30 @@ export function documentToGeometry(doc, warn = () => { }, instancing = 'packed',
1113
1235
  for (let k = 0; k < g.numPrims; k++)
1114
1236
  a.set(k, mname);
1115
1237
  }
1238
+ constantAttribute(g.prims, 'path', 'string', 1, path);
1239
+ objectExtras(g.prims, extras, collisions);
1116
1240
  parts.push(g);
1117
1241
  }
1118
1242
  }
1119
1243
  };
1120
- const scene = root.getDefaultScene() ?? root.listScenes()[0] ?? null;
1121
- if (scene)
1122
- scene.traverse(visitNode);
1123
- else
1124
- for (const n of root.listNodes())
1125
- if (!n.getParentNode())
1126
- n.traverse(visitNode);
1244
+ for (const node of nodes)
1245
+ visitNode(node);
1246
+ if (skipped)
1247
+ warn(`skipped ${skipped} cameras/lights`);
1248
+ if (collisions.size)
1249
+ warn(`glTF extras skipped reserved attributes: ${[...collisions].sort().join(', ')}`);
1127
1250
  if (instanced)
1128
1251
  warn('EXT_mesh_gpu_instancing instances were expanded into geometry');
1252
+ // Existing unpack/export rules prefer point attributes over prim attributes. Empty-point schemas must not
1253
+ // hide an instance's values with merge defaults. Mirror only colliding keys onto its anchor, before the normal
1254
+ // Geometry.merge handles type promotion/defaults. Explicit native instance point metadata still wins.
1255
+ const pointKeys = new Set(parts.flatMap((g) => g.points.names()));
1256
+ for (const part of parts)
1257
+ if (part.hasPacked()) {
1258
+ for (const [key, a] of part.prims.map)
1259
+ if (pointKeys.has(key) && !part.points.has(key))
1260
+ part.points.set(key, a.clone());
1261
+ }
1129
1262
  const out = Geometry.merge(parts);
1130
1263
  if (Object.keys(table).length)
1131
1264
  writeMaterials(out, table);
@@ -1143,7 +1276,9 @@ export function documentToGeometry(doc, warn = () => { }, instancing = 'packed',
1143
1276
  if (name === null || images.has(name) || !bytes?.length || imageMime(bytes) !== 'image/png')
1144
1277
  continue;
1145
1278
  try {
1146
- images.set(name, imageFromPNG(decodePNG(bytes), name));
1279
+ // one layer per texture: a grey + alpha map stays RGBA, so a re-export writes its alpha
1280
+ const png = decodePNG(bytes);
1281
+ images.set(name, imageFromPNG(png, name, { layers: png.channels === 1 ? 'grey' : 'rgba' }));
1147
1282
  }
1148
1283
  catch (e) {
1149
1284
  warn(`texture "${path}": ${e?.message ?? e}`);
@@ -3,7 +3,7 @@ import type { Heightfield } from '../core/heightfield.ts';
3
3
  export type HeightmapFormat = 'png16' | 'r16' | 'mask_png';
4
4
  export declare const HEIGHTMAP_FORMATS: HeightmapFormat[];
5
5
  export interface HeightmapOptions {
6
- /** png16/r16: the layer to write (default "height"); mask_png: the layer when `layers` is not given (default "mask") */
6
+ /** png16/r16: the layer to write (default "height"); mask_png: the layer when `layers` is not given (default "mask"); unreal_landscape: the height layer */
7
7
  layer?: string;
8
8
  /** mask_png: layers to write, one 8-bit PNG each */
9
9
  layers?: string[];
@@ -10,10 +10,10 @@ export interface ImportOptions {
10
10
  path?: string;
11
11
  /** receives non-fatal problems (skipped primitives or attributes) */
12
12
  warn?: (msg: string) => void;
13
- /** glTF EXT_mesh_gpu_instancing: 'packed' (default) = packed prims sharing one source per mesh; 'expand' = real geometry */
13
+ /** glTF shared meshes / EXT_mesh_gpu_instancing: 'packed' (default) = packed prims; 'expand' = real geometry */
14
14
  instancing?: 'packed' | 'expand';
15
15
  }
16
- /** Format from a file name: the extension, with ".points.json" → pointsjson. */
16
+ /** Format from a file name: the extension, with ".points.json" → pointsjson (".png" → png: an image). */
17
17
  export declare function importFormatFromPath(path: string): string;
18
18
  /** Synchronous import for every format except glb/gltf (see importGeometryAsync). */
19
19
  export declare function importGeometry(bytes: Uint8Array, fmt: string, opts?: ImportOptions): Geometry;
package/dist/io/import.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Importers: OBJ, STL, PLY, glTF/GLB (gltf-transform, src/io/gltf.ts), native JSON, Houdini .geo (src/io/hgeo.ts),
2
- // CSV and points JSON (src/io/points.ts).
2
+ // CSV and points JSON (src/io/points.ts), and PNG as a geometry holding one image volume (src/io/textures.ts).
3
3
  //
4
4
  // importGeometry() is synchronous and covers every format except glTF, whose reader (gltf-transform) is
5
5
  // asynchronous: use importGeometryAsync() for glb/gltf (it handles every format, and reads external .bin files).
@@ -8,12 +8,13 @@ import { geoFromJSON } from './json.js';
8
8
  import { importGLTFAsync } from './gltf.js';
9
9
  import { importHoudiniBgeo, importHoudiniGeo, isBinaryJSON } from './hgeo.js';
10
10
  import { importCSV, importPointsJSON, isPointsJSON } from './points.js';
11
+ import { imageFromPNGFile } from './textures.js';
11
12
  export { importGLTFAsync } from './gltf.js';
12
13
  export { importHoudiniBgeo, importHoudiniGeo } from './hgeo.js';
13
14
  export { importCSV, importPointsJSON } from './points.js';
14
15
  const td = new TextDecoder();
15
- export const IMPORT_FORMATS = ['obj', 'stl', 'ply', 'glb', 'gltf', 'json', 'geo', 'bgeo', 'csv', 'pointsjson'];
16
- /** Format from a file name: the extension, with ".points.json" → pointsjson. */
16
+ export const IMPORT_FORMATS = ['obj', 'stl', 'ply', 'glb', 'gltf', 'blend', 'json', 'geo', 'bgeo', 'csv', 'pointsjson', 'png'];
17
+ /** Format from a file name: the extension, with ".points.json" → pointsjson (".png" → png: an image). */
17
18
  export function importFormatFromPath(path) {
18
19
  const p = String(path).split('?')[0].toLowerCase();
19
20
  if (p.endsWith('.points.json'))
@@ -42,6 +43,12 @@ export function importGeometry(bytes, fmt, opts = {}) {
42
43
  case 'bgeo': return isBinaryJSON(bytes) ? importHoudiniBgeo(bytes, opts.warn) : importHoudiniGeo(td.decode(bytes), opts.warn);
43
44
  case 'csv': return importCSV(td.decode(bytes));
44
45
  case 'pointsjson': return importPointsJSON(JSON.parse(td.decode(bytes)));
46
+ case 'png': {
47
+ // an image volume named after the file, as the image_file node reads it with its defaults
48
+ const g = new Geometry();
49
+ g.volumes = [imageFromPNGFile(bytes, opts.path ?? 'image.png')];
50
+ return g;
51
+ }
45
52
  case 'glb':
46
53
  case 'gltf': throw new Error(`${fmt} import is asynchronous (gltf-transform): use importGeometryAsync`);
47
54
  case 'bgeo.sc': throw new Error('compressed Houdini geometry (.bgeo.sc / .gz) is not supported: save as .geo or uncompressed .bgeo');
@@ -14,8 +14,11 @@ export interface PngImage {
14
14
  /** samples, row-major, channels interleaved; row 0 is the top of the image */
15
15
  data: Uint16Array;
16
16
  }
17
- /** Decode a PNG file. Throws a readable Error for unsupported or corrupt files. */
18
- export declare function decodePNG(bytes: Uint8Array): PngImage;
17
+ /**
18
+ * Decode a PNG file. Throws a readable Error for unsupported or corrupt files, and for images of more than maxPixels
19
+ * pixels before any pixel memory is allocated.
20
+ */
21
+ export declare function decodePNG(bytes: Uint8Array, maxPixels?: number): PngImage;
19
22
  export type PngChannel = 'luminance' | 'average' | 'r' | 'g' | 'b' | 'a';
20
23
  /** One channel of a decoded image as floats 0..1 (luminance = Rec. 709 weights; grey images ignore r/g/b). */
21
24
  export declare function pngChannel(img: PngImage, channel?: PngChannel): Float32Array;
@@ -1,6 +1,6 @@
1
1
  // Minimal PNG decoder (pure TypeScript, no host APIs): non-interlaced grey, grey+alpha, RGB, RGBA and palette
2
2
  // images at 1/2/4/8/16 bits per channel, with a small inflate (RFC 1950/1951) so it runs anywhere the core runs.
3
- // Used by heightfield_from_image; exported for other readers (attribute from map, textures).
3
+ // Used by heightfield_from_image, attribute_from_map and image_file (io/textures.ts), and the glTF importer.
4
4
  // ---------------------------------------------------------------- inflate
5
5
  class Bits {
6
6
  data;
@@ -180,8 +180,11 @@ export function inflateZlib(data, sizeHint = 0) {
180
180
  return inflateRaw(data, sizeHint, 2);
181
181
  }
182
182
  const SIG = [137, 80, 78, 71, 13, 10, 26, 10];
183
- /** Decode a PNG file. Throws a readable Error for unsupported or corrupt files. */
184
- export function decodePNG(bytes) {
183
+ /**
184
+ * Decode a PNG file. Throws a readable Error for unsupported or corrupt files, and for images of more than maxPixels
185
+ * pixels before any pixel memory is allocated.
186
+ */
187
+ export function decodePNG(bytes, maxPixels = Infinity) {
185
188
  for (let i = 0; i < 8; i++)
186
189
  if (bytes[i] !== SIG[i])
187
190
  throw new Error('not a PNG file (bad signature)');
@@ -226,6 +229,10 @@ export function decodePNG(bytes) {
226
229
  throw new Error(`unsupported PNG bit depth ${depth} for colour type ${ctype}`);
227
230
  if (ctype === 3 && !palette)
228
231
  throw new Error('palette PNG without a PLTE chunk');
232
+ if (width * height > maxPixels) {
233
+ const side = Math.floor(Math.sqrt(maxPixels));
234
+ throw new Error(`the image is ${width} × ${height} pixels, more than the ${maxPixels.toLocaleString('en-US')} pixels allowed here (e.g. ${side} × ${side}): scale it down`);
235
+ }
229
236
  let total = 0;
230
237
  for (const c of idat)
231
238
  total += c.length;
@@ -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
+ }