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/nodes/flow.js
CHANGED
|
@@ -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
|
|
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',
|
|
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
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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) => {
|
|
1285
|
-
|
|
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
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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;
|
package/dist/nodes/texture.d.ts
CHANGED
|
@@ -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;
|
package/dist/nodes/texture.js
CHANGED
|
@@ -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,
|
|
3
|
-
// process one layer of one image of their first input and pass everything else through,
|
|
4
|
-
// images to a material ("image:<name>" texture slots), and exporters embed them (glTF) or
|
|
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({
|
package/dist/render/options.js
CHANGED
|
@@ -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);
|
package/dist/render/raster.d.ts
CHANGED
|
@@ -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;
|
package/dist/render/raster.js
CHANGED
|
@@ -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
|
|
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)`);
|
package/dist/render/texview.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import
|
|
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 */
|
package/dist/render/texview.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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;
|