Govern task context and advance execution pointer
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-20 06:02:43 -04:00
parent 380cbed4ff
commit 10640aeb3c
984 changed files with 543475 additions and 327 deletions

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Read the Action gap fixture with Blender 5.2 and verify save/reopen stability."""
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def action_report():
actions = []
for action in sorted(bpy.data.actions, key=lambda value: value.name):
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
channels.extend((curve.data_path, curve.array_index, len(curve.keyframe_points)) for curve in bag.fcurves)
actions.append({"name": action.name, "frameRange": [round(float(value), 6) for value in action.frame_range], "channels": sorted(channels)})
return actions
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2:
raise SystemExit("usage: blender -b --python check-action-desktop.py -- FIXTURE REPORT")
fixture, report_path = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = action_report()
if len(before) != 1 or before[0]["name"] != "AnimatedObjectAction":
raise RuntimeError(f"unexpected Action inventory: {before}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-action-reopen-", suffix=".blend")
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = action_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"Action save/reopen drift: before={before} after={after}")
report = {"schemaVersion": 1, "task": "M16-GAP-00001", "operation": "ACTION_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "actions": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"action-desktop-ok actions={len(after)} channels={len(after[0]['channels'])} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
armature = next((value for value in bpy.data.armatures if value.name == "RiggedArmature"), None)
if armature is None: raise RuntimeError("RiggedArmature is missing")
return {"name": armature.name, "bones": [{"name": bone.name, "parent": bone.parent.name if bone.parent else None, "head": [round(float(v), 6) for v in bone.head_local], "tail": [round(float(v), 6) for v in bone.tail_local]} for bone in armature.bones]}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-armature-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-reopen-", suffix=".blend"); os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"armature save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00002", "operation": "ARMATURE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "armature": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"armature-desktop-ok bones={len(after['bones'])} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
mesh_object = bpy.data.objects.get("WebGapArmatureMeshObject")
armature_object = bpy.data.objects.get("WebGapArmatureObject")
if mesh_object is None or mesh_object.type != "MESH":
raise RuntimeError("WebGapArmatureMeshObject is missing")
if armature_object is None or armature_object.type != "ARMATURE":
raise RuntimeError("WebGapArmatureObject is missing")
modifiers = [modifier for modifier in mesh_object.modifiers if modifier.type == "ARMATURE"]
if len(modifiers) != 1:
raise RuntimeError(f"expected exactly one ARMATURE modifier, found {len(modifiers)}")
modifier = modifiers[0]
type_code = bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value
return {
"meshId": "mesh:" + mesh_object.data.name,
"meshName": mesh_object.data.name,
"armatureObjectId": "object:" + armature_object.name,
"armatureDataId": "armature:" + armature_object.data.name,
"modifier": {
"name": modifier.name,
"type": modifier.type,
"typeCode": type_code,
"enabled": bool(modifier.show_viewport),
"showViewport": bool(modifier.show_viewport),
"showRender": bool(modifier.show_render),
"showEditMode": bool(modifier.show_in_editmode),
"showOnCage": bool(modifier.show_on_cage),
"vertexGroup": modifier.vertex_group,
"targetObjectId": "object:" + modifier.object.name if modifier.object else None,
},
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-armature-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"armature modifier save/reopen drift: {before} != {after}")
value = {
"schemaVersion": 1,
"task": "M16-GAP-00030",
"operation": "ARMATURE_MODIFIER_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"modifier": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"armature-modifier-desktop-ok mesh={after['meshId']} modifier={after['modifier']['name']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
source = bpy.data.objects.get("WebGapArrayObject")
if source is None or source.type != "MESH":
raise RuntimeError("WebGapArrayObject is missing")
modifiers = [modifier for modifier in source.modifiers if modifier.type == "ARRAY"]
if len(modifiers) != 1:
raise RuntimeError(f"expected exactly one ARRAY modifier, found {len(modifiers)}")
modifier = modifiers[0]
return {
"meshId": "mesh:" + source.data.name,
"modifier": {
"name": modifier.name,
"type": modifier.type,
"typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value,
"count": int(modifier.count),
"startCapObjectId": "object:" + modifier.start_cap.name if modifier.start_cap else None,
"endCapObjectId": "object:" + modifier.end_cap.name if modifier.end_cap else None,
"showViewport": bool(modifier.show_viewport),
"showRender": bool(modifier.show_render),
"showEditMode": bool(modifier.show_in_editmode),
"showOnCage": bool(modifier.show_on_cage),
},
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-array-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-array-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"array modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00031", "operation": "ARRAY_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"array-modifier-desktop-ok mesh={after['meshId']} count={after['modifier']['count']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
obj = bpy.data.objects.get("WebGapBevelObject")
if obj is None or obj.type != "MESH":
raise RuntimeError("WebGapBevelObject is missing")
modifiers = [modifier for modifier in obj.modifiers if modifier.type == "BEVEL"]
if len(modifiers) != 1:
raise RuntimeError(f"expected exactly one BEVEL modifier, found {len(modifiers)}")
modifier = modifiers[0]
return {
"meshId": "mesh:" + obj.data.name,
"modifier": {
"name": modifier.name,
"type": modifier.type,
"typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value,
"width": round(float(modifier.width), 6),
"segments": int(modifier.segments),
"profile": round(float(modifier.profile), 6),
"showViewport": bool(modifier.show_viewport),
"showRender": bool(modifier.show_render),
"showEditMode": bool(modifier.show_in_editmode),
"showOnCage": bool(modifier.show_on_cage),
},
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-bevel-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-bevel-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"bevel modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00032", "operation": "BEVEL_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"bevel-modifier-desktop-ok mesh={after['meshId']} width={after['modifier']['width']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const task = process.argv[2];
const config = {
"M15-02C": { mode: "LOCAL_EXACT", nextTask: "M15-02D", parent: "M15-02B", dir: "M15-02C", file: "local-exact-audit" },
"M15-02D": { mode: "LOCAL_EQUIVALENT", nextTask: "M15-02E", parent: "M15-02C", dir: "M15-02D", file: "local-equivalent-audit" },
"M15-02E": { mode: "SERVER_EXACT", nextTask: "M15-02F", parent: "M15-02D", dir: "M15-02E", file: "server-exact-audit" },
"M15-02F": { mode: "UNKNOWN_DATA_PRESERVATION", nextTask: "M15-03A", parent: "M15-02E", dir: "M15-02F", file: "unknown-data-audit" },
};
const c = config[task];
if (!c) throw new Error("usage: node check-blender-contract-audit.mjs M15-02C");
const generator = path.join(root, "tools/web/generate-blender-contract-audit.mjs");
const auditPath = path.join(root, `tests/golden/${c.dir}/${c.file}.json`);
const reportPath = path.join(root, `tests/golden/${c.dir}/${c.file}-check-report.json`);
const manifestPath = path.join(root, `tests/golden/${c.dir}/manifest.json`);
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const audit = JSON.parse(fs.readFileSync(auditPath, "utf8"));
assert.equal(audit.schemaVersion, 1); assert.equal(audit.task, task); assert.equal(audit.operation, "BLENDER_PARITY_CONTRACT_AUDIT"); assert.equal(audit.contract.mode, c.mode); assert.equal(audit.contract.failClosed, true); assert.equal(audit.summary.inventoried, 6900); assert.equal(audit.summary.applicable, 0); assert.equal(audit.summary.blocked, 0); assert.equal(audit.summary.ready, 0); assert.equal(audit.summary.notApplicable, audit.summary.inventoried); assert.equal(audit.interpretation, "NO_CLAIMS_DECLARED"); assert.deepEqual(audit.blockedIds, []); assert.deepEqual(audit.readyIds, []);
assert.equal(fileSha256(path.join(root, audit.source.path)), audit.source.sha256);
const temporary = path.join(root, `.tmp-${task}.json`);
try { execFileSync(process.execPath, [generator, task, temporary], { cwd: root, stdio: "pipe" }); assert.deepEqual(fs.readFileSync(temporary), fs.readFileSync(auditPath), `${task} audit is not deterministic`); } finally { fs.rmSync(temporary, { force: true }); }
const report = { schemaVersion: 1, task, operation: "BLENDER_PARITY_CONTRACT_AUDIT_CHECK", contract: audit.contract, summary: audit.summary, auditSha256: fileSha256(auditPath), nextTask: audit.nextTask };
if (process.env[`UPDATE_${task.replaceAll("-", "_")}_MANIFEST`] === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, `tests/golden/${c.parent}/manifest.json`), generator, checker: path.join(root, "tools/web/check-blender-contract-audit.mjs"), audit: auditPath, report: reportPath, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task, parentTask: c.parent, enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_PARITY", operation: "BLENDER_PARITY_CONTRACT_AUDIT", artifacts, nextTask: c.nextTask }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task, parentTask: c.parent, nextTask: c.nextTask }); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-contract-audit-ok task=${task} mode=${c.mode} applicable=0 blocked=0 notApplicable=${audit.summary.notApplicable} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-core-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01F/blender-core-inventory.json");
const reportPath = path.join(root, "tests/golden/M15-01F/core-inventory-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-01F/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-core-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender core inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01F");
assert.equal(inventory.operation, "BLENDER_CORE_INVENTORY");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
const families = ["Core", "Depsgraph", "Main", "Mesh", "Scene"];
assert.deepEqual(Object.keys(inventory.summary.byFamily).sort(), families);
assert.ok(inventory.summary.classCount >= 100);
assert.equal(inventory.classes.length, inventory.summary.classCount);
const identifiers = inventory.classes.map((entry) => `${entry.family}:${entry.rnaIdentifier}`);
assert.deepEqual(identifiers, [...identifiers].sort(), "core class identifiers are not sorted");
assert.equal(new Set(identifiers).size, identifiers.length, "core class identifiers are not unique");
for (const family of families) assert.equal(inventory.classes.filter((entry) => entry.family === family).length, inventory.summary.byFamily[family]);
for (const entry of inventory.classes) {
assert.ok(families.includes(entry.family));
assert.match(entry.className, /^[A-Za-z0-9_]+$/);
assert.match(entry.rnaIdentifier, /^[A-Za-z0-9_]+$/);
assert.ok(Array.isArray(entry.properties));
const properties = entry.properties.map((property) => property.identifier);
assert.deepEqual(properties, [...properties].sort(), `${entry.className} properties are not sorted`);
assert.equal(new Set(properties).size, properties.length, `${entry.className} properties are not unique`);
}
const report = { schemaVersion: 1, task: "M15-01F", operation: "BLENDER_CORE_INVENTORY_CHECK", runtime: inventory.runtime, summary: inventory.summary, firstClass: inventory.classes[0].rnaIdentifier, lastClass: inventory.classes.at(-1).rnaIdentifier, inventorySha256: sha256(expectedBytes), nextTask: inventory.nextTask };
if (process.env.UPDATE_M15_01F_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01E/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-core-inventory.mjs"), inventory: expectedPath, report: reportPath, runtime: blender, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01F", parentTask: "M15-01E", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_CORE", operation: "BLENDER_CORE_INVENTORY", artifacts, nextTask: "M15-02A" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01F", parentTask: "M15-01E", nextTask: "M15-02A" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-core-inventory-ok classes=${inventory.summary.classCount} main=${inventory.summary.byFamily.Main} scene=${inventory.summary.byFamily.Scene} mesh=${inventory.summary.byFamily.Mesh} depsgraph=${inventory.summary.byFamily.Depsgraph} core=${inventory.summary.byFamily.Core} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-editor-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01E/blender-editor-inventory.json");
const reportPath = path.join(root, "tests/golden/M15-01E/editor-inventory-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-01E/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-editor-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender editor inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01E");
assert.equal(inventory.operation, "BLENDER_EDITOR_INVENTORY");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
assert.ok(inventory.summary.areaTypeCount >= 15);
assert.ok(inventory.summary.regionTypeCount >= 15);
assert.ok(inventory.summary.spaceTypeCount >= 15);
assert.ok(inventory.summary.spaceClassCount >= 10);
assert.ok(inventory.summary.workspaceModeCount >= 10);
assert.ok(inventory.summary.keymapCount >= 250);
assert.ok(inventory.summary.keymapItemCount >= 3000);
assert.equal(inventory.keymaps.length, inventory.summary.keymapCount);
assert.equal(inventory.keymaps.reduce((count, keymap) => count + keymap.items.length, 0), inventory.summary.keymapItemCount);
const sortedUnique = (values, label) => {
assert.deepEqual(values, [...values].sort(), `${label} are not sorted`);
assert.equal(new Set(values).size, values.length, `${label} are not unique`);
};
for (const [name, values] of [["area types", inventory.areaTypes], ["region types", inventory.regionTypes], ["space types", inventory.spaceTypes], ["workspace modes", inventory.workspaceModes]]) sortedUnique(values.map((entry) => entry.identifier), name);
sortedUnique(inventory.spaceClasses.map((entry) => entry.rnaIdentifier), "space classes");
sortedUnique(inventory.keymaps.map((entry) => `${entry.name}:${entry.spaceType}:${entry.regionType}`), "keymaps");
for (const keymap of inventory.keymaps) {
assert.ok(Array.isArray(keymap.items));
for (const item of keymap.items) {
if (item.idname) assert.match(item.idname, /^[a-z0-9_]+\.[a-z0-9_]+$/);
else assert.equal(keymap.modal, true, `${keymap.name} has an empty operator id outside a modal map`);
assert.match(item.mapType, /^[A-Z]+$/);
assert.equal(typeof item.active, "boolean");
}
}
const report = { schemaVersion: 1, task: "M15-01E", operation: "BLENDER_EDITOR_INVENTORY_CHECK", runtime: inventory.runtime, summary: inventory.summary, firstArea: inventory.areaTypes[0].identifier, lastKeymap: inventory.keymaps.at(-1).name, inventorySha256: sha256(expectedBytes), nextTask: inventory.nextTask };
if (process.env.UPDATE_M15_01E_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01D/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-editor-inventory.mjs"), inventory: expectedPath, report: reportPath, runtime: blender, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01E", parentTask: "M15-01D", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_EDITOR", operation: "BLENDER_EDITOR_INVENTORY", artifacts, nextTask: "M15-01F" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01E", parentTask: "M15-01D", nextTask: "M15-01F" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-editor-inventory-ok areas=${inventory.summary.areaTypeCount} regions=${inventory.summary.regionTypeCount} keymaps=${inventory.summary.keymapCount} items=${inventory.summary.keymapItemCount} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-family-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01C/blender-family-inventory.json");
const reportPath = path.join(root, "tests/golden/M15-01C/family-inventory-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-01C/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-family-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender family inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01C");
assert.equal(inventory.operation, "BLENDER_FAMILY_INVENTORY");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
assert.match(inventory.runtime.binarySha256, /^[a-f0-9]{64}$/);
assert.ok(inventory.summary.modifierCount >= 80);
assert.ok(inventory.summary.constraintCount >= 25);
assert.ok(inventory.summary.nodeCount >= 400);
assert.deepEqual(Object.keys(inventory.summary.nodesByFamily).sort(), ["Compositor", "Geometry", "Shader"]);
assert.equal(inventory.nodes.length, inventory.summary.nodeCount);
for (const [key, value] of Object.entries(inventory.summary.nodesByFamily)) assert.equal(inventory.nodes.filter((node) => node.family === key).length, value);
const checkSortedUnique = (values, label) => {
assert.deepEqual(values, [...values].sort(), `${label} are not sorted`);
assert.equal(new Set(values).size, values.length, `${label} are not unique`);
};
checkSortedUnique(inventory.modifiers.map((entry) => entry.identifier), "modifier identifiers");
checkSortedUnique(inventory.constraints.map((entry) => entry.identifier), "constraint identifiers");
checkSortedUnique(inventory.nodes.map((entry) => `${entry.family}:${entry.rnaIdentifier}`), "node identifiers");
for (const entry of [...inventory.modifiers, ...inventory.constraints]) {
assert.match(entry.identifier, /^[A-Z0-9_]+$/);
assert.equal(typeof entry.name, "string");
assert.equal(typeof entry.description, "string");
assert.equal(typeof entry.value, "number");
}
for (const entry of inventory.nodes) {
assert.match(entry.family, /^(Shader|Geometry|Compositor)$/);
assert.match(entry.rnaIdentifier, /^(Shader|Geometry|Compositor)Node[A-Za-z0-9_]+$/);
assert.ok(Array.isArray(entry.properties));
checkSortedUnique(entry.properties.map((property) => property.identifier), `${entry.rnaIdentifier} properties`);
}
const report = { schemaVersion: 1, task: "M15-01C", operation: "BLENDER_FAMILY_INVENTORY_CHECK", runtime: inventory.runtime, summary: inventory.summary, firstModifier: inventory.modifiers[0].identifier, lastNode: inventory.nodes.at(-1).rnaIdentifier, inventorySha256: sha256(expectedBytes), nextTask: inventory.nextTask };
if (process.env.UPDATE_M15_01C_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01B/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-family-inventory.mjs"), inventory: expectedPath, report: reportPath, runtime: blender, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01C", parentTask: "M15-01B", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_FAMILY", operation: "BLENDER_FAMILY_INVENTORY", artifacts, nextTask: "M15-01D" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01C", parentTask: "M15-01B", nextTask: "M15-01D" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-family-inventory-ok modifiers=${inventory.summary.modifierCount} constraints=${inventory.summary.constraintCount} nodes=${inventory.summary.nodeCount} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-format-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01D/blender-format-inventory.json");
const reportPath = path.join(root, "tests/golden/M15-01D/format-inventory-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-01D/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-format-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender format inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01D");
assert.equal(inventory.operation, "BLENDER_FORMAT_INVENTORY");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
assert.ok(inventory.summary.stripTypeCount >= 20);
assert.ok(inventory.summary.physicsTypeCount >= 40);
assert.ok(inventory.summary.ioOperatorCount >= 25);
assert.equal(inventory.stripTypes.length, inventory.summary.stripTypeCount);
assert.equal(inventory.physicsTypes.length, inventory.summary.physicsTypeCount);
assert.equal(inventory.ioOperators.length, inventory.summary.ioOperatorCount);
const sortedUnique = (values, label) => {
assert.deepEqual(values, [...values].sort(), `${label} are not sorted`);
assert.equal(new Set(values).size, values.length, `${label} are not unique`);
};
sortedUnique(inventory.stripTypes.map((entry) => entry.identifier), "strip types");
sortedUnique(inventory.physicsTypes.map((entry) => entry.rnaIdentifier), "physics types");
sortedUnique(inventory.ioOperators.map((entry) => entry.operator), "I/O operators");
for (const entry of inventory.stripTypes) assert.match(entry.identifier, /^[A-Z0-9_]+$/);
for (const entry of inventory.physicsTypes) {
assert.match(entry.className, /^[A-Za-z0-9_]+$/);
assert.ok(Array.isArray(entry.properties));
sortedUnique(entry.properties.map((property) => property.identifier), `${entry.className} properties`);
}
for (const entry of inventory.ioOperators) {
assert.match(entry.operator, /^[a-z0-9_]+\.[a-z0-9_]+$/);
assert.match(entry.operator, /import|export/i);
assert.match(entry.rnaIdentifier, /^[A-Za-z0-9_]+$/);
assert.ok(Array.isArray(entry.properties));
}
const report = { schemaVersion: 1, task: "M15-01D", operation: "BLENDER_FORMAT_INVENTORY_CHECK", runtime: inventory.runtime, summary: inventory.summary, firstStrip: inventory.stripTypes[0].identifier, lastOperator: inventory.ioOperators.at(-1).operator, inventorySha256: sha256(expectedBytes), nextTask: inventory.nextTask };
if (process.env.UPDATE_M15_01D_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01C/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-format-inventory.mjs"), inventory: expectedPath, report: reportPath, runtime: blender, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01D", parentTask: "M15-01C", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_FORMAT", operation: "BLENDER_FORMAT_INVENTORY", artifacts, nextTask: "M15-01E" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01D", parentTask: "M15-01C", nextTask: "M15-01E" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-format-inventory-ok strips=${inventory.summary.stripTypeCount} physics=${inventory.summary.physicsTypeCount} io=${inventory.summary.ioOperatorCount} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-gap-audit.mjs");
const auditPath = path.join(root, "tests/golden/M15-02B/blender-gap-audit.json");
const reportPath = path.join(root, "tests/golden/M15-02B/gap-audit-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-02B/manifest.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const audit = JSON.parse(fs.readFileSync(auditPath, "utf8"));
assert.equal(audit.schemaVersion, 1);
assert.equal(audit.task, "M15-02B");
assert.equal(audit.operation, "BLENDER_PARITY_GAP_AUDIT");
assert.equal(audit.interpretation.inventoryBaseline, "SUMMARY_ONLY");
assert.equal(fileSha256(path.join(root, audit.source.path)), audit.source.sha256);
const categories = ["unmapped", "duplicateIds", "summaryOnly", "proxyOnly", "routeOnly"];
const sortedUnique = (values, label) => { assert.deepEqual(values, [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0), `${label} are not sorted`); assert.equal(new Set(values).size, values.length, `${label} are not unique`); };
for (const category of categories) { assert.ok(Array.isArray(audit.gaps[category])); sortedUnique(audit.gaps[category], category); }
assert.equal(audit.summary.inventoried, audit.summary.unmapped + audit.summary.summaryOnly + audit.summary.proxyOnly + audit.summary.routeOnly);
assert.equal(audit.summary.duplicate, audit.gaps.duplicateIds.length);
assert.equal(audit.summary.gapCount, categories.reduce((sum, category) => sum + audit.gaps[category].length, 0));
assert.ok(audit.summary.inventoried >= 6900);
assert.equal(audit.summary.unmapped, 0);
assert.equal(audit.summary.duplicate, 0);
assert.equal(audit.summary.proxyOnly, 0);
assert.equal(audit.summary.routeOnly, 0);
assert.equal(audit.summary.summaryOnly, audit.summary.inventoried);
const temporary = path.join(root, ".tmp-m15-02b-audit.json");
try { execFileSync(process.execPath, [generator, temporary], { cwd: root, stdio: "pipe" }); assert.deepEqual(fs.readFileSync(temporary), fs.readFileSync(auditPath), "gap audit is not deterministic"); } finally { fs.rmSync(temporary, { force: true }); }
const report = { schemaVersion: 1, task: "M15-02B", operation: "BLENDER_PARITY_GAP_AUDIT_CHECK", summary: audit.summary, auditSha256: fileSha256(auditPath), nextTask: audit.nextTask };
if (process.env.UPDATE_M15_02B_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-02A/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-gap-audit.mjs"), audit: auditPath, report: reportPath, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-02B", parentTask: "M15-02A", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_PARITY", operation: "BLENDER_PARITY_GAP_AUDIT", artifacts, nextTask: "M15-02C" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-02B", parentTask: "M15-02A", nextTask: "M15-02C" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-gap-audit-ok inventoried=${audit.summary.inventoried} gaps=${audit.summary.gapCount} summaryOnly=${audit.summary.summaryOnly} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-next-task-plan.mjs"); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const reportPath = path.join(root, "tests/golden/M15-03A/next-task-plan-check-report.json"); const manifestPath = path.join(root, "tests/golden/M15-03A/manifest.json"); const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); const fileSha256 = (f) => sha256(fs.readFileSync(f)); const relative = (f) => path.relative(root, f).replaceAll(path.sep, "/");
const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); assert.equal(plan.task, "M15-03A"); assert.equal(plan.operation, "BLENDER_NEXT_TASK_PLAN"); assert.equal(plan.tasks.length, 6900); assert.equal(plan.summary.active, 1); assert.equal(plan.summary.completed + plan.summary.pending + plan.summary.active, 6900); assert.equal(plan.firstTask, plan.tasks.find((task) => task.state === "active").id); assert.equal(plan.closureGate, "M15-03E");
const ids = plan.tasks.map((task) => task.id); const gaps = plan.tasks.map((task) => task.gapId); assert.equal(new Set(ids).size, ids.length); assert.equal(new Set(gaps).size, gaps.length); assert.ok(plan.tasks.every((task) => /^M(?:1[6-9]|2[0-2])-GAP-\d{5}$/.test(task.id))); assert.ok(plan.tasks.every((task) => task.ownerFamily && !task.id.includes("FAMILY")));
for (const source of Object.values(plan.sources)) assert.equal(fileSha256(path.join(root, source.path)), source.sha256, source.path);
const temporary = path.join(root, ".tmp-m15-03a-plan.json"); try { execFileSync(process.execPath, [generator, temporary], { cwd: root, stdio: "pipe", maxBuffer: 32 * 1024 * 1024 }); assert.deepEqual(fs.readFileSync(temporary), fs.readFileSync(planPath), "next-task plan is not deterministic"); } finally { fs.rmSync(temporary, { force: true }); }
const report = { schemaVersion: 1, task: "M15-03A", operation: "BLENDER_NEXT_TASK_PLAN_CHECK", summary: plan.summary, firstTask: plan.firstTask, planSha256: fileSha256(planPath), nextTask: plan.nextTask };
if (process.env.UPDATE_M15_03A_MANIFEST === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const paths = { parentManifest: path.join(root, "tests/golden/M15-02F/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-next-task-plan.mjs"), plan: planPath, report: reportPath, package: path.join(root, "web/package.json") }; const artifacts = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-03A", parentTask: "M15-02F", enablingTask: false, parityStateChange: false, operation: "BLENDER_NEXT_TASK_PLAN", artifacts, nextTask: "M15-03B" }, null, 2)}\n`); }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`blender-next-task-plan-ok tasks=${plan.tasks.length} active=1 first=${plan.firstTask} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const reportPath = path.join(root, "tests/golden/M15-03B/next-task-spec-check-report.json"); const manifestPath = path.join(root, "tests/golden/M15-03B/manifest.json"); const generator = path.join(root, "tools/web/generate-blender-next-task-plan.mjs"); const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); const fileSha256 = (f) => sha256(fs.readFileSync(f)); const relative = (f) => path.relative(root, f).replaceAll(path.sep, "/");
const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); assert.equal(plan.tasks.length, 6900); const required = ["fixture", "desktopCommand", "webCommand", "comparator", "exitCriteria"]; const missing = []; const malformed = [];
for (const task of plan.tasks) { for (const field of required) { if (field === "fixture") { if (!task.fixture?.path || !task.fixture?.state) missing.push({ id: task.id, field }); } else if (field === "exitCriteria") { if (!Array.isArray(task.exitCriteria) || task.exitCriteria.length < 3) missing.push({ id: task.id, field }); } else if (typeof task[field] !== "string" || task[field].length < 8) missing.push({ id: task.id, field }); } if (!task.desktopCommand.includes("blender") || !task.webCommand.includes("npm") || !task.comparator.includes("check-generated-gap")) malformed.push(task.id); }
assert.deepEqual(missing, []); assert.deepEqual(malformed, []);
const report = { schemaVersion: 1, task: "M15-03B", operation: "BLENDER_NEXT_TASK_SPEC_CHECK", source: { path: "tests/golden/M15-03A/next-task-plan.json", sha256: fileSha256(planPath) }, summary: { tasks: plan.tasks.length, completeSpecs: plan.tasks.length, missingFields: missing.length, malformed: malformed.length }, requiredFields: required, nextTask: "M15-03C" };
if (process.env.UPDATE_M15_03B_MANIFEST === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const paths = { parentManifest: path.join(root, "tests/golden/M15-03A/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-next-task-spec.mjs"), plan: planPath, report: reportPath, package: path.join(root, "web/package.json") }; const artifacts = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-03B", parentTask: "M15-03A", enablingTask: false, parityStateChange: false, operation: "BLENDER_NEXT_TASK_SPEC_CHECK", artifacts, nextTask: "M15-03C" }, null, 2)}\n`); }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`blender-next-task-spec-ok tasks=${report.summary.tasks} complete=${report.summary.completeSpecs} missing=${report.summary.missingFields} malformed=${report.summary.malformed} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-operator-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01B/blender-operator-inventory.json");
const reportPath = path.join(root, "tests/golden/M15-01B/operator-inventory-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-01B/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-operator-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], { cwd: root, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender operator inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.deepEqual(inventory, JSON.parse(fs.readFileSync(regeneratedPath, "utf8")));
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01B");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
assert.match(inventory.runtime.binarySha256, /^[a-f0-9]{64}$/);
assert.ok(inventory.summary.count >= 500, `unexpectedly small operator inventory: ${inventory.summary.count}`);
assert.equal(inventory.operators.length, inventory.summary.count);
const operatorIds = inventory.operators.map((entry) => entry.operator);
assert.deepEqual(operatorIds, [...operatorIds].sort(), "operator IDs are not sorted");
assert.equal(new Set(operatorIds).size, operatorIds.length, "operator IDs are not unique");
assert.equal(inventory.summary.registered, inventory.operators.filter((entry) => entry.registered).length);
assert.equal(inventory.summary.pollTrue + inventory.summary.pollFalse + inventory.summary.pollUnknown, inventory.summary.count);
for (const entry of inventory.operators) {
assert.match(entry.operator, /^[a-z0-9_]+\.[a-z0-9_]+$/);
assert.equal(entry.registered, true);
assert.match(entry.rnaIdentifier, /^[A-Za-z0-9_]+$/);
assert.ok(Array.isArray(entry.properties));
const properties = entry.properties.map((property) => property.identifier);
assert.deepEqual(properties, [...properties].sort(), `${entry.operator} properties are not sorted`);
}
const report = { schemaVersion: 1, task: "M15-01B", operation: "BLENDER_OPERATOR_INVENTORY_CHECK", runtime: inventory.runtime, count: inventory.summary.count, registered: inventory.summary.registered, poll: { true: inventory.summary.pollTrue, false: inventory.summary.pollFalse, unknown: inventory.summary.pollUnknown }, first: operatorIds[0], last: operatorIds.at(-1), inventorySha256: sha256(expectedBytes), nextTask: inventory.nextTask };
if (process.env.UPDATE_M15_01B_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01A/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-operator-inventory.mjs"), inventory: expectedPath, report: reportPath, runtime: blender, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01B", parentTask: "M15-01A", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_OPERATOR", operation: "BLENDER_OPERATOR_INVENTORY", artifacts, nextTask: "M15-01C" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01B", parentTask: "M15-01A", nextTask: "M15-01C" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-operator-inventory-ok count=${inventory.summary.count} registered=${inventory.summary.registered} pollUnknown=${inventory.summary.pollUnknown} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-parity-map.mjs");
const mapPath = path.join(root, "tests/golden/M15-02A/blender-parity-map.json");
const reportPath = path.join(root, "tests/golden/M15-02A/parity-map-check-report.json");
const manifestPath = path.join(root, "tests/golden/M15-02A/manifest.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
assert.equal(map.schemaVersion, 1);
assert.equal(map.task, "M15-02A");
assert.equal(map.operation, "BLENDER_PARITY_MAPPING");
assert.equal(map.statusAxis, "INVENTORY_ONLY");
assert.deepEqual(map.implementationClasses, ["INVENTORY_BASELINE"]);
assert.equal(map.entries.length, map.summary.mapped);
const ids = map.entries.map((entry) => entry.id);
assert.deepEqual(ids, [...ids].sort(), "parity map IDs are not sorted");
assert.equal(new Set(ids).size, ids.length, "parity map IDs are not unique");
for (const entry of map.entries) {
assert.match(entry.id, /^[\x20-\x7e]+$/);
assert.equal(entry.implementationClass, "INVENTORY_BASELINE");
assert.equal(entry.coverage, "INVENTORIED_ONLY");
assert.ok(entry.ownerFamily);
assert.equal(entry.tests.length, 1);
assert.ok(fs.existsSync(path.join(root, entry.tests[0])));
assert.equal(entry.evidence.length, 1);
assert.ok(fs.existsSync(path.join(root, entry.evidence[0])));
}
for (const [task, ref] of Object.entries(map.sourceInventories)) assert.equal(fileSha256(path.join(root, ref.path)), ref.sha256, `${task} inventory hash drifted`);
const temporary = path.join(root, ".tmp-m15-02a-map.json");
try {
execFileSync(process.execPath, [generator, temporary], { cwd: root, stdio: "pipe" });
assert.deepEqual(fs.readFileSync(temporary), fs.readFileSync(mapPath), "parity map is not deterministic");
} finally { fs.rmSync(temporary, { force: true }); }
const report = { schemaVersion: 1, task: "M15-02A", operation: "BLENDER_PARITY_MAPPING_CHECK", mapped: map.summary.mapped, byTask: map.summary.byTask, ownerFamilies: map.summary.ownerFamilies, mapSha256: fileSha256(mapPath), nextTask: map.nextTask };
if (process.env.UPDATE_M15_02A_MANIFEST === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = { parentManifest: path.join(root, "tests/golden/M15-01F/manifest.json"), generator, checker: path.join(root, "tools/web/check-blender-parity-map.mjs"), map: mapPath, report: reportPath, package: path.join(root, "web/package.json") };
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-02A", parentTask: "M15-01F", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_PARITY", operation: "BLENDER_PARITY_MAPPING", artifacts, nextTask: "M15-02B" }, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-02A", parentTask: "M15-01F", nextTask: "M15-02B" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-parity-map-ok mapped=${map.summary.mapped} families=${map.summary.ownerFamilies.length} map=${fileSha256(mapPath)} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const generator = path.join(root, "tools/web/generate-blender-rna-datablock-inventory.py");
const expectedPath = path.join(root, "tests/golden/M15-01A/blender-rna-datablock-inventory.json");
const manifestPath = path.join(root, "tests/golden/M15-01A/manifest.json");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-rna-datablock-inventory-"));
const regeneratedPath = path.join(temporary, "inventory.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const fileSha256 = (file) => sha256(fs.readFileSync(file));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
try {
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
const run = spawnSync(blender, ["-b", "--factory-startup", "--python", generator, "--", regeneratedPath], {
cwd: root,
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
assert.equal(run.status, 0, `${run.stdout ?? ""}\n${run.stderr ?? ""}`);
const expectedBytes = fs.readFileSync(expectedPath);
assert.deepEqual(fs.readFileSync(regeneratedPath), expectedBytes, "Blender RNA inventory is not deterministic");
const inventory = JSON.parse(expectedBytes);
assert.deepEqual(inventory, JSON.parse(fs.readFileSync(regeneratedPath, "utf8")));
assert.equal(inventory.schemaVersion, 1);
assert.equal(inventory.task, "M15-01A");
assert.equal(inventory.operation, "BLENDER_RNA_DATABLOCK_INVENTORY");
assert.deepEqual(inventory.runtime.versionTuple, [5, 2, 0]);
assert.equal(inventory.runtime.blenderVersion, "5.2.0 LTS");
assert.match(inventory.runtime.binarySha256, /^[a-f0-9]{64}$/);
assert.equal(inventory.summary.baseType, "ID");
assert.ok(inventory.summary.count >= 20, `unexpectedly small ID inventory: ${inventory.summary.count}`);
assert.equal(inventory.dataBlockTypes.length, inventory.summary.count);
const identifiers = inventory.dataBlockTypes.map((entry) => entry.rnaIdentifier);
assert.deepEqual(identifiers, [...identifiers].sort(), "RNA identifiers are not sorted");
assert.equal(new Set(identifiers).size, identifiers.length, "RNA identifiers are not unique");
for (const entry of inventory.dataBlockTypes) {
assert.match(entry.parityId, /^BLENDER52_RNA_ID_[A-Z0-9_]+$/);
assert.equal(entry.baseType, "ID");
assert.match(entry.sourceAnchor, /blender-5\.2\.0\/source\/blender\/makesrna/);
}
assert.equal(inventory.nextTask, "M15-01B");
const report = {
schemaVersion: 1,
task: "M15-01A",
operation: "BLENDER_RNA_DATABLOCK_INVENTORY_CHECK",
runtime: inventory.runtime,
count: inventory.summary.count,
first: identifiers[0],
last: identifiers.at(-1),
inventorySha256: sha256(expectedBytes),
nextTask: inventory.nextTask,
};
if (process.env.UPDATE_M15_01A_MANIFEST === "1") {
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
const artifactPaths = {
parentManifest: path.join(root, "tests/golden/M14-04H/manifest.json"),
generator,
checker: path.join(root, "tools/web/check-blender-rna-datablock-inventory.mjs"),
inventory: expectedPath,
runtime: blender,
package: path.join(root, "web/package.json"),
};
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-01A", parentTask: "M14-04H", enablingTask: false, parityStateChange: false, runtime: "BLENDER_5_2_RNA", operation: "BLENDER_RNA_DATABLOCK_INVENTORY", artifacts, nextTask: "M15-01B" }, null, 2)}\n`);
fs.writeFileSync(path.join(root, "tests/golden/M15-01A/inventory-check-report.json"), `${JSON.stringify(report, null, 2)}\n`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M15-01A", parentTask: "M14-04H", nextTask: "M15-01B" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`blender-rna-datablock-inventory-ok count=${inventory.summary.count} blender=${inventory.runtime.blenderVersion} inventory=${sha256(expectedBytes)} next=${manifest.nextTask}\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const reportPath = path.join(root, "tests/golden/M15-03C/task-graph-check-report.json"); const manifestPath = path.join(root, "tests/golden/M15-03C/manifest.json"); const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); const fileSha256 = (f) => sha256(fs.readFileSync(f)); const relative = (f) => path.relative(root, f).replaceAll(path.sep, "/");
const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const ids = new Set(plan.tasks.map((task) => task.id)); const active = plan.tasks.filter((task) => task.state === "active"); const missingDeps = []; const cycles = []; for (const task of plan.tasks) { for (const dep of task.dependencies) { if (!ids.has(dep)) missingDeps.push(`${task.id}->${dep}`); let cursor = dep; const visited = new Set([task.id]); while (cursor) { if (visited.has(cursor)) { cycles.push(task.id); break; } visited.add(cursor); cursor = plan.tasks.find((candidate) => candidate.id === cursor)?.dependencies[0] ?? null; } } }
assert.equal(active.length, 1); assert.equal(missingDeps.length, 0); assert.equal(cycles.length, 0); assert.equal(plan.tasks.length, 6900);
const report = { schemaVersion: 1, task: "M15-03C", operation: "BLENDER_TASK_GRAPH_CHECK", source: { path: "tests/golden/M15-03A/next-task-plan.json", sha256: fileSha256(planPath) }, summary: { nodes: plan.tasks.length, active: active.length, missingDependencies: missingDeps.length, cycles: cycles.length }, activeTask: active[0].id, nextTask: "M15-03D" };
if (process.env.UPDATE_M15_03C_MANIFEST === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const paths = { parentManifest: path.join(root, "tests/golden/M15-03B/manifest.json"), checker: path.join(root, "tools/web/check-blender-task-graph.mjs"), plan: planPath, report: reportPath, package: path.join(root, "web/package.json") }; const artifacts = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-03C", parentTask: "M15-03B", enablingTask: false, parityStateChange: false, operation: "BLENDER_TASK_GRAPH_CHECK", artifacts, nextTask: "M15-03D" }, null, 2)}\n`); }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`blender-task-graph-ok nodes=${report.summary.nodes} active=${report.summary.active} missing=${report.summary.missingDependencies} cycles=${report.summary.cycles} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,11 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const gapPath = path.join(root, "tests/golden/M15-02B/blender-gap-audit.json"); const reportPath = path.join(root, "tests/golden/M15-03D/task-stats-check-report.json"); const manifestPath = path.join(root, "tests/golden/M15-03D/manifest.json"); const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); const fileSha256 = (f) => sha256(fs.readFileSync(f)); const relative = (f) => path.relative(root, f).replaceAll(path.sep, "/");
const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const gaps = JSON.parse(fs.readFileSync(gapPath, "utf8")); const completed = plan.tasks.filter((task) => task.state === "completed").length; const blocked = plan.tasks.filter((task) => task.state === "blocked").length; const inventoried = gaps.summary.inventoried; const uninventoried = Math.max(0, inventoried - plan.tasks.length); assert.equal(inventoried, 6900); assert.equal(plan.tasks.length, 6900); assert.equal(uninventoried, 0); assert.equal(blocked, 0);
const report = { schemaVersion: 1, task: "M15-03D", operation: "BLENDER_TASK_STATS_CHECK", sources: { plan: { path: "tests/golden/M15-03A/next-task-plan.json", sha256: fileSha256(planPath) }, gaps: { path: "tests/golden/M15-02B/blender-gap-audit.json", sha256: fileSha256(gapPath) } }, statistics: { inventoried, completed, blocked, uninventoried }, nextTask: "M15-03E" };
if (process.env.UPDATE_M15_03D_MANIFEST === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const paths = { parentManifest: path.join(root, "tests/golden/M15-03C/manifest.json"), checker: path.join(root, "tools/web/check-blender-task-stats.mjs"), plan: planPath, gapAudit: gapPath, report: reportPath, package: path.join(root, "web/package.json") }; const artifacts = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-03D", parentTask: "M15-03C", enablingTask: false, parityStateChange: false, operation: "BLENDER_TASK_STATS_CHECK", artifacts, nextTask: "M15-03E" }, null, 2)}\n`); }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`blender-task-stats-ok inventoried=${inventoried} completed=${completed} blocked=${blocked} uninventoried=${uninventoried} next=${manifest.nextTask}\n`);

View File

@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const reportPath = path.join(root, "tests/golden/M15-03E/zero-gap-gate-report.json"); const manifestPath = path.join(root, "tests/golden/M15-03E/manifest.json"); const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); const fileSha256 = (f) => sha256(fs.readFileSync(f)); const relative = (f) => path.relative(root, f).replaceAll(path.sep, "/");
const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const byFamily = {}; for (const task of plan.tasks) { const stats = byFamily[task.ownerFamily] ?? { total: 0, completed: 0, blocked: 0, remaining: 0 }; stats.total++; if (task.state === "completed") stats.completed++; else if (task.state === "blocked") stats.blocked++; else stats.remaining++; byFamily[task.ownerFamily] = stats; } const remaining = Object.values(byFamily).reduce((sum, value) => sum + value.remaining, 0); const gateStatus = remaining === 0 ? "READY" : "BLOCKED"; assert.equal(gateStatus, "BLOCKED"); assert.equal(remaining, plan.tasks.length - plan.summary.completed); assert.equal(plan.summary.active, 1); assert.equal(plan.firstTask, plan.tasks.find((task) => task.state === "active").id);
const report = { schemaVersion: 1, task: "M15-03E", operation: "BLENDER_ZERO_GAP_GATE", source: { path: "tests/golden/M15-03A/next-task-plan.json", sha256: fileSha256(planPath) }, status: gateStatus, summary: { ownerFamilies: Object.keys(byFamily).length, remaining }, byFamily, activeTask: plan.firstTask, nextTask: plan.firstTask };
if (process.env.UPDATE_M15_03E_MANIFEST === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const paths = { parentManifest: path.join(root, "tests/golden/M15-03D/manifest.json"), checker: path.join(root, "tools/web/check-blender-zero-gap-gate.mjs"), plan: planPath, report: reportPath, package: path.join(root, "web/package.json") }; const artifacts = Object.fromEntries(Object.entries(paths).map(([name, file]) => [name, { path: relative(file), sha256: fileSha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M15-03E", parentTask: "M15-03D", status: "BLOCKED", enablingTask: false, parityStateChange: false, operation: "BLENDER_ZERO_GAP_GATE", artifacts, nextTask: plan.firstTask }, null, 2)}\n`); }
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); assert.equal(manifest.status, "BLOCKED"); assert.equal(manifest.nextTask, plan.firstTask); for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(path.join(root, artifact.path)), artifact.sha256, artifact.path); process.stdout.write(`blender-zero-gap-gate-ok status=${gateStatus} families=${report.summary.ownerFamilies} remaining=${remaining} active=${plan.firstTask}\n`);

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
source = bpy.data.objects.get("WebGapBooleanObject")
if source is None or source.type != "MESH":
raise RuntimeError("WebGapBooleanObject is missing")
modifiers = [modifier for modifier in source.modifiers if modifier.type == "BOOLEAN"]
if len(modifiers) != 1:
raise RuntimeError(f"expected exactly one BOOLEAN modifier, found {len(modifiers)}")
modifier = modifiers[0]
return {
"meshId": "mesh:" + source.data.name,
"modifier": {
"name": modifier.name,
"type": modifier.type,
"typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value,
"operation": modifier.operation,
"solver": modifier.solver,
"targetObjectId": "object:" + modifier.object.name if modifier.object else None,
"showViewport": bool(modifier.show_viewport),
"showRender": bool(modifier.show_render),
"showEditMode": bool(modifier.show_in_editmode),
"showOnCage": bool(modifier.show_on_cage),
},
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-boolean-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-boolean-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"boolean modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00033", "operation": "BOOLEAN_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"boolean-modifier-desktop-ok mesh={after['meshId']} operation={after['modifier']['operation']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
brush = bpy.data.brushes.get("WebGapBrush")
if brush is None: raise RuntimeError("WebGapBrush is missing")
return {"name": brush.name, "size": brush.size, "alpha": round(float(brush.strength), 6), "hardness": round(float(brush.hardness), 6), "spacing": brush.spacing, "jitter": round(float(brush.jitter), 6), "sculptTool": str(brush.sculpt_brush_type)}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-brush-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-brush-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"brush save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00003", "operation": "BRUSH_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "brush": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"brush-desktop-ok name={after['name']} size={after['size']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
obj = bpy.data.objects.get("WebGapBuildObject")
if obj is None or obj.type != "MESH":
raise RuntimeError("WebGapBuildObject is missing")
modifiers = [modifier for modifier in obj.modifiers if modifier.type == "BUILD"]
if len(modifiers) != 1:
raise RuntimeError(f"expected exactly one BUILD modifier, found {len(modifiers)}")
modifier = modifiers[0]
return {"meshId": "mesh:" + obj.data.name, "modifier": {"name": modifier.name, "type": modifier.type, "typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value, "start": round(float(modifier.frame_start), 6), "length": round(float(modifier.frame_duration), 6), "randomize": bool(modifier.use_random_order), "reverse": bool(modifier.use_reverse), "seed": int(modifier.seed), "showViewport": bool(modifier.show_viewport), "showRender": bool(modifier.show_render), "showEditMode": bool(modifier.show_in_editmode), "showOnCage": bool(modifier.show_on_cage)}}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-build-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-build-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"build modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00034", "operation": "BUILD_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"build-modifier-desktop-ok mesh={after['meshId']} start={after['modifier']['start']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
camera = bpy.data.cameras.get("WebGapCamera")
if camera is None: raise RuntimeError("WebGapCamera is missing")
return {"name": camera.name, "type": camera.type, "lens": round(float(camera.lens), 6), "clipStart": round(float(camera.clip_start), 6), "clipEnd": round(float(camera.clip_end), 6), "sensorWidth": round(float(camera.sensor_width), 6)}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-camera-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-camera-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"camera save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00004", "operation": "CAMERA_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "camera": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"camera-desktop-ok name={after['name']} lens={after['lens']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
import hashlib, json, os, pathlib, sys, tempfile
import bpy
def report():
obj = bpy.data.objects.get("WebGapCastObject")
if obj is None: raise RuntimeError("WebGapCastObject is missing")
modifiers = [m for m in obj.modifiers if m.type == "CAST"]
if len(modifiers) != 1: raise RuntimeError(f"expected one CAST modifier, found {len(modifiers)}")
m = modifiers[0]
return {"meshId": "mesh:" + obj.data.name, "modifier": {"name": m.name, "type": m.type, "typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[m.type].value, "factor": round(float(m.factor), 6), "radius": round(float(m.radius), 6), "castType": m.cast_type, "useX": bool(m.use_x), "useY": bool(m.use_y), "useZ": bool(m.use_z), "showViewport": bool(m.show_viewport), "showRender": bool(m.show_render), "showEditMode": bool(m.show_in_editmode), "showOnCage": bool(m.show_on_cage)}}
def main():
args = sys.argv[sys.argv.index("--") + 1 :]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-cast-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(v).resolve() for v in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report()
fd, temporary = tempfile.mkstemp(prefix="m16-cast-modifier-reopen-", suffix=".blend", dir=fixture.parent); os.close(fd)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"cast modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00035", "operation": "CAST_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"cast-modifier-desktop-ok mesh={after['meshId']} factor={after['modifier']['factor']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const distRoot = path.join(root, "web/dist");
const reportPath = path.join(root, "tests/golden/M14-04H/chromium-accessibility-report.json");
const manifestPath = path.join(root, "tests/golden/M14-04H/manifest.json");
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
const server = http.createServer((request, response) => {
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
const file = path.resolve(distRoot, relative);
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404);
response.end("not found");
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
fs.createReadStream(file).pipe(response);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
let browser;
try {
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
const results = {};
for (const backend of ["main", "offscreen"]) {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 }, hasTouch: true, isMobile: false });
const page = await context.newPage();
await page.goto(`http://127.0.0.1:${address.port}/?offscreen=${backend === "offscreen" ? "1" : "0"}`, { waitUntil: "load" });
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
const focusByTab = async (locator) => {
for (let index = 0; index < 100; index += 1) {
if (await locator.evaluate((element) => element === document.activeElement)) return index;
await page.keyboard.press("Tab");
}
throw new Error(`KEYBOARD_FOCUS_NOT_REACHED:${await locator.getAttribute("aria-label") ?? await locator.textContent() ?? "target"}`);
};
const waitFocused = async (locator) => {
const handle = await locator.elementHandle();
assert.ok(handle);
await page.waitForFunction((element) => document.activeElement === element, handle);
};
const fileMenu = page.getByRole("button", { name: "文件", exact: true });
const fileTabCount = await focusByTab(fileMenu);
await page.keyboard.press("Enter");
const fileItem = page.getByRole("menuitem", { name: "打开", exact: true });
await fileItem.waitFor({ state: "visible" });
assert.equal(await fileItem.evaluate((element) => element === document.activeElement), true);
await page.keyboard.press("Escape");
await waitFocused(fileMenu);
await page.keyboard.press("F3");
const search = page.getByRole("textbox", { name: "搜索操作", exact: true });
await search.waitFor({ state: "visible" });
assert.equal(await search.evaluate((element) => element === document.activeElement), true);
await page.keyboard.press("Escape");
const searchTrigger = page.getByRole("button", { name: "操作搜索", exact: true });
await waitFocused(searchTrigger);
const accessible = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const name = (element) => {
const aria = element.getAttribute("aria-label")?.trim();
if (aria) return aria;
const labelledBy = element.getAttribute("aria-labelledby");
if (labelledBy) return labelledBy.split(/\s+/u).map((id) => document.getElementById(id)?.textContent?.trim() ?? "").join(" ").trim();
if (element.id) {
const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
if (label?.textContent?.trim()) return label.textContent.trim();
}
return element.textContent?.trim() ?? "";
};
const missing = [];
for (const element of document.querySelectorAll("button, input, select, textarea, canvas, [role]")) {
if (!visible(element)) continue;
const role = element.getAttribute("role") ?? element.tagName.toLowerCase();
if (!name(element)) missing.push({ role, tag: element.tagName.toLowerCase(), html: element.outerHTML.slice(0, 180) });
}
return { missing, visibleInteractiveCount: [...document.querySelectorAll("button, input, select, textarea, canvas, [role]")].filter(visible).length };
});
assert.deepEqual(accessible.missing, [], JSON.stringify({ backend, accessible }));
results[backend] = { fileTabCount, accessible };
await context.close();
}
const report = { schemaVersion: 1, task: "M14-04H", operation: "CHROMIUM_KEYBOARD_ACCESSIBILITY_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", backends: ["main", "offscreen"], results, guarantees: { menuFocus: "RESTORED", operatorSearchFocus: "RESTORED", accessibleNames: "COMPLETE" }, execution: "DISABLED", nextTask: "M15-01A" };
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
if (process.env.UPDATE_M14_04H_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = {
parentManifest: path.join(root, "tests/golden/M14-04G/manifest.json"),
checker: path.join(root, "tools/web/check-chromium-accessibility.mjs"),
app: path.join(root, "web/app/src/app/App.tsx"),
css: path.join(root, "web/app/src/app/app-shell.css"),
referenceE2E: path.join(root, "web/tests/e2e/keyboard-accessibility.spec.ts"),
package: path.join(root, "web/package.json"),
report: reportPath,
};
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04H", parentTask: "M14-04G", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_KEYBOARD_ACCESSIBILITY_BOUNDARY", artifacts, nextTask: "M15-01A" }, null, 2)}\n`);
}
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04H", parentTask: "M14-04G", nextTask: "M15-01A" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`chromium-accessibility-ok backends=main,offscreen menuFocus=RESTORED operatorSearchFocus=RESTORED names=COMPLETE execution=DISABLED next=${manifest.nextTask}\n`);
} finally {
await browser?.close();
await new Promise((resolve) => server.close(resolve));
}

View File

@@ -18,28 +18,57 @@ let browser;
try {
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await (await browser.newContext({ viewport: { width: 960, height: 640 }, hasTouch: true, isMobile: true })).newPage();
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "load" });
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
const browserEvents = await page.locator("canvas.viewport-canvas").evaluate((element) => {
const events = [];
for (const [pointerId, pointerType] of [[2, "touch"], [4, "touch"], [9, "pen"]]) {
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId, pointerType, buttons: 1, pressure: pointerType === "pen" ? 0.6 : 0.5 }));
events.push({ pointerId, pointerType, phase: "down" });
}
element.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 2, pointerType: "touch" }));
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, pointerType: "pen" }));
events.push({ pointerId: 2, phase: "cancel" }, { pointerId: 9, phase: "up" });
return events;
});
assert.equal(browserEvents.length, 5);
const report = { schemaVersion: 1, task: "M14-04F", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", browserEvents, guarantees: { touchCancelMainCommit: 0, twoFingerNavigationRevision: 1, penMainCommit: 1 }, execution: "DISABLED", nextTask: "M14-04G" };
const runScenario = async (offscreen) => {
const context = await browser.newContext({ viewport: { width: 960, height: 640 }, hasTouch: true, isMobile: true });
const page = await context.newPage();
await page.goto(`http://127.0.0.1:${address.port}/?offscreen=${offscreen ? "1" : "0"}`, { waitUntil: "load" });
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
const result = await page.locator("canvas.viewport-canvas").evaluate((element) => {
const events = [];
const state = () => ({
kind: element.dataset.inputModalKind,
activePointerIds: element.dataset.inputModalActivePointerIds ?? "",
cancelled: element.dataset.inputModalCancelled,
navigationRevision: Number(element.dataset.inputModalNavigationRevision),
mainCommitCount: Number(element.dataset.inputModalMainCommitCount),
});
const dispatch = (type, pointerId, pointerType) => {
element.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId, pointerType, buttons: type === "pointerup" || type === "pointercancel" ? 0 : 1, pressure: pointerType === "pen" ? 0.6 : 0.5 }));
events.push({ pointerId, pointerType, phase: type.slice("pointer".length) });
};
dispatch("pointerdown", 2, "touch");
dispatch("pointerdown", 4, "touch");
const twoFinger = state();
dispatch("pointercancel", 2, "touch");
const touchCancel = state();
dispatch("pointerdown", 9, "pen");
dispatch("pointerup", 9, "pen");
dispatch("pointerup", 9, "pen");
dispatch("pointercancel", 9, "pen");
const penCommit = state();
return { events, twoFinger, touchCancel, penCommit };
});
await context.close();
return result;
};
const backends = { main: await runScenario(false), offscreen: await runScenario(true) };
for (const result of Object.values(backends)) {
assert.equal(result.events.length, 7);
assert.equal(result.twoFinger.navigationRevision, 1);
assert.equal(result.twoFinger.activePointerIds, "2,4");
assert.equal(result.twoFinger.mainCommitCount, 0);
assert.equal(result.touchCancel.cancelled, "1");
assert.equal(result.touchCancel.activePointerIds, "");
assert.equal(result.touchCancel.mainCommitCount, 0);
assert.equal(result.penCommit.mainCommitCount, 1);
}
const report = { schemaVersion: 1, task: "M14-04F", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", backends, guarantees: { touchCancelMainCommit: 0, twoFingerNavigationRevision: 1, penMainCommit: 1 }, execution: "DISABLED", nextTask: "M14-04G" };
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
if (process.env.UPDATE_M14_04F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-input-modal.mjs"), protocol: path.join(root, "web/protocol/input-modal.ts"), unit: path.join(root, "web/tests/unit/input-modal.test.mjs"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04F", parentTask: "M14-04E", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", artifacts, nextTask: "M14-04G" }, null, 2)}\n`); }
if (process.env.UPDATE_M14_04F_REPORT === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); const artifactPaths = { parentManifest: path.join(root, "tests/golden/M14-04E/manifest.json"), checker: path.join(root, "tools/web/check-chromium-input-modal.mjs"), protocol: path.join(root, "web/protocol/input-modal.ts"), unit: path.join(root, "web/tests/unit/input-modal.test.mjs"), mainViewport: path.join(root, "web/app/src/three-adapter/viewport.ts"), offscreenViewport: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"), package: path.join(root, "web/package.json"), report: reportPath }; const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }])); fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04F", parentTask: "M14-04E", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_INPUT_MODAL_BOUNDARY", artifacts, nextTask: "M14-04G" }, null, 2)}\n`); }
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04F", parentTask: "M14-04E", nextTask: "M14-04G" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`chromium-input-modal-ok touchCancel=0 twoFingerRevision=1 penCommit=1 execution=DISABLED next=${manifest.nextTask}\n`);
process.stdout.write(`chromium-input-modal-ok backends=main,offscreen touchCancel=0 twoFingerRevision=1 penCommit=1 execution=DISABLED next=${manifest.nextTask}\n`);
} finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); }

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const distRoot = path.join(root, "web/dist");
const reportPath = path.join(root, "tests/golden/M14-04G/chromium-layout-report.json");
const manifestPath = path.join(root, "tests/golden/M14-04G/manifest.json");
const mime = new Map([[".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".json", "application/json"], [".wasm", "application/wasm"]]);
const viewports = [
{ name: "desktop-1440x900", width: 1440, height: 900 },
{ name: "desktop-1280x720", width: 1280, height: 720 },
{ name: "tablet-834x1112", width: 834, height: 1112 },
{ name: "phone-390x844", width: 390, height: 844 },
];
const server = http.createServer((request, response) => {
const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\//u, "");
const file = path.resolve(distRoot, relative);
if (!file.startsWith(`${distRoot}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404);
response.end("not found");
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", mime.get(path.extname(file)) ?? "application/octet-stream");
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
response.setHeader("Cross-Origin-Resource-Policy", "same-origin");
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
fs.createReadStream(file).pipe(response);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
let browser;
try {
const playwright = await import(pathToFileURL(path.join(root, "web/node_modules/playwright/index.mjs")).href);
browser = await playwright.chromium.launch({ headless: true, args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
const results = [];
for (const backend of ["main", "offscreen"]) {
for (const deviceScaleFactor of [1, 2]) {
for (const viewport of viewports) {
const context = await browser.newContext({ viewport, deviceScaleFactor, hasTouch: viewport.width < 900, isMobile: viewport.width < 600 });
const page = await context.newPage();
await page.goto(`http://127.0.0.1:${address.port}/?offscreen=${backend === "offscreen" ? "1" : "0"}`, { waitUntil: "load" });
await page.waitForFunction(() => document.querySelector("[data-testid=engine-status]")?.textContent === "Engine: ready, open a .blend file", undefined, { timeout: 20_000 });
const measurement = await page.evaluate(() => {
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
};
const rect = (element) => {
const value = element.getBoundingClientRect();
return { left: value.left, top: value.top, right: value.right, bottom: value.bottom, width: value.width, height: value.height };
};
const selectors = [".topbar", ".workspace-toolbar", ".storage-budget-panel", ".workspace-grid", ".viewport-area", ".timeline-area", ".status-bar"];
const bands = Object.fromEntries(selectors.map((selector) => {
const element = document.querySelector(selector);
return [selector, element && visible(element) ? rect(element) : null];
}));
const bandViolations = [];
for (const selector of [".topbar", ".workspace-toolbar", ".storage-budget-panel", ".status-bar"]) {
const band = document.querySelector(selector);
if (!band || !visible(band)) continue;
const bandRect = band.getBoundingClientRect();
const horizontalConstrained = !["auto", "scroll", "hidden"].includes(getComputedStyle(band).overflowX);
for (const control of band.querySelectorAll("button, input, select, output, span")) {
if (!visible(control)) continue;
const controlRect = control.getBoundingClientRect();
if (controlRect.top < bandRect.top - 1 || controlRect.bottom > bandRect.bottom + 1 ||
(horizontalConstrained && (controlRect.left < bandRect.left - 1 || controlRect.right > bandRect.right + 1))) {
bandViolations.push(`${selector}:${control.textContent?.trim() || control.getAttribute("aria-label") || control.tagName}`);
}
}
}
const textOverflow = [...document.querySelectorAll(".topbar button, .topbar select, .workspace-toolbar button, .workspace-toolbar > span, .storage-budget-panel span, .storage-budget-panel output, .editor-header button")]
.filter(visible)
.filter((element) => element.scrollWidth > element.clientWidth + 1)
.map((element) => element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName);
const root = document.documentElement;
const app = document.querySelector(".blender-app");
return {
bands,
bandViolations,
textOverflow,
rootScrollWidth: root.scrollWidth,
rootClientWidth: root.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: window.innerWidth,
appRect: app ? rect(app) : null,
rendererBackend: document.querySelector("canvas.viewport-canvas")?.dataset.rendererBackend ?? "MISSING",
};
});
assert.equal(measurement.rendererBackend, backend === "offscreen" ? "offscreen-worker" : "webgl-pbr");
assert.deepEqual(measurement.bandViolations, [], JSON.stringify({ backend, deviceScaleFactor, viewport, measurement }));
assert.deepEqual(measurement.textOverflow, [], JSON.stringify({ backend, deviceScaleFactor, viewport, measurement }));
assert.ok(measurement.rootScrollWidth <= measurement.viewportWidth + 1, JSON.stringify({ backend, deviceScaleFactor, viewport, measurement }));
assert.ok(measurement.bodyScrollWidth <= measurement.viewportWidth + 1, JSON.stringify({ backend, deviceScaleFactor, viewport, measurement }));
assert.ok(measurement.appRect && measurement.appRect.right <= viewport.width + 1 && measurement.appRect.bottom <= viewport.height + 1, JSON.stringify({ backend, deviceScaleFactor, viewport, measurement }));
results.push({ backend, deviceScaleFactor, viewport, measurement });
await context.close();
}
}
}
const report = { schemaVersion: 1, task: "M14-04G", operation: "CHROMIUM_RESPONSIVE_LAYOUT_BOUNDARY", runtime: "PLAYWRIGHT_CHROMIUM", viewports, deviceScaleFactors: [1, 2], backends: ["main", "offscreen"], resultCount: results.length, results, execution: "DISABLED", nextTask: "M14-04H" };
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
if (process.env.UPDATE_M14_04G_REPORT === "1") {
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const artifactPaths = {
parentManifest: path.join(root, "tests/golden/M14-04F/manifest.json"),
checker: path.join(root, "tools/web/check-chromium-layout.mjs"),
app: path.join(root, "web/app/src/app/App.tsx"),
css: path.join(root, "web/app/src/app/app-shell.css"),
mainViewport: path.join(root, "web/app/src/three-adapter/viewport.ts"),
offscreenViewport: path.join(root, "web/app/src/three-adapter/offscreen-viewport.ts"),
referenceE2E: path.join(root, "web/tests/e2e/responsive-layout.spec.ts"),
package: path.join(root, "web/package.json"),
report: reportPath,
};
const artifacts = Object.fromEntries(Object.entries(artifactPaths).map(([name, file]) => [name, { path: relative(file), sha256: sha256(file) }]));
fs.writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, task: "M14-04G", parentTask: "M14-04F", enablingTask: false, parityStateChange: false, runtime: "PLAYWRIGHT_CHROMIUM", operation: "CHROMIUM_RESPONSIVE_LAYOUT_BOUNDARY", artifacts, nextTask: "M14-04H" }, null, 2)}\n`);
}
assert.deepEqual(JSON.parse(fs.readFileSync(reportPath, "utf8")), report);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
assert.deepEqual({ task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { task: "M14-04G", parentTask: "M14-04F", nextTask: "M14-04H" });
for (const artifact of Object.values(manifest.artifacts)) assert.equal(sha256(path.join(root, artifact.path)), artifact.sha256, artifact.path);
process.stdout.write(`chromium-layout-ok backends=main,offscreen viewports=4 dpr=1,2 results=${results.length} execution=DISABLED next=${manifest.nextTask}\n`);
} finally {
await browser?.close();
await new Promise((resolve) => server.close(resolve));
}

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
import hashlib, json, os, pathlib, sys, tempfile
import bpy
def report():
obj = bpy.data.objects.get("WebGapClothObject")
if obj is None: raise RuntimeError("WebGapClothObject is missing")
modifiers = [m for m in obj.modifiers if m.type == "CLOTH"]
if len(modifiers) != 1: raise RuntimeError(f"expected one CLOTH modifier, found {len(modifiers)}")
m = modifiers[0]
return {"meshId": "mesh:" + obj.data.name, "modifier": {"name": m.name, "type": m.type, "typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[m.type].value, "showViewport": bool(m.show_viewport), "showRender": bool(m.show_render), "showEditMode": bool(m.show_in_editmode), "showOnCage": bool(m.show_on_cage)}}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-cloth-modifier-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(v).resolve() for v in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report()
fd, temporary = tempfile.mkstemp(prefix="m16-cloth-modifier-reopen-", suffix=".blend", dir=fixture.parent); os.close(fd)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"cloth modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00036", "operation": "CLOTH_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"cloth-modifier-desktop-ok mesh={after['meshId']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
return [{"name": c.name, "children": sorted(ch.name for ch in c.children), "objects": sorted(o.name for o in c.objects)} for c in sorted(bpy.data.collections, key=lambda value: value.name) if c.name.startswith("WebGap")]
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-collection-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-collection-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"collection save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00005", "operation": "COLLECTION_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "collections": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"collection-desktop-ok collections={len(after)} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
import hashlib,json,os,pathlib,sys,tempfile
import bpy
def report():
o=bpy.data.objects.get("WebGapCollisionObject")
if o is None: raise RuntimeError("WebGapCollisionObject is missing")
ms=[m for m in o.modifiers if m.type=="COLLISION"]
if len(ms)!=1: raise RuntimeError(f"expected one COLLISION modifier, found {len(ms)}")
m=ms[0]
return {"meshId":"mesh:"+o.data.name,"modifier":{"name":m.name,"type":m.type,"typeCode":bpy.types.Modifier.bl_rna.properties["type"].enum_items[m.type].value,"showViewport":bool(m.show_viewport),"showRender":bool(m.show_render),"showEditMode":bool(m.show_in_editmode),"showOnCage":bool(m.show_on_cage)}}
def main():
a=sys.argv[sys.argv.index("--")+1:]
if len(a)!=2: raise SystemExit("usage: blender -b --python check-collision-modifier-desktop.py -- FIXTURE REPORT")
f,out=(pathlib.Path(x).resolve() for x in a); bpy.ops.wm.open_mainfile(filepath=str(f),load_ui=False); before=report(); fd,t=tempfile.mkstemp(prefix="m16-collision-reopen-",suffix=".blend",dir=f.parent); os.close(fd)
try: bpy.ops.wm.save_as_mainfile(filepath=t,check_existing=False,compress=True); bpy.ops.wm.open_mainfile(filepath=t,load_ui=False); after=report()
finally: pathlib.Path(t).unlink(missing_ok=True)
if before!=after: raise RuntimeError(f"collision modifier save/reopen drift: {before} != {after}")
v={"schemaVersion":1,"task":"M16-GAP-00037","operation":"COLLISION_MODIFIER_DESKTOP","fixture":str(f),"fixtureSha256":hashlib.sha256(f.read_bytes()).hexdigest(),"modifier":after,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string}; out.parent.mkdir(parents=True,exist_ok=True); out.write_text(json.dumps(v,indent=2,sort_keys=True)+"\n"); print(f"collision-modifier-desktop-ok mesh={after['meshId']} saveReopen=exact")
if __name__=="__main__": main()

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { buildTaskContext, root, verifyTaskIndex } from "./task-context-lib.mjs";
import { validateCatalogRecords, validateContextBundle } from "./context-governance.mjs";
const requested = process.argv.indexOf("--task");
const task = requested >= 0 ? process.argv[requested + 1] : undefined;
const bundle = buildTaskContext(task);
const current = task === undefined;
const { report, violations } = validateContextBundle(bundle, { current });
const index = verifyTaskIndex();
if (index) {
const catalogPath = path.join(root, index.catalog.path);
violations.push(...validateCatalogRecords(catalogPath, index));
const catalogHandle = fs.openSync(catalogPath, "r");
let active;
try {
active = Object.entries(index.entries).filter(([id]) => {
const metadata = index.entries[id];
const buffer = Buffer.alloc(metadata.length);
fs.readSync(catalogHandle, buffer, 0, metadata.length, metadata.offset);
return JSON.parse(buffer.toString("utf8")).state === "active";
});
} finally {
fs.closeSync(catalogHandle);
}
if (active.length !== 1) violations.push({ code: "INDEX_ACTIVE_TASK_COUNT", detail: `count=${active.length}` });
if (current && active[0]?.[0] !== bundle.context.task) violations.push({ code: "INDEX_ACTIVE_TASK_MISMATCH", detail: `${active[0]?.[0] ?? "NONE"}!=${bundle.context.task}` });
}
assert.equal(violations.length, 0, `context governance violations: ${JSON.stringify(violations)}`);
process.stdout.write(`context-governance-ok task=${bundle.context.task} totalTokens=${report.totalTokens} inputs=${bundle.context.inputPaths.length} commands=${bundle.context.commands.length}\n`);

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
curve = bpy.data.curves.get("WebGapCurve")
if curve is None: raise RuntimeError("WebGapCurve is missing")
return {"name": curve.name, "dimensions": curve.dimensions, "resolution": curve.resolution_u, "splines": [{"type": s.type, "count": len(s.bezier_points) if s.type == "BEZIER" else len(s.points), "cyclic": s.use_cyclic_u} for s in curve.splines], "points": [[round(float(v), 6) for v in point.co] for s in curve.splines if s.type == "BEZIER" for point in s.bezier_points]}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-curve-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-curve-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"curve save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00006", "operation": "CURVE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "curve": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"curve-desktop-ok splines={len(after['splines'])} points={len(after['points'])} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
curves = bpy.data.hair_curves.get("WebGapCurves") or bpy.data.curves.get("WebGapCurves")
if curves is None: raise RuntimeError("WebGapCurves is missing")
geometry = getattr(curves, "curves", None)
return {"name": curves.name, "pointCount": len(curves.attributes.get("position").data) if curves.attributes.get("position") else 0, "curveCount": len(geometry) if geometry is not None else 0}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-curves-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-curves-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"Curves save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00007", "operation": "CURVES_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "curves": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"curves-desktop-ok name={after['name']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
style = bpy.data.linestyles.get("WebGapLineStyle")
if style is None: raise RuntimeError("WebGapLineStyle is missing")
return {"name": style.name, "color": [round(float(v), 6) for v in style.color], "alpha": round(float(style.alpha), 6), "thickness": round(float(style.thickness), 6), "chaining": style.use_chaining, "dashed": style.use_dashed_line, "modifierCounts": {"color": len(style.color_modifiers), "alpha": len(style.alpha_modifiers), "thickness": len(style.thickness_modifiers), "geometry": len(style.geometry_modifiers)}}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-freestyle-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-freestyle-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"Freestyle save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00008", "operation": "FREESTYLE_LINE_STYLE_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "lineStyle": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"freestyle-desktop-ok name={after['name']} thickness={after['thickness']} saveReopen=exact")
if __name__ == "__main__": main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report(task, modifier_type, object_name):
obj = bpy.data.objects.get(object_name)
if obj is None:
raise RuntimeError(f"{object_name} is missing")
modifiers = [modifier for modifier in obj.modifiers if modifier.type == modifier_type]
if len(modifiers) != 1:
raise RuntimeError(f"expected one {modifier_type} modifier, found {len(modifiers)}")
modifier = modifiers[0]
value = {
"task": task,
"objectId": "object:" + obj.name,
"modifier": {
"name": modifier.name,
"type": modifier.type,
"typeCode": bpy.types.Modifier.bl_rna.properties["type"].enum_items[modifier.type].value,
"showViewport": bool(modifier.show_viewport),
"showRender": bool(modifier.show_render),
"showEditMode": bool(modifier.show_in_editmode),
"showOnCage": bool(modifier.show_on_cage),
},
}
if obj.type == "MESH":
value["meshId"] = "mesh:" + obj.data.name
if modifier_type == "DECIMATE":
value["modifier"]["ratio"] = float(modifier.ratio)
value["modifier"]["iterations"] = int(modifier.iterations)
value["modifier"]["angleLimit"] = float(modifier.angle_limit)
value["modifier"]["decimateType"] = modifier.decimate_type
value["modifier"]["decimateMode"] = {"COLLAPSE": 0, "DISSOLVE": 1, "UNSUBDIV": 2}[modifier.decimate_type]
if modifier_type == "DISPLACE":
value["modifier"]["direction"] = {"X": 0, "Y": 1, "Z": 2, "NORMAL": 3, "CUSTOM_NORMAL": 4}[modifier.direction]
value["modifier"]["strength"] = float(modifier.strength)
value["modifier"]["midLevel"] = float(modifier.mid_level)
value["modifier"]["space"] = {"GLOBAL": 0, "LOCAL": 1, "NORMAL": 2}.get(modifier.texture_coords, 0)
value["modifier"]["textureMapping"] = {"GLOBAL": 1, "LOCAL": 2, "OBJECT": 3, "UV": 4}.get(modifier.texture_coords, 1)
if modifier_type == "EDGE_SPLIT":
value["modifier"]["splitAngle"] = float(modifier.split_angle)
return value
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 5:
raise SystemExit("usage: blender -b --python check-generic-modifier-desktop.py -- FIXTURE REPORT TASK TYPE OBJECT")
fixture = pathlib.Path(arguments[0]).resolve()
output = pathlib.Path(arguments[1]).resolve()
task, modifier_type, object_name = arguments[2:]
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report(task, modifier_type, object_name)
descriptor, temporary = tempfile.mkstemp(prefix="m16-generic-modifier-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report(task, modifier_type, object_name)
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"modifier save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": task, "operation": modifier_type + "_MODIFIER_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "modifier": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"generic-modifier-desktop-ok task={task} type={modifier_type} mesh={after['meshId']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import os
import bpy
def report():
data = bpy.data.grease_pencils.get("GreasePencilData")
if data is None: raise RuntimeError("GreasePencilData is missing")
layers = []
for layer in data.layers:
frames = sorted(int(frame.frame_number) for frame in layer.frames)
layers.append({"name": layer.name, "frames": frames})
return {"name": data.name, "layers": layers}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --python check-grease-pencil-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = report(); descriptor, temporary = tempfile.mkstemp(prefix="m16-gp-reopen-", suffix=".blend"); os.close(descriptor)
try: bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"Grease Pencil save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00009", "operation": "GREASE_PENCIL_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "greasePencil": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"grease-pencil-desktop-ok layers={len(after['layers'])} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(values):
return [round(float(value), 6) for value in values]
def lattice_report():
lattice = bpy.data.lattices.get("WebGapLattice")
if lattice is None:
raise RuntimeError("WebGapLattice is missing")
return {
"id": "lattice:WebGapLattice",
"name": lattice.name,
"dimensions": [lattice.points_u, lattice.points_v, lattice.points_w],
"pointCount": len(lattice.points),
"interpolation": [
lattice.interpolation_type_u,
lattice.interpolation_type_v,
lattice.interpolation_type_w,
],
"useOutside": bool(lattice.use_outside),
"activePoint": -1,
"vertexGroup": lattice.vertex_group,
"points": [
{
"coDeform": rounded(point.co_deform),
"weight": round(float(point.weight_softbody), 6),
"selected": bool(point.select),
}
for point in lattice.points
],
}
def main() -> None:
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-lattice-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = lattice_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-lattice-reopen-", suffix=".blend")
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = lattice_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"lattice save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00010",
"operation": "LATTICE_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"lattice": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"lattice-desktop-ok points={after['pointCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def library_report():
libraries = list(bpy.data.libraries)
if len(libraries) != 1:
raise RuntimeError(f"expected one library, found {len(libraries)}")
library = libraries[0]
return {
"id": "library:" + library.name,
"name": library.name,
"sourcePath": library.filepath,
"packed": library.packed_file is not None,
"readOnly": True,
"dependencyIds": [],
}
def main() -> None:
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-library-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = library_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-library-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = library_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"library save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00011",
"operation": "LIBRARY_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"library": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"library-desktop-ok id={after['id']} sourcePath={after['sourcePath']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def light_report():
light = bpy.data.lights.get("WebGapLight")
if light is None:
raise RuntimeError("WebGapLight is missing")
return {
"id": "light:WebGapLight",
"name": light.name,
"lightType": {"POINT": 0, "SUN": 1, "SPOT": 2, "AREA": 4}[light.type],
"color": [rounded(value) for value in light.color],
"energy": rounded(light.energy),
"exposure": rounded(light.exposure),
"temperature": rounded(light.temperature),
"useTemperature": bool(light.use_temperature),
"castsShadow": bool(light.use_shadow),
"radius": rounded(light.shadow_soft_size),
"spotAngle": rounded(getattr(light, "spot_size", 0.785398)),
"spotBlend": rounded(getattr(light, "spot_blend", 0.15)),
"areaShape": {"POINT": 0, "DISK": 0, "RECTANGLE": 1, "ELLIPSE": 2}[light.shape] if light.type == "AREA" else 0,
"areaSize": rounded(light.size),
"areaSizeY": rounded(light.size_y),
"areaSpread": rounded(light.spread),
"sunAngle": rounded(getattr(light, "angle", 0.00918)),
}
def main() -> None:
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-light-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = light_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-light-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = light_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"light save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00012",
"operation": "LIGHT_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"light": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"light-desktop-ok id={after['id']} type={after['lightType']} energy={after['energy']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def socket_value(node, name, fallback):
socket = node.inputs.get(name)
if socket is None:
return fallback
value = socket.default_value
if hasattr(value, "__len__"):
return [rounded(item) for item in value]
return rounded(value)
def material_report():
material = bpy.data.materials.get("WebGapMaterial")
if material is None or not material.use_nodes:
raise RuntimeError("WebGapMaterial node tree is missing")
nodes = material.node_tree.nodes
principled = nodes.get("Principled BSDF")
if principled is None:
raise RuntimeError("WebGapMaterial Principled node is missing")
return {
"id": "material:WebGapMaterial",
"name": material.name,
"baseColor": socket_value(principled, "Base Color", [0.8, 0.8, 0.8, 1.0]),
"roughness": socket_value(principled, "Roughness", 0.4),
"metallic": socket_value(principled, "Metallic", 0.0),
"emissionColor": socket_value(principled, "Emission Color", [0.0, 0.0, 0.0, 1.0]),
"alpha": socket_value(principled, "Alpha", 1.0),
"ior": socket_value(principled, "IOR", 1.45),
"specularIORLevel": socket_value(principled, "Specular IOR Level", 0.5),
"transmissionWeight": socket_value(principled, "Transmission Weight", 0.0),
"coatWeight": socket_value(principled, "Coat Weight", 0.0),
"coatRoughness": socket_value(principled, "Coat Roughness", 0.03),
"emissionStrength": socket_value(principled, "Emission Strength", 1.0),
"nodeTypes": sorted(node.bl_idname for node in nodes),
"linkCount": len(material.node_tree.links),
}
def main() -> None:
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-material-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = material_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-material-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = material_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"material save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00013",
"operation": "MATERIAL_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"material": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"material-desktop-ok id={after['id']} nodes={len(after['nodeTypes'])} links={after['linkCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def mesh_report():
mesh = bpy.data.meshes.get("WebGapMesh")
if mesh is None:
raise RuntimeError("WebGapMesh is missing")
mesh.calc_loop_triangles()
positions = [rounded(value) for vertex in mesh.vertices for value in vertex.co]
indices = [index for triangle in mesh.loop_triangles for index in triangle.vertices]
uv_layer = mesh.uv_layers.get("WebGapUV")
uvs = [rounded(value) for loop in uv_layer.data for value in loop.uv] if uv_layer else []
return {
"id": "mesh:WebGapMesh",
"name": mesh.name,
"vertexCount": len(mesh.vertices),
"edgeCount": len(mesh.edges),
"faceCount": len(mesh.polygons),
"cornerCount": len(mesh.loops),
"triangleCount": len(mesh.loop_triangles),
"positions": positions,
"indices": indices,
"uvLayers": [layer.name for layer in mesh.uv_layers],
"uvs": uvs,
}
def main() -> None:
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-mesh-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = mesh_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-mesh-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = mesh_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"mesh save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00014",
"operation": "MESH_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"mesh": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"mesh-desktop-ok id={after['id']} vertices={after['vertexCount']} triangles={after['triangleCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def report():
data = bpy.data.metaballs.get("WebGapMetaBall")
if data is None:
raise RuntimeError("WebGapMetaBall is missing")
return {
"id": "metaball:WebGapMetaBall",
"name": data.name,
"resolution": rounded(data.resolution),
"renderResolution": rounded(data.render_resolution),
"pointCount": len(data.elements),
"elements": [{"position": [rounded(v) for v in item.co], "radius": rounded(item.radius), "scale": [rounded(item.size_x), rounded(item.size_y), rounded(item.size_z)]} for item in data.elements],
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-metaball-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-metaball-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"metaball save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00015", "operation": "METABALL_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "metaball": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"metaball-desktop-ok id={after['id']} points={after['pointCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def graph_report():
material = bpy.data.materials.get("WebGapNodeTreeMaterial")
if material is None or not material.use_nodes:
raise RuntimeError("WebGapNodeTreeMaterial is missing")
tree = material.node_tree
return {
"id": "nodetree:WebGapNodeTreeMaterial",
"name": tree.name,
"nodeTypes": sorted(node.bl_idname for node in tree.nodes),
"nodeNames": sorted(node.name for node in tree.nodes),
"linkCount": len(tree.links),
"links": sorted([[link.from_node.bl_idname, link.from_socket.name, link.to_node.bl_idname, link.to_socket.name] for link in tree.links]),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-nodetree-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = graph_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-nodetree-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = graph_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"node tree save/reopen drift: {before} != {after}")
report = {"schemaVersion": 1, "task": "M16-GAP-00016", "operation": "NODETREE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "nodeTree": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"nodetree-desktop-ok id={after['id']} nodes={len(after['nodeTypes'])} links={after['linkCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def report():
obj = bpy.data.objects.get("WebGapObject")
if obj is None:
raise RuntimeError("WebGapObject is missing")
return {"id": "object:WebGapObject", "name": obj.name, "type": obj.type, "visible": not obj.hide_get(), "selectable": not obj.hide_select, "location": [rounded(v) for v in obj.location], "rotationEuler": [rounded(v) for v in obj.rotation_euler], "scale": [rounded(v) for v in obj.scale], "parent": obj.parent.name if obj.parent else None}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-object-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-object-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"object save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00017", "operation": "OBJECT_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "object": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"object-desktop-ok id={after['id']} type={after['type']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def settings_report():
settings = bpy.data.particles.get("WebGapParticleSettings")
if settings is None:
raise RuntimeError("WebGapParticleSettings is missing")
return {
"id": "particle-settings:" + settings.name,
"name": settings.name,
"type": int({"EMITTER": 0, "HAIR": 1}.get(settings.type, -1)),
"from": int({"FACE": 0, "VERT": 1, "VOLUME": 2}.get(settings.emit_from, -1)),
"distribution": int({"JIT": 0, "RAND": 1, "GRID": 2}.get(settings.distribution, -1)),
"physicsType": int({"NEWTON": 0, "KEYED": 1, "BOIDS": 2, "NO": 3}.get(settings.physics_type, -1)),
"totalParticles": int(settings.count),
"start": rounded(settings.frame_start),
"end": rounded(settings.frame_end),
"lifetime": rounded(settings.lifetime),
"size": rounded(settings.particle_size),
"drawSize": rounded(settings.display_size),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-particle-settings-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = settings_report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-particle-settings-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = settings_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"particle settings save/reopen drift: {before} != {after}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00018",
"operation": "PARTICLE_SETTINGS_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"particleSettings": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"particle-settings-desktop-ok id={after['id']} count={after['totalParticles']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def rounded(value):
return round(float(value), 6)
def report():
data = bpy.data.pointclouds.get("WebGapPointCloud")
if data is None:
raise RuntimeError("WebGapPointCloud is missing")
positions = [0.0] * (len(data.points) * 3)
data.points.foreach_get("co", positions)
radius = data.attributes.get("radius")
radii = [0.0] * len(data.points)
if radius is not None:
radius.data.foreach_get("value", radii)
return {
"id": "pointcloud:" + data.name,
"name": data.name,
"pointCount": len(data.points),
"controlPoints": [rounded(value) for value in positions],
"radii": [rounded(value) for value in radii],
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-pointcloud-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-pointcloud-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"point cloud save/reopen drift: {before} != {after}")
value = {
"schemaVersion": 1,
"task": "M16-GAP-00019",
"operation": "POINT_CLOUD_DATABLOCK_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"pointCloud": after,
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"pointcloud-desktop-ok id={after['id']} points={after['pointCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
scene = bpy.data.scenes.get("WebGapScene")
if scene is None:
raise RuntimeError("WebGapScene is missing")
return {
"id": "scene:" + scene.name,
"name": scene.name,
"frameCurrent": int(scene.frame_current),
"frameStart": int(scene.frame_start),
"frameEnd": int(scene.frame_end),
"fps": round(float(scene.render.fps), 6),
"fpsBase": round(float(scene.render.fps_base), 6),
"unitSystem": {"NONE": 0, "METRIC": 1, "IMPERIAL": 2}.get(scene.unit_settings.system, -1),
"unitScale": round(float(scene.unit_settings.scale_length), 6),
"renderEngine": scene.render.engine,
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-scene-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-scene-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"scene save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00020", "operation": "SCENE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "scene": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"scene-desktop-ok id={after['id']} frames={after['frameStart']}-{after['frameEnd']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
screen = bpy.data.screens.get("WebGapScreen")
if screen is None:
raise RuntimeError("WebGapScreen is missing")
return {
"id": "screen:" + screen.name,
"name": screen.name,
"areaCount": len(screen.areas),
"editors": sorted(area.type for area in screen.areas),
"regionCount": sum(len(area.regions) for area in screen.areas),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-screen-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=True)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-screen-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=True)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"screen save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00021", "operation": "SCREEN_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "screen": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"screen-desktop-ok id={after['id']} areas={after['areaCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
sound = bpy.data.sounds.get("WebGapSound")
if sound is None:
raise RuntimeError("WebGapSound is missing")
return {
"id": "sound:" + sound.name,
"name": sound.name,
"sourcePath": sound.filepath,
"packed": sound.packed_file is not None,
"volume": 0.0,
"pitch": 0.0,
"audioChannels": {"MONO": 1, "STEREO": 2, "STEREO_LFE": 3, "SURROUND4": 4, "SURROUND5_1": 6, "SURROUND7_1": 8}.get(sound.channels, 0),
"sampleRate": int(sound.samplerate),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-sound-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-sound-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"sound save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00022", "operation": "SOUND_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "sound": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"sound-desktop-ok id={after['id']} volume={after['volume']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
speaker = bpy.data.speakers.get("WebGapSpeaker")
if speaker is None:
raise RuntimeError("WebGapSpeaker is missing")
return {
"id": "speaker:" + speaker.name,
"name": speaker.name,
"soundId": "sound:" + speaker.sound.name if speaker.sound else None,
"volumeMax": float(speaker.volume_max),
"volumeMin": float(speaker.volume_min),
"distanceMax": float(speaker.distance_max),
"distanceReference": float(speaker.distance_reference),
"attenuation": float(speaker.attenuation),
"coneAngleOuter": float(speaker.cone_angle_outer),
"coneAngleInner": float(speaker.cone_angle_inner),
"coneVolumeOuter": float(speaker.cone_volume_outer),
"volume": float(speaker.volume),
"pitch": float(speaker.pitch),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-speaker-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-speaker-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"speaker save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00023", "operation": "SPEAKER_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "speaker": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"speaker-desktop-ok id={after['id']} volume={after['volume']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { buildTaskContext, contextSizeReport, CONTEXT_LIMITS, root, verifyTaskIndex } from "./task-context-lib.mjs";
import { validateContextBundle } from "./context-governance.mjs";
const taskArg = process.argv.indexOf("--task");
const task = taskArg >= 0 ? process.argv[taskArg + 1] : undefined;
const write = process.argv.includes("--write");
verifyTaskIndex();
const bundle = buildTaskContext(task);
const report = contextSizeReport(bundle);
const { context } = bundle;
const governance = validateContextBundle(bundle);
assert.equal(report.withinBudget, true, `task context exceeds budget: ${JSON.stringify(report)}`);
assert.deepEqual(governance.violations, [], `task context governance failed: ${JSON.stringify(governance.violations)}`);
if (task) assert.equal(context.task, task);
assert.ok(context.parentTask);
assert.ok(context.commands.length > 0, "task must expose at least one focused command");
assert.ok(context.sourceDocuments.parentManifest.endsWith("manifest.json"));
assert.ok(!context.sourceDocuments.taskCard.includes("CURRENT_EXECUTION_PLAN"));
if (bundle.sources.taskSource && context.task === bundle.sources.queue.currentTask) {
for (const heading of ["## 目标", "## 输入与范围", "## 验收", "## 交付与回滚"]) {
assert.ok(bundle.sources.taskSource.includes(heading), `current task card must include ${heading}`);
}
assert.match(bundle.sources.taskSource, /malformed|unsupported|取消|超限|负例|失败/iu, "current task card must name one failure boundary");
}
if (context.task === bundle.sources.queue.currentTask) {
assert.ok(bundle.sources.taskSource, "current task must have a compact task card; do not fall back to the full plan");
assert.ok(bundle.sources.taskSource.length <= CONTEXT_LIMITS.taskBytes, "current task card exceeds the 8 KiB context budget");
}
const outputDir = path.join(root, "tests/golden", context.task);
const outputPath = path.join(outputDir, "task-context.json");
if (write) {
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify({ ...context, size: report }, null, 2)}\n`);
}
process.stdout.write(`task-context-ok task=${context.task} parent=${context.parentTask} totalTokens=${report.totalTokens} taskBytes=${context.budgets.taskBytes} next=${context.nextTask ?? "NONE"}${write ? ` output=${path.relative(root, outputPath)}` : ""}\n`);

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const indexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const resolve = (value) => path.join(root, value);
const planPath = resolve(index.source.path);
const catalogPath = resolve(index.catalog.path);
assert.equal(index.schemaVersion, 1);
assert.equal(sha256File(planPath), index.source.sha256, "task index source plan is stale; regenerate it");
assert.equal(sha256File(catalogPath), index.catalog.sha256, "task catalog hash mismatch");
assert.equal(Object.keys(index.entries).length, index.taskCount);
const catalog = fs.openSync(catalogPath, "r");
let activeCount = 0;
try {
const bytes = fs.statSync(catalogPath).size;
let previousEnd = 0;
for (const [id, entry] of Object.entries(index.entries)) {
assert.equal(entry.offset, previousEnd, `${id} offset is not contiguous`);
assert.ok(entry.length > 0, `${id} has an empty record`);
assert.ok(entry.offset + entry.length <= bytes, `${id} points past catalog`);
const buffer = Buffer.alloc(entry.length);
fs.readSync(catalog, buffer, 0, entry.length, entry.offset);
const record = JSON.parse(buffer.toString("utf8"));
assert.equal(record.id, id);
assert.ok(record.gapId && record.ownerFamily && record.targetImplementationClass);
if (record.state === "active") activeCount += 1;
previousEnd += entry.length;
}
assert.equal(previousEnd, bytes, "catalog has unindexed bytes");
} finally {
fs.closeSync(catalog);
}
assert.ok(index.entries[index.activeTask]?.previous, "active task must have a parent");
assert.equal(activeCount, 1, "task index must contain exactly one active task");
process.stdout.write(`task-index-ok tasks=${index.taskCount} active=${index.activeTask} catalog=${index.catalog.path}\n`);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
text = bpy.data.texts.get("WebGapText")
if text is None:
raise RuntimeError("WebGapText is missing")
source = text.as_string()
return {
"id": "text:" + text.name,
"name": text.name,
"source": source,
"sourceSha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
"byteLength": len(source.encode("utf-8")),
"lineCount": len(source.splitlines()) + (1 if source.endswith("\n") else 0),
"sourcePath": text.filepath,
"internal": not bool(text.filepath),
"isDirty": text.is_dirty,
"useModule": text.use_module,
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-text-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-text-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"text save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00024", "operation": "TEXT_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "text": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"text-desktop-ok id={after['id']} bytes={after['byteLength']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
texture = bpy.data.textures.get("WebGapTexture")
if texture is None:
raise RuntimeError("WebGapTexture is missing")
return {
"id": "texture:" + texture.name,
"name": texture.name,
"type": {"NONE": 0, "CLOUDS": 1, "WOOD": 2, "MARBLE": 3, "MAGIC": 4, "BLEND": 5, "STUCCI": 6, "NOISE": 7, "IMAGE": 8, "MUSGRAVE": 9, "VORONOI": 10, "DISTORTED_NOISE": 11}.get(texture.type, 0),
"noiseScale": float(texture.noise_scale),
"noiseDepth": int(texture.noise_depth),
"intensity": float(texture.intensity),
"contrast": float(texture.contrast),
"saturation": float(texture.saturation),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-texture-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-texture-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"texture save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00025", "operation": "TEXTURE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "texture": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"texture-desktop-ok id={after['id']} type={after['type']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
volume = bpy.data.volumes.get("WebGapVolume")
if volume is None:
raise RuntimeError("WebGapVolume is missing")
return {"id": "volume:" + volume.name, "name": volume.name, "sourcePath": volume.filepath, "displayDensity": float(volume.display.density), "interpolation": "NEAREST" if volume.display.interpolation_method == "CLOSEST" else "LINEAR", "stepSize": float(volume.render.step_size), "velocityGrid": volume.velocity_grid, "velocityScale": 1.0}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-volume-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-volume-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"volume save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00026", "operation": "VOLUME_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "volume": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"volume-desktop-ok id={after['id']} density={after['displayDensity']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def report():
manager = bpy.context.window_manager
return {"id": "window-manager:" + manager.name, "name": manager.name, "presetName": manager.preset_name, "windowCount": len(manager.windows), "interfaceLocked": manager.is_interface_locked}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-window-manager-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-window-manager-reopen-", suffix=".blend", dir=fixture.parent)
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"window manager save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00027", "operation": "WINDOW_MANAGER_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "windowManager": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"window-manager-desktop-ok id={after['id']} windows={after['windowCount']} saveReopen=exact")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
import hashlib, json, os, pathlib, sys, tempfile
import bpy
def report():
workspace = bpy.data.workspaces.get("WebGapWorkspace")
if workspace is None: raise RuntimeError("WebGapWorkspace is missing")
return {"id": "workspace:" + workspace.name, "name": workspace.name, "screenCount": len(workspace.screens)}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-workspace-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture)); before = report()
descriptor, temporary = tempfile.mkstemp(prefix="m16-workspace-reopen-", suffix=".blend", dir=fixture.parent); os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary); after = report()
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"workspace save/reopen drift: {before} != {after}")
value = {"schemaVersion": 1, "task": "M16-GAP-00028", "operation": "WORKSPACE_DATABLOCK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "workspace": after, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"); print(f"workspace-desktop-ok id={after['id']} saveReopen=exact")
if __name__ == "__main__": main()

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import hashlib,json,os,pathlib,sys,tempfile
import bpy
def report():
world=bpy.data.worlds.get("WebGapWorld")
if world is None: raise RuntimeError("WebGapWorld is missing")
mist=world.mist_settings
return {"id":"world:"+world.name,"name":world.name,"color":list(world.color),"exposure":0.0,"backgroundVisible":True,"environmentStrength":1.0,"mist":{"enabled":mist.use_mist,"type":"QUADRATIC","start":float(mist.start),"depth":float(mist.depth),"intensity":float(mist.intensity),"height":0.0}}
def main():
args=sys.argv[sys.argv.index("--")+1:]
if len(args)!=2: raise SystemExit("usage: blender -b --python check-world-desktop.py -- FIXTURE REPORT")
fixture,output=(pathlib.Path(v).resolve() for v in args); bpy.ops.wm.open_mainfile(filepath=str(fixture),load_ui=False); before=report(); fd,tmp=tempfile.mkstemp(prefix="m16-world-reopen-",suffix=".blend",dir=fixture.parent); os.close(fd)
try: bpy.ops.wm.save_as_mainfile(filepath=tmp,check_existing=False,compress=True); bpy.ops.wm.open_mainfile(filepath=tmp,load_ui=False); after=report()
finally: pathlib.Path(tmp).unlink(missing_ok=True)
if before!=after: raise RuntimeError(f"world save/reopen drift: {before} != {after}")
value={"schemaVersion":1,"task":"M16-GAP-00029","operation":"WORLD_DATABLOCK_DESKTOP","fixture":str(fixture),"fixtureSha256":hashlib.sha256(fixture.read_bytes()).hexdigest(),"world":after,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string}; output.parent.mkdir(parents=True,exist_ok=True); output.write_text(json.dumps(value,indent=2,sort_keys=True)+"\n",encoding="utf-8"); print(f"world-desktop-ok id={after['id']} saveReopen=exact")
if __name__=="__main__": main()

View File

@@ -0,0 +1,148 @@
import fs from "node:fs";
import path from "node:path";
import { CONTEXT_LIMITS, contextSizeReport, root } from "./task-context-lib.mjs";
export const GOVERNANCE_LIMITS = Object.freeze({
maxTaskInputs: 12,
maxTaskCommands: 8,
maxManifestArtifacts: 32,
maxCatalogRecordBytes: 2048,
});
export const FORBIDDEN_CONTEXT_REFERENCES = Object.freeze([
"next-task-plan.json",
"CURRENT_EXECUTION_PLAN.md",
"PROJECT_STATUS_AND_NEXT_WORK.md",
"BLENDER_5_2_FULL_PARITY_WBS.md",
"BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md",
"test-results/",
]);
export const INPUT_EXCLUSION_REASONS = Object.freeze(new Set([
"ALREADY_IN_CONTEXT",
"EVIDENCE_FILE_COUNT",
"EVIDENCE_BYTE_BUDGET",
"CONTEXT_REMAINING_SPACE",
"MISSING_PATH",
"PATH_OUTSIDE_REPOSITORY",
"PATH_STAT_FAILED",
"SYMLINK_PATH",
"UNREADABLE_PATH_TYPE",
"GENERATED_EVIDENCE_OUTPUT",
]));
const byteLength = (value) => Buffer.byteLength(value, "utf8");
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
function add(violations, code, detail) {
violations.push({ code, detail });
}
export function validateContextBundle(bundle, { current = true } = {}) {
const violations = [];
const { context, files, sources } = bundle;
const report = contextSizeReport(bundle);
const measured = [
["queue", sources.queue.source, CONTEXT_LIMITS.queueBytes],
["task", sources.taskSource, CONTEXT_LIMITS.taskBytes],
["parentManifest", sources.parentManifestSource, CONTEXT_LIMITS.parentManifestBytes],
["parentStatus", sources.parentStatusSource, CONTEXT_LIMITS.parentStatusBytes],
];
for (const [name, source, limit] of measured) {
if (byteLength(source) > limit) add(violations, "DOCUMENT_OVER_BUDGET", `${name}=${byteLength(source)}>${limit}`);
}
if (!report.withinBudget) add(violations, "CONTEXT_OVER_BUDGET", `tokens=${report.totalTokens}>${CONTEXT_LIMITS.contextTokens}`);
if (context.inputPaths.length > GOVERNANCE_LIMITS.maxTaskInputs) {
add(violations, "TASK_INPUTS_TOO_WIDE", `count=${context.inputPaths.length}`);
}
if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) {
add(violations, "TASK_COMMANDS_TOO_WIDE", `count=${context.commands.length}`);
}
const selection = context.inputSelection;
if (!selection || selection.schemaVersion !== 1 || !Array.isArray(selection.selected) || !Array.isArray(selection.excluded)) {
add(violations, "INPUT_SELECTION_AUDIT_MISSING", context.task);
} else {
const sourceBytes = report.documents.reduce((sum, item) => sum + item.bytes, 0);
if (selection.source?.bytes !== sourceBytes || selection.source?.tokens !== report.sourceTokens) {
add(violations, "INPUT_SELECTION_SOURCE_MISMATCH", context.task);
}
const selectedPaths = selection.selected.map((item) => item?.path);
if (JSON.stringify(selectedPaths) !== JSON.stringify(context.inputPaths)) {
add(violations, "INPUT_SELECTION_PATHS_MISMATCH", context.task);
}
const selectedBytes = selection.selected.reduce((sum, item) => sum + (Number.isSafeInteger(item?.bytes) ? item.bytes : 0), 0);
const selectedTokens = selection.selected.reduce((sum, item) => sum + (Number.isSafeInteger(item?.tokens) ? item.tokens : 0), 0);
for (const item of selection.selected) {
if (!item?.path || !Number.isSafeInteger(item.bytes) || item.bytes < 0 || item.tokens !== Math.ceil(item.bytes / 4)) {
add(violations, "INPUT_SELECTION_ENTRY_INVALID", context.task);
}
}
if (selection.selected.length > CONTEXT_LIMITS.evidenceFiles) add(violations, "EVIDENCE_FILE_COUNT_OVER_BUDGET", `count=${selection.selected.length}`);
if (selectedBytes > CONTEXT_LIMITS.evidenceBytes) add(violations, "EVIDENCE_BYTES_OVER_BUDGET", `bytes=${selectedBytes}`);
if (selectedBytes > selection.limits?.contextRemainingBytes) add(violations, "CONTEXT_REMAINING_BYTES_OVER_BUDGET", `bytes=${selectedBytes}`);
if (selectedTokens > selection.limits?.contextRemainingTokens) add(violations, "CONTEXT_REMAINING_SPACE_OVER_BUDGET", `tokens=${selectedTokens}`);
if (selection.totals?.files !== selection.selected.length || selection.totals?.bytes !== selectedBytes || selection.totals?.tokens !== selectedTokens) {
add(violations, "INPUT_SELECTION_TOTALS_MISMATCH", context.task);
}
for (const item of selection.excluded) {
if (!item?.path || typeof item.reason !== "string" || item.reason.length === 0) add(violations, "INPUT_EXCLUSION_REASON_MISSING", context.task);
else if (!INPUT_EXCLUSION_REASONS.has(item.reason)) add(violations, "INPUT_EXCLUSION_REASON_UNKNOWN", `${context.task}:${item.reason}`);
}
}
const taskSource = sources.taskSource;
if (taskSource && context.task === sources.queue.currentTask) {
for (const heading of ["## 目标", "## 输入与范围", "## 验收", "## 交付与回滚"]) {
if (!taskSource.includes(heading)) add(violations, "TASK_CARD_SECTION_MISSING", heading);
}
if (!/malformed|unsupported|取消|超限|负例|失败/iu.test(taskSource)) {
add(violations, "TASK_CARD_FAILURE_BOUNDARY_MISSING", context.task);
}
}
for (const [name, source] of [["task", taskSource], ["parentStatus", sources.parentStatusSource]]) {
for (const reference of FORBIDDEN_CONTEXT_REFERENCES) {
if (source.includes(reference)) add(violations, "FORBIDDEN_CONTEXT_REFERENCE", `${name}:${reference}`);
}
}
const manifest = sources.parentManifest;
const artifacts = manifest?.artifacts && typeof manifest.artifacts === "object" ? Object.keys(manifest.artifacts) : [];
if (artifacts.length > GOVERNANCE_LIMITS.maxManifestArtifacts) {
add(violations, "MANIFEST_ARTIFACTS_TOO_WIDE", `count=${artifacts.length}`);
}
for (const [name, artifact] of Object.entries(manifest?.artifacts ?? {})) {
if (!artifact?.path || path.isAbsolute(artifact.path) || artifact.path.includes("..")) {
add(violations, "MANIFEST_PATH_NOT_REPOSITORY_RELATIVE", name);
}
}
if (current && sources.queue.currentTask !== context.task) add(violations, "QUEUE_TASK_MISMATCH", `${sources.queue.currentTask}!=${context.task}`);
if (manifest.nextTask !== context.task) add(violations, "PARENT_NEXT_TASK_MISMATCH", `${manifest.nextTask}!=${context.task}`);
if (path.basename(path.dirname(files.parentManifestPath)) !== context.parentTask) {
add(violations, "PARENT_MANIFEST_PATH_MISMATCH", relative(files.parentManifestPath));
}
return { report, violations };
}
export function validateCatalogRecords(catalogPath, index) {
const violations = [];
const handle = fs.openSync(catalogPath, "r");
try {
for (const [id, entry] of Object.entries(index.entries ?? {})) {
if (!Number.isInteger(entry.offset) || !Number.isInteger(entry.length) || entry.length <= 0) {
add(violations, "CATALOG_OFFSET_INVALID", id);
continue;
}
if (entry.length > GOVERNANCE_LIMITS.maxCatalogRecordBytes) add(violations, "CATALOG_RECORD_TOO_LARGE", `${id}=${entry.length}`);
const buffer = Buffer.alloc(entry.length);
fs.readSync(handle, buffer, 0, entry.length, entry.offset);
if (!buffer.toString("utf8").endsWith("\n")) add(violations, "CATALOG_RECORD_NOT_LINE_DELIMITED", id);
}
} finally {
fs.closeSync(handle);
}
return violations;
}

View File

@@ -0,0 +1,36 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const task = process.argv[2];
const output = path.resolve(process.argv[3] ?? "");
const contracts = {
"M15-02C": { mode: "LOCAL_EXACT", nextTask: "M15-02D", required: ["desktopFixture", "wasmFixture", "sameFixture"] },
"M15-02D": { mode: "LOCAL_EQUIVALENT", nextTask: "M15-02E", required: ["mainEvidence", "saveReopenEvidence"] },
"M15-02E": { mode: "SERVER_EXACT", nextTask: "M15-02F", required: ["jobEvidence", "cancelEvidence", "isolationEvidence", "resultBindingEvidence"] },
"M15-02F": { mode: "UNKNOWN_DATA_PRESERVATION", nextTask: "M15-03A", required: ["unknownDataFixture", "saveReloadEvidence", "bytePreservationEvidence"] },
};
if (!contracts[task] || !output) throw new Error("usage: node generate-blender-contract-audit.mjs M15-02C OUTPUT.json");
const contract = contracts[task];
const mapPath = path.join(root, "tests/golden/M15-02A/blender-parity-map.json");
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
const claims = map.entries.filter((entry) => entry.implementationClass === contract.mode);
const blocked = claims.filter((entry) => contract.required.some((field) => !entry[field]));
const ready = claims.filter((entry) => !blocked.includes(entry));
const audit = {
schemaVersion: 1,
task,
operation: "BLENDER_PARITY_CONTRACT_AUDIT",
source: { path: "tests/golden/M15-02A/blender-parity-map.json", sha256: crypto.createHash("sha256").update(fs.readFileSync(mapPath)).digest("hex") },
contract: { mode: contract.mode, requiredEvidence: contract.required, failClosed: true },
summary: { inventoried: map.entries.length, applicable: claims.length, blocked: blocked.length, ready: ready.length, notApplicable: map.entries.length - claims.length },
blockedIds: blocked.map((entry) => entry.id).sort((a, b) => a < b ? -1 : a > b ? 1 : 0),
readyIds: ready.map((entry) => entry.id).sort((a, b) => a < b ? -1 : a > b ? 1 : 0),
interpretation: claims.length === 0 ? "NO_CLAIMS_DECLARED" : "MISSING_EVIDENCE_BLOCKS_CLAIM",
nextTask: contract.nextTask,
};
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(audit, null, 2)}\n`);
process.stdout.write(`blender-contract-audit-generated task=${task} mode=${contract.mode} applicable=${claims.length} blocked=${blocked.length} output=${output}\n`);

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Generate deterministic Blender 5.2 Main/Scene/Mesh/Depsgraph/core RNA inventory."""
import hashlib
import json
import pathlib
import sys
import bpy
def text(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def class_properties(rna):
properties = []
for prop in rna.properties:
if prop.identifier == "rna_type":
continue
value = {"identifier": prop.identifier, "type": prop.type, "readOnly": bool(prop.is_readonly)}
if hasattr(prop, "array_length"):
value["arrayLength"] = prop.array_length
properties.append(value)
properties.sort(key=lambda prop: prop["identifier"])
return properties
def family_for(name):
if name == "BlendData" or name.startswith("BlendData"):
return "Main"
if name.startswith("Depsgraph"):
return "Depsgraph"
if name.startswith("Mesh") or name in {"Attribute", "AttributeGroupMesh", "BoolAttribute", "ByteColorAttribute", "FloatAttribute", "IntAttribute"}:
return "Mesh"
if name in {"Scene", "Object", "Collection", "ViewLayer", "LayerCollection", "Window", "WindowManager", "Screen", "RenderSettings", "World", "Camera", "Light", "Material", "NodeTree", "Modifier", "Constraint"}:
return "Scene"
if name in {"ID", "Image", "Curve", "Armature", "Action", "Text", "MovieClip", "Mask", "Sound", "Speaker", "GreasePencil", "Volume", "ParticleSettings", "Key", "Brush", "Palette", "Lattice", "MetaBall", "PointCloud", "Curves"}:
return "Core"
return None
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-core-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
entries = []
for class_name in sorted(name for name in dir(bpy.types) if not name.startswith("_")):
family = family_for(class_name)
if not family:
continue
cls = getattr(bpy.types, class_name)
rna = getattr(cls, "bl_rna", None)
if not rna:
continue
entries.append({"family": family, "className": class_name, "rnaIdentifier": text(rna.identifier), "properties": class_properties(rna)})
entries.sort(key=lambda entry: (entry["family"], entry["rnaIdentifier"], entry["className"]))
counts = {family: sum(1 for entry in entries if entry["family"] == family) for family in ("Main", "Scene", "Mesh", "Depsgraph", "Core")}
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
inventory = {
"schemaVersion": 1,
"task": "M15-01F",
"operation": "BLENDER_CORE_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/makesrna",
"runtime": {
"blenderVersion": text(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": text(bpy.app.build_hash),
"buildBranch": text(bpy.app.build_branch),
"buildPlatform": text(bpy.app.build_platform),
"buildType": text(bpy.app.build_type),
"buildDate": text(bpy.app.build_date),
"buildTime": text(bpy.app.build_time),
"binarySha256": sha256_file(binary_path),
},
"summary": {"classCount": len(entries), "byFamily": counts},
"classes": entries,
"nextTask": "M15-02A",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-core-inventory-generated classes={len(entries)} families={counts} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Generate deterministic Blender 5.2 editor, space, workspace, and keymap inventory."""
import hashlib
import json
import pathlib
import sys
import bpy
def text(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def enum_info(rna_type, property_name):
entries = []
for item in rna_type.bl_rna.properties[property_name].enum_items:
entries.append({"identifier": item.identifier, "name": text(item.name), "description": text(item.description), "value": item.value})
entries.sort(key=lambda entry: entry["identifier"])
return entries
def property_info(prop):
result = {"identifier": prop.identifier, "type": prop.type, "readOnly": bool(prop.is_readonly)}
if hasattr(prop, "array_length"):
result["arrayLength"] = prop.array_length
return result
def space_types():
entries = []
for class_name in sorted(name for name in dir(bpy.types) if name.startswith("Space")):
cls = getattr(bpy.types, class_name)
try:
if not isinstance(cls, type) or cls is bpy.types.Space or not issubclass(cls, bpy.types.Space):
continue
rna = getattr(cls, "bl_rna", None)
if not rna:
continue
props = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"]
props.sort(key=lambda prop: prop["identifier"])
entries.append({"className": class_name, "rnaIdentifier": text(rna.identifier), "properties": props})
except (AttributeError, RuntimeError, TypeError):
continue
return entries
def keymaps():
bpy.utils.keyconfig_init()
keyconfig = bpy.context.window_manager.keyconfigs.default
maps = []
for keymap in sorted(keyconfig.keymaps, key=lambda value: f"{value.name}:{value.space_type}:{value.region_type}"):
items = []
for item in keymap.keymap_items:
items.append({
"idname": text(item.idname),
"type": text(item.type),
"value": text(item.value),
"ctrl": bool(item.ctrl),
"shift": bool(item.shift),
"alt": bool(item.alt),
"oskey": bool(item.oskey),
"any": bool(item.any),
"repeat": bool(item.repeat),
"keyModifier": text(item.key_modifier),
"direction": text(item.direction),
"mapType": text(item.map_type),
"active": bool(item.active),
})
items.sort(key=lambda item: tuple(item.values()))
maps.append({"name": text(keymap.name), "spaceType": text(keymap.space_type), "regionType": text(keymap.region_type), "modal": bool(keymap.is_modal), "items": items})
return maps
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-editor-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
areas = enum_info(bpy.types.Area, "type")
regions = enum_info(bpy.types.Region, "type")
spaces = enum_info(bpy.types.Space, "type")
workspace_modes = enum_info(bpy.types.WorkSpace, "object_mode")
space_classes = space_types()
maps = keymaps()
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
inventory = {
"schemaVersion": 1,
"task": "M15-01E",
"operation": "BLENDER_EDITOR_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/editors",
"runtime": {
"blenderVersion": text(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": text(bpy.app.build_hash),
"buildBranch": text(bpy.app.build_branch),
"buildPlatform": text(bpy.app.build_platform),
"buildType": text(bpy.app.build_type),
"buildDate": text(bpy.app.build_date),
"buildTime": text(bpy.app.build_time),
"binarySha256": sha256_file(binary_path),
},
"summary": {"areaTypeCount": len(areas), "regionTypeCount": len(regions), "spaceTypeCount": len(spaces), "spaceClassCount": len(space_classes), "workspaceModeCount": len(workspace_modes), "keymapCount": len(maps), "keymapItemCount": sum(len(keymap["items"]) for keymap in maps)},
"areaTypes": areas,
"regionTypes": regions,
"spaceTypes": spaces,
"spaceClasses": space_classes,
"workspaceModes": workspace_modes,
"keymaps": maps,
"nextTask": "M15-01F",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-editor-inventory-generated areas={len(areas)} regions={len(regions)} spaces={len(spaces)} keymaps={len(maps)} items={inventory['summary']['keymapItemCount']} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Generate deterministic Blender 5.2 modifier, constraint, and node inventories."""
import hashlib
import json
import pathlib
import sys
import bpy
def text(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def property_info(prop):
result = {
"identifier": prop.identifier,
"type": prop.type,
"readOnly": bool(prop.is_readonly),
}
if hasattr(prop, "array_length"):
result["arrayLength"] = prop.array_length
return result
def enum_item_info(item):
return {
"identifier": item.identifier,
"name": text(item.name),
"description": text(item.description),
"value": item.value,
}
def enum_inventory(rna_type, property_name):
enum = rna_type.bl_rna.properties[property_name].enum_items
entries = [enum_item_info(item) for item in enum]
entries.sort(key=lambda item: item["identifier"])
return entries
def node_properties(rna):
properties = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"]
properties.sort(key=lambda prop: prop["identifier"])
return properties
def node_inventory():
entries = []
for class_name in sorted(name for name in dir(bpy.types) if not name.startswith("_")):
cls = getattr(bpy.types, class_name)
try:
if not isinstance(cls, type) or not issubclass(cls, bpy.types.Node) or cls is bpy.types.Node:
continue
is_registered = getattr(cls, "is_registered_node_type", None)
if is_registered is None or not is_registered():
continue
rna = getattr(cls, "bl_rna", None)
identifier = text(getattr(rna, "identifier", "")) if rna else ""
family = next((prefix[:-4] for prefix in ("ShaderNode", "GeometryNode", "CompositorNode") if identifier.startswith(prefix)), None)
if not family:
continue
entries.append({
"family": family,
"className": class_name,
"rnaIdentifier": identifier,
"properties": node_properties(rna),
})
except (AttributeError, RuntimeError, TypeError):
continue
entries.sort(key=lambda entry: (entry["family"], entry["rnaIdentifier"], entry["className"]))
return entries
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-family-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
modifiers = enum_inventory(bpy.types.Modifier, "type")
constraints = enum_inventory(bpy.types.Constraint, "type")
nodes = node_inventory()
family_counts = {family: sum(1 for node in nodes if node["family"] == family) for family in ("Shader", "Geometry", "Compositor")}
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
inventory = {
"schemaVersion": 1,
"task": "M15-01C",
"operation": "BLENDER_FAMILY_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/makesrna",
"runtime": {
"blenderVersion": text(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": text(bpy.app.build_hash),
"buildBranch": text(bpy.app.build_branch),
"buildPlatform": text(bpy.app.build_platform),
"buildType": text(bpy.app.build_type),
"buildDate": text(bpy.app.build_date),
"buildTime": text(bpy.app.build_time),
"binarySha256": sha256_file(binary_path),
},
"summary": {
"modifierCount": len(modifiers),
"constraintCount": len(constraints),
"nodeCount": len(nodes),
"nodesByFamily": family_counts,
},
"modifiers": modifiers,
"constraints": constraints,
"nodes": nodes,
"nextTask": "M15-01D",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-family-inventory-generated modifiers={len(modifiers)} constraints={len(constraints)} nodes={len(nodes)} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Generate deterministic Blender 5.2 sequencer, physics, and I/O inventories."""
import hashlib
import json
import pathlib
import re
import sys
import bpy
def text(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def property_info(prop):
result = {"identifier": prop.identifier, "type": prop.type, "readOnly": bool(prop.is_readonly)}
if hasattr(prop, "array_length"):
result["arrayLength"] = prop.array_length
return result
def enum_item_info(item):
return {"identifier": item.identifier, "name": text(item.name), "description": text(item.description), "value": item.value}
def enum_inventory(rna_type, property_name):
entries = [enum_item_info(item) for item in rna_type.bl_rna.properties[property_name].enum_items]
entries.sort(key=lambda item: item["identifier"])
return entries
def class_properties(rna):
entries = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"]
entries.sort(key=lambda prop: prop["identifier"])
return entries
def physics_inventory():
marker = re.compile(r"(Particle|Boid|Cloth|SoftBody|RigidBody|Fluid|DynamicPaint|Effector|Collision|Field|PointCache|BakeSettings)")
entries = []
for class_name in sorted(name for name in dir(bpy.types) if not name.startswith("_")):
cls = getattr(bpy.types, class_name)
if not isinstance(cls, type) or not marker.search(class_name) or re.match(r"[A-Z0-9]+_(?:PT|OT|MT|UL)_", class_name):
continue
rna = getattr(cls, "bl_rna", None)
if not rna or class_name.endswith("Node"):
continue
entries.append({"className": class_name, "rnaIdentifier": text(rna.identifier), "properties": class_properties(rna)})
entries.sort(key=lambda entry: (entry["rnaIdentifier"], entry["className"]))
return entries
def operator_inventory():
entries = []
for module_name in sorted(name for name in dir(bpy.ops) if not name.startswith("_")):
module = getattr(bpy.ops, module_name)
for operator_name in sorted(name for name in dir(module) if not name.startswith("_")):
operator_path = f"{module_name}.{operator_name}"
if not re.search(r"(?:import|export)", operator_path, re.IGNORECASE):
continue
try:
operator = getattr(module, operator_name)
rna = operator.get_rna_type()
properties = class_properties(rna)
try:
poll = bool(operator.poll())
poll_error = None
except (AttributeError, RuntimeError) as error:
poll = None
poll_error = type(error).__name__
entries.append({"operator": operator_path, "rnaIdentifier": text(rna.identifier), "poll": poll, "pollError": poll_error, "properties": properties})
except (AttributeError, KeyError, RuntimeError):
continue
entries.sort(key=lambda entry: entry["operator"])
return entries
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-format-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
strips = enum_inventory(bpy.types.Strip, "type")
physics = physics_inventory()
io_operators = operator_inventory()
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
inventory = {
"schemaVersion": 1,
"task": "M15-01D",
"operation": "BLENDER_FORMAT_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/makesrna",
"runtime": {
"blenderVersion": text(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": text(bpy.app.build_hash),
"buildBranch": text(bpy.app.build_branch),
"buildPlatform": text(bpy.app.build_platform),
"buildType": text(bpy.app.build_type),
"buildDate": text(bpy.app.build_date),
"buildTime": text(bpy.app.build_time),
"binarySha256": sha256_file(binary_path),
},
"summary": {"stripTypeCount": len(strips), "physicsTypeCount": len(physics), "ioOperatorCount": len(io_operators)},
"stripTypes": strips,
"physicsTypes": physics,
"ioOperators": io_operators,
"nextTask": "M15-01E",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-format-inventory-generated strips={len(strips)} physics={len(physics)} io={len(io_operators)} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,37 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const inputPath = path.join(root, "tests/golden/M15-02A/blender-parity-map.json");
const output = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M15-02B/blender-gap-audit.json"));
const map = JSON.parse(fs.readFileSync(inputPath, "utf8"));
const seen = new Set();
const duplicateIds = [];
const unmapped = [];
const summaryOnly = [];
const proxyOnly = [];
const routeOnly = [];
for (const entry of map.entries) {
if (seen.has(entry.id)) duplicateIds.push(entry.id);
seen.add(entry.id);
if (!entry.ownerFamily || !entry.implementationClass || !Array.isArray(entry.tests) || !Array.isArray(entry.evidence)) unmapped.push(entry.id);
if (entry.implementationClass === "INVENTORY_BASELINE") summaryOnly.push(entry.id);
if (entry.implementationClass === "PROXY_ONLY") proxyOnly.push(entry.id);
if (entry.implementationClass === "ROUTE_ONLY") routeOnly.push(entry.id);
}
for (const values of [duplicateIds, unmapped, summaryOnly, proxyOnly, routeOnly]) values.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
const audit = {
schemaVersion: 1,
task: "M15-02B",
operation: "BLENDER_PARITY_GAP_AUDIT",
source: { path: "tests/golden/M15-02A/blender-parity-map.json", sha256: crypto.createHash("sha256").update(fs.readFileSync(inputPath)).digest("hex") },
summary: { inventoried: map.entries.length, unmapped: unmapped.length, duplicate: duplicateIds.length, summaryOnly: summaryOnly.length, proxyOnly: proxyOnly.length, routeOnly: routeOnly.length, gapCount: unmapped.length + duplicateIds.length + summaryOnly.length + proxyOnly.length + routeOnly.length },
gaps: { unmapped, duplicateIds, summaryOnly, proxyOnly, routeOnly },
interpretation: { inventoryBaseline: "SUMMARY_ONLY", parityClaims: "NONE", nextTaskPerEntry: false },
nextTask: "M15-02C",
};
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(audit, null, 2)}\n`);
process.stdout.write(`blender-gap-audit-generated inventoried=${audit.summary.inventoried} gaps=${audit.summary.gapCount} summaryOnly=${audit.summary.summaryOnly} output=${output}\n`);

View File

@@ -0,0 +1,70 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const output = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M15-03A/next-task-plan.json"));
const mapPath = path.join(root, "tests/golden/M15-02A/blender-parity-map.json");
const gapPath = path.join(root, "tests/golden/M15-02B/blender-gap-audit.json");
const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
const gaps = JSON.parse(fs.readFileSync(gapPath, "utf8"));
const completionPath = path.join(root, "tests/golden/M15-03A/completed-gap-tasks.json");
const completedIds = fs.existsSync(completionPath) ? new Set(JSON.parse(fs.readFileSync(completionPath, "utf8"))) : new Set();
const byId = new Map(map.entries.map((entry) => [entry.id, entry]));
const waveFor = (owner) => {
if (["Main", "Mesh", "MODIFIER", "RNA_DATABLOCK", "OPERATOR"].includes(owner)) return "M16";
if (owner === "CONSTRAINT") return "M17";
if (["SHADER_NODE", "GEOMETRY_NODE", "COMPOSITOR_NODE"].includes(owner)) return "M18";
if (owner === "Core") return "M19";
if (["PHYSICS", "SEQUENCER"].includes(owner)) return "M20";
if (["IMPORT_EXPORT", "EDITOR", "REGION", "SPACE", "WORKSPACE", "KEYMAP"].includes(owner)) return "M21";
return "M22";
};
const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap";
const sourceGaps = [...gaps.gaps.unmapped, ...gaps.gaps.summaryOnly, ...gaps.gaps.proxyOnly, ...gaps.gaps.routeOnly];
const uniqueGaps = [...new Set(sourceGaps)];
const work = uniqueGaps.map((gapId) => {
const mapping = byId.get(gapId);
if (!mapping) throw new Error(`gap has no parity map entry: ${gapId}`);
return { gapId, mapping, wave: waveFor(mapping.ownerFamily) };
});
work.sort((a, b) => a.wave.localeCompare(b.wave) || (a.gapId < b.gapId ? -1 : a.gapId > b.gapId ? 1 : 0));
const waveCounts = new Map();
const activeIndex = work.findIndex((value) => !completedIds.has(value.gapId));
const tasks = work.map((value, index) => {
const serial = (waveCounts.get(value.wave) ?? 0) + 1;
waveCounts.set(value.wave, serial);
const id = `${value.wave}-GAP-${String(serial).padStart(5, "0")}`;
const fixturePath = `tests/files/web/generated/${id}-${safeName(value.gapId)}.blend`;
return {
id,
gapId: value.gapId,
ownerFamily: value.mapping.ownerFamily,
sourceTask: value.mapping.task,
state: completedIds.has(value.gapId) ? "completed" : index === activeIndex ? "active" : "pending",
dependencies: [],
targetImplementationClass: "LOCAL_EXACT",
fixture: { path: fixturePath, state: "REQUIRED" },
desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${id}.py -- ${fixturePath}`,
webCommand: `npm --prefix web run test:generated-gap -- --task ${id}`,
comparator: `node tools/web/check-generated-gap.mjs --task ${id}`,
exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"],
};
});
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const completionBytes = fs.readFileSync(completionPath);
const plan = {
schemaVersion: 1,
task: "M15-03A",
operation: "BLENDER_NEXT_TASK_PLAN",
sources: { parityMap: { path: "tests/golden/M15-02A/blender-parity-map.json", sha256: sha256File(mapPath) }, gapAudit: { path: "tests/golden/M15-02B/blender-gap-audit.json", sha256: sha256File(gapPath) }, completions: { path: "tests/golden/M15-03A/completed-gap-tasks.json", sha256: crypto.createHash("sha256").update(completionBytes).digest("hex") } },
summary: { taskCount: tasks.length, active: tasks.filter((task) => task.state === "active").length, pending: tasks.filter((task) => task.state === "pending").length, completed: tasks.filter((task) => task.state === "completed").length, blocked: tasks.filter((task) => task.state === "blocked").length, byWave: Object.fromEntries([...waveCounts.entries()]) },
tasks,
firstTask: tasks.find((task) => task.state === "active")?.id ?? null,
closureGate: "M15-03E",
nextTask: "M15-03B",
};
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(plan, null, 2)}\n`);
process.stdout.write(`blender-next-task-plan-generated tasks=${tasks.length} active=${plan.summary.active} first=${plan.firstTask} output=${output}\n`);

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Generate a deterministic operator/RNA inventory from pinned Blender 5.2."""
import hashlib
import json
import pathlib
import sys
import bpy
def decode(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def binary_sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def property_info(prop):
result = {"identifier": prop.identifier, "type": prop.type}
if hasattr(prop, "array_length"):
result["arrayLength"] = prop.array_length
return result
def operator_info(module_name, operator_name):
operator_path = f"{module_name}.{operator_name}"
try:
operator = getattr(getattr(bpy.ops, module_name), operator_name)
rna = operator.get_rna_type()
properties = [property_info(prop) for prop in rna.properties if prop.identifier != "rna_type"]
properties.sort(key=lambda prop: prop["identifier"])
try:
poll = bool(operator.poll())
poll_error = None
except (AttributeError, RuntimeError) as error:
poll = None
poll_error = type(error).__name__
return {
"operator": operator_path,
"rnaIdentifier": rna.identifier,
"registered": True,
"poll": poll,
"pollError": poll_error,
"properties": properties,
}
except (AttributeError, KeyError, RuntimeError) as error:
return {
"operator": operator_path,
"rnaIdentifier": None,
"registered": False,
"poll": None,
"pollError": type(error).__name__,
"properties": [],
}
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-operator-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
operators = []
for module_name in sorted(name for name in dir(bpy.ops) if not name.startswith("_")):
try:
module = getattr(bpy.ops, module_name)
names = sorted(name for name in dir(module) if not name.startswith("_"))
except (AttributeError, RuntimeError):
continue
for operator_name in names:
try:
value = getattr(module, operator_name)
if not hasattr(value, "get_rna_type"):
continue
except (AttributeError, RuntimeError):
continue
operators.append(operator_info(module_name, operator_name))
operators.sort(key=lambda entry: entry["operator"])
operator_ids = [entry["operator"] for entry in operators]
if len(operator_ids) != len(set(operator_ids)):
raise RuntimeError("duplicate Blender operator idname")
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
inventory = {
"schemaVersion": 1,
"task": "M15-01B",
"operation": "BLENDER_OPERATOR_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/editors",
"runtime": {
"blenderVersion": decode(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": decode(bpy.app.build_hash),
"buildBranch": decode(bpy.app.build_branch),
"buildPlatform": decode(bpy.app.build_platform),
"buildType": decode(bpy.app.build_type),
"buildDate": decode(bpy.app.build_date),
"buildTime": decode(bpy.app.build_time),
"binarySha256": binary_sha256(binary_path),
},
"summary": {"count": len(operators), "registered": sum(1 for entry in operators if entry["registered"]), "pollTrue": sum(1 for entry in operators if entry["poll"] is True), "pollFalse": sum(1 for entry in operators if entry["poll"] is False), "pollUnknown": sum(1 for entry in operators if entry["poll"] is None)},
"operators": operators,
"nextTask": "M15-01C",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-operator-inventory-generated count={len(operators)} blender={bpy.app.version_string} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,69 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const output = path.resolve(process.argv[2] ?? path.join(root, "tests/golden/M15-02A/blender-parity-map.json"));
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const read = (relative) => { const file = path.join(root, relative); const bytes = fs.readFileSync(file); return { relative, bytes, value: JSON.parse(bytes) }; };
const sources = [
read("tests/golden/M15-01A/blender-rna-datablock-inventory.json"),
read("tests/golden/M15-01B/blender-operator-inventory.json"),
read("tests/golden/M15-01C/blender-family-inventory.json"),
read("tests/golden/M15-01D/blender-format-inventory.json"),
read("tests/golden/M15-01E/blender-editor-inventory.json"),
read("tests/golden/M15-01F/blender-core-inventory.json"),
];
const checkerByTask = {
"M15-01A": "tools/web/check-blender-rna-datablock-inventory.mjs",
"M15-01B": "tools/web/check-blender-operator-inventory.mjs",
"M15-01C": "tools/web/check-blender-family-inventory.mjs",
"M15-01D": "tools/web/check-blender-format-inventory.mjs",
"M15-01E": "tools/web/check-blender-editor-inventory.mjs",
"M15-01F": "tools/web/check-blender-core-inventory.mjs",
};
function item(id, task, ownerFamily, source, detail = {}) {
return { id, task, ownerFamily, implementationClass: "INVENTORY_BASELINE", coverage: "INVENTORIED_ONLY", tests: [checkerByTask[task]], evidence: [`tests/golden/${task}/manifest.json`], source, ...detail };
}
const entries = [];
for (const source of sources) {
const inv = source.value;
const task = inv.task;
if (task === "M15-01A") for (const value of inv.dataBlockTypes) entries.push(item(`datablock:${value.rnaIdentifier}`, task, "RNA_DATABLOCK", source.relative, { rnaIdentifier: value.rnaIdentifier }));
if (task === "M15-01B") for (const value of inv.operators) entries.push(item(`operator:${value.operator}`, task, "OPERATOR", source.relative, { operator: value.operator, poll: value.poll }));
if (task === "M15-01C") {
for (const value of inv.modifiers) entries.push(item(`modifier:${value.identifier}`, task, "MODIFIER", source.relative, { identifier: value.identifier }));
for (const value of inv.constraints) entries.push(item(`constraint:${value.identifier}`, task, "CONSTRAINT", source.relative, { identifier: value.identifier }));
for (const value of inv.nodes) entries.push(item(`node:${value.family}:${value.rnaIdentifier}`, task, `${value.family.toUpperCase()}_NODE`, source.relative, { identifier: value.rnaIdentifier }));
}
if (task === "M15-01D") {
for (const value of inv.stripTypes) entries.push(item(`strip:${value.identifier}`, task, "SEQUENCER", source.relative, { identifier: value.identifier }));
for (const value of inv.physicsTypes) entries.push(item(`physics:${value.rnaIdentifier}`, task, "PHYSICS", source.relative, { identifier: value.rnaIdentifier }));
for (const value of inv.ioOperators) entries.push(item(`io:${value.operator}`, task, "IMPORT_EXPORT", source.relative, { operator: value.operator }));
}
if (task === "M15-01E") {
for (const value of inv.areaTypes) entries.push(item(`area:${value.identifier}`, task, "EDITOR", source.relative, { identifier: value.identifier }));
for (const value of inv.regionTypes) entries.push(item(`region:${value.identifier}`, task, "REGION", source.relative, { identifier: value.identifier }));
for (const value of inv.spaceTypes) entries.push(item(`space:${value.identifier}`, task, "SPACE", source.relative, { identifier: value.identifier }));
for (const value of inv.spaceClasses) entries.push(item(`space-class:${value.rnaIdentifier}`, task, "SPACE", source.relative, { identifier: value.rnaIdentifier }));
for (const value of inv.workspaceModes) entries.push(item(`workspace:${value.identifier}`, task, "WORKSPACE", source.relative, { identifier: value.identifier }));
for (const value of inv.keymaps) {
const key = `${value.name}:${value.spaceType}:${value.regionType}`;
entries.push(item(`keymap:${key}`, task, "KEYMAP", source.relative, { name: value.name, spaceType: value.spaceType, regionType: value.regionType, itemCount: value.items.length }));
for (const [index, keymapItem] of value.items.entries()) entries.push(item(`keymap-item:${key}:${index}`, task, "KEYMAP", source.relative, { keymap: key, idname: keymapItem.idname, type: keymapItem.type, value: keymapItem.value }));
}
}
if (task === "M15-01F") for (const value of inv.classes) entries.push(item(`rna:${value.family}:${value.rnaIdentifier}`, task, value.family, source.relative, { identifier: value.rnaIdentifier }));
}
entries.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const ids = entries.map((entry) => entry.id);
if (new Set(ids).size !== ids.length) throw new Error("duplicate parity map IDs");
const inventoryRefs = Object.fromEntries(sources.map((source) => [source.value.task, { path: source.relative, sha256: sha256(source.bytes) }]));
const map = { schemaVersion: 1, task: "M15-02A", operation: "BLENDER_PARITY_MAPPING", statusAxis: "INVENTORY_ONLY", implementationClasses: ["INVENTORY_BASELINE"], sourceInventories: inventoryRefs, summary: { mapped: entries.length, byTask: Object.fromEntries(sources.map((source) => [source.value.task, entries.filter((entry) => entry.task === source.value.task).length])), ownerFamilies: [...new Set(entries.map((entry) => entry.ownerFamily))].sort() }, entries, nextTask: "M15-02B" };
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(map, null, 2)}\n`);
process.stdout.write(`blender-parity-map-generated entries=${entries.length} output=${output} sha256=${sha256(fs.readFileSync(output))}\n`);

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate the Blender 5.2 RNA data-block type inventory from the pinned runtime."""
import hashlib
import json
import pathlib
import sys
import bpy
def decode(value):
return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value)
def binary_sha256(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def main():
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1:]) != 1:
raise SystemExit("usage: blender --background --factory-startup --python generate-blender-rna-datablock-inventory.py -- OUTPUT.json")
output = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
version = tuple(int(value) for value in bpy.app.version)
if version != (5, 2, 0):
raise RuntimeError(f"expected Blender 5.2.0, got {version}")
binary_path = pathlib.Path(bpy.app.binary_path).resolve()
entries = []
for cls in bpy.types.ID.__subclasses__():
rna = getattr(cls, "bl_rna", None)
identifier = getattr(rna, "identifier", "") if rna else ""
if not identifier:
raise RuntimeError(f"RNA ID type {cls.__name__} has no identifier")
entries.append({
"parityId": f"BLENDER52_RNA_ID_{identifier.upper()}",
"rnaIdentifier": identifier,
"typeName": cls.__name__,
"baseType": "ID",
"sourceAnchor": "blender-5.2.0/source/blender/makesrna",
})
entries.sort(key=lambda entry: (entry["rnaIdentifier"], entry["typeName"]))
identifiers = [entry["rnaIdentifier"] for entry in entries]
if len(identifiers) != len(set(identifiers)):
raise RuntimeError("duplicate RNA data-block identifier")
inventory = {
"schemaVersion": 1,
"task": "M15-01A",
"operation": "BLENDER_RNA_DATABLOCK_INVENTORY",
"sourceAnchor": "blender-5.2.0/source/blender/makesrna",
"runtime": {
"blenderVersion": decode(bpy.app.version_string),
"versionTuple": list(version),
"buildHash": decode(bpy.app.build_hash),
"buildBranch": decode(bpy.app.build_branch),
"buildPlatform": decode(bpy.app.build_platform),
"buildType": decode(bpy.app.build_type),
"buildDate": decode(bpy.app.build_date),
"buildTime": decode(bpy.app.build_time),
"binarySha256": binary_sha256(binary_path),
},
"summary": {"count": len(entries), "baseType": "ID"},
"dataBlockTypes": entries,
"nextTask": "M15-01B",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"blender-rna-datablock-inventory-generated count={len(entries)} blender={bpy.app.version_string} output={output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
brush = bpy.data.brushes.new("WebGapBrush")
brush.size = 42
brush.strength = 0.65
brush.hardness = 0.8
brush.spacing = 17
brush.jitter = 0.2
brush.sculpt_brush_type = "DRAW"
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-brush-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
camera = bpy.data.cameras.new("WebGapCamera")
camera.lens = 52.0
camera.clip_start = 0.1
camera.clip_end = 2500.0
camera.sensor_width = 36.0
camera.type = "PERSP"
object_ = bpy.data.objects.new("WebGapCameraObject", camera)
bpy.context.collection.objects.link(object_)
object_.location = (1.0, -2.0, 3.0)
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-camera-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
parent = bpy.data.collections.new("WebGapCollection")
child = bpy.data.collections.new("WebGapChild")
bpy.context.scene.collection.children.link(parent)
parent.children.link(child)
mesh = bpy.data.meshes.new("WebGapCollectionMesh")
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)])
object_ = bpy.data.objects.new("WebGapCollectionObject", mesh)
child.objects.link(object_)
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-collection-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
curve = bpy.data.curves.new("WebGapCurve", type="CURVE")
curve.dimensions = "3D"
curve.resolution_u = 8
spline = curve.splines.new("BEZIER")
spline.bezier_points.add(2)
points = [((-1, 0, 0), (1, 1, 1)), ((0, 1, 0.5), (1, 1, 1)), ((1, 0, 1), (1, 1, 1))]
for point, (co, handle) in zip(spline.bezier_points, points):
point.co = co
point.handle_left_type = "AUTO"
point.handle_right_type = "AUTO"
spline.use_cyclic_u = False
object_ = bpy.data.objects.new("WebGapCurveObject", curve)
bpy.context.collection.objects.link(object_)
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-curve-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
legacy = bpy.data.curves.new("WebGapCurvesSeed", "CURVE")
legacy.dimensions = "3D"
spline = legacy.splines.new("BEZIER")
spline.bezier_points.add(3)
for point, co in zip(spline.bezier_points, ((-1.5, 0, 0), (-0.5, 0.3, 0.5), (0.5, 0, 0), (1.5, 0.3, 0.5))):
point.co = co
point.handle_left_type = "AUTO"
point.handle_right_type = "AUTO"
object_ = bpy.data.objects.new("WebGapCurvesObject", legacy)
bpy.context.collection.objects.link(object_)
bpy.context.view_layer.objects.active = object_
object_.select_set(True)
bpy.ops.object.convert(target="CURVES")
if legacy != object_.data: bpy.data.curves.remove(legacy, do_unlink=True)
object_.data.name = "WebGapCurves"
radius = object_.data.attributes.get("radius") or object_.data.attributes.new("radius", "FLOAT", "POINT")
for index, item in enumerate(radius.data): item.value = 0.03 + index * 0.005
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-curves-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
import sys
import bpy
def main(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
style = bpy.data.linestyles.new("WebGapLineStyle")
style.color = (0.2, 0.4, 0.8)
style.alpha = 0.75
style.thickness = 2.5
style.use_chaining = True
style.use_dashed_line = True
scene = bpy.context.scene
scene.render.engine = "BLENDER_WORKBENCH"
scene.render.use_freestyle = True
line_set = scene.view_layers[0].freestyle_settings.linesets.new("WebGapLineSet")
line_set.linestyle = style
bpy.ops.wm.save_as_mainfile(filepath=output, compress=True)
if __name__ == "__main__":
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 1: raise SystemExit("usage: blender -b --python generate-freestyle-fixture.py -- OUTPUT")
main(args[0])

View File

@@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
import { readIndexedTask, verifyTaskIndex, root } from './task-context-lib.mjs';
const taskArg = process.argv.indexOf('--task');
const task = taskArg >= 0 ? process.argv[taskArg + 1] : undefined;
if (!task) throw new Error('usage: node tools/web/generate-task-card.mjs --task <task-id>');
verifyTaskIndex();
const entry = readIndexedTask(task);
if (!entry) throw new Error('unknown indexed task: ' + task);
const cardPath = path.join(root, 'docs/tasks', task + '.md');
if (fs.existsSync(cardPath) && !process.argv.includes('--force')) {
process.stdout.write('task-card-exists task=' + task + ' path=' + path.relative(root, cardPath) + '\n');
process.exit(0);
}
if (!entry.previous) throw new Error('task ' + task + ' has no indexed parent');
const safeName = entry.gapId.replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '') || 'gap';
const fixture = 'tests/files/web/generated/' + task + '-' + safeName + '.blend';
const source = [
'# ' + task + ': ' + entry.gapId + ' LOCAL_EXACT slice', '',
'- task: ' + task, '- parent: ' + entry.previous, '- status: in_progress',
'- gap: ' + entry.gapId, '- ownerFamily: ' + entry.ownerFamily,
'- targetImplementationClass: ' + entry.targetImplementationClass, '',
'## 目标', '',
'Make the same minimal fixture produce observable ' + entry.gapId + ' data in Blender desktop and WASM/Main, with save/reopen stability. Change only this gap; do not expand to other data-blocks, editors, or browsers.', '',
'## 输入与范围', '',
'- Fixture: `' + fixture + '`',
'- Generator: `tools/web/generated/' + task + '.py`',
'- Production: `blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp`',
'- Checker: `tools/web/check-generated-gap.mjs`',
'- Do: field read, desktop/WASM comparison, save/reopen, structured report.',
'- Do not: other gaps, Firefox/WebKit, or full editor behavior.', '',
'## 验收', '',
'build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/' + task + '.py -- ' + fixture,
'npm --prefix web run test:generated-gap -- --task ' + task,
'node tools/web/check-generated-gap.mjs --task ' + task, '',
'Malformed/unsupported, cancellation, duplicate, over-budget, or hash-drift cases must keep in_progress; they must not change Main revision or advance nextTask.', '',
'## 交付与回滚', '',
'- Reports: `tests/golden/' + task + '/`', '- Status: `docs/status/' + task + '.md`',
'- Manifest: `tests/golden/' + task + '/manifest.json`',
'- Handoff: `node tools/web/check-task-context.mjs --task ' + task + ' --write`',
'- Rollback: remove this task production entry, fixture, tests, reports, manifest, status, and context; restore the parent as queue tail without rewriting parent evidence.',
].join('\n') + '\n';
fs.mkdirSync(path.dirname(cardPath), { recursive: true });
fs.writeFileSync(cardPath, source);
process.stdout.write('task-card-generated task=' + task + ' bytes=' + Buffer.byteLength(source) + ' path=' + path.relative(root, cardPath) + '\n');

View File

@@ -0,0 +1,56 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
const outputDir = path.join(root, "tests/golden/M15-03A");
const catalogPath = path.join(outputDir, "task-catalog.jsonl");
const indexPath = path.join(outputDir, "task-index.json");
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const planBytes = fs.readFileSync(planPath);
const plan = JSON.parse(planBytes);
if (!Array.isArray(plan.tasks) || plan.tasks.length === 0) throw new Error("next-task plan has no tasks");
fs.mkdirSync(outputDir, { recursive: true });
const lines = [];
const entries = {};
let offset = 0;
for (let index = 0; index < plan.tasks.length; index += 1) {
const task = plan.tasks[index];
const compactTask = {
id: task.id,
gapId: task.gapId,
ownerFamily: task.ownerFamily,
sourceTask: task.sourceTask,
state: task.state,
targetImplementationClass: task.targetImplementationClass,
};
const line = `${JSON.stringify(compactTask)}\n`;
const length = Buffer.byteLength(line, "utf8");
lines.push(line);
entries[task.id] = {
line: index,
offset,
length,
previous: plan.tasks[index - 1]?.id ?? null,
next: plan.tasks[index + 1]?.id ?? null,
};
offset += length;
}
const catalogBytes = Buffer.from(lines.join(""), "utf8");
fs.writeFileSync(catalogPath, catalogBytes);
const index = {
schemaVersion: 1,
operation: "BLENDER_TASK_CONTEXT_INDEX",
source: { path: relative(planPath), sha256: sha256(planBytes) },
catalog: { path: relative(catalogPath), sha256: sha256(catalogBytes) },
taskCount: plan.tasks.length,
activeTask: plan.firstTask,
entries,
};
fs.writeFileSync(indexPath, `${JSON.stringify(index)}\n`);
process.stdout.write(`task-index-generated tasks=${plan.tasks.length} catalogBytes=${catalogBytes.byteLength} output=${relative(indexPath)}\n`);

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
lattice = bpy.data.lattices.new("WebGapLattice")
lattice.points_u = 3
lattice.points_v = 2
lattice.points_w = 2
lattice.interpolation_type_u = "KEY_LINEAR"
lattice.interpolation_type_v = "KEY_CARDINAL"
lattice.interpolation_type_w = "KEY_BSPLINE"
lattice.use_outside = True
lattice.vertex_group = "WebGapInfluence"
lattice_object = bpy.data.objects.new("WebGapLatticeObject", lattice)
bpy.context.collection.objects.link(lattice_object)
for index, point in enumerate(lattice.points):
point.co_deform = (
point.co.x + 0.125 * (index % 3),
point.co.y - 0.0625 * (index % 2),
point.co.z + 0.25 * (index // 6),
)
point.weight_softbody = 0.5 + index * 0.125
point.select = index in {0, len(lattice.points) - 1}
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00010.py -- OUTPUT")
main(pathlib.Path(arguments[0]).resolve())

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
LIBRARY_NAME = "WebGapLibrary.blend"
OBJECT_NAME = "WebGapLibraryObject"
def reset() -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_source(path: pathlib.Path) -> None:
reset()
mesh = bpy.data.meshes.new("WebGapLibraryMesh")
mesh.from_pydata([(-1.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
bpy.context.scene.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False, compress=True)
def create_fixture(output: pathlib.Path) -> None:
source = output.with_name(LIBRARY_NAME)
create_source(source)
reset()
with bpy.data.libraries.load(str(source), link=True) as (data_from, data_to):
if OBJECT_NAME not in data_from.objects:
raise RuntimeError("source object is missing")
data_to.objects = [OBJECT_NAME]
linked = data_to.objects[0]
if linked is None:
raise RuntimeError("linked object is missing")
bpy.context.scene.collection.objects.link(linked)
bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False, compress=True)
def main(output: pathlib.Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
create_fixture(output.resolve())
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00011.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
light = bpy.data.lights.new("WebGapLight", type="AREA")
light.color = (0.25, 0.5, 0.75)
light.energy = 400.0
light.exposure = 1.0
light.temperature = 5000.0
light.use_temperature = True
light.use_shadow = False
light.shadow_soft_size = 0.3
light.shape = "RECTANGLE"
light.size = 3.0
light.size_y = 2.0
light.spread = 2.4
light_object = bpy.data.objects.new("WebGapLightObject", light)
bpy.context.collection.objects.link(light_object)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00012.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def set_socket(node, name, value):
socket = node.inputs.get(name)
if socket is not None:
socket.default_value = value
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
material = bpy.data.materials.new("WebGapMaterial")
material.use_nodes = True
material.diffuse_color = (0.2, 0.4, 0.8, 0.9)
nodes = material.node_tree.nodes
links = material.node_tree.links
principled = nodes.get("Principled BSDF")
output_node = nodes.get("Material Output")
if principled is None or output_node is None:
raise RuntimeError("default Principled/Output nodes are missing")
set_socket(principled, "Base Color", (0.2, 0.4, 0.8, 0.9))
set_socket(principled, "Metallic", 0.35)
set_socket(principled, "Roughness", 0.6)
set_socket(principled, "IOR", 1.6)
set_socket(principled, "Specular IOR Level", 0.35)
set_socket(principled, "Transmission Weight", 0.27)
set_socket(principled, "Coat Weight", 0.64)
set_socket(principled, "Coat Roughness", 0.12)
set_socket(principled, "Emission Color", (0.05, 0.1, 0.2, 1.0))
set_socket(principled, "Emission Strength", 3.5)
set_socket(principled, "Alpha", 0.9)
if not any(link.from_node == principled and link.to_node == output_node for link in links):
links.new(principled.outputs["BSDF"], output_node.inputs["Surface"])
mesh = bpy.data.meshes.new("WebGapMaterialMesh")
mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
mesh.materials.append(material)
obj = bpy.data.objects.new("WebGapMaterialObject", mesh)
bpy.context.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00013.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.25), (-1.0, 1.0, 0.25)],
[],
[(0, 1, 2, 3)],
)
uv_layer = mesh.uv_layers.new(name="WebGapUV")
for loop, uv in zip(uv_layer.data, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]):
loop.uv = uv
mesh.update()
mesh_object = bpy.data.objects.new("WebGapMeshObject", mesh)
bpy.context.collection.objects.link(mesh_object)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00014.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
data = bpy.data.metaballs.new("WebGapMetaBall")
data.resolution = 0.2
data.render_resolution = 0.1
first = data.elements.new()
first.co = (-0.65, 0.0, 0.4)
first.radius = 0.75
first.size_x = 1.1
first.size_y = 0.9
first.size_z = 1.2
second = data.elements.new()
second.co = (0.65, 0.0, 0.4)
second.radius = 0.55
second.size_x = 0.8
second.size_y = 1.05
second.size_z = 0.95
obj = bpy.data.objects.new("WebGapMetaBallObject", data)
bpy.context.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00015.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
material = bpy.data.materials.new("WebGapNodeTreeMaterial")
material.use_nodes = True
nodes = material.node_tree.nodes
links = material.node_tree.links
principled = nodes.get("Principled BSDF")
output_node = nodes.get("Material Output")
if principled is None or output_node is None:
raise RuntimeError("default shader nodes are missing")
rgb = nodes.new("ShaderNodeRGB")
rgb.name = "WebGapRGB"
rgb.outputs["Color"].default_value = (0.1, 0.3, 0.7, 1.0)
links.new(rgb.outputs["Color"], principled.inputs["Base Color"])
links.new(principled.outputs["BSDF"], output_node.inputs["Surface"])
mesh = bpy.data.meshes.new("WebGapNodeTreeMesh")
mesh.from_pydata([(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)], [], [(0, 1, 2)])
mesh.materials.append(material)
obj = bpy.data.objects.new("WebGapNodeTreeObject", mesh)
bpy.context.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00016.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
obj = bpy.data.objects.new("WebGapObject", None)
obj.location = (1.25, -2.5, 3.75)
obj.rotation_mode = "XYZ"
obj.rotation_euler = (0.2, -0.35, 0.5)
obj.scale = (1.5, 0.75, 2.0)
obj.hide_viewport = False
obj.hide_render = False
obj.hide_set(False)
bpy.context.collection.objects.link(obj)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00017.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
settings = bpy.data.particles.new("WebGapParticleSettings")
settings.use_fake_user = True
settings.type = "EMITTER"
settings.emit_from = "FACE"
settings.physics_type = "NEWTON"
settings.distribution = "JIT"
settings.count = 128
settings.frame_start = 4.0
settings.frame_end = 96.0
settings.lifetime = 32.0
settings.particle_size = 0.125
settings.display_size = 0.25
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00018.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapPointCloudSeed")
mesh.from_pydata([(-1.0, 0.0, 0.0), (0.0, 1.0, 0.5), (1.0, 0.0, 1.0), (0.0, -1.0, 1.5)], [], [])
obj = bpy.data.objects.new("WebGapPointCloudObject", mesh)
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.convert(target="POINTCLOUD")
data = obj.data
data.name = "WebGapPointCloud"
radius = data.attributes.new("radius", "FLOAT", "POINT")
radius.data.foreach_set("value", [0.25, 0.5, 0.75, 1.0])
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00019.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.name = "WebGapScene"
scene.frame_start = 12
scene.frame_end = 180
scene.frame_set(48)
scene.render.fps = 30
scene.render.fps_base = 1.0
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 0.25
scene.render.engine = "BLENDER_EEVEE"
scene.view_settings.look = "AgX - Medium High Contrast"
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00020.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
screen = bpy.context.screen
if screen is None:
raise RuntimeError("factory startup did not create a screen")
screen.name = "WebGapScreen"
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00021.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
source = pathlib.Path(__file__).resolve().parents[3] / "tests/files/web/media/sequencer-silence.wav"
sound = bpy.data.sounds.load(str(source), check_existing=False)
sound.name = "WebGapSound"
sound.filepath = "//WebGapSound.wav"
sound.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00022.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
source = pathlib.Path(__file__).resolve().parents[3] / "tests/files/web/media/sequencer-silence.wav"
sound = bpy.data.sounds.load(str(source), check_existing=False)
sound.name = "WebGapSpeakerSound"
sound.use_fake_user = True
speaker = bpy.data.speakers.new("WebGapSpeaker")
speaker.sound = sound
speaker.volume_max = 0.85
speaker.volume_min = 0.15
speaker.distance_max = 12.5
speaker.distance_reference = 2.25
speaker.attenuation = 0.75
speaker.cone_angle_outer = 270.0
speaker.cone_angle_inner = 120.0
speaker.cone_volume_outer = 0.35
speaker.volume = 0.65
speaker.pitch = 1.1
speaker.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00023.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
text = bpy.data.texts.new("WebGapText")
text.write("Web Blender text datablock\nSecond line\n")
text.filepath = "//WebGapText.txt"
text.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00024.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
texture = bpy.data.textures.new("WebGapTexture", type="CLOUDS")
texture.noise_scale = 0.42
texture.noise_depth = 4
texture.intensity = 0.72
texture.contrast = 1.25
texture.saturation = 0.8
texture.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00025.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
volume = bpy.data.volumes.new("WebGapVolume")
volume.filepath = "//WebGapVolume.vdb"
volume.display.density = 2.5
volume.display.interpolation_method = "CLOSEST"
volume.render.step_size = 0.125
volume.use_fake_user = True
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00026.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.window_manager.preset_name = "WebGapWindowManager"
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00027.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
workspace = bpy.data.workspaces.get("Layout")
if workspace is not None:
workspace.name = "WebGapWorkspace"
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00028.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
world = bpy.data.worlds.new("WebGapWorld")
world.color = (0.12, 0.2, 0.35)
world.use_fake_user = True
world.mist_settings.use_mist = True
world.mist_settings.start = 3.0
world.mist_settings.depth = 18.0
world.mist_settings.intensity = 0.4
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1: raise SystemExit("usage: blender -b --python M16-GAP-00029.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapArmatureMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2), (0, 2, 3)],
)
mesh.update()
mesh_object = bpy.data.objects.new("WebGapArmatureMeshObject", mesh)
bpy.context.collection.objects.link(mesh_object)
armature = bpy.data.armatures.new("WebGapArmature")
armature_object = bpy.data.objects.new("WebGapArmatureObject", armature)
bpy.context.collection.objects.link(armature_object)
bpy.context.view_layer.objects.active = armature_object
armature_object.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
root = armature.edit_bones.new("Root")
root.head = (0.0, 0.0, 0.0)
root.tail = (0.0, 0.0, 1.0)
bpy.ops.object.mode_set(mode="OBJECT")
armature_object.select_set(False)
vertex_group = mesh_object.vertex_groups.new(name="Root")
vertex_group.add([0, 1, 2, 3], 1.0, "REPLACE")
modifier = mesh_object.modifiers.new(name="WebGapArmature", type="ARMATURE")
modifier.object = armature_object
modifier.vertex_group = "Root"
modifier.show_viewport = True
modifier.show_render = True
modifier.show_in_editmode = True
modifier.show_on_cage = False
bpy.context.view_layer.objects.active = mesh_object
mesh_object.select_set(True)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00030.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def mesh_object(name: str, vertices, faces):
mesh = bpy.data.meshes.new(name + "Mesh")
mesh.from_pydata(vertices, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
return obj
def main(output: pathlib.Path) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
source = mesh_object(
"WebGapArrayObject",
[(-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)],
[(0, 1, 2, 3)],
)
start_cap = mesh_object(
"WebGapArrayStartCap",
[(-0.4, -0.4, 0.0), (0.4, -0.4, 0.0), (0.0, 0.4, 0.0)],
[(0, 1, 2)],
)
end_cap = mesh_object(
"WebGapArrayEndCap",
[(-0.4, -0.4, 0.0), (0.4, -0.4, 0.0), (0.0, 0.4, 0.0)],
[(0, 1, 2)],
)
start_cap.location.x = -2.0
end_cap.location.x = 5.0
modifier = source.modifiers.new(name="WebGapArray", type="ARRAY")
modifier.count = 4
modifier.relative_offset_displace = (1.25, 0.0, 0.0)
modifier.start_cap = start_cap
modifier.end_cap = end_cap
modifier.show_viewport = True
modifier.show_render = False
modifier.show_in_editmode = True
modifier.show_on_cage = False
bpy.context.view_layer.objects.active = source
source.select_set(True)
bpy.ops.wm.save_as_mainfile(filepath=str(output.resolve()), check_existing=False, compress=True)
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python M16-GAP-00031.py -- OUTPUT")
main(pathlib.Path(arguments[0]))

Some files were not shown because too many files have changed in this diff Show More