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
@@ -0,0 +1,200 @@
1
+ """Headless .blend -> static GLB for TopoTopo. Never saves or overwrites the .blend."""
2
+ import json
3
+ import os
4
+ import copy
5
+ import struct
6
+ import sys
7
+ import traceback
8
+ from collections import Counter
9
+
10
+ import bpy
11
+
12
+
13
+ def static_copies():
14
+ """Bake evaluated geometry/transforms into copies; keep linked, unmodified meshes shared."""
15
+ scenes = list(bpy.data.scenes)
16
+ original_scene = bpy.context.window.scene
17
+ objects = list(dict.fromkeys(obj for scene in scenes for obj in scene.objects))
18
+ print("TopoTopo objects before export: " + json.dumps(dict(sorted(Counter(o.type for o in objects).items()))), flush=True)
19
+
20
+ # Excluded collections must be evaluated too, even though visibility is not an export filter.
21
+ def enable(layer):
22
+ layer.exclude = False
23
+ layer.hide_viewport = False
24
+ for child in layer.children:
25
+ enable(child)
26
+
27
+ for collection in bpy.data.collections:
28
+ collection.hide_viewport = False
29
+ for scene in scenes:
30
+ for layer in scene.view_layers:
31
+ enable(layer.layer_collection)
32
+ for obj in objects:
33
+ obj.hide_viewport = False
34
+ obj.hide_render = False
35
+ obj.hide_set(False)
36
+
37
+ copies, worlds, names, data_copies = {}, {}, {}, {}
38
+ for scene in scenes:
39
+ bpy.context.window.scene = scene
40
+ bpy.context.view_layer.update()
41
+ depsgraph = bpy.context.evaluated_depsgraph_get()
42
+ for obj in list(scene.objects):
43
+ if obj in copies:
44
+ continue
45
+ evaluated = obj.evaluated_get(depsgraph)
46
+ worlds[obj] = evaluated.matrix_world.copy()
47
+ names[obj] = obj.name
48
+ if obj.type in {'MESH', 'CURVE', 'SURFACE', 'FONT'}:
49
+ if obj.type == 'MESH' and not obj.modifiers and not obj.data.shape_keys:
50
+ if obj.data not in data_copies:
51
+ data_copies[obj.data] = obj.data.copy()
52
+ mesh = data_copies[obj.data]
53
+ else:
54
+ # Unlike the exporter’s curve path, this also retains unbevelled curve edges.
55
+ mesh = bpy.data.meshes.new_from_object(evaluated, preserve_all_data_layers=True, depsgraph=depsgraph)
56
+ if mesh is None:
57
+ raise RuntimeError("Could not evaluate " + obj.name + " as a mesh")
58
+ for key in obj.data.keys():
59
+ if key not in mesh:
60
+ mesh[key] = obj.data[key]
61
+ copy = bpy.data.objects.new(obj.name, mesh)
62
+ for key in obj.keys():
63
+ copy[key] = obj[key]
64
+ for index, slot in enumerate(obj.material_slots):
65
+ if index < len(copy.material_slots) and slot.link == 'OBJECT':
66
+ copy.material_slots[index].link = 'OBJECT'
67
+ copy.material_slots[index].material = slot.material
68
+ else:
69
+ copy = obj.copy()
70
+ copy.animation_data_clear()
71
+ copy.constraints.clear()
72
+ copies[obj] = copy
73
+
74
+ # Relink copies in the existing collection hierarchy; originals are removed only from this process's memory.
75
+ parents = {obj: obj.parent for obj in objects}
76
+ for obj, copy in copies.items():
77
+ for collection in obj.users_collection:
78
+ collection.objects.link(copy)
79
+ copy.parent = copies.get(parents[obj])
80
+ copy.parent_type = 'OBJECT'
81
+ copy.matrix_parent_inverse.identity()
82
+ def depth(obj):
83
+ return 0 if parents[obj] not in parents else 1 + depth(parents[obj])
84
+ for obj in sorted(objects, key=depth):
85
+ copies[obj].matrix_world = worlds[obj]
86
+ desired_names = [(copies[obj], names[obj]) for obj in objects]
87
+ for obj in objects:
88
+ bpy.data.objects.remove(obj, do_unlink=True)
89
+ for copy, name in desired_names:
90
+ copy.name = name
91
+ for scene in scenes:
92
+ scene.unit_settings.scale_length = 1.0 # apply units once to the exported roots, including collection instances
93
+ bpy.context.window.scene = original_scene
94
+ bpy.context.view_layer.update()
95
+ print("TopoTopo objects after conversion: " + json.dumps(dict(sorted(Counter(o.type for o in copies.values()).items()))), flush=True)
96
+
97
+
98
+ def apply_scene_units(output, units):
99
+ """Scale GLB roots, never prototypes: a collection instance must not apply the unit factor twice."""
100
+ if all(value == 1.0 for value in units.values()):
101
+ return
102
+ with open(output, 'rb') as stream:
103
+ data = stream.read()
104
+ magic, version, length = struct.unpack_from('<4sII', data)
105
+ if magic != b'glTF' or version != 2 or length != len(data):
106
+ raise RuntimeError('Exporter did not produce a valid GLB container')
107
+ chunks, offset = [], 12
108
+ while offset < length:
109
+ size, kind = struct.unpack_from('<II', data, offset)
110
+ chunks.append((kind, data[offset + 8:offset + 8 + size]))
111
+ offset += 8 + size
112
+ document = json.loads(chunks[0][1])
113
+ nodes = document.get('nodes', [])
114
+ originals = copy.deepcopy(nodes)
115
+ requests = {}
116
+ for scene in document.get('scenes', []):
117
+ factor = units.get(scene.get('name'), 1.0)
118
+ for index, root in enumerate(scene.get('nodes', [])):
119
+ requests.setdefault(root, {}).setdefault(factor, []).append((scene, index))
120
+
121
+ def clone_tree(index):
122
+ node = copy.deepcopy(originals[index])
123
+ if 'children' in node:
124
+ node['children'] = [clone_tree(child) for child in node['children']]
125
+ nodes.append(node)
126
+ return len(nodes) - 1
127
+
128
+ for root, factors in requests.items():
129
+ for variant, (factor, references) in enumerate(factors.items()):
130
+ # Usually roots are unique per scene. If scenes share a root but use different units, share meshes
131
+ # and copy only the node hierarchy so one scene's conversion cannot alter the other.
132
+ target = root if variant == 0 else clone_tree(root)
133
+ for scene, index in references:
134
+ scene['nodes'][index] = target
135
+ node = nodes[target]
136
+ if 'matrix' in node:
137
+ node['matrix'] = [value * factor if i % 4 != 3 else value for i, value in enumerate(node['matrix'])]
138
+ else:
139
+ node['translation'] = [value * factor for value in node.get('translation', [0, 0, 0])]
140
+ node['scale'] = [value * factor for value in node.get('scale', [1, 1, 1])]
141
+ payload = json.dumps(document, separators=(',', ':'), ensure_ascii=False).encode('utf8')
142
+ chunks[0] = (chunks[0][0], payload + b' ' * (-len(payload) % 4))
143
+ body = b''.join(struct.pack('<II', len(payload), kind) + payload for kind, payload in chunks)
144
+ with open(output, 'wb') as stream:
145
+ stream.write(struct.pack('<4sII', b'glTF', 2, 12 + len(body)) + body)
146
+ print('TopoTopo scene unit scales: ' + json.dumps(units, sort_keys=True), flush=True)
147
+
148
+
149
+ def main():
150
+ args = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
151
+ if len(args) != 1:
152
+ raise RuntimeError("Expected one output .glb path after --")
153
+ output = os.path.abspath(args[0])
154
+ units = {scene.name: scene.unit_settings.scale_length for scene in bpy.data.scenes}
155
+ static_copies()
156
+ properties = bpy.ops.export_scene.gltf.get_rna_type().properties
157
+ kwargs, missing = {}, []
158
+
159
+ def option(identifiers, value):
160
+ # Option identifiers come from RNA, not a version assumption. Aliases cover older exporters.
161
+ for identifier in identifiers:
162
+ if identifier in properties:
163
+ kwargs[identifier] = value
164
+ return
165
+ missing.append('/'.join(identifiers))
166
+
167
+ for identifiers, value in [
168
+ (('filepath',), output), (('export_format',), 'GLB'), (('check_existing',), False),
169
+ (('export_yup',), True), (('export_apply',), True),
170
+ (('use_mesh_edges', 'export_loose_edges'), True),
171
+ (('use_mesh_vertices', 'export_loose_points'), True),
172
+ (('export_extras',), True), (('export_materials',), 'EXPORT'),
173
+ (('export_image_format',), 'AUTO'), (('export_keep_originals',), False),
174
+ (('export_cameras',), False), (('export_lights',), False),
175
+ (('export_animations',), False), (('export_morph',), False), (('export_skins',), False),
176
+ (('use_selection',), False), (('use_visible',), False), (('use_renderable',), False),
177
+ (('use_active_collection',), False), (('use_active_collection_with_nested',), False),
178
+ (('use_active_scene',), False), (('collection',), ''),
179
+ (('export_hierarchy_full_collections',), True), (('export_hierarchy_flatten_objs',), False),
180
+ (('export_gpu_instances',), False), (('will_save_settings',), False),
181
+ ]:
182
+ option(identifiers, value)
183
+ print("TopoTopo exporter options set: " + json.dumps(kwargs, sort_keys=True), flush=True)
184
+ print("TopoTopo exporter options missing: " + json.dumps(missing), flush=True)
185
+ result = bpy.ops.export_scene.gltf(**kwargs)
186
+ if 'FINISHED' not in result or not os.path.isfile(output):
187
+ raise RuntimeError("glTF export did not finish: " + str(result))
188
+ apply_scene_units(output, units)
189
+
190
+
191
+ if __name__ == '__main__':
192
+ try:
193
+ main()
194
+ except Exception:
195
+ traceback.print_exc()
196
+ print("TopoTopo .blend conversion failed", file=sys.stderr, flush=True)
197
+ # Blender otherwise reports many Python errors with exit code zero.
198
+ sys.stdout.flush()
199
+ sys.stderr.flush()
200
+ os._exit(1)
@@ -5,6 +5,8 @@ import type { NodePoolOptions } from './pool.ts';
5
5
  * sources (src/host) and for the built package (dist/host): both sit two levels below the package root.
