579 lines
33 KiB
JavaScript
579 lines
33 KiB
JavaScript
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`);
|