Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
136
tools/web/check-asset-catalog-field-inventory.mjs
Normal file
136
tools/web/check-asset-catalog-field-inventory.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-01A/asset-catalog-field-inventory.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const sourceByRole = new Map();
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.task, "M12-01A");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.blenderVersion, "5.2.0");
|
||||
assert.equal(manifest.nextTask, "M12-01B");
|
||||
|
||||
const expectedRoles = [
|
||||
"DNA_ASSET_STORAGE",
|
||||
"RNA_ASSET_API",
|
||||
"ASSET_BLEND_IO",
|
||||
"CATALOG_MODEL",
|
||||
"CATALOG_PATH",
|
||||
"CATALOG_FILE_API",
|
||||
"CATALOG_FILE_FORMAT",
|
||||
"CURRENT_WEB_V1_SNAPSHOT",
|
||||
];
|
||||
assert.deepEqual(manifest.sources.map((source) => source.role), expectedRoles);
|
||||
for (const source of manifest.sources) {
|
||||
const absolutePath = path.join(root, source.path);
|
||||
const bytes = fs.readFileSync(absolutePath);
|
||||
assert.equal(sha256(bytes), source.sha256, `${source.role} source hash drifted`);
|
||||
sourceByRole.set(source.role, bytes.toString("utf8"));
|
||||
}
|
||||
|
||||
const expectedTagFields = ["next", "prev", "name"];
|
||||
const expectedDNAFields = [
|
||||
"local_type_info",
|
||||
"properties",
|
||||
"catalog_id",
|
||||
"catalog_simple_name",
|
||||
"author",
|
||||
"description",
|
||||
"copyright",
|
||||
"license",
|
||||
"tags",
|
||||
"active_tag",
|
||||
"tot_tags",
|
||||
"flag",
|
||||
"preferred_import_method",
|
||||
"_pad",
|
||||
];
|
||||
assert.deepEqual(manifest.assetTag.fields.map((field) => field.name), expectedTagFields);
|
||||
assert.deepEqual(manifest.assetMetaData.dnaFields.map((field) => field.name), expectedDNAFields);
|
||||
assert.equal(new Set(expectedDNAFields).size, manifest.assetMetaData.dnaFields.length);
|
||||
|
||||
const dna = sourceByRole.get("DNA_ASSET_STORAGE");
|
||||
for (const field of [...manifest.assetTag.fields, ...manifest.assetMetaData.dnaFields]) {
|
||||
assert.ok(dna.includes(field.sourceToken), `DNA token for ${field.name} is missing`);
|
||||
}
|
||||
assert.equal(manifest.assetTag.fields.find((field) => field.name === "name").maximumStorageBytesIncludingNull, 64);
|
||||
assert.equal(manifest.assetMetaData.dnaFields.find((field) => field.name === "properties").semantic, "CUSTOM_METADATA_NO_ID_POINTERS");
|
||||
assert.equal(manifest.assetMetaData.dnaFields.find((field) => field.name === "catalog_simple_name").semantic, "RECOVERY_ONLY_NOT_AUTHORITY");
|
||||
|
||||
const rna = sourceByRole.get("RNA_ASSET_API");
|
||||
const assetDataDefinition = rna.slice(rna.indexOf("static void rna_def_asset_data"), rna.indexOf("static void rna_def_asset_representation"));
|
||||
const sourceRNAProperties = [...assetDataDefinition.matchAll(/RNA_def_property\(srna, "([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.deepEqual(sourceRNAProperties, manifest.assetMetaData.rnaProperties.map((property) => property.identifier));
|
||||
assert.match(rna, /RNA_def_property_string_maxlength\(prop, MAX_NAME\)/);
|
||||
assert.match(rna, /RNA_def_function\(srna, "new", "rna_AssetMetaData_tag_new"\)/);
|
||||
assert.match(rna, /RNA_def_function\(srna, "remove", "rna_AssetMetaData_tag_remove"\)/);
|
||||
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
const python = [
|
||||
"import bpy,json",
|
||||
"props=[{'identifier':p.identifier,'type':p.type,'isReadonly':p.is_readonly,'isRuntime':p.is_runtime} for p in bpy.types.AssetMetaData.bl_rna.properties if p.identifier != 'rna_type']",
|
||||
"print('M12_RNA='+json.dumps(props,separators=(',',':')))",
|
||||
].join(";");
|
||||
const runtimeOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", python], { encoding: "utf8" });
|
||||
const runtimeLine = runtimeOutput.split(/\r?\n/).find((line) => line.startsWith("M12_RNA="));
|
||||
assert.ok(runtimeLine, "Blender AssetMetaData RNA report is missing");
|
||||
assert.deepEqual(JSON.parse(runtimeLine.slice("M12_RNA=".length)), manifest.assetMetaData.rnaProperties);
|
||||
|
||||
const blendIO = sourceByRole.get("ASSET_BLEND_IO");
|
||||
for (const value of ["properties", "author", "description", "copyright", "license", "tags"]) {
|
||||
assert.match(blendIO, new RegExp(`asset_data->${value}`), `${value} is not bound to Blend IO`);
|
||||
}
|
||||
assert.match(blendIO, /asset_data->tags\.count\(\) == asset_data->tot_tags/);
|
||||
|
||||
const catalog = sourceByRole.get("CATALOG_MODEL");
|
||||
for (const field of [...manifest.catalog.semanticFields, ...manifest.catalog.runtimeFlags]) {
|
||||
assert.ok(catalog.includes(field.sourceToken), `catalog token for ${field.name} is missing`);
|
||||
}
|
||||
assert.deepEqual(manifest.catalog.definitionFile.recordFields, ["catalog_id", "path", "simple_name"]);
|
||||
assert.deepEqual(manifest.catalog.definitionFile.writeOrder, ["path", "is_first_loaded", "catalog_id"]);
|
||||
assert.deepEqual(manifest.catalog.identityRules.duplicatePathSelectionOrder, ["is_first_loaded", "catalog_id"]);
|
||||
|
||||
const catalogFile = sourceByRole.get("CATALOG_FILE_FORMAT");
|
||||
assert.match(catalogFile, /SUPPORTED_VERSION = 1/);
|
||||
assert.match(catalogFile, /VERSION_MARKER = "VERSION "/);
|
||||
assert.match(catalogFile, /catalog->catalog_id << ":" << catalog->path << ":" << catalog->simple_name/);
|
||||
const catalogPath = sourceByRole.get("CATALOG_PATH");
|
||||
for (const statement of [
|
||||
"Only slashes are used as path component separators",
|
||||
"Paths are stored as byte sequences, and assumed to be UTF8",
|
||||
"Empty components (caused by double slashes or leading/trailing slashes) are removed",
|
||||
]) assert.ok(catalogPath.includes(statement));
|
||||
|
||||
const webPath = path.join(root, manifest.sources.find((source) => source.role === "CURRENT_WEB_V1_SNAPSHOT").path);
|
||||
const webSource = ts.createSourceFile(webPath, fs.readFileSync(webPath, "utf8"), ts.ScriptTarget.Latest, true);
|
||||
function interfaceFields(name) {
|
||||
const declaration = webSource.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
|
||||
assert.ok(declaration, `missing Web interface ${name}`);
|
||||
return declaration.members.filter(ts.isPropertySignature).map((member) => member.name.getText(webSource));
|
||||
}
|
||||
assert.deepEqual(interfaceFields("AssetCatalogIR"), manifest.currentWebSnapshot.assetCatalogIRFields);
|
||||
assert.deepEqual(interfaceFields("AssetEntryIR"), manifest.currentWebSnapshot.assetEntryIRFields);
|
||||
assert.deepEqual(manifest.currentWebSnapshot.missingMetadata, [
|
||||
"properties",
|
||||
"description",
|
||||
"copyright",
|
||||
"catalog_simple_name",
|
||||
"active_tag",
|
||||
"use_preferred_import_method",
|
||||
"preferred_import_method",
|
||||
]);
|
||||
assert.equal(manifest.currentWebSnapshot.semanticDrift.length, 5);
|
||||
|
||||
process.stdout.write(
|
||||
`asset-catalog-field-inventory-ok dna=${expectedDNAFields.length} rna=${sourceRNAProperties.length} tag=${expectedTagFields.length} catalog=${manifest.catalog.semanticFields.length} gaps=${manifest.currentWebSnapshot.missingMetadata.length} next=${manifest.nextTask}\n`,
|
||||
);
|
||||
67
tools/web/check-asset-catalog-m12-evidence.mjs
Normal file
67
tools/web/check-asset-catalog-m12-evidence.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
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 read = (file) => JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
|
||||
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const stableJSON = (value) => {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
|
||||
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
const sha256Value = (value) => crypto.createHash("sha256").update(stableJSON(value)).digest("hex");
|
||||
const checkArtifact = (artifact) => assert.equal(sha256File(artifact.path), artifact.sha256, artifact.path);
|
||||
|
||||
const evidence = read("tests/golden/M12-01/evidence.json");
|
||||
assert.equal(evidence.task, "M12-01I");
|
||||
assert.equal(evidence.status, "READY");
|
||||
assert.equal(evidence.enablingTask, true);
|
||||
assert.equal(evidence.parityStateChange, false);
|
||||
assert.equal(evidence.nextTask, "M12-02A");
|
||||
assert.equal(evidence.subtasks.length, 8);
|
||||
|
||||
const taskNames = evidence.subtasks.map((entry) => entry.task);
|
||||
assert.deepEqual(taskNames, ["M12-01A", "M12-01B", "M12-01C", "M12-01D", "M12-01E", "M12-01F", "M12-01G", "M12-01H"]);
|
||||
evidence.subtasks.forEach((entry, index) => {
|
||||
checkArtifact(entry);
|
||||
const manifest = read(entry.path);
|
||||
assert.equal(manifest.task, entry.task);
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, index === evidence.subtasks.length - 1 ? "M12-01I" : evidence.subtasks[index + 1].task);
|
||||
if (Array.isArray(manifest.sources)) manifest.sources.forEach(checkArtifact);
|
||||
if (record(manifest.artifacts)) Object.values(manifest.artifacts).forEach(checkArtifact);
|
||||
});
|
||||
|
||||
Object.values(evidence.schema).forEach(checkArtifact);
|
||||
for (const key of ["sourceFixture", "targetFixture", "report", "productionProtocol"]) checkArtifact(evidence.migration[key]);
|
||||
for (const key of ["indexedDB", "restartWorker"]) checkArtifact(evidence.runtime[key]);
|
||||
|
||||
const source = read(evidence.migration.sourceFixture.path);
|
||||
const target = read(evidence.migration.targetFixture.path);
|
||||
const report = read(evidence.migration.report.path);
|
||||
assert.equal(source.schemaVersion, 1);
|
||||
assert.equal(target.schemaVersion, 2);
|
||||
assert.equal(source.revision, evidence.migration.revision);
|
||||
assert.equal(target.revision, evidence.migration.revision);
|
||||
assert.equal(sha256Value(source), evidence.migration.sourceManifestSha256);
|
||||
assert.equal(sha256Value(target), evidence.migration.targetManifestSha256);
|
||||
assert.equal(report.sourceManifestSha256, evidence.migration.sourceManifestSha256);
|
||||
assert.equal(report.targetManifestSha256, evidence.migration.targetManifestSha256);
|
||||
assert.equal(report.sourceRevision, evidence.migration.revision);
|
||||
assert.equal(report.targetRevision, evidence.migration.revision);
|
||||
assert.equal(report.preserved.catalogs, evidence.runtime.catalogCount);
|
||||
assert.equal(report.preserved.assets, evidence.runtime.assetCount);
|
||||
|
||||
const restart = read("tests/golden/M12-01H/manifest.json").restart;
|
||||
assert.deepEqual(restart.catalogOrder, target.catalogs.map((catalog) => catalog.catalogId));
|
||||
assert.deepEqual(restart.assetOrder, target.assets.map((asset) => asset.assetId));
|
||||
assert.equal(restart.manifestSha256, evidence.migration.targetManifestSha256);
|
||||
assert.deepEqual(restart.contexts, evidence.runtime.restartContexts);
|
||||
assert.deepEqual(read("tests/golden/M12-01G/manifest.json").transaction.faultPoints, evidence.runtime.faultPoints);
|
||||
|
||||
process.stdout.write(`asset-catalog-m12-evidence-ok subtasks=${evidence.subtasks.length} catalogs=${evidence.runtime.catalogCount} assets=${evidence.runtime.assetCount} next=${evidence.nextTask}\n`);
|
||||
80
tools/web/check-asset-catalog-v1-fixture.mjs
Normal file
80
tools/web/check-asset-catalog-v1-fixture.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(root, "tests/golden/M12-01B/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const artifact = (name) => path.join(root, manifest.artifacts[name].path);
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.task, "M12-01B");
|
||||
assert.equal(manifest.enablingTask, true);
|
||||
assert.equal(manifest.parityStateChange, false);
|
||||
assert.equal(manifest.nextTask, "M12-01C");
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
for (const name of ["generator", "exporter", "checker", "fixture", "catalogDefinition", "canonicalReport"]) {
|
||||
assert.equal(sha256(artifact(name)), manifest.artifacts[name].sha256, `${name} hash drifted`);
|
||||
}
|
||||
|
||||
const expected = JSON.parse(fs.readFileSync(artifact("canonicalReport"), "utf8"));
|
||||
assert.equal(expected.schemaVersion, 1);
|
||||
assert.equal(expected.blenderVersion, "5.2.0");
|
||||
assert.equal(expected.catalogDefinition.version, 1);
|
||||
assert.equal(expected.catalogDefinition.records.length, 3);
|
||||
assert.deepEqual(expected.catalogDefinition.records.map((item) => item.path), ["Characters", "Characters/Heroes", "Materials/Metal"]);
|
||||
assert.deepEqual(expected.catalogDefinition.records.map((item) => item.parentPath), [null, "Characters", "Materials"]);
|
||||
assert.equal(expected.assets.length, 3);
|
||||
assert.deepEqual(expected.assets.map((item) => `${item.idType}:${item.name}`), [
|
||||
"MATERIAL:M12 Brushed Metal",
|
||||
"OBJECT:M12 Hero",
|
||||
"WORLD:M12 Uncataloged World",
|
||||
]);
|
||||
const hero = expected.assets.find((item) => item.name === "M12 Hero");
|
||||
assert.deepEqual(hero.tags, ["character", "hero", "rig-ready"]);
|
||||
assert.equal(hero.activeTag, 1);
|
||||
assert.equal(hero.catalogId, "22222222-2222-4222-8222-222222222222");
|
||||
assert.equal(hero.catalogSimpleName, "");
|
||||
assert.equal(hero.usePreferredImportMethod, true);
|
||||
assert.equal(hero.preferredImportMethod, "APPEND");
|
||||
assert.deepEqual(hero.customProperties.map((item) => item.name), ["approved", "dimensions", "rating", "source"]);
|
||||
assert.deepEqual(hero.customProperties.find((item) => item.name === "dimensions").value, [2, 2, 0]);
|
||||
const material = expected.assets.find((item) => item.name === "M12 Brushed Metal");
|
||||
assert.equal(material.author, "");
|
||||
assert.equal(material.license, "");
|
||||
const world = expected.assets.find((item) => item.name === "M12 Uncataloged World");
|
||||
assert.equal(world.catalogId, "00000000-0000-0000-0000-000000000000");
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-asset-catalog-v1-"));
|
||||
try {
|
||||
execFileSync(blender, [
|
||||
"--background", "--factory-startup", "--python", artifact("generator"), "--", temporary,
|
||||
], { cwd: root, stdio: "pipe" });
|
||||
const generatedFixture = path.join(temporary, "m12_asset_catalog_v1.blend");
|
||||
const generatedCatalog = path.join(temporary, "blender_assets.cats.txt");
|
||||
const generatedReport = path.join(temporary, "canonical.json");
|
||||
assert.ok(fs.statSync(generatedFixture).size > 0);
|
||||
assert.equal(fs.readFileSync(generatedCatalog, "utf8"), fs.readFileSync(artifact("catalogDefinition"), "utf8"));
|
||||
execFileSync(blender, [
|
||||
"--background", generatedFixture, "--python", artifact("exporter"), "--", generatedCatalog, generatedReport,
|
||||
], { cwd: root, stdio: "pipe" });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(generatedReport, "utf8")), expected);
|
||||
|
||||
const reopenedReport = path.join(temporary, "checked-in-canonical.json");
|
||||
execFileSync(blender, [
|
||||
"--background", artifact("fixture"), "--python", artifact("exporter"), "--",
|
||||
artifact("catalogDefinition"), reopenedReport,
|
||||
], { cwd: root, stdio: "pipe" });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(reopenedReport, "utf8")), expected);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`asset-catalog-v1-fixture-ok catalogs=3 assets=3 metadata=complete next=${manifest.nextTask}\n`);
|
||||
48
tools/web/check-asset-preview-display.mjs
Normal file
48
tools/web/check-asset-preview-display.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/manifest.json"), "utf8"));
|
||||
const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02H/desktop-report.json"), "utf8"));
|
||||
const content = path.join(root, "tests/golden/M12-02B/preview.png");
|
||||
const generator = path.join(root, "tools/web/generate-asset-preview-identity.py");
|
||||
const source = path.join(root, "tests/files/web/media/sequencer-frame.png");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-preview-display-"));
|
||||
|
||||
try {
|
||||
const regenerated = path.join(temporary, "preview.png");
|
||||
const raw = path.join(temporary, "preview.rgba8");
|
||||
const output = execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", source, regenerated], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.match(output, /asset-preview-generated width=8 height=8/);
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
assert.equal(sha256(fs.readFileSync(regenerated)), report.contentSha256);
|
||||
execFileSync("magick", [regenerated, "-depth", "8", `RGBA:${raw}`], { cwd: root, stdio: "pipe" });
|
||||
const pixels = fs.readFileSync(raw);
|
||||
assert.equal(pixels.byteLength, report.pixelCount * 4);
|
||||
assert.equal(sha256(pixels), report.rgbaSha256);
|
||||
const unique = new Set();
|
||||
for (let offset = 0; offset < pixels.byteLength; offset += 4) {
|
||||
const pixel = Array.from(pixels.subarray(offset, offset + 4));
|
||||
unique.add(pixel.join(","));
|
||||
assert.deepEqual(pixel, report.referencePixel);
|
||||
}
|
||||
assert.equal(unique.size, report.uniquePixelCount);
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`asset-preview-display-desktop-ok size=${report.width}x${report.height} rgba=${report.rgbaSha256} next=${manifest.nextTask}\n`);
|
||||
50
tools/web/check-asset-preview-identity.mjs
Normal file
50
tools/web/check-asset-preview-identity.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/manifest.json"), "utf8"));
|
||||
const identity = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02B/identity.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, manifest.artifacts.blender.path);
|
||||
const source = path.join(root, manifest.artifacts.source.path);
|
||||
const generator = path.join(root, manifest.artifacts.generator.path);
|
||||
const golden = path.join(root, manifest.artifacts.content.path);
|
||||
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
assert.equal(sha256(blender), identity.generator.executableSha256);
|
||||
assert.equal(sha256(generator), identity.generator.scriptSha256);
|
||||
assert.equal(sha256(source), identity.source.sha256);
|
||||
assert.equal(fs.statSync(source).size, identity.source.byteLength);
|
||||
assert.equal(sha256(golden), identity.content.sha256);
|
||||
assert.equal(fs.statSync(golden).size, identity.content.byteLength);
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-asset-preview-identity-"));
|
||||
try {
|
||||
const first = path.join(temporary, "first.png");
|
||||
const second = path.join(temporary, "second.png");
|
||||
for (const output of [first, second]) {
|
||||
execFileSync(blender, ["--background", "--factory-startup", "--python", generator, "--", source, output], { cwd: root, stdio: "pipe" });
|
||||
assert.equal(sha256(output), identity.content.sha256);
|
||||
assert.equal(fs.statSync(output).size, identity.content.byteLength);
|
||||
}
|
||||
const probe = [
|
||||
"import bpy,json",
|
||||
`a=bpy.data.images.load(${JSON.stringify(source)},check_existing=False)`,
|
||||
`b=bpy.data.images.load(${JSON.stringify(first)},check_existing=False)`,
|
||||
"print('M12_PREVIEW_COMPARE='+json.dumps({'source':list(a.size),'content':list(b.size),'max':max(abs(x-y) for x,y in zip(a.pixels[:],b.pixels[:]))},separators=(',',':')))",
|
||||
].join(";");
|
||||
const output = execFileSync(blender, ["--background", "--factory-startup", "--python-expr", probe], { cwd: root, encoding: "utf8" });
|
||||
const line = output.split(/\r?\n/).find((item) => item.startsWith("M12_PREVIEW_COMPARE="));
|
||||
assert.ok(line);
|
||||
assert.deepEqual(JSON.parse(line.slice("M12_PREVIEW_COMPARE=".length)), { source: [8, 8], content: [8, 8], max: 0 });
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`asset-preview-identity-ok source=${identity.source.sha256} content=${identity.content.sha256} size=${identity.content.width}x${identity.content.height} next=${manifest.nextTask}\n`);
|
||||
98
tools/web/check-asset-preview-inventory.mjs
Normal file
98
tools/web/check-asset-preview-inventory.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-02A/asset-preview-inventory.json"), "utf8"));
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
|
||||
assert.equal(inventory.task, "M12-02A");
|
||||
assert.equal(inventory.enablingTask, true);
|
||||
assert.equal(inventory.parityStateChange, false);
|
||||
assert.equal(inventory.blenderVersion, "5.2.0");
|
||||
assert.equal(inventory.nextTask, "M12-02B");
|
||||
for (const item of [...inventory.sources, ...inventory.fixtures]) assert.equal(sha256(item.path), item.sha256, item.path);
|
||||
|
||||
const source = (role) => fs.readFileSync(path.join(root, inventory.sources.find((item) => item.role === role).path), "utf8");
|
||||
const dna = source("PREVIEW_DNA");
|
||||
for (const token of ["unsigned int w[2]", "unsigned int h[2]", "short flag[2]", "short changed_timestamp[2]", "unsigned int *rect[2]", "PreviewImageRuntime *runtime"]) {
|
||||
assert.ok(dna.includes(token), `PreviewImage DNA token is missing: ${token}`);
|
||||
}
|
||||
const slots = source("PREVIEW_SLOT_ENUM");
|
||||
assert.match(slots, /ICON_SIZE_ICON = 0/);
|
||||
assert.match(slots, /ICON_SIZE_PREVIEW = 1/);
|
||||
assert.equal(inventory.storage.slotCount, 2);
|
||||
|
||||
const implementation = source("PREVIEW_IMPLEMENTATION");
|
||||
assert.match(implementation, /PreviewImage assumes pre-multiplied alpha/);
|
||||
assert.match(implementation, /writer->write_uint32_array\(prv_copy\.w\[0\] \* prv_copy\.h\[0\]/);
|
||||
assert.match(implementation, /writer->write_uint32_array\(prv_copy\.w\[1\] \* prv_copy\.h\[1\]/);
|
||||
assert.match(implementation, /prv->flag\[i\] &= ~PRV_RENDERING/);
|
||||
assert.equal(inventory.storage.colorSpace, null);
|
||||
assert.ok(!inventory.storage.fields.some((field) => /color/i.test(field.name)));
|
||||
|
||||
const rnaSource = source("PREVIEW_RNA");
|
||||
assert.match(rnaSource, /Image pixels, as bytes \(always 32-bit RGBA\)/);
|
||||
assert.match(rnaSource, /length\[0\] = prv_img->w\[size\] \* prv_img->h\[size\] \* 4/);
|
||||
assert.match(rnaSource, /values\[i\] = data\[i\] \* \(1\.0f \/ 255\.0f\)/);
|
||||
assert.doesNotMatch(rnaSource.slice(rnaSource.indexOf("static void rna_def_image_preview"), rnaSource.indexOf("static void rna_def_image_user")), /color.?space/i);
|
||||
|
||||
const parseInterface = (file, name) => {
|
||||
const absolute = path.join(root, file);
|
||||
const parsed = ts.createSourceFile(absolute, fs.readFileSync(absolute, "utf8"), ts.ScriptTarget.Latest, true);
|
||||
const declaration = parsed.statements.find((statement) => ts.isInterfaceDeclaration(statement) && statement.name.text === name);
|
||||
assert.ok(declaration, `${name} is missing`);
|
||||
return declaration.members.filter(ts.isPropertySignature).map((member) => member.name.getText(parsed));
|
||||
};
|
||||
assert.deepEqual(parseInterface("web/protocol/asset-library-io.ts", "AssetPreviewIR"), inventory.currentWeb.v1Fields);
|
||||
assert.deepEqual(parseInterface("web/protocol/asset-catalog-v2.ts", "AssetCatalogV2PreviewIR"), inventory.currentWeb.v2Fields);
|
||||
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
const rnaProbe = [
|
||||
"import bpy,json",
|
||||
"props=[{'identifier':p.identifier,'type':p.type,'readOnly':p.is_readonly,'arrayLength':p.array_length} for p in bpy.types.ImagePreview.bl_rna.properties if p.identifier != 'rna_type']",
|
||||
"idp=bpy.types.ID.bl_rna.properties['preview']",
|
||||
"print('M12_PREVIEW_RNA='+json.dumps({'idProperty':{'identifier':idp.identifier,'type':idp.type,'readOnly':idp.is_readonly,'nullWhenAbsent':True},'imagePreviewProperties':props},separators=(',',':')))",
|
||||
].join(";");
|
||||
const rnaOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", rnaProbe], { encoding: "utf8" });
|
||||
const rnaLine = rnaOutput.split(/\r?\n/).find((line) => line.startsWith("M12_PREVIEW_RNA="));
|
||||
assert.ok(rnaLine);
|
||||
assert.deepEqual(JSON.parse(rnaLine.slice("M12_PREVIEW_RNA=".length)), {
|
||||
idProperty: inventory.rna.idProperty,
|
||||
imagePreviewProperties: inventory.rna.imagePreviewProperties,
|
||||
});
|
||||
|
||||
const noPreviewScript = [
|
||||
"import bpy,json",
|
||||
"items=[]",
|
||||
"groups=(bpy.data.objects,bpy.data.materials,bpy.data.worlds)",
|
||||
"[items.append({'type':item.bl_rna.identifier,'preview':None if item.preview is None else list(item.preview.image_size)}) for group in groups for item in group if item.asset_data]",
|
||||
"print('M12_NO_PREVIEW='+json.dumps(items,separators=(',',':')))",
|
||||
].join(";");
|
||||
const noPreviewOutput = execFileSync(blender, ["--factory-startup", "--background", path.join(root, inventory.fixtures[1].path), "--python-expr", noPreviewScript], { encoding: "utf8" });
|
||||
const noPreviewLine = noPreviewOutput.split(/\r?\n/).find((line) => line.startsWith("M12_NO_PREVIEW="));
|
||||
assert.ok(noPreviewLine);
|
||||
const noPreview = JSON.parse(noPreviewLine.slice("M12_NO_PREVIEW=".length));
|
||||
assert.deepEqual(noPreview.map((item) => item.type), inventory.runtimeStates.noPreview.assetTypes);
|
||||
assert.deepEqual(noPreview.map((item) => item.preview), inventory.runtimeStates.noPreview.previewValues);
|
||||
|
||||
const png = path.join(root, inventory.fixtures[0].path);
|
||||
const loadedScript = [
|
||||
"import bpy,json,bpy.utils.previews",
|
||||
"collection=bpy.utils.previews.new()",
|
||||
`preview=collection.load('m12-preview',${JSON.stringify(png)},'IMAGE',True)`,
|
||||
"result={'sourceSize':[8,8],'imageSize':list(preview.image_size),'iconSize':list(preview.icon_size),'imagePackedPixelCount':len(preview.image_pixels),'imageFloatComponentCount':len(preview.image_pixels_float),'iconPackedPixelCount':len(preview.icon_pixels),'imageCustom':preview.is_image_custom,'iconCustom':preview.is_icon_custom}",
|
||||
"print('M12_LOADED_PREVIEW='+json.dumps(result,separators=(',',':')))",
|
||||
"bpy.utils.previews.remove(collection)",
|
||||
].join(";");
|
||||
const loadedOutput = execFileSync(blender, ["--factory-startup", "--background", "--python-expr", loadedScript], { encoding: "utf8" });
|
||||
const loadedLine = loadedOutput.split(/\r?\n/).find((line) => line.startsWith("M12_LOADED_PREVIEW="));
|
||||
assert.ok(loadedLine);
|
||||
assert.deepEqual(JSON.parse(loadedLine.slice("M12_LOADED_PREVIEW=".length)), inventory.runtimeStates.loadedPreview);
|
||||
|
||||
process.stdout.write(`asset-preview-inventory-ok slots=${inventory.storage.slotCount} rna=${inventory.rna.imagePreviewProperties.length} absent=${noPreview.length} loaded=${inventory.runtimeStates.loadedPreview.imageSize.join("x")} next=${inventory.nextTask}\n`);
|
||||
100
tools/web/check-library-append-fixture.mjs
Normal file
100
tools/web/check-library-append-fixture.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
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 { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportPath = path.join(root, "tests/golden/M12-03C/desktop-append-report.json");
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03C/manifest.json"), "utf8"));
|
||||
const fixtureRoot = path.join(root, "tests/files/web/m12_library_append_v1");
|
||||
const generator = path.join(root, "tools/web/generate-library-append-fixture.py");
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
assert.equal(manifest.task, "M12-03C");
|
||||
assert.equal(manifest.nextTask, "M12-03D");
|
||||
for (const artifact of Object.values(manifest.artifacts)) {
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, artifact.path);
|
||||
}
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.task, "M12-03C");
|
||||
assert.equal(report.operation, "APPEND");
|
||||
assert.equal(report.blenderVersion, "5.2.0");
|
||||
assert.equal(report.nextTask, "M12-03D");
|
||||
assert.deepEqual(report.selectedRoots, ["Object/M12 Append Object"]);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.source.file))), report.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(fixtureRoot, report.target.file))), report.target.sha256);
|
||||
assert.deepEqual(report.sourceGraph, report.appendedGraph);
|
||||
assert.deepEqual(report.appendedGraph.edges, [
|
||||
{ from: "Object/M12 Append Object", relation: "OBJECT_DATA", to: "Mesh/M12 Append Mesh" },
|
||||
{ from: "Mesh/M12 Append Mesh", relation: "MATERIAL_SLOT[0]", to: "Material/M12 Append Material" },
|
||||
{ from: "Material/M12 Append Material", relation: "NODE_IMAGE[M12 Append Image Node]", to: "Image/M12 Append Image" },
|
||||
]);
|
||||
assert.deepEqual(Object.keys(report.appendedGraph.ids).sort(), ["IMAGE", "MATERIAL", "MESH", "OBJECT"]);
|
||||
for (const value of Object.values(report.appendedGraph.ids)) {
|
||||
assert.equal(value.library, null);
|
||||
assert.equal(value.isLibraryOverride, false);
|
||||
}
|
||||
assert.deepEqual(report.appendedGraph.geometry, {
|
||||
edges: 4,
|
||||
loops: 4,
|
||||
materialSlots: ["M12 Append Material"],
|
||||
polygons: 1,
|
||||
uvLayers: ["UVMap"],
|
||||
vertices: 4,
|
||||
});
|
||||
assert.deepEqual(report.appendedGraph.image.size, [2, 2]);
|
||||
assert.equal(report.appendedGraph.image.channels, 4);
|
||||
assert.equal(report.appendedGraph.image.colorspace, "sRGB");
|
||||
assert.equal(report.appendedGraph.image.packed, true);
|
||||
assert.match(report.appendedGraph.image.pixelFloat32Sha256, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual(report.stableMapping.map((item) => [item.owner, item.readOnly]), [
|
||||
["LOCAL_MAIN", false],
|
||||
["LOCAL_MAIN", false],
|
||||
["LOCAL_MAIN", false],
|
||||
["LOCAL_MAIN", false],
|
||||
]);
|
||||
assert.deepEqual(report.stableMapping.map((item) => item.source), [
|
||||
"Object/M12 Append Object",
|
||||
"Mesh/M12 Append Mesh",
|
||||
"Material/M12 Append Material",
|
||||
"Image/M12 Append Image",
|
||||
]);
|
||||
assert.deepEqual(report.stableMapping.map((item) => item.local), report.stableMapping.map((item) => item.source));
|
||||
|
||||
const normalizeContainerHashes = (value) => ({
|
||||
...value,
|
||||
source: { ...value.source, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
target: { ...value.target, sha256: "<SESSION_BOUND_BLEND_CONTAINER>" },
|
||||
});
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-library-append-"));
|
||||
try {
|
||||
const generatedFixtureRoot = path.join(temporary, "files");
|
||||
const generatedReportPath = path.join(temporary, "report.json");
|
||||
const output = execFileSync(blender, [
|
||||
"--background",
|
||||
"--factory-startup",
|
||||
"--python",
|
||||
generator,
|
||||
"--",
|
||||
generatedFixtureRoot,
|
||||
generatedReportPath,
|
||||
], { cwd: root, encoding: "utf8" });
|
||||
assert.match(output, /library-append-fixture-ok roots=1 mapping=4/);
|
||||
assert.match(output, /next=M12-03D/);
|
||||
const generated = JSON.parse(fs.readFileSync(generatedReportPath, "utf8"));
|
||||
assert.deepEqual(normalizeContainerHashes(generated), normalizeContainerHashes(report));
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.source.file))), generated.source.sha256);
|
||||
assert.equal(sha256(fs.readFileSync(path.join(generatedFixtureRoot, generated.target.file))), generated.target.sha256);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`library-append-fixture-check-ok roots=${report.selectedRoots.length} mapping=${report.stableMapping.length} edges=${report.appendedGraph.edges.length} pixels=${report.appendedGraph.image.pixelFloat32Sha256} next=${report.nextTask}\n`,
|
||||
);
|
||||
153
tools/web/check-library-operation-inventory.mjs
Normal file
153
tools/web/check-library-operation-inventory.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-03A/library-operation-inventory.json"), "utf8"));
|
||||
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const sources = new Map();
|
||||
|
||||
assert.equal(inventory.schemaVersion, 1);
|
||||
assert.equal(inventory.task, "M12-03A");
|
||||
assert.equal(inventory.enablingTask, true);
|
||||
assert.equal(inventory.parityStateChange, false);
|
||||
assert.equal(inventory.blenderVersion, "5.2.0");
|
||||
assert.equal(inventory.nextTask, "M12-03B");
|
||||
assert.deepEqual(inventory.sources.map((source) => source.role), [
|
||||
"LINK_APPEND_CONTEXT_API",
|
||||
"LINK_APPEND_CLOSURE",
|
||||
"LINK_APPEND_OPERATORS",
|
||||
"PYTHON_LIBRARY_LOAD",
|
||||
"BLEND_LIBRARY_READER",
|
||||
"OVERRIDE_STORAGE",
|
||||
"OVERRIDE_API",
|
||||
"OVERRIDE_CLOSURE",
|
||||
"CURRENT_WEB_LIBRARY_GATE",
|
||||
]);
|
||||
for (const source of inventory.sources) {
|
||||
const bytes = fs.readFileSync(path.join(root, source.path));
|
||||
assert.equal(sha256(bytes), source.sha256, `${source.role} source hash drifted`);
|
||||
sources.set(source.role, bytes.toString("utf8"));
|
||||
}
|
||||
assert.equal(sha256(fs.readFileSync(path.join(root, inventory.runtimeFixture.path))), inventory.runtimeFixture.sha256);
|
||||
|
||||
assert.equal(inventory.dataBlockSurface.discoverableCollections.length, 36);
|
||||
assert.equal(new Set(inventory.dataBlockSurface.discoverableCollections).size, 36);
|
||||
assert.deepEqual(inventory.dataBlockSurface.onlyAppendableCollections, ["screens", "workspaces"]);
|
||||
assert.deepEqual(inventory.operations.map((operation) => operation.operation), ["APPEND", "LINK", "LIBRARY_OVERRIDE"]);
|
||||
for (const operation of inventory.operations) {
|
||||
assert.ok(operation.dataBlockRoles.length >= 6, `${operation.operation} has an incomplete role inventory`);
|
||||
assert.ok(operation.closure && Object.keys(operation.closure).length >= 5, `${operation.operation} has an incomplete closure inventory`);
|
||||
}
|
||||
|
||||
const context = sources.get("LINK_APPEND_CONTEXT_API");
|
||||
for (const token of [
|
||||
"LINK_APPEND_ACT_KEEP_LINKED",
|
||||
"LINK_APPEND_ACT_REUSE_LOCAL",
|
||||
"LINK_APPEND_ACT_MAKE_LOCAL",
|
||||
"LINK_APPEND_ACT_COPY_LOCAL",
|
||||
"LINK_APPEND_TAG_INDIRECT",
|
||||
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY",
|
||||
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY_ONLY",
|
||||
"ID *new_id",
|
||||
"Library *source_library",
|
||||
"ID *liboverride_id",
|
||||
"ID *reusable_local_id",
|
||||
"enum class ProcessStage",
|
||||
]) assert.ok(context.includes(token), `link/append context token is missing: ${token}`);
|
||||
|
||||
const closure = sources.get("LINK_APPEND_CLOSURE");
|
||||
for (const token of [
|
||||
"BKE_library_foreach_ID_link",
|
||||
"IDWALK_CB_EMBEDDED",
|
||||
"IDWALK_CB_EMBEDDED_NOT_OWNING",
|
||||
"IDWALK_CB_INTERNAL",
|
||||
"IDWALK_CB_LOOPBACK",
|
||||
"LINK_APPEND_TAG_LIBOVERRIDE_DEPENDENCY_ONLY",
|
||||
"new_id->lib->runtime->parent",
|
||||
]) assert.ok(closure.includes(token), `dependency closure token is missing: ${token}`);
|
||||
assert.match(closure, /BKE_blendfile_append\(/);
|
||||
assert.match(closure, /BKE_blendfile_override\(/);
|
||||
|
||||
const operators = sources.get("LINK_APPEND_OPERATORS");
|
||||
for (const token of [
|
||||
"BKE_idtype_idcode_is_linkable(idcode)",
|
||||
"BKE_idtype_idcode_is_only_appendable(idcode)",
|
||||
"BLO_LIBLINK_APPEND_RECURSIVE",
|
||||
"BLO_LIBLINK_APPEND_LOCAL_ID_REUSE",
|
||||
"void WM_OT_link",
|
||||
"void WM_OT_append",
|
||||
]) assert.ok(operators.includes(token), `operator inventory token is missing: ${token}`);
|
||||
|
||||
const pythonLoad = sources.get("PYTHON_LIBRARY_LOAD");
|
||||
for (const argument of inventory.operatorSurface.pythonLoadArguments) {
|
||||
assert.ok(pythonLoad.includes(`\"${argument}\"`), `Python load argument is missing: ${argument}`);
|
||||
}
|
||||
for (const token of [
|
||||
"if (!is_library && !BKE_idtype_idcode_is_linkable(code))",
|
||||
"if (!BKE_idtype_idcode_is_linkable(idcode) || (idcode == ID_WS && !do_append))",
|
||||
"create_liboverrides",
|
||||
"BKE_blendfile_link(lapp_context",
|
||||
"BKE_blendfile_append(lapp_context",
|
||||
"BKE_blendfile_override(lapp_context",
|
||||
]) assert.ok(pythonLoad.includes(token), `Python library closure token is missing: ${token}`);
|
||||
|
||||
const reader = sources.get("BLEND_LIBRARY_READER");
|
||||
for (const token of ["BLO_library_link_begin", "BLO_library_link_named_part", "BLO_library_link_end"]) {
|
||||
assert.ok(reader.includes(token), `reader stage is missing: ${token}`);
|
||||
}
|
||||
|
||||
const overrideStorage = sources.get("OVERRIDE_STORAGE");
|
||||
for (const token of [
|
||||
"struct IDOverrideLibraryPropertyOperation",
|
||||
"struct IDOverrideLibraryProperty",
|
||||
"struct IDOverrideLibrary",
|
||||
"ID *reference",
|
||||
"ID *hierarchy_root",
|
||||
"ListBaseT<IDOverrideLibraryProperty> properties",
|
||||
]) assert.ok(overrideStorage.includes(token), `override storage token is missing: ${token}`);
|
||||
const overrideApi = sources.get("OVERRIDE_API");
|
||||
for (const token of [
|
||||
"BKE_lib_override_library_create(",
|
||||
"BKE_lib_override_library_resync(",
|
||||
"BKE_lib_override_library_operations_create(",
|
||||
]) assert.ok(overrideApi.includes(token), `override API token is missing: ${token}`);
|
||||
assert.match(sources.get("OVERRIDE_CLOSURE"), /BKE_library_foreach_ID_link/);
|
||||
|
||||
const web = sources.get("CURRENT_WEB_LIBRARY_GATE");
|
||||
assert.match(web, /gateLibraryMutation\(operation:/);
|
||||
assert.match(web, /LIBRARY_MUTATION_UNAVAILABLE/);
|
||||
for (const gap of inventory.currentWebGap.missing) assert.ok(!inventory.currentWebGap.represented.includes(gap));
|
||||
|
||||
const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender");
|
||||
assert.match(execFileSync(blender, ["--version"], { encoding: "utf8" }), /^Blender 5\.2\.0 LTS/m);
|
||||
const script = [
|
||||
"import bpy,json",
|
||||
`fixture=${JSON.stringify(path.join(root, inventory.runtimeFixture.path))}`,
|
||||
"result={}",
|
||||
"result['appendProperties']=[p.identifier for p in bpy.ops.wm.append.get_rna_type().properties if p.identifier != 'rna_type']",
|
||||
"result['linkProperties']=[p.identifier for p in bpy.ops.wm.link.get_rna_type().properties if p.identifier != 'rna_type']",
|
||||
"ctx=bpy.data.libraries.load(fixture,link=True)",
|
||||
"source,target=ctx.__enter__()",
|
||||
"result['collections']=sorted([name for name in dir(source) if not name.startswith('_') and name not in {'libraries','version'}])",
|
||||
"result['libraries']=[{'filepath':item.filepath,'isArchive':item.is_archive} for item in source.libraries]",
|
||||
"ctx.__exit__(None,None,None)",
|
||||
"result['overrideRNA']={name:[{'identifier':p.identifier,'type':p.type,'isReadonly':p.is_readonly} for p in getattr(bpy.types,name).bl_rna.properties if p.identifier != 'rna_type'] for name in ['IDOverrideLibrary','IDOverrideLibraryProperty','IDOverrideLibraryPropertyOperation']}",
|
||||
"print('M12_03A='+json.dumps(result,separators=(',',':'),sort_keys=True))",
|
||||
].join(";");
|
||||
const runtimeOutput = execFileSync(blender, ["--background", "--factory-startup", "--python-expr", script], { cwd: root, encoding: "utf8" });
|
||||
const runtimeLine = runtimeOutput.split(/\r?\n/).find((line) => line.startsWith("M12_03A="));
|
||||
assert.ok(runtimeLine, "Blender link/append/override runtime inventory is missing");
|
||||
const runtime = JSON.parse(runtimeLine.slice("M12_03A=".length));
|
||||
assert.deepEqual(runtime.appendProperties, inventory.operatorSurface.appendProperties);
|
||||
assert.deepEqual(runtime.linkProperties, inventory.operatorSurface.linkProperties);
|
||||
assert.deepEqual(runtime.collections, inventory.dataBlockSurface.discoverableCollections);
|
||||
assert.deepEqual(runtime.libraries, inventory.runtimeFixture.nestedLibraries);
|
||||
assert.deepEqual(runtime.overrideRNA, inventory.overrideRNA);
|
||||
|
||||
process.stdout.write(
|
||||
`library-operation-inventory-ok roots=${runtime.collections.length} appendOnly=${inventory.dataBlockSurface.onlyAppendableCollections.length} operations=${inventory.operations.length} overrideFields=${Object.values(runtime.overrideRNA).reduce((sum, values) => sum + values.length, 0)} gaps=${inventory.currentWebGap.missing.length} next=${inventory.nextTask}\n`,
|
||||
);
|
||||
105
tools/web/export-asset-catalog-v1.py
Normal file
105
tools/web/export-asset-catalog-v1.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
NIL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
def catalog_records(path):
|
||||
version = None
|
||||
records = []
|
||||
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if version is None:
|
||||
if not line.startswith("VERSION "):
|
||||
raise RuntimeError(f"catalog definition line {line_number} has no version marker")
|
||||
version = int(line.removeprefix("VERSION "))
|
||||
continue
|
||||
catalog_id, catalog_path, simple_name = line.split(":", 2)
|
||||
records.append({
|
||||
"catalogId": catalog_id,
|
||||
"path": catalog_path,
|
||||
"simpleName": simple_name.strip(),
|
||||
"parentPath": catalog_path.rpartition("/")[0] or None,
|
||||
})
|
||||
return version, records
|
||||
|
||||
|
||||
def custom_property_value(value):
|
||||
if isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if hasattr(value, "to_list"):
|
||||
return value.to_list()
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
raise RuntimeError(f"unsupported asset custom property type {type(value).__name__}")
|
||||
|
||||
|
||||
def asset_record(id_type, asset):
|
||||
metadata = asset.asset_data
|
||||
custom_properties = [
|
||||
{
|
||||
"name": name,
|
||||
"type": type(metadata[name]).__name__.upper(),
|
||||
"value": custom_property_value(metadata[name]),
|
||||
}
|
||||
for name in sorted(metadata.keys())
|
||||
]
|
||||
return {
|
||||
"idType": id_type,
|
||||
"name": asset.name,
|
||||
"catalogId": metadata.catalog_id or NIL_UUID,
|
||||
"catalogSimpleName": metadata.catalog_simple_name,
|
||||
"author": metadata.author,
|
||||
"description": metadata.description,
|
||||
"copyright": metadata.copyright,
|
||||
"license": metadata.license,
|
||||
"tags": [tag.name for tag in metadata.tags],
|
||||
"activeTag": metadata.active_tag,
|
||||
"usePreferredImportMethod": metadata.use_preferred_import_method,
|
||||
"preferredImportMethod": metadata.preferred_import_method,
|
||||
"customProperties": custom_properties,
|
||||
}
|
||||
|
||||
|
||||
def main(catalog_file, output_file):
|
||||
catalog_path = pathlib.Path(catalog_file).resolve()
|
||||
output_path = pathlib.Path(output_file).resolve()
|
||||
version, catalogs = catalog_records(catalog_path)
|
||||
assets = []
|
||||
for id_type, collection in (
|
||||
("MATERIAL", bpy.data.materials),
|
||||
("OBJECT", bpy.data.objects),
|
||||
("WORLD", bpy.data.worlds),
|
||||
):
|
||||
assets.extend(asset_record(id_type, asset) for asset in collection if asset.asset_data is not None)
|
||||
assets.sort(key=lambda item: (item["idType"], item["name"]))
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-01B",
|
||||
"enablingTask": True,
|
||||
"parityStateChange": False,
|
||||
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
|
||||
"catalogDefinition": {
|
||||
"fileName": catalog_path.name,
|
||||
"version": version,
|
||||
"records": catalogs,
|
||||
},
|
||||
"assets": assets,
|
||||
"nextTask": "M12-01C",
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
|
||||
print(f"m12-asset-catalog-v1-canonical catalogs={len(catalogs)} assets={len(assets)} output={output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 2:
|
||||
raise SystemExit("usage: blender --background FIXTURE --python export-asset-catalog-v1.py -- CATALOG_FILE OUTPUT_JSON")
|
||||
main(*arguments)
|
||||
39
tools/web/generate-asset-catalog-compatibility.mjs
Normal file
39
tools/web/generate-asset-catalog-compatibility.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const [sourceArg, outputArg] = process.argv.slice(2);
|
||||
if (!sourceArg || !outputArg) throw new Error("usage: node generate-asset-catalog-compatibility.mjs V2_JSON OUTPUT_JSON");
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-compatibility-generator-"));
|
||||
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-compatibility.ts"];
|
||||
try {
|
||||
for (const sourceName of sources) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"'));
|
||||
}
|
||||
const compatibility = await import(pathToFileURL(path.join(temporary, "asset-catalog-compatibility.mjs")));
|
||||
const value = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
|
||||
const output = {
|
||||
read: await compatibility.inspectAssetCatalogForLegacyReader(value, "READ"),
|
||||
catalogWrite: await compatibility.inspectAssetCatalogForLegacyReader(value, "CATALOG_WRITE"),
|
||||
assetWrite: await compatibility.inspectAssetCatalogForLegacyReader(value, "ASSET_WRITE"),
|
||||
save: await compatibility.inspectAssetCatalogForLegacyReader(value, "SAVE"),
|
||||
future: await compatibility.inspectAssetCatalogForLegacyReader({ schemaVersion: 3 }, "READ"),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(path.resolve(outputArg)), { recursive: true });
|
||||
fs.writeFileSync(path.resolve(outputArg), `${JSON.stringify(output, null, 2)}\n`);
|
||||
process.stdout.write("asset-catalog-compatibility-generated read=READ_ONLY writes=3/BLOCKED future=BLOCKED\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
35
tools/web/generate-asset-catalog-migration.mjs
Normal file
35
tools/web/generate-asset-catalog-migration.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const [sourceArg, targetArg, reportArg] = process.argv.slice(2);
|
||||
if (!sourceArg || !targetArg || !reportArg) throw new Error("usage: node generate-asset-catalog-migration.mjs V1_JSON V2_JSON REPORT_JSON");
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "asset-catalog-migration-generator-"));
|
||||
const sources = ["asset-path.ts", "capability-gates.ts", "asset-library-io.ts", "asset-catalog-v2.ts", "asset-catalog-migration.ts"];
|
||||
try {
|
||||
for (const sourceName of sources) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
const output = result.outputText.replaceAll(/from "\.\/([a-z0-9-]+)"/g, 'from "./$1.mjs"');
|
||||
fs.writeFileSync(path.join(temporary, sourceName.replace(/\.ts$/, ".mjs")), output);
|
||||
}
|
||||
const migration = await import(pathToFileURL(path.join(temporary, "asset-catalog-migration.mjs")));
|
||||
const value = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
|
||||
const result = await migration.migrateAssetCatalogV1ToV2(value);
|
||||
fs.mkdirSync(path.dirname(path.resolve(targetArg)), { recursive: true });
|
||||
fs.writeFileSync(path.resolve(targetArg), `${JSON.stringify(result.manifest, null, 2)}\n`);
|
||||
fs.writeFileSync(path.resolve(reportArg), `${JSON.stringify(result.report, null, 2)}\n`);
|
||||
process.stdout.write(`asset-catalog-migration-generated catalogs=${result.manifest.catalogs.length} assets=${result.manifest.assets.length} libraries=${result.manifest.libraries.length}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
111
tools/web/generate-asset-catalog-v1.py
Normal file
111
tools/web/generate-asset-catalog-v1.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
CATALOGS = (
|
||||
("11111111-1111-4111-8111-111111111111", "Characters", "Characters"),
|
||||
("22222222-2222-4222-8222-222222222222", "Characters/Heroes", "Heroes"),
|
||||
("33333333-3333-4333-8333-333333333333", "Materials/Metal", "Metal"),
|
||||
)
|
||||
|
||||
|
||||
def set_metadata(asset, *, catalog_id, author, description, copyright_text, license_text,
|
||||
tags, active_tag, preferred_import_method, properties):
|
||||
metadata = asset.asset_data
|
||||
metadata.catalog_id = catalog_id
|
||||
metadata.author = author
|
||||
metadata.description = description
|
||||
metadata.copyright = copyright_text
|
||||
metadata.license = license_text
|
||||
for tag in tags:
|
||||
metadata.tags.new(tag, skip_if_exists=False)
|
||||
metadata.active_tag = active_tag
|
||||
metadata.use_preferred_import_method = preferred_import_method is not None
|
||||
if preferred_import_method is not None:
|
||||
metadata.preferred_import_method = preferred_import_method
|
||||
for name, value in properties.items():
|
||||
metadata[name] = value
|
||||
|
||||
|
||||
def write_catalog_definition(path):
|
||||
lines = [
|
||||
"# This is an Asset Catalog Definition file for Blender.",
|
||||
"#",
|
||||
"# Generated by M12-01B from a locked Blender 5.2 runtime.",
|
||||
"",
|
||||
"VERSION 1",
|
||||
"",
|
||||
]
|
||||
lines.extend(f"{catalog_id}:{catalog_path}:{simple_name}" for catalog_id, catalog_path, simple_name in CATALOGS)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def main(output_directory):
|
||||
output = pathlib.Path(output_directory).resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
mesh = bpy.data.meshes.new("M12 Hero Mesh")
|
||||
mesh.from_pydata(((-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (0.0, 1.0, 0.0)), (), ((0, 1, 2),))
|
||||
hero = bpy.data.objects.new("M12 Hero", mesh)
|
||||
bpy.context.scene.collection.objects.link(hero)
|
||||
hero.asset_mark()
|
||||
set_metadata(
|
||||
hero,
|
||||
catalog_id=CATALOGS[1][0],
|
||||
author="M12 Artist",
|
||||
description="Desktop catalog v1 object fixture",
|
||||
copyright_text="Copyright 2026 M12 Fixture Authors",
|
||||
license_text="CC0-1.0",
|
||||
tags=("character", "hero", "rig-ready"),
|
||||
active_tag=1,
|
||||
preferred_import_method="APPEND",
|
||||
properties={"approved": True, "rating": 5, "source": "M12-01B"},
|
||||
)
|
||||
|
||||
material = bpy.data.materials.new("M12 Brushed Metal")
|
||||
material.diffuse_color = (0.25, 0.3, 0.35, 1.0)
|
||||
material.asset_mark()
|
||||
set_metadata(
|
||||
material,
|
||||
catalog_id=CATALOGS[2][0],
|
||||
author="",
|
||||
description="",
|
||||
copyright_text="",
|
||||
license_text="",
|
||||
tags=("metal",),
|
||||
active_tag=0,
|
||||
preferred_import_method=None,
|
||||
properties={"roughness": 0.35},
|
||||
)
|
||||
|
||||
world = bpy.data.worlds.new("M12 Uncataloged World")
|
||||
world.color = (0.02, 0.03, 0.04)
|
||||
world.asset_mark()
|
||||
set_metadata(
|
||||
world,
|
||||
catalog_id="",
|
||||
author="M12 Artist",
|
||||
description="Asset without a catalog assignment",
|
||||
copyright_text="",
|
||||
license_text="CC0-1.0",
|
||||
tags=(),
|
||||
active_tag=0,
|
||||
preferred_import_method="LINK",
|
||||
properties={},
|
||||
)
|
||||
|
||||
catalog_path = output / "blender_assets.cats.txt"
|
||||
fixture_path = output / "m12_asset_catalog_v1.blend"
|
||||
write_catalog_definition(catalog_path)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(fixture_path), compress=True)
|
||||
print(f"m12-asset-catalog-v1 fixture={fixture_path} catalogs={len(CATALOGS)} assets=3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(arguments) != 1:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-asset-catalog-v1.py -- OUTPUT_DIRECTORY")
|
||||
main(arguments[0])
|
||||
29
tools/web/generate-asset-catalog-v2.mjs
Normal file
29
tools/web/generate-asset-catalog-v2.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const [sourceArg, outputArg] = process.argv.slice(2);
|
||||
if (!sourceArg || !outputArg) throw new Error("usage: node generate-asset-catalog-v2.mjs SOURCE_CANONICAL OUTPUT_JSON");
|
||||
const root = path.resolve(import.meta.dirname, "../..");
|
||||
const sourcePath = path.join(root, "web/protocol/asset-catalog-v2.ts");
|
||||
const temporaryPath = path.resolve(outputArg, "../asset-catalog-v2.generated.mjs");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.mkdirSync(path.dirname(outputArg), { recursive: true });
|
||||
fs.writeFileSync(temporaryPath, transpiled.outputText);
|
||||
try {
|
||||
const protocol = await import(`${pathToFileURL(temporaryPath)}?v=${Date.now()}`);
|
||||
const desktop = JSON.parse(fs.readFileSync(path.resolve(sourceArg), "utf8"));
|
||||
const manifest = await protocol.createAssetCatalogManifestV2FromDesktop(desktop, 1);
|
||||
fs.writeFileSync(path.resolve(outputArg), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
process.stdout.write(`asset-catalog-v2-generated catalogs=${manifest.catalogs.length} assets=${manifest.assets.length}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
33
tools/web/generate-asset-preview-identity.py
Normal file
33
tools/web/generate-asset-preview-identity.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-asset-preview-identity.py -- INPUT OUTPUT")
|
||||
source_arg, output_arg = sys.argv[sys.argv.index("--") + 1 :]
|
||||
source = pathlib.Path(source_arg).resolve()
|
||||
output = pathlib.Path(output_arg).resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
image = bpy.data.images.load(str(source), check_existing=False)
|
||||
width, height = image.size[:]
|
||||
scene = bpy.context.scene
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.image_settings.color_depth = "8"
|
||||
scene.render.image_settings.compression = 0
|
||||
scene.display_settings.display_device = "sRGB"
|
||||
scene.view_settings.view_transform = "Standard"
|
||||
scene.view_settings.look = "None"
|
||||
scene.view_settings.exposure = 0
|
||||
scene.view_settings.gamma = 1
|
||||
image.save_render(str(output), scene=scene)
|
||||
if not output.is_file() or output.stat().st_size == 0:
|
||||
raise RuntimeError("asset preview output was not written")
|
||||
print(f"asset-preview-generated width={width} height={height} output={output}")
|
||||
|
||||
|
||||
main()
|
||||
181
tools/web/generate-library-append-fixture.py
Normal file
181
tools/web/generate-library-append-fixture.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
ROOT_OBJECT = "M12 Append Object"
|
||||
MESH_NAME = "M12 Append Mesh"
|
||||
MATERIAL_NAME = "M12 Append Material"
|
||||
IMAGE_NAME = "M12 Append Image"
|
||||
IMAGE_NODE_NAME = "M12 Append Image Node"
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def pixel_sha256(image: bpy.types.Image) -> str:
|
||||
values = list(image.pixels)
|
||||
return hashlib.sha256(struct.pack(f"<{len(values)}f", *values)).hexdigest()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
|
||||
def create_source(path: pathlib.Path) -> None:
|
||||
reset()
|
||||
image = bpy.data.images.new(IMAGE_NAME, width=2, height=2, alpha=True, float_buffer=False)
|
||||
image.colorspace_settings.name = "sRGB"
|
||||
image.pixels = [
|
||||
1.0, 0.0, 0.0, 1.0,
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0, 1.0,
|
||||
1.0, 1.0, 1.0, 0.5,
|
||||
]
|
||||
image.pack()
|
||||
|
||||
material = bpy.data.materials.new(MATERIAL_NAME)
|
||||
material.use_nodes = True
|
||||
node_tree = material.node_tree
|
||||
principled = node_tree.nodes.get("Principled BSDF")
|
||||
image_node = node_tree.nodes.new("ShaderNodeTexImage")
|
||||
image_node.name = IMAGE_NODE_NAME
|
||||
image_node.label = IMAGE_NODE_NAME
|
||||
image_node.image = image
|
||||
image_node.interpolation = "Closest"
|
||||
image_node.extension = "REPEAT"
|
||||
node_tree.links.new(image_node.outputs["Color"], principled.inputs["Base Color"])
|
||||
|
||||
mesh = bpy.data.meshes.new(MESH_NAME)
|
||||
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, 3)],
|
||||
)
|
||||
mesh.materials.append(material)
|
||||
uv_layer = mesh.uv_layers.new(name="UVMap")
|
||||
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()
|
||||
|
||||
obj = bpy.data.objects.new(ROOT_OBJECT, mesh)
|
||||
obj["m12_source_marker"] = "M12-03C"
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(path), check_existing=False)
|
||||
|
||||
|
||||
def data_block(id_type: str, value: object) -> dict:
|
||||
return {
|
||||
"idType": id_type,
|
||||
"name": value.name,
|
||||
"nameFull": value.name_full,
|
||||
"library": None if value.library is None else value.library.filepath,
|
||||
"isLibraryOverride": value.override_library is not None,
|
||||
}
|
||||
|
||||
|
||||
def inspect_local_graph() -> dict:
|
||||
obj = bpy.data.objects[ROOT_OBJECT]
|
||||
mesh = obj.data
|
||||
material = mesh.materials[0]
|
||||
image_node = material.node_tree.nodes[IMAGE_NODE_NAME]
|
||||
image = image_node.image
|
||||
ids = {
|
||||
"OBJECT": data_block("OBJECT", obj),
|
||||
"MESH": data_block("MESH", mesh),
|
||||
"MATERIAL": data_block("MATERIAL", material),
|
||||
"IMAGE": data_block("IMAGE", image),
|
||||
}
|
||||
return {
|
||||
"root": ids["OBJECT"],
|
||||
"ids": ids,
|
||||
"edges": [
|
||||
{"from": "Object/M12 Append Object", "relation": "OBJECT_DATA", "to": "Mesh/M12 Append Mesh"},
|
||||
{"from": "Mesh/M12 Append Mesh", "relation": "MATERIAL_SLOT[0]", "to": "Material/M12 Append Material"},
|
||||
{"from": "Material/M12 Append Material", "relation": f"NODE_IMAGE[{IMAGE_NODE_NAME}]", "to": "Image/M12 Append Image"},
|
||||
],
|
||||
"geometry": {
|
||||
"vertices": len(mesh.vertices),
|
||||
"edges": len(mesh.edges),
|
||||
"polygons": len(mesh.polygons),
|
||||
"loops": len(mesh.loops),
|
||||
"uvLayers": [layer.name for layer in mesh.uv_layers],
|
||||
"materialSlots": [item.name for item in mesh.materials],
|
||||
},
|
||||
"image": {
|
||||
"size": list(image.size),
|
||||
"channels": image.channels,
|
||||
"colorspace": image.colorspace_settings.name,
|
||||
"packed": image.packed_file is not None,
|
||||
"pixelFloat32Sha256": pixel_sha256(image),
|
||||
},
|
||||
"sourceMarker": obj["m12_source_marker"],
|
||||
}
|
||||
|
||||
|
||||
def append_object(source: pathlib.Path, target: pathlib.Path) -> dict:
|
||||
reset()
|
||||
with bpy.data.libraries.load(str(source), link=False) as (data_from, data_to):
|
||||
if ROOT_OBJECT not in data_from.objects:
|
||||
raise RuntimeError("source root object is missing")
|
||||
data_to.objects = [ROOT_OBJECT]
|
||||
if len(data_to.objects) != 1 or data_to.objects[0] is None:
|
||||
raise RuntimeError("desktop append did not return one object")
|
||||
bpy.context.scene.collection.objects.link(data_to.objects[0])
|
||||
before_save = inspect_local_graph()
|
||||
if any(item["library"] is not None or item["isLibraryOverride"] for item in before_save["ids"].values()):
|
||||
raise RuntimeError("appended dependency closure is not fully local")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(target), check_existing=False)
|
||||
bpy.ops.wm.open_mainfile(filepath=str(target), load_ui=False)
|
||||
reopened = inspect_local_graph()
|
||||
if reopened != before_save:
|
||||
raise RuntimeError("appended dependency mapping drifted after save/reopen")
|
||||
return reopened
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if "--" not in sys.argv or len(sys.argv[sys.argv.index("--") + 1 :]) != 2:
|
||||
raise SystemExit("usage: blender --background --factory-startup --python generate-library-append-fixture.py -- OUTPUT_DIR REPORT")
|
||||
output_arg, report_arg = sys.argv[sys.argv.index("--") + 1 :]
|
||||
output_dir = pathlib.Path(output_arg).resolve()
|
||||
report_path = pathlib.Path(report_arg).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = output_dir / "m12_append_source.blend"
|
||||
target = output_dir / "m12_append_target.blend"
|
||||
|
||||
create_source(source)
|
||||
source_graph = inspect_local_graph()
|
||||
appended_graph = append_object(source, target)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"task": "M12-03C",
|
||||
"operation": "APPEND",
|
||||
"blenderVersion": "5.2.0",
|
||||
"source": {"file": source.name, "sha256": sha256_file(source)},
|
||||
"target": {"file": target.name, "sha256": sha256_file(target)},
|
||||
"selectedRoots": ["Object/M12 Append Object"],
|
||||
"sourceGraph": source_graph,
|
||||
"appendedGraph": appended_graph,
|
||||
"stableMapping": [
|
||||
{"source": "Object/M12 Append Object", "local": "Object/M12 Append Object", "owner": "LOCAL_MAIN", "readOnly": False},
|
||||
{"source": "Mesh/M12 Append Mesh", "local": "Mesh/M12 Append Mesh", "owner": "LOCAL_MAIN", "readOnly": False},
|
||||
{"source": "Material/M12 Append Material", "local": "Material/M12 Append Material", "owner": "LOCAL_MAIN", "readOnly": False},
|
||||
{"source": "Image/M12 Append Image", "local": "Image/M12 Append Image", "owner": "LOCAL_MAIN", "readOnly": False},
|
||||
],
|
||||
"nextTask": "M12-03D",
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"library-append-fixture-ok roots={len(report['selectedRoots'])} "
|
||||
f"mapping={len(report['stableMapping'])} source={report['source']['sha256']} "
|
||||
f"target={report['target']['sha256']} next={report['nextTask']}"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user