6
6
  */
7
7
  export declare const SHELF_DIR: string;
8
+ /** Executable lookup shared by conversion and integration tests. An unavailable installation returns null. */
9
+ export declare function findBlender(): Promise<string | null>;
8
10
  export interface NodeHostOptions {
9
11
  baseDir?: string;
10
12
  /** shelf directory (default SHELF_DIR), or false for an engine without the shelf */
@@ -16,5 +18,11 @@ export interface NodeHostOptions {
16
18
  workers?: number;
17
19
  /** further pool settings for a host with its own pool (idleMs, minElements) */
18
20
  pool?: Partial<NodePoolOptions>;
21
+ /** Optional diagnostics for actual Blender conversions (not version probes or cache hits). */
22
+ onBlenderRun?: (result: {
23
+ executable: string;
24
+ durationMs: number;
25
+ output: string;
26
+ }) => void;
19
27
  }
20
28
  export declare function createNodeHost(opts?: NodeHostOptions): Host;
package/dist/host/node.js CHANGED
@@ -3,6 +3,8 @@ import vm from 'node:vm';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
+ import { execFile } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import { createNodePool, poolOptionsFromEnv } from './pool.js';
8
10
  import { REALM_CONTROL, SCRIPT_REALM_SETUP } from '../core/snippet-lib.js';
@@ -33,11 +35,55 @@ function filterRealmRejections() {
33
35
  * sources (src/host) and for the built package (dist/host): both sit two levels below the package root.
34
36
  */
35
37
  export const SHELF_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../shelf');
38
+ /** No shell: executable paths and file names are passed as separate arguments on every platform. */
39
+ function runBlender(executable, args, timeout) {
40
+ return new Promise((resolve, reject) => {
41
+ execFile(executable, args, { timeout, windowsHide: true, maxBuffer: 16 * 1024 * 1024, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
42
+ const output = `${stdout}\n${stderr}`.trim();
43
+ if (error) {
44
+ const tail = output.split(/\r?\n/).slice(-40).join('\n');
45
+ const reason = error.message.split(/\r?\n/)[0]; // execFile's message may itself contain all of stderr
46
+ reject(new Error(`Blender failed (exit code ${error.code ?? 'none'}${error.signal ? `, signal ${error.signal}` : ''}${error.killed ? ', timed out or killed' : ''}): ${reason}\n${tail}`));
47
+ }
48
+ else
49
+ resolve(output);
50
+ });
51
+ });
52
+ }
53
+ /** Executable lookup shared by conversion and integration tests. An unavailable installation returns null. */
54
+ export async function findBlender() {
55
+ const candidates = [process.env.TOPOTOPO_BLENDER, 'blender'].filter((p) => !!p);
56
+ const foundation = path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Blender Foundation');
57
+ try {
58
+ const versions = fs.readdirSync(foundation, { withFileTypes: true }).filter((e) => e.isDirectory() && /^Blender /i.test(e.name));
59
+ versions.sort((a, b) => b.name.localeCompare(a.name, 'en', { numeric: true }));
60
+ candidates.push(...versions.map((e) => path.join(foundation, e.name, 'blender.exe')));
61
+ }
62
+ catch { /* normal on non-Windows hosts, or without a Foundation install */ }
63
+ candidates.push('C:\\Program Files (x86)\\Steam\\steamapps\\common\\Blender\\blender.exe', '/Applications/Blender.app/Contents/MacOS/Blender', '/usr/bin/blender', '/snap/bin/blender');
64
+ for (const candidate of new Set(candidates)) {
65
+ try {
66
+ await runBlender(candidate, ['--version'], 10_000);
67
+ return candidate;
68
+ }
69
+ catch { /* try the next installation */ }
70
+ }
71
+ return null;
72
+ }
73
+ function blenderScript() {
74
+ for (const relative of ['./export_scene.py', '../../integrations/blender/export_scene.py']) {
75
+ const file = fileURLToPath(new URL(relative, import.meta.url));
76
+ if (fs.existsSync(file))
77
+ return file;
78
+ }
79
+ throw new Error('Blender conversion script export_scene.py is missing from this TopoTopo installation');
80
+ }
36
81
  /** opts.shelf: shelf directory (default SHELF_DIR), or false for an engine without the shelf. */
37
82
  /** The process-wide pool of hosts created with the environment's settings (created lazily: no thread until used). */
38
83
  let envPool = null;
39
84
  export function createNodeHost(opts = {}) {
40
85
  const baseDir = opts.baseDir ?? process.cwd();
86
+ const conversions = new Map();
41
87
  let pool;
42
88
  if (opts.workers === undefined && !opts.pool) {
43
89
  if (envPool === null)
@@ -202,6 +248,74 @@ export function createNodeHost(opts = {}) {
202
248
  return 'missing';
203
249
  }
204
250
  },
251
+ isDirectory(p) {
252
+ try {
253
+ return fs.statSync(this.resolvePath(p)).isDirectory();
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ },
259
+ listDir(p) {
260
+ const dir = this.resolvePath(p);
261
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile() || (e.isSymbolicLink() && fs.statSync(path.join(dir, e.name)).isFile()))
262
+ .map((e) => e.name).sort((a, b) => a.localeCompare(b, 'en'));
263
+ },
264
+ async convertFile(p, target) {
265
+ if (target !== 'glb')
266
+ throw new Error(`unsupported conversion target "${target}"`);
267
+ const absolute = this.resolvePath(p);
268
+ if (/^https?:\/\//.test(absolute))
269
+ throw new Error('.blend conversion needs a local file path');
270
+ const stamp = this.fileStamp(absolute);
271
+ if (stamp === 'missing')
272
+ throw new Error(`cannot read "${absolute}": file is missing`);
273
+ const key = createHash('sha256').update(JSON.stringify([absolute, stamp])).digest('hex');
274
+ const cacheDir = path.resolve(baseDir, '.cache', 'blend');
275
+ const cached = path.join(cacheDir, `${key}.glb`);
276
+ // In-flight conversions of the same source share a process, including concurrent file nodes.
277
+ let job = conversions.get(key);
278
+ if (!job) {
279
+ job = (async () => {
280
+ try {
281
+ return new Uint8Array(await fs.promises.readFile(cached));
282
+ }
283
+ catch (e) {
284
+ if (e.code !== 'ENOENT')
285
+ throw e;
286
+ }
287
+ const executable = await findBlender();
288
+ if (!executable)
289
+ throw new Error('Blender was not found. Set TOPOTOPO_BLENDER to its executable, or install Blender from https://www.blender.org/download/');
290
+ const seconds = Number(process.env.TOPOTOPO_BLENDER_TIMEOUT ?? 300);
291
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds * 1000 > 2_147_483_647)
292
+ throw new Error('TOPOTOPO_BLENDER_TIMEOUT must be a positive number of seconds (default 300)');
293
+ await fs.promises.mkdir(cacheDir, { recursive: true });
294
+ const tempDir = await fs.promises.mkdtemp(path.join(cacheDir, 'convert-'));
295
+ try {
296
+ const outputFile = path.join(tempDir, 'scene.glb');
297
+ const start = performance.now();
298
+ // Factory settings keep user add-ons/startup handlers out of deterministic batch conversions.
299
+ const output = await runBlender(executable, ['--factory-startup', '--disable-autoexec', '-b', absolute, '--python', blenderScript(), '--', outputFile], Math.ceil(seconds * 1000));
300
+ opts.onBlenderRun?.({ executable, durationMs: performance.now() - start, output });
301
+ const bytes = new Uint8Array(await fs.promises.readFile(outputFile));
302
+ await fs.promises.rename(outputFile, cached);
303
+ return bytes;
304
+ }
305
+ finally {
306
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
307
+ }
308
+ })();
309
+ conversions.set(key, job);
310
+ }
311
+ try {
312
+ return await job;
313
+ }
314
+ finally {
315
+ if (conversions.get(key) === job)
316
+ conversions.delete(key);
317
+ }
318
+ },
205
319
  compile(source) {
206
320
  if (source === 'globalThis')
207
321
  return realmGlobal;
@@ -95,6 +95,11 @@ export interface Host {
95
95
  /** A string that changes when the file changes (mtime+size), for cache keys. */
96
96
  fileStamp(path: string): string;
97
97
  resolvePath(path: string): string;
98
+ /** Optional local-directory support. listDir returns immediate file names only (no subdirectories). */
99
+ isDirectory?(path: string): boolean;
100
+ listDir?(path: string): string[];
101
+ /** Convert a local source file to GLB (Node: .blend through headless Blender). Absent in browser/kernel hosts. */
102
+ convertFile?(path: string, target: 'glb'): Promise<Uint8Array>;
98
103
  /**
99
104
  * Compile JS source inside the scripting realm (which has the snippet library
100
105
  * installed) and return the resulting value (usually a function). The realm is set up with
package/dist/inspect.js CHANGED
@@ -130,8 +130,11 @@ export function summarize(g) {
130
130
  if (!t.map.size)
131
131
  continue;
132
132
  const items = [...t.map].map(([k, a]) => {
133
- if (cls === 'detail')
134
- return `${k} ${typeCode(a)} = ${fmtAttr(a, 0)}`;
133
+ if (cls === 'detail') {
134
+ // the summary stays readable: long values (e.g. the materials JSON) are cut; detail "attributes" shows them whole
135
+ const v = fmtAttr(a, 0);
136
+ return `${k} ${typeCode(a)} = ${v.length > 120 ? `${v.slice(0, 100)}… (${v.length} chars)` : v}`;
137
+ }
135
138
  if (a.isArray)
136
139
  return `${k} ${a.type} (${arrayLens(a, t.count)})`;
137
140
  if (a.type === 'string') {
@@ -22,6 +22,12 @@ export interface ExportOptions {
22
22
  quantize?: boolean;
23
23
  /** glTF: write materials from the prim "material" attribute and the detail table (default true) */
24
24
  materials?: boolean;
25
+ /**
26
+ * glTF: a named material's base colour is the factor even where Cd varies (COLOR_0 is still written). For engines
27
+ * whose default materials ignore vertex colours (Unreal Interchange), where glTF's white factor × COLOR_0 imports
28
+ * white; spec viewers then show factor × COLOR_0. The unreal_landscape bundle sets it. Default false.
29
+ */
30
+ materialColorWins?: boolean;
25
31
  /** glTF: polylines as one LINE_STRIP per curve ('strip', default) or one LINES primitive ('lines') */
26
32
  lineMode?: 'strip' | 'lines';
27
33
  /** csv / points JSON: point attributes to write (names or a comma/space separated list; default all) */
package/dist/io/export.js CHANGED
@@ -168,6 +168,9 @@ export function exportPLY(g, o = {}) {
168
168
  const usdName = (s) => (s.replace(/[^A-Za-z0-9_]/g, '_').replace(/^(\d)/, '_$1') || 'mesh');
169
169
  const fmt = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(6).replace(/0+$/, '').replace(/\.$/, ''));
170
170
  const tup = (a, k, n) => '(' + Array.from({ length: n }, (_, c) => fmt(a[k * n + c])).join(', ') + ')';
171
+ /** A colour tuple for USD: TopoTopo colours are display values, USD colour inputs and primvars are linear. */
172
+ const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
173
+ const tupColor = (a, k, n) => '(' + Array.from({ length: n }, (_, c) => fmt(linear(a[k * n + c]))).join(', ') + ')';
171
174
  /**
172
175
  * USDA stage: meshes, curves and points under /root, split by prim name. Packed instances (instancing 'packed', the
173
176
  * default) become one PointInstancer /root/instances whose prototypes (one per packed source, under its
@@ -226,10 +229,10 @@ function usdaMaterials(table, o, out) {
226
229
  const I = ' ';
227
230
  out.push(` def Material "${id}"`, ' {', ` token outputs:surface.connect = <${mp}/surface.outputs:surface>`, '');
228
231
  out.push(`${I}def Shader "surface"`, `${I}{`, `${I} uniform token info:id = "UsdPreviewSurface"`);
229
- out.push(`${I} color3f inputs:diffuseColor = ${tup(m.baseColor, 0, 3)}`);
232
+ out.push(`${I} color3f inputs:diffuseColor = ${tupColor(m.baseColor, 0, 3)}`);
230
233
  if (tex.baseColor)
231
234
  out.push(`${I} color3f inputs:diffuseColor.connect = <${mp}/base_color_map.outputs:rgb>`);
232
- out.push(`${I} color3f inputs:emissiveColor = ${tup(m.emissive, 0, 3)}`);
235
+ out.push(`${I} color3f inputs:emissiveColor = ${tupColor(m.emissive, 0, 3)}`);
233
236
  out.push(`${I} float inputs:metallic = ${fmt(m.metallic)}`);
234
237
  if (tex.orm)
235
238
  out.push(`${I} float inputs:metallic.connect = <${mp}/orm_map.outputs:b>`);
@@ -343,7 +346,7 @@ function usdaInstancer(g, o, uniq, out, mats = null) {
343
346
  if ((a.name === 'Cd' || a.name === 'Alpha') && t === 'float' && num.every((v) => v.every((x) => x === 1)))
344
347
  continue;
345
348
  if (a.name === 'Cd' && t === 'float' && sz >= 3) {
346
- out.push(` color3f[] primvars:displayColor = [${num.map((v) => tup(v, 0, 3)).join(', ')}] (`, ...vertex);
349
+ out.push(` color3f[] primvars:displayColor = [${num.map((v) => tupColor(v, 0, 3)).join(', ')}] (`, ...vertex);
347
350
  continue;
348
351
  }
349
352
  if (a.name === 'Alpha' && t === 'float' && sz === 1) {
@@ -419,7 +422,7 @@ function usdaPrims(g, o, uniq, out, mats = null) {
419
422
  const c = cd.attr.get(i);
420
423
  return Array.isArray(c) ? c.slice(0, 3) : [c, c, c];
421
424
  });
422
- out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) => tup(cols, k, 3)).join(', ')}] (`, ' interpolation = "faceVarying"', ' )');
425
+ out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) => tupColor(cols, k, 3)).join(', ')}] (`, ' interpolation = "faceVarying"', ' )');
423
426
  }
424
427
  if (o.custom)
425
428
  for (const [n, a] of geo.points.map) {
package/dist/io/gltf.d.ts CHANGED
@@ -32,7 +32,7 @@ export interface GLTFImportOptions {
32
32
  /** EXT_mesh_gpu_instancing: 'packed' (default) = packed prims sharing one source per mesh; 'expand' = real geometry */
33
33
  instancing?: 'packed' | 'expand';
34
34
  }
35
- /** Geometry from a gltf-transform Document (all scenes' nodes flattened into world space). */
35
+ /** Geometry from the default (or first) scene, traversed in scene order with world transforms. */
36
36
  export declare function documentToGeometry(doc: Document, warn?: (m: string) => void, instancing?: 'packed' | 'expand', base?: string): Geometry;
37
37
  /** Import GLB or .gltf bytes (external buffers through opts.readFile). */
38
38
  export declare function importGLTFAsync(bytes: Uint8Array, opts?: GLTFImportOptions): Promise<Geometry>;