Add Chromium-only Blender WebEngine parity work
This commit is contained in:
578
tools/web/run-web-engine-smoke.mjs
Normal file
578
tools/web/run-web-engine-smoke.mjs
Normal file
@@ -0,0 +1,578 @@
|
||||
import fs from "node:fs";
|
||||
import factory from "../../web/app/src/vendor/blender/web_engine.js";
|
||||
|
||||
const wasmBinary = fs.readFileSync(new URL("../../web/app/src/vendor/blender/web_engine.wasm", import.meta.url));
|
||||
const emptyBlend = fs.readFileSync(new URL("../../tests/files/web/empty.blend", import.meta.url));
|
||||
const attributeBlend = fs.readFileSync(new URL("../../tests/files/web/attribute_scene.blend", import.meta.url));
|
||||
const animationBlend = fs.readFileSync(new URL("../../tests/files/web/animation_scene.blend", import.meta.url));
|
||||
const riggedShapeBlend = fs.readFileSync(new URL("../../tests/files/web/rigged_shape_scene.blend", import.meta.url));
|
||||
const blend = fs.readFileSync(new URL("../../tests/files/web/basic_scene.blend", import.meta.url));
|
||||
const invalidBlend = Uint8Array.from([0x42, 0x41, 0x44, 0x00]);
|
||||
const engine = await factory({ wasmBinary });
|
||||
|
||||
function openFixture(engineHandle, bytes) {
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
const result = engine._web_engine_open_blend(engineHandle, pointer, bytes.byteLength);
|
||||
engine._free(pointer);
|
||||
return result;
|
||||
}
|
||||
|
||||
function readHandleSnapshot(engineHandle) {
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
const result = engine._web_engine_get_scene_snapshot(engineHandle, dataOut, lengthOut);
|
||||
if (result !== 0) throw new Error("mode smoke snapshot request failed");
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
const snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function readHandleDepsgraph(engineHandle) {
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
const result = engine._web_engine_evaluate_depsgraph(engineHandle, dataOut, lengthOut);
|
||||
if (result !== 0) {
|
||||
const error = engine.UTF8ToString(engine._web_engine_last_error_message());
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
return { unavailable: true, code: result, error };
|
||||
}
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
const report = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
return report;
|
||||
}
|
||||
|
||||
function applyHandleCommand(engineHandle, command) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(command));
|
||||
const pointer = engine._malloc(bytes.byteLength);
|
||||
engine.HEAPU8.set(bytes, pointer);
|
||||
const result = engine._web_engine_apply_command(engineHandle, pointer, bytes.byteLength);
|
||||
engine._free(pointer);
|
||||
return result;
|
||||
}
|
||||
|
||||
function saveHandleBlend(engineHandle) {
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
const result = engine._web_engine_save_blend(engineHandle, dataOut, lengthOut);
|
||||
if (result !== 0) {
|
||||
const message = engine.UTF8ToString(engine._web_engine_last_error_message());
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
throw new Error(`blend save failed: ${message}`);
|
||||
}
|
||||
const pointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const length = engine.HEAPU32[lengthOut >>> 2];
|
||||
const bytes = engine.HEAPU8.slice(pointer, pointer + length);
|
||||
engine._web_engine_free_buffer(pointer);
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const handle = engine._web_engine_create();
|
||||
if (handle !== 1 || engine._web_engine_get_live_handles() !== 1) {
|
||||
throw new Error(`unexpected engine state: handle=${handle}`);
|
||||
}
|
||||
|
||||
const invalidInput = engine._malloc(invalidBlend.byteLength);
|
||||
engine.HEAPU8.set(invalidBlend, invalidInput);
|
||||
if (engine._web_engine_open_blend(handle, invalidInput, invalidBlend.byteLength) !== -4) {
|
||||
throw new Error("invalid blend input should return BLEND_READ_FAILED");
|
||||
}
|
||||
engine._free(invalidInput);
|
||||
|
||||
const emptyInput = engine._malloc(emptyBlend.byteLength);
|
||||
engine.HEAPU8.set(emptyBlend, emptyInput);
|
||||
if (engine._web_engine_open_blend(handle, emptyInput, emptyBlend.byteLength) !== 0) {
|
||||
throw new Error(`empty.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(emptyInput);
|
||||
|
||||
const attributeInput = engine._malloc(attributeBlend.byteLength);
|
||||
engine.HEAPU8.set(attributeBlend, attributeInput);
|
||||
if (engine._web_engine_open_blend(handle, attributeInput, attributeBlend.byteLength) !== 0) {
|
||||
throw new Error(`attribute_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(attributeInput);
|
||||
const attributeDataOut = engine._malloc(4);
|
||||
const attributeLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_snapshot(handle, attributeDataOut, attributeLengthOut) !== 0) {
|
||||
throw new Error("attribute snapshot request failed");
|
||||
}
|
||||
const attributePointer = engine.HEAPU32[attributeDataOut >>> 2];
|
||||
const attributeLength = engine.HEAPU32[attributeLengthOut >>> 2];
|
||||
const attributeSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(attributePointer, attributePointer + attributeLength)));
|
||||
const attributeMesh = attributeSnapshot.meshes[0];
|
||||
const attributeMaterial = attributeSnapshot.materials.find((material) => material.id === "material:AttributeRed");
|
||||
const attributeImage = attributeSnapshot.images.find((image) => image.id === "image:AttributeTexture");
|
||||
if (attributeMesh.geometryStatus !== "available" || attributeMesh.faceCount !== 2 || attributeMesh.triangleCount !== 3 ||
|
||||
attributeMesh.normals?.length !== 15 || attributeMesh.uvs?.length !== 14 || attributeMesh.colors?.length !== 28 ||
|
||||
attributeMesh.triangleCornerIndices?.length !== 9 || attributeMesh.triangleMaterialIndices?.join(",") !== "0,0,1" ||
|
||||
attributeMesh.materialSlotIds?.join(",") !== "material:AttributeRed,material:AttributeBlue" ||
|
||||
attributeMesh.bounds?.min?.join(",") !== "-1,-1,0" || attributeMesh.bounds?.max?.join(",") !== "1,1,1" ||
|
||||
!attributeMaterial || Math.abs(attributeMaterial.metallic - 0.25) > 1e-6 || Math.abs(attributeMaterial.roughness - 0.35) > 1e-6 ||
|
||||
Math.abs(attributeMaterial.alpha - 0.8) > 1e-6 || Math.abs(attributeMaterial.ior - 1.33) > 1e-6 ||
|
||||
attributeMaterial.imageIds?.join(",") !== "image:AttributeTexture" || !attributeMaterial.warnings?.includes("linked_input_not_evaluated:Base Color") ||
|
||||
attributeImage?.mimeType !== "image/png" || !attributeImage.sourcePath?.endsWith("textures/attribute.png")) {
|
||||
throw new Error(`unexpected attribute SceneIR: ${JSON.stringify(attributeMesh)}`);
|
||||
}
|
||||
engine._free(attributeDataOut);
|
||||
engine._free(attributeLengthOut);
|
||||
|
||||
const animationInput = engine._malloc(animationBlend.byteLength);
|
||||
engine.HEAPU8.set(animationBlend, animationInput);
|
||||
if (engine._web_engine_open_blend(handle, animationInput, animationBlend.byteLength) !== 0) {
|
||||
throw new Error(`animation_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(animationInput);
|
||||
const animationDataOut = engine._malloc(4);
|
||||
const animationLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_snapshot(handle, animationDataOut, animationLengthOut) !== 0) {
|
||||
throw new Error("animation snapshot request failed");
|
||||
}
|
||||
const animationPointer = engine.HEAPU32[animationDataOut >>> 2];
|
||||
const animationLength = engine.HEAPU32[animationLengthOut >>> 2];
|
||||
const animationSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(animationPointer, animationPointer + animationLength)));
|
||||
const animation = animationSnapshot.animations.find((item) => item.targetId === "object:AnimatedObject");
|
||||
if (animationSnapshot.frame.start !== 1 || animationSnapshot.frame.end !== 10 || !animation ||
|
||||
animation.frameStart !== 1 || animation.frameEnd !== 10 || animation.channels.length !== 3 ||
|
||||
animation.channels.some((channel) => channel.keyframes.length !== 2) ||
|
||||
animation.channels.find((channel) => channel.path === "location[2]")?.keyframes[1]?.value?.[0] !== 4) {
|
||||
throw new Error(`unexpected animation SceneIR: ${JSON.stringify(animationSnapshot.animations)}`);
|
||||
}
|
||||
engine._free(animationDataOut);
|
||||
engine._free(animationLengthOut);
|
||||
|
||||
const riggedInput = engine._malloc(riggedShapeBlend.byteLength);
|
||||
engine.HEAPU8.set(riggedShapeBlend, riggedInput);
|
||||
if (engine._web_engine_open_blend(handle, riggedInput, riggedShapeBlend.byteLength) !== 0) {
|
||||
throw new Error(`rigged_shape_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(riggedInput);
|
||||
const riggedSnapshot = readHandleSnapshot(handle);
|
||||
const riggedMesh = riggedSnapshot.meshes.find((mesh) => mesh.id === "mesh:RiggedShapeMesh");
|
||||
const riggedArmature = riggedSnapshot.armatures?.find((armature) => armature.id === "armature:RiggedArmature");
|
||||
const riggedModifier = riggedMesh?.modifierStack?.find((modifier) => modifier.name === "Preview Decimate");
|
||||
const riggedArmatureModifier = riggedMesh?.modifierStack?.find((modifier) => modifier.type === "ARMATURE");
|
||||
if (!riggedMesh || riggedMesh.skinWeights?.boneNames?.join(",") !== "Root,Tip" ||
|
||||
riggedMesh.skinWeights.indices?.length !== 16 || riggedMesh.skinWeights.weights?.length !== 16 ||
|
||||
riggedMesh.skinWeights.armatureId !== riggedArmature?.id || riggedMesh.skinWeights.jointIds?.length !== 2 ||
|
||||
riggedArmature?.bones?.length !== 2 || riggedArmature.bones[1]?.parentId !== riggedArmature.bones[0]?.id ||
|
||||
riggedArmature.bones.some((bone) => bone.restMatrix.length !== 16 || bone.poseMatrix?.length !== 16) ||
|
||||
riggedMesh.skinWeights.bindMatrix?.[12] === 0 ||
|
||||
riggedMesh.shapeKeys?.length !== 1 || riggedMesh.shapeKeys[0]?.name !== "Smile" ||
|
||||
riggedMesh.shapeKeys[0]?.positions?.[8] !== 0.5 || !riggedModifier ||
|
||||
riggedModifier.type !== "DECIMATE" || riggedModifier.parameters?.ratio !== 0.75 ||
|
||||
riggedArmatureModifier?.parameters?.armatureObjectId !== "object:RiggedArmatureObject" ||
|
||||
riggedArmatureModifier?.parameters?.targetObjectIds?.[0] !== "object:RiggedArmatureObject" ||
|
||||
riggedArmatureModifier?.dependsOn?.[0] !== "object:RiggedArmatureObject" ||
|
||||
!riggedModifier.enabled || riggedModifier.showRender) {
|
||||
throw new Error(`unexpected rigged SceneIR: ${JSON.stringify(riggedMesh)}`);
|
||||
}
|
||||
const riggedDepsgraph = readHandleDepsgraph(handle);
|
||||
if (riggedDepsgraph.unavailable) {
|
||||
if (riggedDepsgraph.code !== -3 || !riggedDepsgraph.error.includes("headless frame evaluation is memory-safe")) {
|
||||
throw new Error(`Depsgraph did not fail safely: ${JSON.stringify(riggedDepsgraph)}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const evaluatedRiggedMesh = riggedDepsgraph.meshes?.find((mesh) => mesh.sourceMeshId === "mesh:RiggedShapeMesh");
|
||||
if (riggedDepsgraph.engine !== "BlenderDepsgraph" || riggedDepsgraph.status !== "EVALUATED" ||
|
||||
riggedDepsgraph.meshObjectCount < 1 || !evaluatedRiggedMesh ||
|
||||
evaluatedRiggedMesh.positions?.length !== evaluatedRiggedMesh.vertexCount * 3 ||
|
||||
evaluatedRiggedMesh.indices?.length !== evaluatedRiggedMesh.triangleCount * 3 ||
|
||||
evaluatedRiggedMesh.modifierCount < 1) {
|
||||
throw new Error(`unexpected Blender Depsgraph report: ${JSON.stringify(riggedDepsgraph)}`);
|
||||
}
|
||||
}
|
||||
const modifierUuid = riggedModifier.uuid;
|
||||
if (applyHandleCommand(handle, { type: "setModifierEnabled", meshId: riggedMesh.id, modifierUuid, enabled: false }) !== 0) {
|
||||
throw new Error(`setModifierEnabled failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const modifierDisabled = readHandleSnapshot(handle);
|
||||
if (modifierDisabled.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false ||
|
||||
modifierDisabled.revision !== riggedSnapshot.revision + 1) {
|
||||
throw new Error(`modifier disable did not update SceneIR: ${JSON.stringify(modifierDisabled.meshes[0]?.modifierStack)}`);
|
||||
}
|
||||
const modifierSaveDataOut = engine._malloc(4);
|
||||
const modifierSaveLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_save_blend(handle, modifierSaveDataOut, modifierSaveLengthOut) !== 0) {
|
||||
throw new Error(`modifier authoritative save failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const modifierSavedPointer = engine.HEAPU32[modifierSaveDataOut >>> 2];
|
||||
const modifierSavedLength = engine.HEAPU32[modifierSaveLengthOut >>> 2];
|
||||
const modifierSaved = engine.HEAPU8.slice(modifierSavedPointer, modifierSavedPointer + modifierSavedLength);
|
||||
engine._web_engine_free_buffer(modifierSavedPointer);
|
||||
engine._free(modifierSaveDataOut);
|
||||
engine._free(modifierSaveLengthOut);
|
||||
const reopenedModifierHandle = engine._web_engine_create();
|
||||
if (openFixture(reopenedModifierHandle, modifierSaved) !== 0) {
|
||||
throw new Error(`modifier save could not be reopened: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const reopenedModifierSnapshot = readHandleSnapshot(reopenedModifierHandle);
|
||||
if (reopenedModifierSnapshot.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false) {
|
||||
throw new Error("modifier save lost disabled state");
|
||||
}
|
||||
engine._web_engine_destroy(reopenedModifierHandle);
|
||||
if (engine._web_engine_undo(handle) !== 0) throw new Error("modifier undo failed");
|
||||
const modifierUndone = readHandleSnapshot(handle);
|
||||
if (modifierUndone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== true) {
|
||||
throw new Error("modifier undo did not restore enabled state");
|
||||
}
|
||||
const modifierUndoSaved = saveHandleBlend(handle);
|
||||
const modifierUndoHandle = engine._web_engine_create();
|
||||
if (openFixture(modifierUndoHandle, modifierUndoSaved) !== 0 ||
|
||||
readHandleSnapshot(modifierUndoHandle).meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== true) {
|
||||
throw new Error("modifier undo did not restore an authoritative, reopenable Main");
|
||||
}
|
||||
engine._web_engine_destroy(modifierUndoHandle);
|
||||
if (engine._web_engine_redo(handle) !== 0) throw new Error("modifier redo failed");
|
||||
const modifierRedone = readHandleSnapshot(handle);
|
||||
if (modifierRedone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.uuid === modifierUuid)?.enabled !== false) {
|
||||
throw new Error("modifier redo did not restore disabled state");
|
||||
}
|
||||
saveHandleBlend(handle);
|
||||
const armatureModifier = modifierRedone.meshes.find((mesh) => mesh.id === riggedMesh.id)?.modifierStack?.find((modifier) => modifier.type === "ARMATURE");
|
||||
if (!armatureModifier || applyHandleCommand(handle, { type: "setModifierEnabled", meshId: riggedMesh.id, modifierUuid: armatureModifier.uuid, enabled: false }) !== 0) {
|
||||
throw new Error(`armature modifier disable failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const skinSource = readHandleSnapshot(handle);
|
||||
const skinProfile = {
|
||||
schemaVersion: 1,
|
||||
sourceMeshRevision: skinSource.revision,
|
||||
attributePolicy: "PRESERVE",
|
||||
mode: "COLLAPSE",
|
||||
ratio: 0.5,
|
||||
vertexGroup: null,
|
||||
vertexGroupFactor: 1,
|
||||
vertexGroupInvert: false,
|
||||
triangulate: false,
|
||||
useSymmetry: false,
|
||||
symmetryAxis: 0,
|
||||
symmetryTolerance: 1e-4,
|
||||
skinPolicy: { maxInfluences: 4, minWeight: 0.001, maxPositionError: 0.001, shapeKeys: "PRESERVE" },
|
||||
};
|
||||
const { skinPolicy: ignoredSkinPolicy, ...skinlessProfile } = skinProfile;
|
||||
if (applyHandleCommand(handle, { type: "decimateMesh", meshId: riggedMesh.id, profile: skinlessProfile }) !== -3 ||
|
||||
!engine.UTF8ToString(engine._web_engine_last_error_message()).includes("requires skinPolicy")) {
|
||||
throw new Error("skinless decimate was not safely blocked");
|
||||
}
|
||||
if (applyHandleCommand(handle, { type: "decimateMesh", meshId: riggedMesh.id, profile: skinProfile }) !== 0) {
|
||||
throw new Error(`skin-aware decimate failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const skinDecimated = readHandleSnapshot(handle).meshes.find((mesh) => mesh.id === riggedMesh.id);
|
||||
if (!skinDecimated?.skinWeights || skinDecimated.skinWeights.indices.length !== skinDecimated.vertexCount * 4 ||
|
||||
skinDecimated.skinWeights.weights.length !== skinDecimated.vertexCount * 4 ||
|
||||
skinDecimated.skinWeights.jointIds?.join(",") !== riggedMesh.skinWeights.jointIds?.join(",") ||
|
||||
skinDecimated.skinWeights.bindMatrix?.join(",") !== riggedMesh.skinWeights.bindMatrix?.join(",") ||
|
||||
skinDecimated.shapeKeys?.some((shape) => shape.positions.length !== skinDecimated.vertexCount * 3) ||
|
||||
!skinDecimated.shapeKeys?.[0]?.positions.some((value) => Math.abs(value) > 0)) {
|
||||
throw new Error(`skin-aware decimate did not remap attributes: ${JSON.stringify(skinDecimated)}`);
|
||||
}
|
||||
for (let vertex = 0; vertex < skinDecimated.vertexCount; vertex++) {
|
||||
const total = skinDecimated.skinWeights.weights.slice(vertex * 4, vertex * 4 + 4).reduce((sum, weight) => sum + weight, 0);
|
||||
if (Math.abs(total - 1) > 1e-5) throw new Error(`skin weights are not normalized at vertex ${vertex}`);
|
||||
}
|
||||
const skinSaveDataOut = engine._malloc(4);
|
||||
const skinSaveLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_save_blend(handle, skinSaveDataOut, skinSaveLengthOut) !== -3 ||
|
||||
!engine.UTF8ToString(engine._web_engine_last_error_message()).includes("BLEND_WRITE_REQUIRES_MAIN_AUTHORITY")) {
|
||||
throw new Error("skin/shape-key decimate should remain structurally blocked from save");
|
||||
}
|
||||
engine._free(skinSaveDataOut);
|
||||
engine._free(skinSaveLengthOut);
|
||||
|
||||
const input = engine._malloc(blend.byteLength);
|
||||
engine.HEAPU8.set(blend, input);
|
||||
if (engine._web_engine_open_blend(handle, input, blend.byteLength) !== 0) {
|
||||
throw new Error(`basic_scene.blend rejected: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(input);
|
||||
|
||||
const dataOut = engine._malloc(4);
|
||||
const lengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut) !== 0) {
|
||||
throw new Error("snapshot request failed");
|
||||
}
|
||||
const snapshotPointer = engine.HEAPU32[dataOut >>> 2];
|
||||
const snapshotLength = engine.HEAPU32[lengthOut >>> 2];
|
||||
const snapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(snapshotPointer, snapshotPointer + snapshotLength)));
|
||||
if (snapshot.schemaVersion !== 1 || snapshot.nodes.length !== 3 || snapshot.meshes[0].vertexCount !== 8 ||
|
||||
snapshot.meshes[0].geometryStatus !== "available" || snapshot.meshes[0].positions.length !== 24 || snapshot.meshes[0].indices.length !== 36 ||
|
||||
snapshot.nodes.some((node) => node.worldMatrix.length !== 16 || typeof node.selectable !== "boolean") ||
|
||||
!Array.isArray(snapshot.images) || !Array.isArray(snapshot.animations) ||
|
||||
snapshot.cameras[0]?.sensorWidthMm !== 36 || snapshot.cameras[0]?.sensorHeightMm !== 24 ||
|
||||
snapshot.lights[0]?.areaSize !== 5 || snapshot.lights[0]?.areaSizeY !== 1 ||
|
||||
snapshot.scenes[0]?.worldId !== "world:World" || snapshot.worlds[0]?.id !== "world:World") {
|
||||
throw new Error(`unexpected SceneIR: ${JSON.stringify({ nodes: snapshot.nodes.length, meshes: snapshot.meshes.length })}`);
|
||||
}
|
||||
if (engine._web_engine_apply_command(handle, 0, 0) !== -2) {
|
||||
throw new Error("empty command input should return INVALID_ARGUMENT");
|
||||
}
|
||||
const frameCommand = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame: 12 }));
|
||||
const frameCommandPointer = engine._malloc(frameCommand.byteLength);
|
||||
engine.HEAPU8.set(frameCommand, frameCommandPointer);
|
||||
if (engine._web_engine_apply_command(handle, frameCommandPointer, frameCommand.byteLength) !== 0) {
|
||||
throw new Error(`setFrame command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(frameCommandPointer);
|
||||
const frameDeltaDataOut = engine._malloc(4);
|
||||
const frameDeltaLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_delta(handle, frameDeltaDataOut, frameDeltaLengthOut) !== 0) {
|
||||
throw new Error("setFrame SceneDelta request failed");
|
||||
}
|
||||
const frameDeltaPointer = engine.HEAPU32[frameDeltaDataOut >>> 2];
|
||||
const frameDeltaLength = engine.HEAPU32[frameDeltaLengthOut >>> 2];
|
||||
const frameDelta = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(frameDeltaPointer, frameDeltaPointer + frameDeltaLength)));
|
||||
const frameRevision = snapshot.revision + 1;
|
||||
if (frameDelta.baseRevision !== snapshot.revision || frameDelta.nextRevision !== frameRevision || frameDelta.frame?.current !== 12) {
|
||||
throw new Error(`unexpected setFrame SceneDelta: ${JSON.stringify(frameDelta)}`);
|
||||
}
|
||||
engine._free(frameDeltaDataOut);
|
||||
engine._free(frameDeltaLengthOut);
|
||||
|
||||
const visibilityCommand = new TextEncoder().encode(JSON.stringify({ type: "setObjectVisibility", objectId: "object:BasicCube", visible: false }));
|
||||
const visibilityCommandPointer = engine._malloc(visibilityCommand.byteLength);
|
||||
engine.HEAPU8.set(visibilityCommand, visibilityCommandPointer);
|
||||
if (engine._web_engine_apply_command(handle, visibilityCommandPointer, visibilityCommand.byteLength) !== 0) {
|
||||
throw new Error(`setObjectVisibility command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(visibilityCommandPointer);
|
||||
const visibilityRevision = frameRevision + 1;
|
||||
const visibilityDataOut = engine._malloc(4);
|
||||
const visibilityLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_snapshot(handle, visibilityDataOut, visibilityLengthOut) !== 0) {
|
||||
throw new Error("visibility snapshot request failed");
|
||||
}
|
||||
const visibilitySnapshotPointer = engine.HEAPU32[visibilityDataOut >>> 2];
|
||||
const visibilitySnapshotLength = engine.HEAPU32[visibilityLengthOut >>> 2];
|
||||
const visibilitySnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(visibilitySnapshotPointer, visibilitySnapshotPointer + visibilitySnapshotLength)));
|
||||
if (visibilitySnapshot.revision !== visibilityRevision || visibilitySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible !== false) {
|
||||
throw new Error(`unexpected visibility snapshot: ${JSON.stringify(visibilitySnapshot)}`);
|
||||
}
|
||||
engine._free(visibilityDataOut);
|
||||
engine._free(visibilityLengthOut);
|
||||
|
||||
const authoritySaveDataOut = engine._malloc(4);
|
||||
const authoritySaveLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_save_blend(handle, authoritySaveDataOut, authoritySaveLengthOut) !== 0) {
|
||||
throw new Error(`authoritative save request failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const authoritySavedPointer = engine.HEAPU32[authoritySaveDataOut >>> 2];
|
||||
const authoritySavedLength = engine.HEAPU32[authoritySaveLengthOut >>> 2];
|
||||
if (authoritySavedPointer === 0 || authoritySavedLength === 0) {
|
||||
throw new Error("authoritative save produced an empty buffer");
|
||||
}
|
||||
const authoritySaved = engine.HEAPU8.slice(authoritySavedPointer, authoritySavedPointer + authoritySavedLength);
|
||||
engine._web_engine_free_buffer(authoritySavedPointer);
|
||||
engine._free(authoritySaveDataOut);
|
||||
engine._free(authoritySaveLengthOut);
|
||||
|
||||
const reopenedAuthorityHandle = engine._web_engine_create();
|
||||
if (openFixture(reopenedAuthorityHandle, authoritySaved) !== 0) {
|
||||
throw new Error(`authoritative save could not be reopened: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const reopenedAuthoritySnapshot = readHandleSnapshot(reopenedAuthorityHandle);
|
||||
if (reopenedAuthoritySnapshot.frame?.current !== 12 ||
|
||||
reopenedAuthoritySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.visible !== false) {
|
||||
throw new Error(`authoritative save lost edits: ${JSON.stringify(reopenedAuthoritySnapshot.frame)}`);
|
||||
}
|
||||
engine._web_engine_destroy(reopenedAuthorityHandle);
|
||||
|
||||
const decimateMesh = visibilitySnapshot.meshes.find((mesh) => mesh.id === visibilitySnapshot.nodes.find((node) => node.id === "object:BasicCube")?.dataId) ?? visibilitySnapshot.meshes[0];
|
||||
const decimateProfile = {
|
||||
schemaVersion: 1,
|
||||
sourceMeshRevision: visibilitySnapshot.revision,
|
||||
attributePolicy: "PRESERVE",
|
||||
mode: "COLLAPSE",
|
||||
ratio: 0.5,
|
||||
vertexGroup: null,
|
||||
vertexGroupFactor: 1,
|
||||
vertexGroupInvert: false,
|
||||
triangulate: false,
|
||||
useSymmetry: false,
|
||||
symmetryAxis: 0,
|
||||
symmetryTolerance: 1e-4,
|
||||
};
|
||||
const decimateCommand = new TextEncoder().encode(JSON.stringify({ type: "decimateMesh", meshId: decimateMesh.id, profile: decimateProfile }));
|
||||
const decimateCommandPointer = engine._malloc(decimateCommand.byteLength);
|
||||
engine.HEAPU8.set(decimateCommand, decimateCommandPointer);
|
||||
if (engine._web_engine_apply_command(handle, decimateCommandPointer, decimateCommand.byteLength) !== 0) {
|
||||
throw new Error(`decimateMesh command failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
engine._free(decimateCommandPointer);
|
||||
const decimateDataOut = engine._malloc(4);
|
||||
const decimateLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_snapshot(handle, decimateDataOut, decimateLengthOut) !== 0) {
|
||||
throw new Error("decimate snapshot request failed");
|
||||
}
|
||||
const decimatePointer = engine.HEAPU32[decimateDataOut >>> 2];
|
||||
const decimateLength = engine.HEAPU32[decimateLengthOut >>> 2];
|
||||
const decimateSnapshot = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(decimatePointer, decimatePointer + decimateLength)));
|
||||
const decimatedMesh = decimateSnapshot.meshes.find((mesh) => mesh.id === decimateMesh.id);
|
||||
const decimateRevision = visibilityRevision + 1;
|
||||
if (decimateSnapshot.revision !== decimateRevision || !decimatedMesh || decimatedMesh.triangleCount <= 0 ||
|
||||
decimatedMesh.triangleCount >= decimateMesh.triangleCount ||
|
||||
decimatedMesh.vertexCount >= decimateMesh.vertexCount || decimatedMesh.positions?.length !== decimatedMesh.vertexCount * 3 ||
|
||||
decimatedMesh.indices?.length !== decimatedMesh.triangleCount * 3 || decimatedMesh.indices.some((index) => index >= decimatedMesh.vertexCount) ||
|
||||
decimatedMesh.normals?.length !== decimatedMesh.vertexCount * 3 ||
|
||||
decimatedMesh.triangleCornerIndices?.length !== decimatedMesh.triangleCount * 3 ||
|
||||
decimatedMesh.uvs?.length !== decimatedMesh.triangleCount * 6 ||
|
||||
decimatedMesh.triangleMaterialIndices?.length !== decimatedMesh.triangleCount) {
|
||||
throw new Error(`unexpected decimated mesh: ${JSON.stringify(decimatedMesh)}`);
|
||||
}
|
||||
engine._free(decimateDataOut);
|
||||
engine._free(decimateLengthOut);
|
||||
engine._free(dataOut);
|
||||
engine._free(lengthOut);
|
||||
|
||||
const decimateSaved = saveHandleBlend(handle);
|
||||
const decimateSavedHandle = engine._web_engine_create();
|
||||
if (openFixture(decimateSavedHandle, decimateSaved) !== 0 ||
|
||||
readHandleSnapshot(decimateSavedHandle).meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimatedMesh.triangleCount) {
|
||||
throw new Error("authoritative decimate did not survive save/reopen");
|
||||
}
|
||||
engine._web_engine_destroy(decimateSavedHandle);
|
||||
|
||||
if (engine._web_engine_undo(handle) !== 0) throw new Error("authoritative decimate undo failed");
|
||||
const decimateUndone = readHandleSnapshot(handle);
|
||||
if (decimateUndone.meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimateMesh.triangleCount) {
|
||||
throw new Error("snapshot-only undo did not restore original geometry");
|
||||
}
|
||||
saveHandleBlend(handle);
|
||||
if (engine._web_engine_redo(handle) !== 0) throw new Error("authoritative decimate redo failed");
|
||||
const decimateRedone = readHandleSnapshot(handle);
|
||||
if (decimateRedone.meshes.find((mesh) => mesh.id === decimateMesh.id)?.triangleCount !== decimatedMesh.triangleCount) {
|
||||
throw new Error("snapshot-only redo did not restore decimated geometry");
|
||||
}
|
||||
if (applyHandleCommand(handle, { type: "setFrame", frame: 2 }) !== 0) {
|
||||
throw new Error("Main command failed after authoritative decimate redo");
|
||||
}
|
||||
saveHandleBlend(handle);
|
||||
const postHistoryRevision = decimateRevision + 3;
|
||||
|
||||
const emptyAgainInput = engine._malloc(emptyBlend.byteLength);
|
||||
engine.HEAPU8.set(emptyBlend, emptyAgainInput);
|
||||
if (engine._web_engine_open_blend(handle, emptyAgainInput, emptyBlend.byteLength) !== 0) {
|
||||
throw new Error("second empty.blend open failed");
|
||||
}
|
||||
engine._free(emptyAgainInput);
|
||||
const deltaDataOut = engine._malloc(4);
|
||||
const deltaLengthOut = engine._malloc(4);
|
||||
if (engine._web_engine_get_scene_delta(handle, deltaDataOut, deltaLengthOut) !== 0) {
|
||||
throw new Error("native SceneDelta request failed");
|
||||
}
|
||||
const deltaPointer = engine.HEAPU32[deltaDataOut >>> 2];
|
||||
const deltaLength = engine.HEAPU32[deltaLengthOut >>> 2];
|
||||
const delta = JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(deltaPointer, deltaPointer + deltaLength)));
|
||||
if (delta.schemaVersion !== 1 || delta.baseRevision !== postHistoryRevision || delta.nextRevision !== postHistoryRevision + 1 ||
|
||||
!delta.nodes?.removed?.includes("object:BasicCube")) {
|
||||
throw new Error(`unexpected native SceneDelta: ${JSON.stringify(delta)}`);
|
||||
}
|
||||
engine._free(deltaDataOut);
|
||||
engine._free(deltaLengthOut);
|
||||
|
||||
for (const modeProfile of [
|
||||
{ mode: "UNSUBDIV", iterations: 1 },
|
||||
{ mode: "DISSOLVE_PLANAR", angleLimit: 0.08726646, useDissolveBoundaries: false, delimit: ["MATERIAL"] },
|
||||
]) {
|
||||
const modeHandle = engine._web_engine_create();
|
||||
if (openFixture(modeHandle, blend) !== 0) throw new Error(`${modeProfile.mode} fixture open failed`);
|
||||
const modeSource = readHandleSnapshot(modeHandle);
|
||||
const modeMesh = modeSource.meshes[0];
|
||||
const modeResult = applyHandleCommand(modeHandle, {
|
||||
type: "decimateMesh",
|
||||
meshId: modeMesh.id,
|
||||
profile: { schemaVersion: 1, sourceMeshRevision: modeSource.revision, attributePolicy: "PRESERVE", ...modeProfile },
|
||||
});
|
||||
if (modeResult !== 0) {
|
||||
throw new Error(`${modeProfile.mode} failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const modeSnapshot = readHandleSnapshot(modeHandle);
|
||||
const modeOutput = modeSnapshot.meshes[0];
|
||||
if (modeSnapshot.revision !== modeSource.revision + 1 || !modeOutput || modeOutput.triangleCount <= 0 ||
|
||||
modeOutput.positions?.length !== modeOutput.vertexCount * 3 || modeOutput.indices?.length !== modeOutput.triangleCount * 3 ||
|
||||
modeOutput.normals?.length !== modeOutput.vertexCount * 3 || modeOutput.uvs?.length !== modeOutput.triangleCount * 6 ||
|
||||
modeOutput.triangleMaterialIndices?.length !== modeOutput.triangleCount) {
|
||||
throw new Error(`unexpected ${modeProfile.mode} result: ${JSON.stringify(modeOutput)}`);
|
||||
}
|
||||
saveHandleBlend(modeHandle);
|
||||
engine._web_engine_destroy(modeHandle);
|
||||
}
|
||||
|
||||
const modelingHandle = engine._web_engine_create();
|
||||
if (openFixture(modelingHandle, emptyBlend) !== 0) throw new Error("modeling fixture open failed");
|
||||
const modelingStart = readHandleSnapshot(modelingHandle);
|
||||
if (applyHandleCommand(modelingHandle, {
|
||||
type: "createPrimitive",
|
||||
primitive: "CUBE",
|
||||
name: "WebCube",
|
||||
location: [1, 2, 3],
|
||||
}) !== 0) {
|
||||
throw new Error(`createPrimitive failed: ${engine.UTF8ToString(engine._web_engine_last_error_message())}`);
|
||||
}
|
||||
const created = readHandleSnapshot(modelingHandle);
|
||||
const cubeNode = created.nodes.find((node) => node.name === "WebCube");
|
||||
const cubeMesh = created.meshes.find((mesh) => mesh.id === cubeNode?.dataId);
|
||||
if (created.revision !== modelingStart.revision + 1 || !cubeNode || !cubeMesh ||
|
||||
cubeMesh.vertexCount !== 8 || cubeMesh.triangleCount !== 12 ||
|
||||
JSON.stringify(cubeNode.transform.translation) !== JSON.stringify([1, 2, 3])) {
|
||||
throw new Error(`unexpected created primitive: ${JSON.stringify({ node: cubeNode, mesh: cubeMesh })}`);
|
||||
}
|
||||
if (applyHandleCommand(modelingHandle, {
|
||||
type: "setObjectTransform",
|
||||
objectId: cubeNode.id,
|
||||
translation: [2, 3, 4],
|
||||
rotationEuler: [0.1, 0.2, 0.3],
|
||||
scale: [1.5, 0.75, 2],
|
||||
}) !== 0) throw new Error("setObjectTransform failed");
|
||||
if (applyHandleCommand(modelingHandle, {
|
||||
type: "translateMeshVertices",
|
||||
meshId: cubeMesh.id,
|
||||
vertexIndices: [0, 1],
|
||||
offset: [0, 0, 0.5],
|
||||
}) !== 0) throw new Error("translateMeshVertices failed");
|
||||
if (applyHandleCommand(modelingHandle, {
|
||||
type: "duplicateObject",
|
||||
objectId: cubeNode.id,
|
||||
offset: [0.5, 0, 0],
|
||||
}) !== 0) throw new Error("duplicateObject failed");
|
||||
const duplicated = readHandleSnapshot(modelingHandle);
|
||||
const duplicateNode = duplicated.nodes.find((node) => node.id !== cubeNode.id && node.name.startsWith("WebCube"));
|
||||
if (!duplicateNode || duplicated.nodes.length !== 2 || duplicated.meshes.length !== 2) {
|
||||
throw new Error(`duplicateObject did not create independent object/mesh IDs: ${JSON.stringify(duplicated.nodes)}`);
|
||||
}
|
||||
if (applyHandleCommand(modelingHandle, { type: "deleteObject", objectId: duplicateNode.id }) !== 0) {
|
||||
throw new Error("deleteObject failed");
|
||||
}
|
||||
const deleted = readHandleSnapshot(modelingHandle);
|
||||
if (deleted.nodes.some((node) => node.id === duplicateNode.id)) throw new Error("deleteObject left a scene node");
|
||||
if (engine._web_engine_undo(modelingHandle) !== 0 ||
|
||||
!readHandleSnapshot(modelingHandle).nodes.some((node) => node.id === duplicateNode.id)) {
|
||||
throw new Error("modeling undo failed");
|
||||
}
|
||||
if (engine._web_engine_redo(modelingHandle) !== 0 ||
|
||||
readHandleSnapshot(modelingHandle).nodes.some((node) => node.id === duplicateNode.id)) {
|
||||
throw new Error("modeling redo failed");
|
||||
}
|
||||
const modelingSaved = saveHandleBlend(modelingHandle);
|
||||
const modelingReopenHandle = engine._web_engine_create();
|
||||
if (openFixture(modelingReopenHandle, modelingSaved) !== 0) throw new Error("modeling save/reopen failed");
|
||||
const reopenedModel = readHandleSnapshot(modelingReopenHandle);
|
||||
const reopenedCube = reopenedModel.nodes.find((node) => node.name === "WebCube");
|
||||
if (!reopenedCube || JSON.stringify(reopenedCube.transform.translation) !== JSON.stringify([2, 3, 4]) ||
|
||||
reopenedModel.nodes.length !== 1) {
|
||||
throw new Error(`modeling edits did not survive save/reopen: ${JSON.stringify(reopenedModel.nodes)}`);
|
||||
}
|
||||
engine._web_engine_destroy(modelingReopenHandle);
|
||||
engine._web_engine_destroy(modelingHandle);
|
||||
engine._web_engine_destroy(handle);
|
||||
console.log(`web-engine-blend-ok handle=${handle} nodes=${snapshot.nodes.length} vertices=${snapshot.meshes[0].vertexCount} bytes=${authoritySavedLength} authoritativeDecimate=ok modeling=ok`);
|
||||
Reference in New Issue
Block a user