mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 10:33:20 +00:00
Extract boundary generation to ifcopenshell.util.boundary
Move Blender-independent boundary generation algorithm from Bonsai (GPL) to ifcopenshell.util.boundary (LGPL): - ifcopenshell.util.shape.dissolve_faces: reconstruct polygonal faces from triangulated mesh using original edges from get_edges() + Union-Find - ifcopenshell.util.boundary.auto_generate_boundaries: full boundary generation algorithm using IFC geometry (numpy, shapely) without Blender — replaces bmesh, matrix_world, tool.Cad.is_x, mathutils with numpy equivalents - Uses existing ifcopenshell.api.boundary.assign_connection_geometry for connection geometry creation - Uses existing ifcopenshell.util.placement.a2p + np_normal for face matrix construction - BOUNDARY_ELEMENT_CLASSES expanded to include IfcColumn and IfcCurtainWall Bonsai's boundary/operator.py auto_generate_boundaries is now a thin adapter handling Blender-specific preprocessing (flushing moved objects, building iterator + tree) then delegating to the util module. Added 12 tests: 3 for dissolve_faces, 3 for auto_generate_boundaries. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Blender-independent IfcRelSpaceBoundary generation from IFC geometry.
|
||||
|
||||
These functions operate on IFC geometry data (vertices, faces, edges,
|
||||
element relationships) without requiring any Blender objects to be loaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from math import acos, degrees
|
||||
from typing import Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.boundary
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.shape_builder as sb
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
import shapely
|
||||
import shapely.ops
|
||||
|
||||
logger = logging.getLogger("ImportIFC")
|
||||
|
||||
BOUNDARY_ELEMENT_CLASSES = ("IfcWall", "IfcColumn", "IfcSlab", "IfcVirtualElement", "IfcCurtainWall")
|
||||
|
||||
|
||||
def auto_generate_boundaries(
|
||||
ifc_file: ifcopenshell.file,
|
||||
space: ifcopenshell.entity_instance,
|
||||
shapes: dict,
|
||||
boundary_class: str,
|
||||
boundary_element_classes: tuple = BOUNDARY_ELEMENT_CLASSES,
|
||||
) -> Union[str, list[ifcopenshell.entity_instance]]:
|
||||
"""Generate IfcRelSpaceBoundary records from IFC geometry without Blender.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param space: The IfcSpace entity to generate boundaries for.
|
||||
:param shapes: Dict ``{element_id: {"verts": ndarray, "faces": ndarray,
|
||||
"edges": ndarray, "matrix": ndarray}}``. Must include the space itself.
|
||||
Built by the caller via ``ifcopenshell.geom.iterator``.
|
||||
:param boundary_class: IFC class for boundaries (e.g.
|
||||
``"IfcRelSpaceBoundary2ndLevel"``).
|
||||
:param boundary_element_classes: IFC classes to consider as boundary elements.
|
||||
:return: List of created ``IfcRelSpaceBoundary`` entities, or error string.
|
||||
"""
|
||||
boundaries: list[ifcopenshell.entity_instance] = []
|
||||
|
||||
space_shape = shapes.get(space.id())
|
||||
if space_shape is None:
|
||||
return "Space geometry not found in shapes dict."
|
||||
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
|
||||
# Identify all potential building elements
|
||||
building_elements = []
|
||||
for ifc_class in boundary_element_classes:
|
||||
building_elements.extend(ifc_file.by_type(ifc_class))
|
||||
|
||||
# Don't generate boundaries for elements that already have boundaries
|
||||
for boundary in space.BoundedBy:
|
||||
if boundary.RelatedBuildingElement in building_elements:
|
||||
building_elements.remove(boundary.RelatedBuildingElement)
|
||||
|
||||
# Filter to elements that have shapes in the cache
|
||||
building_elements = [e for e in building_elements if e.id() in shapes]
|
||||
|
||||
if not building_elements:
|
||||
return "No building elements found to create boundaries."
|
||||
|
||||
# Dissolve space mesh — verts are in local coords, matrix is the placement
|
||||
space_matrix = space_shape["matrix"]
|
||||
space_matrix_3x3 = space_matrix[:3, :3]
|
||||
space_matrix_inv = np.linalg.inv(space_matrix)
|
||||
# Space verts are already local (get_vertices without use-world-coords)
|
||||
space_verts_local = space_shape["verts"]
|
||||
space_ngons = ifcopenshell.util.shape.dissolve_faces(
|
||||
space_verts_local, space_shape["faces"], space_shape["edges"], merge_coplanar=True
|
||||
)
|
||||
|
||||
# Dissolve building element meshes — verts are in element-local coords
|
||||
element_ngons = {}
|
||||
for element in building_elements:
|
||||
es = shapes[element.id()]
|
||||
element_ngons[element.id()] = ifcopenshell.util.shape.dissolve_faces(
|
||||
es["verts"], es["faces"], es["edges"], merge_coplanar=True
|
||||
)
|
||||
|
||||
# Compare space faces and building element faces
|
||||
for space_ngon in space_ngons:
|
||||
space_verts_l = space_verts_local[space_ngon]
|
||||
# Normal from local verts, then transform to world via space placement
|
||||
space_face_normal_local = _face_normal(space_verts_l)
|
||||
if space_face_normal_local is None:
|
||||
continue
|
||||
space_face_normal_world = space_matrix_3x3 @ space_face_normal_local
|
||||
|
||||
for element in building_elements:
|
||||
element_shape = shapes[element.id()]
|
||||
element_matrix = element_shape["matrix"]
|
||||
element_matrix_3x3 = element_matrix[:3, :3]
|
||||
element_matrix_inv = np.linalg.inv(element_matrix)
|
||||
|
||||
for ngon in element_ngons[element.id()]:
|
||||
elem_verts_l = element_shape["verts"][ngon]
|
||||
# Normal from local verts, transform to world via element placement
|
||||
elem_face_normal_local = _face_normal(elem_verts_l)
|
||||
if elem_face_normal_local is None:
|
||||
continue
|
||||
elem_face_normal_world = element_matrix_3x3 @ elem_face_normal_local
|
||||
|
||||
# Both normals point outward from their respective solids.
|
||||
# Adjacent faces have anti-parallel normals (angle ≈ 180°).
|
||||
# Virtual elements use parallel normals (angle ≈ 0°).
|
||||
angle = degrees(acos(max(min(float(np.dot(space_face_normal_world, elem_face_normal_world)), 1), -1)))
|
||||
if _is_x(angle, 180, tolerance=2):
|
||||
pass
|
||||
elif element.is_a("IfcVirtualElement") and _is_x(angle, 0, tolerance=2):
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
|
||||
# Distance check: transform space vert to element-local, compare to element face
|
||||
# space-local -> world -> element-local
|
||||
space_vert_in_elem = sb.np_apply_matrix(space_verts_l[:1], element_matrix_inv @ space_matrix)[0]
|
||||
dist = float(np.dot(space_vert_in_elem - elem_verts_l[0], elem_face_normal_local))
|
||||
if abs(dist) > 0.05:
|
||||
continue
|
||||
|
||||
# Build face matrix in space-local coordinates
|
||||
# (assign_connection_geometry expects location/axes relative to space placement)
|
||||
face_matrix = _face_matrix_from_verts(space_verts_l[:3])
|
||||
face_matrix_inv = np.linalg.inv(face_matrix)
|
||||
|
||||
# Project space face (already space-local) to 2D
|
||||
space_face_polygon = _verts_to_polygon(space_verts_l, face_matrix_inv)
|
||||
if not space_face_polygon.is_valid:
|
||||
space_face_polygon = space_face_polygon.buffer(0)
|
||||
|
||||
# Transform element verts to space-local, then project to 2D
|
||||
# element-local -> world -> space-local
|
||||
elem_verts_in_space = sb.np_apply_matrix(elem_verts_l, space_matrix_inv @ element_matrix)
|
||||
face_polygon = _verts_to_polygon(elem_verts_in_space, face_matrix_inv)
|
||||
if not face_polygon.is_valid:
|
||||
face_polygon = face_polygon.buffer(0)
|
||||
|
||||
try:
|
||||
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
|
||||
except shapely.errors.GEOSException:
|
||||
logger.warning(
|
||||
"Skipping invalid geometry for %s (shapely topology error).",
|
||||
element.Name or element.is_a(),
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if type(gross_boundary_polygon) == shapely.GeometryCollection:
|
||||
for geom in gross_boundary_polygon.geoms:
|
||||
if type(geom) == shapely.Polygon:
|
||||
gross_boundary_polygon = geom
|
||||
break
|
||||
|
||||
if not (isinstance(gross_boundary_polygon, shapely.Polygon) and gross_boundary_polygon.is_valid):
|
||||
continue
|
||||
if gross_boundary_polygon.is_empty:
|
||||
continue
|
||||
|
||||
exterior_boundary_polygon = shapely.Polygon(gross_boundary_polygon.exterior.coords)
|
||||
|
||||
# Create parent boundary
|
||||
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
|
||||
if element.is_a("IfcVirtualElement"):
|
||||
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
|
||||
else:
|
||||
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
|
||||
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
|
||||
_set_internal_external(parent_boundary, element)
|
||||
parent_boundary.RelatingSpace = space
|
||||
parent_boundary.RelatedBuildingElement = element
|
||||
|
||||
_assign_connection_geometry(
|
||||
ifc_file,
|
||||
parent_boundary,
|
||||
exterior_boundary_polygon,
|
||||
face_matrix,
|
||||
unit_scale,
|
||||
)
|
||||
_set_boundary_name(parent_boundary)
|
||||
boundaries.append(parent_boundary)
|
||||
|
||||
# Process openings
|
||||
boundaries.extend(
|
||||
_process_openings(
|
||||
ifc_file,
|
||||
element,
|
||||
elem_face_normal_world,
|
||||
space_matrix_inv,
|
||||
element_matrix,
|
||||
face_matrix,
|
||||
face_matrix_inv,
|
||||
exterior_boundary_polygon,
|
||||
boundary_class,
|
||||
parent_boundary,
|
||||
space,
|
||||
unit_scale,
|
||||
)
|
||||
)
|
||||
|
||||
return boundaries
|
||||
|
||||
|
||||
def _process_openings(
|
||||
ifc_file,
|
||||
building_element,
|
||||
face_normal_world,
|
||||
space_matrix_inv,
|
||||
element_matrix,
|
||||
face_matrix,
|
||||
face_matrix_inv,
|
||||
exterior_boundary_polygon,
|
||||
boundary_class,
|
||||
parent_boundary,
|
||||
space,
|
||||
unit_scale,
|
||||
):
|
||||
"""Process openings and fillings for a building element.
|
||||
|
||||
:param face_normal_world: The building element face normal in world space.
|
||||
:param space_matrix_inv: Inverse of the space placement matrix.
|
||||
:param element_matrix: The building element placement matrix.
|
||||
:param face_matrix: The face matrix in space-local coordinates (for connection geometry).
|
||||
:param face_matrix_inv: The inverse face matrix (for 2D projection).
|
||||
"""
|
||||
boundaries = []
|
||||
|
||||
for rel in getattr(building_element, "HasOpenings", []):
|
||||
opening = rel.RelatedOpeningElement
|
||||
filling = opening.HasFillings[0].RelatedBuildingElement if opening.HasFillings else None
|
||||
|
||||
settings = ifcopenshell.geom.settings()
|
||||
try:
|
||||
shape = ifcopenshell.geom.create_shape(settings, opening)
|
||||
except Exception:
|
||||
continue
|
||||
opening_verts_l = ifcopenshell.util.shape.get_vertices(shape.geometry)
|
||||
opening_faces = ifcopenshell.util.shape.get_faces(shape.geometry)
|
||||
opening_edges = ifcopenshell.util.shape.get_edges(shape.geometry)
|
||||
opening_matrix = ifcopenshell.util.shape.get_shape_matrix(shape)
|
||||
opening_matrix_3x3 = opening_matrix[:3, :3]
|
||||
|
||||
opening_ngons = ifcopenshell.util.shape.dissolve_faces(
|
||||
opening_verts_l, opening_faces, opening_edges, merge_coplanar=True
|
||||
)
|
||||
|
||||
opening_polygons = []
|
||||
for ngon in opening_ngons:
|
||||
o_verts_l = opening_verts_l[ngon]
|
||||
# Normal from local verts, transform to world via opening placement
|
||||
o_normal_local = _face_normal(o_verts_l)
|
||||
if o_normal_local is None:
|
||||
continue
|
||||
o_normal_world = opening_matrix_3x3 @ o_normal_local
|
||||
angle = degrees(acos(max(min(float(np.dot(o_normal_world, face_normal_world)), 1), -1)))
|
||||
if not _is_x(angle, 180, tolerance=2):
|
||||
continue
|
||||
# Transform opening verts to space-local: opening-local -> world -> space-local
|
||||
o_verts_in_space = sb.np_apply_matrix(o_verts_l, space_matrix_inv @ opening_matrix)
|
||||
polygon = _verts_to_polygon(o_verts_in_space, face_matrix_inv)
|
||||
opening_polygons.append(polygon)
|
||||
|
||||
if not opening_polygons:
|
||||
continue
|
||||
|
||||
opening_polygon = shapely.ops.unary_union(opening_polygons)
|
||||
|
||||
if opening_polygon.intersection(exterior_boundary_polygon).area == 0:
|
||||
continue
|
||||
|
||||
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=boundary_class)
|
||||
boundary.RelatingSpace = space
|
||||
boundary.RelatedBuildingElement = filling or opening
|
||||
|
||||
# Use the same space-local face_matrix for connection geometry
|
||||
_assign_connection_geometry(
|
||||
ifc_file,
|
||||
boundary,
|
||||
opening_polygon,
|
||||
face_matrix,
|
||||
unit_scale,
|
||||
)
|
||||
if filling:
|
||||
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
|
||||
else:
|
||||
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
|
||||
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
|
||||
if boundary.is_a() != "IfcRelSpaceBoundary":
|
||||
boundary.ParentBoundary = parent_boundary
|
||||
_set_boundary_name(boundary)
|
||||
boundaries.append(boundary)
|
||||
|
||||
return boundaries
|
||||
|
||||
|
||||
def _face_normal(verts: np.ndarray) -> Optional[np.ndarray]:
|
||||
"""Compute the normal of a polygon from its vertices."""
|
||||
if len(verts) < 3:
|
||||
return None
|
||||
return sb.np_normal([verts[0], verts[1], verts[2]])
|
||||
|
||||
|
||||
def _face_matrix_from_verts(verts3: np.ndarray) -> np.ndarray:
|
||||
"""Build a 4x4 face-local coordinate matrix from 3 vertices."""
|
||||
p1, p2, p3 = verts3[0], verts3[1], verts3[2]
|
||||
z = sb.np_normal([p1, p2, p3])
|
||||
x = sb.np_normalized(p2 - p1)
|
||||
return ifcopenshell.util.placement.a2p(o=p1, z=z, x=x)
|
||||
|
||||
|
||||
def _verts_to_polygon(verts: np.ndarray, face_matrix_inv: np.ndarray) -> shapely.Polygon:
|
||||
"""Project 3D vertices onto a 2D plane and create a shapely Polygon."""
|
||||
verts_2d = sb.np_apply_matrix(verts, face_matrix_inv)[:, :2]
|
||||
return shapely.Polygon([tuple(v) for v in verts_2d])
|
||||
|
||||
|
||||
def _assign_connection_geometry(
|
||||
ifc_file: ifcopenshell.file,
|
||||
boundary: ifcopenshell.entity_instance,
|
||||
polygon: shapely.Polygon,
|
||||
face_matrix: np.ndarray,
|
||||
unit_scale: float,
|
||||
) -> None:
|
||||
"""Assign connection geometry to a boundary using the existing API."""
|
||||
location = face_matrix[:3, 3]
|
||||
axis = face_matrix[:3, 0]
|
||||
ref_direction = face_matrix[:3, 2]
|
||||
|
||||
outer_boundary = [list(coord) for coord in polygon.exterior.coords[:-1]]
|
||||
inner_boundaries = [list(interior.coords[:-1]) for interior in polygon.interiors]
|
||||
|
||||
ifcopenshell.api.boundary.assign_connection_geometry(
|
||||
ifc_file,
|
||||
rel_space_boundary=boundary,
|
||||
outer_boundary=outer_boundary,
|
||||
location=location.tolist(),
|
||||
axis=axis.tolist(),
|
||||
ref_direction=ref_direction.tolist(),
|
||||
inner_boundaries=inner_boundaries if inner_boundaries else None,
|
||||
unit_scale=unit_scale,
|
||||
)
|
||||
|
||||
|
||||
def _set_internal_external(
|
||||
boundary: ifcopenshell.entity_instance, building_element: ifcopenshell.entity_instance
|
||||
) -> None:
|
||||
"""Set InternalOrExternalBoundary based on element type and psets."""
|
||||
if building_element.is_a("IfcWall"):
|
||||
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_WallCommon", "IsExternal")
|
||||
if is_external is True:
|
||||
boundary.InternalOrExternalBoundary = "EXTERNAL"
|
||||
elif is_external is False:
|
||||
boundary.InternalOrExternalBoundary = "INTERNAL"
|
||||
elif building_element.is_a("IfcSlab"):
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
|
||||
if predefined_type == "BASESLAB":
|
||||
boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
|
||||
else:
|
||||
is_external = ifcopenshell.util.element.get_pset(building_element, "Pset_SlabCommon", "IsExternal")
|
||||
if is_external is True:
|
||||
boundary.InternalOrExternalBoundary = "EXTERNAL"
|
||||
elif is_external is False:
|
||||
boundary.InternalOrExternalBoundary = "INTERNAL"
|
||||
|
||||
|
||||
def _set_boundary_name(boundary: ifcopenshell.entity_instance) -> None:
|
||||
"""Set Name/Description per IFC4x3 convention."""
|
||||
if boundary.is_a("IfcRelSpaceBoundary2ndLevel"):
|
||||
boundary.Name = "2ndLevel"
|
||||
if boundary.CorrespondingBoundary:
|
||||
boundary.Description = "2a"
|
||||
else:
|
||||
boundary.Description = "2b"
|
||||
elif boundary.is_a("IfcRelSpaceBoundary1stLevel"):
|
||||
boundary.Name = "1stLevel"
|
||||
|
||||
|
||||
def _is_x(value: float, x: float, tolerance: float = 1e-5) -> bool:
|
||||
"""Check whether value is within tolerance of x."""
|
||||
return (x + tolerance) > value > (x - tolerance)
|
||||
@@ -830,3 +830,190 @@ def bisect_mesh_plane_vf(
|
||||
else:
|
||||
segments.append(pts_xy)
|
||||
return segments
|
||||
|
||||
|
||||
def dissolve_faces(
|
||||
verts: npt.NDArray[np.float64],
|
||||
faces: npt.NDArray[np.int32],
|
||||
edges: npt.NDArray[np.int32],
|
||||
merge_coplanar: bool = False,
|
||||
angle_tolerance: float = 0.017453292519943295,
|
||||
) -> list[list[int]]:
|
||||
"""Reconstruct polygonal faces from triangulated mesh data.
|
||||
|
||||
Uses the original (pre-triangulation) edges from ``get_edges`` to
|
||||
identify which triangle edges are internal (to be merged) vs external
|
||||
(ngon boundaries). Triangles connected by internal edges are grouped
|
||||
into polygonal faces.
|
||||
|
||||
When ``merge_coplanar`` is True, a second pass merges adjacent ngons
|
||||
whose face normals are parallel within ``angle_tolerance`` radians.
|
||||
This mirrors ``bmesh.ops.dissolve_limit`` behavior where coplanar
|
||||
faces sharing an edge are merged regardless of the original face
|
||||
structure. This is needed when the IFC representation splits a single
|
||||
planar face into multiple faces (e.g. an L-shaped top face split into
|
||||
triangles + quads).
|
||||
|
||||
:param verts: (n, 3) array of vertices.
|
||||
:param faces: (m, 3) array of triangle vertex indices.
|
||||
:param edges: (e, 2) array of original (pre-triangulation) edge vertex
|
||||
indices, as returned by :func:`get_edges`.
|
||||
:param merge_coplanar: If True, merge adjacent coplanar ngons.
|
||||
:param angle_tolerance: Angle in radians for coplanar merge (default 1°).
|
||||
:return: List of polygonal faces, each as an ordered list of vertex indices
|
||||
forming a closed polygon (last vertex connects back to first).
|
||||
"""
|
||||
if len(faces) == 0:
|
||||
return []
|
||||
if len(edges) == 0:
|
||||
return [list(f) for f in faces]
|
||||
|
||||
original_edges = {frozenset((int(e[0]), int(e[1]))) for e in edges}
|
||||
|
||||
tri_edges = []
|
||||
for f in faces:
|
||||
tri_edges.append(
|
||||
(
|
||||
frozenset((int(f[0]), int(f[1]))),
|
||||
frozenset((int(f[1]), int(f[2]))),
|
||||
frozenset((int(f[2]), int(f[0]))),
|
||||
)
|
||||
)
|
||||
|
||||
internal_edge_to_tris: dict[frozenset, list[int]] = {}
|
||||
for tri_idx, edges_3 in enumerate(tri_edges):
|
||||
for e in edges_3:
|
||||
if e not in original_edges:
|
||||
internal_edge_to_tris.setdefault(e, []).append(tri_idx)
|
||||
|
||||
parent = list(range(len(faces)))
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(x, y):
|
||||
px, py = find(x), find(y)
|
||||
if px != py:
|
||||
parent[px] = py
|
||||
|
||||
for tri_indices in internal_edge_to_tris.values():
|
||||
if len(tri_indices) == 2:
|
||||
union(tri_indices[0], tri_indices[1])
|
||||
|
||||
ngons: dict[int, list[int]] = {}
|
||||
for tri_idx in range(len(faces)):
|
||||
root = find(tri_idx)
|
||||
ngons.setdefault(root, []).append(tri_idx)
|
||||
|
||||
if merge_coplanar:
|
||||
_merge_coplanar_ngons(ngons, faces, verts, tri_edges, parent, find, union, angle_tolerance)
|
||||
|
||||
result = []
|
||||
for tri_indices in ngons.values():
|
||||
tri_edge_set = set()
|
||||
for tri_idx in tri_indices:
|
||||
for e in tri_edges[tri_idx]:
|
||||
tri_edge_set.add(e)
|
||||
|
||||
boundary_edges = [e for e in tri_edge_set if e in original_edges]
|
||||
|
||||
if not boundary_edges:
|
||||
result.append(list(faces[tri_indices[0]]))
|
||||
continue
|
||||
|
||||
edge_adjacency: dict[int, int] = {}
|
||||
for e in boundary_edges:
|
||||
v_list = list(e)
|
||||
for tri_idx in tri_indices:
|
||||
f = faces[tri_idx]
|
||||
f_edges = [(int(f[0]), int(f[1])), (int(f[1]), int(f[2])), (int(f[2]), int(f[0]))]
|
||||
for fe in f_edges:
|
||||
if frozenset(fe) == e:
|
||||
edge_adjacency[fe[0]] = fe[1]
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
start = next(iter(edge_adjacency))
|
||||
polygon = [start]
|
||||
current = edge_adjacency[start]
|
||||
while current != start:
|
||||
polygon.append(current)
|
||||
if current not in edge_adjacency:
|
||||
break
|
||||
current = edge_adjacency[current]
|
||||
result.append(polygon)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _merge_coplanar_ngons(
|
||||
ngons: dict[int, list[int]],
|
||||
faces: npt.NDArray[np.int32],
|
||||
verts: npt.NDArray[np.float64],
|
||||
tri_edges: list,
|
||||
parent: list[int],
|
||||
find,
|
||||
union,
|
||||
angle_tolerance: float,
|
||||
) -> None:
|
||||
"""Merge adjacent ngons whose face normals are parallel within tolerance.
|
||||
|
||||
Modifies ``ngons`` and ``parent`` in place.
|
||||
"""
|
||||
from math import acos
|
||||
|
||||
# Compute normal for each ngon
|
||||
ngon_normals: dict[int, np.ndarray] = {}
|
||||
ngon_edge_to_ngons: dict[frozenset, list[int]] = {}
|
||||
ngon_roots = list(ngons.keys())
|
||||
|
||||
for root in ngon_roots:
|
||||
tri_indices = ngons[root]
|
||||
f0 = faces[tri_indices[0]]
|
||||
v0, v1, v2 = verts[f0[0]], verts[f0[1]], verts[f0[2]]
|
||||
edge1 = v1 - v0
|
||||
edge2 = v2 - v0
|
||||
normal = np.cross(edge1, edge2)
|
||||
norm = np.linalg.norm(normal)
|
||||
if norm > 0:
|
||||
normal = normal / norm
|
||||
ngon_normals[root] = normal
|
||||
|
||||
# Collect all edges of this ngon
|
||||
ngon_edges = set()
|
||||
for tri_idx in tri_indices:
|
||||
for e in tri_edges[tri_idx]:
|
||||
ngon_edges.add(e)
|
||||
for e in ngon_edges:
|
||||
ngon_edge_to_ngons.setdefault(e, []).append(root)
|
||||
|
||||
# Find shared edges between different ngons and check coplanarity
|
||||
for edge, root_list in ngon_edge_to_ngons.items():
|
||||
if len(root_list) != 2:
|
||||
continue
|
||||
root_a, root_b = root_list[0], root_list[1]
|
||||
if root_a == root_b:
|
||||
continue
|
||||
# Check if already merged
|
||||
ra, rb = find(root_a), find(root_b)
|
||||
if ra == rb:
|
||||
continue
|
||||
# Compare normals
|
||||
na, nb = ngon_normals[root_a], ngon_normals[root_b]
|
||||
dot = max(min(float(np.dot(na, nb)), 1.0), -1.0)
|
||||
angle = acos(dot)
|
||||
if angle < angle_tolerance:
|
||||
union(root_a, root_b)
|
||||
|
||||
# Rebuild ngons dict with merged groups
|
||||
new_ngons: dict[int, list[int]] = {}
|
||||
for root in ngon_roots:
|
||||
new_root = find(root)
|
||||
new_ngons.setdefault(new_root, []).extend(ngons[root])
|
||||
ngons.clear()
|
||||
ngons.update(new_ngons)
|
||||
|
||||
Reference in New Issue
Block a user