This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,3 @@
from . import blender
__all__ = ["blender"]
@@ -0,0 +1,142 @@
import os
import platform
from .. import util
from ..constants import log
from ..resources import get_string
from ..typed import BooleanOperationType, Iterable
from .generic import MeshScript
if platform.system() == "Windows":
# try to find Blender install on Windows
# split existing path by delimiter
_search_path = [i for i in os.environ.get("PATH", "").split(";") if len(i) > 0]
for pf in [r"C:\Program Files", r"C:\Program Files (x86)"]:
pf = os.path.join(pf, "Blender Foundation")
if os.path.exists(pf):
for p in os.listdir(pf):
if "Blender" in p:
_search_path.append(os.path.join(pf, p))
_search_path = ";".join(set(_search_path))
log.debug("searching for blender in: %s", _search_path)
elif platform.system() == "Darwin":
# try to find Blender on Mac OSX
_search_path = [i for i in os.environ.get("PATH", "").split(":") if len(i) > 0]
_search_path.extend(
[
"/Applications/blender.app/Contents/MacOS",
"/Applications/Blender.app/Contents/MacOS",
"/Applications/Blender/blender.app/Contents/MacOS",
]
)
_search_path = ":".join(set(_search_path))
log.debug("searching for blender in: %s", _search_path)
else:
_search_path = os.environ.get("PATH", "")
_blender_executable = util.which("blender", path=_search_path)
exists = _blender_executable is not None
# a map that translates:
# `trimesh.BooleanOperationType` -> blender value
_blender_bool = {
"union": "UNION",
"difference": "DIFFERENCE",
"intersection": "INTERSECT",
}
def boolean(
meshes: Iterable,
operation: BooleanOperationType = "difference",
use_exact: bool = True,
use_self: bool = False,
debug: bool = False,
check_volume: bool = True,
):
"""
Run a boolean operation with multiple meshes using Blender.
Parameters
-----------
meshes
List of mesh objects to be operated on
operation
Type of boolean operation ("difference", "union", "intersect").
use_exact
Use the "exact" mode as opposed to the "fast" mode.
use_self
Whether to consider self-intersections.
debug
Provide additional output for troubleshooting.
check_volume
Raise an error if not all meshes are watertight
positive volumes. Advanced users may want to ignore
this check as it is expensive.
Returns
----------
result
The result of the boolean operation on the provided meshes.
"""
if not exists:
raise ValueError("No blender available!")
if check_volume and not all(m.is_volume for m in meshes):
raise ValueError("Not all meshes are volumes!")
# conversions from the trimesh `BooleanOperationType` to the blender option
key = operation.lower().strip()
if key not in _blender_bool:
raise ValueError(
f"`{operation}` is not a valid boolean: `{_blender_bool.keys()}`"
)
if use_exact:
solver_options = "EXACT"
else:
solver_options = "FAST"
# get the template from our resources folder
template = get_string("templates/blender_boolean.py.tmpl")
# use string substitutions rather than `string.Template` as we aren't going
# to be filling in all the values here, `MeshScript` is going to be
# the source of `$MESH_PRE`, etc.
script = (
template.replace("$OPERATION", _blender_bool[key])
.replace("$SOLVER_OPTIONS", solver_options)
.replace("$USE_SELF", f"{use_self}")
)
with MeshScript(meshes=meshes, script=script, debug=debug) as blend:
result = blend.run(_blender_executable + " --background --python $SCRIPT")
result = util.make_sequence(result)
for m in result:
# blender returns actively incorrect face normals
m.face_normals = None
return util.concatenate(result)
def unwrap(
mesh, angle_limit: float = 66.0, island_margin: float = 0.0, debug: bool = False
):
"""
Run an unwrap operation using blender.
"""
if not exists:
raise ValueError("No blender available!")
# get the template from our resources folder
template = get_string("templates/blender_unwrap.py.template")
script = template.replace("$ANGLE_LIMIT", f"{angle_limit:.6f}").replace(
"$ISLAND_MARGIN", f"{island_margin:.6f}"
)
with MeshScript(meshes=[mesh], script=script, exchange="obj", debug=debug) as blend:
result = blend.run(_blender_executable + " --background --python $SCRIPT")
for m in util.make_sequence(result):
# blender returns actively incorrect face normals
m.face_normals = None
return result
@@ -0,0 +1,97 @@
import os
import platform
import subprocess
from string import Template
from subprocess import CalledProcessError, check_output
from tempfile import NamedTemporaryFile
from .. import exchange
from ..util import log
class MeshScript:
def __init__(self, meshes, script, exchange="stl", debug=False, **kwargs):
self.debug = debug
self.kwargs = kwargs
self.meshes = meshes
self.script = script
self.exchange = exchange
def __enter__(self):
# windows has problems with multiple programs using open files so we close
# them at the end of the enter call, and delete them ourselves at exit
# Blender sorts its objects alphabetically
# so prefix the mesh number on the file name
digit_count = len(str(len(self.meshes)))
self.mesh_pre = [
NamedTemporaryFile(
suffix=f".{self.exchange}",
prefix=f"{str(i).zfill(digit_count)}_",
mode="wb",
delete=False,
)
for i in range(len(self.meshes))
]
self.mesh_post = NamedTemporaryFile(
suffix=f".{self.exchange}", mode="rb", delete=False
)
self.script_out = NamedTemporaryFile(mode="wb", delete=False)
# export the meshes to a temporary STL container
for mesh, file_obj in zip(self.meshes, self.mesh_pre):
mesh.export(file_obj=file_obj.name)
self.replacement = {"MESH_" + str(i): m.name for i, m in enumerate(self.mesh_pre)}
self.replacement["MESH_PRE"] = str([i.name for i in self.mesh_pre])
self.replacement["MESH_POST"] = self.mesh_post.name
self.replacement["SCRIPT"] = self.script_out.name
script_text = Template(self.script).substitute(self.replacement)
if platform.system() == "Windows":
script_text = script_text.replace("\\", "\\\\")
self.script_out.write(script_text.encode("utf-8"))
# close all temporary files
self.script_out.close()
self.mesh_post.close()
for file_obj in self.mesh_pre:
file_obj.close()
return self
def run(self, command):
command_run = Template(command).substitute(self.replacement).split()
# run the binary
startupinfo = None
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
if self.debug:
log.info("executing: {}".format(" ".join(command_run)))
try:
output = check_output(
command_run, stderr=subprocess.STDOUT, startupinfo=startupinfo
)
except CalledProcessError as E:
# raise with the output from the process
raise RuntimeError(E.output.decode())
if self.debug:
log.info(output.decode())
# bring the binaries result back as a set of Trimesh kwargs
mesh_results = exchange.load.load_mesh(self.mesh_post.name, **self.kwargs)
return mesh_results
def __exit__(self, *args, **kwargs):
if self.debug:
log.info(f"MeshScript.debug: not deleting {self.script_out.name}")
return
# delete all the temporary files by name
# they are closed but their names are still available
os.remove(self.script_out.name)
for file_obj in self.mesh_pre:
os.remove(file_obj.name)
os.remove(self.mesh_post.name)