Add wall-fillet helper functions + recreate_wall hook

Eleven module-level helpers in wall.py that the upcoming wall-fillet
operators + gizmo groups depend on. Each is self-contained or
references only helpers earlier in the file; the operators and
gizmos themselves land in follow-up commits.

* _wall_fillet_props / _wall_fillet_preview_active /
  _wall_fillet_preview_walls: thin read-side accessors over the
  BIMPreviewProperties.wall_fillet pointer (added with the
  operators commit). Safe today: get_preview_props returns None
  until the pointer is attached.
* _walls_have_zero_slope_for_fillet: validates that input walls
  are vertical (x_angle ~ 0); slanted-extrusion fillets require
  swept-along-curve geometry the banana profile builder doesn't
  support.
* _build_curved_corner_body_representation: builds the banana
  (annular sector) IfcExtrudedAreaSolid as a polyline-tessellated
  IfcIndexedPolyCurve.
* _apply_fillet_corner_geometry: positions the corner wall at
  tangent_a and rebuilds its body. Shared by the creation operator
  and the regenerate path.
* _resolve_two_walls: pulls (active, other) from a 2-wall
  selection, validates both as LAYER2 + straight-axis + not-already-
  a-fillet-corner.
* _pick_dominant_wall_material: returns the thickest layer's
  material from an element's IfcMaterialLayerSet / Usage.
* regenerate_fillet_corner_wall: re-runs the geometry build from
  BBIM_Wall.FilletRadius + current neighbour layer parameters.
  Called by tool.Model.recreate_wall when the IsFilletCorner pset
  is set; the FIXME(PR4) placeholder in recreate_wall is dropped.
* _wall_fillet_gizmo_x_matrix: 4x4 placement matrix with local +X
  aligned to a world-space direction; used by the fillet preview
  gizmo group.

