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/graph/types.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface NodeSpec {
|
|
|
74
74
|
note?: string;
|
|
75
75
|
ui?: any;
|
|
76
76
|
}
|
|
77
|
+
/** An exposed var: asset parameter docs and range, and the control the live viewer draws for it. */
|
|
77
78
|
export interface VarDef {
|
|
78
79
|
type?: string;
|
|
79
80
|
default?: any;
|
|
@@ -81,17 +82,42 @@ export interface VarDef {
|
|
|
81
82
|
min?: number;
|
|
82
83
|
max?: number;
|
|
83
84
|
options?: string[];
|
|
85
|
+
/** control label for humans (default: the var name) */
|
|
86
|
+
label?: string;
|
|
87
|
+
/** slider step (default: 1 for ints, else derived from the range) */
|
|
88
|
+
step?: number;
|
|
84
89
|
}
|
|
90
|
+
/** Free-form graph metadata (showcase render presets, editor layout, …). */
|
|
91
|
+
export interface GraphMeta {
|
|
92
|
+
[key: string]: any;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Where a published output exports to: a format name ("glb"), written as exports/<output>.<ext>, or an object with
|
|
96
|
+
* the format, an optional path inside exports/ and the export tool's options ({format: "obj", up_axis: "z"}).
|
|
97
|
+
*/
|
|
98
|
+
export type ExportTarget = string | {
|
|
99
|
+
format: string;
|
|
100
|
+
path?: string;
|
|
101
|
+
[option: string]: unknown;
|
|
102
|
+
};
|
|
85
103
|
export interface GraphSpec {
|
|
86
104
|
/** graph format version (see GRAPH_VERSION); missing = 0, migrated on load */
|
|
87
105
|
version?: number;
|
|
88
106
|
nodes: Record<string, NodeSpec>;
|
|
89
107
|
output?: string;
|
|
108
|
+
/** where the default output exports to (io/targets.ts): a format ("glb"), or {format, path?, ...export options} */
|
|
109
|
+
export?: ExportTarget;
|
|
110
|
+
/** Named deliverables for the viewer. `output` remains the default cook target. */
|
|
111
|
+
outputs?: Record<string, {
|
|
112
|
+
node: string;
|
|
113
|
+
label?: string;
|
|
114
|
+
export?: ExportTarget;
|
|
115
|
+
}>;
|
|
90
116
|
vars?: Record<string, any>;
|
|
91
|
-
/** metadata for exposed vars
|
|
117
|
+
/** metadata for exposed vars: asset parameters, and the controls the live viewer shows for them */
|
|
92
118
|
params?: Record<string, VarDef>;
|
|
93
119
|
description?: string;
|
|
94
|
-
meta?:
|
|
120
|
+
meta?: GraphMeta;
|
|
95
121
|
}
|
|
96
122
|
export interface CookContext {
|
|
97
123
|
node: string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Artifact {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
graph?: string;
|
|
5
|
+
node?: string;
|
|
6
|
+
format?: string;
|
|
7
|
+
sourceRevision?: string;
|
|
8
|
+
/** the published output a target export wrote ('' = the graph's default output) */
|
|
9
|
+
output?: string;
|
|
10
|
+
files: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare const artifactId: (p: string) => string;
|
|
13
|
+
export declare class ArtifactStore {
|
|
14
|
+
dir: string;
|
|
15
|
+
constructor(dir: string);
|
|
16
|
+
get manifest(): string;
|
|
17
|
+
records(): Artifact[];
|
|
18
|
+
record(files: string[], info?: Partial<Omit<Artifact, 'id' | 'files'>>): Artifact;
|
|
19
|
+
/** Discover unregistered agent-written files too. Never follow symlinks/junctions while scanning. */
|
|
20
|
+
list(): Artifact[];
|
|
21
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Persistent output receipts shared by the CLI, MCP and viewer. Paths are registered by trusted host code;
|
|
2
|
+
// HTTP callers only receive identifiers, never a route that reads an arbitrary absolute path.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
export const artifactId = (p) => createHash('sha256').update(path.resolve(p)).digest('hex').slice(0, 24);
|
|
7
|
+
export class ArtifactStore {
|
|
8
|
+
dir;
|
|
9
|
+
constructor(dir) { this.dir = path.resolve(dir); }
|
|
10
|
+
get manifest() { return path.join(this.dir, '.outputs.json'); }
|
|
11
|
+
records() {
|
|
12
|
+
try {
|
|
13
|
+
const j = JSON.parse(fs.readFileSync(this.manifest, 'utf8'));
|
|
14
|
+
return Array.isArray(j) ? j.filter((a) => a && typeof a.id === 'string' && Array.isArray(a.files) && a.files.every((p) => typeof p === 'string')) : [];
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
record(files, info = {}) {
|
|
21
|
+
if (!files.length)
|
|
22
|
+
throw new Error('an output needs at least one file');
|
|
23
|
+
files = files.map((p) => path.resolve(p));
|
|
24
|
+
const a = { id: artifactId(files[0]), label: path.basename(files[0]), ...info, files };
|
|
25
|
+
const all = this.records().filter((x) => x.id !== a.id);
|
|
26
|
+
all.push(a);
|
|
27
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
28
|
+
const temp = this.manifest + '.part';
|
|
29
|
+
fs.writeFileSync(temp, JSON.stringify(all, null, 2) + '\n');
|
|
30
|
+
fs.renameSync(temp, this.manifest);
|
|
31
|
+
return a;
|
|
32
|
+
}
|
|
33
|
+
/** Discover unregistered agent-written files too. Never follow symlinks/junctions while scanning. */
|
|
34
|
+
list() {
|
|
35
|
+
const all = this.records().filter((a) => a.files.some((p) => fs.statSync(p, { throwIfNoEntry: false })?.isFile()));
|
|
36
|
+
const known = new Set(all.flatMap((a) => a.files.map((p) => path.resolve(p))));
|
|
37
|
+
const walk = (dir) => {
|
|
38
|
+
// a folder can be missing, or vanish between listing its parent and reading it
|
|
39
|
+
const entries = (() => { try {
|
|
40
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
} })();
|
|
45
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
|
|
46
|
+
if (e.name.startsWith('.') || e.isSymbolicLink())
|
|
47
|
+
continue;
|
|
48
|
+
const p = path.join(dir, e.name);
|
|
49
|
+
if (e.isDirectory())
|
|
50
|
+
walk(p);
|
|
51
|
+
else if (e.isFile() && !known.has(p))
|
|
52
|
+
all.push({ id: artifactId(p), label: path.relative(this.dir, p).split(path.sep).join('/'), files: [p] });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(this.dir);
|
|
56
|
+
return all;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Geometry } from '../core/geometry.ts';
|
|
2
|
+
import type { ExportFormat } from '../io/export.ts';
|
|
3
|
+
import type { ExportFile } from '../io/files.ts';
|
|
4
|
+
import type { PublishedOutput, ResolvedTarget } from '../io/targets.ts';
|
|
5
|
+
import type { Host } from './types.ts';
|
|
6
|
+
/** The files an export writes, nothing written yet; `a` holds the export tool's snake_case options. */
|
|
7
|
+
export declare function serializeExport(geo: Geometry, format: ExportFormat, p: string, a: Record<string, any>, host: Host): Promise<{
|
|
8
|
+
files: ExportFile[];
|
|
9
|
+
summary: string;
|
|
10
|
+
warnings: string[];
|
|
11
|
+
outDir: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** `p` resolved inside `root`, crossing no symbolic link or junction on the way; throws otherwise. */
|
|
14
|
+
export declare function insideRoot(root: string, p: string): string;
|
|
15
|
+
/** Write files, each through a .part renamed into place; with `root`, each must stay inside it. Returns the count. */
|
|
16
|
+
export declare function writeExportFiles(files: ExportFile[], root?: string): number;
|
|
17
|
+
/** A published output serialized to its target, ready to write. */
|
|
18
|
+
export interface PreparedTarget {
|
|
19
|
+
graph: string;
|
|
20
|
+
output: string | null;
|
|
21
|
+
label: string;
|
|
22
|
+
node: string;
|
|
23
|
+
target: ResolvedTarget;
|
|
24
|
+
files: ExportFile[];
|
|
25
|
+
summary: string;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Serialize one published output to its target inside `exportsDir` (nothing is written yet). */
|
|
29
|
+
export declare function prepareTarget(geo: Geometry, graph: string, p: PublishedOutput & {
|
|
30
|
+
target: ResolvedTarget;
|
|
31
|
+
}, exportsDir: string, host: Host): Promise<PreparedTarget>;
|
|
32
|
+
/** Write prepared targets and record their receipts; two targets writing one file are refused before any write. */
|
|
33
|
+
export declare function writeTargets(prepared: PreparedTarget[], exportsDir: string): number;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Writing exports for the CLI, the MCP server and the viewer: serialize with the export tool's options, then write
|
|
2
|
+
// each file through a .part beside it, renamed into place. Published targets (io/targets.ts) write inside exports/
|
|
3
|
+
// only, and every target is serialized before any file is written, so a failed cook or format changes nothing.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import zlib from 'node:zlib';
|
|
7
|
+
import { exportFilesAsync } from '../io/files.js';
|
|
8
|
+
import { ArtifactStore } from './artifacts.js';
|
|
9
|
+
/** The files an export writes, nothing written yet; `a` holds the export tool's snake_case options. */
|
|
10
|
+
export async function serializeExport(geo, format, p, a, host) {
|
|
11
|
+
const warnings = [];
|
|
12
|
+
const outDir = format === 'unreal_landscape' && !/\.json$/i.test(p) ? p : path.dirname(p);
|
|
13
|
+
const res = await exportFilesAsync(geo, format, p, {
|
|
14
|
+
upAxis: a.up_axis, scale: a.scale, splitByName: a.split_by_name ?? true, custom: a.custom_attribs,
|
|
15
|
+
materials: a.materials, compress: a.compress, quantize: a.quantize, attributes: a.attributes, pcg: a.pcg || a.pcg_struct, pcgStruct: a.pcg_struct,
|
|
16
|
+
precision: a.precision, instancing: a.instancing, instances: a.instances, meshes: a.meshes,
|
|
17
|
+
layer: a.layer, layers: a.layers, heightMin: a.height_min, heightMax: a.height_max, heightfield: a.heightfield, tile_size: a.tile_size,
|
|
18
|
+
deflate: (d) => zlib.deflateSync(d, { level: 6 }),
|
|
19
|
+
readFile: (t) => host.readFile(t),
|
|
20
|
+
texturePath: (t) => {
|
|
21
|
+
const abs = host.resolvePath(t);
|
|
22
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(abs) ? abs : path.relative(outDir, abs).split(path.sep).join('/');
|
|
23
|
+
},
|
|
24
|
+
warn: (m) => warnings.push(m),
|
|
25
|
+
});
|
|
26
|
+
return { files: res.files, summary: res.summary, warnings: [...new Set(warnings)], outDir };
|
|
27
|
+
}
|
|
28
|
+
/** `p` resolved inside `root`, crossing no symbolic link or junction on the way; throws otherwise. */
|
|
29
|
+
export function insideRoot(root, p) {
|
|
30
|
+
const abs = path.resolve(p), rel = path.relative(path.resolve(root), abs);
|
|
31
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
|
|
32
|
+
throw new Error(`${p} is outside ${root}`);
|
|
33
|
+
let current = path.resolve(root);
|
|
34
|
+
for (const part of rel.split(path.sep)) {
|
|
35
|
+
current = path.join(current, part);
|
|
36
|
+
if (fs.lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
37
|
+
throw new Error(`export paths cannot pass through the link ${current}`);
|
|
38
|
+
}
|
|
39
|
+
return abs;
|
|
40
|
+
}
|
|
41
|
+
/** Write files, each through a .part renamed into place; with `root`, each must stay inside it. Returns the count. */
|
|
42
|
+
export function writeExportFiles(files, root) {
|
|
43
|
+
let written = 0;
|
|
44
|
+
try {
|
|
45
|
+
for (const f of files) {
|
|
46
|
+
const abs = root ? insideRoot(root, f.path) : path.resolve(f.path);
|
|
47
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
48
|
+
fs.writeFileSync(abs + '.part', f.bytes);
|
|
49
|
+
fs.renameSync(abs + '.part', abs);
|
|
50
|
+
written++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new Error(`export stopped after writing ${written} of ${files.length} files: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
return written;
|
|
57
|
+
}
|
|
58
|
+
/** Serialize one published output to its target inside `exportsDir` (nothing is written yet). */
|
|
59
|
+
export async function prepareTarget(geo, graph, p, exportsDir, host) {
|
|
60
|
+
const dest = insideRoot(exportsDir, path.join(exportsDir, ...p.target.path.split('/')));
|
|
61
|
+
const r = await serializeExport(geo, p.target.format, dest, p.target.options, host);
|
|
62
|
+
for (const f of r.files)
|
|
63
|
+
insideRoot(exportsDir, f.path);
|
|
64
|
+
return { graph, output: p.output, label: p.label, node: p.node, target: p.target, files: r.files, summary: r.summary, warnings: r.warnings };
|
|
65
|
+
}
|
|
66
|
+
/** Write prepared targets and record their receipts; two targets writing one file are refused before any write. */
|
|
67
|
+
export function writeTargets(prepared, exportsDir) {
|
|
68
|
+
const owner = new Map();
|
|
69
|
+
for (const p of prepared)
|
|
70
|
+
for (const f of p.files) {
|
|
71
|
+
const key = path.resolve(f.path).toLowerCase(), other = owner.get(key);
|
|
72
|
+
// status: the viewer answers 409 (the request conflicts with itself); the CLI and MCP print the message
|
|
73
|
+
if (other && other !== p)
|
|
74
|
+
throw Object.assign(new Error(`"${other.label}" (${other.graph}) and "${p.label}" (${p.graph}) both write ${path.relative(exportsDir, f.path)}: give one a different export path`), { status: 409 });
|
|
75
|
+
owner.set(key, p);
|
|
76
|
+
}
|
|
77
|
+
const store = new ArtifactStore(exportsDir);
|
|
78
|
+
let written = 0;
|
|
79
|
+
for (const p of prepared) {
|
|
80
|
+
written += writeExportFiles(p.files, exportsDir);
|
|
81
|
+
store.record(p.files.map((f) => f.path), { label: p.target.path, graph: p.graph, node: p.node, format: p.target.format, output: p.output ?? '' });
|
|
82
|
+
}
|
|
83
|
+
return written;
|
|
84
|
+
}
|
|
@@ -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) */
|