Checkpoint web parity through Chromium input tasks
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-19 10:39:03 -04:00
parent 5a11045ca5
commit 380cbed4ff
634 changed files with 41862 additions and 212 deletions

View File

@@ -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));