manifold-3d
Version:
Geometry library for topological robustness
407 lines • 15.9 kB
JavaScript
// Copyright 2022-2025 The Manifold Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* The scene builder provides modelling outside of the native
* capabilities of Manifold WASM. This includes scene graphs, materials,
* and animation functions. In general, the scene builder
* follows GLTF semantics.
*
* @packageDocumentation
* @group ManifoldCAD
* @category Core
*/
import { Document, Extension, Material, Node, PropertyType } from '@gltf-transform/core';
import { copyToDocument, dedup, unpartition } from '@gltf-transform/functions';
import { addAnimationToDoc, addMotion, cleanup as cleanupAnimation, cleanupAnimationInDoc, getMorph, morphEnd, morphStart, setMorph } from "./animation.js";
import { cleanup as cleanupDebug, getDebugGLTFMesh, getMaterialByID } from "./debug.js";
import { cleanup as cleanupExport, getPropertyResolver } from "./export-model.js";
import { attributeDefs, writeMesh } from "./gltf-io.js";
import { BaseGLTFNode, CrossSectionGLTFNode, GLTFNode, VisualizationGLTFNode } from "./gltf-node.js";
import { cleanup as cleanupImport } from "./import-model.js";
import { cleanup as cleanupMaterial, getBackupMaterial, getCachedMaterial } from "./material.js";
import { euler2quat } from "./math.js";
import { formatArea, formatLength, formatVolume } from "./util.js";
import { getManifoldModuleSync } from "./wasm.js";
export { getAnimationDuration, getAnimationFPS, getAnimationMode, setAnimationDuration, setAnimationFPS, setAnimationMode, setMorphEnd, setMorphStart } from "./animation.js";
export { only, show } from "./debug.js";
export { GLTFNode } from "./gltf-node.js";
export { getCircularSegments, getMinCircularAngle, getMinCircularEdgeLength, resetToCircularDefaults, setCircularSegments, setMinCircularAngle, setMinCircularEdgeLength } from "./level-of-detail.js";
export { setMaterial } from "./material.js";
/**
* Reset and garbage collect the scene builder and any
* encapsulated modules.
*
* @group Management Functions
*/
export function cleanup() {
cleanupAnimation();
cleanupDebug();
cleanupMaterial();
cleanupImport();
cleanupExport();
}
// Swallow informational logs in testing framework
function log(...args) {
if (typeof self !== 'undefined' && self.console) {
self.console.log(...args);
}
}
function applyTransformation(doc, sourceNode, targetNode) {
// Animation Motion
const pos = addMotion(doc, 'translation', sourceNode, targetNode);
if (pos != null) {
targetNode.setTranslation(pos);
}
const rot = addMotion(doc, 'rotation', sourceNode, targetNode);
if (rot != null) {
targetNode.setRotation(euler2quat(rot));
}
const scale = addMotion(doc, 'scale', sourceNode, targetNode);
if (scale != null) {
targetNode.setScale(scale);
}
}
function writeManifold(doc, node, nodeDef, backupMaterial = {}) {
if (nodeDef.name)
node.setName(nodeDef.name);
const manifold = nodeDef.manifold;
var normalIdx = -1;
if (nodeDef.material?.attributes != null) {
const { attributes } = nodeDef.material;
if (attributes.includes('NORMAL')) {
normalIdx = 0;
for (const attribute of attributes) {
if (attribute === 'NORMAL')
break;
normalIdx += attributeDefs[attribute].components;
}
}
}
const manifoldMesh = manifold.getMesh(normalIdx);
// Log this conversion
const name = nodeDef.name ? `"${nodeDef.name}"` : 'object';
log(`Exporting Manifold ${name} as mesh:`);
log(` Triangles: ${manifold.numTri().toLocaleString()}`);
const box = manifold.boundingBox();
const size = [0, 0, 0];
for (let i = 0; i < 3; i++) {
size[i] = box.max[i] - box.min[i];
}
log(` Bounding Box: X = ${formatLength(size[0])}, Y = ${formatLength(size[1])}, Z = ${formatLength(size[2])}`);
log(` Genus: ${manifold.genus().toLocaleString()}`);
log(` Volume: ${formatVolume(manifold.volume())}`);
// Material
const id2properties = new Map();
for (const id of manifoldMesh.runOriginalID) {
// This manifold object was not imported.
const material = getMaterialByID(id) || backupMaterial;
id2properties.set(id, {
material: getCachedMaterial(doc, material),
attributes: ['POSITION', ...material.attributes ?? []]
});
}
// Animation Morph
const morph = getMorph(manifold);
const inputPositions = morphStart(manifoldMesh, morph);
// Core
const mesh = writeMesh(doc, manifoldMesh, id2properties);
node.setMesh(mesh);
// Animation Morph
morphEnd(doc, manifoldMesh, mesh, inputPositions, morph);
// If we're using a debug mode (`show` or `only`), check
// to see if this mesh requires special handling.
const debugNodes = getDebugGLTFMesh(doc, manifoldMesh, id2properties, backupMaterial);
for (const debugNode of debugNodes) {
node.addChild(debugNode);
}
}
function writeCrossSection(doc, node, nodeDef) {
node.setName(nodeDef.name || `CrossSection_${nodeDef.runID}`);
const cs = nodeDef.crossSection;
const { Mesh, triangulate } = getManifoldModuleSync(); // Lazy instantiation.
const polygons = cs.toPolygons();
const triangles = triangulate(polygons);
// Log this conversion.
log(`Exporting CrossSection ${nodeDef.name ? `"${nodeDef.name}"` : 'object'} as mesh:`);
log(` Triangles: ${triangles.length.toLocaleString()}`);
const box = cs.bounds();
const size = [0, 0];
for (let i = 0; i < 2; i++) {
size[i] = box.max[i] - box.min[i];
}
log(` Bounding Box: X = ${formatLength(size[0])}, Y = ${formatLength(size[1])}`);
log(` Area: ${formatArea(cs.area())}`);
// Material.
const id2properties = new Map();
id2properties.set(nodeDef.runID, {
material: getCachedMaterial(doc, {
baseColorFactor: [1, 0, 1],
...(nodeDef.material ?? {}),
doubleSided: true
}),
// CrossSection does not have vertex attributes beyond position.
attributes: ['POSITION']
});
// Convert geometry to a Mesh.
const manifoldMesh = new Mesh({
numProp: 3, // Position only.
vertProperties: new Float32Array(polygons.flat().map((v) => ([...v, 0])).flat()),
triVerts: new Uint32Array(triangles.flat()),
runIndex: new Uint32Array([0, 3 * (triangles.length)]),
runOriginalID: new Uint32Array(triangles.length).fill(nodeDef.runID)
});
// And finally export the mesh.
const mesh = writeMesh(doc, manifoldMesh, id2properties, false);
node.setMesh(mesh);
}
/**
* Clone a given node in `doc`, retaining materials.
*/
function cloneNode(toNode, fromNode) {
toNode.setMesh(fromNode.getMesh());
fromNode.listChildren().forEach((child) => {
const clone = child.clone();
toNode.addChild(clone);
});
}
/**
* Clone a given node in `doc`, replacing materials.
*/
function cloneNodeNewMaterial(doc, toNode, fromNode, newMaterial, oldMaterial) {
cloneNode(toNode, fromNode);
const oldMesh = fromNode.getMesh();
const newMesh = doc.createMesh();
toNode.setMesh(newMesh);
oldMesh.listPrimitives().forEach((primitive) => {
const newPrimitive = primitive.clone();
if (primitive.getMaterial() === oldMaterial) {
newPrimitive.setMaterial(newMaterial);
}
newMesh.addPrimitive(newPrimitive);
});
// Track cloned meshes for easier export, later.
newMesh.setExtras({ clonedFrom: oldMesh });
}
/**
* Write a Manifold or CrossSection object, reusing previous conversions if
* possible.
*/
function createNodeFromCache(doc, nodeDef, source2node) {
const node = doc.createNode(nodeDef.name);
applyTransformation(doc, nodeDef, node);
if (nodeDef instanceof GLTFNode) {
setMorph(doc, node, nodeDef.manifold);
}
const cacheKey = () => {
if (nodeDef instanceof CrossSectionGLTFNode) {
return nodeDef.crossSection;
}
return nodeDef.manifold;
};
const cachedNodes = source2node.get(cacheKey());
const material = getBackupMaterial(nodeDef);
if (cachedNodes == null) {
// Cache miss.
if (nodeDef instanceof CrossSectionGLTFNode) {
writeCrossSection(doc, node, nodeDef);
}
else {
writeManifold(doc, node, nodeDef, material);
}
const cachedNodes = new Map();
cachedNodes.set(material, node);
source2node.set(cacheKey(), cachedNodes);
}
else {
// Cache hit...
const cachedNode = cachedNodes.get(material);
if (cachedNode == null) {
// ...but not for this material.
const [oldMaterial, oldNode] = cachedNodes.entries().next().value;
cloneNodeNewMaterial(doc, node, oldNode, getCachedMaterial(doc, material), getCachedMaterial(doc, oldMaterial));
cachedNodes.set(material, node);
}
else {
// ...for this exact material.
cloneNode(node, cachedNode);
}
}
return node;
}
/**
* Copy part of a glTF document (on nodeDef) into doc.
*
* At the time of writing, `copyToDocument()` is flagged as experimental in
* glTF-Transform. It has a few limitations. These are not show-stoppers, but
* do require some cleanup.
*
* * It creates multiple buffers. Under the hood, it just copies the
* original buffer into the new document. It is unclear whether it copies
* the entire buffer, or only the sections of the buffer that are relevant.
* ManifoldCAD users will likely import all geometry within a file anyhow,
* so buffer duplication is an issue for future investigation.
* This can be remediated through `unpartition()`.
* * It will try to use a PropertyResolver to deduplicate. However certain
* properties like textures) are not deduplicated yet.
* This can be fixed with `dedup()`.
*/
function copyNodeToDocument(doc, nodeDef) {
const sourceDoc = nodeDef.document;
let targetNode = null;
// Get a property resolver, to deduplicate what we can.
const resolve = getPropertyResolver(doc, sourceDoc);
// Ensure that any extensions applied to the
// source document are carried through.
for (const sourceExtension of sourceDoc.getRoot().listExtensionsUsed()) {
const ctor = sourceExtension.constructor;
const targetExtension = doc.createExtension(ctor);
if (sourceExtension.isRequired())
targetExtension.setRequired(true);
}
if (nodeDef.node) {
const sourceNode = nodeDef.node;
copyToDocument(doc, sourceDoc, [sourceNode], resolve);
targetNode = resolve(sourceNode);
}
else {
targetNode = doc.createNode();
for (const sourceNode of sourceDoc.getRoot().listNodes()) {
copyToDocument(doc, sourceDoc, [sourceNode], resolve);
if (sourceNode.getParentNode())
continue;
targetNode.addChild(resolve(sourceNode));
}
}
if (nodeDef.name)
targetNode.setName(nodeDef.name);
applyTransformation(doc, nodeDef, targetNode);
return targetNode;
}
/**
* Scale and transform exported geometry, by wrapping it a top level node with a
* transformation.
*
* glTF has a defined scale of 1:1 metre.
* ManifoldCAD has a defined scale of 1:1 mm.
*
* glTF defines up as '+Y'.
* ManifoldCAD defines up as '+Z'.
*
* See also `importTransform()` in `import-model.ts`.
*/
function exportTransform(doc) {
// GLTF has a defined scale of 1:1 metre.
const mm2m = 1 / 1000;
const wrapper = doc.createNode('wrapper');
wrapper.setRotation(euler2quat([-90, 0, 0]));
wrapper.setScale([mm2m, mm2m, mm2m]);
doc.createScene().addChild(wrapper);
return wrapper;
}
/**
* Convert a Manifold object into a GLTF-Transform Document.
*
* @group Management Functions
* @param manifold The Manifold object
* @returns An in-memory GLTF-Transform Document
*/
export async function manifoldToGLTFDoc(manifold) {
const node = new GLTFNode();
node.manifold = manifold;
return await GLTFNodesToGLTFDoc([node]);
}
/**
* Convert a list of GLTF Nodes into a GLTF-Transform Document.
*
* @group Management Functions
* @param nodes A list of GLTF Nodes
* @returns An in-memory GLTF-Transform Document
*/
export async function GLTFNodesToGLTFDoc(nodes) {
const doc = new Document();
doc.createBuffer();
addAnimationToDoc(doc);
const node2gltf = new Map();
const source2node = new Map();
let manifoldNodes = 0;
let visualizationNodes = 0;
let crossSectionNodes = 0;
let noGeometryNodes = 0;
// First, create a node in the GLTF document for each ManifoldCAD node.
for (const nodeDef of nodes) {
let node = null;
if (nodeDef.isEmpty()) {
// No geometry here. Create the node anyhow as it may contain
// transformations.
node = doc.createNode(nodeDef.name);
applyTransformation(doc, nodeDef, node);
++noGeometryNodes;
}
else if (nodeDef instanceof VisualizationGLTFNode) {
// Copy from another glTF document in memory.
node = copyNodeToDocument(doc, nodeDef);
++visualizationNodes;
}
else {
// Manifold or CrossSection Object.
// Previous meshes and materials are cached in `source2node`.
node = createNodeFromCache(doc, nodeDef, source2node);
if (nodeDef instanceof GLTFNode)
++manifoldNodes;
if (nodeDef instanceof CrossSectionGLTFNode)
++crossSectionNodes;
}
node2gltf.set(nodeDef, node);
}
// Step through each node and set its parent.
// Nodes without parents are added directly to the root.
const root = exportTransform(doc);
for (const nodeDef of nodes) {
const gltfNode = node2gltf.get(nodeDef);
const { parent } = nodeDef;
if (parent) {
node2gltf.get(parent).addChild(gltfNode);
}
else {
root.addChild(gltfNode);
}
}
log(`Total glTF nodes: ${nodes.length}`);
if (manifoldNodes) {
log(` Manifold meshes: ${manifoldNodes}`);
}
if (crossSectionNodes) {
log(` CrossSection meshes: ${crossSectionNodes}`);
}
if (visualizationNodes) {
log(` Visualization-only (imported) nodes: ${visualizationNodes}`);
}
if (noGeometryNodes) {
log(` Nodes without geometry: ${noGeometryNodes}`);
}
cleanupAnimationInDoc();
// `copyToDocument()` creates multiple buffers. Under the hood, it just
// copies the original buffer into the new document.
// `unpartition()` merges those buffers together.
await doc.transform(unpartition());
// `copyToDocument()` will try to use a PropertyResolver to deduplicate.
// However, it's an experimental feature in general, and certain properties
// (like textures) are not deduplicated yet.
await doc.transform(dedup({
keepUniqueNames: true,
propertyTypes: [PropertyType.TEXTURE, PropertyType.MATERIAL, PropertyType.MESH]
}));
return doc;
}
//# sourceMappingURL=scene-builder.js.map