dsh-plugin-blender-3d 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/cordis.patch.yml CHANGED
File without changes
@@ -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
- export async function ensureBridge(opts = {}) {
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() + (opts.startupMs || 20000);
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
- throw new Error(`blender bridge did not come up on http://${host}:${port}`);
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");
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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.1.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",
@@ -50,10 +51,9 @@
50
51
  },
51
52
  "repository": {
52
53
  "type": "git",
53
- "url": "git+https://gitcode.com/LOOYIABC/blender.git",
54
- "directory": "dsh-plugin-blender-3d"
54
+ "url": "git+https://github.com/walkzzz/dsh-plugin-blender-3d.git"
55
55
  },
56
- "homepage": "https://gitcode.com/LOOYIABC/blender",
56
+ "homepage": "https://github.com/walkzzz/dsh-plugin-blender-3d",
57
57
  "scripts": {
58
58
  "test": "node tests/smoke.mjs",
59
59
  "example": "node examples/carve_block.mjs"
@@ -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
+ ```