Checkpoint web parity through Chromium input tasks
This commit is contained in:
@@ -11,6 +11,10 @@ interface GLBAccessor {
|
||||
componentType: number;
|
||||
count: number;
|
||||
type: string;
|
||||
normalized?: boolean;
|
||||
min?: number[];
|
||||
max?: number[];
|
||||
sparse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GLBBufferView {
|
||||
@@ -22,20 +26,62 @@ interface GLBBufferView {
|
||||
interface GLBPrimitive {
|
||||
attributes?: Record<string, number>;
|
||||
indices?: number;
|
||||
material?: number;
|
||||
mode?: number;
|
||||
targets?: Array<Record<string, number>>;
|
||||
}
|
||||
|
||||
interface GLBDocument {
|
||||
asset?: { version?: string };
|
||||
buffers?: Array<{ byteLength?: number }>;
|
||||
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<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> }>;
|
||||
images?: Array<{ name?: string; mimeType?: string; bufferView?: number; uri?: string; extras?: Record<string, unknown> }>;
|
||||
samplers?: Array<Record<string, unknown>>;
|
||||
textures?: Array<{ sampler?: number; source?: number }>;
|
||||
materials?: Array<{
|
||||
name?: string;
|
||||
alphaMode?: string;
|
||||
doubleSided?: boolean;
|
||||
pbrMetallicRoughness?: {
|
||||
baseColorFactor?: number[];
|
||||
baseColorTexture?: Record<string, unknown>;
|
||||
metallicFactor?: number;
|
||||
roughnessFactor?: number;
|
||||
};
|
||||
normalTexture?: Record<string, unknown>;
|
||||
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<string, unknown> }>;
|
||||
animations?: Array<{
|
||||
name?: string;
|
||||
samplers?: Array<{ input?: number; output?: number; interpolation?: string }>;
|
||||
channels?: Array<{ sampler?: number; target?: { node?: number; path?: string } }>;
|
||||
extras?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const GLB_IMPORT_BUDGET = {
|
||||
maxBytes: 512 * 1024,
|
||||
maxJsonBytes: 256 * 1024,
|
||||
maxBufferViews: 4096,
|
||||
maxAccessors: 8192,
|
||||
} as const;
|
||||
|
||||
export interface ImportedGLBImage {
|
||||
name?: string;
|
||||
blenderId?: string;
|
||||
@@ -62,6 +108,73 @@ export interface GLBSemanticComparison {
|
||||
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<string | null>;
|
||||
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<string, GLBDesktopAccessorSemantics>;
|
||||
indices: GLBDesktopAccessorSemantics | null;
|
||||
material: number | null;
|
||||
mode: number;
|
||||
targets: Array<Record<string, GLBDesktopAccessorSemantics>>;
|
||||
}>;
|
||||
}>;
|
||||
materials: Array<{
|
||||
name: string | null;
|
||||
alphaMode: string;
|
||||
doubleSided: boolean;
|
||||
pbr: {
|
||||
baseColorFactor: number[] | null;
|
||||
baseColorTexture: Record<string, unknown> | null;
|
||||
metallicFactor: number | null;
|
||||
roughnessFactor: number | null;
|
||||
};
|
||||
normalTexture: Record<string, unknown> | null;
|
||||
emissiveFactor: number[] | null;
|
||||
}>;
|
||||
textures: Array<Record<string, unknown>>;
|
||||
images: Array<Record<string, unknown>>;
|
||||
samplers: Array<Record<string, unknown>>;
|
||||
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<string, unknown> | undefined): string | undefined {
|
||||
return typeof extras?.blenderId === "string" ? extras.blenderId : undefined;
|
||||
}
|
||||
@@ -72,11 +185,12 @@ function requireIndex(value: unknown, size: number, label: string): 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 (view.getUint32(16, true) !== JSON_CHUNK || jsonLength % 4 !== 0 || 20 + jsonLength > bytes.byteLength) throw new Error("GLB JSON chunk is invalid");
|
||||
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());
|
||||
@@ -102,11 +216,15 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
|
||||
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}]`);
|
||||
@@ -153,6 +271,173 @@ export function importGLBSemantics(glb: ArrayBuffer): ImportedGLBSemantics {
|
||||
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<string, GLBDesktopAccessorSemantics> = {};
|
||||
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<string, GLBDesktopAccessorSemantics> = {};
|
||||
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<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user