mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-21 12:36:00 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e084f2830 | |||
| dd5bd58916 | |||
| c3abe0b3c7 | |||
| 7017d5400d | |||
| fb3cd09d6d | |||
| 332435416a | |||
| f0c6de4bdf | |||
| 665c5fa77e | |||
| de13379162 | |||
| 3b16356181 | |||
| 44860cd615 | |||
| 5651cd6494 | |||
| 29b9d8807e | |||
| 37f557b4b5 | |||
| 881fb10fe6 | |||
| 1949adda44 | |||
| 87193ac323 | |||
| c34a6ddac6 | |||
| 92cc601a85 | |||
| adf01be1d0 | |||
| c8f8196b09 | |||
| cb22dd7a43 | |||
| 243f13de09 | |||
| 0d5c9a0ce2 | |||
| ea6f03409f | |||
| 21b4cd2403 | |||
| a543022bba | |||
| 6f9d5c4005 | |||
| a7b6c66f77 | |||
| 6dc671f24d |
@@ -17,3 +17,6 @@
|
||||
[submodule "src/svgfill/3rdparty/svgpp"]
|
||||
path = src/svgfill/3rdparty/svgpp
|
||||
url = https://github.com/svgpp/svgpp
|
||||
[submodule "src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles"]
|
||||
path = src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles
|
||||
url = https://github.com/CyrilWaechter/IfcRelSpaceBoundary_TestFiles
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Design Spec: Space Regeneration with Sloped Roofs, Walls, and Slabs
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `bonsai.core.spatial.generate_space` so it produces correct `IfcSpace`
|
||||
geometry for non-rectilinear envelopes:
|
||||
|
||||
- sloped roofs,
|
||||
- sloped slabs,
|
||||
- sloped walls,
|
||||
- curved walls.
|
||||
|
||||
The existing footprint-based `IfcExtrudedAreaSolid` path is preserved for
|
||||
ordinary vertical extrusions. A new hybrid path keeps the representation
|
||||
parametric when possible and falls back to an `IfcFacetedBrep` only when the
|
||||
boundary cannot be expressed as a clipped extrusion.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Existing footprint generation │
|
||||
│ (get_space_polygon_from_*_objects) │
|
||||
└──────────────┬────────────────────────────┘
|
||||
v
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Detect extrudability and bounding planes │
|
||||
│ (pure-Python util, Blender-independent) │
|
||||
└──────────────┬────────────────────────────┘
|
||||
v
|
||||
┌──────┴──────┐
|
||||
v v
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Extrusion + clips │ │ B-rep fallback │
|
||||
│ IfcExtrudedAreaSolid│ │ IfcFacetedBrep │
|
||||
│ + IfcBooleanClippingResult│ │ (or IfcPolygonalFaceSet) │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
## Prior art
|
||||
|
||||
- **CBIP** (Lilis et al.): constructive solid geometry approach that builds
|
||||
space volumes as half-space intersections of bounding planes — the basis for
|
||||
the parametric clipping path.
|
||||
- **Fichter et al. 2021**: ray-tracing method for automatic boundary
|
||||
generation; motivates the use of `geom.tree.select_ray` for top/bottom plane
|
||||
detection.
|
||||
- **Lilis et al. 2021**: semi-automatic boundary recognition; informs the
|
||||
fallback to existing `boundary.auto_generate_boundaries` machinery.
|
||||
- **Ying & Lee 2019**: faceting of curved walls; motivates the B-rep fallback
|
||||
for curved-in-plan walls that cannot be represented as vertical extruded
|
||||
profiles.
|
||||
|
||||
## Detection criteria
|
||||
|
||||
Use the parametric `IfcExtrudedAreaSolid` + `IfcBooleanClippingResult` path
|
||||
when **all** are true:
|
||||
|
||||
1. Side walls are vertical extrusions (face normal is horizontal).
|
||||
Curved-in-plan walls are allowed; their footprint is polygonized or
|
||||
reconstructed as a curved profile.
|
||||
2. The roof/top boundary is piecewise-planar.
|
||||
3. The bottom slab/floor boundary is piecewise-planar.
|
||||
4. The footprint is a single closed outer region, possibly with inner closed
|
||||
regions for holes.
|
||||
5. The resulting half-space intersection is non-empty and produces a single
|
||||
solid.
|
||||
|
||||
Otherwise use the B-rep fallback.
|
||||
|
||||
## Parametric extrusion + clipping algorithm
|
||||
|
||||
1. **Build the profile**
|
||||
- Outer ring from the footprint polygon → `IfcArbitraryClosedProfileDef`.
|
||||
- Inner rings (holes, e.g., around columns) →
|
||||
`IfcArbitraryProfileDefWithVoids`.
|
||||
2. **Extrude**
|
||||
- Create `IfcExtrudedAreaSolid` along local +Z, with a height large enough
|
||||
to cover all bounding planes.
|
||||
3. **Find top planes**
|
||||
- Cast vertical rays upward from the footprint centroid and sample points
|
||||
using `ifcopenshell.geom.tree.select_ray`.
|
||||
- Check each hit face for planarity with
|
||||
`ifcopenshell.util.shape.dissolve_faces(..., merge_coplanar=True)`.
|
||||
- Group coplanar hits into distinct planes.
|
||||
4. **Find bottom planes**
|
||||
- Same as top, but downward.
|
||||
5. **Clip**
|
||||
- For each top plane: create `IfcHalfSpaceSolid` with normal pointing
|
||||
upward (removed side), apply via `ifcopenshell.api.geometry.clip_solid`.
|
||||
- For each bottom plane: create `IfcHalfSpaceSolid` with normal pointing
|
||||
downward, apply via `clip_solid`.
|
||||
6. **Output**
|
||||
- `IfcExtrudedAreaSolid` wrapped in a chain of `IfcBooleanClippingResult`.
|
||||
|
||||
## B-rep fallback algorithm
|
||||
|
||||
For non-extrudable cases (sloped walls, curved roofs, etc.):
|
||||
|
||||
1. **Seed space**
|
||||
- Create a temporary rough mesh (e.g., extruded footprint bounding box) as
|
||||
a placeholder.
|
||||
2. **Extract boundary faces**
|
||||
- Run `ifcopenshell.util.boundary.auto_generate_boundaries` against the
|
||||
seed to identify the faces of bounding elements that touch the space.
|
||||
- Convert each boundary polygon from face-local back to 3D world
|
||||
coordinates.
|
||||
3. **Build closed shell**
|
||||
- Collect the 3D boundary faces.
|
||||
- Add narrow gap-closing faces if `auto_generate_boundaries` leaves
|
||||
unmatched edges.
|
||||
- Triangulate and produce `IfcClosedShell` → `IfcFacetedBrep` (or
|
||||
`IfcPolygonalFaceSet` for IFC4+).
|
||||
4. **Clean up**
|
||||
- Assign the B-rep to the `IfcSpace` and remove the temporary seed
|
||||
geometry.
|
||||
|
||||
## Files to touch
|
||||
|
||||
- `src/ifcopenshell-python/ifcopenshell/util/space.py`
|
||||
- New: `detect_space_volume_strategy`
|
||||
- New: `build_extruded_clipped_space`
|
||||
- New: `build_brep_space`
|
||||
- New helpers for ray-cast plane detection and face planarity checks.
|
||||
- `src/bonsai/bonsai/tool/spatial.py`
|
||||
- Extend `set_space_representation_from_polygon` to dispatch to the new
|
||||
strategy.
|
||||
- Extend footprint/profile creation to support inner rings for holes.
|
||||
- `src/bonsai/bonsai/core/spatial.py`
|
||||
- `generate_space` calls the dispatcher.
|
||||
|
||||
## Testing
|
||||
|
||||
- Add unit tests in `src/ifcopenshell-python/test/util/test_space.py` for pure
|
||||
geometry helpers:
|
||||
- simple shed roof,
|
||||
- gable roof,
|
||||
- sloped slab,
|
||||
- L-shaped footprint with sloped roof,
|
||||
- curved wall.
|
||||
- Add Bonsai tests in `src/bonsai/test/tool/test_spatial.py` for end-to-end
|
||||
`generate_space` with non-rectilinear geometry.
|
||||
|
||||
## Error handling
|
||||
|
||||
- If detection fails or half-space clipping produces an invalid result, fall
|
||||
back to the B-rep path.
|
||||
- If the B-rep path also fails, return an error string and leave the existing
|
||||
space representation unchanged.
|
||||
|
||||
## Known limitations and non-goals
|
||||
|
||||
- **Curved (single/double-curvature) roofs and domes** are handled only via the
|
||||
B-rep fallback; they are not expressible as `IfcExtrudedAreaSolid` +
|
||||
`IfcBooleanClippingResult` in this design.
|
||||
- The B-rep fallback produces **non-parametric** geometry: the resulting
|
||||
`IfcFacetedBrep`/`IfcPolygonalFaceSet` cannot be re-edited parametrically by
|
||||
the user afterwards. This is an accepted trade-off; the parametric path is
|
||||
preferred whenever detection succeeds.
|
||||
- The B-rep fallback depends on `boundary.auto_generate_boundaries`, so it
|
||||
inherits its assumptions: bounding elements must be related to the space and
|
||||
the seed volume must intersect them. Gap-closing faces may produce
|
||||
non-manifold output for degenerate envelopes; we accept this for
|
||||
non-extrudable edge cases.
|
||||
- The parametric path requires a single closed outer footprint with optional
|
||||
inner holes. Multi-region disconnected footprints are not supported and fall
|
||||
back to B-rep.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
from math import acos, degrees, inf, pi, radians
|
||||
from math import inf, pi
|
||||
from typing import Optional, Union
|
||||
|
||||
import bmesh
|
||||
@@ -28,6 +28,7 @@ import ifcopenshell.api.boundary
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as W
|
||||
import ifcopenshell.util.boundary
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.shape
|
||||
@@ -695,36 +696,30 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def auto_generate_boundaries(
|
||||
self, space: ifcopenshell.entity_instance, space_obj: bpy.types.Object
|
||||
) -> Union[str, list[ifcopenshell.entity_instance]]:
|
||||
"""
|
||||
:return: list of created boundaries or a string with error description.
|
||||
"""Generate boundaries by delegating to ifcopenshell.util.boundary.
|
||||
|
||||
This method handles Blender-specific preprocessing (flushing moved
|
||||
objects, building the geometry cache + spatial tree) then delegates
|
||||
the algorithm to the Blender-independent util module.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
props = tool.Model.get_model_props()
|
||||
boundaries: list[ifcopenshell.entity_instance] = []
|
||||
assert isinstance(space_obj.data, bpy.types.Mesh)
|
||||
|
||||
# Identify all potential building elements
|
||||
# TODO: don't select everything, use AABB culling in Blender
|
||||
building_elements = list(
|
||||
tool.Ifc.get().by_type("IfcWall")
|
||||
+ tool.Ifc.get().by_type("IfcSlab")
|
||||
+ tool.Ifc.get().by_type("IfcVirtualElement")
|
||||
)
|
||||
building_elements = []
|
||||
for ifc_class in ifcopenshell.util.boundary.BOUNDARY_ELEMENT_CLASSES:
|
||||
building_elements.extend(ifc_file.by_type(ifc_class))
|
||||
|
||||
# Flush moved objects to IFC
|
||||
for building_element in building_elements:
|
||||
if obj := tool.Ifc.get_object(building_element):
|
||||
if tool.Ifc.is_moved(obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
|
||||
if tool.Ifc.is_moved(space_obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=space_obj)
|
||||
|
||||
# Don't generate boundaries of building elements that we've already got bounaries for.
|
||||
for boundary in space.BoundedBy:
|
||||
if boundary.RelatedBuildingElement in building_elements:
|
||||
building_elements.remove(boundary.RelatedBuildingElement)
|
||||
|
||||
# Create tree of gross shapes of all potential related building elements
|
||||
# Build shapes dict with iterator (parallel, includes space + building elements)
|
||||
include = building_elements + [space]
|
||||
tree = ifcopenshell.geom.tree()
|
||||
shapes = {}
|
||||
@@ -739,189 +734,23 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
|
||||
shapes[shape.id] = {
|
||||
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
|
||||
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
|
||||
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
|
||||
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
|
||||
}
|
||||
if not iterator.next():
|
||||
break
|
||||
|
||||
# Spatially query all potential boundary elements via a 100mm extension of the space
|
||||
building_elements = [e for e in tree.select(space, extend=0.1) if e != space]
|
||||
# Pass all building element shapes to the auto-generation function.
|
||||
# The function performs its own spatial filtering (coplanarity + overlap),
|
||||
# so tree-adjacency filtering is not needed here.
|
||||
filtered_shapes = {space.id(): shapes[space.id()]}
|
||||
for element in building_elements:
|
||||
if element.id() in shapes:
|
||||
filtered_shapes[element.id()] = shapes[element.id()]
|
||||
|
||||
if not building_elements:
|
||||
return "No building elements found to create boundaries."
|
||||
|
||||
# Create a dissolved bmesh for the space
|
||||
space_bm = bmesh.new()
|
||||
space_bm.from_mesh(space_obj.data)
|
||||
bmesh.ops.dissolve_limit(space_bm, angle_limit=pi * 2 / 360, verts=space_bm.verts[:], edges=space_bm.edges[:])
|
||||
|
||||
# Create dissolved bmeshes for all boundary elements
|
||||
building_element_bms = {}
|
||||
for building_element in building_elements:
|
||||
bm = bmesh.new()
|
||||
shape = shapes[building_element.id()]
|
||||
|
||||
for vert in shape["verts"]:
|
||||
bm.verts.new(Vector(vert))
|
||||
bm.verts.ensure_lookup_table()
|
||||
|
||||
for face in shape["faces"]:
|
||||
bm.faces.new([bm.verts[i] for i in face])
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.faces.ensure_lookup_table()
|
||||
bm.normal_update() # Needed so that dissolve_limit will work.
|
||||
bmesh.ops.dissolve_limit(bm, angle_limit=radians(1), verts=bm.verts[:], edges=bm.edges[:])
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.faces.ensure_lookup_table()
|
||||
building_element_bms[building_element.id()] = bm
|
||||
|
||||
# Compare space faces and building element faces to see if they relate to one another
|
||||
for space_face in space_bm.faces:
|
||||
space_face_normal = space_obj.matrix_world.to_3x3() @ space_face.normal
|
||||
space_face_vert = space_obj.matrix_world @ space_face.verts[0].co
|
||||
for building_element in building_elements:
|
||||
for face in building_element_bms[building_element.id()].faces:
|
||||
building_obj = tool.Ifc.get_object(building_element)
|
||||
face_normal = building_obj.matrix_world.to_3x3() @ face.normal
|
||||
angle = degrees(acos(max(min(space_face_normal.dot(face_normal), 1), -1)))
|
||||
if tool.Cad.is_x(angle, 180, tolerance=2):
|
||||
pass # Faces need to be parallel and have opposite normals to be related.
|
||||
elif building_element.is_a("IfcVirtualElement") and tool.Cad.is_x(angle, 0, tolerance=2):
|
||||
pass # Virtual elements only need to be parallel to be related, since they are planes.
|
||||
else:
|
||||
continue
|
||||
|
||||
# Both faces should be close to one another. Say within 50mm.
|
||||
space_vert = building_obj.matrix_world.inverted() @ space_face_vert
|
||||
dist = mathutils.geometry.distance_point_to_plane(space_vert, face.verts[0].co, face.normal)
|
||||
if abs(dist) > 0.05:
|
||||
continue
|
||||
|
||||
# Project the building element face onto the space face
|
||||
space_face_verts = [v.co.copy() for v in space_face.verts]
|
||||
space_face_matrix = self.get_face_matrix(*[v.copy() for v in space_face_verts[0:3]])
|
||||
space_face_matrix_i = space_face_matrix.inverted()
|
||||
|
||||
space_face_polygon = shapely.Polygon(
|
||||
[tuple((space_face_matrix_i @ v).xy) for v in space_face_verts]
|
||||
)
|
||||
|
||||
space_matrix_world_i = space_obj.matrix_world.inverted()
|
||||
face_verts = [space_matrix_world_i @ building_obj.matrix_world @ v.co.copy() for v in face.verts]
|
||||
face_polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in face_verts])
|
||||
|
||||
gross_boundary_polygon = space_face_polygon.intersection(face_polygon)
|
||||
|
||||
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)
|
||||
or gross_boundary_polygon.is_empty
|
||||
):
|
||||
continue
|
||||
|
||||
# The gross boundary polygon may not be a true gross boundary since it
|
||||
# may have openings already removed, such as in IFC4 Reference View. So
|
||||
# we cheat by using the exterior boundary to mean "gross".
|
||||
exterior_boundary_polygon = shapely.Polygon(gross_boundary_polygon.exterior.coords)
|
||||
|
||||
parent_boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
|
||||
if building_element.is_a("IfcVirtualElement"):
|
||||
parent_boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
|
||||
else:
|
||||
parent_boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
|
||||
parent_boundary.InternalOrExternalBoundary = "NOTDEFINED"
|
||||
if building_element.is_a("IfcWall"):
|
||||
is_external = ifcopenshell.util.element.get_pset(
|
||||
building_element, "Pset_WallCommon", "IsExternal"
|
||||
)
|
||||
if is_external is True:
|
||||
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
|
||||
elif is_external is False:
|
||||
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
|
||||
elif building_element.is_a("IfcSlab"):
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(building_element)
|
||||
if predefined_type == "BASESLAB":
|
||||
parent_boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH"
|
||||
else:
|
||||
is_external = ifcopenshell.util.element.get_pset(
|
||||
building_element, "Pset_SlabCommon", "IsExternal"
|
||||
)
|
||||
if is_external is True:
|
||||
parent_boundary.InternalOrExternalBoundary = "EXTERNAL"
|
||||
elif is_external is False:
|
||||
parent_boundary.InternalOrExternalBoundary = "INTERNAL"
|
||||
parent_boundary.RelatingSpace = space
|
||||
parent_boundary.RelatedBuildingElement = building_element
|
||||
parent_boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
|
||||
exterior_boundary_polygon, space_face_matrix
|
||||
)
|
||||
self.set_boundary_name(parent_boundary)
|
||||
boundaries.append(parent_boundary)
|
||||
|
||||
for rel in getattr(building_element, "HasOpenings", []):
|
||||
opening = rel.RelatedOpeningElement
|
||||
filling = opening.HasFillings[0].RelatedBuildingElement if opening.HasFillings else None
|
||||
|
||||
# Create shape of opening as a dissolved BMesh
|
||||
settings = ifcopenshell.geom.settings()
|
||||
shape = ifcopenshell.geom.create_shape(settings, opening)
|
||||
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
|
||||
opening_bm = bmesh.new()
|
||||
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
|
||||
for vert in verts:
|
||||
opening_bm.verts.new(Vector(vert))
|
||||
opening_bm.verts.ensure_lookup_table()
|
||||
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
|
||||
for face in faces:
|
||||
opening_bm.faces.new([opening_bm.verts[i] for i in face])
|
||||
opening_bm.verts.ensure_lookup_table()
|
||||
opening_bm.faces.ensure_lookup_table()
|
||||
opening_bm.normal_update() # Needed so that dissolve_limit will work.
|
||||
bmesh.ops.dissolve_limit(
|
||||
opening_bm, angle_limit=radians(1), verts=opening_bm.verts[:], edges=opening_bm.edges[:]
|
||||
)
|
||||
opening_bm.verts.ensure_lookup_table()
|
||||
opening_bm.faces.ensure_lookup_table()
|
||||
|
||||
# Get relevant faces of BMesh that can turn into boundaries
|
||||
opening_polygons = []
|
||||
for opening_face in opening_bm.faces:
|
||||
opening_face_normal = mat.to_3x3() @ opening_face.normal
|
||||
angle = degrees(acos(max(min(opening_face_normal.dot(face_normal), 1), -1)))
|
||||
if not tool.Cad.is_x(angle, 180, tolerance=2):
|
||||
continue # Any non-parallel faces are not relevant
|
||||
opening_face_verts = [space_matrix_world_i @ mat @ v.co.copy() for v in opening_face.verts]
|
||||
polygon = shapely.Polygon([tuple((space_face_matrix_i @ v).xy) for v in opening_face_verts])
|
||||
opening_polygons.append(polygon)
|
||||
|
||||
# Merge them all into a single opening polygon for our boundary
|
||||
opening_polygon = shapely.ops.unary_union(opening_polygons)
|
||||
|
||||
# Only openings that are projected onto our exterior boundary are relevant.
|
||||
if opening_polygon.intersection(exterior_boundary_polygon).area == 0:
|
||||
continue
|
||||
|
||||
boundary = ifcopenshell.api.root.create_entity(ifc_file, ifc_class=props.boundary_class)
|
||||
boundary.RelatingSpace = space
|
||||
boundary.RelatedBuildingElement = filling or opening
|
||||
boundary.ConnectionGeometry = self.create_connection_geometry_from_polygon(
|
||||
opening_polygon, space_face_matrix
|
||||
)
|
||||
if filling:
|
||||
boundary.PhysicalOrVirtualBoundary = "PHYSICAL"
|
||||
else:
|
||||
boundary.PhysicalOrVirtualBoundary = "VIRTUAL"
|
||||
boundary.InternalOrExternalBoundary = parent_boundary.InternalOrExternalBoundary
|
||||
if boundary.is_a() != "IfcRelSpaceBoundary":
|
||||
boundary.ParentBoundary = parent_boundary
|
||||
self.set_boundary_name(boundary)
|
||||
boundaries.append(boundary)
|
||||
|
||||
return boundaries
|
||||
return ifcopenshell.util.boundary.auto_generate_boundaries(
|
||||
ifc_file, space, filtered_shapes, props.boundary_class
|
||||
)
|
||||
|
||||
def create_element_boundary(
|
||||
self,
|
||||
|
||||
@@ -20,9 +20,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
@@ -186,9 +187,6 @@ def generate_space(
|
||||
"""
|
||||
:return: None if successful, error message string if not.
|
||||
"""
|
||||
if not root.get_default_container():
|
||||
raise SpaceGenerationError("Please set a default container to create the space in.")
|
||||
|
||||
active_obj = spatial.get_active_obj()
|
||||
selected_objects = spatial.get_selected_objects()
|
||||
element = None
|
||||
@@ -206,7 +204,15 @@ def generate_space(
|
||||
else:
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
|
||||
|
||||
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
if element and element.is_a("IfcSpace"):
|
||||
z = active_obj.location.z
|
||||
container = ifcopenshell.util.element.get_parent(element) or root.get_default_container()
|
||||
else:
|
||||
container = root.get_default_container()
|
||||
if not container:
|
||||
raise SpaceGenerationError("Please set a default container to create the space in.")
|
||||
|
||||
space_polygon, bounding_walls = spatial.get_space_polygon_from_context_visible_objects(x, y, container=container)
|
||||
|
||||
if isinstance(space_polygon, str):
|
||||
if space_polygon == "NO POLYGONS FOUND":
|
||||
@@ -230,8 +236,15 @@ def generate_space(
|
||||
|
||||
if element and element.is_a("IfcSpace"):
|
||||
assert active_obj
|
||||
active_obj.location.z = z
|
||||
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
|
||||
spatial.set_space_representation_from_polygon(
|
||||
active_obj,
|
||||
element,
|
||||
space_polygon,
|
||||
h,
|
||||
polygon_is_si=True,
|
||||
bounding_walls=bounding_walls,
|
||||
container=container,
|
||||
)
|
||||
else:
|
||||
if relating_type:
|
||||
name = model.generate_occurrence_name(relating_type, "IfcSpace")
|
||||
@@ -244,7 +257,9 @@ def generate_space(
|
||||
spatial.assign_ifcspace_class_to_obj(obj)
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
|
||||
spatial.set_space_representation_from_polygon(
|
||||
obj, element, space_polygon, h, polygon_is_si=True, bounding_walls=bounding_walls, container=container
|
||||
)
|
||||
|
||||
if relating_type:
|
||||
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
|
||||
|
||||
@@ -618,7 +618,7 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
cls.edges.extend([(i, i + 1) for i in range(offset, len(cls.vertices) - 1)])
|
||||
if is_closed:
|
||||
cls.edges[-1] = (len(cls.vertices) - 1, offset) # Close the loop
|
||||
cls.edges.append((len(cls.vertices) - 1, offset)) # Close the loop
|
||||
|
||||
elif curve.is_a("IfcCompositeCurve"):
|
||||
# This is a first pass incomplete implementation only for simple polylines, and misses many details.
|
||||
|
||||
@@ -42,6 +42,7 @@ import ifcopenshell.util.type
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
import shapely
|
||||
import shapely.affinity
|
||||
import shapely.ops
|
||||
from mathutils import Matrix, Vector
|
||||
from natsort import natsorted
|
||||
@@ -874,16 +875,32 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z)
|
||||
|
||||
@classmethod
|
||||
def get_space_polygon_from_context_visible_objects(cls, x: float, y: float) -> tuple[
|
||||
def get_space_polygon_from_context_visible_objects(
|
||||
cls, x: float, y: float, container: Optional[ifcopenshell.entity_instance] = None
|
||||
) -> tuple[
|
||||
Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]],
|
||||
list[ifcopenshell.entity_instance],
|
||||
]:
|
||||
props = tool.Model.get_model_props()
|
||||
calculation_rl = props.rl3
|
||||
container = tool.Root.get_default_container()
|
||||
if container is None:
|
||||
container = tool.Root.get_default_container()
|
||||
container_obj = tool.Ifc.get_object(container)
|
||||
cut_z = container_obj.matrix_world.translation.z + calculation_rl
|
||||
|
||||
# Commit any moved visible bounding objects before reading IFC geometry,
|
||||
# so the IFC-based cache uses the current Blender positions.
|
||||
# Walls/roofs/slabs that affect the space footprint or height must be
|
||||
# committed before the cache is rebuilt; otherwise the IFC geometry read by
|
||||
# the iterator will be stale and a moved roof/slab will not be picked up.
|
||||
affected_classes = ifcopenshell.util.space.BOUNDING_CLASSES + ifcopenshell.util.space.HEIGHT_DETECTION_CLASSES
|
||||
for obj in bpy.context.visible_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not any(element.is_a(c) for c in affected_classes):
|
||||
continue
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
cls._geom_cache.clear()
|
||||
|
||||
boundary_lines, bounding_elements = cls.get_boundary_lines_from_ifc_elements(cut_z)
|
||||
polygon, _ = ifcopenshell.util.space.get_space_polygon(boundary_lines, x, y)
|
||||
if isinstance(polygon, str):
|
||||
@@ -911,6 +928,109 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_space_volume_strategy(
|
||||
cls,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
bounding_walls: list[ifcopenshell.entity_instance],
|
||||
container: Optional[ifcopenshell.entity_instance] = None,
|
||||
) -> tuple[str, Optional[list], Optional[list]]:
|
||||
"""Decide how to build the space volume (clipped extrusion or B-rep).
|
||||
|
||||
Rays are cast from the RL cut elevation (``container_z + props.rl3``), the
|
||||
same level at which the space footprint polygon was found.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
cache = cls.get_or_build_geom_cache()
|
||||
start_z = None
|
||||
if container is None:
|
||||
container = tool.Root.get_default_container()
|
||||
if container is not None:
|
||||
container_obj = tool.Ifc.get_object(container)
|
||||
props = tool.Model.get_model_props()
|
||||
start_z = container_obj.matrix_world.translation.z + props.rl3
|
||||
tree = ifcopenshell.geom.tree(ifc_file)
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("disable-opening-subtractions", True)
|
||||
settings.set("use-world-coords", True)
|
||||
tree.add_file(ifc_file, settings)
|
||||
return ifcopenshell.util.space.detect_space_volume_strategy(
|
||||
ifc_file, cache["shapes"], tree, space_polygon, base_z, bounding_walls, start_z=start_z
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_body_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
||||
"""Return the Model/Body/MODEL_VIEW context, creating one if absent."""
|
||||
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
if context is not None:
|
||||
return context
|
||||
# Some subcontexts may not expose the inherited ContextType value, so also
|
||||
# search by ContextIdentifier/TargetView directly.
|
||||
for ctx in ifc_file.by_type("IfcGeometricRepresentationSubContext"):
|
||||
if ctx.ContextIdentifier == "Body" and getattr(ctx, "TargetView", None) == "MODEL_VIEW":
|
||||
return ctx
|
||||
# Create a minimal context if none exists.
|
||||
model_context = ifcopenshell.util.representation.get_context(ifc_file, "Model")
|
||||
if model_context is None:
|
||||
model_context = ifc_file.createIfcGeometricRepresentationContext(
|
||||
ContextType="Model",
|
||||
CoordinateSpaceDimension=3,
|
||||
Precision=1e-5,
|
||||
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
||||
),
|
||||
TrueNorth=ifc_file.createIfcDirection([0.0, 1.0, 0.0]),
|
||||
)
|
||||
return ifc_file.createIfcGeometricRepresentationSubContext(
|
||||
ParentContext=model_context,
|
||||
ContextIdentifier="Body",
|
||||
TargetView="MODEL_VIEW",
|
||||
ContextType="Model",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _remove_existing_body_representations(
|
||||
cls, element: ifcopenshell.entity_instance
|
||||
) -> Optional[ifcopenshell.entity_instance]:
|
||||
"""Remove every existing Body representation from an element.
|
||||
|
||||
Returns the context of the first removed representation, or None.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
if element.Representation is None:
|
||||
return None
|
||||
body_reps = [r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"]
|
||||
context = None
|
||||
for rep in body_reps:
|
||||
context = rep.ContextOfItems
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=rep)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
def set_brep_representation_from_mesh(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
element: ifcopenshell.entity_instance,
|
||||
item: ifcopenshell.entity_instance,
|
||||
) -> None:
|
||||
"""Assign a representation item (clipped solid or B-rep) to the element."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
context = cls._remove_existing_body_representations(element)
|
||||
if context is None:
|
||||
context = cls._get_or_create_body_context(ifc_file)
|
||||
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
new_body = builder.get_representation(context, item)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def debug_shape(cls, foo: shapely.Polygon) -> None:
|
||||
coords = [(p[0], p[1], 0) for p in foo.exterior.coords]
|
||||
@@ -1222,13 +1342,9 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
curve = builder.polyline(coords_2d, closed=True)
|
||||
item = builder.extrude(curve, magnitude=depth_ifc)
|
||||
|
||||
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if old_body:
|
||||
context = old_body.ContextOfItems
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
|
||||
else:
|
||||
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
context = cls._remove_existing_body_representations(element)
|
||||
if context is None:
|
||||
context = cls._get_or_create_body_context(ifc_file)
|
||||
|
||||
new_body = builder.get_representation(context, item)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
||||
@@ -1247,13 +1363,104 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
poly: Polygon,
|
||||
h: float,
|
||||
polygon_is_si: bool = True,
|
||||
bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None,
|
||||
container: Optional[ifcopenshell.entity_instance] = None,
|
||||
) -> None:
|
||||
"""Create or replace the IFC body representation of a space from a polygon.
|
||||
|
||||
:param h: The height in SI (meters).
|
||||
"""
|
||||
# Remove collinear points introduced by the mesh bisection so the
|
||||
# footprint polygon has a minimal vertex count.
|
||||
poly = poly.simplify(0, preserve_topology=True)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
||||
ifc_file = tool.Ifc.get()
|
||||
x, y, z = obj.matrix_world.translation
|
||||
origin = obj.matrix_world.translation # Blender SI
|
||||
# The space builders expect base_z and polygon in SI (world) units.
|
||||
base_z = z
|
||||
poly_si = poly if polygon_is_si else shapely.affinity.scale(poly, unit_scale, unit_scale, origin=(0, 0))
|
||||
|
||||
# Ensure the IFC entity has an ObjectPlacement matching the Blender object,
|
||||
# so the generated representation is in the correct local coordinate system.
|
||||
bpy.context.view_layer.update()
|
||||
matrix = np.array(obj.matrix_world)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
ifc_file,
|
||||
product=element,
|
||||
matrix=matrix,
|
||||
is_si=True,
|
||||
)
|
||||
|
||||
for b in list(element.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc_file, b)
|
||||
|
||||
cls._remove_existing_body_representations(element)
|
||||
|
||||
if cls.get_spatial_props().force_space_height:
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
||||
return
|
||||
if bounding_walls is None:
|
||||
bounding_walls = []
|
||||
if container is None:
|
||||
container = ifcopenshell.util.element.get_container(element)
|
||||
if container is not None:
|
||||
for wall in ifc_file.by_type("IfcWall"):
|
||||
if wall in ifcopenshell.util.element.get_decomposition(container):
|
||||
bounding_walls.append(wall)
|
||||
|
||||
# Detect planes in world SI (same coordinate system as the geom cache).
|
||||
strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly_si, base_z, bounding_walls, container)
|
||||
|
||||
# Build the geometry in the space's local coordinate system so the IFC
|
||||
# representation is relative to the object's ObjectPlacement.
|
||||
# Use the full inverse of the object's placement matrix so rotated spaces
|
||||
# keep the correct footprint orientation.
|
||||
matrix_inv = np.array(obj.matrix_world.inverted())
|
||||
# shapely.affine_transform expects [a, b, d, e, xoff, yoff]
|
||||
# where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff.
|
||||
affine_params = [
|
||||
matrix_inv[0, 0],
|
||||
matrix_inv[0, 1],
|
||||
matrix_inv[1, 0],
|
||||
matrix_inv[1, 1],
|
||||
matrix_inv[0, 3],
|
||||
matrix_inv[1, 3],
|
||||
]
|
||||
local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params)
|
||||
local_base_z = base_z - origin.z
|
||||
|
||||
def localize_plane(plane):
|
||||
point, normal = plane
|
||||
local_point = matrix_inv @ np.array([*point, 1.0])
|
||||
rotation_inv = matrix_inv[:3, :3]
|
||||
local_normal = rotation_inv @ np.array(normal)
|
||||
local_normal = local_normal / np.linalg.norm(local_normal)
|
||||
return (local_point[:3], local_normal)
|
||||
|
||||
local_top_planes = [localize_plane(p) for p in (top_planes or [])]
|
||||
local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])]
|
||||
|
||||
if strategy == "EXTRUDE_CLIP" and top_planes:
|
||||
item = ifcopenshell.util.space.build_extruded_clipped_space(
|
||||
ifc_file, local_poly_si, local_base_z, local_top_planes, local_bottom_planes
|
||||
)
|
||||
cls.set_brep_representation_from_mesh(obj, element, item)
|
||||
else:
|
||||
shapes = cls.get_or_build_geom_cache()["shapes"]
|
||||
local_shapes = {}
|
||||
for shape_id, shape_data in shapes.items():
|
||||
local_shape_data = dict(shape_data)
|
||||
local_shape_data["top_z"] = shape_data["top_z"] - origin.z
|
||||
local_shape_data["bottom_z"] = shape_data["bottom_z"] - origin.z
|
||||
local_shapes[shape_id] = local_shape_data
|
||||
item = ifcopenshell.util.space.build_brep_space(
|
||||
ifc_file, element, local_shapes, local_poly_si, local_base_z
|
||||
)
|
||||
if item is None:
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
||||
else:
|
||||
cls.set_brep_representation_from_mesh(obj, element, item)
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -32,6 +32,7 @@ import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.shape_builder
|
||||
import numpy as np
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder, V
|
||||
from mathutils import Matrix
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
@@ -1044,3 +1045,41 @@ class TestGetSiblingOccurrenceCount(NewFile):
|
||||
ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
|
||||
|
||||
assert subject.get_sibling_occurrence_count(wall_type) == 2
|
||||
|
||||
|
||||
class TestConvertCurveToMesh(NewFile):
|
||||
def test_closed_polyline_converts_to_closed_loop(self):
|
||||
"""A closed IfcPolyline must produce the full edge loop.
|
||||
|
||||
Before the fix (cls.edges[-1] = … overwrite) the closing edge
|
||||
replaced the real last segment, leaving every loop open by one
|
||||
edge — e.g. a quad got only 3 edges.
|
||||
"""
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
# Closed quad: 4 unique points + closing repeat = 5 points
|
||||
p0 = ifc.createIfcCartesianPoint((0.0, 0.0))
|
||||
p1 = ifc.createIfcCartesianPoint((1.0, 0.0))
|
||||
p2 = ifc.createIfcCartesianPoint((1.0, 1.0))
|
||||
p3 = ifc.createIfcCartesianPoint((0.0, 1.0))
|
||||
polyline = ifc.createIfcPolyline((p0, p1, p2, p3, p0))
|
||||
|
||||
subject.vertices = []
|
||||
subject.edges = []
|
||||
subject.arcs = []
|
||||
subject.circles = []
|
||||
subject.unit_scale = 1.0
|
||||
|
||||
subject.convert_curve_to_mesh(None, Matrix(), polyline)
|
||||
|
||||
assert len(subject.vertices) == 4, f"Expected 4 vertices, got {len(subject.vertices)}"
|
||||
assert len(subject.edges) == 4, f"Expected 4 edges, got {len(subject.edges)}"
|
||||
|
||||
# Every vertex must appear in exactly 2 edges (closed loop)
|
||||
from collections import defaultdict
|
||||
counts = defaultdict(int)
|
||||
for e in subject.edges:
|
||||
counts[e[0]] += 1
|
||||
counts[e[1]] += 1
|
||||
for v_idx, cnt in counts.items():
|
||||
assert cnt == 2, f"Vertex {v_idx} has {cnt} incident edges (expected 2)"
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
@@ -26,7 +28,9 @@ import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.util.representation
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
import pytest
|
||||
import shapely
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
@@ -270,7 +274,7 @@ class _BlockHelper:
|
||||
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
|
||||
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0)
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-5.0, -5.0, 0.0]))
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]))
|
||||
extrusion = ifc.createIfcExtrudedAreaSolid(
|
||||
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
|
||||
)
|
||||
@@ -278,6 +282,21 @@ class _BlockHelper:
|
||||
wall.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
return wall, extrusion
|
||||
|
||||
@staticmethod
|
||||
def create_thin_wall(ifc, cx, cy, width, depth, height=10.0):
|
||||
"""Create an IFC wall with a thin block representation centered at (cx, cy)."""
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
|
||||
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, width, depth)
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([cx, cy, 0.0]))
|
||||
extrusion = ifc.createIfcExtrudedAreaSolid(
|
||||
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
|
||||
)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
|
||||
wall.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
return wall
|
||||
|
||||
@staticmethod
|
||||
def create_slab(ifc, z=4.0):
|
||||
"""Create an IfcSlab with a 12x12x1.0 block representation at bottom_z={z}."""
|
||||
@@ -285,7 +304,7 @@ class _BlockHelper:
|
||||
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
|
||||
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
|
||||
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0)
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-6.0, -6.0, z]))
|
||||
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, z]))
|
||||
extrusion = ifc.createIfcExtrudedAreaSolid(profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), 1.0)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
|
||||
slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
|
||||
@@ -384,6 +403,7 @@ class TestGenerateSpace(NewFile):
|
||||
spatial_props = tool.Spatial.get_spatial_props()
|
||||
spatial_props.space_height = 6
|
||||
bpy.context.view_layer.objects.active = space
|
||||
space.hide_viewport = False
|
||||
space.select_set(True)
|
||||
|
||||
bpy.ops.bim.apply_space_height_to_selection()
|
||||
@@ -456,7 +476,7 @@ class TestGenerateSpace(NewFile):
|
||||
ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]),
|
||||
ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]),
|
||||
]
|
||||
face_set = ifc.createIfcPolygonalFaceSet(points, closed=True, faces=faces)
|
||||
face_set = ifc.createIfcPolygonalFaceSet(points, True, faces)
|
||||
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set])
|
||||
|
||||
space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace")
|
||||
@@ -480,9 +500,345 @@ class TestGenerateSpace(NewFile):
|
||||
|
||||
mesh = obj.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
verts = [v.co.z for v in mesh.vertices]
|
||||
min_z = min(verts)
|
||||
max_z = max(verts)
|
||||
assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}"
|
||||
assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}"
|
||||
assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}"
|
||||
world_verts = [obj.matrix_world @ v.co for v in mesh.vertices]
|
||||
world_zs = [v.z for v in world_verts]
|
||||
assert min(world_zs) >= -0.1, f"Expected space world bottom near z>=0, got {min(world_zs)}"
|
||||
assert max(world_zs) > 0, f"Expected space to have positive height, got {max(world_zs)}"
|
||||
assert np.isclose(obj.location.z, 5.0, atol=0.01), f"Expected location.z=5.0, got {obj.location.z}"
|
||||
|
||||
|
||||
class TestGenerateSpaceSlopedRoof(NewFile):
|
||||
def _create_shed_roof(self, ifc, z=4.0, rise=3.0):
|
||||
"""Create an IfcRoof whose underside is a sloped plane across the footprint.
|
||||
|
||||
Triangular prism: vertical profile (in the y-z plane) extruded along +x.
|
||||
Profile points (u, v) with placement loc=(-5, 0, z), axis=(1,0,0),
|
||||
ref=(0,0,1). The local frame maps u to world +z (u=0 -> z, u=rise ->
|
||||
z+rise) and v to world -y (v=-5 -> y=+5, v=+5 -> y=-5):
|
||||
(0,-5) -> world (-5, +5, z) eave (low) at north
|
||||
(rise,-5) -> world (-5, +5, z+rise) vertical edge
|
||||
(rise,5) -> world (-5, -5, z+rise) ridge at south
|
||||
The underside is the sloped face from (y=+5, z) to (y=-5, z+rise).
|
||||
ExtrudedDirection (0,0,1) is local, mapping to world +x; depth 10 spans
|
||||
x in [-5, 5].
|
||||
"""
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
roof = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcRoof")
|
||||
pts = [
|
||||
ifc.createIfcCartesianPoint((0.0, -5.0)),
|
||||
ifc.createIfcCartesianPoint((float(rise), -5.0)),
|
||||
ifc.createIfcCartesianPoint((float(rise), 5.0)),
|
||||
]
|
||||
polyline = ifc.createIfcPolyline(pts)
|
||||
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
|
||||
placement = ifc.createIfcAxis2Placement3D(
|
||||
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
|
||||
ifc.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
ifc.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
)
|
||||
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
|
||||
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
|
||||
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
|
||||
ifcopenshell.api.geometry.assign_representation(ifc, product=roof, representation=rep)
|
||||
return roof
|
||||
|
||||
def test_generate_space_under_shed_roof(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
_BlockHelper.create_thin_wall(ifc, 0.0, 4.8, 10.0, 0.4)
|
||||
_BlockHelper.create_thin_wall(ifc, 0.0, -4.8, 10.0, 0.4)
|
||||
_BlockHelper.create_thin_wall(ifc, 4.8, 0.0, 0.4, 10.0)
|
||||
_BlockHelper.create_thin_wall(ifc, -4.8, 0.0, 0.4, 10.0)
|
||||
self._create_shed_roof(ifc, z=4.0, rise=3.0)
|
||||
bpy.context.scene.cursor.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
mesh = space.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
verts = np.array([v.co for v in mesh.vertices])
|
||||
min_z = verts[:, 2].min()
|
||||
max_z = verts[:, 2].max()
|
||||
assert min_z >= -0.1
|
||||
assert max_z > 0
|
||||
top_z_north = max([v[2] for v in verts if v[1] > 1])
|
||||
top_z_south = max([v[2] for v in verts if v[1] < -1])
|
||||
assert abs(top_z_north - top_z_south) > 0.05, f"Top should slope along y: {top_z_north} vs {top_z_south}"
|
||||
|
||||
|
||||
class TestSpaceVolumeStrategy(NewFile):
|
||||
def test_vertical_box_returns_extrude_clip(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
_BlockHelper.create_wall(ifc, height=10.0)
|
||||
_BlockHelper.create_slab(ifc, z=4.0)
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [ifc.by_type("IfcWall")[0]])
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(top) == 1
|
||||
assert len(bottom) == 0
|
||||
|
||||
@staticmethod
|
||||
def _create_sloped_slab(ifc, z=4.0, rise=3.0):
|
||||
"""Create an IfcSlab whose underside is a sloped plane across the footprint."""
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW")
|
||||
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
|
||||
pts = [
|
||||
ifc.createIfcCartesianPoint((0.0, -5.0)),
|
||||
ifc.createIfcCartesianPoint((float(rise), -5.0)),
|
||||
ifc.createIfcCartesianPoint((float(rise), 5.0)),
|
||||
]
|
||||
polyline = ifc.createIfcPolyline(pts)
|
||||
profile = ifc.createIfcArbitraryClosedProfileDef(ProfileType="CURVE", OuterCurve=polyline)
|
||||
placement = ifc.createIfcAxis2Placement3D(
|
||||
ifc.createIfcCartesianPoint((-5.0, 0.0, z)),
|
||||
ifc.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
ifc.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
)
|
||||
extrude_dir = ifc.createIfcDirection((0.0, 0.0, 1.0))
|
||||
solid = ifc.createIfcExtrudedAreaSolid(profile, placement, extrude_dir, 10.0)
|
||||
rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [solid])
|
||||
ifcopenshell.api.geometry.assign_representation(ifc, product=slab, representation=rep)
|
||||
return slab
|
||||
|
||||
def test_sloped_slab_returns_extrude_clip(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
self._create_sloped_slab(ifc, z=4.0, rise=3.0)
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [])
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(top) == 1
|
||||
assert len(bottom) == 0
|
||||
|
||||
|
||||
class TestRegenerateSpaceFromRealIfc2x3(NewFile):
|
||||
def load_house_with_garage(self):
|
||||
filepath = (
|
||||
Path(__file__).parents[3]
|
||||
/ "ifcopenshell-python"
|
||||
/ "test"
|
||||
/ "IfcRelSpaceBoundary_TestFiles"
|
||||
/ "IfcRelSpaceBoundary2ndLevel"
|
||||
/ "HouseWithGarage_AC22_IFC2X3.ifc"
|
||||
).resolve()
|
||||
bpy.ops.bim.load_project(filepath=filepath.as_posix())
|
||||
ifc = tool.Ifc.get()
|
||||
return ifc
|
||||
|
||||
def _regenerate_space(self, ifc, space_id):
|
||||
space = ifc.by_id(space_id)
|
||||
obj = tool.Ifc.get_object(space)
|
||||
assert obj
|
||||
import numpy as np
|
||||
|
||||
original_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
|
||||
original_bounds = (
|
||||
original_verts[:, 0].min(),
|
||||
original_verts[:, 0].max(),
|
||||
original_verts[:, 1].min(),
|
||||
original_verts[:, 1].max(),
|
||||
original_verts[:, 2].min(),
|
||||
original_verts[:, 2].max(),
|
||||
)
|
||||
original_origin = obj.matrix_world.translation.copy()
|
||||
|
||||
# Delete existing related IfcRelSpaceBoundary as in the manual repro.
|
||||
for b in list(space.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc, b)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Patch Spatial helpers so generate_space uses the active IfcSpace.
|
||||
original_get_selected_objects = tool.Spatial.get_selected_objects
|
||||
original_get_active_obj = tool.Spatial.get_active_obj
|
||||
try:
|
||||
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
|
||||
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
|
||||
bpy.ops.bim.generate_space()
|
||||
finally:
|
||||
tool.Spatial.get_selected_objects = original_get_selected_objects
|
||||
tool.Spatial.get_active_obj = original_get_active_obj
|
||||
|
||||
regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
|
||||
regen_bounds = (
|
||||
regen_verts[:, 0].min(),
|
||||
regen_verts[:, 0].max(),
|
||||
regen_verts[:, 1].min(),
|
||||
regen_verts[:, 1].max(),
|
||||
regen_verts[:, 2].min(),
|
||||
regen_verts[:, 2].max(),
|
||||
)
|
||||
regen_origin = obj.matrix_world.translation.copy()
|
||||
return (original_bounds, original_origin), (regen_bounds, regen_origin)
|
||||
|
||||
def test_regenerate_space_5710_keeps_world_location(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 5710)
|
||||
assert (regen_origin - original_origin).length < 0.02
|
||||
for o, r in zip(original_bounds, regen_bounds):
|
||||
assert r == pytest.approx(o, abs=0.02)
|
||||
|
||||
def test_regenerate_space_2363_keeps_world_location(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
(original_bounds, original_origin), (regen_bounds, regen_origin) = self._regenerate_space(ifc, 2363)
|
||||
assert (regen_origin - original_origin).length < 0.02
|
||||
# X and Y stable; Z may differ because the regenerated space detects the
|
||||
# sloped roof and clips the extrusion.
|
||||
for j in (0, 1, 2, 3, 4):
|
||||
assert regen_bounds[j] == pytest.approx(original_bounds[j], abs=0.02)
|
||||
# Verify the regenerated body contains boolean clipping (roof clipping).
|
||||
space = ifc.by_id(2363)
|
||||
body = ifcopenshell.util.representation.get_representation(space, "Model", "Body", "MODEL_VIEW")
|
||||
assert body is not None
|
||||
boolean_items = [i for i in (body.Items or []) if i.is_a("IfcBooleanClippingResult")]
|
||||
assert len(boolean_items) >= 1, "Expected roof clipping but got no boolean result"
|
||||
|
||||
def test_regenerate_space_twice_does_not_duplicate_half_spaces(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
space = ifc.by_id(2363)
|
||||
obj = tool.Ifc.get_object(space)
|
||||
assert obj
|
||||
|
||||
for b in list(space.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc, b)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
original_get_selected_objects = tool.Spatial.get_selected_objects
|
||||
original_get_active_obj = tool.Spatial.get_active_obj
|
||||
try:
|
||||
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
|
||||
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
|
||||
bpy.ops.bim.generate_space()
|
||||
bpy.ops.bim.generate_space()
|
||||
finally:
|
||||
tool.Spatial.get_selected_objects = original_get_selected_objects
|
||||
tool.Spatial.get_active_obj = original_get_active_obj
|
||||
|
||||
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
|
||||
assert len(body_reps) == 1
|
||||
rep = body_reps[0]
|
||||
boolean_chains = [item for item in rep.Items if item.is_a("IfcBooleanClippingResult")]
|
||||
assert len(boolean_chains) <= 1
|
||||
if boolean_chains:
|
||||
half_space_ids = set()
|
||||
for item in ifc.traverse(boolean_chains[0]):
|
||||
if item.is_a("IfcHalfSpaceSolid"):
|
||||
assert item.id() not in half_space_ids, "Duplicate half-space solid in boolean chain"
|
||||
half_space_ids.add(item.id())
|
||||
|
||||
def test_regenerate_space_after_moving_roof_updates_shape(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
space = ifc.by_id(2363)
|
||||
space_obj = tool.Ifc.get_object(space)
|
||||
assert space_obj
|
||||
|
||||
roof = ifc.by_id(5773)
|
||||
roof_obj = tool.Ifc.get_object(roof)
|
||||
assert roof_obj
|
||||
|
||||
for b in list(space.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc, b)
|
||||
bpy.context.view_layer.objects.active = space_obj
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
space_obj.select_set(True)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
original_get_selected_objects = tool.Spatial.get_selected_objects
|
||||
original_get_active_obj = tool.Spatial.get_active_obj
|
||||
try:
|
||||
tool.Spatial.get_selected_objects = classmethod(lambda cls: [space_obj])
|
||||
tool.Spatial.get_active_obj = classmethod(lambda cls: space_obj)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
roof_obj.hide_set(False)
|
||||
roof_obj.location.z += 1.0
|
||||
bpy.context.view_layer.update()
|
||||
tool.Geometry.commit_placement_if_moved(roof_obj)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
finally:
|
||||
tool.Spatial.get_selected_objects = original_get_selected_objects
|
||||
tool.Spatial.get_active_obj = original_get_active_obj
|
||||
|
||||
body_reps = [r for r in (space.Representation.Representations or []) if r.RepresentationIdentifier == "Body"]
|
||||
assert len(body_reps) == 1
|
||||
|
||||
def test_regenerate_space_is_stable_across_multiple_iterations(self):
|
||||
"""Regenerating the same space 5+ times must produce identical Z and bounds."""
|
||||
ifc = self.load_house_with_garage()
|
||||
space = ifc.by_id(2363)
|
||||
obj = tool.Ifc.get_object(space)
|
||||
assert obj
|
||||
|
||||
for b in list(space.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc, b)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
original_get_selected_objects = tool.Spatial.get_selected_objects
|
||||
original_get_active_obj = tool.Spatial.get_active_obj
|
||||
|
||||
def snapshot():
|
||||
verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=float)
|
||||
return (
|
||||
obj.matrix_world.translation.copy(),
|
||||
(
|
||||
float(verts[:, 0].min()),
|
||||
float(verts[:, 0].max()),
|
||||
float(verts[:, 1].min()),
|
||||
float(verts[:, 1].max()),
|
||||
float(verts[:, 2].min()),
|
||||
float(verts[:, 2].max()),
|
||||
),
|
||||
)
|
||||
|
||||
snapshots = []
|
||||
try:
|
||||
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
|
||||
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
|
||||
for _ in range(5):
|
||||
bpy.ops.bim.generate_space()
|
||||
snapshots.append(snapshot())
|
||||
finally:
|
||||
tool.Spatial.get_selected_objects = original_get_selected_objects
|
||||
tool.Spatial.get_active_obj = original_get_active_obj
|
||||
|
||||
ref_origin, ref_bounds = snapshots[0]
|
||||
for i, (origin, bounds) in enumerate(snapshots[1:], start=1):
|
||||
assert (
|
||||
origin - ref_origin
|
||||
).length < 0.02, f"Iteration {i}: Z drifted from {list(ref_origin)} to {list(origin)}"
|
||||
for j, (o, r) in enumerate(zip(ref_bounds, bounds)):
|
||||
assert r == pytest.approx(
|
||||
o, abs=0.02
|
||||
), f"Iteration {i} axis {j}: {o} != {r} full ref={ref_bounds} cur={bounds}"
|
||||
|
||||
|
||||
class TestGenerateSpaceLocation(NewFile):
|
||||
def test_generate_space_at_non_zero_cursor_location(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
# 4 thin walls forming a hollow box around (10, 20).
|
||||
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 + 4.8, 10.0, 0.4)
|
||||
_BlockHelper.create_thin_wall(ifc, 10.0, 20.0 - 4.8, 10.0, 0.4)
|
||||
_BlockHelper.create_thin_wall(ifc, 10.0 + 4.8, 20.0, 0.4, 10.0)
|
||||
_BlockHelper.create_thin_wall(ifc, 10.0 - 4.8, 20.0, 0.4, 10.0)
|
||||
bpy.context.scene.cursor.location = (10, 20, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
mesh = space.data
|
||||
assert isinstance(mesh, bpy.types.Mesh)
|
||||
world_verts = np.array([space.matrix_world @ v.co for v in mesh.vertices])
|
||||
center = (world_verts.min(axis=0) + world_verts.max(axis=0)) / 2
|
||||
assert center[0] == pytest.approx(10.0, abs=0.1)
|
||||
assert center[1] == pytest.approx(20.0, abs=0.1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -830,3 +830,201 @@ 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()
|
||||
edge_count: dict[frozenset, int] = {}
|
||||
for tri_idx in tri_indices:
|
||||
for e in tri_edges[tri_idx]:
|
||||
tri_edge_set.add(e)
|
||||
edge_count[e] = edge_count.get(e, 0) + 1
|
||||
|
||||
if merge_coplanar:
|
||||
boundary_edges = [e for e in tri_edge_set if edge_count.get(e, 0) == 1]
|
||||
else:
|
||||
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
|
||||
|
||||
if not edge_adjacency:
|
||||
result.append(list(faces[tri_indices[0]]))
|
||||
continue
|
||||
|
||||
start = next(iter(edge_adjacency))
|
||||
polygon = [start]
|
||||
current = edge_adjacency[start]
|
||||
while current != start and current in edge_adjacency:
|
||||
polygon.append(current)
|
||||
current = edge_adjacency[current]
|
||||
|
||||
if len(polygon) >= 3:
|
||||
result.append(polygon)
|
||||
else:
|
||||
result.append(list(faces[tri_indices[0]]))
|
||||
|
||||
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 > 1e-8:
|
||||
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)
|
||||
|
||||
@@ -26,11 +26,18 @@ for IFC analysis.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.util.boundary
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.shape_builder
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
import shapely
|
||||
|
||||
BOUNDING_CLASSES = ("IfcWall", "IfcColumn", "IfcMember", "IfcVirtualElement", "IfcPlate")
|
||||
@@ -244,3 +251,377 @@ def get_height_from_wall_tops(
|
||||
if lowest_top_z is not None:
|
||||
return lowest_top_z - base_z
|
||||
return None
|
||||
|
||||
|
||||
def _nearest_ray_hits(
|
||||
tree: ifcopenshell.geom.tree,
|
||||
origins: list[tuple[float, float, float]],
|
||||
ray_dir: np.ndarray,
|
||||
) -> list[ifcopenshell.geom.hit]:
|
||||
"""Nearest hit per origin; select_ray returns all hits including duplicates."""
|
||||
hits = []
|
||||
for origin in origins:
|
||||
results = sorted(tree.select_ray(origin, ray_dir, 1e4), key=lambda h: h.distance)
|
||||
if results:
|
||||
hits.append(results[0])
|
||||
return hits
|
||||
|
||||
|
||||
def get_vertical_bounding_planes(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
tree: ifcopenshell.geom.tree,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
direction: Literal["UP", "DOWN"],
|
||||
start_z: Optional[float] = None,
|
||||
) -> tuple[str, list[tuple[np.ndarray, np.ndarray]]]:
|
||||
"""Detect the top or bottom bounding planes for a space footprint.
|
||||
|
||||
Rays are cast from ``start_z`` (the RL cut elevation passed by the Bonsai
|
||||
tool layer) so they start in the same horizontal slice of the room where
|
||||
the footprint polygon was found. When ``start_z`` is None, rays start at
|
||||
``base_z + 0.001``.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Cached element shapes keyed by element id.
|
||||
:param tree: Geometry tree with all bounding elements added.
|
||||
:param space_polygon: Space footprint in world XY.
|
||||
:param base_z: Base elevation of the space in SI.
|
||||
:param direction: "UP" for top (ceiling/roof) or "DOWN" for bottom (floor/slab).
|
||||
:param start_z: Elevation to cast rays from in SI (the RL cut level).
|
||||
:return: (strategy, planes). Strategy is always "EXTRUDE_CLIP"; an empty
|
||||
planes list means open top (direction="UP") or void below
|
||||
(direction="DOWN"). The strategy decision between extrusion and B-rep
|
||||
happens in the calling layer. Planes are (point, normal) tuples in SI;
|
||||
the normal points toward the removed side (half-space convention).
|
||||
"""
|
||||
ray_dir = np.array([0.0, 0.0, 1.0]) if direction == "UP" else np.array([0.0, 0.0, -1.0])
|
||||
origin_z = start_z if start_z is not None else base_z + 0.001
|
||||
|
||||
bounds = space_polygon.bounds
|
||||
cx = (bounds[0] + bounds[2]) / 2.0
|
||||
cy = (bounds[1] + bounds[3]) / 2.0
|
||||
sample_points = [(cx, cy)]
|
||||
if bounds[2] - bounds[0] > 0.1:
|
||||
sample_points.append((cx + 0.25 * (bounds[2] - bounds[0]), cy))
|
||||
sample_points.append((cx - 0.25 * (bounds[2] - bounds[0]), cy))
|
||||
if bounds[3] - bounds[1] > 0.1:
|
||||
sample_points.append((cx, cy + 0.25 * (bounds[3] - bounds[1])))
|
||||
sample_points.append((cx, cy - 0.25 * (bounds[3] - bounds[1])))
|
||||
|
||||
# Sample from footprint corners, offset toward centroid so the ray origin
|
||||
# sits inside the space (not on a bounding wall). This catches elements
|
||||
# (e.g. sloped roofs) that only cover a corner of the space.
|
||||
for coords in space_polygon.exterior.coords[:-1]:
|
||||
vx, vy = coords[0], coords[1]
|
||||
sample_points.append((vx + 0.05 * (cx - vx), vy + 0.05 * (cy - vy)))
|
||||
|
||||
hits = _nearest_ray_hits(tree, [(x, y, origin_z) for x, y in sample_points], ray_dir)
|
||||
if not hits:
|
||||
return "EXTRUDE_CLIP", [] # open top / void below: no bounding planes
|
||||
|
||||
tol_floor = 0.05
|
||||
plane_hits = []
|
||||
for result in hits:
|
||||
point = np.array(result.position, dtype=float)
|
||||
normal = np.array(result.normal, dtype=float)
|
||||
if abs(normal[2]) < 0.5:
|
||||
continue # vertical face; not a top/bottom bounding plane
|
||||
if direction == "UP" and point[2] < base_z - tol_floor:
|
||||
continue # RL below the space base: ignore hits under it
|
||||
if direction == "DOWN" and abs(point[2] - base_z) < tol_floor:
|
||||
continue # flat floor at the space base: no bottom clip needed
|
||||
plane_hits.append((point, normal))
|
||||
|
||||
tol_normal = 0.02
|
||||
tol_distance = 0.05
|
||||
plane_groups: list[tuple[np.ndarray, list[np.ndarray]]] = []
|
||||
for point, normal in plane_hits:
|
||||
added = False
|
||||
for anchor, members in plane_groups:
|
||||
plane_normal = np.array(members[0])
|
||||
if np.linalg.norm(normal - plane_normal) < tol_normal:
|
||||
if abs(np.dot(point - anchor, plane_normal)) < tol_distance:
|
||||
members.append(normal)
|
||||
added = True
|
||||
break
|
||||
if not added:
|
||||
plane_groups.append((point, [normal]))
|
||||
|
||||
planes = []
|
||||
for anchor, normals in plane_groups:
|
||||
mean_normal = np.mean(normals, axis=0)
|
||||
mean_normal /= np.linalg.norm(mean_normal)
|
||||
if np.dot(mean_normal, ray_dir) < 0:
|
||||
mean_normal = -mean_normal
|
||||
planes.append((anchor, mean_normal))
|
||||
|
||||
return "EXTRUDE_CLIP", planes
|
||||
|
||||
|
||||
def detect_space_volume_strategy(
|
||||
ifc_file: ifcopenshell.file,
|
||||
shapes: dict,
|
||||
tree: ifcopenshell.geom.tree,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
bounding_walls: list[ifcopenshell.entity_instance],
|
||||
start_z: Optional[float] = None,
|
||||
) -> tuple[str, Optional[list], Optional[list]]:
|
||||
"""Decide whether a space can be represented as a clipped extrusion.
|
||||
|
||||
A space is "EXTRUDE_CLIP" when all bounding walls have vertical side faces
|
||||
and the detected top/bottom bounding planes are few and piecewise-planar
|
||||
(0-2 top planes, 0-1 bottom plane). Otherwise it is "BREP".
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param shapes: Cached element shapes.
|
||||
:param tree: Geometry tree with bounding elements.
|
||||
:param space_polygon: Space footprint in world XY.
|
||||
:param base_z: Base elevation in SI.
|
||||
:param bounding_walls: List of wall elements bounding the space.
|
||||
:param start_z: Ray-cast origin elevation (RL cut level) in SI.
|
||||
:return: ("EXTRUDE_CLIP", top_planes, bottom_planes) or ("BREP", None, None).
|
||||
"""
|
||||
tol = 0.02
|
||||
for wall in bounding_walls:
|
||||
shape_data = shapes.get(wall.id())
|
||||
if not shape_data:
|
||||
continue
|
||||
verts = shape_data["verts"]
|
||||
faces = shape_data["faces"]
|
||||
if len(verts) == 0 or len(faces) == 0:
|
||||
continue
|
||||
v1 = verts[faces[:, 1]] - verts[faces[:, 0]]
|
||||
v2 = verts[faces[:, 2]] - verts[faces[:, 0]]
|
||||
normals = np.cross(v1, v2)
|
||||
norms = np.linalg.norm(normals, axis=1)
|
||||
normals = normals[norms > 1e-8]
|
||||
if len(normals) == 0:
|
||||
continue
|
||||
normals = normals / np.linalg.norm(normals, axis=1)[:, np.newaxis]
|
||||
side_mask = np.abs(normals[:, 2]) < 0.5
|
||||
if np.any(side_mask) and np.mean(np.abs(normals[side_mask, 2])) > tol:
|
||||
return "BREP", None, None
|
||||
|
||||
_, top_planes = get_vertical_bounding_planes(ifc_file, shapes, tree, space_polygon, base_z, "UP", start_z=start_z)
|
||||
|
||||
_, bottom_planes = get_vertical_bounding_planes(
|
||||
ifc_file, shapes, tree, space_polygon, base_z, "DOWN", start_z=start_z
|
||||
)
|
||||
|
||||
if len(top_planes) > 2 or len(bottom_planes) > 1:
|
||||
return "BREP", None, None
|
||||
|
||||
return "EXTRUDE_CLIP", top_planes, bottom_planes
|
||||
|
||||
|
||||
def _footprint_coords(space_polygon: shapely.Polygon):
|
||||
"""Yield (x, y) boundary coordinates of a footprint polygon (exterior then holes)."""
|
||||
for coords in [space_polygon.exterior.coords, *[ring.coords for ring in space_polygon.interiors]]:
|
||||
for point in coords:
|
||||
yield point[0], point[1]
|
||||
|
||||
|
||||
def build_extruded_clipped_space(
|
||||
ifc_file: ifcopenshell.file,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
top_planes: list[tuple[np.ndarray, np.ndarray]],
|
||||
bottom_planes: list[tuple[np.ndarray, np.ndarray]],
|
||||
) -> ifcopenshell.entity_instance:
|
||||
"""Build an IfcExtrudedAreaSolid clipped to top/bottom planes.
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param space_polygon: Footprint polygon in world XY.
|
||||
:param base_z: Base elevation in SI.
|
||||
:param top_planes: List of (point, normal) tuples for top clipping planes.
|
||||
:param bottom_planes: List of (point, normal) tuples for bottom clipping planes.
|
||||
:return: IfcBooleanClippingResult chain.
|
||||
"""
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
|
||||
centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y])
|
||||
exterior = [
|
||||
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
|
||||
for p in space_polygon.exterior.coords[:-1]
|
||||
]
|
||||
inner_curves = []
|
||||
for interior in space_polygon.interiors:
|
||||
inner = [
|
||||
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
|
||||
for p in interior.coords[:-1]
|
||||
]
|
||||
inner_curve = builder.polyline(inner, closed=True)
|
||||
inner_curves.append(inner_curve)
|
||||
|
||||
outer_curve = builder.polyline(exterior, closed=True)
|
||||
profile = builder.profile(outer_curve, inner_curves=inner_curves)
|
||||
|
||||
all_z = [base_z]
|
||||
for point, normal in top_planes + bottom_planes:
|
||||
all_z.append(float(point[2]))
|
||||
for x, y in _footprint_coords(space_polygon):
|
||||
# A sloped plane's height varies across the footprint. The anchor
|
||||
# point is near the centre, so also cover the plane at the polygon
|
||||
# vertices, otherwise the high side of a sloped ceiling is capped
|
||||
# below the plane it should reach.
|
||||
if abs(normal[2]) < 1e-6:
|
||||
continue
|
||||
plane_z = (float(np.dot(normal, point)) - float(normal[0]) * x - float(normal[1]) * y) / float(normal[2])
|
||||
all_z.append(plane_z)
|
||||
min_z = min(all_z)
|
||||
max_z = max(all_z)
|
||||
height = max_z - min_z
|
||||
|
||||
extrusion = builder.extrude(
|
||||
profile,
|
||||
magnitude=height / unit_scale,
|
||||
position=[(centroid[0] / unit_scale), (centroid[1] / unit_scale), min_z / unit_scale],
|
||||
)
|
||||
|
||||
result = extrusion
|
||||
for point, normal in top_planes + bottom_planes:
|
||||
# clip_solid takes location in SI; it converts to project units internally.
|
||||
result = ifcopenshell.api.geometry.clip_solid(
|
||||
ifc_file,
|
||||
item=result,
|
||||
location=[float(point[i]) for i in range(3)],
|
||||
normal=[float(normal[i]) for i in range(3)],
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_local_shapes(ifc_file: ifcopenshell.file) -> dict:
|
||||
"""Build the local-coordinates shapes dict required by auto_generate_boundaries.
|
||||
|
||||
Keys are element ids; values have ``verts`` (local), ``faces``, ``edges``
|
||||
and ``matrix`` as produced by ``ifcopenshell.geom.iterator``.
|
||||
"""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("disable-opening-subtractions", True)
|
||||
shapes = {}
|
||||
iterator = ifcopenshell.geom.iterator(settings, ifc_file)
|
||||
if iterator.initialize():
|
||||
while True:
|
||||
shape = iterator.get()
|
||||
shapes[shape.id] = {
|
||||
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
|
||||
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
|
||||
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
|
||||
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
|
||||
}
|
||||
if not iterator.next():
|
||||
break
|
||||
return shapes
|
||||
|
||||
|
||||
def build_brep_space(
|
||||
ifc_file: ifcopenshell.file,
|
||||
space: ifcopenshell.entity_instance,
|
||||
shapes: dict,
|
||||
space_polygon: shapely.Polygon,
|
||||
base_z: float,
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Build a closed-shell B-rep space from auto-generated boundary faces.
|
||||
|
||||
Seeds the space with a temporary extrusion spanning the bounding elements'
|
||||
vertical extent, generates 1st-level boundaries with
|
||||
``auto_generate_boundaries``, then merges the boundary polygons into a
|
||||
closed mesh (``IfcFacetedBrep``/``IfcPolygonalFaceSet``).
|
||||
|
||||
:param ifc_file: The IFC file.
|
||||
:param space: The IfcSpace entity.
|
||||
:param shapes: Cached element shapes (world coords) for the z-extent.
|
||||
:param space_polygon: Footprint polygon in world XY.
|
||||
:param base_z: Base elevation in SI.
|
||||
:return: IfcFacetedBrep or IfcPolygonalFaceSet, or None if boundaries
|
||||
cannot be resolved.
|
||||
"""
|
||||
all_z = [base_z]
|
||||
for shape_data in shapes.values():
|
||||
all_z.append(shape_data["top_z"])
|
||||
all_z.append(shape_data["bottom_z"])
|
||||
min_z = min(all_z)
|
||||
max_z = max(all_z)
|
||||
height = max_z - min_z
|
||||
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
centroid = np.array([space_polygon.centroid.x, space_polygon.centroid.y])
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
exterior = [
|
||||
(float(p[0] - centroid[0]) / unit_scale, float(p[1] - centroid[1]) / unit_scale)
|
||||
for p in space_polygon.exterior.coords[:-1]
|
||||
]
|
||||
outer_curve = builder.polyline(exterior, closed=True)
|
||||
profile = builder.profile(outer_curve)
|
||||
|
||||
ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
if ctx is None:
|
||||
return None
|
||||
|
||||
seed = builder.extrude(
|
||||
profile,
|
||||
magnitude=height / unit_scale,
|
||||
position=[centroid[0] / unit_scale, centroid[1] / unit_scale, min_z / unit_scale],
|
||||
)
|
||||
seed_rep = builder.get_representation(ctx, seed)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=space, representation=seed_rep)
|
||||
|
||||
local_shapes = _build_local_shapes(ifc_file)
|
||||
boundary_class = "IfcRelSpaceBoundary1stLevel"
|
||||
if ifc_file.schema == "IFC2X3":
|
||||
boundary_class = "IfcRelSpaceBoundary"
|
||||
boundaries = ifcopenshell.util.boundary.auto_generate_boundaries(
|
||||
ifc_file, space, local_shapes, boundary_class=boundary_class
|
||||
)
|
||||
if isinstance(boundaries, str) or not boundaries:
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep)
|
||||
return None
|
||||
|
||||
points = []
|
||||
point_index = {}
|
||||
faces = []
|
||||
|
||||
def add_point(p):
|
||||
key = tuple(np.round(p, 5))
|
||||
if key not in point_index:
|
||||
point_index[key] = len(points)
|
||||
points.append([float(c) for c in p])
|
||||
return point_index[key]
|
||||
|
||||
for boundary in boundaries:
|
||||
connection = boundary.ConnectionGeometry
|
||||
if connection is None:
|
||||
continue
|
||||
surface = connection.SurfaceOnRelatingElement
|
||||
if surface is None or not surface.is_a("IfcCurveBoundedPlane"):
|
||||
continue
|
||||
outer = surface.OuterBoundary
|
||||
if outer is None or not outer.is_a("IfcPolyline"):
|
||||
continue
|
||||
matrix = ifcopenshell.util.placement.get_axis2placement(surface.BasisSurface.Position)
|
||||
poly_points = []
|
||||
for loop_point in outer.Points:
|
||||
local = np.array([float(c) for c in loop_point.Coordinates])
|
||||
if len(local) == 2:
|
||||
local = np.array([*local, 0.0])
|
||||
world = np.delete(matrix @ np.array([*local, 1.0]), 3)
|
||||
poly_points.append(world)
|
||||
if len(poly_points) >= 3:
|
||||
faces.append([add_point(p) for p in poly_points])
|
||||
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=seed_rep)
|
||||
|
||||
if not faces:
|
||||
return None
|
||||
|
||||
tri_faces = []
|
||||
for face in faces:
|
||||
for i in range(1, len(face) - 1):
|
||||
tri_faces.append([face[0], face[i], face[i + 1]])
|
||||
|
||||
return builder.mesh(points, tri_faces)
|
||||
|
||||
Submodule src/ifcopenshell-python/test/IfcRelSpaceBoundary_TestFiles added at 748de702d7
@@ -0,0 +1,440 @@
|
||||
# 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/>.
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import shapely
|
||||
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.boundary as subject
|
||||
import ifcopenshell.util.shape
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0, direction=(0.0, 0.0, 1.0)):
|
||||
"""Add a body representation (extruded polyline) to an element."""
|
||||
if not ifc_file.by_type("IfcProject"):
|
||||
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
|
||||
ctx = ifc_file.createIfcGeometricRepresentationContext(
|
||||
ContextType="Model",
|
||||
CoordinateSpaceDimension=3,
|
||||
Precision=1e-5,
|
||||
WorldCoordinateSystem=ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
),
|
||||
)
|
||||
sub_ctx = ifc_file.createIfcGeometricRepresentationSubContext(
|
||||
ContextIdentifier="Body",
|
||||
ContextType="Model",
|
||||
ParentContext=ctx,
|
||||
TargetView="MODEL_VIEW",
|
||||
)
|
||||
pts = [ifc_file.createIfcCartesianPoint((float(x), float(y))) for x, y in coords_2d]
|
||||
polyline = ifc_file.createIfcPolyline(pts)
|
||||
profile = ifc_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="CURVE", OuterCurve=polyline)
|
||||
placement = ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, z_offset)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
)
|
||||
direction = ifc_file.createIfcDirection((float(direction[0]), float(direction[1]), float(direction[2])))
|
||||
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
|
||||
rep = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=sub_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[solid],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=rep)
|
||||
|
||||
|
||||
def _build_shapes_dict(ifc_file, elements):
|
||||
"""Build a shapes dict as expected by ifcopenshell.util.boundary."""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("disable-opening-subtractions", True)
|
||||
shapes = {}
|
||||
for element in elements:
|
||||
shape = ifcopenshell.geom.create_shape(settings, element)
|
||||
shapes[element.id()] = {
|
||||
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
|
||||
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
|
||||
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
|
||||
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
|
||||
}
|
||||
return shapes
|
||||
|
||||
|
||||
def _build_shapes_dict_from_iterator(ifc_file):
|
||||
"""Build a shapes dict for all products in a file (excluding openings)."""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("disable-opening-subtractions", True)
|
||||
shapes = {}
|
||||
iterator = ifcopenshell.geom.iterator(settings, ifc_file)
|
||||
if iterator.initialize():
|
||||
while True:
|
||||
shape = iterator.get()
|
||||
element = ifc_file.by_id(shape.id)
|
||||
if not element.is_a("IfcOpeningElement"):
|
||||
shapes[shape.id] = {
|
||||
"verts": ifcopenshell.util.shape.get_vertices(shape.geometry),
|
||||
"faces": ifcopenshell.util.shape.get_faces(shape.geometry),
|
||||
"edges": ifcopenshell.util.shape.get_edges(shape.geometry),
|
||||
"matrix": ifcopenshell.util.shape.get_shape_matrix(shape),
|
||||
}
|
||||
if not iterator.next():
|
||||
break
|
||||
return shapes
|
||||
|
||||
|
||||
def _boundaries_for(boundaries, element):
|
||||
return [b for b in boundaries if b.RelatedBuildingElement == element]
|
||||
|
||||
|
||||
def _boundary_inner_count(boundary):
|
||||
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement if boundary.ConnectionGeometry else None
|
||||
if surface and surface.is_a("IfcCurveBoundedPlane") and surface.InnerBoundaries:
|
||||
return len(surface.InnerBoundaries)
|
||||
return 0
|
||||
|
||||
|
||||
def _outer_boundary_area(boundary):
|
||||
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement
|
||||
points = [(p.Coordinates[0], p.Coordinates[1]) for p in surface.OuterBoundary.Points]
|
||||
area = 0.0
|
||||
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
||||
area += x1 * y2 - x2 * y1
|
||||
return 0.5 * abs(area)
|
||||
|
||||
|
||||
def _boundary_polygon_3d(boundary):
|
||||
"""The boundary outer boundary as world-space 3D points."""
|
||||
surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement
|
||||
position = surface.BasisSurface.Position
|
||||
origin = np.array(position.Location.Coordinates, dtype=float)
|
||||
z = np.array(position.Axis.DirectionRatios if position.Axis else [0, 0, 1], dtype=float)
|
||||
x = np.array(position.RefDirection.DirectionRatios if position.RefDirection else [1, 0, 0], dtype=float)
|
||||
y = np.cross(z, x)
|
||||
points = np.array([[p.Coordinates[0], p.Coordinates[1]] for p in surface.OuterBoundary.Points])
|
||||
return origin + points[:, 0, None] * x + points[:, 1, None] * y
|
||||
|
||||
|
||||
def _boundary_polygon_in_plane(boundary, reference=None):
|
||||
"""The boundary polygon projected onto the reference boundary plane."""
|
||||
reference = reference or boundary
|
||||
surface = reference.ConnectionGeometry.SurfaceOnRelatingElement
|
||||
position = surface.BasisSurface.Position
|
||||
origin = np.array(position.Location.Coordinates, dtype=float)
|
||||
z = np.array(position.Axis.DirectionRatios if position.Axis else [0, 0, 1], dtype=float)
|
||||
x = np.array(position.RefDirection.DirectionRatios if position.RefDirection else [1, 0, 0], dtype=float)
|
||||
y = np.cross(z, x)
|
||||
points = _boundary_polygon_3d(boundary) - origin
|
||||
coords = [(float(p @ x), float(p @ y)) for p in points]
|
||||
return shapely.Polygon(coords)
|
||||
|
||||
|
||||
def _add_wall_with_window(ifc_file):
|
||||
"""Add a space bounded by a wall with a fully interior opening filled by a window."""
|
||||
space = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcSpace")
|
||||
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall")
|
||||
opening_element = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcOpeningElement")
|
||||
window = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWindow")
|
||||
_add_extruded_body(ifc_file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(ifc_file, wall, [[-5, 5], [5, 5], [5, 5.5], [-5, 5.5]], 3.0)
|
||||
_add_extruded_body(ifc_file, opening_element, [[-2, 5], [2, 5], [2, 5.5], [-2, 5.5]], 1.8, z_offset=0.6)
|
||||
_add_extruded_body(ifc_file, window, [[-2, 4.5], [2, 4.5], [2, 5.5], [-2, 5.5]], 1.5, z_offset=0.75)
|
||||
ifc_file.createIfcRelVoidsElement(RelatingBuildingElement=wall, RelatedOpeningElement=opening_element)
|
||||
ifc_file.createIfcRelFillsElement(RelatingOpeningElement=opening_element, RelatedBuildingElement=window)
|
||||
return space, wall, window
|
||||
|
||||
|
||||
def _add_roof_with_skylight(ifc_file):
|
||||
"""Add a space with a roof pierced by an opening covered by a skylight window.
|
||||
|
||||
The window is deliberately not related through IfcRelFillsElement to exercise
|
||||
the geometric detection of fillings.
|
||||
"""
|
||||
space = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcSpace")
|
||||
roof = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcRoof")
|
||||
opening_element = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcOpeningElement")
|
||||
window = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWindow")
|
||||
_add_extruded_body(ifc_file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(ifc_file, roof, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.5, z_offset=3.0)
|
||||
_add_extruded_body(ifc_file, opening_element, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 0.8, z_offset=2.8)
|
||||
_add_extruded_body(ifc_file, window, [[-1, -1], [1, -1], [1, 1], [-1, 1]], 0.2, z_offset=3.5)
|
||||
ifc_file.createIfcRelVoidsElement(RelatingBuildingElement=roof, RelatedOpeningElement=opening_element)
|
||||
return space, roof, window
|
||||
|
||||
|
||||
def _external_earth_ifczip():
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"IfcRelSpaceBoundary_TestFiles",
|
||||
"IfcRelSpaceBoundary2ndLevel",
|
||||
"ExternalEarth_R20_IFC4.ifczip",
|
||||
)
|
||||
|
||||
|
||||
def _over_splitted_ifc():
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"IfcRelSpaceBoundary_TestFiles",
|
||||
"IfcRelSpaceBoundary2ndLevel",
|
||||
"OverSplitted_R20_IFC2X3.ifc",
|
||||
)
|
||||
|
||||
|
||||
def _small_house_ifczip():
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"IfcRelSpaceBoundary_TestFiles",
|
||||
"IfcRelSpaceBoundary2ndLevel",
|
||||
"SmallHouse_BB_IFC4.ifczip",
|
||||
)
|
||||
|
||||
|
||||
def _triangle_ifczip():
|
||||
return os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"IfcRelSpaceBoundary_TestFiles",
|
||||
"IfcRelSpaceBoundary2ndLevel",
|
||||
"Triangle_BB_IFC4.ifczip",
|
||||
)
|
||||
|
||||
|
||||
def _boundary_element_counts(ifc_file, space_id):
|
||||
space = ifc_file.by_id(space_id)
|
||||
counts = Counter()
|
||||
for boundary in space.BoundedBy or []:
|
||||
if boundary.RelatedBuildingElement:
|
||||
counts[boundary.RelatedBuildingElement.id()] += 1
|
||||
return counts
|
||||
|
||||
|
||||
class TestAutoGenerateBoundaries(test.bootstrap.IFC4):
|
||||
def test_no_building_elements_returns_error(self):
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
shapes = _build_shapes_dict(self.file, [space])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, str)
|
||||
assert "No building elements" in result
|
||||
|
||||
def test_space_not_in_shapes_returns_error(self):
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
result = subject.auto_generate_boundaries(self.file, space, {}, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, str)
|
||||
assert "not found" in result.lower()
|
||||
|
||||
def test_generates_boundary_for_adjacent_wall(self):
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.2], [-5, 5.2]], 3.0)
|
||||
shapes = _build_shapes_dict(self.file, [space, wall])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 1
|
||||
boundary = result[0]
|
||||
assert boundary.RelatingSpace == space
|
||||
assert boundary.RelatedBuildingElement == wall
|
||||
assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
|
||||
def test_generates_boundary_for_sloped_wall(self):
|
||||
# A wall whose inner face is tilted away from the space (extrusion
|
||||
# direction (0, 0.5, 1.0) makes the face ~153 deg from the seed face
|
||||
# normal) previously matched no seed face, so no boundary was
|
||||
# generated. The relaxed fallback match should attribute the space's
|
||||
# north face to the wall.
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.2], [-5, 5.2]], 3.0, direction=(0.0, 0.5, 1.0))
|
||||
shapes = _build_shapes_dict(self.file, [space, wall])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, list)
|
||||
wall_boundaries = _boundaries_for(result, wall)
|
||||
assert len(wall_boundaries) == 1
|
||||
assert wall_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
assert _outer_boundary_area(wall_boundaries[0]) == pytest.approx(26.83, abs=1e-2)
|
||||
|
||||
def test_generates_boundary_for_sloped_roof(self):
|
||||
# A roof slab extruded at a tilt so its underside is a sloped plane
|
||||
# (~163 deg from the seed top face normal) cutting across the space's
|
||||
# top face. Previously no seed face matched it, so no boundary was
|
||||
# generated.
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
roof = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(
|
||||
self.file, roof, [[-5, -7], [5, -7], [5, -5], [-5, -5]], 2.0, z_offset=3.2, direction=(0.0, 1.0, 0.3)
|
||||
)
|
||||
shapes = _build_shapes_dict(self.file, [space, roof])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary")
|
||||
assert isinstance(result, list)
|
||||
roof_boundaries = _boundaries_for(result, roof)
|
||||
assert len(roof_boundaries) == 1
|
||||
assert roof_boundaries[0].PhysicalOrVirtualBoundary == "PHYSICAL"
|
||||
assert _outer_boundary_area(roof_boundaries[0]) == pytest.approx(19.16, abs=1e-2)
|
||||
|
||||
def test_wall_boundary_with_window_has_no_inner_boundary(self):
|
||||
space, wall, window = _add_wall_with_window(self.file)
|
||||
shapes = _build_shapes_dict(self.file, [space, wall, window])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
|
||||
assert isinstance(result, list)
|
||||
wall_boundaries = _boundaries_for(result, wall)
|
||||
assert len(wall_boundaries) == 1
|
||||
assert _boundary_inner_count(wall_boundaries[0]) == 0
|
||||
assert _outer_boundary_area(wall_boundaries[0]) == pytest.approx(30.0, abs=1e-3)
|
||||
window_boundaries = _boundaries_for(result, window)
|
||||
assert len(window_boundaries) == 1
|
||||
assert window_boundaries[0].ParentBoundary == wall_boundaries[0]
|
||||
|
||||
def test_roof_boundary_with_skylight_has_no_inner_boundary(self):
|
||||
space, roof, window = _add_roof_with_skylight(self.file)
|
||||
shapes = _build_shapes_dict(self.file, [space, roof, window])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
|
||||
assert isinstance(result, list)
|
||||
roof_boundaries = _boundaries_for(result, roof)
|
||||
assert len(roof_boundaries) == 1
|
||||
assert _boundary_inner_count(roof_boundaries[0]) == 0
|
||||
assert _outer_boundary_area(roof_boundaries[0]) == pytest.approx(100.0, abs=1e-3)
|
||||
window_boundaries = _boundaries_for(result, window)
|
||||
assert len(window_boundaries) == 1
|
||||
assert window_boundaries[0].ParentBoundary == roof_boundaries[0]
|
||||
|
||||
def test_wall_boundary_with_unfilled_opening_has_no_inner_boundary(self):
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
opening_element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement")
|
||||
_add_extruded_body(self.file, space, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, wall, [[-5, 5], [5, 5], [5, 5.5], [-5, 5.5]], 3.0)
|
||||
_add_extruded_body(self.file, opening_element, [[-2, 5], [2, 5], [2, 5.5], [-2, 5.5]], 1.8, z_offset=0.6)
|
||||
self.file.createIfcRelVoidsElement(RelatingBuildingElement=wall, RelatedOpeningElement=opening_element)
|
||||
shapes = _build_shapes_dict(self.file, [space, wall])
|
||||
result = subject.auto_generate_boundaries(self.file, space, shapes, "IfcRelSpaceBoundary2ndLevel")
|
||||
assert isinstance(result, list)
|
||||
wall_boundaries = _boundaries_for(result, wall)
|
||||
assert len(wall_boundaries) == 1
|
||||
assert _boundary_inner_count(wall_boundaries[0]) == 0
|
||||
|
||||
def test_openings_are_unioned_into_parent_boundary(self):
|
||||
# Some authoring tools bake the opening into the building element mesh,
|
||||
# leaving a notch in the gross boundary polygon. The parent boundary must
|
||||
# union the opening back in so it overlaps its own inner boundary.
|
||||
wall_face = shapely.Polygon([(-5, 5), (5, 5), (5, 5.5), (2, 5.5), (2, 5), (-2, 5), (-2, 5.5), (-5, 5.5)])
|
||||
window = shapely.Polygon([(-2, 5), (2, 5), (2, 5.5), (-2, 5.5)])
|
||||
assert wall_face.area == pytest.approx(3.0, abs=1e-9)
|
||||
parent = subject._union_openings_into_parent(wall_face, [("opening", "window", window)])
|
||||
assert isinstance(parent, shapely.Polygon)
|
||||
assert parent.area == pytest.approx(5.0, abs=1e-9)
|
||||
assert parent.contains(window)
|
||||
|
||||
def test_external_earth_boundaries(self):
|
||||
ifczip = _external_earth_ifczip()
|
||||
if not os.path.exists(ifczip):
|
||||
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
|
||||
ifc_file = ifcopenshell.open(ifczip)
|
||||
shapes = _build_shapes_dict_from_iterator(ifc_file)
|
||||
for space_id, expected_counts in [
|
||||
(182, {996: 1, 1122: 1, 1235: 2, 1288: 1, 2970: 1, 3071: 1, 3140: 1, 3214: 1, 3435: 1, 3605: 1, 3719: 1}),
|
||||
(440, {1122: 1, 3140: 1, 3214: 1, 3493: 1, 3605: 1, 3640: 1, 3669: 1, 3719: 1}),
|
||||
(628, {3140: 1, 3838: 1, 3927: 1, 3980: 2, 4033: 1, 4086: 1, 4139: 1, 4199: 1}),
|
||||
]:
|
||||
copy = ifcopenshell.file.from_string(ifc_file.to_string())
|
||||
new_space = copy.by_id(space_id)
|
||||
result = subject.auto_generate_boundaries(
|
||||
copy, new_space, shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
|
||||
)
|
||||
assert _boundary_element_counts(copy, space_id) == expected_counts
|
||||
for boundary in result:
|
||||
assert _boundary_inner_count(boundary) == 0
|
||||
if boundary.ParentBoundary:
|
||||
parent_polygon = _boundary_polygon_in_plane(boundary.ParentBoundary)
|
||||
child_polygon = _boundary_polygon_in_plane(boundary, reference=boundary.ParentBoundary)
|
||||
assert child_polygon.intersection(parent_polygon).area == pytest.approx(
|
||||
child_polygon.area, abs=1e-2
|
||||
)
|
||||
if space_id in (182, 440):
|
||||
roof_boundaries = _boundaries_for(result, copy.by_id(3214))
|
||||
assert len(roof_boundaries) == 1
|
||||
skylight = [b for b in result if b.RelatedBuildingElement.id() in (3435, 3640)]
|
||||
assert len(skylight) == 1
|
||||
assert skylight[0].ParentBoundary == roof_boundaries[0]
|
||||
|
||||
def test_over_splitted_roof_keeps_shaft_opening(self):
|
||||
# Space 251 of OverSplitted_R20_IFC2X3.ifc has an L-shaped ceiling
|
||||
# pierced by a shaft opening. The ceiling boundary must keep the
|
||||
# opening as an inner boundary, and each shaft wall must get exactly
|
||||
# one boundary instead of fragmented partial + gap boundaries.
|
||||
ifc_path = _over_splitted_ifc()
|
||||
if not os.path.exists(ifc_path):
|
||||
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
|
||||
ifc_file = ifcopenshell.open(ifc_path)
|
||||
shapes = _build_shapes_dict_from_iterator(ifc_file)
|
||||
copy = ifcopenshell.file.from_string(ifc_file.to_string())
|
||||
result = subject.auto_generate_boundaries(
|
||||
copy, copy.by_id(251), shapes=shapes, boundary_class="IfcRelSpaceBoundary"
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 17
|
||||
roof_boundaries = _boundaries_for(result, copy.by_id(5248))
|
||||
assert len(roof_boundaries) == 1
|
||||
assert _boundary_inner_count(roof_boundaries[0]) == 1
|
||||
surface = roof_boundaries[0].ConnectionGeometry.SurfaceOnRelatingElement
|
||||
outer = [(p.Coordinates[0], p.Coordinates[1]) for p in surface.OuterBoundary.Points]
|
||||
inner = [[(p.Coordinates[0], p.Coordinates[1]) for p in ib.Points] for ib in surface.InnerBoundaries]
|
||||
assert shapely.Polygon(outer, inner).area == pytest.approx(9.962, abs=1e-3)
|
||||
for wall_id in (5832, 5877, 5922, 5967):
|
||||
assert len(_boundaries_for(result, copy.by_id(wall_id))) == 1
|
||||
|
||||
def test_small_house_boundaries(self):
|
||||
ifc_path = _small_house_ifczip()
|
||||
if not os.path.exists(ifc_path):
|
||||
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
|
||||
ifc_file = ifcopenshell.open(ifc_path)
|
||||
shapes = _build_shapes_dict_from_iterator(ifc_file)
|
||||
for space_id, expected_total in [(1692, 8), (4356, 8), (4380, 13), (6185, 1)]:
|
||||
copy = ifcopenshell.file.from_string(ifc_file.to_string())
|
||||
result = subject.auto_generate_boundaries(
|
||||
copy, copy.by_id(space_id), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
|
||||
)
|
||||
assert len(result) == expected_total
|
||||
|
||||
def test_triangle_boundaries(self):
|
||||
ifc_path = _triangle_ifczip()
|
||||
if not os.path.exists(ifc_path):
|
||||
pytest.skip("IfcRelSpaceBoundary_TestFiles submodule is not checked out")
|
||||
ifc_file = ifcopenshell.open(ifc_path)
|
||||
shapes = _build_shapes_dict_from_iterator(ifc_file)
|
||||
copy = ifcopenshell.file.from_string(ifc_file.to_string())
|
||||
result = subject.auto_generate_boundaries(
|
||||
copy, copy.by_id(1573), shapes=shapes, boundary_class="IfcRelSpaceBoundary2ndLevel"
|
||||
)
|
||||
assert len(result) == 6
|
||||
@@ -95,3 +95,45 @@ class TestBisectMeshPlaneVf:
|
||||
for start, end in segments:
|
||||
for coord in start + end:
|
||||
assert round(coord, 6) == coord
|
||||
|
||||
|
||||
class TestDissolveFaces:
|
||||
def test_dissolve_cube_into_ngons(self):
|
||||
"""A triangulated cube (12 triangles) should dissolve into 6 quad faces."""
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
edges = np.array(
|
||||
[
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
[3, 0],
|
||||
[4, 5],
|
||||
[5, 6],
|
||||
[6, 7],
|
||||
[7, 4],
|
||||
[0, 4],
|
||||
[1, 5],
|
||||
[2, 6],
|
||||
[3, 7],
|
||||
],
|
||||
dtype=np.int32,
|
||||
)
|
||||
ngons = subject.dissolve_faces(verts, faces, edges)
|
||||
assert len(ngons) == 6
|
||||
for ngon in ngons:
|
||||
assert len(ngon) == 4
|
||||
|
||||
def test_dissolve_no_edges_returns_triangles(self):
|
||||
"""With no original edges, triangles should be returned as-is."""
|
||||
verts, faces = _cube_verts_faces(size=2.0)
|
||||
edges = np.array([], dtype=np.int32).reshape(0, 2)
|
||||
ngons = subject.dissolve_faces(verts, faces, edges)
|
||||
assert len(ngons) == 12
|
||||
for ngon in ngons:
|
||||
assert len(ngon) == 3
|
||||
|
||||
def test_dissolve_empty_faces(self):
|
||||
verts = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float64)
|
||||
faces = np.array([], dtype=np.int32).reshape(0, 3)
|
||||
edges = np.array([], dtype=np.int32).reshape(0, 2)
|
||||
assert subject.dissolve_faces(verts, faces, edges) == []
|
||||
|
||||
@@ -22,6 +22,9 @@ import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.space as subject
|
||||
import math
|
||||
import numpy as np
|
||||
import os
|
||||
import pytest
|
||||
import shapely
|
||||
import test.bootstrap
|
||||
@@ -47,7 +50,7 @@ def _build_shapes_dict(ifc_file, elements):
|
||||
return shapes
|
||||
|
||||
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
|
||||
def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0, direction=(0.0, 0.0, 1.0)):
|
||||
"""Add a body representation (extruded polyline) to an element."""
|
||||
if not ifc_file.by_type("IfcProject"):
|
||||
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
|
||||
@@ -75,7 +78,7 @@ def _add_extruded_body(ifc_file, element, coords_2d, depth, z_offset=0.0):
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
)
|
||||
direction = ifc_file.createIfcDirection((0.0, 0.0, 1.0))
|
||||
direction = ifc_file.createIfcDirection(direction)
|
||||
solid = ifc_file.createIfcExtrudedAreaSolid(profile, placement, direction, depth)
|
||||
rep = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
@@ -184,3 +187,158 @@ class TestGetAutoSpaceHeight(test.bootstrap.IFC4):
|
||||
space_polygon = shapely.box(-100, -100, -90, -90)
|
||||
height = subject.get_auto_space_height(self.file, shapes, space_polygon, 0.0, [])
|
||||
assert height is None
|
||||
|
||||
|
||||
class TestGetVerticalBoundingPlanes(test.bootstrap.IFC4):
|
||||
def test_flat_ceiling_returns_single_plane(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
strategy, planes = subject.get_vertical_bounding_planes(
|
||||
self.file, shapes, tree, space_polygon, base_z=0.0, direction="UP"
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(planes) == 1
|
||||
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
|
||||
assert np.allclose(planes[0][1], [0.0, 0.0, 1.0], atol=0.01)
|
||||
|
||||
def test_flat_ceiling_returns_single_plane_from_rl_origin(self):
|
||||
# Same result when rays are cast from an RL cut elevation (z=1.0)
|
||||
# instead of the default base_z + 0.001.
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, planes = subject.get_vertical_bounding_planes(
|
||||
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), base_z=0.0, direction="UP", start_z=1.0
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(planes) == 1
|
||||
assert np.allclose(planes[0][0], [0.0, 0.0, 3.0], atol=0.1)
|
||||
|
||||
|
||||
class TestDetectSpaceVolumeStrategy(test.bootstrap.IFC4):
|
||||
def test_vertical_walls_flat_slab_returns_extrude_clip(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, top, bottom = subject.detect_space_volume_strategy(
|
||||
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall]
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(top) == 1
|
||||
assert len(bottom) == 0
|
||||
|
||||
def test_vertical_walls_flat_slab_returns_extrude_clip_from_rl_origin(self):
|
||||
# Same as above but rays cast from an RL cut elevation (z=1.0).
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 3.0)
|
||||
_add_extruded_body(self.file, slab, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 0.3, z_offset=3.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall, slab])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, top, bottom = subject.detect_space_volume_strategy(
|
||||
self.file, shapes, tree, shapely.box(-5, -5, 5, 5), 0.0, [wall], start_z=1.0
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(top) == 1
|
||||
assert len(bottom) == 0
|
||||
|
||||
def test_sloped_wall_returns_brep(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 6.0, direction=(0.0, 0.5, 1.0))
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, _, _ = subject.detect_space_volume_strategy(
|
||||
self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall]
|
||||
)
|
||||
assert strategy == "BREP"
|
||||
|
||||
def test_curved_vertical_wall_returns_extrude_clip(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
pts = []
|
||||
for i in range(9):
|
||||
angle = -math.pi / 2.0 + math.pi * i / 8.0
|
||||
pts.append((5.0 * math.cos(angle), 5.0 * math.sin(angle)))
|
||||
_add_extruded_body(self.file, wall, pts, 6.0)
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
tree = ifcopenshell.geom.tree(self.file)
|
||||
tree.add_file(self.file, ifcopenshell.geom.settings())
|
||||
strategy, top, bottom = subject.detect_space_volume_strategy(
|
||||
self.file, shapes, tree, shapely.box(-4, -4, 4, 4), 0.0, [wall]
|
||||
)
|
||||
assert strategy == "EXTRUDE_CLIP"
|
||||
assert len(top) == 1
|
||||
assert len(bottom) == 0
|
||||
|
||||
|
||||
class TestBuildExtrudedClippedSpace(test.bootstrap.IFC4):
|
||||
def test_shed_roof_clips_to_sloped_plane(self):
|
||||
space_polygon = shapely.box(-5, -5, 5, 5)
|
||||
top_plane = (np.array([0.0, 0.0, 4.0]), np.array([0.0, 0.0, 1.0]))
|
||||
bottom_plane = (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, -1.0]))
|
||||
item = subject.build_extruded_clipped_space(self.file, space_polygon, 0.0, [top_plane], [bottom_plane])
|
||||
assert item.is_a("IfcBooleanClippingResult")
|
||||
assert item.SecondOperand.is_a("IfcHalfSpaceSolid")
|
||||
# Chain: bottom clip -> top clip -> IfcExtrudedAreaSolid.
|
||||
assert item.FirstOperand.is_a("IfcBooleanClippingResult")
|
||||
assert item.FirstOperand.FirstOperand.is_a("IfcExtrudedAreaSolid")
|
||||
|
||||
def test_sloped_top_plane_reaches_footprint_max(self):
|
||||
# A sloped ceiling's high side must reach the plane at the footprint
|
||||
# edge (z=7.0 at y=-5), not be capped at the plane anchor z=5.5.
|
||||
# 3D coords guard against the polygon carrying Z from the bisection.
|
||||
space_polygon = shapely.Polygon([(-5, -5, 0), (5, -5, 0), (5, 5, 0), (-5, 5, 0)])
|
||||
top_plane = (np.array([0.0, 0.0, 5.5]), np.array([0.0, 0.287, 0.958]))
|
||||
bottom_plane = (np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, -1.0]))
|
||||
item = subject.build_extruded_clipped_space(self.file, space_polygon, 0.0, [top_plane], [bottom_plane])
|
||||
shape = ifcopenshell.geom.create_shape(ifcopenshell.geom.settings(), item)
|
||||
verts = ifcopenshell.util.shape.get_vertices(shape)
|
||||
assert verts[:, 2].min() == pytest.approx(0.0, abs=0.1)
|
||||
assert verts[:, 2].max() == pytest.approx(7.0, abs=0.1)
|
||||
|
||||
|
||||
class TestBuildBrepSpace(test.bootstrap.IFC4):
|
||||
def test_sloped_wall_brep_has_faces(self):
|
||||
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
_add_extruded_body(self.file, wall, [[-5, -5], [5, -5], [5, 5], [-5, 5]], 6.0, direction=(0.0, 0.5, 1.0))
|
||||
space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace")
|
||||
shapes = _build_shapes_dict(self.file, [wall])
|
||||
item = subject.build_brep_space(self.file, space, shapes, shapely.box(-4, -4, 4, 4), 0.0)
|
||||
assert item is not None
|
||||
assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet")
|
||||
|
||||
|
||||
class TestBuildBrepSpaceIfc2x3:
|
||||
def test_build_brep_space_on_ifc2x3_uses_rel_space_boundary(self):
|
||||
# IFC2X3 does not have IfcRelSpaceBoundary1stLevel; build_brep_space must
|
||||
# fall back to plain IfcRelSpaceBoundary without raising a schema error.
|
||||
# We use the real IFC2X3 fixture because the installed wrapper in this
|
||||
# environment has a quirk with synthetic IFC2X3 geometry creation.
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"IfcRelSpaceBoundary_TestFiles",
|
||||
"IfcRelSpaceBoundary2ndLevel",
|
||||
"HouseWithGarage_AC22_IFC2X3.ifc",
|
||||
)
|
||||
ifc_file = ifcopenshell.open(path)
|
||||
space = ifc_file.by_type("IfcSpace")[0]
|
||||
shapes = _build_shapes_dict(ifc_file, ifc_file.by_type("IfcWall") + ifc_file.by_type("IfcSlab"))
|
||||
item = subject.build_brep_space(ifc_file, space, shapes, shapely.box(-1, -1, 1, 1), 0.0)
|
||||
assert item is not None
|
||||
assert item.is_a("IfcFacetedBrep") or item.is_a("IfcPolygonalFaceSet")
|
||||
|
||||
Reference in New Issue
Block a user