720 lines
42 KiB
TypeScript
720 lines
42 KiB
TypeScript
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<string, number>;
|
|
indices: number;
|
|
mode?: 0 | 1;
|
|
material?: number;
|
|
targets?: Array<Record<string, number>>;
|
|
}
|
|
|
|
interface AnimationGroup {
|
|
node: number;
|
|
path: "translation" | "rotation" | "scale";
|
|
source: "VECTOR" | "EULER" | "QUATERNION";
|
|
components: Map<number, Map<number, number>>;
|
|
}
|
|
|
|
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<number>, 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<number>(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<number>(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<number>): 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<number>): Float32Array {
|
|
return convertPosition(values);
|
|
}
|
|
|
|
function convertPositionDelta(target: ArrayLike<number>, base: ArrayLike<number>): 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<T extends Float32Array | Uint32Array>(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<MaterialIR["nodes"]>[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<MaterialIR["nodes"]>[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<string, string>();
|
|
const mappedInputs = new Set<string>();
|
|
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<string, number>): Record<string, unknown> {
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {};
|
|
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<string, number>();
|
|
const gltfImages: Array<Record<string, unknown>> = [];
|
|
const gltfTextures: Array<Record<string, unknown>> = [];
|
|
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<string, unknown> }> = [];
|
|
const meshIndexById = new Map<string, number>();
|
|
const skinnedMeshes = new Map<string, MeshSummaryIR>();
|
|
|
|
for (const summary of snapshot.meshes) {
|
|
const geometryId = summary.geometryBufferId ?? summary.id;
|
|
const payload = bufferById.get(geometryId) ?? bufferById.get(summary.id);
|
|
const positionsSource = geometryArray<Float32Array>(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<Uint32Array>(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<Float32Array>(payload, summary, "normals");
|
|
const cornerSource = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices ? new Uint32Array(summary.triangleCornerIndices) : undefined;
|
|
const uvsSource = geometryArray<Float32Array>(payload, summary, "uvs");
|
|
const colorsSource = geometryArray<Float32Array>(payload, summary, "colors");
|
|
const materialSource = linePrimitive || pointPrimitive ? undefined : geometryArray<Uint32Array>(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<Record<string, number>> = [];
|
|
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<string, number> = { 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<number, number[]>() : new Map<number, number[]>([[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<string, unknown> = { 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<string, number>();
|
|
const armatureSkeletonNode = new Map<string, number>();
|
|
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<Record<string, unknown>> = [];
|
|
const skinIndexByMeshId = new Map<string, number>();
|
|
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<string, unknown>).skin = skinIndexByMeshId.get(node.dataId);
|
|
}
|
|
const gltfAnimations: Array<Record<string, unknown>> = [];
|
|
for (const animation of snapshot.animations) {
|
|
const groups = new Map<string, AnimationGroup>();
|
|
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<number, number>();
|
|
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<Record<string, unknown>> = [];
|
|
const channels: Array<Record<string, unknown>> = [];
|
|
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) };
|
|
}
|