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/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 = ${
|
|
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 = ${
|
|
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) =>
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/io/heightmap.d.ts
CHANGED
|
@@ -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[];
|
package/dist/io/import.d.ts
CHANGED
|
@@ -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
|
|
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');
|
package/dist/io/png-decode.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
18
|
-
|
|
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;
|
package/dist/io/png-decode.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
184
|
-
|
|
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
|
+
}
|