201 lines
12 KiB
TypeScript
201 lines
12 KiB
TypeScript
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<string, number>;
|
|
indices?: number;
|
|
targets?: Array<Record<string, number>>;
|
|
}
|
|
|
|
interface GLBDocument {
|
|
asset?: { version?: string };
|
|
buffers?: Array<{ byteLength?: number }>;
|
|
bufferViews?: GLBBufferView[];
|
|
accessors?: GLBAccessor[];
|
|
meshes?: Array<{ name?: string; primitives?: GLBPrimitive[]; extras?: Record<string, unknown> }>;
|
|
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; extras?: Record<string, unknown> }>;
|
|
skins?: Array<{ joints?: number[]; inverseBindMatrices?: number; extras?: Record<string, unknown> }>;
|
|
animations?: Array<{ name?: string; channels?: Array<{ target?: { node?: number; path?: string } }>; extras?: Record<string, unknown> }>;
|
|
}
|
|
|
|
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<string, unknown> | 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<string>();
|
|
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<string>();
|
|
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 };
|
|
}
|