manifold-3d
Version:
Geometry library for topological robustness
483 lines • 16.7 kB
JavaScript
// Copyright 2024-25 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.
/**
* Import models into manifoldCAD.
*
* ManifoldCAD uses [gltf-transform](https://gltf-transform.dev/) internally to
* represent scenes. Importers must convert their models to in-memory
* gltf-transform Documents.
*
* The high level functions `importModel()` and `importManifold()` will import
* models as display-only and full manifold objects respectively. These
* functions are available in manifoldCAD.
*
* @packageDocumentation
* @group ManifoldCAD
* @category Input/Output
* @groupDescription Low Level Functions
* These functions are not available within manifoldCAD, but can be used when
* including manifold in another project.
*/
import * as GLTFTransform from '@gltf-transform/core';
import { ImportError, UnsupportedFormatError } from "./error.js";
import * as gltfIO from "./gltf-io.js";
import { VisualizationGLTFNode } from "./gltf-node.js";
import * as import3MF from "./import-3mf.js";
import { setMaterialByID } from "./material.js";
import { euler2quat, multiplyQuat } from "./math.js";
import { fetchWithRetry, findExtension, findMimeType, isNode } from "./util.js";
import { getManifoldModuleSync } from "./wasm.js";
const importers = [];
register(gltfIO);
register(import3MF);
const id2mesh = new Map();
const mesh2node = new Map();
const mesh2mesh = new Map();
const node2doc = new Map();
export const cleanup = () => {
id2mesh.clear();
mesh2node.clear();
mesh2mesh.clear();
node2doc.clear();
};
/**
* @internal
*/
export const getDocumentByID = (runID) => {
const mesh = id2mesh.get(runID);
if (!mesh)
return null;
const [node] = mesh2node.get(mesh) ?? [];
if (!node)
return null;
return node2doc.get(node) ?? null;
};
function getFormat(identifier) {
const formats = importers.flatMap(im => im.importFormats);
const format = (findMimeType(identifier, formats) ??
findExtension(identifier, formats));
if (!format)
throw new UnsupportedFormatError(identifier, formats);
return format;
}
function getImporter(identifier) {
const format = typeof identifier === 'string' ? getFormat(identifier) : identifier;
return importers.find(im => im.importFormats.includes(format));
}
function getSourceFilename(source) {
let path;
if (source instanceof URL) {
if (source.protocol === 'blob:' || source.protocol === 'data:')
return;
path = source.pathname;
}
else if (typeof source === 'string') {
if (source.startsWith('blob:') || source.startsWith('data:'))
return;
try {
const url = new URL(source);
if (url.protocol === 'blob:' || url.protocol === 'data:')
return;
path = url.pathname;
}
catch {
path = source;
}
}
if (!path)
return;
const filename = path.split(/[\/\\]/).pop();
if (!filename)
return;
try {
return decodeURIComponent(filename);
}
catch {
return filename;
}
}
/**
* Returns true if a given extension or mimetype can be imported.
*
* @param filetype
* @param throwOnFailure If true, throw an `UnsupportedFormatException` rather
* than return false.
* @group Management
*/
export function supports(filetype, throwOnFailure = false) {
if (throwOnFailure)
return !!getFormat(filetype);
try {
return !!getFormat(filetype);
}
catch (e) {
return false;
}
}
/**
* Register an importer.
*
* Supported formats will be inferred.
* @group Management
*/
export function register(importer) {
importers.push(importer);
}
/**
* Import a model, for display only.
*/
export async function importModel(source, options = {}) {
const sourceDoc = await readModel(source, options);
const sourceNodes = sourceDoc.getRoot().listNodes();
if (!sourceNodes.length) {
throw new ImportError(`Model imported from \`${source}\` contains no nodes.`);
}
const targetNode = new VisualizationGLTFNode();
targetNode.document = sourceDoc;
if (sourceNodes.length == 1) {
const [sourceNode] = sourceNodes;
targetNode.node = sourceNode;
targetNode.name = sourceNode.getName() || getSourceFilename(source);
}
else {
targetNode.name = getSourceFilename(source);
}
if (typeof source === 'string')
targetNode.uri = source;
return targetNode;
}
/**
* Import a model, and convert it to a Manifold object for manipulation.
*
* The original imported model may consist of an entire tree of nodes, each of
* which may or may not be manifold. This method will convert each child node,
* and then union the results together. If a child node has no mesh, the mesh
* has no geometry, or the mesh is not manifold, that child node will be
* silently excluded.
*/
export async function importManifold(source, options = {}) {
const { document, node } = await importModel(source, options);
try {
return gltfDocToManifold(document, node, options.tolerance);
}
catch (e) {
if (e instanceof ImportError) {
const newError = new Error(`Model imported from \`${source}\` contains no manifold geometry.`);
newError.cause = e;
throw newError;
}
throw e;
}
}
/**
* Resolve and read a model, be it a file, a URL or a Blob.
*
* @group Low Level Functions
*/
export async function readModel(source, options = {}) {
if (source instanceof Blob) {
return await fromBlob(source, options);
}
if (source instanceof ArrayBuffer) {
return await fromArrayBuffer(source, options.mimetype);
}
let path = null;
if (source instanceof URL) {
path = source.href;
}
else if ('string' === typeof source) {
path = source;
}
if (path) {
if (path.startsWith('data:') || path.startsWith('blob:')) {
// Fetch can probably handle this.
return await fetchModel(path, options);
}
else if (/^https?:\/\//.test(path)) {
// Absolute URL.
return await fetchModel(path, options);
}
else if (path.startsWith('file:')) {
// File URL.
return readFile(path, options);
}
else {
// Relative URL.
if (isNode()) {
// In node, assume it's relative to the current working directory.
// That may not be the same as relative to the source file.
return await readFile(path, options);
}
else {
// In the browser, it's relative to the current URL.
return await fetchModel(path, options);
}
}
}
throw new ImportError(`Could not import model \`${source}\`.`);
}
/**
* Fetch a model over HTTP/HTTPS.
*
* @group Low Level Functions
*/
export async function fetchModel(uri, options = {}) {
const importer = getImporter(options.mimetype ?? uri);
const response = await fetchWithRetry(uri);
const blob = await response.blob();
return importTransform(await importer.fromArrayBuffer(await blob.arrayBuffer(), options));
}
/**
* Read a model from a Blob.
*
* @group Low Level Functions
*/
export async function fromBlob(blob, options = {}) {
if (!blob.type && !options.mimetype) {
throw new ImportError('Could not infer format of Blob');
}
const importer = getImporter(options.mimetype ?? blob.type);
return importTransform(await importer.fromArrayBuffer(await blob.arrayBuffer(), options));
}
/**
* Read a model from an ArrayBuffer.
*
* @group Low Level Functions
*/
export async function fromArrayBuffer(
// FIXME consistency
buffer, identifier) {
if (!identifier) {
throw new ImportError('Must specify a mime type when reading an ArrayBuffer');
}
const importer = getImporter(identifier);
return importTransform(await importer.fromArrayBuffer(buffer));
}
/**
* Read a model from disk.
* @group Low Level Functions
*/
export async function readFile(filename, options = {}) {
if (!isNode()) {
throw new ImportError('Must have a filesystem to read files.');
}
const importer = getImporter(options.mimetype ?? filename);
const fs = await import('node:fs/promises');
const { fileURLToPath } = await import('node:url');
const path = filename.startsWith('file:') ? fileURLToPath(filename) : filename;
const buffer = (await fs.readFile(path)).buffer;
return importTransform(await importer.fromArrayBuffer(buffer, options));
}
/**
* Scale and transform imported geometry.
*
* 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'.
*/
function importTransform(doc) {
for (const scene of doc.getRoot().listScenes()) {
const nodes = scene.listChildren().filter(c => c instanceof GLTFTransform.Node);
const rotate = euler2quat([90, 0, 0]);
const scale = 1000;
if (nodes.length === 1) {
// If there's just one node, transform it in place.
const [node] = nodes;
node.setScale(node.getScale().map(n => n * scale));
node.setRotation(multiplyQuat(node.getRotation(), rotate));
}
else {
// If there's more than one node, create a parent node and transform that.
const parent = doc.createNode();
for (const node of nodes)
parent.addChild(node);
parent.setScale([scale, scale, scale]);
parent.setRotation(rotate);
scene.addChild(parent);
}
}
return doc;
}
/**
* Convert a gltf-transform Node and its descendants into a Manifold object.
*
* The original imported model may consist of an entire tree of nodes, each of
* which may or may not be manifold. This method will convert each child node,
* and then union the results together. If a child node has no mesh, the mesh
* has no geometry, or the mesh is not manifold, that child node will be
* silently excluded.
*
* Other errors will be re-thrown for the caller to handle.
*
* @group Low Level Functions
*/
export function gltfDocToManifold(document, node, tolerance) {
const meshes = gltfNodeToMeshes(document, node);
if (!meshes.length) {
throw new ImportError(`Model contains no meshes!`);
}
return meshesToManifold(meshes, tolerance);
}
;
/**
* Extract meshes from a gltf-transform node (and its descendants), or from all
* nodes in a document, and convert them to Mesh objects. Meshless nodes will
* be silently skipped.
*
*/
function gltfNodeToMeshes(document, node) {
const descendants = [];
const getDescendants = (root) => root.traverse(node => descendants.push(node));
if (node) {
getDescendants(node);
}
else {
for (const node of document.getRoot().listNodes()) {
if (node.getParentNode())
continue;
getDescendants(node);
}
}
return descendants
.map(descendant => {
const gltfmesh = descendant.getMesh();
if (!gltfmesh)
return null;
node2doc.set(descendant, document);
const nodes = mesh2node.get(gltfmesh) ?? [];
nodes.push(descendant);
mesh2node.set(gltfmesh, nodes);
const mesh = gltfMeshToMesh(gltfmesh);
return mesh;
})
.filter(mesh => !!mesh);
}
/**
* Convert a Mesh into a Manifold. Returns null if the result is not manifold
* or is empty. All other exceptions will be re-thrown.
*/
const tryToMakeManifold = (mesh) => {
const { Manifold } = getManifoldModuleSync();
try {
const manifold = new Manifold(mesh);
if (manifold && !manifold.isEmpty()) {
return manifold;
}
}
catch (e) {
if (e?.name === 'ManifoldError' ||
e?.code === 'NotManifold') {
}
else {
throw e;
}
}
return null;
};
/**
* Given a list of Mesh objects, attempt to convert them individually into
* Manifold objects, and return the union. Non-manifold Meshes will be silently
* skipped.
*
*/
function meshesToManifold(meshes, tolerance) {
const { Manifold } = getManifoldModuleSync();
const mesh2nextNode = new Map();
const manifolds = [];
for (const mesh of meshes) {
let manifold = tryToMakeManifold(mesh);
if (!manifold) {
// That didn't work. Do we need to merge primitives?
mesh.merge();
manifold = tryToMakeManifold(mesh);
}
if (!manifold && tolerance) {
// That didn't work either.
// Can we adjust the model within tolerance?
mesh.tolerance = tolerance;
mesh.merge();
manifold = tryToMakeManifold(mesh);
}
if (!manifold)
continue;
// We have a manifold object, but it is in the local coordinate system of
// the original glTF-transform node. Find that node, and transform it back
// if possible.
const sourceMesh = mesh2mesh.get(mesh);
const sourceNodes = sourceMesh ? mesh2node.get(sourceMesh) : null;
const nextNode = sourceMesh ? (mesh2nextNode.get(sourceMesh) ?? 0) : 0;
const sourceNode = sourceNodes?.[nextNode] ?? null;
if (sourceMesh && sourceNode)
mesh2nextNode.set(sourceMesh, nextNode + 1);
if (sourceNode) {
manifolds.push(manifold.transform(sourceNode.getWorldMatrix()));
}
else {
manifolds.push(manifold);
}
}
if (!manifolds?.length) {
throw new ImportError(`Model contains no manifold geometry.`);
}
return Manifold.union(manifolds);
}
/**
* Convert a single gltf-transform Mesh to a Mesh object.
*
* Each primitive in a gltf-transform mesh may have its own material and
* attributes. Those primitives become runs once translated into Manifold.
* Each run may have a different material attached. ManifoldCAD can manage this
* case, although there is not a user-facing way to quickly assign materials to
* parts of a model. This function will index the original materials
* (properties) to be copied into an exported GLTF document. It will also set
* ManifoldCAD materials (a subset of GLTF materials) as a fallback.
*
*/
function gltfMeshToMesh(gltfmesh) {
const { Manifold, Mesh } = getManifoldModuleSync();
const { mesh, runProperties } = gltfIO.readMesh(gltfmesh);
// Get a a reserved ID from manifold for each run.
const numID = runProperties.length;
const firstID = Manifold.reserveIDs(numID);
mesh.runOriginalID = new Uint32Array(numID);
// Iterate through each primitive.
for (let primitiveID = 0; primitiveID < numID; ++primitiveID) {
// Set the manifold runID. This will be parsed by `new Mesh()`.
const runID = firstID + primitiveID;
mesh.runOriginalID[primitiveID] = runID;
const { attributes, material } = runProperties[primitiveID];
// Save these for later lookup.
id2mesh.set(runID, gltfmesh);
// Import what we can as a manifoldCAD material.
// We'll leave the original material attached for export.
setMaterialByID(runID, {
// 'POSITION' is always present; we don't need to specify it.
attributes: attributes.filter(x => x !== 'POSITION'),
alpha: material.getAlpha(),
baseColorFactor: material.getBaseColorFactor().slice(0, 3),
metallic: material.getMetallicFactor(),
roughness: material.getRoughnessFactor(),
name: material.getName(),
// Make sure we can find this source material later.
sourceMaterial: material,
sourceRunID: runID,
});
}
const manifoldMesh = new Mesh(mesh);
mesh2mesh.set(manifoldMesh, gltfmesh);
return manifoldMesh;
}
//# sourceMappingURL=import-model.js.map