272 lines
9.6 KiB
JavaScript
272 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
// Parity harness for the osmassets refactor (docs/refactor-plan.md).
|
|
//
|
|
// node scripts/parity.js capture <label> [--areas a,b] [--stages blender,cesium]
|
|
// node scripts/parity.js compare <labelA> <labelB>
|
|
//
|
|
// capture runs the pipeline and snapshots everything that must not change:
|
|
// the stage stdout markers, a structural digest of the .blend, a structural
|
|
// digest of the .glb, and a hash of the render PNG. compare diffs two
|
|
// snapshots field by field.
|
|
//
|
|
// Snapshots live under outputs/_refactor-baseline/<label>/, which is inside
|
|
// the gitignored outputs/ tree — baselines are local scratch, not artifacts.
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { spawnSync } = require("child_process");
|
|
const { blenderExecutable } = require("./lib/tool-paths");
|
|
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
const baselineRoot = path.join(repoRoot, "outputs", "_refactor-baseline");
|
|
const DEFAULT_AREAS = ["nantaizi-lake-innovation-valley", "hanyang-block"];
|
|
|
|
// Fields a control run (identical code, run twice) proved unstable. They are
|
|
// still recorded — a human reading a snapshot wants them — but comparing them
|
|
// would bury real regressions under noise.
|
|
//
|
|
// files.*.sha256 / .bytes for blend, glb, render
|
|
// .blend embeds absolute paths and packs images in hash-map order, so the
|
|
// file hash moves while the structural digest stays put. The EEVEE render
|
|
// is likewise not bit-reproducible.
|
|
// glbDigest.fileBytes / .buffers / .counts.accessors
|
|
// The glTF exporter deduplicates identical accessors. smart_project UVs
|
|
// carry float noise, so two runs can differ by one shared UV accessor
|
|
// (observed: 399 vs 398 accessors, 720 bytes) with identical nodes,
|
|
// meshes, primitives, materials and images.
|
|
//
|
|
// What remains compared is the real contract: the SCENE_DONE / CESIUM markers,
|
|
// the full .blend structural digest (objects, meshes, materials, custom
|
|
// properties), and the GLB node/mesh/material/image structure.
|
|
const IGNORED_PATHS = new Set([
|
|
"capturedAt",
|
|
"durationMs",
|
|
"label",
|
|
"files.blend.sha256",
|
|
"files.glb.sha256",
|
|
"files.glb.bytes",
|
|
"files.render.sha256",
|
|
"files.render.bytes",
|
|
"glbDigest.fileBytes",
|
|
"glbDigest.buffers",
|
|
"glbDigest.counts.accessors",
|
|
]);
|
|
|
|
function main() {
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
if (command === "capture") return capture(rest);
|
|
if (command === "compare") return compare(rest);
|
|
console.error("usage: parity.js capture <label> [--areas a,b] [--stages s]");
|
|
console.error(" parity.js compare <labelA> <labelB>");
|
|
process.exit(1);
|
|
}
|
|
|
|
function parseFlags(argv) {
|
|
const flags = {};
|
|
const positional = [];
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
if (argv[i].startsWith("--") && i + 1 < argv.length) {
|
|
flags[argv[i].slice(2)] = argv[i + 1];
|
|
i += 1;
|
|
} else {
|
|
positional.push(argv[i]);
|
|
}
|
|
}
|
|
return { flags, positional };
|
|
}
|
|
|
|
function capture(argv) {
|
|
const { flags, positional } = parseFlags(argv);
|
|
const label = positional[0];
|
|
if (!label) throw new Error("capture needs a label");
|
|
const areas = (flags.areas ? flags.areas.split(",") : DEFAULT_AREAS)
|
|
.map((a) => a.trim())
|
|
.filter(Boolean);
|
|
const stages = flags.stages || "blender,cesium";
|
|
|
|
for (const area of areas) {
|
|
const configPath = path.join(repoRoot, "config", "areas", `${area}.json`);
|
|
if (!fs.existsSync(configPath)) throw new Error(`No config for area: ${area}`);
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
const areaDir = path.join(repoRoot, "outputs", area);
|
|
const stem = area;
|
|
const outDir = path.join(baselineRoot, label, area);
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
console.log(`\n=== parity capture [${label}] ${area} (stages: ${stages}) ===`);
|
|
const started = Date.now();
|
|
const run = spawnSync(process.execPath, [
|
|
path.join(repoRoot, "scripts", "build-area.js"),
|
|
"--config", configPath,
|
|
"--stages", stages,
|
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
const stdout = `${run.stdout || ""}`;
|
|
const stderr = `${run.stderr || ""}`;
|
|
process.stdout.write(stdout);
|
|
if (run.status !== 0) {
|
|
process.stderr.write(stderr);
|
|
throw new Error(`build-area failed for ${area} (exit ${run.status})`);
|
|
}
|
|
|
|
const snapshot = {
|
|
label,
|
|
area,
|
|
stages,
|
|
capturedAt: new Date().toISOString(),
|
|
durationMs: Date.now() - started,
|
|
markers: {
|
|
scene: parseMarker(stdout, "SCENE_DONE"),
|
|
cesium: parseMarker(stdout, "CESIUM_EXPORT_DONE"),
|
|
},
|
|
files: {},
|
|
};
|
|
|
|
const blend = path.join(areaDir, `${stem}.blend`);
|
|
if (fs.existsSync(blend)) {
|
|
snapshot.files.blend = fileStat(blend);
|
|
snapshot.blendDigest = blendDigest(config, blend, path.join(outDir, "blend-digest.json"));
|
|
}
|
|
const glb = path.join(areaDir, `${stem}.glb`);
|
|
if (fs.existsSync(glb)) {
|
|
snapshot.files.glb = fileStat(glb);
|
|
snapshot.glbDigest = glbDigest(glb, path.join(outDir, "glb-digest.json"));
|
|
}
|
|
for (const [key, file] of [
|
|
["render", path.join(areaDir, `${stem}.png`)],
|
|
["metadata", path.join(areaDir, `${stem}.json`)],
|
|
]) {
|
|
if (fs.existsSync(file)) snapshot.files[key] = fileStat(file);
|
|
}
|
|
if (fs.existsSync(path.join(areaDir, `${stem}.json`))) {
|
|
snapshot.metadata = JSON.parse(
|
|
fs.readFileSync(path.join(areaDir, `${stem}.json`), "utf8"),
|
|
);
|
|
}
|
|
|
|
writeJson(path.join(outDir, "snapshot.json"), snapshot);
|
|
console.log(`Snapshot: ${path.join(outDir, "snapshot.json")}`);
|
|
}
|
|
}
|
|
|
|
function parseMarker(stdout, marker) {
|
|
const line = stdout.split("\n").find((l) => l.startsWith(`${marker} `));
|
|
if (!line) return null;
|
|
try {
|
|
return JSON.parse(line.slice(marker.length + 1));
|
|
} catch (error) {
|
|
return { unparsed: line };
|
|
}
|
|
}
|
|
|
|
function fileStat(file) {
|
|
const buffer = fs.readFileSync(file);
|
|
return {
|
|
bytes: buffer.length,
|
|
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
|
};
|
|
}
|
|
|
|
function blendDigest(config, blend, outFile) {
|
|
const blenderApp = config.blenderApp || (process.platform === "darwin" ? "/Applications/Blender.app" : "/usr/bin/blender");
|
|
const blender = blenderExecutable(blenderApp);
|
|
const run = spawnSync(blender, [
|
|
"--background", "--factory-startup",
|
|
"--python", path.join(repoRoot, "blender", "tools", "scene_digest.py"),
|
|
"--", "--blend", blend, "--out", outFile,
|
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
if (run.status !== 0) {
|
|
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
|
|
throw new Error(`scene_digest failed for ${blend}`);
|
|
}
|
|
return JSON.parse(fs.readFileSync(outFile, "utf8"));
|
|
}
|
|
|
|
function glbDigest(glb, outFile) {
|
|
const run = spawnSync(process.execPath, [
|
|
path.join(repoRoot, "scripts", "glb-digest.js"), glb, "--out", outFile,
|
|
], { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
if (run.status !== 0) {
|
|
process.stderr.write(`${run.stdout || ""}${run.stderr || ""}`);
|
|
throw new Error(`glb-digest failed for ${glb}`);
|
|
}
|
|
return JSON.parse(fs.readFileSync(outFile, "utf8"));
|
|
}
|
|
|
|
function writeJson(file, value) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function compare(argv) {
|
|
const [a, b] = argv;
|
|
if (!a || !b) throw new Error("compare needs two labels");
|
|
const areas = fs.readdirSync(path.join(baselineRoot, a))
|
|
.filter((entry) => fs.existsSync(path.join(baselineRoot, a, entry, "snapshot.json")));
|
|
|
|
let differences = 0;
|
|
for (const area of areas) {
|
|
const left = readSnapshot(a, area);
|
|
const right = readSnapshot(b, area);
|
|
if (!right) {
|
|
console.log(`\n[${area}] missing in ${b} — skipped`);
|
|
continue;
|
|
}
|
|
const diffs = [];
|
|
diffValues("", left, right, diffs);
|
|
console.log(`\n=== ${area}: ${a} vs ${b} ===`);
|
|
if (!diffs.length) {
|
|
console.log("identical");
|
|
} else {
|
|
differences += diffs.length;
|
|
for (const line of diffs.slice(0, 200)) console.log(line);
|
|
if (diffs.length > 200) console.log(`… ${diffs.length - 200} more`);
|
|
}
|
|
}
|
|
console.log(`\n${differences === 0 ? "PARITY OK" : `PARITY DIFF (${differences})`}`);
|
|
process.exitCode = differences === 0 ? 0 : 2;
|
|
}
|
|
|
|
function readSnapshot(label, area) {
|
|
const file = path.join(baselineRoot, label, area, "snapshot.json");
|
|
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null;
|
|
}
|
|
|
|
function diffValues(pathKey, left, right, out) {
|
|
if (IGNORED_PATHS.has(pathKey)) return;
|
|
if (left === right) return;
|
|
const bothObjects = left && right && typeof left === "object" && typeof right === "object";
|
|
if (!bothObjects) {
|
|
out.push(` ${pathKey || "<root>"}: ${format(left)} -> ${format(right)}`);
|
|
return;
|
|
}
|
|
if (Array.isArray(left) !== Array.isArray(right)) {
|
|
out.push(` ${pathKey}: array/object mismatch`);
|
|
return;
|
|
}
|
|
if (Array.isArray(left)) {
|
|
if (left.length !== right.length) {
|
|
out.push(` ${pathKey}.length: ${left.length} -> ${right.length}`);
|
|
}
|
|
const limit = Math.min(left.length, right.length);
|
|
for (let i = 0; i < limit; i += 1) {
|
|
diffValues(`${pathKey}[${i}]`, left[i], right[i], out);
|
|
}
|
|
return;
|
|
}
|
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
for (const key of [...keys].sort()) {
|
|
diffValues(pathKey ? `${pathKey}.${key}` : key, left[key], right[key], out);
|
|
}
|
|
}
|
|
|
|
function format(value) {
|
|
if (value === undefined) return "<missing>";
|
|
const text = JSON.stringify(value);
|
|
return text && text.length > 120 ? `${text.slice(0, 117)}…` : text;
|
|
}
|
|
|
|
main();
|