Files
workinf_Blender_Wasm/web/protocol/nonmesh-export.ts
2026-08-12 04:47:48 -04:00

234 lines
12 KiB
TypeScript

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<string, ReassembledNonMeshGeometry>;
losses: Array<{ dataId: string; message: string }>;
}
function evaluatedByData(depsgraph?: DepsgraphEvaluationIR): Map<string, DepsgraphNonMeshGeometryIR[]> {
const result = new Map<string, DepsgraphNonMeshGeometryIR[]>();
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<string, { mesh: MeshSummaryIR; buffer: MeshGeometryBuffer; geometry: ReassembledNonMeshGeometry }>();
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<string, ReassembledNonMeshGeometry>();
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,
};
}