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.
- package/README.md +44 -12
- package/dist/cli.js +98 -23
- package/dist/graph/engine.js +4 -1
- package/dist/graph/params.d.ts +7 -1
- package/dist/graph/params.js +21 -8
- package/dist/graph/types.d.ts +11 -2
- 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/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 +113 -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.js +32 -18
- package/dist/nodes/code.js +2 -0
- 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 +230 -0
- package/dist/viewer/app/grid.js +217 -0
- package/dist/viewer/app/guides.js +402 -0
- package/dist/viewer/app/keymap.js +187 -0
- package/dist/viewer/app/layers.js +225 -0
- package/dist/viewer/app/main.js +590 -0
- package/dist/viewer/app/markers.js +206 -0
- package/dist/viewer/app/mesh.js +172 -0
- package/dist/viewer/app/outliner.js +184 -0
- package/dist/viewer/app/props.js +432 -0
- package/dist/viewer/app/scene.js +529 -0
- package/dist/viewer/app/select.js +142 -0
- package/dist/viewer/app/selection.js +79 -0
- package/dist/viewer/app/style.css +172 -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 +53 -184
- package/dist/viewer/server.d.ts +42 -1
- package/dist/viewer/server.js +432 -17
- package/dist/workspace.d.ts +52 -2
- package/dist/workspace.js +95 -3
- package/package.json +8 -3
- package/shelf/brick_wall.json +1 -1
- 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)
|
package/dist/host/node.d.ts
CHANGED
|
@@ -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;
|
package/dist/host/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/dist/io/common.d.ts
CHANGED
|
@@ -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 = ${
|
|
232
|
+
out.push(`${I} color3f inputs:diffuseColor = ${tupColor(m.baseColor, 0, 3)}`);
|
|
230
233
|
if (tex.baseColor)
|
|
231
234
|
out.push(`${I} color3f inputs:diffuseColor.connect = <${mp}/base_color_map.outputs:rgb>`);
|
|
232
|
-
out.push(`${I} color3f inputs:emissiveColor = ${
|
|
235
|
+
out.push(`${I} color3f inputs:emissiveColor = ${tupColor(m.emissive, 0, 3)}`);
|
|
233
236
|
out.push(`${I} float inputs:metallic = ${fmt(m.metallic)}`);
|
|
234
237
|
if (tex.orm)
|
|
235
238
|
out.push(`${I} float inputs:metallic.connect = <${mp}/orm_map.outputs:b>`);
|
|
@@ -343,7 +346,7 @@ function usdaInstancer(g, o, uniq, out, mats = null) {
|
|
|
343
346
|
if ((a.name === 'Cd' || a.name === 'Alpha') && t === 'float' && num.every((v) => v.every((x) => x === 1)))
|
|
344
347
|
continue;
|
|
345
348
|
if (a.name === 'Cd' && t === 'float' && sz >= 3) {
|
|
346
|
-
out.push(` color3f[] primvars:displayColor = [${num.map((v) =>
|
|
349
|
+
out.push(` color3f[] primvars:displayColor = [${num.map((v) => tupColor(v, 0, 3)).join(', ')}] (`, ...vertex);
|
|
347
350
|
continue;
|
|
348
351
|
}
|
|
349
352
|
if (a.name === 'Alpha' && t === 'float' && sz === 1) {
|
|
@@ -419,7 +422,7 @@ function usdaPrims(g, o, uniq, out, mats = null) {
|
|
|
419
422
|
const c = cd.attr.get(i);
|
|
420
423
|
return Array.isArray(c) ? c.slice(0, 3) : [c, c, c];
|
|
421
424
|
});
|
|
422
|
-
out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) =>
|
|
425
|
+
out.push(` color3f[] primvars:displayColor = [${Array.from({ length: cols.length / 3 }, (_, k) => tupColor(cols, k, 3)).join(', ')}] (`, ' interpolation = "faceVarying"', ' )');
|
|
423
426
|
}
|
|
424
427
|
if (o.custom)
|
|
425
428
|
for (const [n, a] of geo.points.map) {
|
package/dist/io/gltf.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface GLTFImportOptions {
|
|
|
32
32
|
/** EXT_mesh_gpu_instancing: 'packed' (default) = packed prims sharing one source per mesh; 'expand' = real geometry */
|
|
33
33
|
instancing?: 'packed' | 'expand';
|
|
34
34
|
}
|
|
35
|
-
/** Geometry from
|
|
35
|
+
/** Geometry from the default (or first) scene, traversed in scene order with world transforms. */
|
|
36
36
|
export declare function documentToGeometry(doc: Document, warn?: (m: string) => void, instancing?: 'packed' | 'expand', base?: string): Geometry;
|
|
37
37
|
/** Import GLB or .gltf bytes (external buffers through opts.readFile). */
|
|
38
38
|
export declare function importGLTFAsync(bytes: Uint8Array, opts?: GLTFImportOptions): Promise<Geometry>;
|