Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
196 lines
11 KiB
JavaScript
196 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
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';
|
|
import createEngine from '../build_web_blender6/bin/web_engine.js';
|
|
|
|
const publishDiagnostic = value => {
|
|
const routes = {
|
|
fcurve_explicit_handles_persist: ['setFCurveKeyframes', 'web_engine_blend_main_set_fcurve_keyframes'],
|
|
driver_is_registered_on_object: ['setDriverExpression', 'web_engine_blend_main_set_driver_expression'],
|
|
action_group_survives_desktop_read: ['setActionGroups', 'web_engine_blend_main_set_action_groups'],
|
|
keying_path_owns_object_property: ['setKeyingSet', 'web_engine_blend_main_set_keying_set'],
|
|
bake_samples_evaluated_animation: ['bakeAnimation', 'web_engine_blend_main_bake_animation'],
|
|
};
|
|
if (process.env.WBV2_RECEIPT_DIR) {
|
|
fs.mkdirSync(process.env.WBV2_RECEIPT_DIR, { recursive: true });
|
|
const findings = (value.checks ?? []).filter(check => !check.ok).map(check => ({
|
|
assertion: check.name, message: check.message,
|
|
sourceUnit: routes[check.name] ? 'web-command:' + routes[check.name][0] : null,
|
|
sourcePath: 'blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp',
|
|
symbol: routes[check.name]?.[1],
|
|
}));
|
|
if (!value.ok && !findings.length) findings.push({ assertion: 'native_execution', message: value.error });
|
|
fs.writeFileSync(path.join(process.env.WBV2_RECEIPT_DIR, 'native-business-diagnostic.json'), JSON.stringify({
|
|
schemaVersion: 1, job: 'ANIMATION-FCURVE-DRIVER-COVERAGE', checkId: 'native-animation',
|
|
status: value.ok ? 'PASS' : 'FAIL', findings, stages: value.stages,
|
|
reproduce: { cwd: '.', argv: ['node', 'my-tools/diagnose_local_animation_authoring.mjs', '--indexed-path'] },
|
|
}, null, 2) + '\n');
|
|
}
|
|
console.log(JSON.stringify(value));
|
|
};
|
|
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wbv2-animation-native-'));
|
|
const stages = [];
|
|
const record = (stage, started) => stages.push({ stage, seconds: (performance.now() - started) / 1000 });
|
|
const log = (message) => fs.appendFileSync(path.join(directory, 'native.log'), `${message}\n`);
|
|
const started = performance.now();
|
|
const engine = await createEngine({
|
|
wasmBinary: fs.readFileSync(path.join(root, 'build_web_blender6/bin/web_engine.wasm')),
|
|
print: log,
|
|
printErr: log,
|
|
});
|
|
record('instantiate', started);
|
|
const handle = engine._web_engine_create();
|
|
const pointers = engine._malloc(8);
|
|
const output = () => {
|
|
const pointer = engine.HEAPU32[pointers >> 2];
|
|
const length = engine.HEAPU32[(pointers + 4) >> 2];
|
|
return engine.HEAPU8.slice(pointer, pointer + length);
|
|
};
|
|
const call = (stage, data) => {
|
|
const begin = performance.now();
|
|
const pointer = data ? engine._malloc(data.length) : 0;
|
|
try {
|
|
if (data) engine.HEAPU8.set(data, pointer);
|
|
const code = stage === 'open'
|
|
? engine._web_engine_open_blend(handle, pointer, data.length)
|
|
: stage === 'snapshot'
|
|
? engine._web_engine_get_scene_snapshot(handle, pointers, pointers + 4)
|
|
: engine._web_engine_call_json(handle, pointer, data.length, pointers, pointers + 4);
|
|
if (code !== 0) throw new Error(`${stage}: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
|
return stage === 'open' ? null : JSON.parse(new TextDecoder().decode(output()));
|
|
} finally {
|
|
if (pointer) engine._free(pointer);
|
|
record(stage, begin);
|
|
}
|
|
};
|
|
const edit = (command, expected = true) => {
|
|
const result = call(command.type, Buffer.from(JSON.stringify({ cmd: 'applyCommand', params: { command } })));
|
|
assert.equal(result.ok, expected, `${command.type}: ${JSON.stringify(result.error ?? result)}`);
|
|
return result;
|
|
};
|
|
const save = (name) => {
|
|
const begin = performance.now();
|
|
assert.equal(engine._web_engine_save_blend(handle, pointers, pointers + 4), 0);
|
|
const file = path.join(directory, `${name}.blend`);
|
|
fs.writeFileSync(file, output());
|
|
engine._web_engine_free_buffer(engine.HEAPU32[pointers >> 2]);
|
|
record(`save:${name}`, begin);
|
|
return file;
|
|
};
|
|
|
|
try {
|
|
call('open', fs.readFileSync(path.join(root, 'tests/files/web/animation_scene.blend')));
|
|
const initial = call('snapshot');
|
|
if (process.argv.includes('--inspect')) {
|
|
publishDiagnostic({ ok: true, directory, stages, animations: initial.animations, scenes: initial.scenes?.map(({ id, name, keyingSets }) => ({ id, name, keyingSets })), objects: initial.nodes?.map(({ id, name, animationId }) => ({ id, name, animationId })) });
|
|
} else {
|
|
const animation = initial.animations.find((item) => item.targetId === 'object:AnimatedObject');
|
|
assert(animation);
|
|
const scene = initial.scenes.find((item) => item.name === 'Scene');
|
|
const files = { initial: save('initial') };
|
|
const command = {
|
|
type: 'setFCurveKeyframes', animationId: animation.id, path: 'location[0]',
|
|
points: [
|
|
{ frame: 1, value: 0, handleLeft: [0, -1], handleRight: [2, 1], interpolation: 'BEZIER' },
|
|
{ frame: 5, value: 4, handleLeft: [4, 3], handleRight: [6, 5], interpolation: 'BEZIER' },
|
|
],
|
|
};
|
|
edit(command);
|
|
files.points = save('points');
|
|
edit({ type: 'setDriverExpression', animationId: animation.id, path: 'location[1]', expression: 'frame * 0.5' });
|
|
edit({ type: "setFrame", frame: 3 });
|
|
assert.equal(call("snapshot").nodes.find(node => node.name === "AnimatedObject").transform.translation[1], 1.5, "setFrame must publish evaluated driver values");
|
|
edit({ type: "setFrame", frame: 1 });
|
|
edit({ type: "setDriverExpression", animationId: animation.id, path: "location[1]", expression: "frame * 0.25" });
|
|
assert.equal(call("snapshot").nodes.find(node => node.name === "AnimatedObject").transform.translation[1], 0.25, "edited driver expressions must invalidate cached evaluation");
|
|
edit({ type: "setDriverExpression", animationId: animation.id, path: "location[1]", expression: "frame * 0.5" });
|
|
files.driver = save('driver');
|
|
edit({ type: 'setActionGroups', animationId: animation.id, groups: [{ name: 'Web Motion', paths: ['location[0]', 'location[1]'] }] });
|
|
files.groups = save('groups');
|
|
edit({ type: 'setKeyingSet', sceneId: scene.id, name: 'Web Location', paths: [{ path: (process.argv.includes("--indexed-path") ? "location[0]" : "location"), arrayIndex: 0 }], active: true });
|
|
files.keying = save('keying');
|
|
edit({ type: 'bakeAnimation', objectId: animation.targetId, frameStart: 1, frameEnd: 5, step: 2 });
|
|
files.bake = save('bake');
|
|
const negative = [
|
|
{ ...command, path: 'missing_property[0]' },
|
|
{ type: 'setDriverExpression', animationId: animation.id, path: 'location[1]', expression: 'x'.repeat(256) },
|
|
{ type: 'setActionGroups', animationId: animation.id, groups: [{ name: 'Invalid', paths: ['missing_property[0]'] }] },
|
|
{ type: 'setKeyingSet', sceneId: scene.id, name: 'Invalid', paths: [{ path: 17, arrayIndex: 0 }], active: true },
|
|
{ type: 'bakeAnimation', objectId: animation.targetId, frameStart: 5, frameEnd: 1, step: 1 },
|
|
];
|
|
const accepted = call('snapshot');
|
|
for (const rejected of negative) {
|
|
edit(rejected, false);
|
|
assert.deepEqual(call('snapshot'), accepted, `${rejected.type} rejected without mutation`);
|
|
}
|
|
files.negative = save('negative');
|
|
const request = { files, output: path.join(directory, 'main-snapshots.json') };
|
|
const requestPath = path.join(directory, 'readback-request.json');
|
|
fs.writeFileSync(requestPath, JSON.stringify(request));
|
|
const begin = performance.now();
|
|
const reader = spawnSync(process.env.BLENDER_BIN || path.join(root, 'build_blender_native/bin/blender'), [
|
|
'--background', '--factory-startup', '--python-exit-code', '1', '--python', path.join(root, 'web/tests/fixtures/animation-authoring-main-snapshots.py'), '--', requestPath,
|
|
], { cwd: root, encoding: 'utf8', timeout: 20000 });
|
|
fs.writeFileSync(path.join(directory, 'desktop.log'), `${reader.stdout || ''}\n${reader.stderr || ''}`);
|
|
record('desktop_readback', begin);
|
|
assert.equal(reader.status, 0, 'Desktop Main reader failed; details remain in desktop.log');
|
|
const main = JSON.parse(fs.readFileSync(request.output, 'utf8'));
|
|
assert.deepEqual(main.negative, main.bake, 'Rejected commands preserve saved native Main');
|
|
const checks = [];
|
|
const verify = (name, fn) => {
|
|
try { fn(); checks.push({ name, ok: true }); }
|
|
catch (error) { checks.push({ name, ok: false, message: error.message }); }
|
|
};
|
|
const curve = (label, index) => main[label].actions.find((item) => item.name === animation.name)
|
|
?.curves.find((item) => item.path === 'location' && item.index === index);
|
|
verify('fcurve_explicit_handles_persist', () => {
|
|
const point = curve('points', 0).keys.find((key) => key.co[0] === 5);
|
|
assert.deepEqual(point.co, [5, 4]);
|
|
assert.deepEqual(point.left, [4, 3]);
|
|
assert.deepEqual(point.right, [6, 5]);
|
|
assert.equal(point.leftType, 'FREE');
|
|
assert.equal(point.rightType, 'FREE');
|
|
});
|
|
verify('driver_is_registered_on_object', () => {
|
|
const driver = main.driver.objects.find((item) => item.name === 'AnimatedObject').drivers
|
|
.find((item) => item.path === 'location' && item.index === 1);
|
|
assert.equal(driver?.driver.expression, 'frame * 0.5');
|
|
});
|
|
verify('action_group_survives_desktop_read', () => {
|
|
assert.equal(curve('groups', 0).group, 'Web Motion');
|
|
assert.equal(curve('groups', 1).group, 'Web Motion');
|
|
});
|
|
verify('keying_path_owns_object_property', () => {
|
|
const keyingSet = main.keying.scenes[0].keyingSets.find((item) => item.name === 'Web Location');
|
|
assert.equal(keyingSet?.paths[0].id, 'AnimatedObject');
|
|
assert.equal(keyingSet?.paths[0].idType, 'OBJECT');
|
|
assert.equal(keyingSet?.isPathAbsolute, true);
|
|
});
|
|
verify('bake_samples_evaluated_animation', () => {
|
|
for (const sample of main.keying.evaluatedSamples) {
|
|
const object = sample.objects.find((item) => item.name === 'AnimatedObject');
|
|
for (let index = 0; index < 3; index++) {
|
|
const key = curve('bake', index).keys.find((item) => item.co[0] === sample.frame);
|
|
assert(Math.abs(key.co[1] - object.location[index]) < 1e-5, `frame ${sample.frame} location[${index}] expected evaluated ${object.location[index]}, received ${key.co[1]}`);
|
|
}
|
|
}
|
|
});
|
|
const ok = checks.every((check) => check.ok);
|
|
publishDiagnostic({ ok, directory, stages, checks, files, main: request.output });
|
|
if (!ok) process.exitCode = 1;
|
|
}
|
|
} catch (error) {
|
|
log(error.stack);
|
|
publishDiagnostic({ ok: false, directory, error: error.message, frames: String(error.stack).split("\n").filter(line => line.includes("wasm-function")).slice(0, 12), stages });
|
|
process.exitCode = 1;
|
|
} finally {
|
|
engine._free(pointers);
|
|
engine._web_engine_destroy(handle);
|
|
}
|