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.
Files changed (66) hide show
  1. package/README.md +44 -12
  2. package/dist/cli.js +98 -23
  3. package/dist/graph/engine.js +4 -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 +11 -2
  7. package/dist/host/export_scene.py +200 -0
  8. package/dist/host/node.d.ts +8 -0
  9. package/dist/host/node.js +114 -0
  10. package/dist/host/types.d.ts +5 -0
  11. package/dist/inspect.js +5 -2
  12. package/dist/io/common.d.ts +6 -0
  13. package/dist/io/export.js +7 -4
  14. package/dist/io/gltf.d.ts +1 -1
  15. package/dist/io/gltf.js +167 -32
  16. package/dist/io/heightmap.d.ts +1 -1
  17. package/dist/io/import.d.ts +2 -2
  18. package/dist/io/import.js +10 -3
  19. package/dist/io/png-decode.d.ts +5 -2
  20. package/dist/io/png-decode.js +10 -3
  21. package/dist/io/textures.d.ts +31 -2
  22. package/dist/io/textures.js +66 -18
  23. package/dist/io/unreal.js +22 -5
  24. package/dist/mcp/docs.d.ts +9 -2
  25. package/dist/mcp/docs.js +113 -2
  26. package/dist/mcp/inputs.d.ts +28 -0
  27. package/dist/mcp/inputs.js +377 -0
  28. package/dist/mcp/server.js +4 -1
  29. package/dist/mcp/tools.js +32 -18
  30. package/dist/nodes/code.js +2 -0
  31. package/dist/nodes/copy.js +14 -4
  32. package/dist/nodes/flow.js +136 -35
  33. package/dist/nodes/heightfield.js +67 -11
  34. package/dist/nodes/texture.d.ts +1 -1
  35. package/dist/nodes/texture.js +61 -5
  36. package/dist/render/options.js +2 -1
  37. package/dist/render/raster.d.ts +2 -0
  38. package/dist/render/raster.js +3 -3
  39. package/dist/render/texview.d.ts +9 -1
  40. package/dist/render/texview.js +43 -2
  41. package/dist/viewer/app/api.js +64 -0
  42. package/dist/viewer/app/camera.js +230 -0
  43. package/dist/viewer/app/grid.js +217 -0
  44. package/dist/viewer/app/guides.js +402 -0
  45. package/dist/viewer/app/keymap.js +187 -0
  46. package/dist/viewer/app/layers.js +225 -0
  47. package/dist/viewer/app/main.js +590 -0
  48. package/dist/viewer/app/markers.js +206 -0
  49. package/dist/viewer/app/mesh.js +172 -0
  50. package/dist/viewer/app/outliner.js +184 -0
  51. package/dist/viewer/app/props.js +432 -0
  52. package/dist/viewer/app/scene.js +529 -0
  53. package/dist/viewer/app/select.js +142 -0
  54. package/dist/viewer/app/selection.js +79 -0
  55. package/dist/viewer/app/style.css +172 -0
  56. package/dist/viewer/app/undo.js +49 -0
  57. package/dist/viewer/app/units.js +51 -0
  58. package/dist/viewer/app/vars.js +64 -0
  59. package/dist/viewer/index.html +53 -184
  60. package/dist/viewer/server.d.ts +42 -1
  61. package/dist/viewer/server.js +432 -17
  62. package/dist/workspace.d.ts +52 -2
  63. package/dist/workspace.js +95 -3
  64. package/package.json +8 -3
  65. package/shelf/brick_wall.json +1 -1
  66. package/shelf/wall_segment.json +2 -2
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;
@@ -7,8 +7,37 @@ import type { PngImage } from './png-decode.ts';
7
7
  * per channel). deflate compresses (Node: zlib.deflateSync); without it the PNG uses stored (uncompressed) blocks.
8
8
  */
9
9
  export declare function imagePNG(img: Image, layer?: string, deflate?: (d: Uint8Array) => Uint8Array): Uint8Array;
