@@ -162,50 +162,62 @@ def _peel_bottom_upward_faces(
162162 min_normal_y : float = 0.82 ,
163163 max_remove_frac : float = 0.14 ,
164164 max_iterations : int = 3 ,
165+ scan_frac : float = 0.30 ,
166+ max_cross_section_frac : float = 0.45 ,
165167) -> trimesh .Trimesh :
166- """
167- Remove faces **quase horizontais** (|ny| alto) na faixa mais baixa do bbox.
168-
169- Sombras modeladas como placa têm normais para +Y ou -Y (face de cima/baixo);
170- só aceitar +Y falhava em muitos GLB (ex.: Godot / pintura).
171-
172- Conservador: aborta se a remoção afectar demasiadas faces (p.ex. sola inteira).
168+ """Remove pedestal / shadow disc by cross-section and iterative peel.
173169
174- Iterativo: se uma passagem removida faces e a nova base ainda tem concentração
175- de faces horizontais, faz outra passagem. Até ``max_iterations`` vezes.
176- Isto permite remover pedestais mais espessos (ex.: base de rocha) sem sacrificar
177- geometria orgânica — faces curvas não passam no filtro ``min_normal_y``.
170+ Detects which end of the Y axis has higher horizontal-face density,
171+ then slices the mesh on a plane and lets downstream ``make_watertight``
172+ seal the flat cut.
178173 """
179174 result = mesh_yup
180175 original_n = len (mesh_yup .faces )
176+ if original_n == 0 :
177+ return result
178+
179+ ymin = float (result .vertices [:, 1 ].min ())
180+ ymax = float (result .vertices [:, 1 ].max ())
181+ h = ymax - ymin
182+ if h < 1e-8 :
183+ return result
184+
185+ centers = result .triangles_center
186+ normals = result .face_normals
187+ if normals is None or len (normals ) != len (result .faces ):
188+ return result
189+ ny = np .abs (np .asarray (normals [:, 1 ], dtype = np .float64 ))
190+ horizontal = ny >= min_normal_y
191+
192+ # Determine which end has denser horizontal faces (that is the pedestal end).
193+ bottom_horiz = float (np .count_nonzero (horizontal & (centers [:, 1 ] <= ymin + scan_frac * h )))
194+ top_horiz = float (np .count_nonzero (horizontal & (centers [:, 1 ] >= ymax - scan_frac * h )))
195+ pedestal_at_max = top_horiz >= bottom_horiz
196+
197+ # Phase 1: iterative peel from the pedestal end (light cases only).
198+ # Skipped entirely when pedestal is large — Phase 2 cross-section handles those.
181199 for _ in range (max_iterations ):
182200 if len (result .faces ) == 0 :
183201 break
184-
185- _ = np .asarray (result .face_normals )
186-
187- ymin = float (result .vertices [:, 1 ].min ())
188- ymax = float (result .vertices [:, 1 ].max ())
189- h = ymax - ymin
190- if h < 1e-8 :
202+ r_ymin = float (result .vertices [:, 1 ].min ())
203+ r_ymax = float (result .vertices [:, 1 ].max ())
204+ r_h = r_ymax - r_ymin
205+ if r_h < 1e-8 :
191206 break
192-
193- band = max (band_frac * h , 1e-6 )
194- centers = result .triangles_center
195- normals = result .face_normals
196- if normals is None or len (normals ) != len (result .faces ):
207+ band = max (band_frac * r_h , 1e-6 )
208+ r_centers = result .triangles_center
209+ r_normals = result .face_normals
210+ if r_normals is None or len (r_normals ) != len (result .faces ):
197211 break
198-
199- ny = np .asarray (normals [:, 1 ], dtype = np .float64 )
200- horizontal = np .abs (ny ) >= min_normal_y
201- remove = (centers [:, 1 ] <= ymin + band ) & horizontal
212+ r_ny = np .abs (np .asarray (r_normals [:, 1 ], dtype = np .float64 ))
213+ r_horizontal = r_ny >= min_normal_y
214+ if pedestal_at_max :
215+ remove = (r_centers [:, 1 ] >= r_ymax - band ) & r_horizontal
216+ else :
217+ remove = (r_centers [:, 1 ] <= r_ymin + band ) & r_horizontal
202218 n_remove = int (np .count_nonzero (remove ))
203- if n_remove == 0 :
219+ if n_remove == 0 or n_remove > max_remove_frac * original_n :
204220 break
205- # Check against ORIGINAL face count to prevent cascading removal
206- if n_remove > max_remove_frac * original_n :
207- break
208-
209221 keep = ~ remove
210222 try :
211223 sub = result .submesh ([np .where (keep )[0 ]], append = True , only_watertight = False )
@@ -215,7 +227,89 @@ def _peel_bottom_upward_faces(
215227 break
216228 except Exception :
217229 break
218- return result
230+
231+ # Phase 2: cross-section cut at the pedestal boundary.
232+ # Scan from the pedestal end; find the transition from dense-horizontal
233+ # to organic, then slice the mesh on that plane.
234+ if len (result .faces ) == 0 :
235+ return result
236+ r_ymin = float (result .vertices [:, 1 ].min ())
237+ r_ymax = float (result .vertices [:, 1 ].max ())
238+ r_h = r_ymax - r_ymin
239+ if r_h < 1e-8 :
240+ return result
241+
242+ r_centers = result .triangles_center
243+ r_normals = result .face_normals
244+ if r_normals is None or len (r_normals ) != len (result .faces ):
245+ return result
246+ r_ny = np .abs (np .asarray (r_normals [:, 1 ], dtype = np .float64 ))
247+ r_horizontal = r_ny >= min_normal_y
248+
249+ n_bins = max (int (scan_frac * 50 ), 10 )
250+ bin_size = r_h / n_bins
251+ min_dense_bins = 1
252+ dense_run = 0
253+ cut_frac : float | None = None
254+
255+ if pedestal_at_max :
256+ for i in range (n_bins ):
257+ lo = r_ymax - (i + 1 ) * bin_size
258+ hi_y = r_ymax - i * bin_size
259+ in_bin = (r_centers [:, 1 ] >= lo ) & (r_centers [:, 1 ] < hi_y )
260+ n_bin = int (np .count_nonzero (in_bin ))
261+ if n_bin < 3 :
262+ dense_run = 0
263+ continue
264+ pct = float (np .count_nonzero (r_horizontal & in_bin )) / n_bin
265+ if pct >= 0.40 :
266+ dense_run += 1
267+ else :
268+ if dense_run >= min_dense_bins :
269+ cut_frac = 1.0 - i / n_bins
270+ break
271+ dense_run = 0
272+ else :
273+ for i in range (n_bins ):
274+ lo = r_ymin + i * bin_size
275+ hi_y = r_ymin + (i + 1 ) * bin_size
276+ in_bin = (r_centers [:, 1 ] >= lo ) & (r_centers [:, 1 ] < hi_y )
277+ n_bin = int (np .count_nonzero (in_bin ))
278+ if n_bin < 3 :
279+ dense_run = 0
280+ continue
281+ pct = float (np .count_nonzero (r_horizontal & in_bin )) / n_bin
282+ if pct >= 0.40 :
283+ dense_run += 1
284+ else :
285+ if dense_run >= min_dense_bins :
286+ cut_frac = i / n_bins
287+ break
288+ dense_run = 0
289+
290+ if cut_frac is None or cut_frac < 0.02 :
291+ return result
292+
293+ cut_y = r_ymin + cut_frac * r_h
294+ from trimesh .intersections import slice_faces_plane
295+
296+ plane_normal = np .array ([0.0 , - 1.0 , 0.0 ]) if pedestal_at_max else np .array ([0.0 , 1.0 , 0.0 ])
297+
298+ new_verts , new_faces , _ = slice_faces_plane (
299+ result .vertices .copy (),
300+ result .faces .copy (),
301+ plane_normal = plane_normal ,
302+ plane_origin = np .array ([0.0 , cut_y , 0.0 ]),
303+ )
304+ if len (new_faces ) < 4 :
305+ return result
306+
307+ sub = trimesh .Trimesh (vertices = new_verts , faces = new_faces , process = False )
308+ already_removed = original_n - len (result .faces )
309+ if len (sub .faces ) + already_removed < (1.0 - max_cross_section_frac ) * original_n :
310+ return result
311+
312+ return sub
219313
220314
221315def _remove_bottom_center_cylinder (
@@ -1330,6 +1424,45 @@ def _pymeshlab_roundtrip(mesh: trimesh.Trimesh, apply_fn) -> trimesh.Trimesh:
13301424 return mesh
13311425
13321426
1427+ def _pymeshlab_close_holes (mesh : trimesh .Trimesh , * , max_hole_edges : int = 2000 ) -> trimesh .Trimesh :
1428+ """Close boundary holes using pymeshlab only (no remeshing/clean)."""
1429+ try :
1430+
1431+ def _apply (ms ):
1432+ ms .meshing_close_holes (maxholesize = max_hole_edges )
1433+
1434+ return _pymeshlab_roundtrip (mesh , _apply )
1435+ except Exception :
1436+ return mesh
1437+
1438+
1439+ def _pymeshfix_fill_gentle (mesh : trimesh .Trimesh ) -> trimesh .Trimesh :
1440+ """Close boundary holes via pymeshfix without aggressive decimation.
1441+
1442+ ``fill_small_boundaries(nbe=0, refine=True)`` creates new triangles to seal
1443+ holes while preserving existing geometry. ``clean(max_iters=3, inner_loops=1)``
1444+ removes only degenerate faces introduced by the fill.
1445+ """
1446+ try :
1447+ from pymeshfix import PyTMesh
1448+
1449+ verts = np .asarray (mesh .vertices , dtype = np .float64 )
1450+ faces = np .asarray (mesh .faces , dtype = np .int64 )
1451+ mfix = PyTMesh ()
1452+ mfix .load_array (verts , faces )
1453+ mfix .fill_small_boundaries (nbe = 0 , refine = True )
1454+ mfix .clean (max_iters = 3 , inner_loops = 1 )
1455+ v , f = mfix .return_arrays ()
1456+ result = trimesh .Trimesh (
1457+ vertices = np .asarray (v , dtype = np .float64 ),
1458+ faces = np .asarray (f , dtype = np .int64 ),
1459+ process = True ,
1460+ )
1461+ return result
1462+ except Exception :
1463+ return mesh
1464+
1465+
13331466def manifold_repair (mesh : trimesh .Trimesh ) -> trimesh .Trimesh :
13341467 """Repara topologia non-manifold, duplicatas e vértices órfãos via pymeshlab."""
13351468 try :
@@ -2276,7 +2409,12 @@ def _is_box_cage(part: trimesh.Trimesh, *, cardinal_ratio_threshold: float = 0.8
22762409 return False
22772410 cardinal = np .any (np .abs (normals ) >= 0.85 , axis = 1 )
22782411 cardinal_ratio = float (areas [cardinal ].sum () / total_area )
2279- return cardinal_ratio >= cardinal_ratio_threshold
2412+ if cardinal_ratio < cardinal_ratio_threshold :
2413+ return False
2414+ # Elongated shapes (pillars, poles, swords) are never cages — a cage must be
2415+ # roughly cubic to surround another model.
2416+ extents_sorted = sorted (float (x ) for x in part .extents )
2417+ return extents_sorted [2 ] / max (extents_sorted [0 ], 1e-9 ) <= 2.5
22802418 except Exception :
22812419 return False
22822420
@@ -2297,8 +2435,17 @@ def _try_remove_single_component_cage(
22972435 Não faz pré-gate com ``_is_box_cage``: o mesh misto (modelo + cage) pode não
22982436 passar no teste de cardinalidade global, mas as paredes da jaula ainda têm
22992437 normais fortemente cardinais.
2438+
2439+ No entanto, rejeita meshes que não são aproximadamente cúbicos (cube_ratio > 2.5):
2440+ uma cage deve ser cúbica para envolver outro modelo; meshes alongados (como crates)
2441+ não podem conter uma cage interna significativa.
23002442 """
23012443 try :
2444+ # Quick check: a cage must be roughly cubic to surround another model.
2445+ extents_sorted = sorted (float (x ) for x in mesh .extents )
2446+ if extents_sorted [2 ] / max (extents_sorted [0 ], 1e-9 ) > 2.5 :
2447+ return mesh
2448+
23022449 bounds = mesh .bounds
23032450 centers = mesh .triangles_center
23042451 normals = mesh .face_normals
@@ -2333,9 +2480,6 @@ def _try_remove_single_component_cage(
23332480 return sub if not _is_box_cage (sub ) else mesh
23342481
23352482 parts_is_cage = [_is_box_cage (p ) for p in parts ]
2336- max_idx = max (range (len (parts )), key = lambda i : len (parts [i ].faces ))
2337- if parts_is_cage [max_idx ]:
2338- parts_is_cage [max_idx ] = False
23392483
23402484 kept = [p for p , cage in zip (parts , parts_is_cage , strict = True ) if not cage ]
23412485 if not kept :
@@ -2364,12 +2508,6 @@ def _remove_box_cage_components(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
23642508
23652509 is_cage = [_is_box_cage (p ) for p in parts ]
23662510
2367- # Never remove the largest component as a cage — it IS the model.
2368- # A cage must SURROUND another model; the largest part is always the model itself.
2369- max_idx = max (range (len (parts )), key = lambda i : len (parts [i ].faces ))
2370- if is_cage [max_idx ]:
2371- is_cage [max_idx ] = False
2372-
23732511 has_model = any (not c for c in is_cage )
23742512 if not has_model :
23752513 return mesh
@@ -2541,8 +2679,21 @@ def repair_mesh(
25412679 m = sub
25422680 m .remove_unreferenced_vertices ()
25432681 else :
2544- with contextlib .suppress (Exception ):
2545- m = make_watertight (m , max_hole_edges = max (fill_small_holes_max_edges , 500 ))
2682+ n_boundary = _boundary_edge_count (m )
2683+ if n_boundary > 200 :
2684+ # Cross-sectioned mesh: full make_watertight over-decimates.
2685+ # Try pymeshlab close_holes only (no pymeshfix clean/remesh).
2686+ m_backup = m .copy ()
2687+ with contextlib .suppress (Exception ):
2688+ m = make_watertight (m , max_hole_edges = max (fill_small_holes_max_edges , 500 ))
2689+ if len (m .faces ) < 0.70 * len (m_backup .faces ):
2690+ # Over-decimated — revert and try gentle fill via pymeshfix.
2691+ m = m_backup
2692+ with contextlib .suppress (Exception ):
2693+ m = _pymeshfix_fill_gentle (m )
2694+ else :
2695+ with contextlib .suppress (Exception ):
2696+ m = make_watertight (m , max_hole_edges = max (fill_small_holes_max_edges , 500 ))
25462697 elif fill_small_holes_max_edges > 0 :
25472698 with contextlib .suppress (Exception ):
25482699 _fill_small_boundary_holes_inplace (m , fill_small_holes_max_edges )
0 commit comments