@@ -242,6 +242,158 @@ def remesh_with_blender(
242242 return remeshed_mesh
243243
244244
245+ def decimate_with_blender (
246+ mesh ,
247+ ratio = 0.1 ,
248+ blender_executable = None ,
249+ verbose = False ):
250+ """Decimate a mesh using Blender's decimate (collapse) modifier.
251+
252+ Unlike :func:`remesh_with_blender`, this reduces the triangle count while
253+ preserving the original shape, materials and vertex colors. The mesh is
254+ exchanged with Blender in glTF/glb format (Blender 5.x no longer ships the
255+ Collada importer/exporter), so colors round-trip as glTF materials/vertex
256+ colors.
257+
258+ Parameters
259+ ----------
260+ mesh : trimesh.Trimesh or trimesh.Scene or list of trimesh.Trimesh or str or pathlib.Path
261+ Mesh to be decimated. Can be a trimesh object, a list of trimesh
262+ objects, a :class:`trimesh.Scene`, or a path to a mesh file.
263+ ratio : float, optional
264+ Collapse ratio passed to Blender's decimate modifier. The resulting
265+ triangle count is approximately ``ratio`` times the original. Must be
266+ in the range ``(0, 1]``. Default is 0.1.
267+ blender_executable : str, optional
268+ Path to Blender executable. If None, automatically searches for Blender
269+ in common installation locations. Default is None.
270+ verbose : bool, optional
271+ Whether to print progress information. Default is False.
272+
273+ Returns
274+ -------
275+ list of trimesh.Trimesh
276+ Decimated meshes with preserved colors.
277+
278+ Raises
279+ ------
280+ RuntimeError
281+ If Blender decimation fails or the output file is not created.
282+ """
283+ from pathlib import Path
284+ import subprocess
285+ import tempfile
286+
287+ trimesh = _lazy_trimesh ()
288+
289+ if not 0.0 < ratio <= 1.0 :
290+ raise ValueError (
291+ "ratio must be in the range (0, 1], got {}" .format (ratio ))
292+
293+ # Auto-detect Blender if not specified
294+ if blender_executable is None :
295+ blender_executable = _find_blender_executable ()
296+ if blender_executable is None :
297+ raise RuntimeError (
298+ "Blender executable not found. Please install Blender or "
299+ "specify the path using the blender_executable parameter."
300+ )
301+ if verbose :
302+ print (f"Found Blender at: { blender_executable } " )
303+
304+ # Normalize the input into a trimesh.Scene so it can be exported to glb.
305+ if isinstance (mesh , (str , Path )):
306+ scene = trimesh .load (mesh , force = 'scene' )
307+ elif isinstance (mesh , trimesh .Scene ):
308+ scene = mesh
309+ elif isinstance (mesh , (list , tuple )):
310+ scene = trimesh .Scene (list (mesh ))
311+ else :
312+ scene = trimesh .Scene (mesh )
313+
314+ with tempfile .TemporaryDirectory () as temp_dir :
315+ temp_path = Path (temp_dir )
316+ input_glb = temp_path / 'input.glb'
317+ output_glb = temp_path / 'output.glb'
318+ scene .export (str (input_glb ))
319+
320+ script_path = temp_path / 'decimate_script.py'
321+ _create_blender_decimate_script (
322+ script_path , input_glb , output_glb , ratio )
323+
324+ cmd = [
325+ blender_executable ,
326+ '--background' ,
327+ '--python' , str (script_path ),
328+ ]
329+
330+ if verbose :
331+ print (f"Running Blender decimate with ratio={ ratio } ..." )
332+
333+ result = subprocess .run (cmd , capture_output = not verbose , text = True )
334+
335+ if result .returncode != 0 :
336+ error_msg = (
337+ "Blender decimation failed with return code "
338+ f"{ result .returncode } " )
339+ if not verbose and result .stderr :
340+ error_msg += f"\n Error: { result .stderr } "
341+ raise RuntimeError (error_msg )
342+
343+ if not output_glb .exists ():
344+ raise RuntimeError (
345+ f"Decimated output file not found: { output_glb } " )
346+
347+ decimated = trimesh .load (str (output_glb ), force = 'scene' )
348+
349+ geometries = [g for g in decimated .dump ()
350+ if isinstance (g , trimesh .Trimesh )]
351+ if not geometries :
352+ raise RuntimeError ("Decimated mesh contains no Trimesh geometry" )
353+
354+ if verbose :
355+ total_faces = sum (len (g .faces ) for g in geometries )
356+ print (f"Decimation complete. Output faces: { total_faces } " )
357+
358+ return geometries
359+
360+
361+ def _create_blender_decimate_script (script_path , input_path , output_path , ratio ):
362+ """Create a wrapper script that imports the core Blender decimate module.
363+
364+ Parameters
365+ ----------
366+ script_path : pathlib.Path
367+ Path where the wrapper script will be written.
368+ input_path : pathlib.Path
369+ Path to the input glb file.
370+ output_path : pathlib.Path
371+ Path to the output glb file.
372+ ratio : float
373+ Collapse ratio for decimation.
374+ """
375+ from pathlib import Path
376+
377+ core_module_path = Path (__file__ ).parent / '_blender_decimate_core.py'
378+
379+ script_content = f"""import sys
380+ from pathlib import Path
381+
382+ core_module_path = Path(r"{ core_module_path } ")
383+ sys.path.insert(0, str(core_module_path.parent))
384+
385+ from _blender_decimate_core import decimate_glb_file
386+
387+ input_path = Path(r"{ input_path } ")
388+ output_path = Path(r"{ output_path } ")
389+ ratio = { ratio }
390+
391+ decimate_glb_file(input_path, output_path, ratio)
392+ """
393+
394+ script_path .write_text (script_content )
395+
396+
245397def _create_blender_wrapper_script (script_path , input_path , output_path , voxel_size , export_format ):
246398 """Create a wrapper script that imports the core Blender remeshing module.
247399
0 commit comments