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; } interface GLBBufferView { buffer: number; byteOffset?: number; byteLength: number; } interface GLBPrimitive { attributes?: Record; indices?: number; targets?: Array>; } interface GLBDocument { asset?: { version?: string }; buffers?: Array<{ byteLength?: number }>; bufferViews?: GLBBufferView[]; accessors?: GLBAccessor[]; meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record }>; images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record }>; skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record }>; animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record }>; } 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[]; } 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 { 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 (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); const bufferViews = document.bufferViews ?? []; const accessors = document.accessors ?? []; 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 (!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 }; } 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 }; }