This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,14 @@
"""
trimesh/exchange
----------------
Contains the importers and exporters for various mesh formats.
Note that *you should probably not be using these directly*, if
you call `trimesh.load` it will then call and wrap the result
of the various loaders:
```
mesh = trimesh.load(file_name)
```
"""
@@ -0,0 +1,575 @@
"""
Parsing functions for Binvox files.
https://www.patrickmin.com/binvox/binvox.html
Exporting meshes as binvox files requires the
`binvox` executable to be in your path.
"""
import collections
import os
import subprocess
from tempfile import TemporaryDirectory
import numpy as np
from .. import util
from ..base import Trimesh
# find the executable for binvox in PATH
binvox_encoder = util.which("binvox")
Binvox = collections.namedtuple("Binvox", ["rle_data", "shape", "translate", "scale"])
def parse_binvox_header(fp):
"""
Read the header from a binvox file.
Spec available:
https://www.patrickmin.com/binvox/binvox.html
Parameters
------------
fp: file-object
File like object with binvox file
Returns
----------
shape : tuple
Shape of binvox according to binvox spec
translate : tuple
Translation
scale : float
Scale of voxels
Raises
------------
IOError
If invalid binvox file.
"""
line = fp.readline().strip()
if hasattr(line, "decode"):
binvox = b"#binvox"
space = b" "
else:
binvox = "#binvox"
space = " "
if not line.startswith(binvox):
raise OSError("Not a binvox file")
shape = tuple(int(s) for s in fp.readline().strip().split(space)[1:])
translate = tuple(float(s) for s in fp.readline().strip().split(space)[1:])
scale = float(fp.readline().strip().split(space)[1])
fp.readline()
return shape, translate, scale
def parse_binvox(fp, writeable=False):
"""
Read a binvox file, spec at
https://www.patrickmin.com/binvox/binvox.html
Parameters
------------
fp: file-object
File like object with binvox file
Returns
----------
binvox : namedtuple
Containing data
rle : numpy array
Run length encoded data
Raises
------------
IOError
If invalid binvox file
"""
# get the header info
shape, translate, scale = parse_binvox_header(fp)
# get the rest of the file
data = fp.read()
# convert to numpy array
rle_data = np.frombuffer(data, dtype=np.uint8)
if writeable:
rle_data = rle_data.copy()
return Binvox(rle_data, shape, translate, scale)
_binvox_header = """#binvox 1
dim {sx} {sy} {sz}
translate {tx} {ty} {tz}
scale {scale}
data
"""
def binvox_header(shape, translate, scale):
"""
Get a binvox header string.
Parameters
--------
shape: length 3 iterable of ints denoting shape of voxel grid.
translate: length 3 iterable of floats denoting translation.
scale: num length of entire voxel grid.
Returns
--------
string including "data\n" line.
"""
sx, sy, sz = (int(s) for s in shape)
tx, ty, tz = translate
return _binvox_header.format(sx=sx, sy=sy, sz=sz, tx=tx, ty=ty, tz=tz, scale=scale)
def binvox_bytes(rle_data, shape, translate=(0, 0, 0), scale=1):
"""Get a binary representation of binvox data.
Parameters
--------
rle_data : numpy array
Run-length encoded numpy array.
shape : (3,) int
Shape of voxel grid.
translate : (3,) float
Translation of voxels
scale : float
Length of entire voxel grid.
Returns
--------
data : bytes
Suitable for writing to binary file
"""
if rle_data.dtype != np.uint8:
raise ValueError(f"rle_data.dtype must be np.uint8, got {rle_data.dtype}")
header = binvox_header(shape, translate, scale).encode()
return header + rle_data.tobytes()
def voxel_from_binvox(rle_data, shape, translate=None, scale=1.0, axis_order="xzy"):
"""
Factory for building from data associated with binvox files.
Parameters
---------
rle_data : numpy
Run-length-encoded of flat voxel
values, or a `trimesh.rle.RunLengthEncoding` object.
See `trimesh.rle` documentation for description of encoding
shape : (3,) int
Shape of voxel grid.
translate : (3,) float
Translation of voxels
scale : float
Length of entire voxel grid.
encoded_axes : iterable
With values in ('x', 'y', 'z', 0, 1, 2),
where x => 0, y => 1, z => 2
denoting the order of axes in the encoded data. binvox by
default saves in xzy order, but using `xyz` (or (0, 1, 2)) will
be faster in some circumstances.
Returns
---------
result : VoxelGrid
Loaded voxels
"""
# shape must be uniform else scale is ambiguous
from .. import transformations
from ..voxel import encoding as enc
from ..voxel.base import VoxelGrid
if isinstance(rle_data, enc.RunLengthEncoding):
encoding = rle_data
else:
encoding = enc.RunLengthEncoding(rle_data, dtype=bool)
# translate = np.asanyarray(translate) * scale)
# translate = [0, 0, 0]
transform = transformations.scale_and_translate(
scale=scale / (np.array(shape) - 1), translate=translate
)
if axis_order == "xzy":
perm = (0, 2, 1)
shape = tuple(shape[p] for p in perm)
encoding = encoding.reshape(shape).transpose(perm)
elif axis_order is None or axis_order == "xyz":
encoding = encoding.reshape(shape)
else:
raise ValueError(
"Invalid axis_order '%s': must be None, 'xyz' or 'xzy'", axis_order
)
assert encoding.shape == shape
return VoxelGrid(encoding, transform)
def load_binvox(file_obj, resolver=None, axis_order="xzy", file_type=None):
"""
Load trimesh `VoxelGrid` instance from file.
Parameters
-----------
file_obj : file-like object
Contains binvox data
resolver : unused
axis_order : str
Order of axes in encoded data.
Binvox default is 'xzy', but 'xyz' may be faster
where this is not relevant.
Returns
---------
result : trimesh.voxel.VoxelGrid
Loaded voxel data
"""
if file_type is not None and file_type != "binvox":
raise ValueError(f"file_type must be None or binvox, got {file_type}")
data = parse_binvox(file_obj, writeable=True)
return voxel_from_binvox(
rle_data=data.rle_data,
shape=data.shape,
translate=data.translate,
scale=data.scale,
axis_order=axis_order,
)
def export_binvox(voxel, axis_order="xzy"):
"""
Export `trimesh.voxel.VoxelGrid` instance to bytes
Parameters
------------
voxel : `trimesh.voxel.VoxelGrid`
Assumes axis ordering of `xyz` and encodes
in binvox default `xzy` ordering.
axis_order : str
Eements in ('x', 'y', 'z', 0, 1, 2), the order
of axes to encode data (standard is 'xzy' for binvox). `voxel`
data is assumed to be in order 'xyz'.
Returns
-----------
result : bytes
Representation according to binvox spec
"""
translate = voxel.translation
scale = voxel.scale * (np.array(voxel.shape) - 1)
(neg_scale,) = np.where(scale < 0)
encoding = voxel.encoding.flip(neg_scale)
scale = np.abs(scale)
if not util.allclose(scale[0], scale[1:], 1e-6 * scale[0] + 1e-8):
raise ValueError("Can only export binvox with uniform scale")
scale = scale[0]
if axis_order == "xzy":
encoding = encoding.transpose((0, 2, 1))
elif axis_order != "xyz":
raise ValueError('Invalid axis_order: must be one of ("xyz", "xzy")')
rle_data = encoding.flat.run_length_data(dtype=np.uint8)
return binvox_bytes(rle_data, shape=voxel.shape, translate=translate, scale=scale)
class Binvoxer:
"""
Interface for binvox CL tool.
This class is responsible purely for making calls to the CL tool. It
makes no attempt to integrate with the rest of trimesh at all.
Constructor args configure command line options.
`Binvoxer.__call__` operates on the path to a mode file.
If using this interface in published works, please cite the references
below.
See CL tool website for further details.
https://www.patrickmin.com/binvox/
@article{nooruddin03,
author = {Fakir S. Nooruddin and Greg Turk},
title = {Simplification and Repair of Polygonal Models Using Volumetric
Techniques},
journal = {IEEE Transactions on Visualization and Computer Graphics},
volume = {9},
number = {2},
pages = {191--205},
year = {2003}
}
@Misc{binvox,
author = {Patrick Min},
title = {binvox},
howpublished = {{\tt http://www.patrickmin.com/binvox} or
{\tt https://www.google.com/search?q=binvox}},
year = {2004 - 2019},
note = {Accessed: yyyy-mm-dd}
}
"""
SUPPORTED_INPUT_TYPES = (
"ug",
"obj",
"off",
"dfx",
"xgl",
"pov",
"brep",
"ply",
"jot",
)
SUPPORTED_OUTPUT_TYPES = (
"binvox",
"hips",
"mira",
"vtk",
"raw",
"schematic",
"msh",
)
def __init__(
self,
dimension=32,
file_type="binvox",
z_buffer_carving=True,
z_buffer_voting=True,
dilated_carving=False,
exact=True,
bounding_box=None,
remove_internal=False,
center=False,
rotate_x=0,
rotate_z=0,
wireframe=False,
fit=False,
block_id=None,
use_material_block_id=False,
use_offscreen_pbuffer=False,
downsample_factor=None,
downsample_threshold=None,
verbose=False,
binvox_path=None,
):
"""
Configure the voxelizer.
Parameters
------------
dimension: voxel grid size (max 1024 when not using exact)
file_type: str
Output file type, supported types are:
'binvox'
'hips'
'mira'
'vtk'
'raw'
'schematic'
'msh'
z_buffer_carving : use z buffer based carving. At least one of
`z_buffer_carving` and `z_buffer_voting` must be True.
z_buffer_voting: use z-buffer based parity voting method.
dilated_carving: stop carving 1 voxel before intersection.
exact: any voxel with part of a triangle gets set. Does not use
graphics card.
bounding_box: 6-element float list/tuple of min, max values,
(minx, miny, minz, maxx, maxy, maxz)
remove_internal: remove internal voxels if True. Note there is some odd
behaviour if boundary voxels are occupied.
center: center model inside unit cube.
rotate_x: number of 90 degree ccw rotations around x-axis before
voxelizing.
rotate_z: number of 90 degree cw rotations around z-axis before
voxelizing.
wireframe: also render the model in wireframe (helps with thin parts).
fit: only write voxels in the voxel bounding box.
block_id: when converting to schematic, use this as the block ID.
use_matrial_block_id: when converting from obj to schematic, parse
block ID from material spec "usemtl blockid_<id>" (ids 1-255 only).
use_offscreen_pbuffer: use offscreen pbuffer instead of onscreen
window.
downsample_factor: downsample voxels by this factor in each dimension.
Must be a power of 2 or None. If not None/1 and `core dumped`
errors occur, try slightly adjusting dimensions.
downsample_threshold: when downsampling, destination voxel is on if
more than this number of voxels are on.
verbose : bool
If False, silences stdout/stderr from subprocess call.
binvox_path : str
Path to binvox executable. The default looks for an
executable called `binvox` on your `PATH`.
"""
if binvox_path is None:
encoder = binvox_encoder
else:
encoder = binvox_path
if encoder is None:
raise OSError(
" ".join(
[
"No `binvox_path` provided and no binvox executable found",
"on PATH, please go to https://www.patrickmin.com/binvox/ and",
"download the appropriate version.",
]
)
)
if dimension > 1024 and not exact:
raise ValueError("Maximum dimension using exact is 1024, got %d", dimension)
if file_type not in Binvoxer.SUPPORTED_OUTPUT_TYPES:
raise ValueError(
f"file_type {file_type} not in set of supported output types {Binvoxer.SUPPORTED_OUTPUT_TYPES!s}"
)
args = [encoder, "-d", str(dimension), "-t", file_type]
if exact:
args.append("-e")
if z_buffer_carving:
if z_buffer_voting:
pass
else:
args.append("-c")
elif z_buffer_voting:
args.append("-v")
else:
raise ValueError(
"One of `z_buffer_carving` or `z_buffer_voting` must be True"
)
if dilated_carving:
args.append("-dc")
# Additional parameters
if bounding_box is not None:
if len(bounding_box) != 6:
raise ValueError("bounding_box must have 6 elements")
args.append("-bb")
args.extend(str(b) for b in bounding_box)
if remove_internal:
args.append("-ri")
if center:
args.append("-cb")
args.extend(("-rotx",) * rotate_x)
args.extend(("-rotz",) * rotate_z)
if wireframe:
args.append("-aw")
if fit:
args.append("-fit")
if block_id is not None:
args.extend(("-bi", block_id))
if use_material_block_id:
args.append("-mb")
if use_offscreen_pbuffer:
args.append("-pb")
if downsample_factor is not None:
times = np.log2(downsample_factor)
if int(times) != times:
raise ValueError(
"downsample_factor must be a power of 2, got %d", downsample_factor
)
args.extend(("-down",) * int(times))
if downsample_threshold is not None:
args.extend(("-dmin", str(downsample_threshold)))
args.append("PATH")
self._args = args
self._file_type = file_type
self.verbose = verbose
@property
def file_type(self):
return self._file_type
def __call__(self, path, overwrite=False):
"""
Create an voxel file in the same directory as model at `path`.
Parameters
------------
path: string path to model file. Supported types:
'ug'
'obj'
'off'
'dfx'
'xgl'
'pov'
'brep'
'ply'
'jot' (polygongs only)
overwrite: if False, checks the output path (head.file_type) is empty
before running. If True and a file exists, raises an IOError.
Returns
------------
string path to voxel file. File type give by file_type in constructor.
"""
head, ext = os.path.splitext(path)
ext = ext[1:].lower()
if ext not in Binvoxer.SUPPORTED_INPUT_TYPES:
raise ValueError(
f"file_type {ext} not in set of supported input types {Binvoxer.SUPPORTED_INPUT_TYPES!s}"
)
out_path = f"{head}.{self._file_type}"
if os.path.isfile(out_path) and not overwrite:
raise OSError("Attempted to voxelize object at existing path")
self._args[-1] = path
# generalizes to python2 and python3
# will capture terminal output into variable rather than printing
verbosity = subprocess.check_output(self._args)
# if requested print ourselves
if self.verbose:
util.log.debug(verbosity)
return out_path
def voxelize_mesh(mesh, binvoxer=None, export_type="off", **binvoxer_kwargs):
"""
Interface for voxelizing Trimesh object via the binvox tool.
Implementation simply saved the mesh in the specified export_type then
runs the `Binvoxer.__call__` (using either the supplied `binvoxer` or
creating one via `binvoxer_kwargs`)
Parameters
------------
mesh: Trimesh object to voxelize.
binvoxer: optional Binvoxer instance.
export_type: file type to export mesh as temporarily for Binvoxer to
operate on.
**binvoxer_kwargs: kwargs for creating a new Binvoxer instance. If binvoxer
if provided, this must be empty.
Returns
------------
`VoxelGrid` object resulting.
"""
if not isinstance(mesh, Trimesh):
raise ValueError(f"mesh must be Trimesh instance, got {mesh!s}")
if binvoxer is None:
binvoxer = Binvoxer(**binvoxer_kwargs)
elif len(binvoxer_kwargs) > 0:
raise ValueError("Cannot provide binvoxer and binvoxer_kwargs")
if binvoxer.file_type != "binvox":
raise ValueError('Only "binvox" binvoxer `file_type` currently supported')
with TemporaryDirectory() as folder:
model_path = os.path.join(folder, f"model.{export_type}")
with open(model_path, "wb") as fp:
mesh.export(fp, file_type=export_type)
out_path = binvoxer(model_path)
with open(out_path, "rb") as fp:
out_model = load_binvox(fp)
return out_model
_binvox_loaders = {"binvox": load_binvox}
@@ -0,0 +1,74 @@
import os
import tempfile
from ..exceptions import ExceptionWrapper
from ..typed import BinaryIO, Dict, Number, Optional
# used as an intermediate format
from .gltf import load_glb
def load_step(
file_obj: BinaryIO,
file_type,
tol_linear: Optional[Number] = None,
tol_angular: Optional[Number] = None,
tol_relative: Optional[bool] = False,
merge_primitives: bool = True,
**kwargs,
) -> Dict:
"""
Use `cascadio` a packaged version of OpenCASCADE
to load a STEP file using GLB as an intermediate.
Parameters
-----------
file_obj
STEP file to load.
**kwargs
Passed to `cascadio.step_to_glb`
Returns
----------
kwargs
Keyword arguments for a Scene.
"""
# TODO : update upstream `cascadio` to accept bytes objects
# so that we don't need to write a temporary file to disc!
with tempfile.TemporaryDirectory() as F:
# temporarily copy the STEP
stepfile = os.path.join(F, "data.step")
with open(stepfile, "wb") as f:
f.write(file_obj.read())
# where to save the converted GLB
glbfile = os.path.join(F, "converted.glb")
# the arguments for cascadio are not optional so
# filter out any `None` value arguments here
cascadio_kwargs = {
"merge_primitives": bool(merge_primitives),
"tol_linear": tol_linear,
"tol_angular": tol_angular,
"tol_relative": tol_relative,
}
# run the conversion
cascadio.step_to_glb(
stepfile,
glbfile,
**{k: v for k, v in cascadio_kwargs.items() if v is not None},
)
with open(glbfile, "rb") as f:
# return the parsed intermediate file
return load_glb(file_obj=f, merge_primitives=merge_primitives, **kwargs)
try:
# wheels for most platforms: `pip install cascadio`
import cascadio
_cascade_loaders = {"stp": load_step, "step": load_step}
except BaseException as E:
wrapper = ExceptionWrapper(E)
_cascade_loaders = {"stp": wrapper, "step": wrapper}
@@ -0,0 +1,457 @@
import copy
import io
import uuid
import numpy as np
from .. import util, visual
from ..constants import log
from ..util import unique_name
_EYE = np.eye(4)
_EYE.flags.writeable = False
def load_collada(file_obj, resolver=None, ignore_broken=True, **kwargs):
"""
Load a COLLADA (.dae) file into a list of trimesh kwargs.
Parameters
----------
file_obj : file object
Containing a COLLADA file
resolver : trimesh.visual.Resolver or None
For loading referenced files, like texture images
ignore_broken: bool
Ignores broken references during loading:
[collada.common.DaeUnsupportedError,
collada.common.DaeBrokenRefError]
kwargs : **
Passed to trimesh.Trimesh.__init__
Returns
-------
loaded : list of dict
kwargs for Trimesh constructor
"""
import collada
if ignore_broken:
ignores = [
collada.common.DaeError,
collada.common.DaeIncompleteError,
collada.common.DaeMalformedError,
collada.common.DaeBrokenRefError,
collada.common.DaeUnsupportedError,
collada.common.DaeIncompleteError,
]
else:
ignores = None
# load scene using pycollada
c = collada.Collada(file_obj, ignore=ignores)
# Create material map from Material ID to trimesh material
material_map = {}
for m in c.materials:
effect = m.effect
material_map[m.id] = _parse_material(effect, resolver)
unit = c.assetInfo.unitmeter
if unit is None or np.isclose(unit, 1.0):
metadata = {"units": "meters"}
else:
metadata = {"units": f"{unit} * meters"}
# name : kwargs
meshes = {}
# increments to enable `unique_name` to avoid n^2 behavior
meshes_count = {}
# list of dict
graph = []
for node in c.scene.nodes:
_parse_node(
node=node,
parent_matrix=_EYE,
material_map=material_map,
meshes=meshes,
meshes_count=meshes_count,
graph=graph,
resolver=resolver,
metadata=metadata,
)
return {"class": "Scene", "graph": graph, "geometry": meshes}
def export_collada(mesh, **kwargs):
"""
Export a mesh or a list of meshes as a COLLADA .dae file.
Parameters
-----------
mesh: Trimesh object or list of Trimesh objects
The mesh(es) to export.
Returns
-----------
export: str, string of COLLADA format output
"""
import collada
meshes = mesh
if not isinstance(mesh, (list, tuple, set, np.ndarray)):
meshes = [mesh]
c = collada.Collada()
nodes = []
for i, m in enumerate(meshes):
# Load uv, colors, materials
uv = None
colors = None
mat = _unparse_material(None)
if m.visual.defined:
if m.visual.kind == "texture":
mat = _unparse_material(m.visual.material)
uv = m.visual.uv
elif m.visual.kind == "vertex":
colors = (m.visual.vertex_colors / 255.0)[:, :3]
mat.effect.diffuse = np.array(m.visual.main_color) / 255.0
elif m.visual.kind == "face":
mat.effect.diffuse = np.array(m.visual.main_color) / 255.0
c.effects.append(mat.effect)
c.materials.append(mat)
# Create geometry object
vertices = collada.source.FloatSource(
"verts-array", m.vertices.flatten(), ("X", "Y", "Z")
)
normals = collada.source.FloatSource(
"normals-array", m.vertex_normals.flatten(), ("X", "Y", "Z")
)
input_list = collada.source.InputList()
input_list.addInput(0, "VERTEX", "#verts-array")
input_list.addInput(1, "NORMAL", "#normals-array")
arrays = [vertices, normals]
if (uv is not None) and (len(uv) > 0):
texcoords = collada.source.FloatSource(
"texcoords-array", uv.flatten(), ("U", "V")
)
input_list.addInput(2, "TEXCOORD", "#texcoords-array")
arrays.append(texcoords)
if colors is not None:
idx = 2
if uv:
idx = 3
colors = collada.source.FloatSource(
"colors-array", colors.flatten(), ("R", "G", "B")
)
input_list.addInput(idx, "COLOR", "#colors-array")
arrays.append(colors)
geom = collada.geometry.Geometry(c, uuid.uuid4().hex, uuid.uuid4().hex, arrays)
indices = np.repeat(m.faces.flatten(), len(arrays))
matref = f"material{i}"
triset = geom.createTriangleSet(indices, input_list, matref)
geom.primitives.append(triset)
c.geometries.append(geom)
matnode = collada.scene.MaterialNode(matref, mat, inputs=[])
geomnode = collada.scene.GeometryNode(geom, [matnode])
node = collada.scene.Node(f"node{i}", children=[geomnode])
nodes.append(node)
scene = collada.scene.Scene("scene", nodes)
c.scenes.append(scene)
c.scene = scene
b = io.BytesIO()
c.write(b)
b.seek(0)
return b.read()
def _parse_node(
node, parent_matrix, material_map, meshes, meshes_count, graph, resolver, metadata
):
"""
Recursively parse COLLADA scene nodes.
"""
import collada
# Parse mesh node
if isinstance(node, collada.scene.GeometryNode):
geometry = node.geometry
# Create local material map from material symbol to actual material
local_material_map = {}
for mn in node.materials:
symbol = mn.symbol
m = mn.target
if m.id in material_map:
local_material_map[symbol] = material_map[m.id]
else:
local_material_map[symbol] = _parse_material(m, resolver)
# Iterate over primitives of geometry
for primitive in geometry.primitives:
if isinstance(primitive, collada.polylist.Polylist):
primitive = primitive.triangleset()
if isinstance(primitive, collada.triangleset.TriangleSet):
vertex = primitive.vertex
if vertex is None:
continue
vertex_index = primitive.vertex_index
vertices = vertex[vertex_index].reshape(len(vertex_index) * 3, 3)
# Get normals if present
normals = None
if primitive.normal is not None:
normal = primitive.normal
normal_index = primitive.normal_index
normals = normal[normal_index].reshape(len(normal_index) * 3, 3)
# Get colors if present
colors = None
s = primitive.sources
if "COLOR" in s and len(s["COLOR"]) > 0 and len(primitive.index) > 0:
color = s["COLOR"][0][4].data
color_index = primitive.index[:, :, s["COLOR"][0][0]]
colors = color[color_index].reshape(len(color_index) * 3, -1)
faces = np.arange(vertices.shape[0]).reshape(vertices.shape[0] // 3, 3)
# Get UV coordinates if possible
vis = None
if primitive.material in local_material_map:
material = copy.copy(local_material_map[primitive.material])
uv = None
if len(primitive.texcoordset) > 0:
texcoord = primitive.texcoordset[0]
texcoord_index = primitive.texcoord_indexset[0]
uv = texcoord[texcoord_index].reshape(
(len(texcoord_index) * 3, 2)
)
vis = visual.texture.TextureVisuals(uv=uv, material=material)
geom_name = unique_name(geometry.id, contains=meshes, counts=meshes_count)
meshes[geom_name] = {
"vertices": vertices,
"faces": faces,
"vertex_normals": normals,
"vertex_colors": colors,
"visual": vis,
"metadata": metadata,
}
graph.append(
{
"frame_to": geom_name,
"matrix": parent_matrix,
"geometry": geom_name,
}
)
# recurse down tree for nodes with children
elif isinstance(node, collada.scene.Node):
if node.children is not None:
for child in node.children:
# create the new matrix
matrix = np.dot(parent_matrix, node.matrix)
# parse the child node
_parse_node(
node=child,
parent_matrix=matrix,
material_map=material_map,
meshes=meshes,
meshes_count=meshes_count,
graph=graph,
resolver=resolver,
metadata=metadata,
)
elif isinstance(node, collada.scene.CameraNode):
# TODO: convert collada cameras to trimesh cameras
pass
elif isinstance(node, collada.scene.LightNode):
# TODO: convert collada lights to trimesh lights
pass
def _load_texture(file_name, resolver):
"""
Load a texture from a file into a PIL image.
"""
from PIL import Image
file_data = resolver.get(file_name)
image = Image.open(util.wrap_as_stream(file_data))
return image
def _parse_material(effect, resolver):
"""
Turn a COLLADA effect into a trimesh material.
"""
import collada
# Compute base color
baseColorFactor = np.ones(4)
baseColorTexture = None
if isinstance(effect.diffuse, collada.material.Map):
try:
baseColorTexture = _load_texture(
effect.diffuse.sampler.surface.image.path, resolver
)
except BaseException:
log.debug("unable to load base texture", exc_info=True)
elif effect.diffuse is not None:
baseColorFactor = effect.diffuse
# Compute emission color
emissiveFactor = np.zeros(3)
emissiveTexture = None
if isinstance(effect.emission, collada.material.Map):
try:
emissiveTexture = _load_texture(
effect.diffuse.sampler.surface.image.path, resolver
)
except BaseException:
log.warning("unable to load emissive texture", exc_info=True)
elif effect.emission is not None:
emissiveFactor = effect.emission[:3]
# Compute roughness
roughnessFactor = 1.0
if (
not isinstance(effect.shininess, collada.material.Map)
and effect.shininess is not None
):
try:
shininess_value = float(effect.shininess)
roughnessFactor = np.sqrt(2.0 / (2.0 + shininess_value))
except (TypeError, ValueError):
log.warning(
f"Invalid shininess value: {effect.shininess}, using default roughness"
)
# Compute metallic factor
metallicFactor = 0.0
# Compute normal texture
normalTexture = None
if effect.bumpmap is not None:
try:
normalTexture = _load_texture(
effect.bumpmap.sampler.surface.image.path, resolver
)
except BaseException:
log.warning("unable to load bumpmap", exc_info=True)
# Compute opacity
if effect.transparent is not None and not isinstance(
effect.transparent, collada.material.Map
):
baseColorFactor = tuple(
np.append(baseColorFactor[:3], float(effect.transparent[3]))
)
return visual.material.PBRMaterial(
emissiveFactor=emissiveFactor,
emissiveTexture=emissiveTexture,
normalTexture=normalTexture,
baseColorTexture=baseColorTexture,
baseColorFactor=baseColorFactor,
metallicFactor=metallicFactor,
roughnessFactor=roughnessFactor,
)
def _unparse_material(material):
"""
Turn a trimesh material into a COLLADA material.
"""
import collada
# TODO EXPORT TEXTURES
if isinstance(material, visual.material.PBRMaterial):
diffuse = material.baseColorFactor
if diffuse is None:
diffuse = np.array([255.0, 255.0, 255.0, 255.0])
diffuse = diffuse / 255.0
if diffuse is not None:
diffuse = list(diffuse)
emission = material.emissiveFactor
if emission is not None:
emission = [float(emission[0]), float(emission[1]), float(emission[2]), 1.0]
shininess = material.roughnessFactor
if shininess is None:
shininess = 1.0
if shininess is not None:
shininess = 2.0 / shininess**2 - 2.0
effect = collada.material.Effect(
uuid.uuid4().hex,
params=[],
shadingtype="phong",
diffuse=diffuse,
emission=emission,
specular=[1.0, 1.0, 1.0, 1.0],
shininess=float(shininess),
)
material = collada.material.Material(uuid.uuid4().hex, "pbrmaterial", effect)
else:
effect = collada.material.Effect(uuid.uuid4().hex, params=[], shadingtype="phong")
material = collada.material.Material(uuid.uuid4().hex, "defaultmaterial", effect)
return material
def load_zae(file_obj, resolver=None, **kwargs):
"""
Load a ZAE file, which is just a zipped DAE file.
Parameters
-------------
file_obj : file object
Contains ZAE data
resolver : trimesh.visual.Resolver
Resolver to load additional assets
kwargs : dict
Passed to load_collada
Returns
------------
loaded : dict
Results of loading
"""
# a dict, {file name : file object}
archive = util.decompress(file_obj, file_type="zip")
# load the first file with a .dae extension
file_name = next(i for i in archive.keys() if i.lower().endswith(".dae"))
# a resolver so the loader can load textures / etc
resolver = visual.resolvers.ZipResolver(archive)
# run the regular collada loader
loaded = load_collada(archive[file_name], resolver=resolver, **kwargs)
return loaded
# only provide loaders if `pycollada` is installed
_collada_loaders = {}
_collada_exporters = {}
if util.has_module("collada"):
_collada_loaders["dae"] = load_collada
_collada_loaders["zae"] = load_zae
_collada_exporters["dae"] = export_collada
else:
# store an exception to raise later
from ..exceptions import ExceptionWrapper
_exc = ExceptionWrapper(ImportError("missing `pip install pycollada`"))
_collada_loaders.update({"dae": _exc, "zae": _exc})
_collada_exporters["dae"] = _exc
@@ -0,0 +1,332 @@
import json
import os
import numpy as np
from .. import resolvers, util
from ..constants import log
from .dae import _collada_exporters
from .gltf import export_glb, export_gltf
from .obj import export_obj
from .off import _off_exporters
from .ply import _ply_exporters
from .stl import export_stl, export_stl_ascii
from .threemf import _3mf_exporters
from .urdf import export_urdf # NOQA
from .xyz import _xyz_exporters
def export_mesh(mesh, file_obj, file_type=None, resolver=None, **kwargs):
"""
Export a Trimesh object to a file- like object, or to a filename
Parameters
-----------
file_obj : str, file-like
Where should mesh be exported to
file_type : str or None
Represents file type (eg: 'stl')
resolver : None or trimesh.resolvers.Resolver
Resolver to write referenced assets to
Returns
----------
exported : bytes or str
Result of exporter
"""
# if we opened a file object in this function
# we will want to close it when we're done
was_opened = False
file_name = None
if util.is_pathlib(file_obj):
# handle `pathlib` objects by converting to string
file_obj = str(file_obj.absolute())
if isinstance(file_obj, str):
if file_type is None:
# get file type from file name
file_type = (str(file_obj).split(".")[-1]).lower()
if file_type in _mesh_exporters:
was_opened = True
file_name = file_obj
# get full path of file before opening
file_path = os.path.abspath(os.path.expanduser(file_obj))
file_obj = open(file_path, "wb")
if resolver is None:
# create a resolver which can write files to the path
resolver = resolvers.FilePathResolver(file_path)
# make sure file type is lower case
file_type = str(file_type).lower()
if file_type not in _mesh_exporters:
raise ValueError("%s exporter not available!", file_type)
if isinstance(mesh, (list, tuple, set, np.ndarray)):
faces = 0
for m in mesh:
faces += len(m.faces)
log.debug(
"Exporting %d meshes with a total of %d faces as %s",
len(mesh),
faces,
file_type.upper(),
)
elif hasattr(mesh, "faces"):
# if the mesh has faces log the number
log.debug("Exporting %d faces as %s", len(mesh.faces), file_type.upper())
# OBJ files save assets everywhere
if file_type == "obj":
kwargs["resolver"] = resolver
# run the exporter
export = _mesh_exporters[file_type](mesh, **kwargs)
# if the export is multiple files (i.e. GLTF)
if isinstance(export, dict):
# if we have a filename rename the default GLTF
if file_name is not None and "model.gltf" in export:
export[os.path.basename(file_name)] = export.pop("model.gltf")
# write the files if a resolver has been passed
if resolver is not None:
for name, data in export.items():
resolver.write(name=name, data=data)
return export
if hasattr(file_obj, "write"):
result = util.write_encoded(file_obj, export)
else:
result = export
# if we opened anything close it here
if was_opened:
file_obj.close()
return result
def export_dict64(mesh):
"""
Export a mesh as a dictionary, with data encoded
to base64.
"""
return export_dict(mesh, encoding="base64")
def export_dict(mesh, encoding=None):
"""
Export a mesh to a dict
Parameters
------------
mesh : trimesh.Trimesh
Mesh to be exported
encoding : str or None
Such as 'base64'
Returns
-------------
export : dict
Data stored in dict
"""
def encode(item, dtype=None):
if encoding is None:
return item.tolist()
else:
if dtype is None:
dtype = item.dtype
return util.array_to_encoded(item, dtype=dtype, encoding=encoding)
# metadata keys we explicitly want to preserve
# sometimes there are giant datastructures we don't
# care about in metadata which causes exports to be
# extremely slow, so skip all but known good keys
meta_keys = ["units", "file_name", "file_path"]
metadata = {k: v for k, v in mesh.metadata.items() if k in meta_keys}
export = {
"metadata": metadata,
"faces": encode(mesh.faces),
"face_normals": encode(mesh.face_normals),
"vertices": encode(mesh.vertices),
}
if mesh.visual.kind == "face":
export["face_colors"] = encode(mesh.visual.face_colors)
elif mesh.visual.kind == "vertex":
export["vertex_colors"] = encode(mesh.visual.vertex_colors)
return export
def scene_to_dict(scene, use_base64=False, include_metadata=True):
"""
Export a Scene object as a dict.
Parameters
-------------
scene : trimesh.Scene
Scene object to be exported
Returns
-------------
as_dict : dict
Scene as a dict
"""
# save some basic data about the scene
export = {
"graph": scene.graph.to_edgelist(),
"geometry": {},
"scene_cache": {
"bounds": scene.bounds.tolist(),
"extents": scene.extents.tolist(),
"centroid": scene.centroid.tolist(),
"scale": scene.scale,
},
}
if include_metadata:
try:
# jsonify will convert numpy arrays to lists recursively
# a little silly round-tripping to json but it is pretty fast
export["metadata"] = json.loads(util.jsonify(scene.metadata))
except BaseException:
log.warning("failed to serialize metadata", exc_info=True)
# encode arrays with base64 or not
if use_base64:
file_type = "dict64"
else:
file_type = "dict"
# if the mesh has an export method use it
# otherwise put the mesh itself into the export object
for geometry_name, geometry in scene.geometry.items():
if hasattr(geometry, "export"):
# export the data
exported = {
"data": geometry.export(file_type=file_type),
"file_type": file_type,
}
export["geometry"][geometry_name] = exported
else:
# case where mesh object doesn't have exporter
# might be that someone replaced the mesh with a URL
export["geometry"][geometry_name] = geometry
return export
def export_scene(scene, file_obj, file_type=None, resolver=None, **kwargs):
"""
Export a snapshot of the current scene.
Parameters
----------
file_obj : str, file-like, or None
File object to export to
file_type : str or None
What encoding to use for meshes
IE: dict, dict64, stl
Returns
----------
export : bytes
Only returned if file_obj is None
"""
if len(scene.geometry) == 0:
raise ValueError("Can't export empty scenes!")
if util.is_pathlib(file_obj):
# handle `pathlib` objects by converting to string
file_obj = str(file_obj.absolute())
# if we weren't passed a file type extract from file_obj
if file_type is None:
if isinstance(file_obj, str):
file_type = str(file_obj).split(".")[-1]
else:
raise ValueError("file_type not specified!")
# always remove whitespace and leading characters
file_type = file_type.strip().lower().lstrip(".")
# now handle our different scene export types
if file_type == "gltf":
data = export_gltf(scene, **kwargs)
elif file_type == "glb":
data = export_glb(scene, **kwargs)
elif file_type == "dict":
data = scene_to_dict(scene, *kwargs)
elif file_type == "obj":
# if we are exporting by name automatically create a
# resolver which lets the exporter write assets like
# the materials and textures next to the exported mesh
if resolver is None and isinstance(file_obj, str):
resolver = resolvers.FilePathResolver(file_obj)
data = export_obj(scene, resolver=resolver, **kwargs)
elif file_type == "dict64":
data = scene_to_dict(scene, use_base64=True)
elif file_type == "svg":
from trimesh.path.exchange import svg_io
data = svg_io.export_svg(scene, **kwargs)
elif file_type == "ply":
data = _mesh_exporters["ply"](scene.to_mesh(), **kwargs)
elif file_type == "stl":
data = export_stl(scene.to_mesh(), **kwargs)
elif file_type == "3mf":
data = _mesh_exporters["3mf"](scene, **kwargs)
else:
raise ValueError(f"unsupported export format: {file_type}")
# now write the data or return bytes of result
if isinstance(data, dict):
# GLTF files return a dict-of-bytes as they
# represent multiple files so create a filepath
# resolver and write the files if someone passed
# a path we can write to.
if resolver is None and isinstance(file_obj, str):
resolver = resolvers.FilePathResolver(file_obj)
# the requested "gltf"
bare_path = os.path.split(file_obj)[-1]
for name, blob in data.items():
if name == "model.gltf":
# write the root data to specified file
resolver.write(bare_path, blob)
else:
# write the supporting files
resolver.write(name, blob)
return data
if hasattr(file_obj, "write"):
# if it's just a regular file object
return util.write_encoded(file_obj, data)
elif isinstance(file_obj, str):
# assume strings are file paths
file_path = os.path.abspath(os.path.expanduser(file_obj))
with open(file_path, "wb") as f:
util.write_encoded(f, data)
# no writeable file object so return data
return data
_mesh_exporters = {
"stl": export_stl,
"dict": export_dict,
"glb": export_glb,
"obj": export_obj,
"gltf": export_gltf,
"dict64": export_dict64,
"stl_ascii": export_stl_ascii,
}
_mesh_exporters.update(_ply_exporters)
_mesh_exporters.update(_off_exporters)
_mesh_exporters.update(_collada_exporters)
_mesh_exporters.update(_xyz_exporters)
_mesh_exporters.update(_3mf_exporters)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,683 @@
import json
import os
from copy import deepcopy
import numpy as np
from .. import resolvers, util
from ..base import Trimesh
from ..exceptions import ExceptionWrapper
from ..parent import Geometry, LoadSource
from ..points import PointCloud
from ..scene.scene import Scene, append_scenes
from ..typed import Dict, Loadable, Optional, Set
from ..util import log
from . import misc
from .binvox import _binvox_loaders
from .cascade import _cascade_loaders
from .dae import _collada_loaders
from .gltf import _gltf_loaders
from .misc import _misc_loaders
from .obj import _obj_loaders
from .off import _off_loaders
from .ply import _ply_loaders
from .stl import _stl_loaders
from .threedxml import _threedxml_loaders
from .threemf import _three_loaders
from .xaml import _xaml_loaders
from .xyz import _xyz_loaders
try:
from ..path.exchange.load import load_path, path_formats
except BaseException as E:
# save a traceback to see why path didn't import
load_path = ExceptionWrapper(E)
# no path formats available
def path_formats() -> set:
return set()
def mesh_formats() -> Set[str]:
"""
Get a list of mesh formats available to load.
Returns
-----------
loaders
Extensions of available mesh loaders
i.e. `{'stl', 'ply'}`
"""
# filter out exceptionmodule loaders
return {k for k, v in mesh_loaders.items() if not isinstance(v, ExceptionWrapper)}
def available_formats() -> Set[str]:
"""
Get a list of all available loaders
Returns
-----------
loaders
Extensions of all available loaders
i.e. `{'stl', 'ply', 'dxf'}`
"""
loaders = mesh_formats()
loaders.update(path_formats())
loaders.update(compressed_loaders.keys())
return loaders
def load(
file_obj: Loadable,
file_type: Optional[str] = None,
resolver: Optional[resolvers.ResolverLike] = None,
force: Optional[str] = None,
allow_remote: bool = False,
**kwargs,
) -> Geometry:
"""
THIS FUNCTION IS DEPRECATED but there are no current plans for it to be removed.
For new code the typed load functions `trimesh.load_scene` or `trimesh.load_mesh`
are recommended over `trimesh.load` which is a backwards-compatibility wrapper
that mimics the behavior of the old function and can return any geometry type.
Parameters
-----------
file_obj : str, or file- like object
The source of the data to be loadeded
file_type: str
What kind of file type do we have (eg: 'stl')
resolver : trimesh.visual.Resolver
Object to load referenced assets like materials and textures
force : None or str
For 'mesh': try to coerce scenes into a single mesh
For 'scene': try to coerce everything into a scene
allow_remote
If True allow this load call to work on a remote URL.
kwargs : dict
Passed to geometry __init__
Returns
---------
geometry : Trimesh, Path2D, Path3D, Scene
Loaded geometry as trimesh classes
"""
# call the most general loading case into a `Scene`.
loaded = load_scene(
file_obj=file_obj,
file_type=file_type,
resolver=resolver,
allow_remote=allow_remote,
**kwargs,
)
if force == "mesh":
# new code should use `load_mesh` for this
log.debug(
"`trimesh.load(force='mesh')` is a compatibility wrapper for `trimesh.load_mesh`"
)
return loaded.to_mesh()
elif force == "scene":
# new code should use `load_scene` for this
log.debug(
"`trimesh.load(force='scene')` is a compatibility wrapper for `trimesh.load_scene`"
)
return loaded
###########################################
# we are matching old, deprecated behavior here!
kind = loaded.source.file_type
always_scene = {"glb", "gltf", "zip", "3dxml", "tar.gz"}
if kind not in always_scene and len(loaded.geometry) == 1:
geom = next(iter(loaded.geometry.values()))
geom.metadata.update(loaded.metadata)
if isinstance(geom, PointCloud) or kind in {
"obj",
"stl",
"ply",
"svg",
"binvox",
"xaml",
"dxf",
"off",
"msh",
}:
return geom
return loaded
def load_scene(
file_obj: Loadable,
file_type: Optional[str] = None,
resolver: Optional[resolvers.ResolverLike] = None,
allow_remote: bool = False,
metadata: Optional[Dict] = None,
**kwargs,
) -> Scene:
"""
Load geometry into the `trimesh.Scene` container. This may contain
any `parent.Geometry` object, including `Trimesh`, `Path2D`, `Path3D`,
or a `PointCloud`.
Parameters
-----------
file_obj : str, or file- like object
The source of the data to be loadeded
file_type: str
What kind of file type do we have (eg: 'stl')
resolver : trimesh.visual.Resolver
Object to load referenced assets like materials and textures
force : None or str
For 'mesh': try to coerce scenes into a single mesh
For 'scene': try to coerce everything into a scene
allow_remote
If True allow this load call to work on a remote URL.
kwargs : dict
Passed to geometry __init__
Returns
---------
geometry : Trimesh, Path2D, Path3D, Scene
Loaded geometry as trimesh classes
"""
# parse all possible values of file objects into simple types
arg = _parse_file_args(
file_obj=file_obj,
file_type=file_type,
resolver=resolver,
allow_remote=allow_remote,
)
try:
if isinstance(file_obj, dict):
# we've been passed a dictionary so treat them as keyword arguments
loaded = _load_kwargs(file_obj)
elif arg.file_type in path_formats():
# use path loader
loaded = load_path(
file_obj=arg.file_obj,
file_type=arg.file_type,
metadata=metadata,
**kwargs,
)
elif arg.file_type in mesh_loaders:
# use mesh loader
parsed = deepcopy(kwargs)
parsed.update(
mesh_loaders[arg.file_type](
file_obj=arg.file_obj,
file_type=arg.file_type,
resolver=arg.resolver,
metadata=metadata,
**kwargs,
)
)
loaded = _load_kwargs(**parsed)
elif arg.file_type in compressed_loaders:
# for archives, like ZIP files
loaded = _load_compressed(arg.file_obj, file_type=arg.file_type, **kwargs)
elif arg.file_type in voxel_loaders:
loaded = voxel_loaders[arg.file_type](
file_obj=arg.file_obj,
file_type=arg.file_type,
resolver=arg.resolver,
**kwargs,
)
else:
raise NotImplementedError(f"file_type '{arg.file_type}' not supported")
finally:
# if we opened the file ourselves from a file name
# close any opened files even if we crashed out
if arg.was_opened:
arg.file_obj.close()
if not isinstance(loaded, Scene):
# file name may be used for nodes
loaded._source = arg
loaded = Scene(loaded)
# add on the loading information
loaded._source = arg
for g in loaded.geometry.values():
g._source = arg
return loaded
def load_mesh(*args, **kwargs) -> Trimesh:
"""
Load a file into a Trimesh object.
Parameters
-----------
file_obj : str or file object
File name or file with mesh data
file_type : str or None
Which file type, e.g. 'stl'
kwargs : dict
Passed to Trimesh constructor
Returns
----------
mesh
Loaded geometry data.
"""
return load_scene(*args, **kwargs).to_mesh()
def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwargs):
"""
Given a compressed archive load all the geometry that
we can from it.
Parameters
----------
file_obj : open file-like object
Containing compressed data
file_type : str
Type of the archive file
mixed : bool
If False, for archives containing both 2D and 3D
data will only load the 3D data into the Scene.
Returns
----------
scene : trimesh.Scene
Geometry loaded in to a Scene object
"""
# parse the file arguments into clean loadable form
arg = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
# store loaded geometries as a list
geometries = []
# so loaders can access textures/etc
archive = util.decompress(file_obj=arg.file_obj, file_type=arg.file_type)
resolver = resolvers.ZipResolver(archive)
# try to save the files with meaningful metadata
# archive_name = arg.file_path or "archive"
meta_archive = {}
# populate our available formats
if mixed:
available = available_formats()
else:
# all types contained in ZIP archive
contains = {util.split_extension(n).lower() for n in resolver.keys()}
# if there are no mesh formats available
if contains.isdisjoint(mesh_formats()):
available = path_formats()
else:
available = mesh_formats()
for file_name, file_obj in archive.items():
try:
# only load formats that we support
compressed_type = util.split_extension(file_name).lower()
# if file has metadata type include it
if compressed_type in ("yaml", "yml"):
import yaml
continue
meta_archive[file_name] = yaml.safe_load(file_obj)
elif compressed_type == "json":
import json
meta_archive[file_name] = json.load(file_obj)
continue
elif compressed_type not in available:
# don't raise an exception, just try the next one
continue
# load the individual geometry
geometries.append(
load_scene(
file_obj=file_obj,
file_type=compressed_type,
resolver=resolver,
**kwargs,
)
)
except BaseException:
log.debug("failed to load file in zip", exc_info=True)
# if we opened the file in this function
# clean up after ourselves
if arg.was_opened:
arg.file_obj.close()
# append meshes or scenes into a single Scene object
result = append_scenes(geometries)
# append any archive metadata files
if isinstance(result, Scene):
result.metadata.update(meta_archive)
return result
def load_remote(url: str, **kwargs) -> Scene:
"""
Load a mesh at a remote URL into a local trimesh object.
This is a thin wrapper around:
`trimesh.load_scene(file_obj=url, allow_remote=True, **kwargs)`
Parameters
------------
url
URL containing mesh file
**kwargs
Passed to `load_scene`
Returns
------------
loaded : Trimesh, Path, Scene
Loaded result
"""
return load_scene(file_obj=url, allow_remote=True, **kwargs)
def _load_kwargs(*args, **kwargs) -> Geometry:
"""
Load geometry from a properly formatted dict or kwargs
"""
def handle_scene() -> Scene:
"""
Load a scene from our kwargs.
class: Scene
geometry: dict, name: Trimesh kwargs
graph: list of dict, kwargs for scene.graph.update
base_frame: str, base frame of graph
"""
graph = kwargs.get("graph", None)
geometry = {k: _load_kwargs(v) for k, v in kwargs["geometry"].items()}
if graph is not None:
scene = Scene()
scene.geometry.update(geometry)
for k in graph:
if isinstance(k, dict):
scene.graph.update(**k)
elif util.is_sequence(k) and len(k) == 3:
scene.graph.update(k[1], k[0], **k[2])
else:
scene = Scene(geometry)
# camera, if it exists
camera = kwargs.get("camera")
if camera:
scene.camera = camera
scene.camera_transform = kwargs.get("camera_transform")
if "base_frame" in kwargs:
scene.graph.base_frame = kwargs["base_frame"]
metadata = kwargs.get("metadata")
if isinstance(metadata, dict):
scene.metadata.update(kwargs["metadata"])
elif isinstance(metadata, str):
# some ways someone might have encoded a string
# note that these aren't evaluated until we
# actually call the lambda in the loop
candidates = [
lambda: json.loads(metadata),
lambda: json.loads(metadata.replace("'", '"')),
]
for c in candidates:
try:
scene.metadata.update(c())
break
except BaseException:
pass
elif metadata is not None:
log.warning("unloadable metadata")
return scene
def handle_mesh() -> Trimesh:
"""
Handle the keyword arguments for a Trimesh object
"""
# if they've been serialized as a dict
if isinstance(kwargs["vertices"], dict) or isinstance(kwargs["faces"], dict):
return Trimesh(**misc.load_dict(kwargs))
# otherwise just load that puppy
return Trimesh(**kwargs)
def handle_export():
"""
Handle an exported mesh.
"""
data, file_type = kwargs["data"], kwargs["file_type"]
if isinstance(data, dict):
return _load_kwargs(data)
elif file_type in mesh_loaders:
return Trimesh(**mesh_loaders[file_type](data, file_type=file_type))
raise NotImplementedError(f"`{file_type}` is not supported")
def handle_path():
from ..path import Path2D, Path3D
shape = np.shape(kwargs["vertices"])
if len(shape) < 2:
return Path2D()
if shape[1] == 2:
return Path2D(**kwargs)
elif shape[1] == 3:
return Path3D(**kwargs)
else:
raise ValueError("Vertices must be 2D or 3D!")
def handle_pointcloud():
return PointCloud(**kwargs)
# if we've been passed a single dict instead of kwargs
# substitute the dict for kwargs
if len(kwargs) == 0 and len(args) == 1 and isinstance(args[0], dict):
kwargs = args[0]
# (function, tuple of expected keys)
# order is important
handlers = (
(handle_scene, ("geometry",)),
(handle_mesh, ("vertices", "faces")),
(handle_path, ("entities", "vertices")),
(handle_pointcloud, ("vertices",)),
(handle_export, ("file_type", "data")),
)
# filter out keys with a value of None
kwargs = {k: v for k, v in kwargs.items() if v is not None}
# loop through handler functions and expected key
for func, expected in handlers:
if all(i in kwargs for i in expected):
# all expected kwargs exist
return func()
raise ValueError(f"unable to determine type: {kwargs.keys()}")
def _parse_file_args(
file_obj,
file_type: Optional[str],
resolver: Optional[resolvers.ResolverLike] = None,
allow_remote: bool = False,
**kwargs,
) -> LoadSource:
"""
Given a file_obj and a file_type try to magically convert
arguments to a file-like object and a lowercase string of
file type.
Parameters
-----------
file_obj : str
if string represents a file path, returns:
file_obj: an 'rb' opened file object of the path
file_type: the extension from the file path
if string is NOT a path, but has JSON-like special characters:
file_obj: the same string passed as file_obj
file_type: set to 'json'
if string is a valid-looking URL
file_obj: an open 'rb' file object with retrieved data
file_type: from the extension
if string is none of those:
raise ValueError as we can't do anything with input
if file like object:
ValueError will be raised if file_type is None
file_obj: same as input
file_type: same as input
if other object: like a shapely.geometry.Polygon, etc:
file_obj: same as input
file_type: if None initially, set to the class name
(in lower case), otherwise passed through
file_type : str
type of file and handled according to above
Returns
-----------
args
Populated `_FileArg` message
"""
# try to save a file path from various inputs
file_path = None
# keep track if we opened a file ourselves and thus are
# responsible for closing it at the end of loading
was_opened = False
if util.is_pathlib(file_obj):
# convert pathlib objects to string
file_obj = str(file_obj.absolute())
if util.is_file(file_obj) and file_type is None:
raise ValueError("`file_type` must be set for file objects!")
if isinstance(file_obj, str):
try:
# clean up file path to an absolute location
file_path = os.path.abspath(os.path.expanduser(file_obj))
# check to see if this path exists
exists = os.path.isfile(file_path)
except BaseException:
exists = False
file_path = None
# file obj is a string which exists on filesystm
if exists:
# if not passed create a resolver to find other files
if resolver is None:
resolver = resolvers.FilePathResolver(file_path)
# save the file name and path to metadata
# if file_obj is a path that exists use extension as file_type
if file_type is None:
file_type = util.split_extension(file_path, special=["tar.gz", "tar.bz2"])
# actually open the file
file_obj = open(file_path, "rb")
# save that we opened it so we can cleanup later
was_opened = True
else:
if "{" in file_obj:
# if a bracket is in the string it's probably straight JSON
file_type = "json"
file_obj = util.wrap_as_stream(file_obj)
elif "https://" in file_obj or "http://" in file_obj:
if not allow_remote:
raise ValueError("unable to load URL with `allow_remote=False`")
import urllib
# remove the url-safe encoding and query params
file_type = util.split_extension(
urllib.parse.unquote(file_obj).split("?", 1)[0].split("/")[-1].strip()
)
# create a web resolver to do the fetching and whatnot
resolver = resolvers.WebResolver(url=file_obj)
# fetch the base file
file_obj = util.wrap_as_stream(resolver.get_base())
elif file_type is None:
raise ValueError(f"string is not a file: `{file_obj}`")
if isinstance(file_type, str) and "." in file_type:
# if someone has passed the whole filename as the file_type
# use the file extension as the file_type
path = os.path.abspath(os.path.expanduser(file_type))
file_type = util.split_extension(file_type)
if os.path.exists(path):
file_path = path
if resolver is None:
resolver = resolvers.FilePathResolver(file_path)
# all our stored extensions reference in lower case
if file_type is not None:
file_type = file_type.lower()
# if we still have no resolver try using file_obj name
if (
resolver is None
and hasattr(file_obj, "name")
and file_obj.name is not None
and len(file_obj.name) > 0
):
resolver = resolvers.FilePathResolver(file_obj.name)
return LoadSource(
file_obj=file_obj,
file_type=file_type,
file_path=file_path,
was_opened=was_opened,
resolver=resolver,
)
# loader functions for compressed extensions
compressed_loaders = {
"zip": _load_compressed,
"tar.bz2": _load_compressed,
"tar.gz": _load_compressed,
"bz2": _load_compressed,
}
# map file_type to loader function
mesh_loaders = {}
mesh_loaders.update(_misc_loaders)
mesh_loaders.update(_stl_loaders)
mesh_loaders.update(_ply_loaders)
mesh_loaders.update(_obj_loaders)
mesh_loaders.update(_off_loaders)
mesh_loaders.update(_collada_loaders)
mesh_loaders.update(_gltf_loaders)
mesh_loaders.update(_xaml_loaders)
mesh_loaders.update(_threedxml_loaders)
mesh_loaders.update(_three_loaders)
mesh_loaders.update(_xyz_loaders)
mesh_loaders.update(_cascade_loaders)
# collect loaders which return voxel types
voxel_loaders = {}
voxel_loaders.update(_binvox_loaders)
@@ -0,0 +1,153 @@
import json
from tempfile import NamedTemporaryFile
from .. import util
from ..exceptions import ExceptionWrapper
def load_dict(file_obj, **kwargs):
"""
Load multiple input types into kwargs for a Trimesh constructor.
Tries to extract keys:
'faces'
'vertices'
'face_normals'
'vertex_normals'
Parameters
----------
file_obj : dict
accepts multiple forms
-dict: has keys for vertices and faces as (n,3) numpy arrays
-dict: has keys for vertices/faces (n,3) arrays encoded as dicts/base64
with trimesh.util.array_to_encoded/trimesh.util.encoded_to_array
-str: json blob as dict with either straight array or base64 values
-file object: json blob of dict
file_type: not used
Returns
-----------
loaded: dict with keys
-vertices: (n,3) float
-faces: (n,3) int
-face_normals: (n,3) float (optional)
"""
if file_obj is None:
raise ValueError("file_obj passed to load_dict was None!")
if util.is_instance_named(file_obj, "Trimesh"):
return file_obj
if isinstance(file_obj, str):
if "{" not in file_obj:
raise ValueError("Object is not a JSON encoded dictionary!")
file_obj = json.loads(file_obj.decode("utf-8"))
elif util.is_file(file_obj):
file_obj = json.load(file_obj)
# what shape should the file_obj be to be usable
mesh_file_obj = {
"vertices": (-1, 3),
"faces": (-1, (3, 4)),
"face_normals": (-1, 3),
"face_colors": (-1, (3, 4)),
"vertex_normals": (-1, 3),
"vertex_colors": (-1, (3, 4)),
}
# now go through file_obj structure and if anything is encoded as base64
# pull it back into numpy arrays
if not isinstance(file_obj, dict):
raise ValueError(f"`{type(file_obj)}` object passed to dict loader!")
loaded = {}
file_obj = util.decode_keys(file_obj, "utf-8")
for key, shape in mesh_file_obj.items():
if key in file_obj:
loaded[key] = util.encoded_to_array(file_obj[key])
if not util.is_shape(loaded[key], shape):
raise ValueError(
"Shape of %s is %s, not %s!",
key,
str(loaded[key].shape),
str(shape),
)
if len(loaded) == 0:
raise ValueError("Unable to extract a mesh from the dict!")
return loaded
def load_meshio(file_obj, file_type: str, **kwargs):
"""
Load a meshio-supported file into the kwargs for a Trimesh
constructor.
Parameters
----------
file_obj : file object
Contains a meshio file
file_type : str
File extension, aka 'vtk'
Returns
----------
loaded : dict
kwargs for Trimesh constructor
"""
# trimesh "file types" are really filename extensions
# meshio may return multiple answers for each file extension
file_formats = meshio.extension_to_filetypes["." + file_type]
mesh = None
exceptions = []
# meshio appears to only support loading by file name so use a tempfile
with NamedTemporaryFile(suffix=f".{file_type}") as temp:
temp.write(file_obj.read())
temp.flush()
# try the loaders in order
for file_format in file_formats:
try:
mesh = meshio.read(temp.name, file_format=file_format)
break
except BaseException as E:
exceptions.append(str(E))
if mesh is None:
raise ValueError("Failed to load file:" + "\n".join(exceptions))
# save file_obj as kwargs for a trimesh.Trimesh
result = {}
# pass kwargs to mesh constructor
result.update(kwargs)
# add vertices
result["vertices"] = mesh.points
try:
# add faces
result["faces"] = mesh.get_cells_type("triangle")
except BaseException:
util.log.warning("unable to get faces", exc_info=True)
result["faces"] = []
return result
_misc_loaders = {"dict": load_dict, "dict64": load_dict}
_misc_loaders = {}
try:
import meshio
# add meshio loaders here
_meshio_loaders = {k[1:]: load_meshio for k in meshio.extension_to_filetypes.keys()}
_misc_loaders.update(_meshio_loaders)
except BaseException:
_meshio_loaders = {}
try:
import openctm
_misc_loaders["ctm"] = openctm.load_ctm
except BaseException as E:
_misc_loaders["ctm"] = ExceptionWrapper(E)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
import re
import numpy as np
from ..geometry import triangulate_quads
from ..util import array_to_string, comment_strip, decode_text
def load_off(file_obj, **kwargs) -> dict:
"""
Load an OFF file into the kwargs for a Trimesh constructor.
Parameters
----------
file_obj : file object
Contains an OFF file
Returns
----------
loaded : dict
kwargs for Trimesh constructor
"""
text = file_obj.read()
# will magically survive weird encoding sometimes
# comment strip will handle all cases of commenting
text = comment_strip(decode_text(text)).strip()
# split the first key
_, header, raw = re.split("(COFF|OFF)", text, maxsplit=1)
if header.upper() not in ["OFF", "COFF"]:
raise NameError(f"Not an OFF file! Header was: `{header}`")
# split into lines and remove whitespace
splits = [i.strip() for i in str.splitlines(str(raw))]
# remove empty lines
splits = [i for i in splits if len(i) > 0]
# the first non-comment line should be the counts
header = np.array(splits[0].split(), dtype=np.int64)
vertex_count, face_count = header[:2]
vertices = np.array(
[i.split()[:3] for i in splits[1 : vertex_count + 1]], dtype=np.float64
)
# will fail if incorrect number of vertices loaded
vertices = vertices.reshape((vertex_count, 3))
# get lines with face data
faces = [i.split() for i in splits[vertex_count + 1 : vertex_count + face_count + 1]]
# the first value is count
faces = [line[1 : int(line[0]) + 1] for line in faces]
faces = triangulate_quads(faces)
# save data as kwargs for a trimesh.Trimesh
kwargs = {"vertices": vertices, "faces": faces}
return kwargs
def export_off(mesh, digits=10) -> str:
"""
Export a mesh as an OFF file, a simple text format
Parameters
-----------
mesh : trimesh.Trimesh
Geometry to export
digits : int
Number of digits to include on floats
Returns
-----------
export : str
OFF format output
"""
# make sure specified digits is an int
digits = int(digits)
# prepend a 3 (face count) to each face
faces_stacked = np.column_stack((np.ones(len(mesh.faces)) * 3, mesh.faces)).astype(
np.int64
)
# the header is vertex count, face count, another number
export = "\n".join(
[
"OFF",
str(len(mesh.vertices)) + " " + str(len(mesh.faces)) + " 0",
array_to_string(mesh.vertices, col_delim=" ", row_delim="\n", digits=digits),
array_to_string(faces_stacked, col_delim=" ", row_delim="\n"),
"",
]
)
return export
_off_loaders = {"off": load_off}
_off_exporters = {"off": export_off}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
import numpy as np
from .. import util
class HeaderError(Exception):
# the exception raised if an STL file object doesn't match its header
pass
# define a numpy datatype for the data section of a binary STL file
# everything in STL is always Little Endian
# this works natively on Little Endian systems, but blows up on Big Endians
# so we always specify byteorder
_stl_dtype = np.dtype(
[("normals", "<f4", (3)), ("vertices", "<f4", (3, 3)), ("attributes", "<u2")]
)
# define a numpy datatype for the header of a binary STL file
_stl_dtype_header = np.dtype([("header", np.void, 80), ("face_count", "<u4")])
def load_stl(file_obj, **kwargs):
"""
Load an STL file from a file object.
Parameters
----------
file_obj : open file-like object
Containing STL data
Returns
----------
loaded : dict
kwargs for a Trimesh constructor with keys:
vertices: (n,3) float, vertices
faces: (m,3) int, indexes of vertices
face_normals: (m,3) float, normal vector of each face
"""
# save start of file obj
file_pos = file_obj.tell()
try:
# check the file for a header which matches the file length
# if that is true, it is almost certainly a binary STL file
# if the header doesn't match the file length a HeaderError will be
# raised
return load_stl_binary(file_obj)
except HeaderError:
# move the file back to where it was initially
file_obj.seek(file_pos)
# try to load the file as an ASCII STL
# if the header doesn't match the file length
# HeaderError will be raised
return load_stl_ascii(file_obj)
def load_stl_binary(file_obj):
"""
Load a binary STL file from a file object.
Parameters
----------
file_obj : open file- like object
Containing STL data
Returns
----------
loaded: kwargs for a Trimesh constructor with keys:
vertices: (n,3) float, vertices
faces: (m,3) int, indexes of vertices
face_normals: (m,3) float, normal vector of each face
"""
# the header is always 84 bytes long, we just reference the dtype.itemsize
# to be explicit about where that magical number comes from
header_length = _stl_dtype_header.itemsize
header_data = file_obj.read(header_length)
if len(header_data) < header_length:
raise HeaderError("Binary STL shorter than a fixed header!")
try:
header = np.frombuffer(header_data, dtype=_stl_dtype_header)
except BaseException:
raise HeaderError("Binary header incorrect type")
try:
# save the header block as a string
# there could be any garbage in there so wrap in try
metadata = {"header": util.decode_text(bytes(header["header"][0])).strip()}
except BaseException:
metadata = {}
# now we check the length from the header versus the length of the file
# data_start should always be position 84, but hard coding that felt ugly
data_start = file_obj.tell()
# this seeks to the end of the file
# position 0, relative to the end of the file 'whence=2'
file_obj.seek(0, 2)
# we save the location of the end of the file and seek back to where we
# started from
data_end = file_obj.tell()
file_obj.seek(data_start)
# the binary format has a rigidly defined structure, and if the length
# of the file doesn't match the header, the loaded version is almost
# certainly going to be garbage.
len_data = data_end - data_start
len_expected = header["face_count"] * _stl_dtype.itemsize
# this check is to see if this really is a binary STL file.
# if we don't do this and try to load a file that isn't structured properly
# we will be producing garbage or crashing hard
# so it's much better to raise an exception here.
if len_data != len_expected:
raise HeaderError(
f"Binary STL has incorrect length in header: {len_data} vs {len_expected}"
)
blob = np.frombuffer(file_obj.read(), dtype=_stl_dtype)
# return empty geometry if there are no vertices
if not len(blob["vertices"]):
return {"geometry": {}}
# all of our vertices will be loaded in order
# so faces are just sequential indices reshaped.
faces = np.arange(header["face_count"][0] * 3).reshape((-1, 3))
# there are two bytes per triangle saved for anything
# which is sometimes used for face color
result = {
"vertices": blob["vertices"].reshape((-1, 3)),
"face_normals": blob["normals"].reshape((-1, 3)),
"faces": faces,
"face_attributes": {"stl": blob["attributes"]},
"metadata": metadata,
}
return result
def load_stl_ascii(file_obj):
"""
Load an ASCII STL file from a file object.
Parameters
----------
file_obj : open file- like object
Containing input data
Returns
----------
loaded : dict
kwargs for a Trimesh constructor with keys:
vertices: (n, 3) float, vertices
faces: (m, 3) int, indexes of vertices
face_normals: (m, 3) float, normal vector of each face
"""
# read all text into one string
raw_mixed = util.decode_text(file_obj.read()).strip()
# convert to lower case for solids and name capture
raw_lower = raw_mixed.lower()
# collect the keyword arguments for the Trimesh constructor
kwargs = {}
# keep track of our position in the file
position = 0
# use a for loop to avoid any possibility of infinite looping
for _ in range(len(raw_mixed)):
# find the start of the solid chunk
solid_start = raw_lower.find("solid", position)
# find the end of the solid chunk
solid_end = raw_lower.find("endsolid", position)
# on the next loop we don't have to check the text we've consumed
position = solid_end + len("endsolid")
# delimiter wasn't found for a chunk so exit
if solid_end < 0 or solid_start < 0:
break
# end delimiter order is wrong so this file is very malformed
if solid_start > solid_end:
raise ValueError("`endsolid` precedes `solid`!")
# get the chunk of text with this particular solid
solid = raw_lower[solid_start:solid_end]
# extract the vertices
vertex_text = solid.split("vertex")
vertices = np.fromstring(
" ".join(line[: line.find("\n")] for line in vertex_text[1:]),
sep=" ",
dtype=np.float64,
)
if len(vertices) < 3:
continue
if len(vertices) % 3 != 0:
raise ValueError("incorrect number of vertices")
# reshape vertices to final 3D shape
vertices = vertices.reshape((-1, 3))
faces = np.arange(len(vertices)).reshape((-1, 3))
# try to extract the face normals the same way
face_normals = None
try:
normal_text = solid.split("normal")
normals = np.fromstring(
" ".join(line[: line.find("\n")] for line in normal_text[1:]),
sep=" ",
dtype=np.float64,
)
if len(normals) == len(vertices):
face_normals = normals.reshape((-1, 3))
except BaseException:
util.log.warning("failed to extract face_normals", exc_info=True)
try:
# Previously checked to make sure there was matching 'solid' for 'endsolid'
# the name is right after the `solid` keyword if it exists
name = raw_mixed[solid_start : solid_start + solid.find("\n")][6:].strip()
except BaseException:
# will be filled in by unique_name
name = None
# make sure geometry has a unique name for the scene
name = util.unique_name(name, kwargs)
# save the constructor arguments
kwargs[name] = {
"vertices": vertices.reshape((-1, 3)),
"face_normals": face_normals,
"faces": faces,
"metadata": {"name": name},
}
if len(kwargs) == 1:
return next(iter(kwargs.values()))
return {"geometry": kwargs}
def export_stl(mesh) -> bytes:
"""
Convert a Trimesh object into a binary STL file.
Parameters
---------
mesh
Trimesh object to export.
Returns
---------
export
Represents mesh in binary STL form
"""
header = np.zeros(1, dtype=_stl_dtype_header)
if hasattr(mesh, "faces"):
header["face_count"] = len(mesh.faces)
export = header.tobytes()
if hasattr(mesh, "faces"):
packed = np.zeros(len(mesh.faces), dtype=_stl_dtype)
packed["normals"] = mesh.face_normals
packed["vertices"] = mesh.triangles
export += packed.tobytes()
return export
def export_stl_ascii(mesh) -> str:
"""
Convert a Trimesh object into an ASCII STL file.
Parameters
---------
mesh : trimesh.Trimesh
Returns
---------
export
Mesh represented as an ASCII STL file
"""
# move all the data that's going into the STL file into one array
blob = np.zeros((len(mesh.faces), 4, 3))
blob[:, 0, :] = mesh.face_normals
blob[:, 1:, :] = mesh.triangles
# create a lengthy format string for the data section of the file
formatter = (
"\n".join(
[
"facet normal {} {} {}",
"outer loop",
"vertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}",
"endloop",
"endfacet",
"",
]
)
) * len(mesh.faces)
# try applying the name from metadata if it exists
name = mesh.metadata.get("name", "")
if not isinstance(name, str):
name = ""
if len(name) > 80 or "\n" in name:
name = ""
# concatenate the header, data, and footer, and a new line
return "\n".join([f"solid {name}", formatter.format(*blob.reshape(-1)), "endsolid\n"])
_stl_loaders = {"stl": load_stl, "stl_ascii": load_stl}
@@ -0,0 +1,418 @@
"""
threedxml.py
-------------
Load 3DXML files, a scene format from Dassault products like Solidworks, Abaqus, Catia
"""
import numpy as np
try:
# `pip install pillow`
# optional: used for textured meshes
from PIL import Image
except BaseException as E:
# if someone tries to use Image re-raise
# the import error so they can debug easily
from ..exceptions import ExceptionWrapper
Image = ExceptionWrapper(E)
import collections
import json
from .. import util
from ..visual.texture import TextureVisuals
def load_3DXML(file_obj, *args, **kwargs):
"""
Load a 3DXML scene into kwargs. 3DXML is a CAD format
that can be exported from Solidworks
Parameters
------------
file_obj : file object
Open and containing 3DXML data
Returns
-----------
kwargs : dict
Can be passed to trimesh.exchange.load.load_kwargs
"""
archive = util.decompress(file_obj, file_type="zip")
# a dictionary of file name : lxml etree
as_etree = {}
for k, v in archive.items():
# wrap in try statement, as sometimes 3DXML
# contains non- xml files, like JPG previews
try:
as_etree[k] = etree.XML(v.read())
except etree.XMLSyntaxError:
# move the file object back to the file start
v.seek(0)
# the file name of the root scene
root_file = as_etree["Manifest.xml"].find("{*}Root").text
# the etree of the scene layout
tree = as_etree[root_file]
# index of root element of directed acyclic graph
root_id = tree.find("{*}ProductStructure").attrib["root"]
# load the materials library from the materials elements
colors = {}
images = {}
# but only if it exists
material_key = "CATMaterialRef.3dxml"
if material_key in as_etree:
material_tree = as_etree[material_key]
for MaterialDomain in material_tree.iter("{*}MaterialDomain"):
material_id = MaterialDomain.attrib["id"]
material_file = MaterialDomain.attrib["associatedFile"].split("urn:3DXML:")[
-1
]
rend = as_etree[material_file].find("{*}Feature[@Alias='RenderingFeature']")
diffuse = rend.find("{*}Attr[@Name='DiffuseColor']")
# specular = rend.find("{*}Attr[@Name='SpecularColor']")
# emissive = rend.find("{*}Attr[@Name='EmissiveColor']")
if diffuse is not None:
rgb = (np.array(json.loads(diffuse.attrib["Value"])) * 255).astype(
np.uint8
)
colors[material_id] = rgb
texture = rend.find("{*}Attr[@Name='TextureImage']")
if texture is not None:
tex_file, tex_id = texture.attrib["Value"].split(":")[-1].split("#")
rep_image = as_etree[tex_file].find(
f"{{*}}CATRepImage/{{*}}CATRepresentationImage[@id='{tex_id}']"
)
if rep_image is not None:
image_file = rep_image.get("associatedFile", "").split(":")[-1]
images[material_id] = Image.open(archive[image_file])
# copy indexes for instances of colors
for MaterialDomainInstance in material_tree.iter("{*}MaterialDomainInstance"):
instance = MaterialDomainInstance.find("{*}IsInstanceOf")
# colors[b.attrib['id']] = colors[instance.text]
for aggregate in MaterialDomainInstance.findall("{*}IsAggregatedBy"):
colors[aggregate.text] = colors.get(instance.text)
images[aggregate.text] = images.get(instance.text)
# references which hold the 3DXML scene structure as a dict
# element id : {key : value}
references = collections.defaultdict(dict)
def get_rgba(color):
"""
Return (4,) uint8 color array defined by Color element attributes.
Parameters
-----------
color : lxml.Element
Element containing RGBA colors.
Returns
-----------
as_int : (4,) np.uint8
Colors as uint8 RGBA.
"""
assert "RGBAColorType" in color.attrib.values()
# colors will be float 0.0 - 1.0
rgba = np.array(
[color.get(channel, 1.0) for channel in ("red", "green", "blue", "alpha")],
dtype=np.float64,
)
# convert to int colors
return (rgba * 255).astype(np.uint8)
# the 3DXML can specify different visual properties for occurrences
view = tree.find("{*}DefaultView")
if view is not None:
for ViewProp in view.iter("{*}DefaultViewProperty"):
color = ViewProp.find(
"{*}GraphicProperties/" + "{*}SurfaceAttributes/{*}Color"
)
if color is None:
continue
rgba = get_rgba(color)
for occurrence in ViewProp.findall("{*}OccurenceId/{*}id"):
reference_id = occurrence.text.split("#")[-1]
references[reference_id]["color"] = rgba
# geometries will hold meshes
geometries = {}
# get geometry
for ReferenceRep in tree.iter(tag="{*}ReferenceRep"):
# the str of an int that represents this meshes unique ID
part_id = ReferenceRep.attrib["id"]
# which part file in the archive contains the geometry we care about
part_file = ReferenceRep.attrib["associatedFile"].split(":")[-1]
# the format of the geometry file
part_format = ReferenceRep.attrib["format"]
if part_format not in ("TESSELLATED",):
util.log.warning(
f"ReferenceRep {part_file!r} unsupported format {part_format!r}"
)
continue
# load actual geometry
mesh_faces = []
mesh_colors = []
mesh_normals = []
mesh_vertices = []
mesh_uv = []
mesh_image = None
if part_file not in as_etree and part_file in archive:
# the data is stored in some binary format
util.log.warning(f"unable to load Rep {part_file!r}")
# data = archive[part_file]
continue
# the geometry is stored in a Rep
for Rep in as_etree[part_file].iter("{*}Rep"):
rep_faces = [] # faces sharing the same list of vertices
vertices = Rep.find("{*}VertexBuffer/{*}Positions")
if vertices is None:
continue
# they mix delimiters like we couldn't figure it out from the
# shape :(
# load vertices into (n, 3) float64
mesh_vertices.append(
np.fromstring(
vertices.text.replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 3))
)
# load vertex normals into (n, 3) float64
normals = Rep.find("{*}VertexBuffer/{*}Normals")
mesh_normals.append(
np.fromstring(
normals.text.replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 3))
)
uv = Rep.find("{*}VertexBuffer/{*}TextureCoordinates")
if uv is not None: # texture coordinates are available
rep_uv = np.fromstring(
uv.text.replace(",", " "), sep=" ", dtype=np.float64
)
if "1D" == uv.get("dimension"):
mesh_uv.append(np.stack([rep_uv, np.zeros(len(rep_uv))], axis=1))
else: # 2D
mesh_uv.append(rep_uv.reshape(-1, 2))
material = Rep.find(
"{*}SurfaceAttributes/" + "{*}MaterialApplication/" + "{*}MaterialId"
)
if material is None:
material_id = None
else:
(material_file, material_id) = (
material.attrib["id"].split("urn:3DXML:")[-1].split("#")
)
mesh_image = images.get(material_id) # texture for this Rep, if any
for faces in Rep.iter("{*}Faces"):
triangles = [] # mesh triangles for this Faces element
for face in faces.iter("{*}Face"):
# Each Face may have optional strips, triangles or fans attributes
if "strips" in face.attrib:
# triangle strips, sequence of arbitrary length lists
# np.fromstring is substantially faster than np.array(i.split())
# inside the list comprehension
strips = [
np.fromstring(i, sep=" ", dtype=np.int64)
for i in face.attrib["strips"].split(",")
]
# convert strips to (m, 3) int triangles
triangles.extend(util.triangle_strips_to_faces(strips))
if "triangles" in face.attrib:
triangles.extend(
np.fromstring(
face.attrib["triangles"], sep=" ", dtype=np.int64
).reshape((-1, 3))
)
if "fans" in face.attrib:
fans = [
np.fromstring(i, sep=" ", dtype=np.int64)
for i in face.attrib["fans"].split(",")
]
# convert fans to (m, 3) int triangles
triangles.extend(util.triangle_fans_to_faces(fans))
rep_faces.extend(triangles)
# store the material information as (m, 3) uint8 FACE COLORS
faceColor = colors.get(material_id, [128, 128, 128])
# each Face may have its own color
colorElement = face.find("{*}SurfaceAttributes/{*}Color")
if colorElement is not None:
faceColor = get_rgba(colorElement)[:3]
mesh_colors.append(np.tile(faceColor, (len(triangles), 1)))
mesh_faces.append(rep_faces)
# save each mesh as the kwargs for a trimesh.Trimesh constructor
# aka, a Trimesh object can be created with trimesh.Trimesh(**mesh)
# this avoids needing trimesh- specific imports in this IO function
mesh = {}
(mesh["vertices"], mesh["faces"]) = util.append_faces(mesh_vertices, mesh_faces)
mesh["vertex_normals"] = np.vstack(mesh_normals)
if mesh_uv and mesh_image:
mesh["visual"] = TextureVisuals(uv=np.vstack(mesh_uv), image=mesh_image)
else:
mesh["face_colors"] = np.vstack(mesh_colors)
# as far as I can tell, all 3DXML files are exported as
# implicit millimeters (it isn't specified in the file)
mesh["metadata"] = {"units": "mm"}
mesh["class"] = "Trimesh"
geometries[part_id] = mesh
references[part_id]["geometry"] = part_id
# a Reference3D maps to a subassembly or assembly
for Reference3D in tree.iter("{*}Reference3D"):
references[Reference3D.attrib["id"]] = {
"name": Reference3D.attrib["name"],
"type": "Reference3D",
}
# a node that is the connectivity between a geometry and the Reference3D
for InstanceRep in tree.iter("{*}InstanceRep"):
current = InstanceRep.attrib["id"]
instance = InstanceRep.find("{*}IsInstanceOf").text
aggregate = InstanceRep.find("{*}IsAggregatedBy").text
references[current].update(
{"aggregate": aggregate, "instance": instance, "type": "InstanceRep"}
)
# an Instance3D maps basically to a part
for Instance3D in tree.iter("{*}Instance3D"):
matrix = np.eye(4)
relative = Instance3D.find("{*}RelativeMatrix")
if relative is not None:
relative = np.array(relative.text.split(), dtype=np.float64)
# rotation component
matrix[:3, :3] = relative[:9].reshape((3, 3)).T
# translation component
matrix[:3, 3] = relative[9:]
current = Instance3D.attrib["id"]
name = Instance3D.attrib["name"]
instance = Instance3D.find("{*}IsInstanceOf").text
aggregate = Instance3D.find("{*}IsAggregatedBy").text
references[current].update(
{
"aggregate": aggregate,
"instance": instance,
"matrix": matrix,
"name": name,
"type": "Instance3D",
}
)
# turn references into directed graph for path finding
graph = nx.DiGraph()
for k, v in references.items():
# IsAggregatedBy points up to a parent
if "aggregate" in v:
graph.add_edge(v["aggregate"], k)
# IsInstanceOf indicates a child
if "instance" in v:
graph.add_edge(k, v["instance"])
# the 3DXML format is stored as a directed acyclic graph that needs all
# paths from the root to a geometry to generate the tree of the scene
paths = []
for geometry_id in geometries.keys():
paths.extend(nx.all_simple_paths(graph, source=root_id, target=geometry_id))
# the name of the root frame
root_name = references[root_id]["name"]
# create a list of kwargs to send to the scene.graph.update function
# start with a transform from the graphs base frame to our root name
graph_kwargs = [{"frame_to": root_name, "matrix": np.eye(4)}]
# we are going to collect prettier geometry names as we traverse paths
geom_names = {}
# loop through every simple path and generate transforms tree
# note that we are flattening the transform tree here
for path in paths:
name = ""
if "name" in references[path[-3]]:
name = references[path[-3]]["name"]
geom_names[path[-1]] = name
# we need a unique node name for our geometry instance frame
# due to the nature of the DAG names specified by the file may not
# be unique, so we add an Instance3D name then append the path ids
node_name = name + "#" + ":".join(path)
# pull all transformations in the path
matrices = [references[i]["matrix"] for i in path if "matrix" in references[i]]
if len(matrices) == 0:
matrix = np.eye(4)
elif len(matrices) == 1:
matrix = matrices[0]
else:
matrix = util.multi_dot(matrices)
graph_kwargs.append(
{
"matrix": matrix,
"frame_from": root_name,
"frame_to": node_name,
"geometry": path[-1],
}
)
# remap geometry names from id numbers to the name string
# we extracted from the 3DXML tree
geom_final = {}
for key, value in geometries.items():
if key in geom_names:
geom_final[geom_names[key]] = value
# change geometry names in graph kwargs in place
for kwarg in graph_kwargs:
if "geometry" not in kwarg:
continue
kwarg["geometry"] = geom_names[kwarg["geometry"]]
# create the kwargs for load_kwargs
result = {"class": "Scene", "geometry": geom_final, "graph": graph_kwargs}
return result
def print_element(element):
"""
Pretty-print an lxml.etree element.
Parameters
------------
element : etree element
"""
pretty = etree.tostring(element, pretty_print=True).decode("utf-8")
return pretty
try:
# soft dependencies
import networkx as nx
from lxml import etree
_threedxml_loaders = {"3dxml": load_3DXML}
except BaseException as E:
# set loader to exception wrapper
from ..exceptions import ExceptionWrapper
_threedxml_loaders = {"3dxml": ExceptionWrapper(E)}
@@ -0,0 +1,505 @@
import io
import uuid
import zipfile
from collections import defaultdict
import numpy as np
from .. import graph, util
from ..constants import log
from ..util import unique_name
def _read_mesh(mesh):
"""
Read a `<mesh ` XML element into Numpy vertices and faces.
This is generally the most expensive operation in the load as it
has to operate in Python-space on every single vertex and face.
Parameters
----------
mesh : lxml.etree.Element
Input mesh element with `vertex` and `triangle` children.
Returns
----------
vertex_array : (n, 3) float64
Vertices
face_array : (n, 3) int64
Indexes of vertices forming triangles.
"""
# get the XML elements for vertices and faces
vertices = mesh.find("{*}vertices")
faces = mesh.find("{*}triangles")
# get every value as a flat space-delimited string
# this is very sensitive as it is large, i.e. it is
# much faster with the full list comprehension before
# the `.join` as the giant string can be fully allocated
vs = " ".join(
[
f"{i.attrib['x']} {i.attrib['y']} {i.attrib['z']}"
for i in vertices.iter("{*}vertex")
]
)
# convert every value to floating point in one-shot rather than in a loop
v_array = np.fromstring(vs, dtype=np.float64, sep=" ").reshape((-1, 3))
# do the same behavior for faces but as an integer
fs = " ".join(
[
f"{i.attrib['v1']} {i.attrib['v2']} {i.attrib['v3']}"
for i in faces.iter("{*}triangle")
]
)
f_array = np.fromstring(fs, dtype=np.int64, sep=" ").reshape((-1, 3))
return v_array, f_array
def load_3MF(file_obj, postprocess=True, **kwargs):
"""
Load a 3MF formatted file into a Trimesh scene.
Parameters
------------
file_obj : file-like
Contains 3MF formatted data
Returns
------------
kwargs : dict
Constructor arguments for `trimesh.Scene`
"""
# dict, {name in archive: BytesIo}
archive = util.decompress(file_obj, file_type="zip")
# get model with case-insensitive keys
model = next(iter(v for k, v in archive.items() if "3d/3dmodel.model" in k.lower()))
# read root attributes only from XML first
_event, root = next(etree.iterparse(model, tag=("{*}model"), events=("start",)))
# collect unit information from the tree
if "unit" in root.attrib:
metadata = {"units": root.attrib["unit"]}
else:
# the default units, defined by the specification
metadata = {"units": "millimeters"}
# { mesh id : mesh name}
id_name = {}
# { mesh id: (n,3) float vertices}
v_seq = defaultdict(list)
# { mesh id: (n,3) int faces}
f_seq = defaultdict(list)
# components are objects that contain other objects
# {id : [other ids]}
components = defaultdict(list)
# load information about the scene graph
# each instance is a single geometry
build_items = []
# keep track of names we can use
consumed_counts = {}
consumed_names = set()
# iterate the XML object and build elements with an LXML iterator
# loaded elements are cleared to avoid ballooning memory
model.seek(0)
for _, obj in etree.iterparse(model, tag=("{*}object", "{*}build"), events=("end",)):
# parse objects
if "object" in obj.tag:
# id is mandatory
index = obj.attrib["id"]
# start with stored name
# apparently some exporters name multiple meshes
# the same thing so check to see if it's been used
name = unique_name(
obj.attrib.get("name", str(index)), consumed_names, consumed_counts
)
consumed_names.add(name)
# store name reference on the index
id_name[index] = name
# if the object has actual geometry data parse here
for mesh in obj.iter("{*}mesh"):
v, f = _read_mesh(mesh)
v_seq[index].append(v)
f_seq[index].append(f)
# components are references to other geometries
for c in obj.iter("{*}component"):
mesh_index = c.attrib["objectid"]
transform = _attrib_to_transform(c.attrib)
components[index].append((mesh_index, transform))
# if this references another file as the `path` attrib
path = next(
(v.strip("/") for k, v in c.attrib.items() if k.endswith("path")),
None,
)
if path is not None and path in archive:
archive[path].seek(0)
name = unique_name(
obj.attrib.get("name", str(mesh_index)),
consumed_names,
consumed_counts,
)
consumed_names.add(name)
# store name reference on the index
id_name[mesh_index] = name
for _, m in etree.iterparse(
archive[path], tag=("{*}mesh"), events=("end",)
):
v, f = _read_mesh(m)
v_seq[mesh_index].append(v)
f_seq[mesh_index].append(f)
# parse build
if "build" in obj.tag:
# scene graph information stored here, aka "build" the scene
for item in obj.iter("{*}item"):
# get a transform from the item's attributes
transform = _attrib_to_transform(item.attrib)
# the index of the geometry this item instantiates
build_items.append((item.attrib["objectid"], transform))
# have one mesh per 3MF object
# one mesh per geometry ID, store as kwargs for the object
meshes = {}
for gid in v_seq.keys():
v, f = util.append_faces(v_seq[gid], f_seq[gid])
name = id_name[gid]
meshes[name] = {
"vertices": v,
"faces": f,
"metadata": metadata.copy(),
}
# apply any keyword arguments that aren't None
meshes[name].update({k: v for k, v in kwargs.items() if v is not None})
# turn the item / component representation into
# a MultiDiGraph to compound our pain
g = nx.MultiDiGraph()
# build items are the only things that exist according to 3MF
# so we accomplish that by linking them to the base frame
for gid, tf in build_items:
g.add_edge("world", gid, matrix=tf)
# components are instances which need to be linked to base
# frame by a build_item
for start, group in components.items():
for gid, tf in group:
g.add_edge(start, gid, matrix=tf)
# turn the graph into kwargs for a scene graph
# flatten the scene structure and simplify to
# a single unique node per instance
graph_args = []
parents = defaultdict(set)
for path in graph.multigraph_paths(G=g, source="world"):
# collect all the transform on the path
transforms = graph.multigraph_collect(G=g, traversal=path, attrib="matrix")
# combine them into a single transform
if len(transforms) == 1:
transform = transforms[0]
else:
transform = util.multi_dot(transforms)
# the last element of the path should be the geometry
last = path[-1][0]
# if someone included an undefined component, skip it
if last not in id_name:
log.warning(f"id {last} included but not defined!")
continue
# frame names unique
name = id_name[last] + util.unique_id()
# index in meshes
geom = id_name[last]
# collect parents if we want to combine later
if len(path) > 2:
parent = path[-2][0]
parents[parent].add(last)
graph_args.append(
{
"frame_from": "world",
"frame_to": name,
"matrix": transform,
"geometry": geom,
}
)
# solidworks will export each body as its own mesh with the part
# name as the parent so optionally rename and combine these bodies
if postprocess and all("body" in i.lower() for i in meshes.keys()):
# don't rename by default
rename = {k: k for k in meshes.keys()}
for parent, mesh_name in parents.items():
# only handle the case where a parent has a single child
# if there are multiple children we would do a combine op
if len(mesh_name) != 1:
continue
# rename the part
rename[id_name[next(iter(mesh_name))]] = id_name[parent].split("(")[0]
# apply the rename operation meshes
meshes = {rename[k]: m for k, m in meshes.items()}
# rename geometry references in the scene graph
for arg in graph_args:
if "geometry" in arg:
arg["geometry"] = rename[arg["geometry"]]
# construct the kwargs to load the scene
kwargs = {
"base_frame": "world",
"graph": graph_args,
"geometry": meshes,
"metadata": metadata,
}
return kwargs
def export_3MF(mesh, batch_size=4096, compression=zipfile.ZIP_DEFLATED, compresslevel=5):
"""
Converts a Trimesh object into a 3MF file.
Parameters
---------
mesh trimesh.trimesh
Mesh or Scene to export.
batch_size : int
Number of nodes to write per batch.
compression : zipfile.ZIP_*
Type of zip compression to use in this export.
compresslevel : int
For Python > 3.7 specify the 0-9 compression level.
Returns
---------
export : bytes
Represents geometry as a 3MF file.
"""
from ..scene.scene import Scene
if not isinstance(mesh, Scene):
mesh = Scene(mesh)
geometry = mesh.geometry
graph = mesh.graph.to_networkx()
base_frame = mesh.graph.base_frame
# xml namespaces
model_nsmap = {
None: "http://schemas.microsoft.com/3dmanufacturing/core/2015/02",
"m": "http://schemas.microsoft.com/3dmanufacturing/material/2015/02",
"p": "http://schemas.microsoft.com/3dmanufacturing/production/2015/06",
"b": "http://schemas.microsoft.com/3dmanufacturing/beamlattice/2017/02",
"s": "http://schemas.microsoft.com/3dmanufacturing/slice/2015/07",
"sc": "http://schemas.microsoft.com/3dmanufacturing/securecontent/2019/04",
}
rels_nsmap = {None: "http://schemas.openxmlformats.org/package/2006/relationships"}
# model ids
models = []
def model_id(x):
if x not in models:
models.append(x)
return str(models.index(x) + 1)
# 3mf archive dict {path: BytesIO}
file_obj = io.BytesIO()
# specify the parameters for the zip container
zip_kwargs = {"compression": compression}
# compresslevel was added in Python 3.7
zip_kwargs["compresslevel"] = compresslevel
with zipfile.ZipFile(file_obj, mode="w", **zip_kwargs) as z:
# 3dmodel.model
with z.open("3D/3dmodel.model", mode="w") as f, etree.xmlfile(
f, encoding="utf-8"
) as xf:
xf.write_declaration()
# stream elements
with xf.element("model", {"unit": "millimeter"}, nsmap=model_nsmap):
# objects with mesh data and/or references to other objects
with xf.element("resources"):
# stream objects with actual mesh data
for i, (name, m) in enumerate(geometry.items()):
# attributes for object
attribs = {
"id": model_id(name),
"name": name,
"type": "model",
"p:UUID": str(uuid.uuid4()),
}
with xf.element("object", **attribs):
with xf.element("mesh"):
with xf.element("vertices"):
# vertex nodes are written directly to the file
# so make sure lxml's buffer is flushed
xf.flush()
for i in range(0, len(m.vertices), batch_size):
batch = m.vertices[i : i + batch_size]
fragment = (
'<vertex x="{}" y="{}" z="{}" />' * len(batch)
)
f.write(
fragment.format(*batch.flatten()).encode(
"utf-8"
)
)
with xf.element("triangles"):
xf.flush()
for i in range(0, len(m.faces), batch_size):
batch = m.faces[i : i + batch_size]
fragment = (
'<triangle v1="{}" v2="{}" v3="{}" />'
* len(batch)
)
f.write(
fragment.format(*batch.flatten()).encode(
"utf-8"
)
)
# stream components
for node in graph.nodes:
if node == base_frame or node.startswith("camera"):
continue
if len(graph[node]) == 0:
continue
attribs = {
"id": model_id(node),
"name": node,
"type": "model",
"p:UUID": str(uuid.uuid4()),
}
with xf.element("object", **attribs):
with xf.element("components"):
for next, data in graph[node].items():
transform = " ".join(
str(i)
for i in np.array(data["matrix"])[
:3, :4
].T.flatten()
)
xf.write(
etree.Element(
"component",
{
"objectid": model_id(data["geometry"])
if "geometry" in data
else model_id(next),
"transform": transform,
},
)
)
# stream build (objects on base_frame)
with xf.element("build", {"p:UUID": str(uuid.uuid4())}):
for node, data in graph[base_frame].items():
if node.startswith("camera"):
continue
transform = " ".join(
str(i) for i in np.array(data["matrix"])[:3, :4].T.flatten()
)
uuid_tag = "{{{}}}UUID".format(model_nsmap["p"])
xf.write(
etree.Element(
"item",
{
"objectid": model_id(data.get('geometry', node)),
"transform": transform,
uuid_tag: str(uuid.uuid4()),
},
nsmap=model_nsmap,
)
)
# .rels
with z.open("_rels/.rels", "w") as f, etree.xmlfile(f, encoding="utf-8") as xf:
xf.write_declaration()
# stream elements
with xf.element("Relationships", nsmap=rels_nsmap):
rt = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
xf.write(
etree.Element(
"Relationship",
Type=rt,
Target="/3D/3dmodel.model",
Id="rel0",
)
)
# [Content_Types].xml
with z.open("[Content_Types].xml", "w") as f, etree.xmlfile(
f, encoding="utf-8"
) as xf:
xf.write_declaration()
# xml namespaces
nsmap = {None: "http://schemas.openxmlformats.org/package/2006/content-types"}
# stream elements
types = [
("jpeg", "image/jpeg"),
("jpg", "image/jpeg"),
("model", "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"),
("png", "image/png"),
("rels", "application/vnd.openxmlformats-package.relationships+xml"),
(
"texture",
"application/vnd.ms-package.3dmanufacturing-3dmodeltexture",
),
]
with xf.element("Types", nsmap=nsmap):
for ext, ctype in types:
xf.write(etree.Element("Default", Extension=ext, ContentType=ctype))
return file_obj.getvalue()
def _attrib_to_transform(attrib):
"""
Extract a homogeneous transform from a dictionary.
Parameters
------------
attrib: dict, optionally containing 'transform'
Returns
------------
transform: (4, 4) float, homogeonous transformation
"""
transform = np.eye(4, dtype=np.float64)
if "transform" in attrib:
# wangle their transform format
values = np.array(attrib["transform"].split(), dtype=np.float64).reshape((4, 3)).T
transform[:3, :4] = values
return transform
# do import here to keep lxml a soft dependency
try:
import networkx as nx
from lxml import etree
_three_loaders = {"3mf": load_3MF}
_3mf_exporters = {"3mf": export_3MF}
except BaseException as E:
from ..exceptions import ExceptionWrapper
_three_loaders = {"3mf": ExceptionWrapper(E)}
_3mf_exporters = {"3mf": ExceptionWrapper(E)}
@@ -0,0 +1,163 @@
import os
import numpy as np
from ..constants import log, tol
from ..version import __version__
def export_urdf(mesh, directory, scale=1.0, color=None, **kwargs):
"""
Convert a Trimesh object into a URDF package for physics
simulation. This breaks the mesh into convex pieces and
writes them to the same directory as the .urdf file.
Parameters
---------
mesh : trimesh.Trimesh
Input geometry
directory : str
The directory path for the URDF package
Returns
---------
mesh : Trimesh
Multi-body mesh containing convex decomposition
"""
import lxml.etree as et
# TODO: fix circular import
from .export import export_mesh
# Extract the save directory and the file name
fullpath = os.path.abspath(directory)
name = os.path.basename(fullpath)
_, ext = os.path.splitext(name)
if ext != "":
raise ValueError("URDF path must be a directory!")
# Create directory if needed
if not os.path.exists(fullpath):
os.mkdir(fullpath)
elif not os.path.isdir(fullpath):
raise ValueError("URDF path must be a directory!")
# Perform a convex decomposition
try:
convex_pieces = mesh.convex_decomposition()
except BaseException:
log.error("problem with convex decomposition, using hull", exc_info=True)
convex_pieces = [mesh.convex_hull]
# Get the effective density of the mesh
effective_density = mesh.volume / sum([m.volume for m in convex_pieces])
# open an XML tree
root = et.Element("robot", name="root")
# Loop through all pieces, adding each as a link
prev_link_name = None
for i, piece in enumerate(convex_pieces):
# Save each nearly convex mesh out to a file
piece_name = f"{name}_convex_piece_{i}"
piece_filename = f"{piece_name}.obj"
piece_filepath = os.path.join(fullpath, piece_filename)
export_mesh(piece, piece_filepath)
# Set the mass properties of the piece
piece.center_mass = mesh.center_mass
piece.density = effective_density * mesh.density
link_name = f"link_{piece_name}"
geom_name = f"{piece_filename}"
I = [["{:.2E}".format(y) for y in x] for x in piece.moment_inertia] # NOQA
# Write the link out to the XML Tree
link = et.SubElement(root, "link", name=link_name)
# Inertial information
inertial = et.SubElement(link, "inertial")
et.SubElement(inertial, "origin", xyz="0 0 0", rpy="0 0 0")
et.SubElement(inertial, "mass", value=f"{piece.mass:.2E}")
et.SubElement(
inertial,
"inertia",
ixx=I[0][0],
ixy=I[0][1],
ixz=I[0][2],
iyy=I[1][1],
iyz=I[1][2],
izz=I[2][2],
)
# Visual Information
visual = et.SubElement(link, "visual")
et.SubElement(visual, "origin", xyz="0 0 0", rpy="0 0 0")
geometry = et.SubElement(visual, "geometry")
et.SubElement(
geometry,
"mesh",
filename=geom_name,
scale=f"{scale:.4E} {scale:.4E} {scale:.4E}",
)
material = et.SubElement(visual, "material", name="")
if color is not None:
et.SubElement(
material, "color", rgba=f"{color[0]:.2E} {color[1]:.2E} {color[2]:.2E} 1"
)
# Collision Information
collision = et.SubElement(link, "collision")
et.SubElement(collision, "origin", xyz="0 0 0", rpy="0 0 0")
geometry = et.SubElement(collision, "geometry")
et.SubElement(
geometry,
"mesh",
filename=geom_name,
scale=f"{scale:.4E} {scale:.4E} {scale:.4E}",
)
# Create rigid joint to previous link
if prev_link_name is not None:
joint_name = f"{link_name}_joint"
joint = et.SubElement(root, "joint", name=joint_name, type="fixed")
et.SubElement(joint, "origin", xyz="0 0 0", rpy="0 0 0")
et.SubElement(joint, "parent", link=prev_link_name)
et.SubElement(joint, "child", link=link_name)
prev_link_name = link_name
# Write URDF file
tree = et.ElementTree(root)
urdf_filename = f"{name}.urdf"
tree.write(os.path.join(fullpath, urdf_filename), pretty_print=True)
# Write Gazebo config file
root = et.Element("model")
model = et.SubElement(root, "name")
model.text = name
version = et.SubElement(root, "version")
version.text = "1.0"
sdf = et.SubElement(root, "sdf", version="1.4")
sdf.text = f"{name}.urdf"
author = et.SubElement(root, "author")
et.SubElement(author, "name").text = f"trimesh {__version__}"
et.SubElement(author, "email").text = "blank@blank.blank"
description = et.SubElement(root, "description")
description.text = name
tree = et.ElementTree(root)
if tol.strict:
from ..resources import get_stream
# todo : we don't pass the URDF schema validation
schema = et.XMLSchema(file=get_stream("schema/urdf.xsd"))
if not schema.validate(tree):
# actual error isn't raised by validate
log.debug(schema.error_log)
tree.write(os.path.join(fullpath, "model.config"))
return np.sum(convex_pieces)
@@ -0,0 +1,156 @@
"""
xaml.py
---------
Load 3D XAMl files, an export option from Solidworks.
"""
import collections
import numpy as np
from .. import transformations as tf
from .. import util, visual
def load_XAML(file_obj, *args, **kwargs):
"""
Load a 3D XAML file.
Parameters
----------
file_obj : file object
Open XAML file.
Returns
----------
result : dict
Kwargs for a Trimesh constructor.
"""
def element_to_color(element):
"""
Turn an XML element into a (4,) np.uint8 RGBA color
"""
if element is None:
return visual.DEFAULT_COLOR
hexcolor = int(element.attrib["Color"].replace("#", ""), 16)
opacity = float(element.attrib["Opacity"])
rgba = [
(hexcolor >> 16) & 0xFF,
(hexcolor >> 8) & 0xFF,
(hexcolor & 0xFF),
opacity * 0xFF,
]
rgba = np.array(rgba, dtype=np.uint8)
return rgba
def element_to_transform(element):
"""
Turn an XML element into a (4,4) np.float64
transformation matrix.
"""
try:
matrix = next(element.iter(tag=ns + "MatrixTransform3D")).attrib["Matrix"]
matrix = np.array(matrix.split(), dtype=np.float64).reshape((4, 4)).T
return matrix
except StopIteration:
# this will be raised if the MatrixTransform3D isn't in the passed
# elements tree
return np.eye(4)
# read the file and parse XML
file_data = file_obj.read()
root = etree.XML(file_data)
# the XML namespace
ns = root.tag.split("}")[0] + "}"
# the linked lists our results are going in
vertices = []
faces = []
colors = []
normals = []
# iterate through the element tree
# the GeometryModel3D tag contains a material and geometry
for geometry in root.iter(tag=ns + "GeometryModel3D"):
# get the diffuse and specular colors specified in the material
color_search = ".//{ns}{color}Material/*/{ns}SolidColorBrush"
diffuse = geometry.find(color_search.format(ns=ns, color="Diffuse"))
specular = geometry.find(color_search.format(ns=ns, color="Specular"))
# convert the element into a (4,) np.uint8 RGBA color
diffuse = element_to_color(diffuse)
specular = element_to_color(specular)
# to get the final transform of a component we'll have to traverse
# all the way back to the root node and save transforms we find
current = geometry
transforms = collections.deque()
# when the root node is reached its parent will be None and we stop
while current is not None:
# element.find will only return elements that are direct children
# of the current element as opposed to element.iter,
# which will return any depth of child
transform_element = current.find(ns + "ModelVisual3D.Transform")
if transform_element is not None:
# we are traversing the tree backwards, so append new
# transforms to the left of the deque
transforms.appendleft(element_to_transform(transform_element))
# we are going from the lowest level of the tree to the highest
# this avoids having to traverse any branches that don't have
# geometry
current = current.getparent()
if len(transforms) == 0:
# no transforms in the tree mean an identity matrix
transform = np.eye(4)
elif len(transforms) == 1:
# one transform in the tree we can just use
transform = transforms.pop()
else:
# multiple transforms we apply all of them in order
transform = util.multi_dot(transforms)
# iterate through the contained mesh geometry elements
for g in geometry.iter(tag=ns + "MeshGeometry3D"):
c_normals = np.array(
g.attrib["Normals"].replace(",", " ").split(), dtype=np.float64
).reshape((-1, 3))
c_vertices = np.array(
g.attrib["Positions"].replace(",", " ").split(), dtype=np.float64
).reshape((-1, 3))
# bake in the transform as we're saving
c_vertices = tf.transform_points(c_vertices, transform)
c_faces = np.array(
g.attrib["TriangleIndices"].replace(",", " ").split(), dtype=np.int64
).reshape((-1, 3))
# save data to a sequence
vertices.append(c_vertices)
faces.append(c_faces)
colors.append(np.tile(diffuse, (len(c_faces), 1)))
normals.append(c_normals)
# compile the results into clean numpy arrays
result = {"units": "meters"}
result["vertices"], result["faces"] = util.append_faces(vertices, faces)
result["face_colors"] = np.vstack(colors)
result["vertex_normals"] = np.vstack(normals)
return result
try:
from lxml import etree
_xaml_loaders = {"xaml": load_XAML}
except BaseException as E:
# create a dummy module which will raise the ImportError
# or other exception only when someone tries to use networkx
from ..exceptions import ExceptionWrapper
_xaml_loaders = {"xaml": ExceptionWrapper(E)}
@@ -0,0 +1,103 @@
import numpy as np
from .. import util
from ..points import PointCloud
def load_xyz(file_obj, delimiter=None, **kwargs):
"""
Load an XYZ file into a PointCloud.
Parameters
------------
file_obj : an open file-like object
Source data, ASCII XYZ
delimiter : None or string
Characters used to separate the columns of the file
If not passed will use whitespace or commas
Returns
----------
kwargs : dict
Data which can be passed to PointCloud constructor
"""
# read the whole file into memory as a string
raw = util.decode_text(file_obj.read()).strip()
# get the first line to look at
first = raw[: raw.find("\n")].strip()
# guess the column count by looking at the first line
columns = len(first.split())
if columns < 3:
raise ValueError("not enough columns in xyz file!")
if delimiter is None and "," in first:
# if no delimiter passed and file has commas
delimiter = ","
if delimiter is not None:
# replace delimiter with whitespace so split works
raw = raw.replace(delimiter, " ")
# use string splitting to get array
array = np.array(raw.split(), dtype=np.float64)
# reshape to column count
# if file has different numbers of values
# per row this will fail as it should
data = array.reshape((-1, columns))
# start with no colors
colors = None
# vertices are the first three columns
vertices = data[:, :3]
if columns == 6:
# RGB colors
colors = np.array(data[:, 3:], dtype=np.uint8)
colors = np.concatenate(
(colors, np.ones((len(data), 1), dtype=np.uint8) * 255), axis=1
)
elif columns >= 7:
# extract RGBA colors
colors = np.array(data[:, 3:8], dtype=np.uint8)
# add extracted colors and vertices to kwargs
kwargs.update({"vertices": vertices, "colors": colors})
return kwargs
def export_xyz(cloud, write_colors=True, delimiter=None):
"""
Export a PointCloud object to an XYZ format string.
Parameters
-------------
cloud : trimesh.PointCloud
Geometry in space
write_colors : bool
Write colors or not
delimiter : None or str
What to separate columns with
Returns
--------------
export : str
Pointcloud in XYZ format
"""
if not isinstance(cloud, PointCloud):
raise ValueError("object must be PointCloud")
# compile data into a blob
data = cloud.vertices
if write_colors and hasattr(cloud, "colors") and cloud.colors is not None:
# stack colors and vertices
data = np.hstack((data, cloud.colors))
# if delimiter not passed use whitespace
if delimiter is None:
delimiter = " "
# stack blob into XYZ format
export = util.array_to_string(data, col_delim=delimiter)
return export
_xyz_loaders = {"xyz": load_xyz}
_xyz_exporters = {"xyz": export_xyz}