Compare commits

...

1 Commits

Author SHA1 Message Date
Petru Conduraru 4354a8f15f Bonsai: convert a solid IfcWall to a real parametric wall (#8108)
Adds a Convert To Parametric Wall operator (bim.convert_to_parametric_wall),
shown in the geometry representation panel for IfcWall objects. It rebuilds
the active solid-mesh wall as a standard Bonsai layered wall fitted to the
mesh's bounding footprint, the good first step Cyril Waechter scoped in the
issue discussion.

Previously the closest option, Convert To Rectangle Extrusion, only rewrote
the Body to an IfcExtrudedAreaSolid/IfcRectangleProfileDef swept from the
mesh. It never attached the scaffolding a parametric (Dumb) wall needs, so
the result was a frozen extrusion the wall tools do not recognise.

The new operator derives length and thickness from the minimum-area
rectangle of the horizontal footprint (a rotated bounding box, so a wall
placed at any yaw is handled) and height from the vertical extent. It then
builds the parametric structure the wall tool itself uses, an
IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 supplying the
thickness, a Plan/Axis/GRAPH_VIEW reference line supplying the length, a
body from add_wall_representation, an EPset_Parametric Engine marker, and an
object placement whose local X runs along the wall axis. Openings are left
out of scope per the issue.

The converted wall matches a natively generated wall structurally and is
editable with the normal wall tools (verified live in headless Blender 5.2:
length, thickness and height reconstruct the original solid exactly, layer
thickness edits and height changes regenerate the body).

Generated with the assistance of an AI coding tool.
2026-07-17 06:50:11 +03:00
3 changed files with 242 additions and 0 deletions
@@ -446,6 +446,11 @@ class BIM_PT_mesh(Panel):
op = row.operator("bim.update_representation", text="Convert To Arbitrary Extrusion With Voids")
op.ifc_representation_class = "IfcExtrudedAreaSolid/IfcArbitraryProfileDefWithVoids"
element = tool.Ifc.get_entity(obj)
if element and element.is_a("IfcWall"):
row = layout.row()
row.operator("bim.convert_to_parametric_wall", text="Convert To Parametric Wall")
if True:
mprops = tool.Geometry.get_mesh_props(mesh)
row = layout.row()
@@ -90,6 +90,7 @@ classes = (
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
wall.ConvertToParametricWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
+236
View File
@@ -780,6 +780,242 @@ class RecalculateWall(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
def _convex_hull_2d(points: np.ndarray) -> Optional[np.ndarray]:
"""Andrew's monotone chain convex hull of 2D points, returned
counter-clockwise. Returns None when fewer than 3 unique points remain
(a degenerate footprint), leaving the caller to fall back to an
axis-aligned box."""
unique = sorted(set(map(tuple, np.round(points, 9))))
if len(unique) < 3:
return None
def cross(o: tuple[float, float], a: tuple[float, float], b: tuple[float, float]) -> float:
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower: list[tuple[float, float]] = []
for p in unique:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper: list[tuple[float, float]] = []
for p in reversed(unique):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
hull = lower[:-1] + upper[:-1]
if len(hull) < 3:
return None
return np.array(hull, dtype=float)
def _min_area_rectangle(points: np.ndarray) -> Optional[tuple[np.ndarray, float, float, float]]:
"""Minimum-area enclosing rectangle of a 2D point cloud, computed with
rotating calipers over the convex hull edges. Returns
``(center_xy, length_angle, length, thickness)`` where ``length`` is the
longer side, ``thickness`` the shorter, and ``length_angle`` the world
angle (radians) of the long axis. Falls back to the axis-aligned bounding
box for degenerate hulls."""
if len(points) == 0:
return None
hull = _convex_hull_2d(points)
if hull is None:
minx, miny = points.min(axis=0)
maxx, maxy = points.max(axis=0)
width, depth = float(maxx - minx), float(maxy - miny)
center = np.array([(minx + maxx) / 2.0, (miny + maxy) / 2.0])
if width >= depth:
return center, 0.0, width, depth
return center, math.pi / 2.0, depth, width
best: Optional[tuple[float, float, float, float, float, float]] = None
n = len(hull)
for i in range(n):
edge = hull[(i + 1) % n] - hull[i]
angle = math.atan2(edge[1], edge[0])
c, s = math.cos(-angle), math.sin(-angle)
rot = hull @ np.array([[c, -s], [s, c]]).T
minx, maxx = rot[:, 0].min(), rot[:, 0].max()
miny, maxy = rot[:, 1].min(), rot[:, 1].max()
area = (maxx - minx) * (maxy - miny)
if best is None or area < best[0]:
best = (area, angle, minx, maxx, miny, maxy)
assert best is not None
_, angle, minx, maxx, miny, maxy = best
width_along, width_across = maxx - minx, maxy - miny
cx_rot, cy_rot = (minx + maxx) / 2.0, (miny + maxy) / 2.0
c, s = math.cos(angle), math.sin(angle)
center = np.array([c * cx_rot - s * cy_rot, s * cx_rot + c * cy_rot])
if width_along >= width_across:
return center, angle, float(width_along), float(width_across)
return center, angle + math.pi / 2.0, float(width_across), float(width_along)
def _wall_box_from_mesh(obj: bpy.types.Object) -> Optional[tuple[Matrix, float, float, float]]:
"""Derive a parametric wall's placement and dimensions from an object's
current mesh footprint. Returns ``(matrix_world, length, thickness,
height)`` in SI meters, or None if the mesh is degenerate.
``length`` and ``thickness`` come from the minimum-area rectangle of the
footprint projected onto the horizontal plane (Cyril Waechter's "rotated
bounding box" for issue #8108); ``height`` is the vertical extent. The
matrix places the wall so that local +X runs along the length, local +Y
across the thickness (the wall body sits on the +Y side of the axis
reference line, matching :func:`add_wall_representation`), and local +Z is
up, with the origin at the reference corner (axis start)."""
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
if not mesh.vertices:
return None
matrix_world = obj.matrix_world
coords = np.array([(matrix_world @ v.co).to_tuple() for v in mesh.vertices], dtype=float)
zmin, zmax = float(coords[:, 2].min()), float(coords[:, 2].max())
height = zmax - zmin
rect = _min_area_rectangle(coords[:, :2])
if rect is None:
return None
center_xy, length_angle, length, thickness = rect
if length <= 1e-6 or thickness <= 1e-6 or height <= 1e-6:
return None
u = Vector((math.cos(length_angle), math.sin(length_angle), 0.0))
z = Vector((0.0, 0.0, 1.0))
v = z.cross(u)
origin = Vector((center_xy[0], center_xy[1], zmin)) - u * (length / 2.0) - v * (thickness / 2.0)
matrix = Matrix(
(
(u.x, v.x, z.x, origin.x),
(u.y, v.y, z.y, origin.y),
(u.z, v.z, z.z, origin.z),
(0.0, 0.0, 0.0, 1.0),
)
)
return matrix, length, thickness, height
class ConvertToParametricWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.convert_to_parametric_wall"
bl_label = "Convert To Parametric Wall"
bl_description = (
"Rebuild the active IfcWall as a standard Bonsai parametric (layered) wall "
"fitted to the current mesh's bounding footprint.\n"
"Length, thickness and height are derived from the mesh; openings are not recovered.\n"
"The result is editable with the normal wall tools (extend, cardinal point, layer thickness)"
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if not obj or not isinstance(obj.data, bpy.types.Mesh):
cls.poll_message_set("Active object must be a mesh.")
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcWall"):
cls.poll_message_set("Active object must be an IfcWall.")
return False
return True
def _execute(self, context):
ifc_file = tool.Ifc.get()
obj = context.active_object
assert obj and isinstance(obj.data, bpy.types.Mesh)
element = tool.Ifc.get_entity(obj)
assert element
body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if body_context is None:
self.report({"ERROR"}, "Project has no Model/Body/MODEL_VIEW context.")
return {"CANCELLED"}
axis_context = ifcopenshell.util.representation.get_context(ifc_file, "Plan", "Axis", "GRAPH_VIEW")
box = _wall_box_from_mesh(obj)
if box is None:
self.report({"ERROR"}, f"Could not derive a wall box from '{obj.name}' (mesh is empty or degenerate).")
return {"CANCELLED"}
matrix, length, thickness, height = box
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
# A parametric wall's thickness comes from its material layer set. Reuse
# the element's current material for the single layer where possible so
# the conversion keeps its identity, otherwise fall back like the native
# LAYERSET_AXIS2 template does.
layer_material = None
current_material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
if current_material:
if current_material.is_a("IfcMaterialLayerSet") and current_material.MaterialLayers:
layer_material = current_material.MaterialLayers[0].Material
elif current_material.is_a("IfcMaterial"):
layer_material = current_material
if layer_material is None:
existing_materials = ifc_file.by_type("IfcMaterial")
layer_material = (
existing_materials[0]
if existing_materials
else ifcopenshell.api.material.add_material(ifc_file, name="Unknown")
)
layer_set = ifcopenshell.api.material.add_material_set(ifc_file, name="Unnamed", set_type="IfcMaterialLayerSet")
layer = ifcopenshell.api.material.add_layer(ifc_file, layer_set=layer_set, material=layer_material)
layer.LayerThickness = thickness / unit_scale
# Assigning the usage unassigns any prior material association first.
ifcopenshell.api.material.assign_material(
ifc_file, products=[element], type="IfcMaterialLayerSetUsage", material=layer_set
)
# Drop the old frozen body (and any stale axis) so the parametric ones
# take their place in the Body/Axis contexts.
for ctx in (body_context, axis_context):
if ctx is None:
continue
old_rep = tool.Geometry.get_representation_by_context(element, ctx)
if old_rep:
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_rep)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_rep)
obj.matrix_world = matrix
context.view_layer.update()
if axis_context:
axis_rep = ifcopenshell.api.geometry.add_axis_representation(
ifc_file, context=axis_context, axis=[(0.0, 0.0), (length, 0.0)]
)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=axis_rep)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
body_rep = ifcopenshell.api.geometry.add_wall_representation(
ifc_file,
context=body_context,
thickness=thickness,
direction_sense="POSITIVE",
offset=0.0,
length=length,
height=height,
x_angle=0.0,
)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=body_rep)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body_rep)
# Mark it as a Bonsai layered wall so the wall tools recognise and
# regenerate it, and lock the layer-set direction to AXIS2.
pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Parametric")
if pset_data:
pset = ifc_file.by_id(pset_data["id"])
else:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
usage = ifcopenshell.util.element.get_material(element)
if usage and usage.is_a("IfcMaterialLayerSetUsage"):
usage.LayerSetDirection = "AXIS2"
tool.Blender.select_object(obj)
self.report(
{"INFO"},
f"'{obj.name}' converted to a parametric wall "
f"(length {length:.3f} m, thickness {thickness:.3f} m, height {height:.3f} m).",
)
return {"FINISHED"}
class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_extrusion_depth"
bl_label = "Update"