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
@@ -5,7 +5,8 @@ import { NodeError } from '../graph/types.js';
5
5
  import { connectivity } from '../core/topology.js';
6
6
  import { stableStringify } from '../graph/engine.js';
7
7
  import { hashString } from '../core/random.js';
8
- import { importFormatFromPath, importGeometryAsync } from '../io/import.js';
8
+ import { IMPORT_FORMATS, importFormatFromPath, importGeometryAsync } from '../io/import.js';
9
+ import { readImageFile } from '../io/textures.js';
9
10
  import { mergeMaterialTables, writeMaterials } from '../core/materials.js';
10
11
  import { fusePoints } from './transform.js';
11
12
  import { DEFAULT_TRANSFER, composePackedXforms, expandPacked } from '../core/packed.js';
@@ -402,59 +403,159 @@ defineNode({
402
403
  return r.geo;
403
404
  },
404
405
  });
406
+ const folderError = 'folder import needs a host with isDirectory/listDir support (use the Node host)';
407
+ const childPath = (dir, name) => `${dir.replace(/[\\/]+$/, '')}/${name}`;
408
+ /** null = a file; a directory listing includes unsupported files too, for signature invalidation. */
409
+ function directoryFiles(path, host) {
410
+ if (/^https?:\/\//i.test(path))
411
+ return null;
412
+ if (!host.isDirectory) {
413
+ if (/[\\/]$/.test(path))
414
+ throw new NodeError(folderError);
415
+ return null;
416
+ }
417
+ if (!host.isDirectory(path))
418
+ return null;
419
+ if (!host.listDir)
420
+ throw new NodeError(folderError);
421
+ return host.listDir(path).sort((a, b) => a.localeCompare(b, 'en'));
422
+ }
423
+ /** One folder member is a variant, unless it already contains multiple named objects. */
424
+ function folderNames(input, file) {
425
+ const g = input.clone();
426
+ const stem = file.replace(/(?:\.points\.json|\.[^.]+)$/i, '');
427
+ const used = new Uint8Array(g.numPoints);
428
+ for (const pt of g.vertexPoint)
429
+ used[pt] = 1;
430
+ const primName = g.prims.get('name'), pointName = g.points.get('name');
431
+ const names = new Set();
432
+ if (primName)
433
+ for (let i = 0; i < g.numPrims; i++)
434
+ names.add(String(primName.get(i)));
435
+ if (pointName)
436
+ for (let i = 0; i < g.numPoints; i++)
437
+ if (!used[i])
438
+ names.add(String(pointName.get(i)));
439
+ const label = (name) => names.size > 1 ? `${stem}/${name}` : stem;
440
+ const prims = g.prims.add('name', 'string', 1);
441
+ for (let i = 0; i < g.numPrims; i++)
442
+ prims.set(i, label(String(primName?.get(i) ?? '')));
443
+ if (used.some((v) => !v)) {
444
+ const points = g.points.add('name', 'string', 1);
445
+ for (let i = 0; i < g.numPoints; i++)
446
+ if (!used[i])
447
+ points.set(i, label(String(pointName?.get(i) ?? '')));
448
+ }
449
+ // Mixed scenes also carry names on anchors (point attributes take precedence during unpack/export).
450
+ const anchors = g.hasPacked() ? g.points.add('name', 'string', 1) : g.points.get('name');
451
+ if (anchors)
452
+ for (let p = 0; p < g.numPrims; p++)
453
+ if (g.primKind?.[p] === 1)
454
+ anchors.set(g.vertexPoint[g.primStart[p]], prims.get(p));
455
+ return g;
456
+ }
405
457
  defineNode({
406
458
  type: 'file',
407
459
  category: 'io',
408
460
  packed: 'keep',
409
- summary: 'Load geometry from disk/URL: OBJ, STL, PLY, glTF/GLB, Houdini .geo/.bgeo, CSV, points JSON or topotopo JSON. Z-up files can be converted.',
461
+ summary: 'Load a scene or folder from disk, or geometry from a URL: OBJ, STL, PLY, glTF/GLB, .blend, Houdini .geo/.bgeo, CSV, points JSON or topotopo JSON; a PNG becomes an image volume named after the file, as image_file reads it with its defaults (image_file has the layer and colour options). .blend needs the Node host and Blender (TOPOTOPO_BLENDER selects its executable); Blender’s own exporter converts it to a cached GLB. Folder mode loads supported files in name order, non-recursively: name becomes the file stem, or `<stem>/<name>` when the file has multiple distinct object names (on prims, packed prims and isolated points); its PNGs become images. copy_to_points {variants: \'name\'} consumes a dropped folder of models directly. glTF empties become points; path holds ancestor names, and object extras become attributes. For a manual Blender GLB: +Y up (default), Apply Modifiers, Loose Edges and Custom Properties; convert curves to mesh first because the exporter drops curves without bevel geometry. Z-up files can be converted.',
410
462
  aliases: ['import', 'load', 'read'],
411
463
  inputs: [],
412
464
  params: {
413
- path: { type: 'path', default: '', desc: 'file path (relative to the workspace) or URL', aliases: ['file', 'filename'] },
414
- format: { type: 'enum', options: ['auto', 'obj', 'stl', 'ply', 'glb', 'gltf', 'json', 'geo', 'bgeo', 'csv', 'pointsjson'], default: 'auto', desc: 'file format (auto = by extension; ".points.json" = pointsjson; json also reads points streams and Houdini .geo; bgeo = uncompressed binary Houdini geometry, not .bgeo.sc)' },
465
+ path: { type: 'path', default: '', desc: 'file or folder path (relative to the workspace) or file URL; folder mode needs directory support in the host', aliases: ['file', 'filename'] },
466
+ format: { type: 'enum', options: ['auto', ...IMPORT_FORMATS], default: 'auto', desc: 'file format (auto = by extension; folders always use each file’s extension; ".points.json" = pointsjson; json also reads points streams and Houdini .geo; bgeo = uncompressed binary Houdini geometry, not .bgeo.sc; png = an image volume)' },
415
467
  up_axis: { type: 'enum', options: ['y', 'z'], default: 'y', desc: 'source up axis; z converts Z-up files (Blender/Unreal exports) to Y-up' },
416
468
  scale: { type: 'float', default: 1, desc: 'uniform scale (e.g. 0.01 for centimetres → metres)' },
417
469
  weld: { type: 'bool', default: false, desc: 'fuse coincident points (glTF/STL meshes are split at seams; weld before booleans; expands packed instances)' },
418
- instancing: { type: 'enum', options: ['packed', 'expand'], default: 'packed', desc: 'glTF EXT_mesh_gpu_instancing: keep instances as packed prims, or expand them into geometry' },
470
+ instancing: { type: 'enum', options: ['packed', 'expand'], default: 'packed', desc: 'glTF shared meshes and EXT_mesh_gpu_instancing: keep instances as packed prims, or expand them into geometry' },
471
+ },
472
+ signatureExtra: (p, engine) => {
473
+ const host = engine.host, files = directoryFiles(p.path, host);
474
+ return files === null ? host.fileStamp(p.path) : JSON.stringify([host.fileStamp(p.path), files.map((f) => [f, host.fileStamp(childPath(p.path, f))])]);
419
475
  },
420
- signatureExtra: (p, engine) => engine.host.fileStamp(p.path),
421
476
  example: { type: 'file', params: { path: 'props/chair.obj', up_axis: 'z', weld: true } },
422
477
  async cook({ p, engine, warn }) {
423
478
  if (!p.path)
424
479
  throw new NodeError('path is empty');
425
- const bytes = await engine.host.readFile(p.path).catch((e) => { throw new NodeError(`cannot read "${p.path}": ${e.message}`); });
426
- const fmt = p.format === 'auto' ? importFormatFromPath(p.path) : p.format;
427
- let g = await importGeometryAsync(bytes, fmt, { path: p.path, readFile: (f) => engine.host.readFile(f), warn, instancing: p.instancing });
428
- if (p.up_axis === 'z' || p.scale !== 1) {
429
- // packed instances: anchors convert like points, the conversion composes into their transforms (sources stay)
430
- if (g.primKind)
431
- composePackedXforms(g, p.up_axis === 'z' ? Float64Array.from([p.scale, 0, 0, 0, 0, 0, -p.scale, 0, 0, p.scale, 0, 0, 0, 0, 0, 1]) : Float64Array.from([p.scale, 0, 0, 0, 0, p.scale, 0, 0, 0, 0, p.scale, 0, 0, 0, 0, 1]));
432
- const P = g.P;
433
- for (let i = 0; i < P.length; i += 3) {
434
- let x = P[i], y = P[i + 1], z = P[i + 2];
435
- if (p.up_axis === 'z')
436
- [x, y, z] = [x, z, -y];
437
- P[i] = x * p.scale;
438
- P[i + 1] = y * p.scale;
439
- P[i + 2] = z * p.scale;
480
+ const host = engine.host, files = directoryFiles(p.path, host);
481
+ const read = async (file, format) => {
482
+ if (format === 'png') {
483
+ // an image, not a mesh: the image_file loader (its defaults), and no axis, scale or weld
484
+ const g = new Geometry();
485
+ try {
486
+ g.volumes = [await readImageFile((f) => host.readFile(f), file)];
487
+ }
488
+ catch (e) {
489
+ throw new NodeError(String(e?.message ?? e));
490
+ }
491
+ return g;
440
492
  }
441
- const N = g.points.get('N') ?? g.vertices.get('N');
442
- if (N && p.up_axis === 'z') {
443
- const d = N.data;
444
- for (let i = 0; i < d.length; i += 3) {
445
- const y = d[i + 1];
446
- d[i + 1] = d[i + 2];
447
- d[i + 2] = -y;
493
+ let fmt = format;
494
+ let bytes;
495
+ if (fmt === 'blend') {
496
+ if (!host.convertFile)
497
+ throw new NodeError('.blend needs the Node host with Blender installed (set TOPOTOPO_BLENDER); export a .glb instead');
498
+ bytes = await host.convertFile(host.resolvePath(file), 'glb');
499
+ fmt = 'glb';
500
+ }
501
+ else
502
+ bytes = await host.readFile(file).catch((e) => { throw new NodeError(`cannot read "${file}": ${e.message}${!host.isDirectory || !host.listDir ? `; ${folderError}` : ''}`); });
503
+ let g = await importGeometryAsync(bytes, fmt, { path: file, readFile: (f) => host.readFile(f), warn, instancing: p.instancing });
504
+ if (p.up_axis === 'z' || p.scale !== 1) {
505
+ // packed instances: anchors convert like points, the conversion composes into their transforms (sources stay)
506
+ if (g.primKind)
507
+ composePackedXforms(g, p.up_axis === 'z' ? Float64Array.from([p.scale, 0, 0, 0, 0, 0, -p.scale, 0, 0, p.scale, 0, 0, 0, 0, 0, 1]) : Float64Array.from([p.scale, 0, 0, 0, 0, p.scale, 0, 0, 0, 0, p.scale, 0, 0, 0, 0, 1]));
508
+ const P = g.P;
509
+ for (let i = 0; i < P.length; i += 3) {
510
+ let x = P[i], y = P[i + 1], z = P[i + 2];
511
+ if (p.up_axis === 'z')
512
+ [x, y, z] = [x, z, -y];
513
+ P[i] = x * p.scale;
514
+ P[i + 1] = y * p.scale;
515
+ P[i + 2] = z * p.scale;
516
+ }
517
+ const N = g.points.get('N') ?? g.vertices.get('N');
518
+ if (N && p.up_axis === 'z') {
519
+ const d = N.data;
520
+ for (let i = 0; i < d.length; i += 3) {
521
+ const y = d[i + 1];
522
+ d[i + 1] = d[i + 2];
523
+ d[i + 2] = -y;
524
+ }
448
525
  }
449
526
  }
527
+ if (p.weld) {
528
+ if (g.hasPacked())
529
+ g = expandPacked(g, { transfer: DEFAULT_TRANSFER }).geo;
530
+ const b = g.bbox();
531
+ const diag = Math.hypot(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]) || 1;
532
+ return fusePoints(g, diag * 1e-7);
533
+ }
534
+ return g;
535
+ };
536
+ if (files === null)
537
+ return read(p.path, p.format === 'auto' ? importFormatFromPath(p.path) : p.format);
538
+ const parts = [];
539
+ for (const file of files) {
540
+ const fmt = importFormatFromPath(file);
541
+ if (!IMPORT_FORMATS.includes(fmt) || (fmt === 'blend' && !host.convertFile))
542
+ continue;
543
+ if (fmt === 'png') {
544
+ // images ride along with the models; one that cannot be read (a thumbnail too large, interlaced) is skipped
545
+ try {
546
+ parts.push(await read(childPath(p.path, file), fmt));
547
+ }
548
+ catch (e) {
549
+ warn(`skipped ${file}: ${e?.message ?? e}`);
550
+ }
551
+ continue;
552
+ }
553
+ parts.push(folderNames(await read(childPath(p.path, file), fmt), file));
450
554
  }
451
- if (p.weld) {
452
- if (g.hasPacked())
453
- g = expandPacked(g, { transfer: DEFAULT_TRANSFER }).geo;
454
- const b = g.bbox();
455
- const diag = Math.hypot(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]) || 1;
456
- return fusePoints(g, diag * 1e-7);
457
- }
555
+ const g = Geometry.merge(parts);
556
+ const materials = mergeMaterialTables(parts);
557
+ if (Object.keys(materials).length)
558
+ writeMaterials(g, materials);
458
559
  return g;
459
560
  },
460
561
  });
@@ -12,6 +12,8 @@ import { defineNode } from '../graph/registry.js';
12
12
  import { NodeError } from '../graph/types.js';
13
13
  import { evalRamp } from '../graph/params.js';
14
14
  import { sampleEliminate } from './copy.js';
15
+ import { connectivity } from '../core/topology.js';
16
+ import { minDistanceSelect } from '../core/pcg.js';
15
17
  import { P, simBudget } from './util.js';
16
18
  import { wasmCallUnderBudget } from './wasmcall.js';
17
19
  import { getLoadedKernels, loadHostKernels } from '../wasm/kernels.js';
@@ -777,8 +779,12 @@ defineNode({
777
779
  throw new NodeError(`${ctx.type}: expression exceeded ${engine.scriptTimeoutMs} ms (infinite loop?)`);
778
780
  throw new NodeError(`${ctx.type}: ${e?.message ?? e}`);
779
781
  }
780
- if (failed !== undefined)
782
+ if (failed !== undefined) {
783
+ const missing = /^(\w+) is not defined$/.exec(failed);
784
+ if (missing)
785
+ throw new NodeError(`${ctx.type}: "${missing[1]}" is not a layer of this heightfield (layers: ${[...hf.layers.keys()].join(', ')}) and not an expression variable`, 'Compute the layer upstream, or connect the heightfield that has it as input 1 and read it as b_' + missing[1] + '.');
781
786
  throw new NodeError(`${ctx.type}: ${failed}`);
787
+ }
782
788
  if (bad)
783
789
  ctx.warn(`${bad} sample(s) gave NaN or infinity; written as 0`);
784
790
  writeLayer(hf, p.layer, mask, (k) => res[k]);
@@ -1254,7 +1260,7 @@ defineNode({
1254
1260
  type: 'heightfield_project',
1255
1261
  category: 'heightfield',
1256
1262
  summary: 'Stamp meshes or curves onto a heightfield: replace, raise (max), lower (min), add or subtract, blended over a radius.',
1257
- description: 'Houdini: HeightField Project. Polygons stamp the Y of their surface straight above/below each sample; open curves stamp their own height within width/2 of the curve. Outside the stamp the effect fades over radius metres towards the terrain (the nearest stamped height is used). mask_layer stores the blend weight (e.g. to freeze it during erosion or to texture it).',
1263
+ description: 'Houdini: HeightField Project. Polygons stamp the Y of their surface straight above/below each sample; open curves stamp their own height within width/2 of the curve. Outside the stamp the effect fades over radius metres towards the terrain (the nearest stamped height is used). level mean sets each connected polygon piece to the average terrain height under it instead of its own Y: building pads, plazas and car parks sit where cut and fill balance, so slopes get the smallest embankments. mask_layer stores the blend weight (e.g. to freeze it during erosion or to texture it).',
1258
1264
  aliases: ['hf_project', 'heightfield_stamp'],
1259
1265
  inputs: [{ name: 'heightfield', desc: 'geometry with a heightfield' }, { name: 'stamp', desc: 'polygons (plateaus, platforms, rocks) and/or curves (ridges, dykes)' }],
1260
1266
  params: {
@@ -1264,6 +1270,7 @@ defineNode({
1264
1270
  offset: { type: 'float', default: 0, desc: 'added to the stamped height (metres)' },
1265
1271
  width: { type: 'float', default: 1, min: 0, desc: 'curves: stamp width (metres)' },
1266
1272
  direction: { type: 'enum', options: ['down', 'up'], default: 'down', desc: 'polygons: stamp the highest (down) or lowest (up) surface' },
1273
+ level: { type: 'enum', options: ['stamp', 'mean'], default: 'stamp', desc: 'polygons: stamp height = the polygon surface (stamp), or the mean terrain height under each connected piece (mean: building pads that balance cut and fill; offset still applies)', aliases: ['pad', 'flatten_to'] },
1267
1274
  mask_layer: { type: 'string', default: '', desc: 'store the stamp weight in this layer ("" = no)' },
1268
1275
  },
1269
1276
  outputs: 'the layer (and mask_layer)',
@@ -1280,9 +1287,33 @@ defineNode({
1280
1287
  return eachField(ctx, (hf) => {
1281
1288
  const n = hf.count, up = p.direction === 'up';
1282
1289
  const SH = new Float64Array(n).fill(NaN), sites = new Uint8Array(n);
1290
+ const levelMean = p.level === 'mean' && closed > 0;
1291
+ const primPiece = levelMean ? connectivity(st).primClass : null, pieceOf = levelMean ? new Int32Array(n).fill(-1) : null;
1283
1292
  if (closed)
1284
- rasterizeMesh(hf, st, (k, y) => { if (!sites[k] || (up ? y < SH[k] : y > SH[k]))
1285
- SH[k] = y; sites[k] = 1; });
1293
+ rasterizeMesh(hf, st, (k, y, prim) => {
1294
+ if (!sites[k] || (up ? y < SH[k] : y > SH[k])) {
1295
+ SH[k] = y;
1296
+ if (pieceOf)
1297
+ pieceOf[k] = primPiece[prim];
1298
+ }
1299
+ sites[k] = 1;
1300
+ });
1301
+ if (pieceOf) {
1302
+ // each piece at the mean of the terrain it covers (the layer before this stamp)
1303
+ const cur = hf.read(p.layer), sum = new Map(), cnt = new Map();
1304
+ for (let k = 0; k < n; k++) {
1305
+ const c = pieceOf[k];
1306
+ if (c >= 0) {
1307
+ sum.set(c, (sum.get(c) ?? 0) + cur[k]);
1308
+ cnt.set(c, (cnt.get(c) ?? 0) + 1);
1309
+ }
1310
+ }
1311
+ for (let k = 0; k < n; k++) {
1312
+ const c = pieceOf[k];
1313
+ if (c >= 0)
1314
+ SH[k] = sum.get(c) / cnt.get(c);
1315
+ }
1316
+ }
1286
1317
  const best = new Float64Array(n).fill(Infinity);
1287
1318
  for (const c of curvesOf(st)) {
1288
1319
  if (c.closed)
@@ -1476,8 +1507,8 @@ defineNode({
1476
1507
  defineNode({
1477
1508
  type: 'heightfield_scatter',
1478
1509
  category: 'heightfield',
1479
- summary: 'Scatter points on a heightfield by a mask layer\'s density: P on the surface, N from the slope, height/slope/mask attributes. Feed copy_to_points.',
1480
- description: 'Houdini: HeightField Scatter. Count = density × area weighted by the mask (or count). Candidates are drawn by the mask (seeded), then thinned to even blue-noise spacing with relax (weighted sample elimination, as scatter, in chunks on the worker pool). Points sit exactly on the field\'s surface: bilinear between the samples (default), or with sample unreal_triangles the surface of an Unreal Landscape imported from this field\'s heightmap (export png16/r16): each cell split along its (0,0)–(1,1) diagonal in heightmap index space, column = X, row = Z (row 0 = min Z), which is how the Landscape triangulates its quads (vertex (x, y) = (column, row), Unreal Y = our Z), so instance bases land on the Landscape exactly. The modes agree at the samples and along cell edges and differ inside a cell by up to a quarter of its twist |h00 + h11 − h10 − h01|. N and slope come from central differences over one cell of the same surface; mask stays bilinear (Unreal samples weightmaps as textures). Attributes: height (Y), slope (degrees), mask (the density layer value at the point).',
1510
+ summary: 'Scatter points on a heightfield by a mask layer\'s density: P on the surface, N from the slope (or straight up), height/slope/mask attributes, optional minimum distance. Feed copy_to_points.',
1511
+ description: 'Houdini: HeightField Scatter. Count = density × area weighted by the mask (or count). Candidates are drawn by the mask (seeded), then thinned to even blue-noise spacing with relax (weighted sample elimination, as scatter, in chunks on the worker pool). Points sit exactly on the field\'s surface: bilinear between the samples (default), or with sample unreal_triangles the surface of an Unreal Landscape imported from this field\'s heightmap (export png16/r16): each cell split along its (0,0)–(1,1) diagonal in heightmap index space, column = X, row = Z (row 0 = min Z), which is how the Landscape triangulates its quads (vertex (x, y) = (column, row), Unreal Y = our Z), so instance bases land on the Landscape exactly. The modes agree at the samples and along cell edges and differ inside a cell by up to a quarter of its twist |h00 + h11 − h10 − h01|. N and slope come from central differences over one cell of the same surface; mask stays bilinear (Unreal samples weightmaps as textures). Attributes: height (Y), slope (degrees), mask (the density layer value at the point). N is the surface normal, and copy_to_points aims a copy\'s +Z along N (Houdini convention), which lays a Y-up tree down: align upright writes orient = identity so trees, houses and posts stand as modelled, align slope writes an orient that turns +Y onto the normal so rocks and shrubs lean with the terrain (orient wins over N in copy_to_points; random yaw goes on top with copy_to_points rotate_attrib). min_distance keeps every pair of points at least that far apart in XZ (exact dart throwing over the drawn candidates, replacing relax): building lots that never overlap; the count can come out lower when the masked area is full.',
1481
1512
  aliases: ['hf_scatter', 'heightfield_scatter_points'],
1482
1513
  inputs: [{ name: 'heightfield', desc: 'geometry with a heightfield' }],
1483
1514
  params: {
@@ -1487,12 +1518,14 @@ defineNode({
1487
1518
  relax: { type: 'bool', default: true, desc: 'even (blue-noise) spacing' },
1488
1519
  seed: P.seed(),
1489
1520
  keep_heightfield: { type: 'bool', default: false, desc: 'also keep the heightfield (default: points only)' },
1521
+ align: { type: 'enum', options: ['none', 'upright', 'slope'], default: 'none', desc: 'instance orientation for copy_to_points: none = only N (the surface normal; copy_to_points then aims a copy\'s +Z along it, the Houdini convention, which lays Y-up models down); upright = orient identity, models stand as authored (trees, buildings, posts); slope = orient turns a model\'s +Y onto the surface normal (rocks, shrubs leaning with the terrain). N stays the surface normal', aliases: ['orientation', 'upright'] },
1522
+ min_distance: { type: 'float', default: 0, min: 0, desc: 'exact minimum distance between points in XZ (metres; 0 = off), e.g. lot spacing so buildings never overlap; replaces relax, and the count can come out lower', aliases: ['mindist', 'min_dist', 'spacing'] },
1490
1523
  sample: {
1491
1524
  type: 'enum', options: ['bilinear', 'unreal_triangles'], default: 'bilinear', aliases: ['sampling', 'sample_mode'],
1492
1525
  desc: 'surface between samples: bilinear, or unreal_triangles = Unreal Landscape\'s triangles (each cell cut along its (0,0)–(1,1) diagonal in heightmap index space, column = X, row = Z), so points land exactly on a Landscape imported from this field\'s png16/r16 export',
1493
1526
  },
1494
1527
  },
1495
- outputs: 'points: N, height, slope, mask',
1528
+ outputs: 'points: N, height, slope, mask; orient with align upright or slope',
1496
1529
  example: { nodes: { hf: { type: 'heightfield' }, n: { type: 'heightfield_noise', in: ['hf'], params: { amplitude: 3 } }, m: { type: 'heightfield_mask_by_feature', in: ['n'], params: { feature: 'slope', min_angle: 20 } }, s: { type: 'heightfield_scatter', in: ['m'], params: { density: 0.3 } } }, output: 's' },
1497
1530
  cook: async (ctx) => {
1498
1531
  const { p, input } = ctx;
@@ -1522,7 +1555,8 @@ defineNode({
1522
1555
  }
1523
1556
  if (count > 2e6)
1524
1557
  throw new NodeError(`${ctx.type}: ${count.toLocaleString('en-US')} points is too many (max 2,000,000); lower density`);
1525
- const ncand = p.relax ? Math.min(count * 4, 8e6) : count;
1558
+ const md = Math.max(0, p.min_distance || 0);
1559
+ const ncand = md > 0 ? Math.min(Math.max(count * 8, 64), 8e6) : p.relax ? Math.min(count * 4, 8e6) : count;
1526
1560
  const pos = new Float64Array(ncand * 3);
1527
1561
  const x0 = hf.origin[0], z0 = hf.origin[1], x1 = x0 + hf.sizeX, z1 = z0 + hf.sizeZ;
1528
1562
  for (let q = 0; q < ncand; q++) {
@@ -1539,9 +1573,14 @@ defineNode({
1539
1573
  pos[q * 3] = Math.min(x1, Math.max(x0, hf.x(i) + (r() - 0.5) * c));
1540
1574
  pos[q * 3 + 2] = Math.min(z1, Math.max(z0, hf.z(j) + (r() - 0.5) * c));
1541
1575
  }
1542
- const keep = p.relax && ncand > count
1543
- ? await sampleEliminate(pos, count, Math.sqrt(total / (2 * Math.sqrt(3) * count)), undefined, ctx.parallelMap)
1544
- : Int32Array.from({ length: Math.min(ncand, count) }, (_, i) => i);
1576
+ // candidates are drawn in mask-weighted random order, so greedy dart throwing over them keeps the density
1577
+ const keep = md > 0
1578
+ ? minDistanceSelect(pos, Int32Array.from({ length: ncand }, (_, i) => i), md, count)
1579
+ : p.relax && ncand > count
1580
+ ? await sampleEliminate(pos, count, Math.sqrt(total / (2 * Math.sqrt(3) * count)), undefined, ctx.parallelMap)
1581
+ : Int32Array.from({ length: Math.min(ncand, count) }, (_, i) => i);
1582
+ if (md > 0 && keep.length < count)
1583
+ ctx.warn(`min_distance ${md} m fits ${keep.length} of ${count} points on "${hf.name}"`);
1545
1584
  const np = keep.length;
1546
1585
  const out = new Geometry({ positions: new Float64Array(np * 3) });
1547
1586
  const Pd = out.P;
@@ -1549,6 +1588,7 @@ defineNode({
1549
1588
  const ha = out.points.add('height', 'float', 1).data;
1550
1589
  const sa = out.points.add('slope', 'float', 1).data;
1551
1590
  const ma = out.points.add('mask', 'float', 1).data;
1591
+ const OR = p.align === 'upright' || p.align === 'slope' ? out.points.add('orient', 'float', 4).data : null;
1552
1592
  // height, N and slope from the chosen surface (bilinear or Unreal's triangles); the mask always bilinear
1553
1593
  const H = hf.read('height'), e = c * 0.5, s = p.sample === 'unreal_triangles' ? 'unreal_triangles' : 'bilinear';
1554
1594
  keep.forEach((q, o) => {
@@ -1562,6 +1602,22 @@ defineNode({
1562
1602
  N[o * 3] = -gx / l;
1563
1603
  N[o * 3 + 1] = 1 / l;
1564
1604
  N[o * 3 + 2] = -gz / l;
1605
+ if (OR) {
1606
+ // upright: identity; slope: the shortest rotation taking +Y onto N, q = normalise((Y × N), 1 + Y·N)
1607
+ if (p.align === 'upright') {
1608
+ OR[o * 4] = 0;
1609
+ OR[o * 4 + 1] = 0;
1610
+ OR[o * 4 + 2] = 0;
1611
+ OR[o * 4 + 3] = 1;
1612
+ }
1613
+ else {
1614
+ const nx = N[o * 3], ny = N[o * 3 + 1], nz = N[o * 3 + 2], ql = Math.hypot(nz, nx, 1 + ny);
1615
+ OR[o * 4] = nz / ql;
1616
+ OR[o * 4 + 1] = 0;
1617
+ OR[o * 4 + 2] = -nx / ql;
1618
+ OR[o * 4 + 3] = (1 + ny) / ql;
1619
+ }
1620
+ }
1565
1621
  ha[o] = y;
1566
1622
  sa[o] = (Math.atan(Math.hypot(gx, gz)) * 180) / Math.PI;
1567
1623
  ma[o] = mask ? hf.sampleArray(mask, x, z) : 1;
@@ -1,7 +1,7 @@
1
1
  import { Geometry } from '../core/geometry.ts';
2
2
  import { Image } from '../core/image.ts';
3
3
  import type { CookContext, ParamDef } from '../graph/types.ts';
4
- export declare const NO_IMAGE = "make one with a generator (tex_noise, tex_pattern, tex_shape, tex_cells \u2026), a bake (bake_ao, bake_curvature \u2026) or heightfield_to_tex";
4
+ export declare const NO_IMAGE = "make one with a generator (tex_noise, tex_pattern, tex_shape, tex_cells \u2026), a bake (bake_ao, bake_curvature \u2026) or heightfield_to_tex, or load a PNG with image_file";
5
5
  export declare const resolutionParam: (def?: number) => ParamDef;
6
6
  export declare const nameParam: (def: string, desc?: string) => ParamDef;
7
7
  export declare function checkSize(w: number, h: number, node: string): void;
@@ -1,21 +1,22 @@
1
1
  // Texture nodes (category "texture"): a Substance Designer / Houdini COP style image family. Images are volumes of
2
- // kind 'image' carried in the geometry (core/image.ts): generators make a geometry holding one image, filters
3
- // process one layer of one image of their first input and pass everything else through, texture_to_material attaches
4
- // images to a material ("image:<name>" texture slots), and exporters embed them (glTF) or write them next to the file
5
- // (USD, the "textures" format). Bakes from meshes live in nodes/bake.ts.
2
+ // kind 'image' carried in the geometry (core/image.ts): generators make a geometry holding one image, image_file loads
3
+ // a PNG, filters process one layer of one image of their first input and pass everything else through,
4
+ // texture_to_material attaches images to a material ("image:<name>" texture slots), and exporters embed them (glTF) or
5
+ // write them next to the file (USD, the "textures" format). Bakes from meshes live in nodes/bake.ts.
6
6
  import { Geometry } from '../core/geometry.js';
7
7
  import { Image, MAIN_LAYER, MAX_PIXELS, channelsOf, findImage, imagesOf, isImage, resizeLayer, toGrey } from '../core/image.js';
8
8
  import { BLEND_MODES, GRADIENTS, PATTERNS, SHAPES, WARP_MODES, blendLayers, blurLayer, colorize, curvesLayer, edgeDetect, genGradient, genPattern, genScratches, genShape, genSplatter, heightToAO, histogramScanFn, levelsFn, mapValues, normalFromHeight, rampLUT, tileSampler, transformLayer, warpLayer, } from '../core/texture.js';
9
9
  import { Heightfield, heightfieldsOf } from '../core/heightfield.js';
10
10
  import { MATERIAL_ATTRIB, normalizeMaterial, readMaterials, writeMaterials } from '../core/materials.js';
11
11
  import { readUV } from '../core/uv.js';
12
+ import { PNG_LAYERS, readImageFile } from '../io/textures.js';
12
13
  import { defineNode } from '../graph/registry.js';
13
14
  import { NodeError } from '../graph/types.js';
14
15
  import { evalRamp } from '../graph/params.js';
15
16
  import { andThen } from '../graph/parallel.js';
16
17
  import { P, mask } from './util.js';
17
18
  // ---------------------------------------------------------------- shared params and helpers
18
- export const NO_IMAGE = 'make one with a generator (tex_noise, tex_pattern, tex_shape, tex_cells …), a bake (bake_ao, bake_curvature …) or heightfield_to_tex';
19
+ export const NO_IMAGE = 'make one with a generator (tex_noise, tex_pattern, tex_shape, tex_cells …), a bake (bake_ao, bake_curvature …) or heightfield_to_tex, or load a PNG with image_file';
19
20
  export const resolutionParam = (def = 512) => ({
20
21
  type: 'ivec2', default: [def, def], min: 1, max: 8192,
21
22
  desc: `image size in pixels [width, height] (one number = square; at most ${MAX_PIXELS.toLocaleString('en-US')} pixels); powers of two suit GPUs`,
@@ -372,6 +373,61 @@ defineNode({
372
373
  return imageGeo(img.set(MAIN_LAYER, fillContours(contours, w, h)));
373
374
  },
374
375
  });
376
+ // ---------------------------------------------------------------- files
377
+ defineNode({
378
+ type: 'image_file',
379
+ category: 'texture',
380
+ packed: 'keep',
381
+ summary: 'Load a PNG (8 or 16 bit, grey or colour) as an image: your own textures and masks into the texture nodes and materials.',
382
+ description: 'Houdini COP: File; Substance: Bitmap. The image is named after the file ("inputs/brick.png" → "brick") unless name ' +
383
+ 'is set; layer C holds it, grey or RGBA (layers), and a grey image keeps the file\'s alpha, when it has one, in layer ' +
384
+ '"A". Values are samples / 255 (16-bit files / 65535: their precision is kept). Colour layers hold display values, as ' +
385
+ 'every colour in the engine does ("#a5462e" is 0.65, 0.27, 0.18), so srgb (on by default) keeps the numbers the file ' +
386
+ 'stores: a PNG this engine wrote reads back unchanged, and masks, heights, normal and roughness maps load as they are. ' +
387
+ 'Turn srgb off only for a file holding linear-light colour (a linear render): grey and RGB are then encoded to display ' +
388
+ 'values, alpha never (unlike Unreal\'s sRGB checkbox, off does not mean "data"). With input 0 connected the image is ' +
389
+ 'added to that geometry, replacing an image of the same name, so image_file nodes chain. The file node reads .png ' +
390
+ 'files the same way (with these defaults).',
391
+ aliases: ['image_read', 'png_read', 'texture_file'],
392
+ inputs: [{ name: 'geo', desc: 'optional: add the image to this geometry (chain image_file nodes, or carry a mesh); else the output holds only the image', optional: true }],
393
+ params: {
394
+ path: { type: 'path', default: '', desc: 'PNG file (relative to the workspace, e.g. "inputs/brick.png") or URL', aliases: ['file', 'filename'] },
395
+ name: nameParam('', 'image name ("" = the file name without .png); materials reference it as "image:<name>"'),
396
+ layers: { type: 'enum', options: [...PNG_LAYERS], default: 'auto', desc: 'auto = grey for grey PNGs (1–2 channels), RGBA for colour ones; grey = the luminance of a colour file; rgba = always four channels', aliases: ['channels'] },
397
+ srgb: { type: 'bool', default: true, desc: 'the file holds display (sRGB) values: kept as stored, like every colour in the engine (masks and data maps load unchanged too); off = it holds linear-light colour, encoded to display values on load (alpha never)' },
398
+ },
399
+ signatureExtra: (p, engine) => engine.host.fileStamp(p.path),
400
+ outputs: 'image <name>: C grey or RGBA 0..1; A grey (the file\'s alpha, for a grey image of a file with alpha)',
401
+ example: {
402
+ nodes: {
403
+ color: { type: 'image_file', params: { path: 'inputs/brick_color.png' } },
404
+ maps: { type: 'image_file', in: ['color'], params: { path: 'inputs/brick_rough.png', layers: 'grey' } },
405
+ box: { type: 'box' },
406
+ uv: { type: 'uv_project', in: ['box'], params: { method: 'box' } },
407
+ out: { type: 'texture_to_material', in: ['uv', 'maps'], params: { material: 'brick', base_color: 'brick_color', roughness: 'brick_rough' } },
408
+ },
409
+ output: 'out',
410
+ },
411
+ async cook(ctx) {
412
+ const { p, type, engine } = ctx;
413
+ if (!String(p.path ?? '').trim())
414
+ throw new NodeError(`${type}: path is empty`, 'a PNG relative to the workspace (people drop theirs into inputs/) or a URL');
415
+ let img;
416
+ try {
417
+ img = await readImageFile((f) => engine.host.readFile(f), p.path, { name: String(p.name ?? '').trim(), layers: p.layers, srgb: !!p.srgb });
418
+ }
419
+ catch (e) {
420
+ throw new NodeError(`${type}: ${e?.message ?? e}`);
421
+ }
422
+ const g = ctx.hasInput(0) ? ctx.input(0).clone() : new Geometry();
423
+ const before = g.volumes.length;
424
+ g.volumes = g.volumes.filter((v) => !(isImage(v) && v.name === img.name));
425
+ if (g.volumes.length < before)
426
+ ctx.warn(`${type}: replaced the input's image "${img.name}" (set name to keep both)`);
427
+ g.volumes.push(img);
428
+ return g;
429
+ },
430
+ });
375
431
  // ---------------------------------------------------------------- filters
376
432
  const FILTER_INPUT = [{ name: 'image', desc: 'geometry holding the image to process (everything else passes through)' }];
377
433
  defineNode({
@@ -28,7 +28,8 @@ export function renderArgs(a) {
28
28
  if (has('views'))
29
29
  o.views = parseViews(a.views);
30
30
  for (const [s, c] of [['size', 'size'], ['azimuth', 'azimuth'], ['elevation', 'elevation'], ['zoom', 'zoom'], ['hard_angle', 'hardAngle'], ['max_labels', 'maxLabels'],
31
- ['glyph_scale', 'glyphScale'], ['max_glyphs', 'maxGlyphs'], ['ao_radius', 'aoRadius'], ['ortho_scale', 'orthoScale'], ['scale', 'scale'], ['ssaa', 'ssaa']]) {
31
+ ['glyph_scale', 'glyphScale'], ['max_glyphs', 'maxGlyphs'], ['ao_radius', 'aoRadius'], ['ortho_scale', 'orthoScale'], ['scale', 'scale'], ['ssaa', 'ssaa'],
32
+ ['max_triangles', 'maxTriangles']]) {
32
33
  const v = pick(s, c);
33
34
  if (v !== undefined)
34
35
  o[c] = num(v, s);
@@ -38,6 +38,8 @@ export interface RenderOptions {
38
38
  /** look-at point (default bbox centre) */
39
39
  target?: Vec3 | null;
40
40
  ssaa?: number;
41
+ /** packed instances: triangles to draw at most (default RENDER_TRIANGLE_BUDGET, 600k; up to 50M); above it an evenly spread subset */
42
+ maxTriangles?: number;
41
43
  title?: string;
42
44
  /** shade 3D views with an 8×8 procedural checker from uv (stretch and flips become visible) */
43
45
  checker?: boolean;
@@ -105,7 +105,8 @@ function makeCam(name, bmin, bmax, W, H, o, kind = name, azDeg, secAxis = 1) {
105
105
  export function renderGeometry(g, o = {}) {
106
106
  // packed instances are drawn expanded (within a triangle budget), spline curves as polylines, see core/packed.ts
107
107
  if (g.hasPacked() || g.hasSplines()) {
108
- const x = expandForRender(g, o);
108
+ const budget = o.maxTriangles !== undefined && o.maxTriangles > 0 ? Math.min(o.maxTriangles, 50_000_000) : undefined;
109
+ const x = expandForRender(g, o, budget);
109
110
  const r = renderGeometry(x.geo, x.opts);
110
111
  r.notes.unshift(...x.notes);
111
112
  return r;
@@ -1394,10 +1395,9 @@ export function renderGeometry(g, o = {}) {
1394
1395
  drawText(out, outW, outH, 6 * ui, fy, (o.title ? o.title.toUpperCase() + ' ' : '') + stats, [0.85, 0.85, 0.85], 2 * ui, false);
1395
1396
  if (legend) {
1396
1397
  const lx = outW - textWidth(legend, 2 * ui) - 8 * ui;
1398
+ // drawn when it fits; the note below reports it either way (once)
1397
1399
  if (lx > textWidth(stats, 2 * ui) + 30 * ui)
1398
1400
  drawText(out, outW, outH, lx, fy, legend, [1, 0.85, 0.4], 2 * ui, false);
1399
- else
1400
- notes.push(legend.toLowerCase());
1401
1401
  }
1402
1402
  if (labelsSkipped)
1403
1403
  notes.push(`labels: ${labelsSkipped} not drawn to avoid overlaps (raise size, zoom in, or lower max_labels)`);
@@ -1,9 +1,17 @@
1
- import type { Geometry } from '../core/geometry.ts';
1
+ import { Geometry } from '../core/geometry.ts';
2
2
  /**
3
3
  * Draw the texture view into col (W × H RGB floats at SSAA scale ss). Returns the tile label. `spec` is the render
4
4
  * option `texture` (see entriesFor).
5
5
  */
6
6
  export declare function drawTextureView(g: Geometry, col: Float32Array, W: number, H: number, ss: number, spec: string, notes: string[]): string;
7
+ /**
8
+ * For 3D viewers (the live viewer's GLB): a geometry holding only images (a dropped PNG, a texture graph) draws nothing,
9
+ * so each image (at most 16, as in the texture view) becomes a flat quad 1 m wide and height / width tall, standing on
10
+ * y = 0 in the XY plane and facing +Z, in reading order on a grid. Each quad has an unlit, double-sided material named
11
+ * "image:<name>" whose base colour map is the image (the GLB embeds it as a PNG), so it shows the stored values as the
12
+ * texture view does. Anything else (points, prims, other volumes) is returned as it is.
13
+ */
14
+ export declare function withImagePreview(g: Geometry, max?: number): Geometry;
7
15
  /** Base colour maps per prim for persp/ortho views: sample(prim, u, v, lod, out) fills out with RGB and returns true. */
8
16
  export interface TextureSampler {
9
17
  /** true when prim p shows a base colour map */
@@ -1,5 +1,11 @@
1
- import { Image, channelsOf, imageRef, referenceableImages, resizeLayer } from '../core/image.js';
2
- import { primMaterials, readMaterials } from '../core/materials.js';
1
+ // Textures in the renderer: the "texture" view (images drawn flat, labelled, several as a grid: every image, one image's
2
+ // layers, or a material's maps as a strip) and the sampler persp/ortho views use with `textures: true` to show base
3
+ // colour maps on meshes with uv. Pixels are drawn with the numbers they hold (as the PNG export writes them); RGBA
4
+ // layers sit over a checkerboard where alpha < 1. For 3D viewers (the live viewer's GLB), withImagePreview turns a
5
+ // geometry holding only images into flat textured quads laid out like the texture view.
6
+ import { Geometry } from '../core/geometry.js';
7
+ import { Image, channelsOf, imageRef, isImage, referenceableImages, resizeLayer } from '../core/image.js';
8
+ import { MATERIAL_ATTRIB, normalizeMaterial, primMaterials, readMaterials, writeMaterials } from '../core/materials.js';
3
9
  import { drawText, textWidth } from './font.js';
4
10
  const SLOT_LABEL = { baseColor: 'BASE COLOR', normal: 'NORMAL', roughness: 'ROUGHNESS', orm: 'ORM' };
5
11
  /**
@@ -139,6 +145,41 @@ export function drawTextureView(g, col, W, H, ss, spec, notes) {
139
145
  notes.push(`texture view: values outside 0..1 are shown clamped (${ranges.slice(0, 4).join(', ')})`);
140
146
  return n === 1 ? `TEXTURE ${entries[0].label.toUpperCase()}` : `TEXTURES ${n} MAPS`;
141
147
  }
148
+ /**
149
+ * For 3D viewers (the live viewer's GLB): a geometry holding only images (a dropped PNG, a texture graph) draws nothing,
150
+ * so each image (at most 16, as in the texture view) becomes a flat quad 1 m wide and height / width tall, standing on
151
+ * y = 0 in the XY plane and facing +Z, in reading order on a grid. Each quad has an unlit, double-sided material named
152
+ * "image:<name>" whose base colour map is the image (the GLB embeds it as a PNG), so it shows the stored values as the
153
+ * texture view does. Anything else (points, prims, other volumes) is returned as it is.
154
+ */
155
+ export function withImagePreview(g, max = 16) {
156
+ if (g.numPoints || !g.volumes.length || !g.volumes.every(isImage))
157
+ return g;
158
+ const imgs = [...referenceableImages(g).values()].slice(0, max);
159
+ const n = imgs.length, cols = Math.ceil(Math.sqrt(n)), rows = Math.ceil(n / cols), gap = 0.1;
160
+ const cellH = Math.max(...imgs.map((im) => im.height / im.width));
161
+ const P = [], prims = [], uv = [];
162
+ imgs.forEach((im, k) => {
163
+ const h = im.height / im.width, c = k % cols, r = Math.floor(k / cols);
164
+ const x = c * (1 + gap) - (cols * (1 + gap) - gap) / 2, y = (rows - 1 - r) * (cellH + gap) + (cellH - h) / 2;
165
+ const b = P.length / 3;
166
+ P.push(x, y, 0, x + 1, y, 0, x + 1, y + h, 0, x, y + h, 0);
167
+ uv.push(0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0);
168
+ prims.push([b, b + 1, b + 2, b + 3]);
169
+ });
170
+ const out = new Geometry({ positions: P, prims });
171
+ out.points.add('uv', 'float', 3).data.set(uv);
172
+ const mat = out.prims.add(MATERIAL_ATTRIB, 'string', 1);
173
+ const table = {};
174
+ imgs.forEach((im, k) => {
175
+ const name = `image:${im.name}`;
176
+ mat.set(k, name);
177
+ table[name] = normalizeMaterial({ baseColor: [1, 1, 1], roughness: 1, metallic: 0, unlit: true, doubleSided: true, textures: { baseColor: name } });
178
+ });
179
+ writeMaterials(out, table);
180
+ out.volumes = imgs;
181
+ return out;
182
+ }
142
183
  /** A mip chain of a layer (area-averaged halves, wrapping) down to 1 × 1. */
143
184
  function mipChain(img, layer) {
144
185
  const a = img.layer(layer), ch = channelsOf(a, img.count) || 1;