fnf-blender-mcp 0.2.0 → 0.2.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/README.md +9 -5
- package/RELEASING.md +3 -3
- package/dist/server.js +40 -6
- package/dist/skills.d.ts +15 -2
- package/dist/skills.js +89 -5
- package/dist/tools.js +8 -4
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +2 -2
- package/package.json +1 -1
- package/scripts/background_runner.py +8 -1
- package/skills/blender-animation/SKILL.md +140 -0
- package/skills/blender-audit-finalize/SKILL.md +139 -0
- package/skills/blender-camera-blocking/SKILL.md +32 -0
- package/skills/blender-camera-blocking/assets/Blockstage-0.3.0.zip +0 -0
- package/skills/blender-camera-blocking/references/addon-installation.md +17 -0
- package/skills/blender-camera-blocking/references/character-staging.md +19 -0
- package/skills/blender-camera-blocking/references/layout-and-assets.md +19 -0
- package/skills/blender-camera-blocking/references/live-construction.md +7 -0
- package/skills/blender-camera-led-assembly/SKILL.md +48 -0
- package/skills/blender-camera-led-assembly/references/blender-mechanics.md +45 -0
- package/skills/blender-camera-led-assembly/references/camera-choreography.md +25 -0
- package/skills/blender-camera-led-assembly/references/case-template.md +23 -0
- package/skills/blender-camera-led-assembly/references/environment-assembly.md +27 -0
- package/skills/blender-camera-led-assembly/references/product-assembly.md +25 -0
- package/skills/blender-camera-led-assembly/references/validation-delivery.md +33 -0
- package/skills/blender-destruction/SKILL.md +24 -0
- package/skills/blender-destruction/references/assembly.md +23 -0
- package/skills/blender-destruction/references/delivery.md +29 -0
- package/skills/blender-destruction/references/simulation.md +25 -0
- package/skills/blender-generation/SKILL.md +139 -0
- package/skills/blender-greybox/SKILL.md +103 -0
- package/skills/blender-hdri/SKILL.md +133 -0
- package/skills/blender-lighting-camera/SKILL.md +217 -0
- package/skills/blender-lookdev/SKILL.md +99 -0
- package/skills/blender-modeling/SKILL.md +181 -0
- package/skills/blender-pbr/SKILL.md +155 -0
- package/skills/blender-scene/SKILL.md +66 -1
- package/skills/blender-scene-spec/SKILL.md +177 -0
- package/skills/blender-stylized-materials/SKILL.md +34 -0
- package/skills/blender-stylized-materials/assets/BUNDLE.md +19 -0
- package/skills/blender-stylized-materials/assets/cube-demo.blend +0 -0
- package/skills/blender-stylized-materials/assets/cube-preview.png +0 -0
- package/skills/blender-stylized-materials/assets/materials.blend +0 -0
- package/skills/blender-stylized-materials/assets/previews/BACKGROUND.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/CANVAS.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/CEL.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/COMIC_PULSE.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/DRYBRUSH.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/HALFTONE.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/MANGA.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/METAL.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/NEON.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/PAINT.png +0 -0
- package/skills/blender-stylized-materials/assets/previews/WATERCOLOR.png +0 -0
- package/skills/blender-stylized-materials/assets/stylized-materials-addon.zip +0 -0
- package/skills/blender-stylized-materials/references/material-engineering.md +31 -0
- package/skills/blender-stylized-materials/references/ready-to-use-bundle.md +23 -0
- package/skills/blender-stylized-materials/references/scene-delivery.md +27 -0
- package/skills/blender-stylized-materials/references/visual-library.md +21 -0
- package/skills/blender-volatile/SKILL.md +150 -0
- package/skills/use-blender/SKILL.md +2 -1
- package/skills/use-blender/references/installation.md +5 -5
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@ Each MCP connection owns one process. Its scene persists between commands; it ca
|
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
12
12
|
|
|
13
|
-
Requires Node.js 24+ with npm and Blender 4.2+. Version 0.2.0 introduces the background runtime; version 0.1.0 uses the previous add-on.
|
|
13
|
+
Requires Node.js 24+ with npm and Blender 4.2+. Version 0.2.0 introduces the background runtime; version 0.1.0 uses the previous add-on. Install the latest published version (0.2.0 or newer); rerun the install command to update an existing installation.
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
npm view fnf-blender-mcp@
|
|
16
|
+
npm view fnf-blender-mcp@latest version --registry=https://registry.npmjs.org/
|
|
17
17
|
blenderDir="$HOME/.higgsfield/blender-mcp"
|
|
18
|
-
npm install --prefix "$blenderDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@
|
|
18
|
+
npm install --prefix "$blenderDir" --registry=https://registry.npmjs.org/ fnf-blender-mcp@latest
|
|
19
19
|
blenderCli="$blenderDir/node_modules/fnf-blender-mcp/dist/cli.js"
|
|
20
20
|
node "$blenderCli" doctor --blender "/absolute/path/to/blender"
|
|
21
21
|
node "$blenderCli" config --blender "/absolute/path/to/blender" --format json
|
|
@@ -36,10 +36,12 @@ The server starts Blender on its first execution call. `doctor` checks a separat
|
|
|
36
36
|
| Files | `bl_save_project`, `bl_open_project` |
|
|
37
37
|
| Evidence | `bl_render` |
|
|
38
38
|
| Python/status | `bl_execute`, `bl_job_status` |
|
|
39
|
-
| Offline guidance | `bl_get_skill` |
|
|
39
|
+
| Offline guidance | `bl_list_skills`, `bl_get_skill`, `bl_get_skill_asset` |
|
|
40
40
|
|
|
41
41
|
Start with `bl_get_skill(name: "blender-scene")`. Python runs sequentially on the process's main thread. Scene datablocks persist, while script-local variables do not. Assign a JSON-compatible `result` to return data. Python stdout/stderr are capped at 64 Ki characters each; JSON results are limited to 4 MiB. Native Blender diagnostics do not enter the MCP stdout protocol.
|
|
42
42
|
|
|
43
|
+
The npm archive contains the Blender craft modules. `bl_list_skills` lists the modules in the installed package and their references/assets; `bl_get_skill` reads an entry or reference, and `bl_get_skill_asset` returns the local path of a bundled file. The original five `bl_get_skill` names remain valid. An MCP client can read this guidance directly without installing the modules as client-side skills; client-side installation is optional for hosts that support automatic skill activation. Existing installations do not gain new modules or tools until the npm package is updated and the MCP connection refreshed. Skill guidance does not execute Blender operations or prove a live connection.
|
|
44
|
+
|
|
43
45
|
Render a camera frame to PNG; files up to 4 MiB receive an inline preview. Cycles supports sample overrides. Save/render require `overwrite: true` for existing output files; opening a different project refuses unsaved changes unless explicitly discarded. Arbitrary Python is not sandboxed and can bypass these typed-tool guards. Automatic execution of Python embedded in opened `.blend` files is disabled.
|
|
44
46
|
|
|
45
47
|
## Session lifecycle
|
|
@@ -61,8 +63,10 @@ npm run test:package
|
|
|
61
63
|
BLENDER_EXECUTABLE=/absolute/path/to/blender npm run test:live
|
|
62
64
|
```
|
|
63
65
|
|
|
64
|
-
Offline tests exercise actual MCP and child-process pipes using a Python CLI fixture, persistent session state, isolation, timeout recovery, process death, bounded results/history, output formatting, CLI configuration and typed-script guards. They do not replace live Blender validation. `test:package` installs the archive independently and verifies
|
|
66
|
+
Offline tests exercise actual MCP and child-process pipes using a Python CLI fixture, persistent session state, isolation, timeout recovery, process death, bounded results/history, output formatting, CLI configuration and typed-script guards. They do not replace live Blender validation. `test:package` installs the archive independently and verifies 21 tools, offline guidance, asset lookup and the absence of runtime add-on files. `test:live` uses real Blender for geometry, materials, keyframes, file guards and a small Cycles render in disposable files.
|
|
65
67
|
|
|
66
68
|
Verified on macOS arm64 with Blender 4.2.23 LTS: persistent scene, mesh/material/camera/light edits, keyframes, save/reopen guards and a Cycles PNG preview. Windows/Linux native Blender execution has not been verified locally.
|
|
67
69
|
|
|
68
70
|
See [RELEASING.md](RELEASING.md) for release steps. The setup skill is mirrored in fnf-mcp-server's `/use-blender` command; keep its body and references synchronized. See [UPSTREAM.md](UPSTREAM.md) for provenance and [LICENSE](LICENSE) for the MIT license.
|
|
71
|
+
|
|
72
|
+
Background Blender does not reliably mark direct Python edits as dirty. The MCP also tracks attempted mutations, including failed commands and arbitrary `bl_execute` calls, conservatively as unsaved until `bl_save_project` or an explicitly permitted `bl_open_project` succeeds. Read tools preserve this state.
|
package/RELEASING.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Run commands from this `blender/` directory. Its package and lockfile are independent of After Effects.
|
|
4
4
|
|
|
5
5
|
1. Run `npm ci`, `npm test`, `npm run typecheck`, `npm run test:package`, and `test:live` with a real `BLENDER_EXECUTABLE` when available.
|
|
6
|
-
2. Version 0.2.
|
|
7
|
-
3. Keep
|
|
8
|
-
4.
|
|
6
|
+
2. Version 0.2.1 bundles the Blender craft skills, including the additions from PR #25. Verify that it is unpublished with `npm view fnf-blender-mcp versions --json`. Never overwrite an existing npm version; bump both manifests for subsequent releases.
|
|
7
|
+
3. Keep README.md, skills/use-blender and the fnf-mcp-server command bundle on `fnf-blender-mcp@latest`; preserve historical migration versions. Review migration instructions for removed commands and tools.
|
|
8
|
+
4. Merge the source changes and version metadata into `main`, check out its exact commit in a clean worktree, rerun the checks, then run `npm pack --json`, review the contents, then publish the reviewed archive with `npm publish ./fnf-blender-mcp-0.2.1.tgz --access public --registry=https://registry.npmjs.org/` when publication is authorized.
|
|
9
9
|
5. Verify the public version and checksum and test a fresh registry installation. Publish the package before deploying the fnf-mcp-server instructions that require it.
|
|
10
10
|
|
|
11
11
|
Merging the source MR does not publish npm. Version 0.1.0 still requires the old add-on; it is not a fallback for 0.2.0 setup. New clients need a refreshed configuration containing BLENDER_EXECUTABLE. Existing desktop files and add-ons are not removed by this release.
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { BL_TOOLS } from "./tools.js";
|
|
5
5
|
import { BlenderTransport } from "./transport.js";
|
|
6
|
-
import { getSkill,
|
|
6
|
+
import { getSkill, getSkillAsset, listSkills } from "./skills.js";
|
|
7
7
|
const packageInfo = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
8
|
const errorResult = (error) => ({ isError: true, content: [{ type: "text", text: String(error) }] });
|
|
9
9
|
export async function formatResult(job) {
|
|
@@ -32,7 +32,7 @@ export async function formatResult(job) {
|
|
|
32
32
|
}
|
|
33
33
|
export function createServer(transport = new BlenderTransport()) {
|
|
34
34
|
const server = new McpServer({ name: "higgsfield-use-blender", title: "Higgsfield use Blender", version: packageInfo.version }, {
|
|
35
|
-
instructions: "Control a dedicated background Blender process; it cannot access an open desktop window. Save .blend files before disconnecting; unsaved state is lost on reconnect. Read bl_get_skill(blender-scene), then bl_health and bl_get_scene_summary before edits. Prefer typed tools; use bl_execute for other bpy operations. Commands can partially mutate before errors. On timeout, query bl_job_status; never blindly retry. Render and view results. Cloud generation is provided by a separate Higgsfield MCP, not this server.",
|
|
35
|
+
instructions: "Control a dedicated background Blender process; it cannot access an open desktop window. Save .blend files before disconnecting; unsaved state is lost on reconnect. Read bl_get_skill(blender-scene), then bl_health and bl_get_scene_summary before edits. For specialized work, call bl_list_skills, load the relevant skill with bl_get_skill and only needed references; bl_get_skill_asset locates bundled assets. Prefer typed tools; use bl_execute for other bpy operations. Commands can partially mutate before errors. On timeout, query bl_job_status; never blindly retry. Render and view results. Cloud generation is provided by a separate Higgsfield MCP, not this server.",
|
|
36
36
|
});
|
|
37
37
|
for (const tool of BL_TOOLS) {
|
|
38
38
|
server.registerTool(tool.name, {
|
|
@@ -40,7 +40,8 @@ export function createServer(transport = new BlenderTransport()) {
|
|
|
40
40
|
annotations: { readOnlyHint: tool.readOnly, destructiveHint: !tool.readOnly, idempotentHint: tool.readOnly, openWorldHint: tool.name === "bl_execute" },
|
|
41
41
|
}, async (args) => {
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
const mutates = !tool.readOnly && !["bl_save_project", "bl_open_project"].includes(tool.name);
|
|
44
|
+
return await formatResult(await transport.execute(tool.code(args), tool.name === "bl_render" ? 300 : 120, mutates));
|
|
44
45
|
}
|
|
45
46
|
catch (error) {
|
|
46
47
|
return errorResult(error);
|
|
@@ -59,10 +60,43 @@ export function createServer(transport = new BlenderTransport()) {
|
|
|
59
60
|
return errorResult(error);
|
|
60
61
|
}
|
|
61
62
|
});
|
|
63
|
+
server.registerTool("bl_list_skills", {
|
|
64
|
+
title: "List Blender Skills", description: "List bundled Blender skill names, descriptions, references and assets. Does not require Blender to be running.",
|
|
65
|
+
inputSchema: z.object({}).strict(),
|
|
66
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
67
|
+
}, async () => {
|
|
68
|
+
try {
|
|
69
|
+
const index = await listSkills();
|
|
70
|
+
return { content: [{ type: "text", text: JSON.stringify(index) }], structuredContent: index };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return errorResult(error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
62
76
|
server.registerTool("bl_get_skill", {
|
|
63
|
-
title: "Read Blender Skill", description: "Load
|
|
64
|
-
inputSchema: z.object({ name: z.
|
|
77
|
+
title: "Read Blender Skill", description: "Load a bundled Blender skill or one of its listed references. Start with blender-scene. Legacy topic names remain supported. Does not require Blender to be running.",
|
|
78
|
+
inputSchema: z.object({ name: z.string().default("blender-scene"), document: z.string().default("SKILL.md") }).strict(),
|
|
79
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
80
|
+
}, async ({ name, document }) => {
|
|
81
|
+
try {
|
|
82
|
+
return { content: [{ type: "text", text: await getSkill(name, document) }] };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return errorResult(error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
server.registerTool("bl_get_skill_asset", {
|
|
89
|
+
title: "Locate Blender Skill Asset", description: "Return the absolute path and size of an asset bundled with a listed Blender skill. The path is local to the MCP host.",
|
|
90
|
+
inputSchema: z.object({ name: z.string(), path: z.string() }).strict(),
|
|
65
91
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
66
|
-
}, async ({ name }) =>
|
|
92
|
+
}, async ({ name, path }) => {
|
|
93
|
+
try {
|
|
94
|
+
const asset = await getSkillAsset(name, path);
|
|
95
|
+
return { content: [{ type: "text", text: JSON.stringify(asset) }], structuredContent: asset };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return errorResult(error);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
67
101
|
return server;
|
|
68
102
|
}
|
package/dist/skills.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
export declare
|
|
2
|
-
|
|
1
|
+
export declare function listSkills(): Promise<{
|
|
2
|
+
skills: {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
references: string[];
|
|
6
|
+
assets: string[];
|
|
7
|
+
}[];
|
|
8
|
+
}>;
|
|
9
|
+
export declare function getSkill(name: string, document?: string): Promise<string>;
|
|
10
|
+
export declare function getSkillAsset(name: string, path: string): Promise<{
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
absolute_path: string;
|
|
14
|
+
bytes: number;
|
|
15
|
+
}>;
|
package/dist/skills.js
CHANGED
|
@@ -1,6 +1,90 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const root = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
5
|
+
const legacyReferences = new Set(["modeling", "materials", "lighting-camera", "animation"]);
|
|
6
|
+
const skillName = /^blender-[a-z0-9-]+$/;
|
|
7
|
+
const documentPath = /^references\/[a-zA-Z0-9/_-]+\.md$/;
|
|
8
|
+
const assetPath = /^assets\/[a-zA-Z0-9][a-zA-Z0-9/._-]*$/;
|
|
9
|
+
function validParts(path) {
|
|
10
|
+
return !path.split("/").some(part => part === "" || part === "." || part === "..");
|
|
11
|
+
}
|
|
12
|
+
async function bundledFile(name, path) {
|
|
13
|
+
const corpus = await realpath(root);
|
|
14
|
+
const base = await realpath(resolve(root, name));
|
|
15
|
+
const skillLocal = relative(corpus, base);
|
|
16
|
+
if (skillLocal === ".." || skillLocal.startsWith("../") || skillLocal.startsWith("..\\") || isAbsolute(skillLocal)) {
|
|
17
|
+
throw new Error("Skill path escapes the bundled corpus");
|
|
18
|
+
}
|
|
19
|
+
const file = await realpath(resolve(base, path));
|
|
20
|
+
const local = relative(base, file);
|
|
21
|
+
if (local === ".." || local.startsWith("../") || local.startsWith("..\\") || isAbsolute(local)) {
|
|
22
|
+
throw new Error("Skill path escapes the bundled corpus");
|
|
23
|
+
}
|
|
24
|
+
if (!(await stat(file)).isFile())
|
|
25
|
+
throw new Error("Skill path is not a file");
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
async function files(name, folder) {
|
|
29
|
+
try {
|
|
30
|
+
const candidates = (await readdir(resolve(root, name, folder), { recursive: true }))
|
|
31
|
+
.map(path => `${folder}/${path}`)
|
|
32
|
+
.filter(path => validParts(path) && (folder === "assets" ? assetPath.test(path) : documentPath.test(path)))
|
|
33
|
+
.sort();
|
|
34
|
+
const checked = await Promise.all(candidates.map(async (path) => {
|
|
35
|
+
if (!(await stat(resolve(root, name, path))).isFile())
|
|
36
|
+
return null;
|
|
37
|
+
await bundledFile(name, path);
|
|
38
|
+
return path;
|
|
39
|
+
}));
|
|
40
|
+
return checked.filter((path) => path !== null);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === "ENOENT")
|
|
44
|
+
return [];
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function requireSkill(name) {
|
|
49
|
+
if (!skillName.test(name))
|
|
50
|
+
throw new RangeError(`Unknown Blender skill '${name}'`);
|
|
51
|
+
try {
|
|
52
|
+
await bundledFile(name, "SKILL.md");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new RangeError(`Unknown Blender skill '${name}'. Call bl_list_skills for available names.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function listSkills() {
|
|
59
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
60
|
+
const names = entries.filter(entry => entry.isDirectory() && skillName.test(entry.name)).map(entry => entry.name).sort();
|
|
61
|
+
return { skills: await Promise.all(names.map(async (name) => {
|
|
62
|
+
const entry = await readFile(await bundledFile(name, "SKILL.md"), "utf8");
|
|
63
|
+
return {
|
|
64
|
+
name,
|
|
65
|
+
description: /^description:\s*(.+)$/m.exec(entry)?.[1] ?? "",
|
|
66
|
+
references: await files(name, "references"),
|
|
67
|
+
assets: await files(name, "assets"),
|
|
68
|
+
};
|
|
69
|
+
})) };
|
|
70
|
+
}
|
|
71
|
+
export async function getSkill(name, document = "SKILL.md") {
|
|
72
|
+
if (legacyReferences.has(name)) {
|
|
73
|
+
if (document !== "SKILL.md")
|
|
74
|
+
throw new RangeError("Legacy topic aliases do not have references");
|
|
75
|
+
return readFile(await bundledFile("blender-scene", `references/${name}.md`), "utf8");
|
|
76
|
+
}
|
|
77
|
+
await requireSkill(name);
|
|
78
|
+
if (document !== "SKILL.md" && (!validParts(document) || !documentPath.test(document) || !(await files(name, "references")).includes(document))) {
|
|
79
|
+
throw new RangeError(`Unknown reference '${document}' for '${name}'`);
|
|
80
|
+
}
|
|
81
|
+
return readFile(await bundledFile(name, document), "utf8");
|
|
82
|
+
}
|
|
83
|
+
export async function getSkillAsset(name, path) {
|
|
84
|
+
await requireSkill(name);
|
|
85
|
+
if (!validParts(path) || !assetPath.test(path) || !(await files(name, "assets")).includes(path)) {
|
|
86
|
+
throw new RangeError(`Unknown asset '${path}' for '${name}'`);
|
|
87
|
+
}
|
|
88
|
+
const absolute_path = await bundledFile(name, path);
|
|
89
|
+
return { name, path, absolute_path, bytes: (await stat(absolute_path)).size };
|
|
6
90
|
}
|
package/dist/tools.js
CHANGED
|
@@ -189,7 +189,7 @@ result = _info`),
|
|
|
189
189
|
];
|
|
190
190
|
BL_TOOLS.push(scriptTool("bl_health", "Inspect Blender", "Start or inspect this MCP session’s background Blender. Returns its version, PID, active file and scene; does not inspect an open desktop window.", {}, `import os
|
|
191
191
|
result = {"version": bpy.app.version_string, "pid": os.getpid(), "background": bpy.app.background,
|
|
192
|
-
"file": bpy.data.filepath, "dirty": bpy.data.is_dirty, "scene": bpy.context.scene.name}`), scriptTool("bl_save_project", "Save Blender Project", "Save the current scene to an absolute .blend path. Existing files require overwrite=true.", {
|
|
192
|
+
"file": bpy.data.filepath, "dirty": _mcp_session["dirty"] or bpy.data.is_dirty, "scene": bpy.context.scene.name}`), scriptTool("bl_save_project", "Save Blender Project", "Save the current scene to an absolute .blend path. Existing files require overwrite=true.", {
|
|
193
193
|
path: z.string().min(1), overwrite: z.boolean().default(false),
|
|
194
194
|
}, `import os
|
|
195
195
|
_path = _args["path"]
|
|
@@ -197,16 +197,20 @@ if not os.path.isabs(_path) or not _path.lower().endswith(".blend"):
|
|
|
197
197
|
raise ValueError("Provide an absolute .blend path")
|
|
198
198
|
if os.path.exists(_path) and not _args["overwrite"]:
|
|
199
199
|
raise ValueError("File exists; use a new path or explicitly set overwrite=true")
|
|
200
|
-
bpy.ops.wm.save_as_mainfile(filepath=_path, check_existing=False)
|
|
200
|
+
if "FINISHED" not in bpy.ops.wm.save_as_mainfile(filepath=_path, check_existing=False):
|
|
201
|
+
raise RuntimeError("Project save did not finish")
|
|
202
|
+
_mcp_session["dirty"] = False
|
|
201
203
|
result = {"path": bpy.data.filepath, "saved": not bpy.data.is_dirty}`), scriptTool("bl_open_project", "Open Blender Project", "Replace the active project with an existing .blend file. Refuses unsaved changes unless discard_unsaved=true.", {
|
|
202
204
|
path: z.string().min(1), discard_unsaved: z.boolean().default(false),
|
|
203
205
|
}, `import os
|
|
204
206
|
_path = _args["path"]
|
|
205
207
|
if not os.path.isabs(_path) or not os.path.isfile(_path) or not _path.lower().endswith(".blend"):
|
|
206
208
|
raise ValueError("Provide an existing absolute .blend path")
|
|
207
|
-
if bpy.data.is_dirty and not _args["discard_unsaved"]:
|
|
209
|
+
if (_mcp_session["dirty"] or bpy.data.is_dirty) and not _args["discard_unsaved"]:
|
|
208
210
|
raise ValueError("The current project has unsaved changes; save first or explicitly set discard_unsaved=true")
|
|
209
|
-
bpy.ops.wm.open_mainfile(filepath=_path, use_scripts=False)
|
|
211
|
+
if "FINISHED" not in bpy.ops.wm.open_mainfile(filepath=_path, use_scripts=False):
|
|
212
|
+
raise RuntimeError("Project open did not finish")
|
|
213
|
+
_mcp_session["dirty"] = False
|
|
210
214
|
result = {"path": bpy.data.filepath, "scene": bpy.context.scene.name}`), scriptTool("bl_insert_keyframe", "Insert Object Keyframe", "Key the current location, Euler rotation or scale of a named object at a frame.", {
|
|
211
215
|
name: z.string().min(1), property: z.enum(["location", "rotation_euler", "scale"]), frame: z.number().int(),
|
|
212
216
|
}, `_obj = bpy.data.objects.get(_args["name"])
|
package/dist/transport.d.ts
CHANGED
|
@@ -25,6 +25,6 @@ export declare class BlenderTransport {
|
|
|
25
25
|
private start;
|
|
26
26
|
private launch;
|
|
27
27
|
status(jobId: string): Promise<JobResult>;
|
|
28
|
-
execute(code: string, timeoutSeconds?: number): Promise<JobResult>;
|
|
28
|
+
execute(code: string, timeoutSeconds?: number, mutates?: boolean): Promise<JobResult>;
|
|
29
29
|
close(): Promise<void>;
|
|
30
30
|
}
|
package/dist/transport.js
CHANGED
|
@@ -152,7 +152,7 @@ export class BlenderTransport {
|
|
|
152
152
|
throw new Error("Unknown job_id for this MCP session; history is limited to 128 jobs and disappears on reconnect.");
|
|
153
153
|
return job;
|
|
154
154
|
}
|
|
155
|
-
async execute(code, timeoutSeconds = 120) {
|
|
155
|
+
async execute(code, timeoutSeconds = 120, mutates = true) {
|
|
156
156
|
if (!code || code.length > 1_000_000)
|
|
157
157
|
throw new Error("Python source must contain 1–1000000 characters.");
|
|
158
158
|
await this.start();
|
|
@@ -166,7 +166,7 @@ export class BlenderTransport {
|
|
|
166
166
|
return new Promise((resolve, reject) => {
|
|
167
167
|
const timer = setTimeout(() => reject(new Error(`Blender is still running; job_id=${id}. Use bl_job_status on this MCP session before retrying. No automatic retry or cancellation was sent.`)), timeoutSeconds * 1000);
|
|
168
168
|
this.active = { id, finish: result => { clearTimeout(timer); resolve(result); } };
|
|
169
|
-
this.child.stdin.write(JSON.stringify({ job_id: id, code }) + "\n", error => { if (error)
|
|
169
|
+
this.child.stdin.write(JSON.stringify({ job_id: id, code, mutates }) + "\n", error => { if (error)
|
|
170
170
|
this.fail(error); });
|
|
171
171
|
});
|
|
172
172
|
}
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import traceback
|
|
|
9
9
|
PREFIX = "HF_BLENDER_JSON:"
|
|
10
10
|
MAX_RESULT = 4 * 1024 * 1024
|
|
11
11
|
MAX_REQUEST = 8 * 1024 * 1024
|
|
12
|
+
SESSION = {"dirty": False}
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class BoundedText(io.TextIOBase):
|
|
@@ -30,10 +31,16 @@ def execute(request):
|
|
|
30
31
|
code = request["code"]
|
|
31
32
|
if not isinstance(code, str) or not 0 < len(code) <= 1_000_000:
|
|
32
33
|
raise ValueError("Invalid Python source length")
|
|
34
|
+
mutates = request.get("mutates", True)
|
|
35
|
+
if not isinstance(mutates, bool):
|
|
36
|
+
raise ValueError("Invalid mutation flag")
|
|
33
37
|
stdout, stderr = BoundedText(), BoundedText()
|
|
34
38
|
response = {"job_id": job_id, "state": "completed"}
|
|
35
39
|
try:
|
|
36
|
-
namespace = {"__name__": "__blender_mcp__"}
|
|
40
|
+
namespace = {"__name__": "__blender_mcp__", "_mcp_session": SESSION}
|
|
41
|
+
# Background RNA writes may leave bpy.data.is_dirty false, even after partial failure.
|
|
42
|
+
if mutates:
|
|
43
|
+
SESSION["dirty"] = True
|
|
37
44
|
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
38
45
|
exec(compile(code, "<blender-mcp>", "exec"), namespace)
|
|
39
46
|
result = namespace.get("result")
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blender-animation
|
|
3
|
+
description: Dynamic-by-default motion — the motion plan, semantic rigs, deliberate interpolation, constraints and drivers, surface contact, rotational aliasing, visibility keying, dynamic defaults per subject type, and the motion audit. For motion produced by a rigid-body solver rather than keyframes, use blender-destruction.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Animation
|
|
7
|
+
|
|
8
|
+
Read [blender-scene](../blender-scene/SKILL.md) for the local session contract.
|
|
9
|
+
Tool names below use its capability mapping when the named interface is absent;
|
|
10
|
+
read only the relevant rows in [blender-volatile](../blender-volatile/SKILL.md).
|
|
11
|
+
|
|
12
|
+
Motion is part of the default deliverable: unless the user explicitly asked
|
|
13
|
+
for a static scene, the Scene Passport defines at least one purposeful beat.
|
|
14
|
+
"Dynamic" means the viewer can watch something change and read it — not that
|
|
15
|
+
everything moves at once.
|
|
16
|
+
|
|
17
|
+
## Motion plan
|
|
18
|
+
|
|
19
|
+
Pin down before keying anything:
|
|
20
|
+
|
|
21
|
+
- fps, frame range, playback duration;
|
|
22
|
+
- rest pose and beat structure: anticipation → action → settle;
|
|
23
|
+
- the carrier of each beat — object, camera, light, material, or environment;
|
|
24
|
+
- the loop boundary, if the motion loops;
|
|
25
|
+
- constraints, parenting, and control objects involved.
|
|
26
|
+
|
|
27
|
+
One clear primary action, restrained secondaries, and at least one stable
|
|
28
|
+
frame suitable for inspection and thumbnails.
|
|
29
|
+
|
|
30
|
+
## Build animation
|
|
31
|
+
|
|
32
|
+
- Bulk transform/lens keys → `bl_keyframe_properties`; turntable, camera
|
|
33
|
+
orbit/dolly, idle → `bl_create_motion_preset`; frame inspection →
|
|
34
|
+
`bl_set_frame`.
|
|
35
|
+
- Drop to `bl_execute` only for what typed tools cannot express: F-curve
|
|
36
|
+
edits, constraints, drivers, armatures, shape keys, simulations, NLA.
|
|
37
|
+
- Key rigs and semantic controls, not a pile of unrelated child transforms.
|
|
38
|
+
Key only properties that need to change.
|
|
39
|
+
- Choose interpolation on purpose: Bezier for organic motion, linear for
|
|
40
|
+
constant rates, constant for stepped switches.
|
|
41
|
+
- Pick quaternion or Euler deliberately so rotations do not flip mid-move;
|
|
42
|
+
connector boundaries speak radians.
|
|
43
|
+
- Object animation and data animation are separate sets. Lens, `clip_start`,
|
|
44
|
+
and `dof.focus_distance` keys belong to `camera.data`. Follow Path
|
|
45
|
+
`offset_factor` belongs to the constraint and is animated on its owning
|
|
46
|
+
object, through `constraints[...].offset_factor`. Retime both object and
|
|
47
|
+
data actions, filtering their correct action slots (`blender-volatile`),
|
|
48
|
+
then check each shot's transform, path, lens, and focus key ranges.
|
|
49
|
+
- Simulations must be deterministic enough that the audited frames reproduce.
|
|
50
|
+
|
|
51
|
+
## Constraints and drivers
|
|
52
|
+
|
|
53
|
+
- **`Copy Location` on a spinning child is forbidden.** Any residual offset
|
|
54
|
+
between target and owner orbits with the spin, and the part reads as about
|
|
55
|
+
to detach. Only rotation can be compensated.
|
|
56
|
+
- Copy the steering component alone, in the control object's space
|
|
57
|
+
(CUSTOM to CUSTOM, single axis, mix AFTER). Copying full world rotation
|
|
58
|
+
drags support noise into the tilt.
|
|
59
|
+
- The local space of a bone-parented child is useless: its own rotation is
|
|
60
|
+
static and the motion lives in the bone. Name the control object explicitly.
|
|
61
|
+
- **Rolling contact is verified arithmetically.** Rotation per frame equals
|
|
62
|
+
distance per frame divided by radius, and the residual stays sub-millimetre.
|
|
63
|
+
The driver's sign follows the direction of the part's local axis; the
|
|
64
|
+
contact point holding still is the proof.
|
|
65
|
+
- Ray-based ground tracking over a faceted support mesh returns noise, not
|
|
66
|
+
travel. Disable it and solve the surface response deliberately.
|
|
67
|
+
|
|
68
|
+
## Surface contact solve
|
|
69
|
+
|
|
70
|
+
For anything that must stay planted on uneven ground while it moves:
|
|
71
|
+
|
|
72
|
+
1. One ray per support point gives the gaps.
|
|
73
|
+
2. The mean gap is the height correction.
|
|
74
|
+
3. Compute the slope angle with `atan2(height_difference, support_distance)`.
|
|
75
|
+
Use the full horizontal distance between the two compared support points,
|
|
76
|
+
not the half-span. For points at `-a` and `+a`, the denominator is `2*a`.
|
|
77
|
+
Resolve the sign and roll/pitch axis in the rig's local frame and check
|
|
78
|
+
the resulting contact gaps; banked or uneven supports may need a fitted
|
|
79
|
+
support plane rather than two independent slopes.
|
|
80
|
+
4. Two passes per frame for convergence.
|
|
81
|
+
5. Ease in and out over roughly 20 frames with a quintic so no correction
|
|
82
|
+
pops on entry.
|
|
83
|
+
|
|
84
|
+
A baked contact holds height but does not pick up lateral slope: it passes on
|
|
85
|
+
straights and drifts on arcs. Level ground under a tilted body means the body
|
|
86
|
+
is wrong, not the ground.
|
|
87
|
+
|
|
88
|
+
## Rotational aliasing
|
|
89
|
+
|
|
90
|
+
An N-fold symmetric spinner strobes when its step per frame lands near a
|
|
91
|
+
multiple of its symmetry sector — the blades nearly fill the previous
|
|
92
|
+
positions and the eye reads a jerk or a reversal. Hold a constant step half a
|
|
93
|
+
sector away from any multiple of the sector, then re-phase after the shot so
|
|
94
|
+
the rotation stays continuous.
|
|
95
|
+
|
|
96
|
+
Blur decides that read more than the keys do. A shutter near 0.2 at one step
|
|
97
|
+
is no smear at all; 0.75 over 12 steps is the working baseline, raised toward
|
|
98
|
+
0.95 by key for the fastest passage. The viewport never renders blur — spin
|
|
99
|
+
and smear are judged from a render only.
|
|
100
|
+
|
|
101
|
+
## Visibility keying
|
|
102
|
+
|
|
103
|
+
- Visibility switches are keyed with constant interpolation.
|
|
104
|
+
- **Constraint targets hide in render only.** `hide_viewport` drops an object
|
|
105
|
+
from the depsgraph, and every constraint reading it gets a stale matrix.
|
|
106
|
+
- Everything else hides in render and viewport together. A render-only hide
|
|
107
|
+
leaves the viewport lying about framing: geometry sitting millimetres in
|
|
108
|
+
front of a lens still blocks the preview.
|
|
109
|
+
- Props belong to the shots that need them, not to every range sharing a
|
|
110
|
+
state.
|
|
111
|
+
- `animation_data_clear()` removes visibility keys along with everything else.
|
|
112
|
+
|
|
113
|
+
## Dynamic defaults
|
|
114
|
+
|
|
115
|
+
When the brief names no motion:
|
|
116
|
+
|
|
117
|
+
- product/prop — slow turntable or camera orbit, settling on the hero angle;
|
|
118
|
+
- environment — subtle camera drift plus a restrained practical-light change;
|
|
119
|
+
- character/creature — idle breathing or weight shift, only if the rig
|
|
120
|
+
supports it;
|
|
121
|
+
- abstract — one focal transformation with a small secondary response.
|
|
122
|
+
|
|
123
|
+
Never invent character acting, destructive simulation, or a camera that never
|
|
124
|
+
stops when subtle motion already satisfies the dynamic requirement.
|
|
125
|
+
|
|
126
|
+
## Motion audit
|
|
127
|
+
|
|
128
|
+
Sample at minimum: first/rest frame, departure, peak of the fastest change,
|
|
129
|
+
settle, final frame plus the loop seam where one exists. Inspect animated
|
|
130
|
+
properties through `bl_get_object` and `bl_execute`, and view camera renders
|
|
131
|
+
at selected frames using `bl_set_frame` and `bl_render`. This replaces the
|
|
132
|
+
unavailable `bl_audit_motion` and `bl_render_contact_sheet` tools. At each
|
|
133
|
+
sample check framing, collisions
|
|
134
|
+
and intersections, visibility, constraint behavior, shading continuity, and
|
|
135
|
+
motion direction. Open F-curves only when the typed audit cannot explain a
|
|
136
|
+
failure — a still render proves nothing about animation.
|
|
137
|
+
|
|
138
|
+
The pass fails if expected properties carry no keys, motion is imperceptible,
|
|
139
|
+
objects teleport, rotations flip, the camera loses the subject, or the loop
|
|
140
|
+
jumps. Fix and sample again.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blender-audit-finalize
|
|
3
|
+
description: Audit a local Blender scene against its specification, measure structural, motion and visual failures, repair them, and save requested deliverables under the local recovery policy.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Audit and finalize
|
|
7
|
+
|
|
8
|
+
Read [blender-scene](../blender-scene/SKILL.md) for the local session contract.
|
|
9
|
+
Tool names below use its capability mapping when the named interface is absent;
|
|
10
|
+
read only the relevant rows in [blender-volatile](../blender-volatile/SKILL.md).
|
|
11
|
+
|
|
12
|
+
The audit compares the scene to the Scene Passport — not to memory, and not to
|
|
13
|
+
the arguments of the calls that built it. A build passes only when structural,
|
|
14
|
+
motion, and visual evidence all agree.
|
|
15
|
+
|
|
16
|
+
## Structural audit
|
|
17
|
+
|
|
18
|
+
Run `bl_validate_scene`, diff against the pre-build snapshot with
|
|
19
|
+
`bl_scene_diff`, and inspect the important objects with `bl_get_object`
|
|
20
|
+
(`bl_execute` only for facts the typed reads do not expose). Confirm:
|
|
21
|
+
|
|
22
|
+
- every expected object exists exactly once, under its semantic name;
|
|
23
|
+
- dimensions, transforms, origins, parents, collections, visibility match;
|
|
24
|
+
- active camera, frame range, fps, render engine, resolution match, and
|
|
25
|
+
`frame_end` reaches the last marker and the last key;
|
|
26
|
+
- material slots and texture links resolve;
|
|
27
|
+
- generated replacements sit inside their proxy footprints;
|
|
28
|
+
- no stray defaults, `.001` duplicates, orphan stand-ins, or hidden failures;
|
|
29
|
+
- related objects found under every prefix they use — one prefix search is
|
|
30
|
+
not a sweep;
|
|
31
|
+
- hero/export meshes have sane normals, manifold state, and density.
|
|
32
|
+
|
|
33
|
+
## Motion audit
|
|
34
|
+
|
|
35
|
+
Mandatory for dynamic-default scenes; skipped only on an explicit static
|
|
36
|
+
request.
|
|
37
|
+
|
|
38
|
+
- Audit motion from fresh scene reads and `bl_execute`; when diagnosing a
|
|
39
|
+
failure, enumerate animated objects/properties, key times, and interpolation.
|
|
40
|
+
This package does not provide `bl_audit_motion`.
|
|
41
|
+
- Sample first/rest, departure, peak, settle, and final/loop frames.
|
|
42
|
+
- Check collisions, clipping, constraint jumps, rotation flips, camera
|
|
43
|
+
framing, visibility, loop continuity.
|
|
44
|
+
- The promised motion must be perceptible and purposeful.
|
|
45
|
+
|
|
46
|
+
A still image cannot pass this audit.
|
|
47
|
+
|
|
48
|
+
## Visual audit
|
|
49
|
+
|
|
50
|
+
1. Viewed camera render for composition; inspect datablocks, modifiers and
|
|
51
|
+
rigs for editability. A desktop viewport check is unavailable locally.
|
|
52
|
+
2. Inspect active-camera material, light, shadow, transparency, and engine
|
|
53
|
+
settings through fresh reads, then view a `bl_render` output. The package
|
|
54
|
+
does not provide `bl_audit_render`.
|
|
55
|
+
3. For dynamic scenes, call `bl_set_frame` and `bl_render` at representative
|
|
56
|
+
frames; view each result. There is no `bl_render_contact_sheet` tool.
|
|
57
|
+
4. View the returned evidence — success JSON says nothing about appearance.
|
|
58
|
+
And view it at scale: image previews downsample, so few-pixel features
|
|
59
|
+
average away to nothing. Crop 1:1 and count bright pixels numerically
|
|
60
|
+
before concluding something did not render.
|
|
61
|
+
5. **Unreachable evidence fallback** — when a render/screenshot URL cannot
|
|
62
|
+
be opened (network policy, expired link), report that retrieval failure
|
|
63
|
+
and obtain equivalent evidence at an accessible local path. Write camera
|
|
64
|
+
renders via `scene.render.filepath` +
|
|
65
|
+
`bpy.ops.render.render(write_still=True)`; obtain a local viewport capture
|
|
66
|
+
if the claim specifically concerns viewport state. Actually view the
|
|
67
|
+
replacement evidence. If it cannot be viewed either, mark the relevant
|
|
68
|
+
visual check `blocked`, give the user the available paths/URL, and never
|
|
69
|
+
substitute numeric proxies for appearance. Numeric proxies cover
|
|
70
|
+
structure and motion only.
|
|
71
|
+
One useful framing proxy:
|
|
72
|
+
`bpy_extras.object_utils.world_to_camera_view(scene, cam, world_co)`
|
|
73
|
+
returns NDC — x/y in `[0,1]` **with z > 0** means in-frame (z ≤ 0 is
|
|
74
|
+
behind the camera) — but NDC cannot tell a cylinder from a cactus.
|
|
75
|
+
|
|
76
|
+
Check silhouette hierarchy, scale cues, contact, depth separation, texture
|
|
77
|
+
scale, highlight/shadow detail, generated/local style coherence, and delivery
|
|
78
|
+
framing. One extra rule: **no asset may read as an unresolved primitive from
|
|
79
|
+
the active camera** unless its passport fidelity is `blockout`. A bare
|
|
80
|
+
cylinder standing in for a cactus, or a uniform sphere for a rock, fails this
|
|
81
|
+
check even when every structural metric passes.
|
|
82
|
+
|
|
83
|
+
## Specialist audits
|
|
84
|
+
|
|
85
|
+
Owned by their modules, executed here at finalize time when the Scene
|
|
86
|
+
Passport declares the matching work:
|
|
87
|
+
|
|
88
|
+
- **Cinematic lighting** (cinematic, commercial, product, night, atmosphere,
|
|
89
|
+
reference-matched): the full "Lighting audit" in
|
|
90
|
+
`blender-lighting-camera`. If isolated or grayscale evidence cannot
|
|
91
|
+
be produced, the lighting audit is `blocked`, not `passed`.
|
|
92
|
+
- **Generated environment**: the "Audit gate" in `blender-hdri`. Record
|
|
93
|
+
the SDR limitation; a GPT Image PNG is never reported as true HDR/EXR.
|
|
94
|
+
- **Generated PBR material**: the "Audit gate" in `blender-pbr`.
|
|
95
|
+
Luminance-derived maps are inferred, not measured.
|
|
96
|
+
|
|
97
|
+
## Measuring a complaint
|
|
98
|
+
|
|
99
|
+
- Measure the quantity that was complained about, in world space over time —
|
|
100
|
+
never the nearest available proxy. A perfect reading on the wrong quantity
|
|
101
|
+
closes the wrong case, and the complaint returns.
|
|
102
|
+
- A clean zero can be the failure. A component that never varies proves rigid
|
|
103
|
+
parenting as readily as correctness; pick the reference frame the physical
|
|
104
|
+
claim is about — for a wheel that is the ground, not the body.
|
|
105
|
+
- A hidden object returns a stale matrix. Measure on a frame where it is
|
|
106
|
+
visible.
|
|
107
|
+
- `scene.ray_cast` follows viewport visibility, not render visibility. When
|
|
108
|
+
the ray and the render disagree, the render is the evidence.
|
|
109
|
+
|
|
110
|
+
## Repair loop
|
|
111
|
+
|
|
112
|
+
Fix the largest concrete failure first, then rerun the affected audit. No
|
|
113
|
+
polishing of secondary detail while scale, framing, contact, or motion is
|
|
114
|
+
wrong. A blocked check is reported as blocked — never dressed up as a pass.
|
|
115
|
+
|
|
116
|
+
## Finalize
|
|
117
|
+
|
|
118
|
+
- Modifiers, rigs, materials, and generated assets stay editable unless the
|
|
119
|
+
deliverable explicitly demands destructive application.
|
|
120
|
+
- Proxies are removed only after their replacements pass; otherwise they are
|
|
121
|
+
parked predictably and reported.
|
|
122
|
+
- Restore a useful frame and the intended active camera.
|
|
123
|
+
- Record generated job ids, models, estimates, imported object names.
|
|
124
|
+
- Report created/modified/deleted objects with the audit evidence.
|
|
125
|
+
|
|
126
|
+
When `bl_finalize_build` is exposed, run it once after repairs and inspect
|
|
127
|
+
its documented result; a `valid` field must be true when that is the contract.
|
|
128
|
+
Otherwise use the core manual-finalize fallback and report each audit result
|
|
129
|
+
with its actual evidence. Do not invent a tool response or silently convert a
|
|
130
|
+
blocked generation, export, or visual check into a pass.
|
|
131
|
+
|
|
132
|
+
## Save policy
|
|
133
|
+
|
|
134
|
+
Follow blender-scene's local save and recovery contract. Recovery copies
|
|
135
|
+
precede broad/destructive edits; save requested .blend outputs before
|
|
136
|
+
reconnecting or replacing the active project. Preserve useful partial work
|
|
137
|
+
even when an audit is blocked, clearly reporting its status. Confirm an
|
|
138
|
+
ambiguous overwrite target; do not overwrite or pack resources merely as a
|
|
139
|
+
side effect of audit/finalize. Report the verified saved path.
|