234 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|