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.module.drawing import gizmos as gizmo
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.polyline import PolylineOperator
@@ -2557,6 +2558,333 @@ def _iter_path_connections(
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):
"""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
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# FIXME(PR4): the fillet-corner branch lands with PR4's
# `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0
# the function doesn't exist; falling through to the straight-extrusion
# path preserves v0.8.0 behaviour for fillet walls until PR4 ships.
# Curved fillet-corner walls own a hand-built banana body that
# ``regenerate_wall_representation`` would flatten — it reads the axis
# as a 2-point reference line and builds a straight extrusion. Rebuild
# 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)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -2909,7 +2919,7 @@ class Model(bonsai.core.tool.Model):
if not wall:
continue
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):
continue
if is_layer2_usage:
+7
View File
@@ -487,6 +487,13 @@ class Parametric(bonsai.core.tool.Parametric):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
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
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))