dsh-plugin-blender-3d 0.1.1 → 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/lib/bridge-client.js +57 -8
- package/lib/index.js +33 -0
- package/lib/setup.js +280 -0
- package/package.json +2 -1
- package/skill/blender-3d-modeling/SKILL.md +30 -0
package/lib/bridge-client.js
CHANGED
|
@@ -8,14 +8,38 @@
|
|
|
8
8
|
* geometry stays in Python and is written to disk (OOM-safe, see SKILL.md).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
11
|
+
import { spawn, execFile } from "node:child_process";
|
|
12
12
|
import http from "node:http";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
15
17
|
|
|
16
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const BRIDGE_PATH = path.join(__dirname, "runtime", "headless_bridge.py");
|
|
18
20
|
|
|
21
|
+
// Persisted setup config (written by lib/setup.js). Read here directly to keep
|
|
22
|
+
// setup.js an optional import and avoid a circular dependency.
|
|
23
|
+
const _DATA_DIR = process.env.DSH_HOME
|
|
24
|
+
? path.join(process.env.DSH_HOME, ".blender-plugin")
|
|
25
|
+
: path.join(os.homedir(), ".blender-plugin");
|
|
26
|
+
const _CONFIG_PATH = path.join(_DATA_DIR, "config.json");
|
|
27
|
+
|
|
28
|
+
/** Return the configured python binary path, or null. Never throws. */
|
|
29
|
+
function readConfigPython() {
|
|
30
|
+
try {
|
|
31
|
+
const cfg = JSON.parse(fs.readFileSync(_CONFIG_PATH, "utf8"));
|
|
32
|
+
return cfg && typeof cfg.pythonPath === "string" ? cfg.pythonPath : null;
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Quick `python -c "import trimesh"` probe. Never throws. */
|
|
37
|
+
function hasTrimesh(python) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
execFile(python, ["-c", "import trimesh"], { timeout: 8000 }, (err) => resolve(!err));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
// Module-level handle so we don't double-spawn within one harness process.
|
|
20
44
|
let _child = null;
|
|
21
45
|
|
|
@@ -38,11 +62,7 @@ function healthCheck(host, port, timeoutMs = 2000) {
|
|
|
38
62
|
});
|
|
39
63
|
}
|
|
40
64
|
|
|
41
|
-
|
|
42
|
-
const host = opts.host || "127.0.0.1";
|
|
43
|
-
const port = Number(opts.port || process.env.AIB_PORT || 13082);
|
|
44
|
-
const python = opts.python || process.env.AIB_PYTHON || "python3";
|
|
45
|
-
|
|
65
|
+
async function tryStart(python, host, port, startupMs) {
|
|
46
66
|
// Already up?
|
|
47
67
|
const up = await healthCheck(host, port);
|
|
48
68
|
if (up && up.ok) return { host, port, spawned: false, health: up };
|
|
@@ -59,13 +79,42 @@ export async function ensureBridge(opts = {}) {
|
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
// Poll for readiness.
|
|
62
|
-
const deadline = Date.now() + (
|
|
82
|
+
const deadline = Date.now() + (startupMs || 20000);
|
|
63
83
|
while (Date.now() < deadline) {
|
|
64
84
|
await new Promise((r) => setTimeout(r, 300));
|
|
65
85
|
const h = await healthCheck(host, port);
|
|
66
86
|
if (h && h.ok) return { host, port, spawned: true, health: h };
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
return null; // did not come up
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function ensureBridge(opts = {}) {
|
|
92
|
+
const host = opts.host || "127.0.0.1";
|
|
93
|
+
const port = Number(opts.port || process.env.AIB_PORT || 13082);
|
|
94
|
+
const startupMs = opts.startupMs || 20000;
|
|
95
|
+
let python = opts.python || readConfigPython() || process.env.AIB_PYTHON || "python3";
|
|
96
|
+
|
|
97
|
+
let r = await tryStart(python, host, port, startupMs);
|
|
98
|
+
if (r) return r;
|
|
99
|
+
|
|
100
|
+
// Bounded auto-setup: if the bridge failed to start and trimesh is missing in
|
|
101
|
+
// the chosen python, create the deps venv once and retry with that python.
|
|
102
|
+
if (opts.autoSetup !== false) {
|
|
103
|
+
const ok = await hasTrimesh(python);
|
|
104
|
+
if (!ok) {
|
|
105
|
+
try {
|
|
106
|
+
const setup = await import("./setup.js");
|
|
107
|
+
const deps = await setup.ensurePythonDeps({ python, force: false });
|
|
108
|
+
if (deps.ok && deps.pythonPath) {
|
|
109
|
+
python = deps.pythonPath;
|
|
110
|
+
r = await tryStart(python, host, port, startupMs);
|
|
111
|
+
if (r) return r;
|
|
112
|
+
}
|
|
113
|
+
} catch { /* best-effort; fall through to error */ }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new Error(`blender bridge did not come up on http://${host}:${port} (python=${python})`);
|
|
69
118
|
}
|
|
70
119
|
|
|
71
120
|
export function sendCommand({ host, port, command, args = {}, id = 1, timeoutMs = 60000 }) {
|
package/lib/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { call, ensureBridge, stopBridge } from "./bridge-client.js";
|
|
20
|
+
import { setup as runSetup } from "./setup.js";
|
|
20
21
|
import { fileURLToPath } from "node:url";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import fs from "node:fs";
|
|
@@ -108,6 +109,38 @@ export function apply(ctx, config = {}) {
|
|
|
108
109
|
},
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
// Auto environment setup: detect → install deps (venv) → optional Blender → verify.
|
|
113
|
+
ctx.tools.register({
|
|
114
|
+
name: "blender_setup",
|
|
115
|
+
description:
|
|
116
|
+
"Auto-detect, install, configure, and verify the Blender 3D modeling " +
|
|
117
|
+
"environment. Creates an isolated Python venv with numpy/trimesh/manifold3d " +
|
|
118
|
+
"(no Blender needed for headless modeling/export). Set install_blender=true " +
|
|
119
|
+
"to also fetch a portable Blender (linux x64; other platforms get instructions). " +
|
|
120
|
+
"Returns a JSON report with detected paths, actions taken, and verification results.",
|
|
121
|
+
parameters: schema({
|
|
122
|
+
install_blender: { type: "boolean", description: "also download+install Blender (linux x64; default false)" },
|
|
123
|
+
blender_version: { type: "string", description: "Blender version to fetch, e.g. 4.2.0" },
|
|
124
|
+
force: { type: "boolean", description: "recreate the venv even if it exists" },
|
|
125
|
+
}),
|
|
126
|
+
output: {
|
|
127
|
+
schema: { type: "object", additionalProperties: false, properties: { text: { type: "string" } }, required: ["text"] },
|
|
128
|
+
render: (_a, v) => [{ type: "text", text: v.text }],
|
|
129
|
+
},
|
|
130
|
+
async execute(args, _exec) {
|
|
131
|
+
try {
|
|
132
|
+
const report = await runSetup({
|
|
133
|
+
installBlender: args?.install_blender === true,
|
|
134
|
+
blenderVersion: args?.blender_version,
|
|
135
|
+
force: args?.force === true,
|
|
136
|
+
});
|
|
137
|
+
return { text: JSON.stringify(report, null, 2) };
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return { text: JSON.stringify({ ok: false, error: e.message }, null, 2) };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
111
144
|
// Stop the spawned bridge when the harness tears down this composition.
|
|
112
145
|
try {
|
|
113
146
|
ctx.effect(function* () { yield async () => stopBridge(); }, "blender-3d-bridge");
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Environment bootstrap for dsh-plugin-blender-3d — detect, install,
|
|
4
|
+
* configure, verify. Node stdlib only; ESM. No function throws: failures are
|
|
5
|
+
* returned as `{ ok: false, detail }` so a tool caller always gets a report.
|
|
6
|
+
*
|
|
7
|
+
* Two runtimes:
|
|
8
|
+
* - headless: pure-Python `headless_bridge.py` (trimesh + manifold3d). Needs
|
|
9
|
+
* `pip install numpy trimesh manifold3d`. This is the default and needs no
|
|
10
|
+
* Blender. `ensurePythonDeps` creates an isolated venv for it.
|
|
11
|
+
* - bpy add-on: needs a real Blender install. `ensureBlender` can fetch a
|
|
12
|
+
* portable Blender (linux tar.xz) on request; mac/win return instructions.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import https from "node:https";
|
|
20
|
+
import { createWriteStream } from "node:fs";
|
|
21
|
+
|
|
22
|
+
const DATA_DIR = process.env.DSH_HOME
|
|
23
|
+
? path.join(process.env.DSH_HOME, ".blender-plugin")
|
|
24
|
+
: path.join(os.homedir(), ".blender-plugin");
|
|
25
|
+
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
|
|
26
|
+
|
|
27
|
+
export function getConfigPath() { return CONFIG_PATH; }
|
|
28
|
+
|
|
29
|
+
/** Read persisted config (pythonPath/blenderPath/...). Never throws. */
|
|
30
|
+
export function readConfig() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkdirp(p) { try { fs.mkdirSync(p, { recursive: true }); } catch { /* ignore */ } }
|
|
38
|
+
|
|
39
|
+
/** Run a command, return {ok, stdout, stderr, code}. Never throws. */
|
|
40
|
+
function run(cmd, args = [], { timeout = 10000 } = {}) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
execFile(cmd, args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
43
|
+
if (err) resolve({ ok: false, stdout: stdout || "", stderr: (stderr || "") + (err.message || ""), code: err.code ?? null });
|
|
44
|
+
else resolve({ ok: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function venvBin(venvPath) {
|
|
50
|
+
return process.platform === "win32"
|
|
51
|
+
? { python: path.join(venvPath, "Scripts", "python.exe"), pip: path.join(venvPath, "Scripts", "pip.exe") }
|
|
52
|
+
: { python: path.join(venvPath, "bin", "python"), pip: path.join(venvPath, "bin", "pip") };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// detect
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function findPython() {
|
|
60
|
+
for (const c of ["python3", "python"]) {
|
|
61
|
+
const v = await run(c, ["--version"]);
|
|
62
|
+
if (v.ok) {
|
|
63
|
+
const version = (v.stdout || v.stderr).trim().split(/\s+/)[1] || null;
|
|
64
|
+
return { path: c, version, ok: true };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkDeps(python) {
|
|
71
|
+
const out = { numpy: false, trimesh: false, manifold3d: false };
|
|
72
|
+
if (!python) return out;
|
|
73
|
+
for (const mod of ["numpy", "trimesh", "manifold3d"]) {
|
|
74
|
+
const r = await run(python, ["-c", `import ${mod}`], { timeout: 8000 });
|
|
75
|
+
out[mod] = r.ok;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function findBlender() {
|
|
81
|
+
const cands = [];
|
|
82
|
+
if (process.platform === "win32") {
|
|
83
|
+
cands.push("blender.exe");
|
|
84
|
+
for (const d of ["C:\\Program Files\\Blender Foundation", "C:\\Program Files (x86)\\Blender Foundation"]) {
|
|
85
|
+
try {
|
|
86
|
+
for (const sub of fs.readdirSync(d)) cands.push(path.join(d, sub, "blender.exe"));
|
|
87
|
+
} catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
} else if (process.platform === "darwin") {
|
|
90
|
+
cands.push("/Applications/Blender.app/Contents/MacOS/blender", "blender");
|
|
91
|
+
} else {
|
|
92
|
+
cands.push("blender", "/usr/bin/blender", "/usr/local/bin/blender", "/opt/blender/blender",
|
|
93
|
+
path.join(os.homedir(), ".local", "share", "blender", "blender"));
|
|
94
|
+
}
|
|
95
|
+
for (const c of cands) {
|
|
96
|
+
const v = await run(c, ["--version"], { timeout: 8000 });
|
|
97
|
+
if (v.ok) {
|
|
98
|
+
const version = (v.stdout || v.stderr).trim().split(/\s+/)[1] || null;
|
|
99
|
+
return { path: c, version, ok: true };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Detect the environment. Never throws. */
|
|
106
|
+
export async function detect() {
|
|
107
|
+
const python = await findPython();
|
|
108
|
+
const deps = await checkDeps(python ? python.path : null);
|
|
109
|
+
const blender = await findBlender();
|
|
110
|
+
return {
|
|
111
|
+
platform: process.platform,
|
|
112
|
+
arch: process.arch,
|
|
113
|
+
python,
|
|
114
|
+
deps,
|
|
115
|
+
blender,
|
|
116
|
+
inBlender: false, // node context; the bpy add-on sets this inside Blender
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// install
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Create/refresh a venv with numpy/trimesh/manifold3d. Returns venv python path. */
|
|
125
|
+
export async function ensurePythonDeps({ python, force = false } = {}) {
|
|
126
|
+
mkdirp(DATA_DIR);
|
|
127
|
+
const basePython = python || (await findPython())?.path || "python3";
|
|
128
|
+
const venvPath = path.join(DATA_DIR, "venv");
|
|
129
|
+
const bin = venvBin(venvPath);
|
|
130
|
+
const actions = [];
|
|
131
|
+
|
|
132
|
+
const needCreate = force || !fs.existsSync(bin.python);
|
|
133
|
+
if (needCreate) {
|
|
134
|
+
let r = await run(basePython, ["-m", "venv", venvPath], { timeout: 30000 });
|
|
135
|
+
if (!r.ok) r = await run(basePython, ["-m", "venv", "--system-site-packages", venvPath], { timeout: 30000 });
|
|
136
|
+
actions.push(`venv create: ${r.ok ? "ok" : "failed"}`);
|
|
137
|
+
if (!r.ok) return { ok: false, venvPath, pythonPath: bin.python, detail: r.stderr.slice(0, 500), actions };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const deps = ["numpy>=1.26", "trimesh>=4.0", "manifold3d>=2.2"];
|
|
141
|
+
const inst = await run(bin.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", ...deps], { timeout: 180000 });
|
|
142
|
+
actions.push(`pip install: ${inst.ok ? "ok" : "failed"}`);
|
|
143
|
+
if (!inst.ok) return { ok: false, venvPath, pythonPath: bin.python, detail: inst.stderr.slice(0, 500), actions };
|
|
144
|
+
|
|
145
|
+
return { ok: true, venvPath, pythonPath: bin.python, actions };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function download(url, dest) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const file = createWriteStream(dest);
|
|
151
|
+
const req = https.get(url, (res) => {
|
|
152
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
153
|
+
file.close(); try { fs.unlinkSync(dest); } catch { /* ignore */ }
|
|
154
|
+
return download(res.headers.location, dest).then(resolve);
|
|
155
|
+
}
|
|
156
|
+
if (res.statusCode !== 200) { file.close(); return resolve({ ok: false, detail: `HTTP ${res.statusCode}` }); }
|
|
157
|
+
res.pipe(file);
|
|
158
|
+
file.on("finish", () => file.close(() => resolve({ ok: true })));
|
|
159
|
+
});
|
|
160
|
+
req.on("error", (e) => { file.close(); resolve({ ok: false, detail: e.message }); });
|
|
161
|
+
req.setTimeout(300000, () => req.destroy(new Error("download timeout")));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Fetch + extract a portable Blender (linux x64 tar.xz). mac/win: instructions only. */
|
|
166
|
+
export async function ensureBlender({ version = "4.2.0", dir } = {}) {
|
|
167
|
+
mkdirp(DATA_DIR);
|
|
168
|
+
const destDir = dir || path.join(DATA_DIR, "blender");
|
|
169
|
+
const major = version.split(".").slice(0, 2).join(".");
|
|
170
|
+
const base = `https://download.blender.org/release/Blender${major}/`;
|
|
171
|
+
|
|
172
|
+
if (process.platform === "linux" && process.arch === "x64") {
|
|
173
|
+
const url = `${base}blender-${version}-linux-x64.tar.xz`;
|
|
174
|
+
mkdirp(destDir);
|
|
175
|
+
const archive = path.join(DATA_DIR, `blender-${version}.tar.xz`);
|
|
176
|
+
const dl = await download(url, archive);
|
|
177
|
+
if (!dl.ok) return { ok: false, detail: `download failed: ${dl.detail}`, url };
|
|
178
|
+
const ex = await run("tar", ["-xJf", archive, "-C", destDir], { timeout: 120000 });
|
|
179
|
+
if (!ex.ok) return { ok: false, detail: `extract failed: ${ex.stderr.slice(0, 300)}`, url };
|
|
180
|
+
// find the blender binary
|
|
181
|
+
let blenderPath = null;
|
|
182
|
+
const walk = (d, depth = 0) => {
|
|
183
|
+
if (depth > 3 || blenderPath) return;
|
|
184
|
+
try {
|
|
185
|
+
for (const e of fs.readdirSync(d)) {
|
|
186
|
+
const p = path.join(d, e);
|
|
187
|
+
if (e === "blender" && fs.statSync(p).isFile() && fs.statSync(p).mode & 0o111) { blenderPath = p; return; }
|
|
188
|
+
try { if (fs.statSync(p).isDirectory()) walk(p, depth + 1); } catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
} catch { /* ignore */ }
|
|
191
|
+
};
|
|
192
|
+
walk(destDir);
|
|
193
|
+
return { ok: !!blenderPath, blenderPath, dir: destDir, url };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// mac / win / other: don't half-extract; give the user the URL + instructions.
|
|
197
|
+
const url = process.platform === "darwin"
|
|
198
|
+
? `${base}blender-${version}-macos-arm64.dmg`
|
|
199
|
+
: process.platform === "win32"
|
|
200
|
+
? `${base}blender-${version}-windows-x64.zip`
|
|
201
|
+
: `${base}blender-${version}-linux-x64.tar.xz`;
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
detail: `auto-extract unsupported on ${process.platform}/${process.arch}; download and install manually, then run blender_setup again to detect it.`,
|
|
205
|
+
url,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// verify
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/** Smoke-test the runtimes. Never throws. */
|
|
214
|
+
export async function verify({ pythonPath, blenderPath } = {}) {
|
|
215
|
+
const out = { trimesh: null, blender: null };
|
|
216
|
+
|
|
217
|
+
if (pythonPath) {
|
|
218
|
+
const r = await run(pythonPath, ["-c",
|
|
219
|
+
"import trimesh,numpy,manifold3d; m=trimesh.creation.box(extents=[1,2,3]); assert len(m.faces)==12; print('trimesh_ok', len(m.vertices))"],
|
|
220
|
+
{ timeout: 20000 });
|
|
221
|
+
out.trimesh = { ok: r.ok, detail: (r.ok ? r.stdout.trim() : r.stderr.slice(0, 300)) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (blenderPath) {
|
|
225
|
+
const r = await run(blenderPath, ["--background", "--python-expr",
|
|
226
|
+
"import bpy; print('blender_ok', bpy.app.version_string)"], { timeout: 30000 });
|
|
227
|
+
out.blender = { ok: r.ok, detail: (r.ok ? r.stdout.trim().split("\n").pop() : r.stderr.slice(0, 300)) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// orchestrate
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/** Full bootstrap: detect → install deps → (optional) blender → verify → persist config. */
|
|
238
|
+
export async function setup({ installBlender = false, blenderVersion, force = false } = {}) {
|
|
239
|
+
const actions = [];
|
|
240
|
+
const detected = await detect();
|
|
241
|
+
actions.push(`detected python=${detected.python ? detected.python.path : "none"}, blender=${detected.blender ? detected.blender.path : "none"}`);
|
|
242
|
+
|
|
243
|
+
if (!detected.python) {
|
|
244
|
+
return { ok: false, stage: "detect", detected, actions, config: null, verify: null,
|
|
245
|
+
detail: "no python3/python found; install Python 3.10+ first." };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 1) Python deps in an isolated venv.
|
|
249
|
+
const deps = await ensurePythonDeps({ python: detected.python.path, force });
|
|
250
|
+
actions.push(...(deps.actions || []));
|
|
251
|
+
let pythonPath = deps.ok ? deps.pythonPath : detected.python.path;
|
|
252
|
+
if (!deps.ok) actions.push(`deps install failed: ${deps.detail}`);
|
|
253
|
+
|
|
254
|
+
// 2) Optional Blender.
|
|
255
|
+
let blenderPath = detected.blender ? detected.blender.path : null;
|
|
256
|
+
if (installBlender && !blenderPath) {
|
|
257
|
+
const b = await ensureBlender({ version: blenderVersion || "4.2.0" });
|
|
258
|
+
actions.push(`blender install: ${b.ok ? "ok" : "skipped — " + (b.detail || "")}`);
|
|
259
|
+
if (b.ok && b.blenderPath) blenderPath = b.blenderPath;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 3) Verify.
|
|
263
|
+
const verifyResult = await verify({ pythonPath, blenderPath });
|
|
264
|
+
|
|
265
|
+
// 4) Persist config.
|
|
266
|
+
const config = {
|
|
267
|
+
pythonPath,
|
|
268
|
+
blenderPath: blenderPath || undefined,
|
|
269
|
+
venvPath: deps.ok ? deps.venvPath : undefined,
|
|
270
|
+
updatedAt: new Date().toISOString(),
|
|
271
|
+
versions: {
|
|
272
|
+
python: detected.python?.version,
|
|
273
|
+
blender: detected.blender?.version,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
try { mkdirp(DATA_DIR); fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); } catch { /* best-effort */ }
|
|
277
|
+
|
|
278
|
+
const ok = !!(verifyResult.trimesh && verifyResult.trimesh.ok);
|
|
279
|
+
return { ok, stage: "done", detected, actions, config, verify: verifyResult };
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-blender-3d",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: drive Blender 3D modeling (primitives, boolean CSG, transform, material, modifier, scene, export STL/OBJ/PLY/GLTF) via a JSON bridge. Bundles a headless trimesh+manifold3d runtime and a Blender bpy add-on. · DSH 插件:通过 JSON 桥接驱动 Blender 3D 建模。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"lib/index.js",
|
|
18
18
|
"lib/bridge-client.js",
|
|
19
|
+
"lib/setup.js",
|
|
19
20
|
"lib/runtime/**/*",
|
|
20
21
|
"skill/**/*",
|
|
21
22
|
"cordis.patch.yml",
|
|
@@ -116,3 +116,33 @@ sleep 1
|
|
|
116
116
|
node /workspace/blender_harness/blender_driver.js --url http://127.0.0.1:13082 demo
|
|
117
117
|
# expect: DEMO OK + /workspace/demo_cli.stl written
|
|
118
118
|
```
|
|
119
|
+
|
|
120
|
+
## Auto environment setup (`blender_setup`)
|
|
121
|
+
|
|
122
|
+
On a fresh machine, call the **`blender_setup`** tool first — it auto-detects,
|
|
123
|
+
installs, configures, and verifies the environment with no manual steps:
|
|
124
|
+
|
|
125
|
+
- **Detect**: finds `python3`/`python`, probes `numpy`/`trimesh`/`manifold3d`,
|
|
126
|
+
and looks for an installed Blender (common paths + `blender --version`).
|
|
127
|
+
- **Install**: creates an isolated venv at `$DSH_HOME/.blender-plugin/venv` (or
|
|
128
|
+
`~/.blender-plugin/venv`) and `pip install`s `numpy>=1.26 trimesh>=4.0
|
|
129
|
+
manifold3d>=2.2`. No Blender needed for headless modeling/export.
|
|
130
|
+
- **Configure** (optional): pass `install_blender: true` to also fetch a
|
|
131
|
+
portable Blender (linux x64 `tar.xz`; mac/win get a download URL + manual
|
|
132
|
+
instructions instead of half-extracting).
|
|
133
|
+
- **Verify**: smoke-tests trimesh (builds a box, asserts 12 faces) and, if
|
|
134
|
+
Blender was installed, `blender --background` import `bpy`.
|
|
135
|
+
- **Persist**: writes `~/.blender-plugin/config.json` (`pythonPath`,
|
|
136
|
+
`blenderPath`, versions) so the bridge auto-uses the venv python on later
|
|
137
|
+
calls. If a `blender_model` call finds trimesh missing, the bridge also
|
|
138
|
+
self-heals once (creates the venv, retries).
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
// blender_setup({ "install_blender": false }) →
|
|
142
|
+
{ "ok": true, "stage": "done",
|
|
143
|
+
"detected": { "platform": "linux", "python": { "path": "python3", "version": "3.11.6" },
|
|
144
|
+
"deps": { "numpy": true, "trimesh": true, "manifold3d": true },
|
|
145
|
+
"blender": null },
|
|
146
|
+
"config": { "pythonPath": "~/.blender-plugin/venv/bin/python" },
|
|
147
|
+
"verify": { "trimesh": { "ok": true, "detail": "trimesh_ok 8" }, "blender": null } }
|
|
148
|
+
```
|