Add bisect_and_cap helper to tool.Geometry

Bisects a BMesh against a set of planes (clear_outer per plane),
then fills the cut edges as cap faces tagged via a BMesh int layer
so the tag survives subsequent bisects. Cut edges are grouped into
connected components before filling so a hollow profile's outer +
inner loops produce two separate cap faces instead of a single
welded outer face that hides the hole. Pre-welds T-junctions
introduced by IFC Boolean meshes so the cut closes into a fillable
loop.

Callers are responsible for input mesh quality. Non-watertight
inputs (terrain, single-shell surfaces) may produce degenerate
cap faces — that's an accepted user-supplied data limitation
which can be revisited if real-world feedback shows it matters.

Used by the clip-box feature to compute cross-section caps per IFC
product mesh.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-16 13:26:08 +02:00
parent 5cc9daa2f9
commit 51eb8aece7
+106
View File
@@ -157,6 +157,112 @@ class Geometry(bonsai.core.tool.Geometry):
for modifier in obj.modifiers:
obj.modifiers.remove(modifier)
@classmethod
def _group_edges_into_loops(cls, edges) -> list[list]:
"""Group an edge set into connected components by shared vertices.
Each returned group is a list of edges that share at least one
vertex chain. A hollow profile's bisect produces two disjoint
loops (outer ring + inner ring) — grouping splits them so each
can be filled independently as a separate cap face, rather than
``contextual_create`` welding them into one solid outer face
with the inner loop demoted to interior decoration.
"""
edge_set = set(edges)
visited: set = set()
groups: list[list] = []
for start in edges:
if start in visited:
continue
group: list = []
stack: list = [start]
while stack:
e = stack.pop()
if e in visited:
continue
visited.add(e)
group.append(e)
for v in e.verts:
for adj in v.link_edges:
if adj in edge_set and adj not in visited:
stack.append(adj)
groups.append(group)
return groups
@classmethod
def bisect_and_cap(
cls,
bm,
planes_local,
*,
tag_layer_name: str = "bbim_cap",
dist: float = 1e-4,
weld_dist: float = 1e-5,
):
"""Clip ``bm`` against each ``(plane_co, plane_no)`` and fill the cuts.
Per plane, ``bmesh.ops.bisect_plane(clear_outer=True)`` discards
the outside half-space and ``bmesh.ops.contextual_create`` fills
the resulting cut edges with cap faces tagged via a BMesh int
layer so the tag propagates to any split-children from subsequent
planes. After all planes, near-coincident vertices are welded
(``weld_dist``) so adjacent caps from the same cross-section
merge cleanly.
Callers are responsible for input mesh quality. Non-watertight
inputs (terrain, single-shell surfaces) may produce degenerate
cap faces; that's an accepted user-supplied data limitation.
Returns the cap-tag BMLayerItem, or ``None`` if ``bm`` is empty.
"""
import bmesh
if not bm.faces:
return None
# Pre-weld nearby verts: T-junctions in messy IFC meshes (a third
# vertex sitting in the middle of an edge from a Boolean
# operation) make the bisect cut terminate early, leaving open
# loops that no fill op can close. Welding the T-junction's
# near-coincident vertex into the host edge before bisecting
# turns the cut into a closed loop.
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=max(weld_dist, 1e-4))
cap_layer = bm.faces.layers.int.new(tag_layer_name)
for plane_co, plane_no in planes_local:
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
if not geom:
break
results = bmesh.ops.bisect_plane(
bm,
geom=geom,
dist=dist,
plane_co=plane_co,
plane_no=plane_no,
clear_outer=True,
)
cut_edges = [e for e in results["geom_cut"] if isinstance(e, bmesh.types.BMEdge)]
if not cut_edges:
continue
# Group cut edges into connected components BEFORE filling.
# Feeding ``contextual_create`` all edges at once (outer +
# inner of a hollow profile) makes it create a SINGLE outer
# face and treat inner edges as decoration — collapsing the
# hole. Filling each connected loop separately produces one
# cap face per ring.
for loop_edges in cls._group_edges_into_loops(cut_edges):
try:
fill = bmesh.ops.contextual_create(bm, geom=loop_edges)
except (RuntimeError, TypeError):
continue
for f in fill.get("faces", []):
if isinstance(f, bmesh.types.BMFace) and f.is_valid:
f[cap_layer] = 1
if weld_dist > 0.0:
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=weld_dist)
return cap_layer
@classmethod
def clear_scale(cls, obj: bpy.types.Object) -> None:
"""Apply and clear object scale.