10
- /** An image volume from a decoded PNG: grey → 1 channel, grey+alpha / RGB / RGBA → RGBA; samples / maxValue. */
11
- export declare function imageFromPNG(png: PngImage, name: string): Image;
10
+ /** How a PNG's channels become layers: see ImageFromPNGOptions. */
11
+ export declare const PNG_LAYERS: readonly ['auto', 'grey', 'rgba'];
12
+ export type PngLayers = (typeof PNG_LAYERS)[number];
13
+ export interface ImageFromPNGOptions {
14
+ /**
15
+ * 'auto' (default): grey files (1 or 2 channels) give a grey layer C, colour files an RGBA one; 'grey': a grey C
16
+ * (Rec. 709 luminance of a colour file); 'rgba': an RGBA C (grey g as g, g, g). A grey C puts the file's alpha, when
17
+ * it has one, in the grey layer "A" (Houdini's alpha plane).
18
+ */
19
+ layers?: PngLayers;
20
+ /**
21
+ * true (default): the samples are display (sRGB-encoded) values, which colour layers hold too, so they are kept as
22
+ * they are; false: they are linear light, and grey and RGB are encoded to display values (alpha never is).
23
+ */
24
+ srgb?: boolean;
25
+ }
26
+ /** An image volume from a decoded PNG: values = samples / maxValue (16-bit files keep their precision). */
27
+ export declare function imageFromPNG(png: PngImage, name: string, o?: ImageFromPNGOptions): Image;
28
+ /** The image name a file gives: its name without folders and extension ("inputs/brick.png" → "brick"). */
29
+ export declare function imageNameOf(path: string): string;
30
+ /**
31
+ * An image volume from the bytes of the PNG file `path` (the image_file node and the file node's .png path), named
32
+ * `name` or after the file (imageNameOf). Errors name the file; images over MAX_PIXELS are refused before decoding.
33
+ */
34
+ export declare function imageFromPNGFile(bytes: Uint8Array, path: string, o?: ImageFromPNGOptions & {
35
+ name?: string;
36
+ }): Image;
37
+ /** imageFromPNGFile on a file read through `readFile` (usually Host.readFile). */
38
+ export declare function readImageFile(readFile: (path: string) => Promise<Uint8Array>, path: string, o?: ImageFromPNGOptions & {
39
+ name?: string;
40
+ }): Promise<Image>;
12
41
  /** A file-name-safe version of an image name ("" → "image"). */
13
42
  export declare function safeFileName(name: string): string;
14
43
  /** The images a material table references ("image:<name>"), with the slots using each. */
@@ -1,7 +1,8 @@
1
- import { Image, channelsOf, imageRef, imageToBytes, opaque, referenceableImages } from '../core/image.js';
1
+ import { Image, LUMA, MAX_PIXELS, channelsOf, imageRef, imageToBytes, opaque, referenceableImages } from '../core/image.js';
2
2
  import { TEXTURE_SLOTS, mergeMaterialTables } from '../core/materials.js';
3
3
  import { encodePNG } from '../render/png.js';
4
4
  import { encodeGreyPNG, zlibStored } from './heightmap.js';
5
+ import { decodePNG } from './png-decode.js';
5
6
  import { baseOf, dirOf, joinPath } from './common.js';
