init
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user