Centralises the IsFilletCorner pset read as
tool.Parametric.is_fillet_corner_wall — replaces 3 inline
get_pset(element, "BBIM_Wall", "IsFilletCorner") sites
(tool.Model.recreate_wall, tool.Model.recalculate_walls,
tool.Parametric.is_path_connectable_wall) plus the new
_resolve_two_walls call.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-28 16:42:42 +02:00
parent c250b2c1a7
commit 49348908e6
3 changed files with 350 additions and 5 deletions
+328
View File
@@ -54,6 +54,7 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.drawing import gizmos as gizmo from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
@@ -2557,6 +2558,333 @@ def _iter_path_connections(
return out return out
def _wall_fillet_props(context: bpy.types.Context):
return preview_base.get_preview_props(context, "wall_fillet")
def _wall_fillet_preview_active(context: bpy.types.Context) -> bool:
"""``True`` while a wall-fillet preview is open."""
return preview_base.is_preview_active(context, "wall_fillet")
_FILLET_SLOPE_TOLERANCE_RAD = 1e-4
def _walls_have_zero_slope_for_fillet(operator: bpy.types.Operator, *walls: bpy.types.Object) -> bool:
"""``True`` iff every input wall is vertical (``x_angle`` ~ 0). Reports an
ERROR on the operator and returns ``False`` otherwise. Slanted-extrusion
fillets require swept-along-curve geometry that the banana profile builder
isn't designed for — block the entry points so the user sees a clear
explanation instead of malformed corner geometry."""
for wall in walls:
if wall is None:
continue
element = tool.Ifc.get_entity(wall)
if element is None:
continue
x_angle = tool.Wall.get_x_angle(element)
if x_angle is None:
continue
if abs(x_angle) > _FILLET_SLOPE_TOLERANCE_RAD:
operator.report(
{"ERROR"},
"Wall fillet is not supported for slanted walls (non-zero slope). "
"Reset the wall's slope to vertical and try again.",
)
return False
return True
def _build_curved_corner_body_representation(
ifc_file: ifcopenshell.file,
body_context: ifcopenshell.entity_instance,
arc_center_local: tuple[float, float, float],
chord_length_si: float,
radius_si: float,
r_outer_si: float,
r_inner_si: float,
height_si: float,
) -> ifcopenshell.entity_instance:
"""Build an ``IfcShapeRepresentation`` with a banana (annular sector)
``IfcExtrudedAreaSolid``.
Local frame: origin at ``tangent_a``, +X along the chord to ``tangent_b``,
+Z vertical. ``r_outer_si`` / ``r_inner_si`` come from wall A's
``IfcMaterialLayerSetUsage`` so the cross-section matches A at
``tangent_a`` rather than centring on the reference arc."""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
cx_si, cy_si, _ = arc_center_local
dir_a = (-cx_si / radius_si, -cy_si / radius_si)
dir_b = ((chord_length_si - cx_si) / radius_si, -cy_si / radius_si)
# Tessellate the banana profile as an IfcIndexedPolyCurve of straight
# IfcLineIndex segments rather than analytical trimmed-circle arcs:
# IfcOpenShell's geometry kernel and tool.Model.import_profile's edit-mode
# importer both handle polyline segments unconditionally; trimmed-circle
# alternatives fall through both paths to a coarse fallback or a hard error.
# 24 chord segments per arc is visually smooth and round-trip-stable.
arc_resolution = 24
cross_z = dir_a[0] * dir_b[1] - dir_a[1] * dir_b[0]
theta_a = math.atan2(dir_a[1], dir_a[0])
theta_b = math.atan2(dir_b[1], dir_b[0])
# Take the SHORT angular sweep from theta_a to theta_b. CCW (positive
# signed cross product) means walking in increasing-theta direction.
sweep = theta_b - theta_a
if cross_z >= 0:
if sweep < 0:
sweep += 2 * math.pi
else:
if sweep > 0:
sweep -= 2 * math.pi
def _arc_points(radius: float) -> list[tuple[float, float]]:
out = []
for i in range(arc_resolution + 1):
theta = theta_a + sweep * (i / arc_resolution)
out.append((cx_si + radius * math.cos(theta), cy_si + radius * math.sin(theta)))
return out
# Closed loop in counter-clockwise order: outer arc, radial step to inner
# arc, inner arc walked backwards, radial step back to outer start. The
# outer-to-inner and inner-to-outer steps are pure radial lines because
# the arcs share their endpoint angles.
outer_points = _arc_points(r_outer_si)
inner_points_reversed = list(reversed(_arc_points(r_inner_si)))
raw_points = outer_points + inner_points_reversed
points_ifc = [(x / unit_scale, y / unit_scale) for x, y in raw_points]
point_list = ifc_file.createIfcCartesianPointList2D(points_ifc)
# Indices are 1-based per IFC schema. The curve auto-closes by referencing
# the first point as the next-segment start; the explicit closing segment
# survives writers that don't honour implicit close.
n = len(points_ifc)
segments = [ifc_file.createIfcLineIndex((i + 1, ((i + 1) % n) + 1)) for i in range(n)]
curve = ifc_file.createIfcIndexedPolyCurve(point_list, segments, False)
profile = ifc_file.createIfcArbitraryClosedProfileDef("AREA", None, curve)
extrusion = ifc_file.createIfcExtrudedAreaSolid(
profile,
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)),
),
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
height_si / unit_scale,
)
return ifc_file.createIfcShapeRepresentation(
body_context, body_context.ContextIdentifier, "SweptSolid", [extrusion]
)
def _apply_fillet_corner_geometry(
ifc_file: ifcopenshell.file,
corner_obj: bpy.types.Object,
geom: dict,
wall_a_obj: bpy.types.Object,
) -> tuple[Vector, Vector, Vector, float] | None:
"""Position the corner wall at ``tangent_a`` and rebuild its banana body
from ``geom``. Shared by the creation and regenerate paths so a
neighbour-driven recalc matches creation-time output even when wall A's
layer set has been edited since.
Returns ``(x_dir, y_dir, z_dir, chord_length_si)`` on success or ``None``
on degenerate chord / missing Body context. All probes run before any
mutation, so failures leave the corner wall untouched."""
tangent_a = Vector(geom["tangent_a"])
tangent_b = Vector(geom["tangent_b"])
chord = tangent_b - tangent_a
chord_length_si = chord.length
if chord_length_si < 1e-6:
return None
body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if body_context is None:
return None
x_dir = chord.normalized()
z_dir = Vector((0.0, 0.0, 1.0))
y_dir = z_dir.cross(x_dir).normalized()
corner_obj.matrix_world = Matrix(
(
(x_dir.x, y_dir.x, z_dir.x, tangent_a.x),
(x_dir.y, y_dir.y, z_dir.y, tangent_a.y),
(x_dir.z, y_dir.z, z_dir.z, tangent_a.z),
(0.0, 0.0, 0.0, 1.0),
)
)
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=corner_obj, apply_scale=False
)
arc_center_world = Vector(geom["arc_center"])
v_world = arc_center_world - tangent_a
arc_center_local = (v_world.dot(x_dir), v_world.dot(y_dir), v_world.dot(z_dir))
# Banana cross-section side: ``side_sign`` picks whether the body endpoints
# extend toward the arc center (s = -1) or away from it (s = +1), so the
# cross-section at tangent_a matches wall A's body span instead of being
# centred on the reference arc.
radial_a_world = tangent_a - arc_center_world
if radial_a_world.length > 1e-6:
radial_a_world = radial_a_world.normalized()
wall_a_y_world = wall_a_obj.matrix_world.col[1].to_3d().normalized()
side_sign = 1.0 if wall_a_y_world.dot(radial_a_world) >= 0.0 else -1.0
else:
side_sign = -1.0
# ``arc_radius`` is signed (negative = inverted fillet); banana radii use
# the magnitude — the sign only flips which side of A's reference line
# the arc center sits on, not the curve radii themselves.
radius_si = abs(geom["arc_radius"])
offset_si = geom["profile_offset"] or 0.0
thickness_si = geom["profile_thickness"]
r_endpoint_1 = abs(radius_si + side_sign * offset_si)
r_endpoint_2 = abs(radius_si + side_sign * (offset_si + thickness_si))
r_outer_si = max(r_endpoint_1, r_endpoint_2)
r_inner_si = min(r_endpoint_1, r_endpoint_2)
new_body = _build_curved_corner_body_representation(
ifc_file,
body_context,
arc_center_local=arc_center_local,
chord_length_si=chord_length_si,
radius_si=radius_si,
r_outer_si=r_outer_si,
r_inner_si=r_inner_si,
height_si=geom["height"] or 3.0,
)
tool.Model.replace_object_ifc_representation(body_context, corner_obj, new_body)
return x_dir, y_dir, z_dir, chord_length_si
def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bpy.types.Object] | None:
"""``(active, other)`` from a 2-wall selection, both LAYER2 with straight axes."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
active = context.active_object
if active is None or active not in selected:
return None
other = next((o for o in selected if o is not active), None)
if other is None:
return None
for obj in (active, other):
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcWall"):
return None
if not tool.Wall.has_layer2_usage(element):
return None
if not tool.Wall.is_straight_axis(element):
return None
if tool.Parametric.is_fillet_corner_wall(element):
# Re-filleting a curved corner would treat its chord as the
# reference line and produce nonsense geometry.
return None
return active, other
def _pick_dominant_wall_material(
element: ifcopenshell.entity_instance,
) -> Optional[ifcopenshell.entity_instance]:
"""Return a single ``IfcMaterial`` representative of ``element``'s effective
material — the thickest layer's material when the element resolves to a
layer set / usage, the material itself when it is already plain, or
``None`` for unsupported set kinds and elements with no material."""
material = tool.Material.get_material(element, should_inherit=True)
if material is None:
return None
if material.is_a("IfcMaterial"):
return material
layer_set = None
if material.is_a("IfcMaterialLayerSetUsage"):
layer_set = material.ForLayerSet
elif material.is_a("IfcMaterialLayerSet"):
layer_set = material
if layer_set is None:
return None
layers_with_material = [layer for layer in (layer_set.MaterialLayers or ()) if layer.Material is not None]
if not layers_with_material:
return None
thickest = max(layers_with_material, key=lambda layer: layer.LayerThickness or 0.0)
return thickest.Material
def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Rebuild a fillet corner wall's banana body from ``BBIM_Wall.FilletRadius``
and its neighbours' current layer parameters."""
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
radius_si = ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "FilletRadius")
if not radius_si:
return
# Find the two neighbor walls from IfcRelConnectsPathElements. The corner-
# side connection type is NOTDEFINED so neighbours don't miter against the
# chord-axis reference line — take the single rel on each side of the
# corner's inverse graph rather than filtering on type.
wall_a = None
for rel in getattr(element, "ConnectedFrom", []):
if rel.is_a("IfcRelConnectsPathElements"):
wall_a = rel.RelatingElement
break
wall_b = None
for rel in getattr(element, "ConnectedTo", []):
if rel.is_a("IfcRelConnectsPathElements"):
wall_b = rel.RelatedElement
break
if wall_a is None or wall_b is None:
return
wall_a_obj = tool.Ifc.get_object(wall_a)
wall_b_obj = tool.Ifc.get_object(wall_b)
if wall_a_obj is None or wall_b_obj is None:
return
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, float(radius_si))
if geom is None or not geom["valid"]:
return
# Re-anchors the corner's ObjectPlacement at the new tangent_a and rebuilds
# the banana body. If a neighbour moved, the new placement follows; if
# neither moved, the new matrix equals the old within floating-point noise.
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
def _wall_fillet_gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""4×4 matrix placing a gizmo at ``location`` with local +X aligned to
``x_direction`` in world space."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
def _wall_fillet_preview_walls(context: bpy.types.Context):
"""``(wall_a_obj, wall_b_obj)`` pinned by the preview, or ``(None, None)``
when inactive or stale."""
props = _wall_fillet_props(context)
if props is None or not props.is_active:
return None, None
ifc_file = tool.Ifc.get()
if ifc_file is None:
return None, None
try:
elem_a = ifc_file.by_id(props.wall_a_id)
elem_b = ifc_file.by_id(props.wall_b_id)
except (RuntimeError, KeyError):
return None, None
wall_a_obj = tool.Ifc.get_object(elem_a) if elem_a else None
wall_b_obj = tool.Ifc.get_object(elem_b) if elem_b else None
return wall_a_obj, wall_b_obj
class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a wall (active) and one non-wall blender object are co-selected. """Activates when a wall (active) and one non-wall blender object are co-selected.
+15 -5
View File
@@ -2871,10 +2871,20 @@ class Model(bonsai.core.tool.Model):
@classmethod @classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# FIXME(PR4): the fillet-corner branch lands with PR4's # Curved fillet-corner walls own a hand-built banana body that
# `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0 # ``regenerate_wall_representation`` would flatten — it reads the axis
# the function doesn't exist; falling through to the straight-extrusion # as a 2-point reference line and builds a straight extrusion. Rebuild
# path preserves v0.8.0 behaviour for fillet walls until PR4 ships. # the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
# radius + placement from the pset / current ``ObjectPlacement`` while
# picking up new thickness / height from the wall type, which is what
# we want when a type-property edit triggered this call.
if tool.Parametric.is_fillet_corner_wall(element):
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
# addon enable; a module-level import would cycle.
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
regenerate_fillet_corner_wall(element, obj)
return
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
bonsai.core.geometry.switch_representation( bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Ifc,
@@ -2909,7 +2919,7 @@ class Model(bonsai.core.tool.Model):
if not wall: if not wall:
continue continue
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2" is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
if not (is_layer2_usage or is_fillet_corner): if not (is_layer2_usage or is_fillet_corner):
continue continue
if is_layer2_usage: if is_layer2_usage:
+7
View File
@@ -487,6 +487,13 @@ class Parametric(bonsai.core.tool.Parametric):
return False return False
if tool.Model.get_usage_type(element) == "LAYER2": if tool.Model.get_usage_type(element) == "LAYER2":
return True return True
return cls.is_fillet_corner_wall(element)
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
import ifcopenshell.util.element import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))