import type { DepsgraphEvaluationIR, DepsgraphNonMeshGeometryIR } from "./depsgraph"; import type { MeshSummaryIR, NonMeshDataIR, SceneSnapshotIR } from "./scene-ir"; import type { MeshGeometryBuffer } from "./web-engine"; import { reassembleNonMeshGeometry, type NonMeshGeometryChunk, type ReassembledNonMeshGeometry } from "./nonmesh-binary"; export type NonMeshExportStatus = "MAPPED" | "LOSSY" | "BLOCKED"; export interface NonMeshExportMapping { dataId: string; type: NonMeshDataIR["type"]; glbTarget: "MESH" | "NONE"; usdTarget: "UsdGeomMesh" | "UsdGeomPoints" | "UsdGeomBasisCurves" | "OpenVDBAsset" | "NONE"; status: NonMeshExportStatus; losses: string[]; errorCode?: "NON_MESH_EVALUATION_REQUIRED" | "GLB_NON_MESH_UNMAPPED" | "GLB_VOLUME_UNSUPPORTED" | "NON_MESH_RESOURCE_MISSING"; } export interface NonMeshExportReport { schemaVersion: 1; mappings: NonMeshExportMapping[]; canExportGLB: boolean; canExportUSD: boolean; } export interface EvaluatedNonMeshExportScene { snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; report: NonMeshExportReport; } export interface BinaryNonMeshExportScene { snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; geometryByMeshId: Map; losses: Array<{ dataId: string; message: string }>; } function evaluatedByData(depsgraph?: DepsgraphEvaluationIR): Map { const result = new Map(); for (const geometry of depsgraph?.nonMeshGeometries ?? []) { const list = result.get(geometry.sourceDataId) ?? []; list.push(geometry); result.set(geometry.sourceDataId, list); } return result; } function hasBinaryGeometry(data: NonMeshDataIR): boolean { return data.geometryStatus === "binary" && Boolean(data.geometryBufferId); } function mappingFor(data: NonMeshDataIR, evaluations: readonly DepsgraphNonMeshGeometryIR[]): NonMeshExportMapping { const evaluated = evaluations.filter((geometry) => geometry.status === "EVALUATED"); if (["CURVE", "SURFACE", "FONT", "METABALL"].includes(data.type)) { const hasTriangles = evaluated.some((geometry) => geometry.triangleCount > 0); const hasEdges = evaluated.some((geometry) => geometry.edgeCount > 0); if (hasTriangles) return { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomMesh", status: "LOSSY", losses: ["source control topology is baked to the evaluated triangle mesh"] }; if (hasEdges) return { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomBasisCurves", status: "LOSSY", losses: ["source control topology is baked to evaluated line segments", "surface faces and curve width are not represented"] }; return { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: evaluated.length > 0 ? "GLB_NON_MESH_UNMAPPED" : "NON_MESH_EVALUATION_REQUIRED" }; } if (data.type === "POINT_CLOUD") { return hasBinaryGeometry(data) ? { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomPoints", status: "LOSSY", losses: ["GLB uses a POINTS primitive and omits radius and arbitrary WNM attributes"] } : { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_EVALUATION_REQUIRED" }; } if (data.type === "CURVES" || data.type === "HAIR") { return hasBinaryGeometry(data) ? { dataId: data.id, type: data.type, glbTarget: "MESH", usdTarget: "UsdGeomBasisCurves", status: "LOSSY", losses: ["GLB uses line segments and omits radius and arbitrary WNM attributes", "USD maps typed WNM attributes to primvars but Blender-only semantics may be lost"] } : { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_EVALUATION_REQUIRED" }; } const volumeReady = data.type === "VOLUME" && data.resourceKind === "OPENVDB" && Boolean(data.sourcePath) && (data.volumeGrids?.length ?? 0) > 0; return volumeReady ? { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "OpenVDBAsset", status: "LOSSY", losses: ["GLB cannot represent OpenVDB volumes"], errorCode: "GLB_VOLUME_UNSUPPORTED" } : { dataId: data.id, type: data.type, glbTarget: "NONE", usdTarget: "NONE", status: "BLOCKED", losses: [], errorCode: "NON_MESH_RESOURCE_MISSING" }; } export function analyzeNonMeshExport(snapshot: SceneSnapshotIR, depsgraph?: DepsgraphEvaluationIR): NonMeshExportReport { const byData = evaluatedByData(depsgraph); const mappings = (snapshot.nonMeshData ?? []).map((data) => mappingFor(data, byData.get(data.id) ?? [])); return { schemaVersion: 1, mappings, canExportGLB: mappings.every((mapping) => mapping.glbTarget !== "NONE"), canExportUSD: mappings.every((mapping) => mapping.usdTarget !== "NONE"), }; } function geometryBuffer(evaluation: DepsgraphNonMeshGeometryIR): MeshGeometryBuffer { const positions = Float32Array.from(evaluation.positions ?? []).buffer; const indices = Uint32Array.from(evaluation.indices ?? []).buffer; const edgeVertexIndices = evaluation.edgeVertexIndices?.length ? Uint32Array.from(evaluation.edgeVertexIndices).buffer : undefined; const normals = evaluation.normals?.length ? Float32Array.from(evaluation.normals).buffer : undefined; const uvs = evaluation.uvs?.length ? Float32Array.from(evaluation.uvs).buffer : undefined; const triangleMaterialIndices = evaluation.triangleMaterialIndices?.length ? Uint32Array.from(evaluation.triangleMaterialIndices).buffer : undefined; const triangleFaceIndices = evaluation.sourceElementIndices?.length ? Uint32Array.from(evaluation.sourceElementIndices).buffer : undefined; return { schemaVersion: 1, meshId: evaluation.meshId, byteLength: positions.byteLength + indices.byteLength + (edgeVertexIndices?.byteLength ?? 0) + (normals?.byteLength ?? 0) + (uvs?.byteLength ?? 0) + (triangleMaterialIndices?.byteLength ?? 0) + (triangleFaceIndices?.byteLength ?? 0), positions, indices, ...(edgeVertexIndices ? { edgeVertexIndices } : {}), normals, uvs, ...(triangleMaterialIndices ? { triangleMaterialIndices } : {}), ...(triangleFaceIndices ? { triangleFaceIndices } : {}), }; } function meshSummary(evaluation: DepsgraphNonMeshGeometryIR, source?: NonMeshDataIR): MeshSummaryIR { const topology = evaluation.triangleCount > 0 ? "triangles" : "lines"; return { id: evaluation.meshId, name: source?.name ?? evaluation.meshId, vertexCount: evaluation.vertexCount, edgeCount: evaluation.edgeCount, faceCount: evaluation.triangleCount, cornerCount: evaluation.triangleCount * 3, triangleCount: evaluation.triangleCount, geometryStatus: "binary", geometryBufferId: evaluation.meshId, topology, materialSlotIds: evaluation.materialSlotIds, }; } export function mapEvaluatedNonMeshForExport( snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], depsgraph: DepsgraphEvaluationIR, ): EvaluatedNonMeshExportScene { const sourceById = new Map((snapshot.nonMeshData ?? []).map((data) => [data.id, data])); const successful = (depsgraph.nonMeshGeometries ?? []).filter((geometry) => geometry.status === "EVALUATED" && sourceById.has(geometry.sourceDataId)); const byObject = new Map(successful.map((geometry) => [geometry.objectId, geometry])); const evaluatedBySource = evaluatedByData(depsgraph); const nonMeshData = (snapshot.nonMeshData ?? []).map((data): NonMeshDataIR => ({ ...data, evaluatedGeometry: (evaluatedBySource.get(data.id) ?? []).map((geometry) => ({ objectId: geometry.objectId, meshId: geometry.meshId, vertexCount: geometry.vertexCount, edgeCount: geometry.edgeCount, triangleCount: geometry.triangleCount, status: geometry.status, ...(geometry.errorCode ? { errorCode: geometry.errorCode } : {}), })), })); return { snapshot: { ...snapshot, nodes: snapshot.nodes.map((node) => { const evaluation = byObject.get(node.id); return evaluation ? { ...node, dataId: evaluation.meshId } : node; }), meshes: [...snapshot.meshes, ...successful.map((geometry) => meshSummary(geometry, sourceById.get(geometry.sourceDataId)))], nonMeshData, }, geometryBuffers: [...geometryBuffers, ...successful.map(geometryBuffer)], report: analyzeNonMeshExport(snapshot, depsgraph), }; } function lineEdges(offsets: Uint32Array): Uint32Array { let edgeCount = 0; for (let curve = 0; curve < offsets.length - 1; curve++) edgeCount += Math.max(0, offsets[curve + 1] - offsets[curve] - 1); const edges = new Uint32Array(edgeCount * 2); let cursor = 0; for (let curve = 0; curve < offsets.length - 1; curve++) { for (let point = offsets[curve]; point + 1 < offsets[curve + 1]; point++) { edges[cursor++] = point; edges[cursor++] = point + 1; } } return edges; } export function mapBinaryNonMeshForExport( snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], chunks: readonly NonMeshGeometryChunk[], ): BinaryNonMeshExportScene { const mappedData = new Map(); const losses: BinaryNonMeshExportScene["losses"] = []; for (const data of snapshot.nonMeshData ?? []) { if (!["POINT_CLOUD", "CURVES", "HAIR"].includes(data.type) || data.geometryStatus !== "binary") continue; const geometry = reassembleNonMeshGeometry(data.id, chunks); if (geometry.positions.length !== data.pointCount * 3) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} point count does not match SceneIR`); const meshId = `wnm:${data.id}`; const pointTopology = data.type === "POINT_CLOUD"; const offsets = geometry.curveOffsets; if (!pointTopology && !offsets) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} has no curve offsets`); const indices = pointTopology ? Uint32Array.from({ length: data.pointCount }, (_, index) => index) : lineEdges(offsets!); if (!pointTopology && indices.length === 0) throw new Error(`NON_MESH_BINARY_INVALID: ${data.id} contains no exportable curve segments`); const positions = geometry.positions.slice().buffer; const indexBuffer = indices.buffer.slice(indices.byteOffset, indices.byteOffset + indices.byteLength) as ArrayBuffer; const buffer: MeshGeometryBuffer = { schemaVersion: 1, meshId, byteLength: positions.byteLength + indexBuffer.byteLength, positions, indices: pointTopology ? indexBuffer : new ArrayBuffer(0), ...(pointTopology ? {} : { edgeVertexIndices: indexBuffer }), }; const mesh: MeshSummaryIR = { id: meshId, name: data.name, vertexCount: data.pointCount, edgeCount: pointTopology ? 0 : indices.length / 2, faceCount: 0, cornerCount: 0, triangleCount: 0, geometryStatus: "binary", geometryBufferId: meshId, topology: pointTopology ? "points" : "lines", }; mappedData.set(data.id, { mesh, buffer, geometry }); const lost = [geometry.radii ? "radius" : "", ...geometry.attributes.map((attribute) => attribute.name)].filter(Boolean); if (lost.length > 0) losses.push({ dataId: data.id, message: `GLB primitive preserves topology but omits WNM attributes: ${lost.join(", ")}` }); } const geometryByMeshId = new Map(); for (const { mesh, geometry } of mappedData.values()) geometryByMeshId.set(mesh.id, geometry); return { snapshot: { ...snapshot, nodes: snapshot.nodes.map((node) => mappedData.has(node.dataId ?? "") ? { ...node, dataId: mappedData.get(node.dataId!)!.mesh.id } : node), meshes: [...snapshot.meshes, ...[...mappedData.values()].map((entry) => entry.mesh)], nonMeshData: (snapshot.nonMeshData ?? []).filter((data) => !mappedData.has(data.id)), }, geometryBuffers: [...geometryBuffers, ...[...mappedData.values()].map((entry) => entry.buffer)], geometryByMeshId, losses, }; }