import type { MeshGeometryBuffer } from "./web-engine"; import type { MaterialIR, MeshSummaryIR, SceneNodeIR, SceneSnapshotIR } from "./scene-ir"; import type { NonMeshGeometryChunk } from "./nonmesh-binary"; import { mapBinaryNonMeshForExport } from "./nonmesh-export"; export interface GLBAssetBuffer { assetId: string; mimeType: string; data: ArrayBuffer; } export type GLBWarningCode = | "SUMMARY_ONLY_MESH" | "MISSING_GEOMETRY_BUFFER" | "EXTERNAL_IMAGE" | "PACKED_IMAGE_UNAVAILABLE" | "LINKED_MATERIAL_INPUT_UNEVALUATED" | "SHADER_GRAPH_UNMAPPABLE" | "MODIFIER_STACK_NOT_BAKED" | "SKIN_REMAP_UNAVAILABLE" | "SHAPE_KEY_DATA_INVALID" | "NON_MESH_EVALUATION_REQUIRED" | "GLB_NON_MESH_UNMAPPED" | "GLB_VOLUME_UNSUPPORTED" | "NON_MESH_ATTRIBUTE_LOSS" | "NO_EXPORTABLE_GEOMETRY"; export interface GLBExportWarning { code: GLBWarningCode; severity: "warning" | "error"; message: string; id?: string; } export interface GLBExportReport { canExport: boolean; warnings: GLBExportWarning[]; } export interface GLBExportResult { report: GLBExportReport; glb?: ArrayBuffer; } interface Accessor { bufferView: number; componentType: number; count: number; type: "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT4"; min?: number[]; max?: number[]; normalized?: boolean; } interface BufferView { buffer: number; byteOffset: number; byteLength: number; target?: number; } interface Primitive { attributes: Record; indices: number; mode?: 0 | 1; material?: number; targets?: Array>; } interface AnimationGroup { node: number; path: "translation" | "rotation" | "scale"; source: "VECTOR" | "EULER" | "QUATERNION"; components: Map>; } const COMPONENT_FLOAT = 5126; const COMPONENT_UNSIGNED_SHORT = 5123; const COMPONENT_UNSIGNED_INT = 5125; const TARGET_ARRAY_BUFFER = 34962; const TARGET_ELEMENT_ARRAY_BUFFER = 34963; function align4(value: number): number { return (value + 3) & ~3; } function minMax(values: ArrayLike, width: number): { min: number[]; max: number[] } { const min = Array.from({ length: width }, () => Number.POSITIVE_INFINITY); const max = Array.from({ length: width }, () => Number.NEGATIVE_INFINITY); for (let index = 0; index < values.length; index += width) { for (let component = 0; component < width; component++) { const value = values[index + component]; min[component] = Math.min(min[component], value); max[component] = Math.max(max[component], value); } } return { min, max }; } function appendBytes(parts: Uint8Array[], currentLength: number, bytes: Uint8Array): number { const offset = align4(currentLength); if (offset > currentLength) parts.push(new Uint8Array(offset - currentLength)); parts.push(bytes); return offset + bytes.byteLength; } function typedArrayBytes(values: Float32Array | Uint16Array | Uint32Array): Uint8Array { return new Uint8Array(values.buffer, values.byteOffset, values.byteLength); } function matrixMultiply(left: number[], right: number[]): number[] { const result = new Array(16).fill(0); for (let column = 0; column < 4; column++) { for (let row = 0; row < 4; row++) { for (let index = 0; index < 4; index++) result[column * 4 + row] += left[index * 4 + row] * right[column * 4 + index]; } } return result; } function matrixInverse(matrix: readonly number[]): number[] { const rows = Array.from({ length: 4 }, (_, row) => [ matrix[row], matrix[4 + row], matrix[8 + row], matrix[12 + row], row === 0 ? 1 : 0, row === 1 ? 1 : 0, row === 2 ? 1 : 0, row === 3 ? 1 : 0, ]); for (let column = 0; column < 4; column++) { let pivot = column; for (let row = column + 1; row < 4; row++) if (Math.abs(rows[row][column]) > Math.abs(rows[pivot][column])) pivot = row; if (Math.abs(rows[pivot][column]) < 1e-10) throw new Error("matrix is singular"); [rows[column], rows[pivot]] = [rows[pivot], rows[column]]; const divisor = rows[column][column]; for (let index = 0; index < 8; index++) rows[column][index] /= divisor; for (let row = 0; row < 4; row++) if (row !== column) { const factor = rows[row][column]; for (let index = 0; index < 8; index++) rows[row][index] -= factor * rows[column][index]; } } const inverse = new Array(16); for (let column = 0; column < 4; column++) for (let row = 0; row < 4; row++) inverse[column * 4 + row] = rows[row][4 + column]; return inverse; } function convertMatrix(matrix: readonly number[]): number[] { const basis = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 0, 1]; return matrixMultiply(matrixMultiply(basis, Array.from(matrix)), basis); } function convertPosition(values: ArrayLike): Float32Array { const result = new Float32Array(values.length); for (let index = 0; index < values.length; index += 3) { result[index] = values[index]; result[index + 1] = values[index + 2]; result[index + 2] = -values[index + 1]; } return result; } function convertNormal(values: ArrayLike): Float32Array { return convertPosition(values); } function convertPositionDelta(target: ArrayLike, base: ArrayLike): Float32Array { const result = new Float32Array(target.length); for (let index = 0; index < target.length; index += 3) { result[index] = target[index] - base[index]; result[index + 1] = target[index + 2] - base[index + 2]; result[index + 2] = -(target[index + 1] - base[index + 1]); } return result; } function eulerXYZQuaternion(euler: readonly number[]): [number, number, number, number] { const hx = euler[0] * 0.5, hy = euler[1] * 0.5, hz = euler[2] * 0.5; const sx = Math.sin(hx), cx = Math.cos(hx), sy = Math.sin(hy), cy = Math.cos(hy), sz = Math.sin(hz), cz = Math.cos(hz); return [sx * cy * cz + cx * sy * sz, cx * sy * cz - sx * cy * sz, cx * cy * sz + sx * sy * cz, cx * cy * cz - sx * sy * sz]; } function convertQuaternion(values: readonly number[], source: "EULER" | "QUATERNION"): [number, number, number, number] { const blender = source === "EULER" ? eulerXYZQuaternion(values) : [values[1], values[2], values[3], values[0]] as [number, number, number, number]; return [blender[0], blender[2], -blender[1], blender[3]]; } function geometryArray(payload: MeshGeometryBuffer | undefined, summary: MeshSummaryIR, key: "positions" | "indices" | "normals" | "uvs" | "colors" | "triangleMaterialIndices"): T | undefined { if (payload) { const data = payload[key]; if (data) return new (key === "indices" || key === "triangleMaterialIndices" ? Uint32Array : Float32Array)(data) as T; } const data = summary[key]; if (data) return new (key === "indices" || key === "triangleMaterialIndices" ? Uint32Array : Float32Array)(data) as T; return undefined; } interface ShaderPbrMapping { baseColorImageId?: string; normalImageId?: string; baseColorFactor?: [number, number, number, number]; roughnessFactor?: number; metallicFactor?: number; } function shaderColor(node: NonNullable[number]): [number, number, number, number] | undefined { const value = node.defaultValue; return value?.length === 4 && value.every((component) => Number.isFinite(component) && component >= 0 && component <= 1) ? [value[0], value[1], value[2], value[3]] : undefined; } function shaderFactor(node: NonNullable[number]): number | undefined { const value = node.defaultValue?.[0]; return node.defaultValue?.length === 1 && value !== undefined && Number.isFinite(value) && value >= 0 && value <= 1 ? value : undefined; } function shaderPbrMapping(material: MaterialIR): ShaderPbrMapping | string { if (!material.nodes || material.nodes.length === 0) return {}; const nodes = new Map(material.nodes.map((node) => [node.id, node])); const count = (type: string) => material.nodes?.filter((node) => node.type === type).length ?? 0; if (count("PRINCIPLED") !== 1 || count("OUTPUT") !== 1) { return "Shader graph must contain exactly one Principled and one Material Output node"; } const unsupported = material.nodes.find((node) => !["RGB", "VALUE", "PRINCIPLED", "IMAGE_TEXTURE", "NORMAL_MAP", "OUTPUT"].includes(node.type)); if (unsupported) return "Shader node " + unsupported.type + " cannot be represented by glTF PBR"; const principled = material.nodes.find((node) => node.type === "PRINCIPLED"); const output = material.nodes.find((node) => node.type === "OUTPUT"); if (!principled || !output) return "Shader graph is incomplete"; const mapping: ShaderPbrMapping = {}; const normalInputs = new Map(); const mappedInputs = new Set(); let hasSurface = false; for (const link of material.links ?? []) { const from = nodes.get(link.fromNodeId); const to = nodes.get(link.toNodeId); if (!from || !to) return "Shader graph link references a missing node"; if (from.id === principled.id && link.fromSocket === "BSDF" && to.id === output.id && link.toSocket === "Surface") { hasSurface = true; continue; } if (from.type === "IMAGE_TEXTURE" && from.imageId && to.id === principled.id && link.fromSocket === "Color" && link.toSocket === "Base Color") { if (mappedInputs.has("Base Color")) return "Shader graph has multiple Base Color inputs"; mappedInputs.add("Base Color"); mapping.baseColorImageId = from.imageId; continue; } if (from.type === "RGB" && to.id === principled.id && link.fromSocket === "Color" && link.toSocket === "Base Color") { const factor = shaderColor(from); if (!factor) return "RGB Base Color must contain four finite values in the glTF [0, 1] range"; if (mappedInputs.has("Base Color")) return "Shader graph has multiple Base Color inputs"; mappedInputs.add("Base Color"); mapping.baseColorFactor = factor; continue; } if (from.type === "VALUE" && to.id === principled.id && link.fromSocket === "Value" && (link.toSocket === "Roughness" || link.toSocket === "Metallic")) { const factor = shaderFactor(from); if (factor === undefined) return `Value ${link.toSocket} must contain one finite value in the glTF [0, 1] range`; if (mappedInputs.has(link.toSocket)) return `Shader graph has multiple ${link.toSocket} inputs`; mappedInputs.add(link.toSocket); if (link.toSocket === "Roughness") mapping.roughnessFactor = factor; else mapping.metallicFactor = factor; continue; } if (from.type === "IMAGE_TEXTURE" && from.imageId && to.type === "NORMAL_MAP" && link.fromSocket === "Color" && link.toSocket === "Color") { normalInputs.set(to.id, from.imageId); continue; } if (from.type === "NORMAL_MAP" && to.id === principled.id && link.fromSocket === "Normal" && link.toSocket === "Normal") { const imageId = normalInputs.get(from.id); if (!imageId) return "Normal Map input is not an Image Texture output"; if (mapping.normalImageId) return "Shader graph has multiple normal textures"; mapping.normalImageId = imageId; continue; } return "Shader link " + from.type + "." + link.fromSocket + " -> " + to.type + "." + link.toSocket + " cannot be represented by glTF PBR"; } if (!hasSurface) return "Shader graph Material Output has no Principled BSDF surface link"; return mapping; } function materialJSON(material: MaterialIR, textureIndexByImageId: ReadonlyMap): Record { const mapping = shaderPbrMapping(material); if (typeof mapping === "string") throw new Error("Unmappable Shader graph escaped export validation: " + mapping); const alphaMode = material.alpha < 0.999 ? "BLEND" : "OPAQUE"; // A reader may expose Image Texture metadata without a fully serializable node graph. // Preserve that bounded base-color/normal path instead of silently dropping the asset. const baseColorImageId = mapping.baseColorImageId ?? (material.nodes?.length ? undefined : material.imageIds?.[0]); const normalImageId = mapping.normalImageId ?? material.normalImageId; const baseColorTexture = baseColorImageId ? textureIndexByImageId.get(baseColorImageId) : undefined; const normalTexture = normalImageId ? textureIndexByImageId.get(normalImageId) : undefined; const pbr: Record = { baseColorFactor: mapping.baseColorFactor ?? material.baseColor, metallicFactor: mapping.metallicFactor ?? material.metallic, roughnessFactor: mapping.roughnessFactor ?? material.roughness, }; if (baseColorTexture !== undefined) pbr.baseColorTexture = { index: baseColorTexture }; const extensions: Record = {}; if (Math.abs(material.ior - 1.5) > 1e-6) extensions.KHR_materials_ior = { ior: material.ior }; if ((material.transmissionWeight ?? 0) > 0) extensions.KHR_materials_transmission = { transmissionFactor: material.transmissionWeight }; if ((material.coatWeight ?? 0) > 0) extensions.KHR_materials_clearcoat = { clearcoatFactor: material.coatWeight, clearcoatRoughnessFactor: material.coatRoughness ?? 0.03, }; if (Math.abs((material.specularIORLevel ?? 0.5) - 0.5) > 1e-6) extensions.KHR_materials_specular = { // Blender's Specular IOR Level is half the glTF KHR_materials_specular factor. specularFactor: Math.min(1, Math.max(0, (material.specularIORLevel ?? 0.5) * 2)), }; if (Math.abs((material.emissionStrength ?? 1) - 1) > 1e-6) extensions.KHR_materials_emissive_strength = { emissiveStrength: material.emissionStrength }; return { name: material.name, pbrMetallicRoughness: pbr, ...(normalTexture === undefined ? {} : { normalTexture: { index: normalTexture } }), emissiveFactor: material.emissionColor.slice(0, 3), ...(Object.keys(extensions).length === 0 ? {} : { extensions }), alphaMode, alphaCutoff: 0.5, doubleSided: true, extras: { blenderId: material.id, ior: material.ior, emissionColor: material.emissionColor }, }; } function meshWarnings(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], assetBuffers: readonly GLBAssetBuffer[]): GLBExportWarning[] { const warnings: GLBExportWarning[] = []; const bufferIds = new Set(geometryBuffers.map((geometry) => geometry.meshId)); const assetIds = new Set(assetBuffers.map((asset) => asset.assetId)); let exportableGeometry = 0; for (const mesh of snapshot.meshes) { if (mesh.geometryStatus === "summary-only") warnings.push({ code: "SUMMARY_ONLY_MESH", severity: "error", message: `Mesh ${mesh.name} has summary-only geometry`, id: mesh.id }); const geometryId = mesh.geometryBufferId ?? mesh.id; if (mesh.geometryStatus === "binary" && !bufferIds.has(geometryId)) warnings.push({ code: "MISSING_GEOMETRY_BUFFER", severity: "error", message: `Mesh ${mesh.name} has no transferable geometry buffer`, id: mesh.id }); if (mesh.geometryStatus === "available" || bufferIds.has(geometryId)) exportableGeometry++; if (mesh.modifierStack?.some((modifier) => modifier.enabled)) warnings.push({ code: "MODIFIER_STACK_NOT_BAKED", severity: "warning", message: `Mesh ${mesh.name} has enabled modifiers that are not baked for export`, id: mesh.id }); if (mesh.skinWeights) { const armature = mesh.skinWeights.armatureId ? snapshot.armatures?.find((candidate) => candidate.id === mesh.skinWeights?.armatureId) : undefined; const boneIds = new Set(armature?.bones.map((bone) => bone.id)); const hasArmature = Boolean(armature && mesh.skinWeights.jointIds?.length === mesh.skinWeights.boneNames.length && mesh.skinWeights.jointIds.every((id) => boneIds.has(id))); warnings.push(...(hasArmature ? [] : [{ code: "SKIN_REMAP_UNAVAILABLE" as const, severity: "error" as const, message: `Mesh ${mesh.name} skin weights require an armature joint mapping`, id: mesh.id }])); } for (const shape of mesh.shapeKeys ?? []) { if (shape.positions.length !== mesh.vertexCount * 3) warnings.push({ code: "SHAPE_KEY_DATA_INVALID", severity: "error", message: `Shape key ${shape.name} does not match ${mesh.name} vertex count`, id: mesh.id }); } } for (const data of snapshot.nonMeshData ?? []) { if (["CURVE", "SURFACE", "FONT", "METABALL"].includes(data.type)) { const evaluated = data.evaluatedGeometry?.filter((geometry) => geometry.status === "EVALUATED") ?? []; if (evaluated.length === 0) { warnings.push({ code: "NON_MESH_EVALUATION_REQUIRED", severity: "error", message: `${data.type} ${data.name} requires Blender evaluated geometry before GLB export`, id: data.id }); } else if (evaluated.every((geometry) => geometry.triangleCount === 0 && (geometry.edgeCount ?? 0) === 0)) { warnings.push({ code: "GLB_NON_MESH_UNMAPPED", severity: "error", message: `${data.type} ${data.name} evaluated geometry has neither triangles nor edges for GLB export`, id: data.id }); } } else if (data.type === "VOLUME") { warnings.push({ code: "GLB_VOLUME_UNSUPPORTED", severity: "error", message: `Volume ${data.name} cannot be represented by GLB`, id: data.id }); } else { warnings.push({ code: "GLB_NON_MESH_UNMAPPED", severity: "error", message: `${data.type} ${data.name} requires an explicit mesh bake for GLB export`, id: data.id }); } } if (exportableGeometry === 0) warnings.push({ code: "NO_EXPORTABLE_GEOMETRY", severity: "error", message: "The scene has no exportable mesh geometry" }); for (const image of snapshot.images) { if (assetIds.has(image.assetId)) continue; if (image.packed) warnings.push({ code: "PACKED_IMAGE_UNAVAILABLE", severity: "warning", message: `Packed image ${image.name} has no decoded pixel asset`, id: image.id }); else if (image.sourcePath) warnings.push({ code: "EXTERNAL_IMAGE", severity: "warning", message: `External image ${image.name} is not embedded; the GLB uses material factors only`, id: image.id }); } for (const material of snapshot.materials) { if (material.warnings?.some((warning) => warning.includes("linked_input_not_evaluated"))) warnings.push({ code: "LINKED_MATERIAL_INPUT_UNEVALUATED", severity: "warning", message: `Material ${material.name} contains unevaluated linked inputs`, id: material.id }); const mapping = shaderPbrMapping(material); if (typeof mapping === "string") warnings.push({ code: "SHADER_GRAPH_UNMAPPABLE", severity: "error", message: "Material " + material.name + ": " + mapping, id: material.id }); } return warnings; } export function analyzeGLBExport(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[] = [], assetBuffers: readonly GLBAssetBuffer[] = []): GLBExportReport { const warnings = meshWarnings(snapshot, geometryBuffers, assetBuffers); return { canExport: warnings.every((warning) => warning.severity !== "error"), warnings }; } function appendAccessor( parts: Uint8Array[], binaryLength: number, bufferViews: BufferView[], accessors: Accessor[], values: Float32Array | Uint16Array | Uint32Array, type: Accessor["type"], target?: number, ): { index: number; length: number } { const offset = align4(binaryLength); const length = appendBytes(parts, binaryLength, typedArrayBytes(values)); const viewIndex = bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: values.byteLength, target }) - 1; const width = type === "SCALAR" ? 1 : type === "MAT4" ? 16 : Number(type.slice(3)); const bounds = type === "SCALAR" || type === "MAT4" ? undefined : minMax(values, width); const accessor: Accessor = { bufferView: viewIndex, componentType: values instanceof Float32Array ? COMPONENT_FLOAT : values instanceof Uint16Array ? COMPONENT_UNSIGNED_SHORT : COMPONENT_UNSIGNED_INT, count: values.length / width, type }; if (bounds) { accessor.min = bounds.min; accessor.max = bounds.max; } accessors.push(accessor); return { index: accessors.length - 1, length }; } function buildGLB(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], assetBuffers: readonly GLBAssetBuffer[]): ArrayBuffer { const parts: Uint8Array[] = []; let binaryLength = 0; const bufferViews: BufferView[] = []; const accessors: Accessor[] = []; const materialIndex = new Map(snapshot.materials.map((material, index) => [material.id, index])); const bufferById = new Map(geometryBuffers.map((geometry) => [geometry.meshId, geometry])); const textureIndexByImageId = new Map(); const gltfImages: Array> = []; const gltfTextures: Array> = []; const imageByAssetId = new Map(snapshot.images.map((image) => [image.assetId, image])); for (const asset of assetBuffers) { const image = imageByAssetId.get(asset.assetId); if (!image || asset.data.byteLength === 0) continue; const offset = align4(binaryLength); binaryLength = appendBytes(parts, binaryLength, new Uint8Array(asset.data)); const viewIndex = bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: asset.data.byteLength }) - 1; const imageIndex = gltfImages.push({ name: image.name, bufferView: viewIndex, mimeType: asset.mimeType, extras: { blenderId: image.id } }) - 1; const textureIndex = gltfTextures.push({ source: imageIndex, sampler: 0, name: image.name, extras: { blenderId: image.id } }) - 1; textureIndexByImageId.set(image.id, textureIndex); } const materials = snapshot.materials.map((material) => materialJSON(material, textureIndexByImageId)); const gltfMeshes: Array<{ name: string; primitives: Primitive[]; extras?: Record }> = []; const meshIndexById = new Map(); const skinnedMeshes = new Map(); for (const summary of snapshot.meshes) { const geometryId = summary.geometryBufferId ?? summary.id; const payload = bufferById.get(geometryId) ?? bufferById.get(summary.id); const positionsSource = geometryArray(payload, summary, "positions"); const linePrimitive = summary.topology === "lines"; const pointPrimitive = summary.topology === "points"; const indicesSource = linePrimitive ? payload?.edgeVertexIndices ? new Uint32Array(payload.edgeVertexIndices) : summary.edgeVertexIndices ? Uint32Array.from(summary.edgeVertexIndices) : undefined : geometryArray(payload, summary, "indices"); if (!positionsSource || !indicesSource || positionsSource.length % 3 !== 0 || indicesSource.length % (linePrimitive ? 2 : pointPrimitive ? 1 : 3) !== 0) continue; if (indicesSource.some((index) => index >= positionsSource.length / 3)) continue; const normalsSource = linePrimitive || pointPrimitive ? undefined : geometryArray(payload, summary, "normals"); const cornerSource = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices ? new Uint32Array(summary.triangleCornerIndices) : undefined; const uvsSource = geometryArray(payload, summary, "uvs"); const colorsSource = geometryArray(payload, summary, "colors"); const materialSource = linePrimitive || pointPrimitive ? undefined : geometryArray(payload, summary, "triangleMaterialIndices"); const vertexCount = positionsSource.length / 3; const hasCornerUVs = Boolean(uvsSource && uvsSource.length === indicesSource.length * 2 && uvsSource.length !== vertexCount * 2); const hasCornerColors = Boolean(colorsSource && colorsSource.length === indicesSource.length * 4 && colorsSource.length !== vertexCount * 4); const deindexed = Boolean((cornerSource && (uvsSource || colorsSource)) || hasCornerUVs || hasCornerColors); const positions = deindexed ? new Float32Array(indicesSource.length * 3) : convertPosition(positionsSource); const normals = normalsSource ? (deindexed ? new Float32Array(indicesSource.length * 3) : convertNormal(normalsSource)) : undefined; const uvs = uvsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 2) : undefined; const colors = colorsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 4) : undefined; const hasSkin = Boolean(summary.skinWeights && summary.skinWeights.indices.length === vertexCount * 4 && summary.skinWeights.weights.length === vertexCount * 4 && summary.skinWeights.armatureId); const joints = hasSkin ? new Uint16Array((deindexed ? indicesSource.length : vertexCount) * 4) : undefined; const skinWeights = hasSkin ? new Float32Array((deindexed ? indicesSource.length : vertexCount) * 4) : undefined; const indices = deindexed ? new Uint32Array(indicesSource.length).map((_, index) => index) : new Uint32Array(indicesSource); if (deindexed) { for (let corner = 0; corner < indicesSource.length; corner++) { const vertex = indicesSource[corner]; positions[corner * 3] = positionsSource[vertex * 3]; positions[corner * 3 + 1] = positionsSource[vertex * 3 + 2]; positions[corner * 3 + 2] = -positionsSource[vertex * 3 + 1]; if (normals && normalsSource) { normals[corner * 3] = normalsSource[vertex * 3]; normals[corner * 3 + 1] = normalsSource[vertex * 3 + 2]; normals[corner * 3 + 2] = -normalsSource[vertex * 3 + 1]; } const sourceCorner = cornerSource?.[corner] ?? vertex; if (uvs && uvsSource && sourceCorner * 2 + 1 < uvsSource.length) uvs.set(uvsSource.subarray(sourceCorner * 2, sourceCorner * 2 + 2), corner * 2); if (colors && colorsSource && sourceCorner * 4 + 3 < colorsSource.length) colors.set(colorsSource.subarray(sourceCorner * 4, sourceCorner * 4 + 4), corner * 4); if (joints && skinWeights && summary.skinWeights) { let total = 0; for (let slot = 0; slot < 4; slot++) total += summary.skinWeights.weights[vertex * 4 + slot]; for (let slot = 0; slot < 4; slot++) { joints[corner * 4 + slot] = summary.skinWeights.indices[vertex * 4 + slot]; skinWeights[corner * 4 + slot] = total > 0 ? summary.skinWeights.weights[vertex * 4 + slot] / total : slot === 0 ? 1 : 0; } } } } else { if (uvs && uvsSource && uvsSource.length === uvs.length) uvs.set(uvsSource); if (colors && colorsSource && colorsSource.length === colors.length) colors.set(colorsSource); if (joints && skinWeights && summary.skinWeights) { joints.set(summary.skinWeights.indices); skinWeights.set(summary.skinWeights.weights); for (let vertex = 0; vertex < vertexCount; vertex++) { let total = 0; for (let slot = 0; slot < 4; slot++) total += skinWeights[vertex * 4 + slot]; for (let slot = 0; slot < 4; slot++) skinWeights[vertex * 4 + slot] = total > 0 ? skinWeights[vertex * 4 + slot] / total : slot === 0 ? 1 : 0; } } } const positionAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, positions, "VEC3", TARGET_ARRAY_BUFFER); binaryLength = positionAccessor.length; const normalAccessor = normals ? appendAccessor(parts, binaryLength, bufferViews, accessors, normals, "VEC3", TARGET_ARRAY_BUFFER) : undefined; if (normalAccessor) binaryLength = normalAccessor.length; const uvAccessor = uvs ? appendAccessor(parts, binaryLength, bufferViews, accessors, uvs, "VEC2", TARGET_ARRAY_BUFFER) : undefined; if (uvAccessor) binaryLength = uvAccessor.length; const colorAccessor = colors ? appendAccessor(parts, binaryLength, bufferViews, accessors, colors, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (colorAccessor) binaryLength = colorAccessor.length; const jointAccessor = joints ? appendAccessor(parts, binaryLength, bufferViews, accessors, joints, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (jointAccessor) binaryLength = jointAccessor.length; const weightAccessor = skinWeights ? appendAccessor(parts, binaryLength, bufferViews, accessors, skinWeights, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (weightAccessor) binaryLength = weightAccessor.length; const indexAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, indices, "SCALAR", TARGET_ELEMENT_ARRAY_BUFFER); binaryLength = indexAccessor.length; const shapeTargets: Array> = []; const shapeNames: string[] = []; for (const shape of summary.shapeKeys ?? []) { if (shape.positions.length !== positionsSource.length) continue; const shapePositions = deindexed ? new Float32Array(indicesSource.length * 3) : convertPositionDelta(shape.positions, positionsSource); if (deindexed) for (let corner = 0; corner < indicesSource.length; corner++) { const vertex = indicesSource[corner]; shapePositions[corner * 3] = shape.positions[vertex * 3] - positionsSource[vertex * 3]; shapePositions[corner * 3 + 1] = shape.positions[vertex * 3 + 2] - positionsSource[vertex * 3 + 2]; shapePositions[corner * 3 + 2] = -(shape.positions[vertex * 3 + 1] - positionsSource[vertex * 3 + 1]); } const targetAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, shapePositions, "VEC3", TARGET_ARRAY_BUFFER); binaryLength = targetAccessor.length; shapeTargets.push({ POSITION: targetAccessor.index }); shapeNames.push(shape.name); } const attributes: Record = { POSITION: positionAccessor.index }; if (normalAccessor) attributes.NORMAL = normalAccessor.index; if (uvAccessor) attributes.TEXCOORD_0 = uvAccessor.index; if (colorAccessor) attributes.COLOR_0 = colorAccessor.index; if (jointAccessor) attributes.JOINTS_0 = jointAccessor.index; if (weightAccessor) attributes.WEIGHTS_0 = weightAccessor.index; const primitives: Primitive[] = []; const materialGroups = materialSource && materialSource.length === indicesSource.length / 3 ? new Map() : new Map([[0, Array.from({ length: indices.length }, (_, index) => index)] ]); if (materialSource && materialSource.length === indicesSource.length / 3) for (let triangle = 0; triangle < materialSource.length; triangle++) { const key = materialSource[triangle]; const group = materialGroups.get(key) ?? []; group.push(triangle * 3, triangle * 3 + 1, triangle * 3 + 2); materialGroups.set(key, group); } for (const [slot, group] of materialGroups) { const groupIndices = deindexed ? Uint32Array.from(group) : Uint32Array.from(group.map((index) => indices[index])); const groupAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, groupIndices, "SCALAR", TARGET_ELEMENT_ARRAY_BUFFER); binaryLength = groupAccessor.length; const primitive: Primitive = { attributes, indices: groupAccessor.index, ...(linePrimitive ? { mode: 1 as const } : pointPrimitive ? { mode: 0 as const } : {}) }; const materialId = summary.materialSlotIds?.[slot]; if (materialId && materialIndex.has(materialId)) primitive.material = materialIndex.get(materialId); if (shapeTargets.length > 0) primitive.targets = shapeTargets; primitives.push(primitive); } meshIndexById.set(summary.id, gltfMeshes.length); if (hasSkin) skinnedMeshes.set(summary.id, summary); gltfMeshes.push({ name: summary.name, primitives, extras: { blenderId: summary.id, sourceRevision: snapshot.revision, targetNames: shapeNames } }); } const nodes = snapshot.nodes.map((node: SceneNodeIR) => { const result: Record = { name: node.name, matrix: convertMatrix(node.localMatrix), extras: { blenderId: node.id, visible: node.visible, selectable: node.selectable } }; if (node.dataId && meshIndexById.has(node.dataId)) result.mesh = meshIndexById.get(node.dataId); return result; }); const nodeIndex = new Map(snapshot.nodes.map((node, index) => [node.id, index])); for (const [index, node] of snapshot.nodes.entries()) if (node.parentId && nodeIndex.has(node.parentId)) { const parent = nodes[nodeIndex.get(node.parentId)!] as { children?: number[] }; parent.children = [...(parent.children ?? []), index]; } const armatureById = new Map((snapshot.armatures ?? []).map((armature) => [armature.id, armature])); const jointNodeById = new Map(); const armatureSkeletonNode = new Map(); const referencedArmatures = new Set(Array.from(skinnedMeshes.values()).map((mesh) => mesh.skinWeights?.armatureId).filter((id): id is string => Boolean(id))); for (const armatureId of referencedArmatures) { const armature = armatureById.get(armatureId); if (!armature) continue; const boneById = new Map(armature.bones.map((bone) => [bone.id, bone])); for (const bone of armature.bones) { const parent = bone.parentId ? boneById.get(bone.parentId) : undefined; const local = parent ? matrixMultiply(matrixInverse(parent.restMatrix), bone.restMatrix) : bone.restMatrix; const index = nodes.push({ name: bone.name, matrix: convertMatrix(local), extras: { blenderId: bone.id, armatureId } }) - 1; jointNodeById.set(bone.id, index); if (!bone.parentId && !armatureSkeletonNode.has(armatureId)) armatureSkeletonNode.set(armatureId, index); } for (const bone of armature.bones) { const childIndex = jointNodeById.get(bone.id); if (childIndex === undefined) continue; if (bone.parentId) { const parentIndex = jointNodeById.get(bone.parentId); if (parentIndex !== undefined) { const parent = nodes[parentIndex] as { children?: number[] }; parent.children = [...(parent.children ?? []), childIndex]; } } else if (armature.objectId && nodeIndex.has(armature.objectId)) { const owner = nodes[nodeIndex.get(armature.objectId)!] as { children?: number[] }; owner.children = [...(owner.children ?? []), childIndex]; } } } const gltfSkins: Array> = []; const skinIndexByMeshId = new Map(); for (const [meshId, mesh] of skinnedMeshes) { const skin = mesh.skinWeights; const armature = skin?.armatureId ? armatureById.get(skin.armatureId) : undefined; if (!skin || !armature || !skin.jointIds) continue; const boneById = new Map(armature.bones.map((bone) => [bone.id, bone])); const joints = skin.jointIds.map((id) => jointNodeById.get(id)); if (joints.some((index) => index === undefined)) continue; const armatureNode = armature.objectId ? snapshot.nodes.find((node) => node.id === armature.objectId) : undefined; const armatureWorld = armatureNode?.worldMatrix ?? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const inverseBindMatrices = new Float32Array(skin.jointIds.length * 16); for (const [joint, id] of skin.jointIds.entries()) { const bone = boneById.get(id); if (!bone) continue; const jointWorld = matrixMultiply(Array.from(armatureWorld), bone.restMatrix); inverseBindMatrices.set(convertMatrix(matrixMultiply(matrixInverse(jointWorld), skin.bindMatrix)), joint * 16); } const accessor = appendAccessor(parts, binaryLength, bufferViews, accessors, inverseBindMatrices, "MAT4"); binaryLength = accessor.length; const skinIndex = gltfSkins.push({ name: armature.name, joints: joints as number[], inverseBindMatrices: accessor.index, ...(armatureSkeletonNode.has(armature.id) ? { skeleton: armatureSkeletonNode.get(armature.id) } : {}), extras: { blenderId: armature.id, meshBindMatrix: skin.bindMatrix }, }) - 1; skinIndexByMeshId.set(meshId, skinIndex); } for (const [index, node] of snapshot.nodes.entries()) { if (node.dataId && skinIndexByMeshId.has(node.dataId)) (nodes[index] as Record).skin = skinIndexByMeshId.get(node.dataId); } const gltfAnimations: Array> = []; for (const animation of snapshot.animations) { const groups = new Map(); 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]; const component = Number(boneMatch?.[3] ?? objectMatch?.[2]); if (!property || !Number.isInteger(component)) continue; let targetNode = nodeIndex.get(animation.targetId); if (boneMatch) { const armature = (snapshot.armatures ?? []).find((candidate) => candidate.objectId === animation.targetId); const bone = armature?.bones.find((candidate) => candidate.name === boneMatch[1]); targetNode = bone ? jointNodeById.get(bone.id) : undefined; } if (targetNode === undefined) continue; const path = property === "location" ? "translation" : property === "scale" ? "scale" : "rotation"; const source = property === "rotation_euler" ? "EULER" : property === "rotation_quaternion" ? "QUATERNION" : "VECTOR"; const key = `${targetNode}:${path}`; const group = groups.get(key) ?? { node: targetNode, path, source, components: new Map() }; const values = group.components.get(component) ?? new Map(); for (const keyframe of channel.keyframes) if (Number.isFinite(keyframe.value[0])) values.set(keyframe.frame, keyframe.value[0]); group.components.set(component, values); groups.set(key, group); } const samplers: Array> = []; const channels: Array> = []; for (const group of groups.values()) { const frames = Array.from(new Set(Array.from(group.components.values()).flatMap((values) => Array.from(values.keys())))).sort((left, right) => left - right); if (frames.length === 0) continue; const width = group.path === "rotation" ? 4 : 3; const output = new Float32Array(frames.length * width); for (const [frameIndex, frame] of frames.entries()) { const sourceWidth = group.source === "QUATERNION" ? 4 : 3; const value = Array.from({ length: sourceWidth }, (_, component) => group.components.get(component)?.get(frame) ?? (group.path === "scale" || (group.source === "QUATERNION" && component === 0) ? 1 : 0)); const converted = group.path === "translation" ? [value[0], value[2], -value[1]] : group.path === "scale" ? [value[0], value[2], value[1]] : convertQuaternion(value, group.source === "EULER" ? "EULER" : "QUATERNION"); output.set(converted, frameIndex * width); } const times = Float32Array.from(frames, (frame) => frame / 24); const timeAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, times, "SCALAR"); binaryLength = timeAccessor.length; const outputAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, output, group.path === "rotation" ? "VEC4" : "VEC3"); binaryLength = outputAccessor.length; const sampler = samplers.push({ input: timeAccessor.index, output: outputAccessor.index, interpolation: "LINEAR" }) - 1; channels.push({ sampler, target: { node: group.node, path: group.path } }); } if (channels.length > 0) gltfAnimations.push({ name: animation.name, samplers, channels, extras: { blenderId: animation.id, frameStart: animation.frameStart, frameEnd: animation.frameEnd, framesPerSecond: 24 } }); } const roots = snapshot.nodes.map((node, index) => node.parentId && nodeIndex.has(node.parentId) ? -1 : index).filter((index) => index >= 0); const bin = new Uint8Array(binaryLength); let binOffset = 0; for (const part of parts) { bin.set(part, binOffset); binOffset += part.byteLength; } const extensionsUsed = [ { name: "KHR_materials_ior", enabled: snapshot.materials.some((material) => Math.abs(material.ior - 1.5) > 1e-6) }, { name: "KHR_materials_transmission", enabled: snapshot.materials.some((material) => (material.transmissionWeight ?? 0) > 0) }, { name: "KHR_materials_clearcoat", enabled: snapshot.materials.some((material) => (material.coatWeight ?? 0) > 0) }, { name: "KHR_materials_specular", enabled: snapshot.materials.some((material) => Math.abs((material.specularIORLevel ?? 0.5) - 0.5) > 1e-6) }, { name: "KHR_materials_emissive_strength", enabled: snapshot.materials.some((material) => Math.abs((material.emissionStrength ?? 1) - 1) > 1e-6) }, ].filter((entry) => entry.enabled).map((entry) => entry.name); const gltf = { asset: { version: "2.0", generator: "Blender Web SceneIR exporter" }, ...(extensionsUsed.length === 0 ? {} : { extensionsUsed }), scene: 0, scenes: [{ nodes: roots }], nodes, meshes: gltfMeshes, materials, ...(gltfImages.length === 0 ? {} : { images: gltfImages }), ...(gltfTextures.length === 0 ? {} : { textures: gltfTextures }), ...(gltfTextures.length === 0 ? {} : { samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }] }), ...(gltfSkins.length === 0 ? {} : { skins: gltfSkins }), ...(gltfAnimations.length === 0 ? {} : { animations: gltfAnimations }), accessors, bufferViews, buffers: [{ byteLength: bin.byteLength }], extras: { blenderSceneId: snapshot.sceneId, sourceRevision: snapshot.revision, frame: snapshot.frame.current }, }; const jsonBytes = new TextEncoder().encode(JSON.stringify(gltf)); const jsonLength = align4(jsonBytes.byteLength); const totalLength = 12 + 8 + jsonLength + 8 + bin.byteLength; const output = new ArrayBuffer(totalLength); const view = new DataView(output); view.setUint32(0, 0x46546c67, true); view.setUint32(4, 2, true); view.setUint32(8, totalLength, true); view.setUint32(12, jsonLength, true); view.setUint32(16, 0x4e4f534a, true); new Uint8Array(output, 20, jsonBytes.byteLength).set(jsonBytes); new Uint8Array(output, 20 + jsonBytes.byteLength, jsonLength - jsonBytes.byteLength).fill(0x20); const binHeader = 20 + jsonLength; view.setUint32(binHeader, bin.byteLength, true); view.setUint32(binHeader + 4, 0x004e4942, true); new Uint8Array(output, binHeader + 8).set(bin); return output; } export function exportGLB( snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[] = [], assetBuffers: readonly GLBAssetBuffer[] = [], nonMeshGeometryBuffers: readonly NonMeshGeometryChunk[] = [], ): GLBExportResult { let mapped = { snapshot, geometryBuffers: [...geometryBuffers], losses: [] as Array<{ dataId: string; message: string }> }; try { if (nonMeshGeometryBuffers.length > 0) mapped = mapBinaryNonMeshForExport(snapshot, geometryBuffers, nonMeshGeometryBuffers); } catch (error) { return { report: { canExport: false, warnings: [{ code: "MISSING_GEOMETRY_BUFFER", severity: "error", message: error instanceof Error ? error.message : "WNM geometry is invalid" }] } }; } const report = analyzeGLBExport(mapped.snapshot, mapped.geometryBuffers, assetBuffers); report.warnings.push(...mapped.losses.map((loss) => ({ code: "NON_MESH_ATTRIBUTE_LOSS" as const, severity: "warning" as const, message: loss.message, id: loss.dataId }))); if (!report.canExport) return { report }; return { report, glb: buildGLB(mapped.snapshot, mapped.geometryBuffers, assetBuffers) }; }