Add Chromium-only Blender WebEngine parity work
This commit is contained in:
200
web/protocol/glb-import.ts
Normal file
200
web/protocol/glb-import.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user