@threenative/core 0.2.0 → 0.3.1
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/LICENSE +21 -0
- package/README.md +55 -0
- package/capabilities.json +5292 -0
- package/dist/assets-kyoF7JlJ.d.ts +103 -0
- package/dist/audio-BFiGneTL.d.ts +156 -0
- package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
- package/dist/game-XGrTzapq.d.ts +1166 -0
- package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
- package/dist/hot.d.ts +20 -2
- package/dist/hot.js +14 -2
- package/dist/index.d.ts +2108 -55
- package/dist/index.js +15645 -2222
- package/dist/net.d.ts +65 -0
- package/dist/net.js +643 -0
- package/dist/playtest.d.ts +37 -4
- package/dist/playtest.js +246 -546
- package/dist/react.d.ts +177 -0
- package/dist/react.js +635 -0
- package/dist/renderer-C6hqZpoG.d.ts +770 -0
- package/dist/ui-layer.d.ts +306 -0
- package/dist/ui-layer.js +425 -0
- package/dist/world.d.ts +254 -0
- package/dist/world.js +2686 -0
- package/gpl/LICENSE.GPL +117 -0
- package/gpl/convert.py +192 -0
- package/gpl/recipes/_common.py +169 -0
- package/gpl/recipes/bake_ao.py +111 -0
- package/gpl/recipes/decimate.py +64 -0
- package/gpl/recipes/retarget.py +131 -0
- package/gpl/recipes/unwrap.py +71 -0
- package/mcp/assets.mjs +5 -0
- package/mcp/blender-server.mjs +632 -0
- package/mcp/blender.mjs +27 -0
- package/mcp/engine-server.mjs +501 -0
- package/mcp/engine.mjs +31 -0
- package/mcp/install.d.mts +37 -0
- package/mcp/install.mjs +145 -0
- package/mcp/launch.mjs +72 -0
- package/mcp/sculpt.mjs +5 -0
- package/mcp/servers.d.mts +34 -0
- package/mcp/servers.mjs +160 -0
- package/package.json +76 -6
- package/patches/three@0.185.1.patch +522 -0
- package/scripts/apply-three-patch.mjs +297 -0
- package/scripts/ensure-mcp.mjs +43 -0
- package/scripts/postinstall.mjs +6 -0
- package/dist/audio-CEAw0w5y.d.ts +0 -35
- package/dist/game-DRt1Qhq3.d.ts +0 -429
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync, existsSync, readFileSync, statSync, accessSync, constants } from 'fs';
|
|
3
|
+
import path2 from 'path';
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { execFileSync, execFile } from 'child_process';
|
|
7
|
+
import { mkdtemp, rm } from 'fs/promises';
|
|
8
|
+
import os, { platform, homedir } from 'os';
|
|
9
|
+
|
|
10
|
+
var BLENDER_VERSION_FLOOR = Object.freeze({ major: 4, minor: 2 });
|
|
11
|
+
var BLENDER_INSTALL_GUIDANCE = Object.freeze({
|
|
12
|
+
linux: "sudo snap install blender --classic (or your distribution's package, or https://www.blender.org/download/)",
|
|
13
|
+
macos: "brew install --cask blender (or https://www.blender.org/download/)",
|
|
14
|
+
windows: "winget install --id BlenderFoundation.Blender (or https://www.blender.org/download/)"
|
|
15
|
+
});
|
|
16
|
+
function floorString() {
|
|
17
|
+
return `${BLENDER_VERSION_FLOOR.major}.${BLENDER_VERSION_FLOOR.minor}`;
|
|
18
|
+
}
|
|
19
|
+
function installCommandFor(platformName = platform()) {
|
|
20
|
+
if (platformName === "darwin") return BLENDER_INSTALL_GUIDANCE.macos;
|
|
21
|
+
if (platformName === "win32") return BLENDER_INSTALL_GUIDANCE.windows;
|
|
22
|
+
return BLENDER_INSTALL_GUIDANCE.linux;
|
|
23
|
+
}
|
|
24
|
+
function executableFile(candidate) {
|
|
25
|
+
try {
|
|
26
|
+
if (!existsSync(candidate) || !statSync(candidate).isFile()) return false;
|
|
27
|
+
accessSync(candidate, constants.X_OK);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function binaryNames(platformName) {
|
|
34
|
+
return platformName === "win32" ? ["blender.exe", "blender.com"] : ["blender"];
|
|
35
|
+
}
|
|
36
|
+
function pathCandidates(environment, platformName) {
|
|
37
|
+
const raw = environment.PATH ?? environment.Path ?? "";
|
|
38
|
+
if (raw.trim().length === 0) return [];
|
|
39
|
+
return raw.split(path2.delimiter).filter((directory) => directory.trim().length > 0).flatMap((directory) => binaryNames(platformName).map((name) => path2.join(directory, name)));
|
|
40
|
+
}
|
|
41
|
+
function conventionalCandidates(platformName, home) {
|
|
42
|
+
if (platformName === "darwin") {
|
|
43
|
+
return [
|
|
44
|
+
"/Applications/Blender.app/Contents/MacOS/Blender",
|
|
45
|
+
path2.join(home, "Applications/Blender.app/Contents/MacOS/Blender")
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
if (platformName === "win32") {
|
|
49
|
+
const roots = [
|
|
50
|
+
process.env.ProgramFiles ?? "C:\\Program Files",
|
|
51
|
+
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"
|
|
52
|
+
];
|
|
53
|
+
return roots.flatMap(
|
|
54
|
+
(root) => ["5.2", "5.1", "5.0", "4.5", "4.2"].map(
|
|
55
|
+
(release) => path2.join(root, "Blender Foundation", `Blender ${release}`, "blender.exe")
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return [
|
|
60
|
+
"/usr/bin/blender",
|
|
61
|
+
"/usr/local/bin/blender",
|
|
62
|
+
"/snap/bin/blender",
|
|
63
|
+
"/var/lib/flatpak/exports/bin/org.blender.Blender",
|
|
64
|
+
path2.join(home, ".local/bin/blender")
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
function parseBlenderVersion(output) {
|
|
68
|
+
const match = /^Blender\s+(\d+\.\d+(?:\.\d+)?)/mu.exec(output);
|
|
69
|
+
return match?.[1];
|
|
70
|
+
}
|
|
71
|
+
function compareToFloor(version) {
|
|
72
|
+
const [major = 0, minor = 0] = version.split(".").map((part) => Number.parseInt(part, 10));
|
|
73
|
+
if (Number.isNaN(major) || Number.isNaN(minor)) return false;
|
|
74
|
+
if (major !== BLENDER_VERSION_FLOOR.major) return major > BLENDER_VERSION_FLOOR.major;
|
|
75
|
+
return minor >= BLENDER_VERSION_FLOOR.minor;
|
|
76
|
+
}
|
|
77
|
+
var probeBlenderVersion = (binary) => {
|
|
78
|
+
try {
|
|
79
|
+
const output = execFileSync(binary, ["--version"], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
+
timeout: 3e4
|
|
83
|
+
});
|
|
84
|
+
return parseBlenderVersion(output);
|
|
85
|
+
} catch {
|
|
86
|
+
return void 0;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function resolveBlender(environment = process.env, options = {}) {
|
|
90
|
+
const platformName = options.platform ?? platform();
|
|
91
|
+
const home = options.home ?? environment.HOME ?? environment.USERPROFILE ?? homedir();
|
|
92
|
+
const probeVersion = options.probeVersion ?? probeBlenderVersion;
|
|
93
|
+
const minimumVersion = floorString();
|
|
94
|
+
const install = BLENDER_INSTALL_GUIDANCE;
|
|
95
|
+
const override = environment.THREENATIVE_BLENDER_PATH;
|
|
96
|
+
if (override !== void 0 && override.trim().length > 0) {
|
|
97
|
+
const candidate = path2.resolve(override.trim());
|
|
98
|
+
if (!executableFile(candidate)) {
|
|
99
|
+
return {
|
|
100
|
+
available: false,
|
|
101
|
+
cause: "blender-unreadable",
|
|
102
|
+
detail: `THREENATIVE_BLENDER_PATH points at '${candidate}', which is not an executable file.`,
|
|
103
|
+
install,
|
|
104
|
+
minimumVersion,
|
|
105
|
+
path: candidate
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return classify(candidate, probeVersion, minimumVersion, install);
|
|
109
|
+
}
|
|
110
|
+
const candidates = [
|
|
111
|
+
...pathCandidates(environment, platformName),
|
|
112
|
+
...conventionalCandidates(platformName, home)
|
|
113
|
+
];
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (!executableFile(candidate)) continue;
|
|
116
|
+
const classified = classify(candidate, probeVersion, minimumVersion, install);
|
|
117
|
+
if (classified.cause !== "blender-unreadable") return classified;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
available: false,
|
|
121
|
+
cause: "blender-missing",
|
|
122
|
+
detail: `No Blender ${minimumVersion} or newer was found. Set THREENATIVE_BLENDER_PATH, or install it: ${installCommandFor(platformName)}`,
|
|
123
|
+
install,
|
|
124
|
+
minimumVersion
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function classify(binary, probeVersion, minimumVersion, install) {
|
|
128
|
+
const version = probeVersion(binary);
|
|
129
|
+
if (version === void 0) {
|
|
130
|
+
return {
|
|
131
|
+
available: false,
|
|
132
|
+
cause: "blender-unreadable",
|
|
133
|
+
detail: `'${binary}' did not report a Blender version.`,
|
|
134
|
+
install,
|
|
135
|
+
minimumVersion,
|
|
136
|
+
path: binary
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!compareToFloor(version)) {
|
|
140
|
+
return {
|
|
141
|
+
available: false,
|
|
142
|
+
cause: "blender-too-old",
|
|
143
|
+
detail: `Blender ${version} at '${binary}' is older than the supported floor ${minimumVersion}.`,
|
|
144
|
+
install,
|
|
145
|
+
minimumVersion,
|
|
146
|
+
path: binary,
|
|
147
|
+
version
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
available: true,
|
|
152
|
+
detail: `Blender ${version} at '${binary}'.`,
|
|
153
|
+
install,
|
|
154
|
+
minimumVersion,
|
|
155
|
+
path: binary,
|
|
156
|
+
version
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/bridge.ts
|
|
161
|
+
var BLENDER_SOURCE_EXTENSIONS = Object.freeze([
|
|
162
|
+
"blend",
|
|
163
|
+
"dae",
|
|
164
|
+
"fbx",
|
|
165
|
+
"obj"
|
|
166
|
+
]);
|
|
167
|
+
var RESULT_PREFIX = "TN_BLENDER_RESULT ";
|
|
168
|
+
var DEFAULT_TIMEOUT_MS = 3e5;
|
|
169
|
+
function blenderScriptsDirectory(environment = process.env) {
|
|
170
|
+
const override = environment.THREENATIVE_BLENDER_SCRIPTS;
|
|
171
|
+
if (override !== void 0 && override.trim().length > 0) return path2.resolve(override.trim());
|
|
172
|
+
return path2.resolve(fileURLToPath(import.meta.url), "..", "..", "gpl");
|
|
173
|
+
}
|
|
174
|
+
function failure(cause, detail, stderr) {
|
|
175
|
+
return {
|
|
176
|
+
cause,
|
|
177
|
+
detail,
|
|
178
|
+
install: BLENDER_INSTALL_GUIDANCE,
|
|
179
|
+
installCommand: installCommandFor(),
|
|
180
|
+
ok: false,
|
|
181
|
+
...stderr === void 0 ? {} : { stderr }
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function temporaryRoot(environment) {
|
|
185
|
+
const named = environment.TMPDIR ?? environment.TEMP ?? environment.TMP;
|
|
186
|
+
if (named !== void 0 && named.trim().length > 0 && existsSync(named.trim())) {
|
|
187
|
+
return path2.resolve(named.trim());
|
|
188
|
+
}
|
|
189
|
+
return os.tmpdir();
|
|
190
|
+
}
|
|
191
|
+
function unavailable(status) {
|
|
192
|
+
const cause = status.cause === "blender-too-old" ? "blender-too-old" : status.cause === "blender-unreadable" ? "blender-unreadable" : "blender-missing";
|
|
193
|
+
return failure(cause, status.detail);
|
|
194
|
+
}
|
|
195
|
+
function parseBlenderResult(stdout) {
|
|
196
|
+
const line = stdout.split("\n").reverse().find((candidate) => candidate.startsWith(RESULT_PREFIX));
|
|
197
|
+
if (line === void 0) return void 0;
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(line.slice(RESULT_PREFIX.length));
|
|
200
|
+
} catch {
|
|
201
|
+
return void 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function runBlender(binary, script, request, timeoutMs, environment) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const args = [
|
|
207
|
+
"--background",
|
|
208
|
+
"--factory-startup",
|
|
209
|
+
"--python-exit-code",
|
|
210
|
+
"3",
|
|
211
|
+
"--python",
|
|
212
|
+
script,
|
|
213
|
+
"--",
|
|
214
|
+
JSON.stringify(request)
|
|
215
|
+
];
|
|
216
|
+
execFile(
|
|
217
|
+
binary,
|
|
218
|
+
args,
|
|
219
|
+
{ encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs },
|
|
220
|
+
(error, stdout, stderr) => {
|
|
221
|
+
const killed = error?.killed === true;
|
|
222
|
+
const code = error?.code;
|
|
223
|
+
resolve({
|
|
224
|
+
code: error === null ? 0 : typeof code === "number" ? code : null,
|
|
225
|
+
stderr,
|
|
226
|
+
stdout,
|
|
227
|
+
timedOut: killed
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function assertSourcePath(source) {
|
|
234
|
+
if (typeof source !== "string" || source.trim().length === 0) {
|
|
235
|
+
throw new Error("TN_BLENDER_BRIDGE: 'source' must be a non-empty path.");
|
|
236
|
+
}
|
|
237
|
+
return path2.resolve(source);
|
|
238
|
+
}
|
|
239
|
+
async function run(request, options) {
|
|
240
|
+
const environment = options.environment ?? process.env;
|
|
241
|
+
const status = resolveBlender(environment);
|
|
242
|
+
if (!status.available || status.path === void 0) return unavailable(status);
|
|
243
|
+
const scripts = options.scriptsDirectory ?? blenderScriptsDirectory(environment);
|
|
244
|
+
const script = options.script ?? path2.join(scripts, "convert.py");
|
|
245
|
+
if (!existsSync(script)) {
|
|
246
|
+
return failure(
|
|
247
|
+
"script-missing",
|
|
248
|
+
`The Blender script '${script}' is not on disk. Set THREENATIVE_BLENDER_SCRIPTS to the package's gpl/ directory.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const temporary = await mkdtemp(path2.join(temporaryRoot(environment), "tn-blender-"));
|
|
252
|
+
let outcome;
|
|
253
|
+
try {
|
|
254
|
+
outcome = await runBlender(
|
|
255
|
+
status.path,
|
|
256
|
+
script,
|
|
257
|
+
request,
|
|
258
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
259
|
+
{ ...environment, TEMP: temporary, TMP: temporary, TMPDIR: temporary }
|
|
260
|
+
);
|
|
261
|
+
} finally {
|
|
262
|
+
await rm(temporary, { force: true, recursive: true });
|
|
263
|
+
}
|
|
264
|
+
if (outcome.timedOut) {
|
|
265
|
+
return failure(
|
|
266
|
+
"timeout",
|
|
267
|
+
`Blender did not finish within ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms.`,
|
|
268
|
+
outcome.stderr
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const summary = parseBlenderResult(outcome.stdout);
|
|
272
|
+
if (outcome.code !== 0) {
|
|
273
|
+
const named = outcome.stderr.split("\n").find((line) => line.startsWith("TN_BLENDER_ERROR:"));
|
|
274
|
+
const noMeshes = named?.includes("produced no meshes") === true;
|
|
275
|
+
return failure(
|
|
276
|
+
noMeshes ? "no-meshes" : "convert-failed",
|
|
277
|
+
named ?? `Blender exited ${outcome.code ?? "by signal"} for '${String(request.source)}'.`,
|
|
278
|
+
outcome.stderr
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (summary === void 0) {
|
|
282
|
+
return failure(
|
|
283
|
+
"unreadable-result",
|
|
284
|
+
"Blender exited 0 but printed no readable result line; refusing to report a conversion that cannot be described.",
|
|
285
|
+
outcome.stderr
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (summary.meshes === 0) {
|
|
289
|
+
return failure("no-meshes", `'${summary.source}' produced no meshes.`, outcome.stderr);
|
|
290
|
+
}
|
|
291
|
+
if (request.mode === "convert") {
|
|
292
|
+
const out = summary.out;
|
|
293
|
+
if (out === void 0 || !existsSync(out)) {
|
|
294
|
+
return failure(
|
|
295
|
+
"no-output",
|
|
296
|
+
`Blender reported success but wrote no file at '${String(request.out)}'.`,
|
|
297
|
+
outcome.stderr
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { ok: true, summary };
|
|
302
|
+
}
|
|
303
|
+
async function inspectModel(source, options = {}) {
|
|
304
|
+
return run({ mode: "inspect", source: assertSourcePath(source) }, options);
|
|
305
|
+
}
|
|
306
|
+
async function convertModel(source, out, options = {}) {
|
|
307
|
+
if (typeof out !== "string" || out.trim().length === 0) {
|
|
308
|
+
throw new Error("TN_BLENDER_BRIDGE: 'out' must be a non-empty path.");
|
|
309
|
+
}
|
|
310
|
+
return run(
|
|
311
|
+
{ mode: "convert", out: path2.resolve(out), source: assertSourcePath(source) },
|
|
312
|
+
options
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
async function runBlenderScript(script, request, options = {}) {
|
|
316
|
+
if (typeof script !== "string" || script.trim().length === 0) {
|
|
317
|
+
throw new Error("TN_BLENDER_BRIDGE: 'script' must be a non-empty path.");
|
|
318
|
+
}
|
|
319
|
+
return run(request, { ...options, script: path2.resolve(script) });
|
|
320
|
+
}
|
|
321
|
+
var PATH_PARAMETERS = [
|
|
322
|
+
{ description: "Path to the model to read.", name: "source", required: true },
|
|
323
|
+
{ description: "Path of the file to write.", name: "out", required: true }
|
|
324
|
+
];
|
|
325
|
+
var RECIPES = Object.freeze([
|
|
326
|
+
Object.freeze({
|
|
327
|
+
description: "Reduce triangle count to a requested fraction, preserving materials, UVs and rig. Reports trianglesBefore, trianglesAfter and achievedRatio.",
|
|
328
|
+
name: "decimate",
|
|
329
|
+
parameters: Object.freeze([
|
|
330
|
+
...PATH_PARAMETERS,
|
|
331
|
+
{
|
|
332
|
+
description: "Target fraction of the original triangles, 0 < ratio <= 1. Default 0.5.",
|
|
333
|
+
name: "ratio",
|
|
334
|
+
required: false
|
|
335
|
+
}
|
|
336
|
+
]),
|
|
337
|
+
script: "decimate.py"
|
|
338
|
+
}),
|
|
339
|
+
Object.freeze({
|
|
340
|
+
description: "Give meshes a UV layer they do not have, so they can carry a texture or a lightmap. Reports uvLayersBefore and uvLayersAfter.",
|
|
341
|
+
name: "unwrap",
|
|
342
|
+
parameters: Object.freeze([
|
|
343
|
+
...PATH_PARAMETERS,
|
|
344
|
+
{
|
|
345
|
+
description: "Smart-project angle limit in degrees. Default 66.",
|
|
346
|
+
name: "angleLimit",
|
|
347
|
+
required: false
|
|
348
|
+
},
|
|
349
|
+
{ description: "Island margin. Default 0.02.", name: "islandMargin", required: false },
|
|
350
|
+
{
|
|
351
|
+
description: "Only unwrap meshes with no UV layer at all. Default true.",
|
|
352
|
+
name: "onlyMissing",
|
|
353
|
+
required: false
|
|
354
|
+
}
|
|
355
|
+
]),
|
|
356
|
+
script: "unwrap.py"
|
|
357
|
+
}),
|
|
358
|
+
Object.freeze({
|
|
359
|
+
description: "Bake ambient occlusion \u2014 computed from geometry \u2014 into a PNG. Reports the baked image's mean, min and max luminance.",
|
|
360
|
+
name: "bake_ao",
|
|
361
|
+
parameters: Object.freeze([
|
|
362
|
+
...PATH_PARAMETERS,
|
|
363
|
+
{ description: "Square texture size in pixels. Default 256.", name: "size", required: false },
|
|
364
|
+
{ description: "Cycles samples. Default 16.", name: "samples", required: false }
|
|
365
|
+
]),
|
|
366
|
+
script: "bake_ao.py"
|
|
367
|
+
}),
|
|
368
|
+
Object.freeze({
|
|
369
|
+
description: "Move animation clips from one armature's bone names onto another's. Fails when a clip resolves no track on the destination armature.",
|
|
370
|
+
name: "retarget",
|
|
371
|
+
parameters: Object.freeze([
|
|
372
|
+
{ description: "Model carrying the clips to move.", name: "source", required: true },
|
|
373
|
+
{ description: "Model whose armature receives them.", name: "target", required: true },
|
|
374
|
+
{ description: "Path of the .glb to write.", name: "out", required: true },
|
|
375
|
+
{
|
|
376
|
+
description: "Object mapping source bone name to destination bone name.",
|
|
377
|
+
name: "map",
|
|
378
|
+
required: true
|
|
379
|
+
}
|
|
380
|
+
]),
|
|
381
|
+
script: "retarget.py"
|
|
382
|
+
})
|
|
383
|
+
]);
|
|
384
|
+
function recipeNames() {
|
|
385
|
+
return RECIPES.map((recipe) => recipe.name);
|
|
386
|
+
}
|
|
387
|
+
function findRecipe(name) {
|
|
388
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
389
|
+
throw new Error("blender_recipes requires a string 'name' argument, or none to list them all.");
|
|
390
|
+
}
|
|
391
|
+
const recipe = RECIPES.find((candidate) => candidate.name === name);
|
|
392
|
+
if (recipe === void 0) {
|
|
393
|
+
throw new Error(`Unknown recipe '${name}'. Shipped recipes: ${recipeNames().join(", ")}.`);
|
|
394
|
+
}
|
|
395
|
+
return recipe;
|
|
396
|
+
}
|
|
397
|
+
function recipePath(recipe, environment = process.env) {
|
|
398
|
+
return path2.join(blenderScriptsDirectory(environment), "recipes", recipe.script);
|
|
399
|
+
}
|
|
400
|
+
function recipeSource(recipe, environment = process.env) {
|
|
401
|
+
const file = recipePath(recipe, environment);
|
|
402
|
+
if (!existsSync(file)) {
|
|
403
|
+
throw new Error(`TN_BLENDER_RECIPE_MISSING: recipe '${recipe.name}' has no script at ${file}.`);
|
|
404
|
+
}
|
|
405
|
+
return readFileSync(file, "utf8");
|
|
406
|
+
}
|
|
407
|
+
async function runRecipe(name, request, options = {}) {
|
|
408
|
+
const recipe = findRecipe(name);
|
|
409
|
+
const environment = options.environment ?? process.env;
|
|
410
|
+
const script = recipePath(recipe, environment);
|
|
411
|
+
if (!existsSync(script)) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`TN_BLENDER_RECIPE_MISSING: recipe '${recipe.name}' has no script at ${script}.`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
for (const parameter of recipe.parameters) {
|
|
417
|
+
if (!parameter.required) continue;
|
|
418
|
+
if (request[parameter.name] === void 0) {
|
|
419
|
+
throw new Error(`Recipe '${recipe.name}' requires the '${parameter.name}' argument.`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return runBlenderScript(script, request, options);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/index.ts
|
|
426
|
+
var SERVER_NAME = "threenative-blender-mcp";
|
|
427
|
+
var SERVER_VERSION = "0.1.1";
|
|
428
|
+
var AUTHORING_INSTRUCTIONS = "Call blender_status before anything else: it answers whether this machine can convert models at all, and when it cannot it names the install command rather than failing. Blender is never installed for the user \u2014 ask first, then let them run the command. An .fbx, .blend, .obj or .dae placed in a game's assets directory is converted by the build itself; these tools are for inspecting a source before committing it and for operations the build does not perform. For anything no named tool covers, read a shipped recipe with blender_recipes and adapt it, then run it with blender_run_python.";
|
|
429
|
+
var TOOL_DEFINITIONS = [
|
|
430
|
+
{
|
|
431
|
+
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
|
|
432
|
+
name: "blender_status",
|
|
433
|
+
description: "Report whether this machine has a supported Blender, where it is and what version. Never fails: when Blender is absent it returns available:false with the install command for each platform.",
|
|
434
|
+
inputSchema: { additionalProperties: false, properties: {}, type: "object" }
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
|
|
438
|
+
name: "blender_inspect",
|
|
439
|
+
description: `Report what a model file contains without writing anything: mesh, triangle, vertex and bone counts, material names, animation clip names and image names. Accepts ${BLENDER_SOURCE_EXTENSIONS.join(", ")}, .gltf and .glb.`,
|
|
440
|
+
inputSchema: {
|
|
441
|
+
additionalProperties: false,
|
|
442
|
+
properties: {
|
|
443
|
+
source: { description: "Path to the model file to inspect.", type: "string" }
|
|
444
|
+
},
|
|
445
|
+
required: ["source"],
|
|
446
|
+
type: "object"
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: false },
|
|
451
|
+
name: "blender_convert",
|
|
452
|
+
description: "Convert a model file to a GLB a ThreeNative game can load, preserving skeletons, animation clips, materials and textures. A conversion that produces no meshes fails rather than writing an empty GLB. Note that a game's build converts these files itself: use this to check a source before committing it, or to write a GLB somewhere else.",
|
|
453
|
+
inputSchema: {
|
|
454
|
+
additionalProperties: false,
|
|
455
|
+
properties: {
|
|
456
|
+
out: { description: "Path of the .glb to write.", type: "string" },
|
|
457
|
+
source: { description: "Path to the model file to convert.", type: "string" }
|
|
458
|
+
},
|
|
459
|
+
required: ["source", "out"],
|
|
460
|
+
type: "object"
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: false },
|
|
465
|
+
name: "blender_recipes",
|
|
466
|
+
description: "List the shipped bpy recipes, read one's full source text, or run one. Read before you write: a working recipe adapted into your game beats bpy written cold. Omit 'name' to list; pass 'name' alone to read; pass 'name' with 'arguments' to run.",
|
|
467
|
+
inputSchema: {
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
properties: {
|
|
470
|
+
arguments: {
|
|
471
|
+
description: "Recipe arguments. Present means run it; absent means return its source.",
|
|
472
|
+
type: "object"
|
|
473
|
+
},
|
|
474
|
+
name: { description: "Recipe name. Omit to list every recipe.", type: "string" }
|
|
475
|
+
},
|
|
476
|
+
type: "object"
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
annotations: { destructiveHint: true, openWorldHint: false, readOnlyHint: false },
|
|
481
|
+
name: "blender_run_python",
|
|
482
|
+
description: "Run a bpy script of your own inside Blender, with a timeout, returning its result line, stderr and exit code. This is NOT a sandbox and does not claim to be: the script runs with your own privileges, exactly as it would if you invoked Blender through Bash yourself. What it adds is a resolved Blender, the same fail-closed handling every other tool here uses, and discoverability \u2014 not privilege. Read blender_recipes first; the shipped recipes are the worked examples.",
|
|
483
|
+
inputSchema: {
|
|
484
|
+
additionalProperties: false,
|
|
485
|
+
properties: {
|
|
486
|
+
arguments: {
|
|
487
|
+
description: "The JSON request handed to the script after '--'.",
|
|
488
|
+
type: "object"
|
|
489
|
+
},
|
|
490
|
+
script: { description: "Path to the .py file to run inside Blender.", type: "string" },
|
|
491
|
+
timeoutMs: { description: "Kill the run after this many milliseconds.", type: "number" }
|
|
492
|
+
},
|
|
493
|
+
required: ["script"],
|
|
494
|
+
type: "object"
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
];
|
|
498
|
+
function isRecord(value) {
|
|
499
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
500
|
+
}
|
|
501
|
+
function jsonRpcError(id, code, message) {
|
|
502
|
+
return JSON.stringify({ id, jsonrpc: "2.0", error: { code, message } });
|
|
503
|
+
}
|
|
504
|
+
function jsonRpcResult(id, result) {
|
|
505
|
+
return JSON.stringify({ id, jsonrpc: "2.0", result });
|
|
506
|
+
}
|
|
507
|
+
function writeLine(value) {
|
|
508
|
+
process.stdout.write(`${value}
|
|
509
|
+
`);
|
|
510
|
+
}
|
|
511
|
+
function toolText(value) {
|
|
512
|
+
return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] };
|
|
513
|
+
}
|
|
514
|
+
function blenderStatusResult(status = resolveBlender()) {
|
|
515
|
+
return {
|
|
516
|
+
available: status.available,
|
|
517
|
+
...status.cause === void 0 ? {} : { cause: status.cause },
|
|
518
|
+
detail: status.detail,
|
|
519
|
+
install: status.install,
|
|
520
|
+
installCommand: installCommandFor(),
|
|
521
|
+
minimumVersion: status.minimumVersion,
|
|
522
|
+
...status.path === void 0 ? {} : { path: status.path },
|
|
523
|
+
...status.version === void 0 ? {} : { version: status.version }
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function requiredString(argumentsValue, key, tool) {
|
|
527
|
+
const value = argumentsValue[key];
|
|
528
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
529
|
+
throw new Error(`${tool} requires a non-empty string '${key}' argument.`);
|
|
530
|
+
}
|
|
531
|
+
return value;
|
|
532
|
+
}
|
|
533
|
+
async function handleRecipes(argumentsValue) {
|
|
534
|
+
if (argumentsValue.name === void 0) {
|
|
535
|
+
return {
|
|
536
|
+
recipes: RECIPES.map((recipe2) => ({
|
|
537
|
+
description: recipe2.description,
|
|
538
|
+
name: recipe2.name,
|
|
539
|
+
parameters: recipe2.parameters,
|
|
540
|
+
script: recipePath(recipe2)
|
|
541
|
+
}))
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const recipe = findRecipe(argumentsValue.name);
|
|
545
|
+
if (argumentsValue.arguments === void 0) {
|
|
546
|
+
return {
|
|
547
|
+
description: recipe.description,
|
|
548
|
+
name: recipe.name,
|
|
549
|
+
parameters: recipe.parameters,
|
|
550
|
+
script: recipePath(recipe),
|
|
551
|
+
source: recipeSource(recipe)
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (!isRecord(argumentsValue.arguments)) {
|
|
555
|
+
throw new Error("blender_recipes 'arguments' must be an object.");
|
|
556
|
+
}
|
|
557
|
+
return runRecipe(recipe.name, argumentsValue.arguments);
|
|
558
|
+
}
|
|
559
|
+
async function handleToolCall(params) {
|
|
560
|
+
const name = params.name;
|
|
561
|
+
const argumentsValue = isRecord(params.arguments) ? params.arguments : {};
|
|
562
|
+
if (name === "blender_status") return toolText(blenderStatusResult());
|
|
563
|
+
if (name === "blender_inspect") {
|
|
564
|
+
const source = requiredString(argumentsValue, "source", "blender_inspect");
|
|
565
|
+
return toolText(await inspectModel(source));
|
|
566
|
+
}
|
|
567
|
+
if (name === "blender_convert") {
|
|
568
|
+
const source = requiredString(argumentsValue, "source", "blender_convert");
|
|
569
|
+
const out = requiredString(argumentsValue, "out", "blender_convert");
|
|
570
|
+
return toolText(await convertModel(source, out));
|
|
571
|
+
}
|
|
572
|
+
if (name === "blender_recipes") return toolText(await handleRecipes(argumentsValue));
|
|
573
|
+
if (name === "blender_run_python") {
|
|
574
|
+
const script = requiredString(argumentsValue, "script", "blender_run_python");
|
|
575
|
+
const request = isRecord(argumentsValue.arguments) ? argumentsValue.arguments : {};
|
|
576
|
+
const timeoutMs = argumentsValue.timeoutMs;
|
|
577
|
+
if (timeoutMs !== void 0 && typeof timeoutMs !== "number") {
|
|
578
|
+
throw new Error("blender_run_python 'timeoutMs' must be a number.");
|
|
579
|
+
}
|
|
580
|
+
return toolText(
|
|
581
|
+
await runBlenderScript(script, request, timeoutMs === void 0 ? {} : { timeoutMs })
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
throw new Error(`Unknown blender MCP tool '${String(name)}'.`);
|
|
585
|
+
}
|
|
586
|
+
async function handleLine(line) {
|
|
587
|
+
if (line.trim().length === 0) return void 0;
|
|
588
|
+
let request;
|
|
589
|
+
try {
|
|
590
|
+
request = JSON.parse(line);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
return jsonRpcError(null, -32700, `Invalid JSON: ${String(error)}`);
|
|
593
|
+
}
|
|
594
|
+
if (!isRecord(request) || request.id === void 0 || typeof request.method !== "string")
|
|
595
|
+
return void 0;
|
|
596
|
+
try {
|
|
597
|
+
if (request.method === "initialize") {
|
|
598
|
+
return jsonRpcResult(request.id, {
|
|
599
|
+
capabilities: { tools: { listChanged: false } },
|
|
600
|
+
instructions: AUTHORING_INSTRUCTIONS,
|
|
601
|
+
protocolVersion: "2025-06-18",
|
|
602
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (request.method === "tools/list") {
|
|
606
|
+
return jsonRpcResult(request.id, { tools: TOOL_DEFINITIONS });
|
|
607
|
+
}
|
|
608
|
+
if (request.method === "tools/call") {
|
|
609
|
+
if (!isRecord(request.params)) throw new Error("tools/call requires an object params value.");
|
|
610
|
+
return jsonRpcResult(request.id, await handleToolCall(request.params));
|
|
611
|
+
}
|
|
612
|
+
return jsonRpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
return jsonRpcError(request.id, -32e3, error instanceof Error ? error.message : String(error));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function runServer() {
|
|
618
|
+
const input = createInterface({ input: process.stdin });
|
|
619
|
+
let queue = Promise.resolve();
|
|
620
|
+
input.on("line", (line) => {
|
|
621
|
+
queue = queue.then(async () => {
|
|
622
|
+
const response = await handleLine(line);
|
|
623
|
+
if (response !== void 0) writeLine(response);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
var entryPath = process.argv[1];
|
|
628
|
+
if (entryPath !== void 0 && realpathSync(path2.resolve(entryPath)) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
629
|
+
runServer();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export { TOOL_DEFINITIONS, blenderStatusResult, handleLine, handleToolCall, runServer };
|
package/mcp/blender.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { launchMcpServer } from "./launch.mjs";
|
|
6
|
+
import { MCP_PACKAGES } from "./servers.mjs";
|
|
7
|
+
|
|
8
|
+
// Core carries the built server beside this shim, so a project that installed `@threenative/core`
|
|
9
|
+
// has working Blender tools with no network and no extra install.
|
|
10
|
+
//
|
|
11
|
+
// `blender-server.mjs` is generated by `scripts/bundle-blender-mcp.mjs` **and committed**, unlike
|
|
12
|
+
// its `engine-server.mjs` neighbour, which is gitignored. The difference matters: the npx fallback
|
|
13
|
+
// below cannot work for this server, because `threenative-blender-mcp` has never been published —
|
|
14
|
+
// a missing bundle becomes
|
|
15
|
+
//
|
|
16
|
+
// threenative-blender exited before completing MCP requests (1): npm error code E404
|
|
17
|
+
//
|
|
18
|
+
// which is what the golden-path matrix reported when the file was gitignored and CI's packed core
|
|
19
|
+
// tarball therefore did not contain it. `mcp-install.spec.ts` holds the committed copy equal to the
|
|
20
|
+
// current build output, the way `capabilities.json` and the census records are held.
|
|
21
|
+
const localServer = path.resolve(fileURLToPath(import.meta.url), "..", "blender-server.mjs");
|
|
22
|
+
if (existsSync(localServer)) {
|
|
23
|
+
const { runServer } = await import("./blender-server.mjs");
|
|
24
|
+
runServer();
|
|
25
|
+
} else {
|
|
26
|
+
await launchMcpServer(MCP_PACKAGES.blender);
|
|
27
|
+
}
|