import type { GLBAssetBuffer } from "./glb-export"; import type { SceneSnapshotIR } from "./scene-ir"; const GLB_MAGIC = 0x46546c67; const JSON_CHUNK = 0x4e4f534a; const BIN_CHUNK = 0x004e4942; interface GLBAccessor { bufferView?: number; byteOffset?: number; componentType: number; count: number; type: string; normalized?: boolean; min?: number[]; max?: number[]; sparse?: Record; } interface GLBBufferView { buffer: number; byteOffset?: number; byteLength: number; } interface GLBPrimitive { attributes?: Record; indices?: number; material?: number; mode?: number; targets?: Array>; } interface GLBDocument { asset?: { version?: string; generator?: string }; scene?: number; scenes?: Array<{ nodes?: number[] }>; extensionsUsed?: string[]; extensionsRequired?: string[]; buffers?: Array<{ byteLength?: number; uri?: string }>; bufferViews?: GLBBufferView[]; accessors?: GLBAccessor[]; meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record }>; images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record }>; samplers?: Array>; textures?: Array<{ sampler?: number; source?: number }>; materials?: Array<{ name?: string; alphaMode?: string; doubleSided?: boolean; pbrMetallicRoughness?: { baseColorFactor?: number[]; baseColorTexture?: Record; metallicFactor?: number; roughnessFactor?: number; }; normalTexture?: Record; emissiveFactor?: number[]; }>; nodes?: Array<{ name?: string; mesh?: number; skin?: number; children?: number[]; translation?: number[]; rotation?: number[]; scale?: number[]; }>; skins?: Array<{ name?: string; joints?: number[]; inverseBindMatrices?: number; skeleton?: number; extras?: Record }>; animations?: Array<{ name?: string; samplers?: Array<{ input?: number; output?: number; interpolation?: string }>; channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>; extras?: Record; }>; } export const GLB_IMPORT_BUDGET = { maxBytes: 512 * 1024, maxJsonBytes: 256 * 1024, maxBufferViews: 4096, maxAccessors: 8192, } as const; export interface ImportedGLBImage { name?: string; blenderId?: string; mimeType?: string; byteLength: number; signature: number[]; } export interface ImportedGLBSemantics { version: 2; meshCount: number; primitiveCount: number; meshes: Array<{ name?: string; blenderId?: string; primitiveCount: number; attributes: string[]; morphTargetCount: number }>; images: ImportedGLBImage[]; skinCount: number; skins: Array<{ jointCount: number; inverseBindType?: string; inverseBindCount?: number }>; animationCount: number; animationChannelCount: number; animationPaths: string[]; } export interface GLBSemanticComparison { compatible: boolean; mismatches: string[]; } export interface GLBDesktopAccessorSemantics { componentType: number; count: number; type: string; normalized: boolean; min: number[] | null; max: number[] | null; } export interface GLBDesktopFixtureSemantics { asset: { version?: string; generator?: string } | null; extensionsUsed: string[]; extensionsRequired: string[]; scene: number | null; nodeNames: Array; nodes: Array<{ name: string | null; mesh: number | null; skin: number | null; children: number[]; translation: number[] | null; rotation: number[] | null; scale: number[] | null; }>; meshes: Array<{ name: string | null; primitives: Array<{ attributes: Record; indices: GLBDesktopAccessorSemantics | null; material: number | null; mode: number; targets: Array>; }>; }>; materials: Array<{ name: string | null; alphaMode: string; doubleSided: boolean; pbr: { baseColorFactor: number[] | null; baseColorTexture: Record | null; metallicFactor: number | null; roughnessFactor: number | null; }; normalTexture: Record | null; emissiveFactor: number[] | null; }>; textures: Array>; images: Array>; samplers: Array>; skins: Array<{ name: string | null; joints: number[]; inverseBindMatrices: GLBDesktopAccessorSemantics | null; skeleton: number | null; }>; animations: Array<{ name: string | null; samplers: Array<{ interpolation: string; input: GLBDesktopAccessorSemantics | null; output: GLBDesktopAccessorSemantics | null; }>; channels: Array<{ sampler: number; target: { node: number; path: string } }>; }>; } function recordId(extras: Record | undefined): string | undefined { return typeof extras?.blenderId === "string" ? extras.blenderId : undefined; } function requireIndex(value: unknown, size: number, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= size) throw new Error(`${label} index is out of range`); return value as number; } function jsonChunk(bytes: Uint8Array, length: number): GLBDocument { if (bytes.byteLength > GLB_IMPORT_BUDGET.maxBytes) throw new Error(`GLB_IMPORT_BUDGET_EXCEEDED: file exceeds ${GLB_IMPORT_BUDGET.maxBytes} bytes`); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC || view.getUint32(4, true) !== 2) throw new Error("GLB header is invalid"); if (view.getUint32(8, true) !== bytes.byteLength) throw new Error("GLB length does not match header"); const jsonLength = view.getUint32(12, true); if (jsonLength > GLB_IMPORT_BUDGET.maxJsonBytes || view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid"); let document: unknown; try { document = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trim()); } catch (error) { throw new Error(`GLB JSON is invalid: ${error instanceof Error ? error.message : "parse failed"}`); } if (typeof document !== "object" || document === null || Array.isArray(document)) throw new Error("GLB JSON document is invalid"); if ((document as GLBDocument).asset?.version !== "2.0") throw new Error("GLB asset version is not 2.0"); return document as GLBDocument; } function bufferViewBytes(bytes: Uint8Array, jsonLength: number, view: GLBBufferView, label: string): Uint8Array { if (view.buffer !== 0 || !Number.isSafeInteger(view.byteLength) || view.byteLength < 0) throw new Error(`${label} bufferView is invalid`); const binaryStart = 20 + jsonLength + 8; const offset = view.byteOffset ?? 0; if (!Number.isSafeInteger(offset) || offset < 0 || binaryStart + offset + view.byteLength > bytes.byteLength) throw new Error(`${label} bufferView exceeds BIN chunk`); return bytes.subarray(binaryStart + offset, binaryStart + offset + view.byteLength); } export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics { const bytes = new Uint8Array(glb); const view = new DataView(glb); const jsonLength = view.getUint32(12, true); const document = jsonChunk(bytes, glb.byteLength); if ((document.extensionsUsed?.length ?? 0) > 0 || (document.extensionsRequired?.length ?? 0) > 0) throw new Error("GLB_EXTENSION_UNSUPPORTED: extensions are outside the bounded importer"); if ((document.buffers ?? []).some((buffer) => buffer.uri !== undefined) || (document.images ?? []).some((image) => image.uri !== undefined)) throw new Error("GLB_EXTERNAL_URI_BLOCKED: external URI resources are not accepted"); const bufferViews = document.bufferViews ?? []; const accessors = document.accessors ?? []; if (bufferViews.length > GLB_IMPORT_BUDGET.maxBufferViews || accessors.length > GLB_IMPORT_BUDGET.maxAccessors) throw new Error("GLB_IMPORT_BUDGET_EXCEEDED: accessor or bufferView count exceeds the bounded importer"); for (const [index, bufferView] of bufferViews.entries()) bufferViewBytes(bytes, jsonLength, bufferView, `bufferViews[${index}]`); const accessorType = (index: number): string => accessors[requireIndex(index, accessors.length, "accessor")]?.type ?? ""; for (const [index, accessor] of accessors.entries()) { if (accessor.sparse !== undefined) throw new Error(`GLB_SPARSE_ACCESSOR_UNSUPPORTED: accessors[${index}]`); if (!Number.isSafeInteger(accessor.count) || accessor.count < 0 || (accessor.byteOffset ?? 0) < 0) throw new Error(`accessors[${index}] is invalid`); if (accessor.bufferView !== undefined) { const bytesForAccessor = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(accessor.bufferView, bufferViews.length, `accessors[${index}]`)], `accessors[${index}]`); const componentBytes = accessor.componentType === 5126 || accessor.componentType === 5125 ? 4 : accessor.componentType === 5123 ? 2 : 1; const width = accessor.type === "SCALAR" ? 1 : accessor.type === "VEC2" ? 2 : accessor.type === "VEC3" ? 3 : accessor.type === "VEC4" ? 4 : accessor.type === "MAT4" ? 16 : 0; if (width === 0 || (accessor.byteOffset ?? 0) + accessor.count * width * componentBytes > bytesForAccessor.byteLength) throw new Error(`accessors[${index}] exceeds bufferView`); } } const images = (document.images ?? []).map((image, index) => { if (image.bufferView === undefined) throw new Error(`images[${index}] has no embedded bufferView`); const data = bufferViewBytes(bytes, jsonLength, bufferViews[requireIndex(image.bufferView, bufferViews.length, `images[${index}]`)], `images[${index}]`); return { name: image.name, blenderId: recordId(image.extras), mimeType: image.mimeType, byteLength: data.byteLength, signature: Array.from(data.subarray(0, 8)) }; }); const meshes = (document.meshes ?? []).map((mesh, index) => { const primitives = mesh.primitives ?? []; const attributes = new Set(); let morphTargetCount = 0; for (const [primitiveIndex, primitive] of primitives.entries()) { for (const semantic of Object.keys(primitive.attributes ?? {})) { const accessor = requireIndex(primitive.attributes?.[semantic], accessors.length, `meshes[${index}].primitives[${primitiveIndex}].${semantic}`); accessorType(accessor); attributes.add(semantic); } if (primitive.indices !== undefined) accessorType(requireIndex(primitive.indices, accessors.length, "primitive indices")); morphTargetCount = Math.max(morphTargetCount, primitive.targets?.length ?? 0); } return { name: mesh.name, blenderId: recordId(mesh.extras), primitiveCount: primitives.length, attributes: [...attributes].sort(), morphTargetCount }; }); const skins = (document.skins ?? []).map((skin, index) => { const jointCount = skin.joints?.length ?? 0; if (jointCount === 0) throw new Error(`skins[${index}] has no joints`); let inverseBindType: string | undefined; let inverseBindCount: number | undefined; if (skin.inverseBindMatrices !== undefined) { const accessor = accessors[requireIndex(skin.inverseBindMatrices, accessors.length, `skins[${index}].inverseBindMatrices`)]!; inverseBindType = accessor.type; inverseBindCount = accessor.count; if (inverseBindType !== "MAT4" || inverseBindCount !== jointCount) throw new Error(`skins[${index}] inverse bind matrices do not match joints`); } return { jointCount, inverseBindType, inverseBindCount }; }); const animations = document.animations ?? []; const animationPaths = animations.flatMap((animation) => (animation.channels ?? []).map((channel) => channel.target?.path ?? "")).filter(Boolean).sort(); return { version: 2, meshCount: meshes.length, primitiveCount: meshes.reduce((sum, mesh) => sum + mesh.primitiveCount, 0), meshes, images, skinCount: skins.length, skins, animationCount: animations.length, animationChannelCount: animationPaths.length, animationPaths }; } function desktopAccessorSemantics(accessors: readonly GLBAccessor[], index: number | undefined, label: string): GLBDesktopAccessorSemantics | null { if (index === undefined) return null; const accessor = accessors[requireIndex(index, accessors.length, label)]!; return { componentType: accessor.componentType, count: accessor.count, type: accessor.type, normalized: accessor.normalized === true, min: accessor.min ?? null, max: accessor.max ?? null, }; } /** * Imports the canonical, bounded semantic surface used by the M12 desktop GLB * fixtures. This intentionally does not create Blender Main data; that writer * and its stable-ID persistence gate belong to M12-06C. */ export function importGLBDesktopFixtureSemantics(glb: ArrayBuffer): GLBDesktopFixtureSemantics { importGLBSemantics(glb); const bytes = new Uint8Array(glb); const jsonLength = new DataView(glb).getUint32(12, true); const document = jsonChunk(bytes, glb.byteLength); const accessors = document.accessors ?? []; const meshes = document.meshes ?? []; const materials = document.materials ?? []; const nodes = document.nodes ?? []; const textures = document.textures ?? []; const images = document.images ?? []; const samplers = document.samplers ?? []; const skins = document.skins ?? []; const animations = document.animations ?? []; if (document.scene !== undefined) requireIndex(document.scene, document.scenes?.length ?? 0, "scene"); for (const [index, node] of nodes.entries()) { if (node.mesh !== undefined) requireIndex(node.mesh, meshes.length, `nodes[${index}].mesh`); if (node.skin !== undefined) requireIndex(node.skin, skins.length, `nodes[${index}].skin`); for (const child of node.children ?? []) requireIndex(child, nodes.length, `nodes[${index}].children`); } for (const [index, texture] of textures.entries()) { if (texture.source !== undefined) requireIndex(texture.source, images.length, `textures[${index}].source`); if (texture.sampler !== undefined) requireIndex(texture.sampler, samplers.length, `textures[${index}].sampler`); } const importedMeshes = meshes.map((mesh, meshIndex) => ({ name: mesh.name ?? null, primitives: (mesh.primitives ?? []).map((primitive, primitiveIndex) => { if (primitive.material !== undefined) requireIndex(primitive.material, materials.length, `meshes[${meshIndex}].primitives[${primitiveIndex}].material`); const attributes: Record = {}; for (const [name, accessor] of Object.entries(primitive.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) { attributes[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].attributes.${name}`)!; } return { attributes, indices: desktopAccessorSemantics(accessors, primitive.indices, `meshes[${meshIndex}].primitives[${primitiveIndex}].indices`), material: primitive.material ?? null, mode: primitive.mode ?? 4, targets: (primitive.targets ?? []).map((target, targetIndex) => { const imported: Record = {}; for (const [name, accessor] of Object.entries(target).sort(([left], [right]) => left.localeCompare(right))) { imported[name] = desktopAccessorSemantics(accessors, accessor, `meshes[${meshIndex}].primitives[${primitiveIndex}].targets[${targetIndex}].${name}`)!; } return imported; }), }; }), })); const importedSkins = skins.map((skin, skinIndex) => { const joints = skin.joints ?? []; for (const joint of joints) requireIndex(joint, nodes.length, `skins[${skinIndex}].joints`); if (skin.skeleton !== undefined) requireIndex(skin.skeleton, nodes.length, `skins[${skinIndex}].skeleton`); return { name: skin.name ?? null, joints, inverseBindMatrices: desktopAccessorSemantics(accessors, skin.inverseBindMatrices, `skins[${skinIndex}].inverseBindMatrices`), skeleton: skin.skeleton ?? null, }; }); const importedAnimations = animations.map((animation, animationIndex) => { const animationSamplers = animation.samplers ?? []; return { name: animation.name ?? null, samplers: animationSamplers.map((sampler, samplerIndex) => ({ interpolation: sampler.interpolation ?? "LINEAR", input: desktopAccessorSemantics(accessors, sampler.input, `animations[${animationIndex}].samplers[${samplerIndex}].input`), output: desktopAccessorSemantics(accessors, sampler.output, `animations[${animationIndex}].samplers[${samplerIndex}].output`), })), channels: (animation.channels ?? []).map((channel, channelIndex) => { const sampler = requireIndex(channel.sampler, animationSamplers.length, `animations[${animationIndex}].channels[${channelIndex}].sampler`); const node = requireIndex(channel.target?.node, nodes.length, `animations[${animationIndex}].channels[${channelIndex}].target.node`); const path = channel.target?.path; if (path !== "translation" && path !== "rotation" && path !== "scale" && path !== "weights") throw new Error(`animations[${animationIndex}].channels[${channelIndex}].target.path is invalid`); return { sampler, target: { node, path } }; }), }; }); return { asset: document.asset ?? null, extensionsUsed: [...(document.extensionsUsed ?? [])].sort(), extensionsRequired: [...(document.extensionsRequired ?? [])].sort(), scene: document.scene ?? null, nodeNames: nodes.map((node) => node.name ?? null), nodes: nodes.map((node) => ({ name: node.name ?? null, mesh: node.mesh ?? null, skin: node.skin ?? null, children: node.children ?? [], translation: node.translation ?? null, rotation: node.rotation ?? null, scale: node.scale ?? null, })), meshes: importedMeshes, materials: materials.map((material) => { const pbr = material.pbrMetallicRoughness ?? {}; return { name: material.name ?? null, alphaMode: material.alphaMode ?? "OPAQUE", doubleSided: material.doubleSided === true, pbr: { baseColorFactor: pbr.baseColorFactor ?? null, baseColorTexture: pbr.baseColorTexture ?? null, metallicFactor: pbr.metallicFactor ?? null, roughnessFactor: pbr.roughnessFactor ?? null, }, normalTexture: material.normalTexture ?? null, emissiveFactor: material.emissiveFactor ?? null, }; }), textures: textures.map((texture) => ({ ...texture })), images: images.map((image) => ({ ...image })), samplers: samplers.map((sampler) => ({ ...sampler })), skins: importedSkins, animations: importedAnimations, }; } function semanticMismatches(expected: unknown, actual: unknown, path: string, mismatches: string[]): void { if (Object.is(expected, actual)) return; if (Array.isArray(expected) || Array.isArray(actual)) { if (!Array.isArray(expected) || !Array.isArray(actual)) { mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`); return; } if (expected.length !== actual.length) mismatches.push(`${path}.length: expected ${expected.length} got ${actual.length}`); for (let index = 0; index < Math.min(expected.length, actual.length); index++) semanticMismatches(expected[index], actual[index], `${path}[${index}]`, mismatches); return; } if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) { const expectedRecord = expected as Record; const actualRecord = actual as Record; for (const key of [...new Set([...Object.keys(expectedRecord), ...Object.keys(actualRecord)])].sort()) { semanticMismatches(expectedRecord[key], actualRecord[key], `${path}.${key}`, mismatches); } return; } mismatches.push(`${path}: expected ${JSON.stringify(expected)} got ${JSON.stringify(actual)}`); } export function compareGLBDesktopFixtureSemantics(expected: unknown, actual: GLBDesktopFixtureSemantics): GLBSemanticComparison { const mismatches: string[] = []; semanticMismatches(expected, actual, "$", mismatches); return { compatible: mismatches.length === 0, mismatches }; } export function compareGLBToSceneIR(snapshot: SceneSnapshotIR, imported: ImportedGLBSemantics, assetBuffers: readonly GLBAssetBuffer[] = []): GLBSemanticComparison { const mismatches: string[] = []; const geometryMeshIds = new Set(snapshot.meshes.filter((mesh) => mesh.geometryStatus !== "summary-only").map((mesh) => mesh.id)); const importedMeshIds = new Set(imported.meshes.map((mesh) => mesh.blenderId).filter((id): id is string => Boolean(id))); for (const id of geometryMeshIds) if (!importedMeshIds.has(id)) mismatches.push(`mesh missing: ${id}`); const expectedImages = assetBuffers.filter((asset) => snapshot.images.some((image) => image.assetId === asset.assetId && asset.data.byteLength > 0)); if (imported.images.length !== expectedImages.length) mismatches.push(`image count ${imported.images.length} != ${expectedImages.length}`); for (const asset of expectedImages) { const image = imported.images.find((candidate) => candidate.blenderId === snapshot.images.find((source) => source.assetId === asset.assetId)?.id); if (!image || image.byteLength !== asset.data.byteLength || image.mimeType !== asset.mimeType) mismatches.push(`image semantic mismatch: ${asset.assetId}`); } const expectedSkins = snapshot.meshes.filter((mesh) => mesh.skinWeights?.armatureId && snapshot.armatures?.some((armature) => armature.id === mesh.skinWeights?.armatureId)); if (imported.skinCount !== expectedSkins.length) mismatches.push(`skin count ${imported.skinCount} != ${expectedSkins.length}`); for (const mesh of expectedSkins) { const expectedJoints = mesh.skinWeights?.jointIds?.length ?? 0; if (!imported.skins.some((skin) => skin.jointCount === expectedJoints && skin.inverseBindType === "MAT4")) mismatches.push(`skin semantic mismatch: ${mesh.id}`); } const expectedMorphs = snapshot.meshes.filter((mesh) => (mesh.shapeKeys?.length ?? 0) > 0).reduce((sum, mesh) => sum + (mesh.shapeKeys?.length ?? 0), 0); const importedMorphs = imported.meshes.reduce((sum, mesh) => sum + mesh.morphTargetCount, 0); if (importedMorphs !== expectedMorphs) mismatches.push(`morph target count ${importedMorphs} != ${expectedMorphs}`); const expectedAnimationPaths: string[] = []; let expectedAnimations = 0; for (const animation of snapshot.animations) { const groups = new Set(); for (const channel of animation.channels) { const boneMatch = channel.path.match(/^pose\.bones\["(.+)"\]\.(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/); const objectMatch = channel.path.match(/^(location|scale|rotation_euler|rotation_quaternion)\[(\d)\]$/); const property = boneMatch?.[2] ?? objectMatch?.[1]; if (!property || channel.keyframes.length === 0) continue; if (boneMatch) { const armature = (snapshot.armatures ?? []).find((candidate) => candidate.objectId === animation.targetId); if (!armature?.bones.some((bone) => bone.name === boneMatch[1])) continue; } else if (!snapshot.nodes.some((node) => node.id === animation.targetId)) continue; const path = property === "location" ? "translation" : property === "scale" ? "scale" : "rotation"; groups.add(`${boneMatch?.[1] ?? animation.targetId}:${path}`); } if (groups.size > 0) expectedAnimations++; for (const group of groups) expectedAnimationPaths.push(group.slice(group.lastIndexOf(":") + 1)); } expectedAnimationPaths.sort(); if (imported.animationCount !== expectedAnimations) mismatches.push(`animation count ${imported.animationCount} != ${expectedAnimations}`); if (imported.animationPaths.join(",") !== expectedAnimationPaths.join(",")) mismatches.push(`animation paths ${imported.animationPaths.join(",")} != ${expectedAnimationPaths.join(",")}`); return { compatible: mismatches.length === 0, mismatches }; }