6
7
  /**
7
8
  * PNG bytes of an image layer ("" = the main layer): 8-bit grey for 1-channel layers, RGBA for 4-channel ones (8 bits
@@ -13,32 +14,79 @@ export function imagePNG(img, layer = '', deflate) {
13
14
  return encodeGreyPNG(bytes, img.width, img.height, 8, deflate);
14
15
  return encodePNG(bytes, img.width, img.height, deflate ?? zlibStored);
15
16
  }
16
- /** An image volume from a decoded PNG: grey → 1 channel, grey+alpha / RGB / RGBA → RGBA; samples / maxValue. */
17
- export function imageFromPNG(png, name) {
18
- const n = png.width * png.height, ch = png.channels, d = png.data, k = 1 / (png.maxValue || 255);
17
+ /** How a PNG's channels become layers: see ImageFromPNGOptions. */
18
+ export const PNG_LAYERS = ['auto', 'grey', 'rgba'];
19
+ /** Display (sRGB-encoded) value of a linear one (io/gltf.ts converts back and forth for glTF's linear factors). */
20
+ const toDisplay = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
21
+ /** An image volume from a decoded PNG: values = samples / maxValue (16-bit files keep their precision). */
22
+ export function imageFromPNG(png, name, o = {}) {
23
+ const n = png.width * png.height, ch = png.channels, d = png.data, max = png.maxValue || 255, k = 1 / max;
24
+ // samples are integers 0..max: tables give every value once (alpha is never converted)
25
+ const lin = new Float32Array(max + 1);
26
+ for (let s = 0; s <= max; s++)
27
+ lin[s] = s * k;
28
+ const col = o.srgb === false ? Float32Array.from(lin, (_, s) => toDisplay(s * k)) : lin;
19
29
  const img = new Image(png.width, png.height, name);
20
- if (ch === 1) {
21
- const a = new Float32Array(n);
22
- for (let i = 0; i < n; i++)
23
- a[i] = d[i] * k;
24
- return img.set('C', a);
30
+ const mode = o.layers ?? 'auto';
31
+ if (mode === 'grey' || (mode === 'auto' && ch <= 2)) {
32
+ const C = new Float32Array(n);
33
+ if (ch <= 2)
34
+ for (let i = 0; i < n; i++)
35
+ C[i] = col[d[i * ch]];
36
+ else
37
+ for (let i = 0; i < n; i++) {
38
+ const p = i * ch;
39
+ C[i] = col[d[p]] * LUMA[0] + col[d[p + 1]] * LUMA[1] + col[d[p + 2]] * LUMA[2];
40
+ }
41
+ img.set('C', C);
42
+ if (ch === 2 || ch === 4) {
43
+ const A = new Float32Array(n);
44
+ for (let i = 0; i < n; i++)
45
+ A[i] = lin[d[i * ch + ch - 1]];
46
+ img.set('A', A);
47
+ }
48
+ return img;
25
49
  }
26
50
  const a = new Float32Array(n * 4);
27
51
  for (let i = 0; i < n; i++) {
28
- const o = i * ch;
29
- if (ch === 2) {
30
- a[i * 4] = a[i * 4 + 1] = a[i * 4 + 2] = d[o] * k;
31
- a[i * 4 + 3] = d[o + 1] * k;
32
- }
52
+ const p = i * ch, q = i * 4;
53
+ if (ch <= 2)
54
+ a[q] = a[q + 1] = a[q + 2] = col[d[p]];
33
55
  else {
34
- a[i * 4] = d[o] * k;
35
- a[i * 4 + 1] = d[o + 1] * k;
36
- a[i * 4 + 2] = d[o + 2] * k;
37
- a[i * 4 + 3] = ch === 4 ? d[o + 3] * k : 1;
56
+ a[q] = col[d[p]];
57
+ a[q + 1] = col[d[p + 1]];
58
+ a[q + 2] = col[d[p + 2]];
38
59
  }
60
+ a[q + 3] = ch === 2 || ch === 4 ? lin[d[p + ch - 1]] : 1;
39
61
  }
40
62
  return img.set('C', a);
41
63
  }
64
+ /** The image name a file gives: its name without folders and extension ("inputs/brick.png" → "brick"). */
65
+ export function imageNameOf(path) {
66
+ let p = String(path);
67
+ if (/^\w+:\/\//.test(p))
68
+ p = p.split(/[?#]/)[0];
69
+ return baseOf(p.replace(/[\\/]+$/, '')).replace(/\.[^.]*$/, '') || 'image';
70
+ }
71
+ /**
72
+ * An image volume from the bytes of the PNG file `path` (the image_file node and the file node's .png path), named
73
+ * `name` or after the file (imageNameOf). Errors name the file; images over MAX_PIXELS are refused before decoding.
74
+ */
75
+ export function imageFromPNGFile(bytes, path, o = {}) {
76
+ let png;
77
+ try {
78
+ png = decodePNG(bytes, MAX_PIXELS);
79
+ }
80
+ catch (e) {
81
+ throw new Error(`"${path}" cannot be read as a PNG image: ${e?.message ?? e}`);
82
+ }
83
+ return imageFromPNG(png, o.name || imageNameOf(path), o);
84
+ }
85
+ /** imageFromPNGFile on a file read through `readFile` (usually Host.readFile). */
86
+ export async function readImageFile(readFile, path, o = {}) {
87
+ const bytes = await readFile(path).catch((e) => { throw new Error(`cannot read "${path}": ${e?.message ?? e}`); });
88
+ return imageFromPNGFile(bytes, path, o);
89
+ }
42
90
  /** A file-name-safe version of an image name ("" → "image"). */
43
91
  export function safeFileName(name) {
44
92
  return String(name).replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^\.+/, '') || 'image';