Files
IfcOpenShell/src/bonsai/bonsai/bim/module/model/wall.py
T
2026-07-02 21:40:25 +00:00

5246 lines
236 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
#
# pyright: reportUnnecessaryTypeIgnoreComment=error
import copy
import math
import weakref
from collections.abc import Iterable
from math import atan2, cos, degrees, pi, sin
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, get_args
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.feature
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.type
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
import ifcopenshell.util.type
import ifcopenshell.util.unit
import mathutils.geometry
import numpy as np
from mathutils import Matrix, Vector
import bonsai.core.connection
import bonsai.core.geometry
import bonsai.core.model as core
import bonsai.core.root
import bonsai.core.spatial
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, IconSlot
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import (
_BBOX_HIGHLIGHT_LINE_ALPHA,
_BBOX_HIGHLIGHT_LINE_WIDTH,
PolylineDecorator,
ProductDecorator,
bbox_world_edges,
draw_polyline_segments,
)
from bonsai.bim.module.model.polyline import PolylineOperator
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWallProperties
_FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic cannot resolve a value.
_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall.
_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales.
_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead."
def _poll_reject_array_children(operator_cls) -> bool:
"""Shared operator-poll guard: set the array-child poll message on
``operator_cls`` and return ``True`` when the selection includes a Bonsai
array child, so the caller can early-return ``False`` from its ``poll``.
Topology mutations against an array child are wiped by the next
``regenerate_array`` and would orphan the child's GUID in the parent's
``BBIM_Array.Data``. Gizmo groups have their own filter via
``_wall_topology_gizmo_poll_gate``; this helper exists so operator
classes share the same rejection in one line."""
if tool.Blender.Modifier.any_selected_is_array_child():
operator_cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return True
return False
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
viewport gizmos are enabled AND no preview is active. Centralises the
two checks every wall gizmo group otherwise duplicates inline; returning
``False`` here short-circuits the caller's poll before any per-feature
selection inspection runs."""
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if preview_base.any_preview_active(context):
return False
return True
def _resolve_active_partner_pair(
context: bpy.types.Context,
) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None":
"""Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a
selection of exactly two IFC-bound objects with the active one named,
else ``None``. Used by every 2-selection gizmo to skip the standard
"resolve active + partner + IFC entities" preamble."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return None
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
partner = next((o for o in selected if o != active), None)
if partner is None:
return None
active_elem = tool.Ifc.get_entity(active)
partner_elem = tool.Ifc.get_entity(partner)
if active_elem is None or partner_elem is None:
return None
return active, partner, active_elem, partner_elem
def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool:
"""Shared gate for slab-side connection gizmos: exactly 1 IfcSlab
selected, not an array child, has at least one wall clipped to its
underside. With ``require_editing=True`` additionally requires the
slab's parametric edit lifecycle to be active (pen icon clicked) so
the gizmo only surfaces after explicit opt-in."""
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcSlab"):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
if require_editing and not tool.Model.get_slab_props(active).is_editing:
return False
return any(tool.Wall.iter_slab_wall_connections(element))
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
Array children are managed replicas — any topology mutation is wiped by
the next ``regenerate_array``, and ``merge`` would orphan a GUID listed
in the parent's ``BBIM_Array.Data``. Host-opening gizmos (add / toggle)
deliberately stay on the base gate so openings remain authorable on
children, which the array regen pipeline preserves."""
if not _wall_gizmo_poll_gate(context):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
return True
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
True iff the active object's IFC element exposes a non-empty HasOpenings
inverse — keeps the toggle hidden on walls that carry no opening cuts."""
obj = bpy.context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.Geometry.has_openings(element)
def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None:
"""Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC.
The preview omits openings, layer materials, and connection joins; those are
resolved on commit by ``recreate_wall`` / ``recalculate_walls``."""
props = tool.Model.get_wall_props(obj)
length = max(props.length, 0.001)
height = max(props.height, 0.001)
thickness = max(props.thickness, 0.001)
offset = props.offset
x_angle = props.x_angle
x0 = props.anchor_x
x1 = x0 + length
y0 = offset
y1 = offset + thickness
# Slope shifts the top face along +Y by height * tan(x_angle), keeping the bottom fixed.
y_top_shift = core.displacement_from_x_angle(height, x_angle) if x_angle else 0.0
bm = bmesh.new()
verts = [
bm.verts.new((x0, y0, 0.0)),
bm.verts.new((x1, y0, 0.0)),
bm.verts.new((x1, y1, 0.0)),
bm.verts.new((x0, y1, 0.0)),
bm.verts.new((x0, y0 + y_top_shift, height)),
bm.verts.new((x1, y0 + y_top_shift, height)),
bm.verts.new((x1, y1 + y_top_shift, height)),
bm.verts.new((x0, y1 + y_top_shift, height)),
]
bm.faces.new([verts[0], verts[1], verts[2], verts[3]])
bm.faces.new([verts[7], verts[6], verts[5], verts[4]])
bm.faces.new([verts[0], verts[4], verts[5], verts[1]])
bm.faces.new([verts[3], verts[2], verts[6], verts[7]])
bm.faces.new([verts[0], verts[3], verts[7], verts[4]])
bm.faces.new([verts[1], verts[5], verts[6], verts[2]])
assert isinstance(obj.data, bpy.types.Mesh)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
# Mark the mesh as having diverged from the IFC-derived geometry. cancel /
# no-op-finish reads this and calls recreate_wall to restore openings & layers.
tool.Model.get_wall_props(obj).mesh_dirty = True
def _restore_wall_mesh_if_dirty(obj: bpy.types.Object) -> None:
"""Re-derive the wall mesh from IFC if the bmesh preview replaced the real geometry.
Idempotent: clears the dirty flag after restoring. Does call into
``ifcopenshell.api.geometry.regenerate_wall_representation`` (one ifc.run), which is
acceptable here because cancel / no-op-finish are explicit user actions, not per-frame
events. Skipping the call when no drag happened preserves the byte-identical guarantee
for the common enable → ✓ no-drag round-trip."""
props = tool.Model.get_wall_props(obj)
if not props.mesh_dirty:
return
element = tool.Ifc.get_entity(obj)
if element:
tool.Model.recreate_wall(element, obj)
props.mesh_dirty = False
def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None:
"""Populate the draft props from current IFC state. Caller must have validated the
wall via ``tool.Wall.validate_for_parametric_edit`` first — this function assumes the
wall has a LAYER2 usage and an extruded MODEL_VIEW body."""
geom = tool.Wall.read_geometry(obj)
assert geom
props.anchor_x = geom["anchor_x"]
props.length = max(0.01, geom["length"])
props.height = max(0.01, geom["height"])
props.x_angle = geom["x_angle"]
props.thickness = max(0.001, geom["thickness"])
props.offset = geom["offset"]
props.desired_offset_baseline = core.baseline_from_offset(props.offset, props.thickness)
props.snap_length = props.length
props.snap_height = props.height
props.snap_thickness = props.thickness
props.snap_offset = props.offset
props.snap_x_angle = props.x_angle
props.snap_offset_baseline = props.desired_offset_baseline
def _maybe_resync_wall_props_from_ifc(obj: "bpy.types.Object | None") -> None:
"""Re-prime ``BIMWallProperties`` from current IFC after an IFC mutation, so
non-edit-mode gizmos read post-mutation coordinates. Must be called from an
operator's ``_execute`` — ID writes from ``GizmoGroup.refresh`` raise
``AttributeError: Writing to ID classes in this context is not allowed``.
No-op during a draft session; the draft is then the source of truth."""
if obj is None:
return
if tool.Wall.validate_for_parametric_edit(obj) is not None:
return
props = tool.Model.get_wall_props(obj)
if props.is_editing:
return
_read_wall_state_into_props(obj, props)
def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> None:
"""Re-prime each wall's draft props after a one-shot IFC mutation. Safe to
call from operator ``_execute``: ID writes are allowed there, unlike gizmo
refresh."""
for obj in objs:
_maybe_resync_wall_props_from_ifc(obj)
def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None:
"""Rebuild every wall in ``objs`` from current IFC state — extrusion,
openings, and any underside slab clip — so the caller doesn't carry
feature-specific dispatch."""
for obj in objs:
if obj is not None:
tool.Model.regenerate_wall(obj)
class _CommitWallDraftsFirstMixin:
"""Operator mixin that flushes any in-progress wall parametric drafts in
the current selection before delegating to the subclass's ``_perform``.
Subclasses implement ``_perform`` instead of ``_execute``; the IFC
transaction opened by ``tool.Ifc.Operator.execute`` wraps both the
commit and the perform.
Place this BEFORE ``bpy.types.Operator`` in the bases tuple so the
mixin's ``_execute`` resolves first in the MRO."""
def _execute(self, context: bpy.types.Context):
_commit_pending_wall_edits_for_selection(context)
return self._perform(context)
def _perform(self, context: bpy.types.Context):
raise NotImplementedError("Subclasses of _CommitWallDraftsFirstMixin must implement _perform.")
class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unjoin_walls"
bl_label = "Unjoin Walls"
bl_description = "Unjoin the selected walls"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model)
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
"""Disconnect two IFC elements given their GlobalIds — generic dispatcher
that infers the connection rel kind via tool.Connection.find_rels and runs
the right post-disconnect cleanup:
- ``"path"`` (IfcRelConnectsPathElements) → removes every rel between
the pair (catches both orientations) via remove_connection + recreates
both walls + resyncs drafts.
- ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") →
disconnect_element + regenerate_wall_to_underside on the wall side.
- ``"element"`` (other IfcRelConnectsElements) → disconnect_element only.
Both endpoints by GlobalId so the dispatch survives rename / undo / save.
Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab
operators with one entry-point gizmos and shortcuts can bind to."""
bl_idname = "bim.disconnect_elements"
bl_label = "Disconnect Elements"
bl_description = "Remove the connection between two IFC elements identified by GlobalId"
bl_options = {"REGISTER", "UNDO"}
element_a_guid: bpy.props.StringProperty(name="Element A GlobalId")
element_b_guid: bpy.props.StringProperty(name="Element B GlobalId")
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
ifc_file = tool.Ifc.get()
try:
elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
except RuntimeError:
elem_a = elem_b = None
if elem_a is None or elem_b is None:
self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
return
rels = tool.Connection.find_rels(elem_a, elem_b)
if not rels:
self.report({"ERROR"}, "No connection found between elements.")
return
# The fillet corner's join with its source walls defines the fillet's
# identity — unjoining there would tear down the chord axis reference
# without rebuilding the source walls' miter cuts. Deleting the corner
# wall is the supported teardown, which cascades back to the source
# walls via the connection-cleanup handler.
either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall(
elem_b
)
if either_is_fillet and any(k == "path" for _, k in rels):
self.report(
{"INFO"},
"Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.",
)
return
path_objs: list[bpy.types.Object] = []
for subject, kind in rels:
bonsai.core.connection.disconnect_rel(
tool.Ifc,
tool.Geometry,
tool.Model,
tool.Connection,
subject=subject,
kind=kind,
elem=elem_a,
partner=elem_b,
)
if kind == "path":
obj_a = tool.Ifc.get_object(elem_a)
obj_b = tool.Ifc.get_object(elem_b)
if obj_a is not None and obj_a not in path_objs:
path_objs.append(obj_a)
if obj_b is not None and obj_b not in path_objs:
path_objs.append(obj_b)
if path_objs:
_resync_walls_after_mutation(path_objs)
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_walls_to_underside"
bl_label = "Extend Walls To Underside"
bl_description = "Extend and clip selected walls at the bottom faces of an object"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _perform(self, context):
slabs: list[bpy.types.Object] = []
walls: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects():
element = tool.Ifc.get_entity(obj)
if not element:
continue
if tool.Parametric.is_path_connectable_wall(element):
walls.append(obj)
else:
slabs.append(obj)
if slabs and walls:
core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slabs, walls)
_resync_walls_after_mutation(walls)
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element and at least one other IFC element")
class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.regenerate_wall_to_underside"
bl_label = "Regenerate Wall to Underside"
bl_description = "Re-clip selected walls to their connected underside objects after the slab has moved"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
wall_objs = [
obj
for obj in tool.Blender.get_selected_objects()
if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element)
]
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element")
class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_walls_to_wall"
bl_label = "Extend Walls To Wall"
bl_description = "Extend and trim selected walls to another wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
target_obj = None
objs = []
if (
(obj := tool.Blender.get_active_object(is_selected=True))
and (element := tool.Ifc.get_entity(obj))
and tool.Model.get_usage_type(element) == "LAYER2"
):
target_obj = obj
for obj in tool.Blender.get_selected_objects(include_active=False):
if (
obj != target_obj
and (element := tool.Ifc.get_entity(obj))
and tool.Model.get_usage_type(element) == "LAYER2"
):
objs.append(obj)
if target_obj and objs:
if tool.Ifc.is_moved(target_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=target_obj)
joiner = DumbWallJoiner()
target_element = tool.Ifc.get_entity(target_obj)
for obj in objs:
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
element = tool.Ifc.get_entity(obj)
ifcopenshell.api.geometry.connect_wall(
tool.Ifc.get(), wall1=element, wall2=target_element, is_atpath=True
)
tool.Model.recreate_wall(element, obj)
tool.Model.recreate_wall(target_element, target_obj)
_resync_walls_after_mutation([target_obj, *objs])
else:
self.report({"ERROR"}, "Please select at least one LAYER2 element and one active LAYER2 element")
class ExtendWallsToPolylinePoint(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
bl_idname = "bim.extend_walls_to_polyline_point"
bl_label = "Extend Walls To Polyline Point"
bl_description = "Extend and trim selected walls to another wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not (space := context.space_data) or space.type != "VIEW_3D":
return False
for obj in context.selected_objects:
if not (element := tool.Ifc.get_entity(obj)) or not element.is_a("IfcWall"):
return False
return bool(context.selected_objects)
def __init__(self):
super().__init__()
self.connection = "ATEND"
def set_origin(self, context, event, connection="ATSTART"):
obj = context.active_object
ref = tool.Wall.get_world_reference_line(obj)
if ref is None:
return
start = Vector((ref[0].x, ref[0].y, obj.location.z))
end = Vector((ref[1].x, ref[1].y, obj.location.z))
direcion = end - start
value = end if connection == "ATSTART" else start
self.input_ui.set_value("X", value[0])
self.input_ui.set_value("Y", value[1])
self.input_ui.set_value("Z", value[2])
result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state)
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
# Point related to the mouse
polyline_props = tool.Model.get_polyline_props()
snap_prop = polyline_props.snap_mouse_point[0]
mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z))
angle = atan2(direcion.y, direcion.x)
self.tool_state.lock_axis = True
self.tool_state.snap_angle = degrees(angle)
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
def _modal(self, context, event):
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event) # Must come before "PASS_THROUGH"
self.handle_mouse_move(context, event)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
custom_instructions = {
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
"Flip starting point": {"icons": True, "keys": ["EVENT_F"]},
"Confirm": {"icons": True, "keys": ["MOUSE_LMB"]},
"Cancel": {"icons": True, "keys": ["MOUSE_RMB", "EVENT_ESC"]},
}
custom_info = []
self.handle_instructions(context, custom_instructions, custom_info, overwrite=True)
self.handle_mouse_move(context, event, should_round=True)
self.choose_axis(event)
self.handle_snap_selection(context, event)
if event.value == "RELEASE" and event.type == "F":
tool.Polyline.clear_polyline()
self.connection = "ATSTART" if self.connection == "ATEND" else "ATEND"
self.set_origin(context, event, self.connection)
if event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE", "LEFTMOUSE"}:
if self.tool_state.is_input_on:
is_valid = self.recalculate_inputs(context)
if is_valid:
result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state)
if result:
self.report({"WARNING"}, result)
else:
result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state)
if result:
self.report({"WARNING"}, result)
polyline_props = tool.Model.get_polyline_props()
snap_prop = polyline_props.snap_mouse_point[0]
snap_obj = bpy.data.objects.get(snap_prop.snap_object)
if snap_obj and tool.Ifc.get_entity(snap_obj).is_a("IfcWall"):
tool.Blender.set_active_object(snap_obj)
ExtendWallsToWall._execute(self, context)
else:
point = polyline_props.insertion_polyline[0].polyline_points[1]
core.extend_walls(
tool.Ifc,
tool.Blender,
tool.Geometry,
DumbWallJoiner(),
tool.Model,
Vector((point.x, point.y, point.z)),
self.connection,
)
tool.Polyline.clear_polyline()
context.workspace.status_text_set(text=None)
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
self.handle_keyboard_input(context, event)
cancel = self.handle_cancelation(context, event)
if cancel is not None:
return cancel
return {"RUNNING_MODAL"}
def invoke(self, context, event):
super().invoke(context, event)
self.set_origin(context, event, self.connection)
self.tool_state.use_default_container = True
self.tool_state.plane_method = "XY"
# Update snaps after changing plane_method
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
return {"RUNNING_MODAL"}
class AlignWall(bpy.types.Operator):
bl_idname = "bim.align_wall"
bl_label = "Align Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = """ Align the selected walls to the active wall:
'Ext.': align to the EXTERIOR face
'C/L': align to wall CENTER
'Int.': align to the INTERIOR face"""
AlignType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
align_type: bpy.props.EnumProperty( # pyright: ignore [reportRedeclaration]
items=((i, i, "") for i in get_args(AlignType))
)
if TYPE_CHECKING:
align_type: AlignType
def execute(self, context):
try:
core.align_walls(tool.Ifc, tool.Blender, tool.Model, DumbWallAligner(), self.align_type)
except core.RequireAtLeastTwoLayeredElements as e:
self.report({"ERROR"}, str(e))
return {"FINISHED"}
class FlipWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.flip_wall"
bl_label = "Flip Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Switch the origin from the min XY corner to the max XY corner, and rotates the origin by 180"
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
joiner = DumbWallJoiner()
for obj in selected_objs:
joiner.flip(obj)
return {"FINISHED"}
class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_wall"
bl_label = "Split Wall"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Split selected wall into two walls in correspondence of Blender cursor. The cursor must be in the wall volume"
)
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
selected_objs = tool.Model.get_selected_mesh_objects()
post_split_walls: list[bpy.types.Object] = []
for obj in selected_objs:
new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location)
post_split_walls.append(obj)
if new_obj is not None and new_obj not in post_split_walls:
post_split_walls.append(new_obj)
_resync_walls_after_mutation(post_split_walls)
_regenerate_walls(post_split_walls)
return {"FINISHED"}
class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.merge_wall"
bl_label = "Merge Wall"
bl_description = "Merge selected walls into one object"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not context.active_object:
cls.poll_message_set("No active object selected.")
return False
elif not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
mesh_objects = [o for o in tool.Model.get_selected_ifc_objects() if o.type == "MESH"]
if len(mesh_objects) != 2:
cls.poll_message_set("Please select exactly two mesh IFC objects.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context):
active_obj = context.active_object
assert active_obj
selected_objs = tool.Model.get_selected_mesh_objects()
# Active-is-survivor — matches Blender's Ctrl+J / "merge at last"
# convention. The first argument survives, the second is consumed,
# so the active wall ends up absorbing the other.
other_obj = next(o for o in selected_objs if o != active_obj)
DumbWallJoiner().merge(active_obj, other_obj)
_maybe_resync_wall_props_from_ifc(active_obj)
_regenerate_walls([active_obj])
return {"FINISHED"}
class RecalculateWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.recalculate_wall"
bl_label = "Recalculate Wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_mesh_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
return True
def _execute(self, context):
objects = tool.Model.get_selected_mesh_ifc_objects()
tool.Model.recalculate_walls(objects)
return {"FINISHED"}
class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_extrusion_depth"
bl_label = "Update"
bl_description = "Update height for the selected objects."
bl_options = {"REGISTER", "UNDO"}
depth: bpy.props.FloatProperty()
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_mesh_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
return True
def _execute(self, context):
layer2_objs: list[bpy.types.Object] = []
ifc_file = tool.Ifc.get()
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
for obj in selected_objs:
element = tool.Ifc.get_entity(obj)
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
x_angle = Vector((0, 1)).angle_signed(Vector((y, z)))
extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle))
if tool.Model.get_usage_type(element) == "LAYER2":
for rel in element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements":
ifcopenshell.api.geometry.disconnect_element(
ifc_file,
relating_element=rel.RelatingElement,
related_element=element,
)
layer2_objs.append(obj)
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_extrusion_x_angle"
bl_label = "Update"
bl_description = "Update angle for the selected objects."
bl_options = {"REGISTER", "UNDO"}
x_angle: bpy.props.FloatProperty(name="X Angle", default=0, subtype="ANGLE")
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_mesh_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
return True
def _execute(self, context):
layer2_objs: list[bpy.types.Object] = []
x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle
x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
for obj in selected_objs:
element = tool.Ifc.get_entity(obj)
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
if tool.Model.get_usage_type(element) == "LAYER2":
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
depth = core.vertical_height_from_extrusion_depth(extrusion.Depth, existing_x_angle)
perpendicular_depth = depth * abs(1 / cos(x_angle))
extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle))
layer2_objs.append(obj)
extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
profiles = (
extrusion.SweptArea.Profiles
if extrusion.SweptArea.is_a("IfcCompositeProfileDef")
else [extrusion.SweptArea]
)
for profile in profiles:
coord_list = builder.get_polyline_coords(profile.OuterCurve)
coord_list = [
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
] # Reset the transformation and returns to the original points with 0 degrees
coord_list = [
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
] # Apply the transformation for the new x_angle
builder.set_polyline_coords(profile.OuterCurve, coord_list)
# The extrusion direction calculated previously default to the positive direction
# Here we set the extrusion direction to negative if that's the case
direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle)))
# direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
layer_params = tool.Model.get_material_layer_parameters(element)
perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale
perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale
offset_direction = direction_ratios.copy()
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or (
abs(x_angle) > (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is positive. If the layer_parameter is set to negative,
# then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
direction_ratios *= -1
elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or (
(x_angle) < (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is negative. If the layer_parameter is set to positive,
# then the we change the extrusion direction.
# then the we change the extrusion direction. And the offset direction should remain positive
# for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
direction_ratios *= -1
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
if extrusion.Position or perpendicular_offset != 0:
position = offset_direction * perpendicular_offset
tool.Model.add_extrusion_position(extrusion, position)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
# Object rotation
current_z_rot = obj.rotation_euler.z
rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X")
obj.rotation_euler = rot_mat.to_euler()
obj.rotation_euler.z = current_z_rot
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
_resync_walls_after_mutation(layer2_objs)
return {"FINISHED"}
class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_layer_length"
bl_label = "Update"
bl_description = "Update length for the selected objects."
bl_options = {"REGISTER", "UNDO"}
length: bpy.props.FloatProperty()
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_mesh_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
return True
def _execute(self, context):
joiner = DumbWallJoiner()
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
for obj in selected_objs:
joiner.set_length(obj, self.length)
_resync_walls_after_mutation(selected_objs)
class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.offset_walls"
bl_label = "Offset Walls"
bl_description = "Offset selected objects from their reference line."
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_mesh_ifc_objects():
cls.poll_message_set("No mesh IFC objects selected.")
return False
return True
def _execute(self, context):
props = tool.Model.get_model_props()
core.offset_walls(tool.Ifc, tool.Blender, tool.Model, props.offset_type_vertical)
class AddWallsFromSlab(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.draw_walls_from_slab"
bl_label = "Draw Slab From Wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.relating_type = None
props = tool.Model.get_model_props()
relating_type_id = props.relating_type_id
if relating_type_id:
self.relating_type = tool.Ifc.get().by_id(int(relating_type_id))
def _execute(self, context):
if not self.relating_type:
return {"FINISHED"}
slab = tool.Ifc.get_entity(context.active_object)
if not slab.is_a("IfcSlab"):
self.report(
{"WARNING"},
"Please select a slab.",
)
return {"FINISHED"}
walls = DumbWallGenerator(self.relating_type).generate("SLAB")
if walls:
for wall1, wall2 in zip(walls, walls[1:] + [walls[0]]):
DumbWallJoiner().connect(wall2["obj"], wall1["obj"])
class DrawPolylineWall(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
bl_idname = "bim.draw_polyline_wall"
bl_label = "Draw Polyline Wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self.relating_type = None
props = tool.Model.get_model_props()
relating_type_id = props.relating_type_id
if relating_type_id:
self.relating_type = tool.Ifc.get().by_id(int(relating_type_id))
def create_walls_from_polyline(self, context: bpy.types.Context) -> Union[set[str], None]:
if not self.relating_type:
return {"FINISHED"}
model_props = tool.Model.get_model_props()
direction_sense = model_props.direction_sense
offset = model_props.offset
walls, is_polyline_closed = DumbWallGenerator(self.relating_type).generate("POLYLINE")
for wall in walls:
model = tool.Ifc.get()
element = tool.Ifc.get_entity(wall["obj"])
material = ifcopenshell.util.element.get_material(element)
material_set_usage = model.by_id(material.id())
# if material.is_a("IfcMaterialLayerSetUsage"):
attributes = {"OffsetFromReferenceLine": offset, "DirectionSense": direction_sense}
ifcopenshell.api.material.edit_layer_usage(model, usage=material_set_usage, attributes=attributes)
tool.Model.recalculate_walls([wall["obj"]])
if walls:
if is_polyline_closed:
for wall1, wall2 in zip(walls, walls[1:] + [walls[0]]):
DumbWallJoiner().connect(wall2["obj"], wall1["obj"])
else:
for wall1, wall2 in zip(walls[:-1], walls[1:]):
DumbWallJoiner().connect(wall2["obj"], wall1["obj"])
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
def _modal(self, context, event):
if not self.relating_type:
self.report({"WARNING"}, "You need to select a wall type.")
PolylineDecorator.uninstall()
tool.Blender.update_viewport()
return {"FINISHED"}
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event) # Must come before "PASS_TRHOUGH"
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
props = tool.Model.get_model_props()
# Wall axis settings
if event.value == "RELEASE" and event.type == "F":
direction_sense = props.direction_sense
props.direction_sense = "NEGATIVE" if direction_sense == "POSITIVE" else "POSITIVE"
self.set_offset(context, self.relating_type)
if event.value == "RELEASE" and event.type == "O":
items = ("EXTERIOR", "CENTER", "INTERIOR")
index = items.index(props.offset_type_vertical)
size = len(items)
props.offset_type_vertical = items[((index + 1) % size)]
self.set_offset(context, self.relating_type)
custom_instructions = {"Choose Axis": {"icons": True, "keys": ["EVENT_X", "EVENT_Y"]}}
wall_config = [
f"Direction: {props.direction_sense}",
f"Offset Type: {props.offset_type_vertical}",
f"Offset Value: {tool.Polyline.format_input_ui_units(props.offset * self.unit_scale)}",
]
self.handle_instructions(context, custom_instructions, wall_config)
self.handle_mouse_move(context, event, should_round=True)
self.choose_axis(event)
self.handle_snap_selection(context, event)
if (
not self.tool_state.is_input_on
and event.value == "RELEASE"
and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}
):
self.create_walls_from_polyline(context)
context.workspace.status_text_set(text=None)
self.tool_state.plane_method = None
ProductDecorator.uninstall()
PolylineDecorator.uninstall()
tool.Polyline.clear_polyline()
tool.Blender.update_viewport()
return {"FINISHED"}
self.handle_keyboard_input(context, event)
self.handle_inserting_polyline(context, event)
cancel = self.handle_cancelation(context, event)
if cancel is not None:
ProductDecorator.uninstall()
return cancel
return {"RUNNING_MODAL"}
def invoke(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
def _invoke(self, context, event):
super().invoke(context, event)
ProductDecorator.install(context)
self.tool_state.use_default_container = True
self.tool_state.plane_method = "XY"
self.set_offset(context, self.relating_type)
return {"RUNNING_MODAL"}
class DumbWallAligner:
# An alignment shifts the origin of all walls to the closest point on the
# local X axis of the reference wall. In addition, the Z rotation is copied.
# Z translations are ignored for alignment.
def set_reference_wall(self, reference_wall: bpy.types.Object):
self.reference_wall = reference_wall
def align_centerline(self, wall: bpy.types.Object) -> None:
self.wall = wall
self.align_rotation()
l_start = Vector(self.reference_wall.bound_box[0]).lerp(Vector(self.reference_wall.bound_box[3]), 0.5)
l_end = Vector(self.reference_wall.bound_box[4]).lerp(Vector(self.reference_wall.bound_box[7]), 0.5)
start = self.reference_wall.matrix_world @ l_start
end = self.reference_wall.matrix_world @ l_end
l_snap_point = Vector(self.wall.bound_box[0]).lerp(Vector(self.wall.bound_box[3]), 0.5)
snap_point = self.wall.matrix_world @ l_snap_point
offset = snap_point - self.wall.matrix_world.translation
point, _ = mathutils.geometry.intersect_point_line(snap_point, start, end)
new_origin = point - offset
self.wall.matrix_world.translation[0], self.wall.matrix_world.translation[1] = new_origin.xy
def align_last_layer(self, wall: bpy.types.Object) -> None:
self.wall = wall
self.align_rotation()
if self.is_rotation_flipped():
element = tool.Ifc.get_entity(self.wall)
if tool.Model.get_usage_type(element) == "LAYER2":
DumbWallJoiner().flip(self.wall)
bpy.context.view_layer.update()
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[3])
else:
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[0])
else:
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[3])
start = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[3])
end = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[7])
point, _ = mathutils.geometry.intersect_point_line(snap_point, start, end)
offset = snap_point - self.wall.matrix_world.translation
new_origin = point - offset
self.wall.matrix_world.translation[0], self.wall.matrix_world.translation[1] = new_origin.xy
def align_first_layer(self, wall: bpy.types.Object) -> None:
self.wall = wall
self.align_rotation()
if self.is_rotation_flipped():
element = tool.Ifc.get_entity(self.wall)
if tool.Model.get_usage_type(element) == "LAYER2":
DumbWallJoiner().flip(self.wall)
bpy.context.view_layer.update()
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[0])
else:
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[3])
else:
snap_point = self.wall.matrix_world @ Vector(self.wall.bound_box[0])
start = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[0])
end = self.reference_wall.matrix_world @ Vector(self.reference_wall.bound_box[4])
point, _ = mathutils.geometry.intersect_point_line(snap_point, start, end)
offset = snap_point - self.wall.matrix_world.translation
new_origin = point - offset
self.wall.matrix_world.translation[0], self.wall.matrix_world.translation[1] = new_origin.xy
def align_rotation(self) -> None:
reference = (self.reference_wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
wall = (self.wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
angle = reference.angle_signed(wall)
if round(degrees(angle) % 360) in (0, 180):
return
elif angle > (pi / 2):
self.wall.rotation_euler[2] -= pi - angle
else:
self.wall.rotation_euler[2] += angle
bpy.context.view_layer.update()
def is_rotation_flipped(self) -> bool:
reference = (self.reference_wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
wall = (self.wall.matrix_world.to_quaternion() @ Vector((1, 0, 0))).to_2d()
angle = reference.angle_signed(wall)
return round(degrees(angle) % 360) == 180
class DumbWallGenerator:
SLAB_ARC_RESOLUTION = 24
SLAB_POINT_MERGE_TOLERANCE = 1e-6
def __init__(self, relating_type):
self.relating_type = relating_type
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
def generate(self, insertion_type="CURSOR"):
self.file = tool.Ifc.get()
self.layers = tool.Model.get_material_layer_parameters(self.relating_type)
if not self.layers["thickness"]:
return
self.body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
self.axis_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
props = tool.Model.get_model_props()
self.container = None
self.container_obj = None
if container := tool.Root.get_default_container():
self.container = container
self.container_obj = tool.Ifc.get_object(container)
self.width = self.layers["thickness"]
self.height = props.extrusion_depth
self.length = props.length
self.rotation = 0.0
self.location = Vector((0, 0, 0))
self.x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
if insertion_type == "POLYLINE":
return self.derive_from_polyline()
elif insertion_type == "SLAB":
return self.derive_from_slab()
elif insertion_type == "CURSOR":
return self.derive_from_cursor()
def derive_from_polyline(self) -> tuple[list[Union[dict[str, Any], None]], bool]:
polyline_props = tool.Model.get_polyline_props()
polyline_data = polyline_props.insertion_polyline
polyline_points = polyline_data[0].polyline_points if polyline_data else []
is_polyline_closed = False
if len(polyline_points) > 3:
first_vec = Vector((polyline_points[0].x, polyline_points[0].y, polyline_points[0].z))
last_vec = Vector((polyline_points[-1].x, polyline_points[-1].y, polyline_points[-1].z))
if first_vec == last_vec:
is_polyline_closed = True
walls = []
for i in range(len(polyline_points) - 1):
vec1 = Vector((polyline_points[i].x, polyline_points[i].y, polyline_points[i].z))
vec2 = Vector((polyline_points[i + 1].x, polyline_points[i + 1].y, polyline_points[i + 1].z))
coords = (vec1, vec2)
walls.append(self.create_wall_from_2_points(coords))
return walls, is_polyline_closed
def _curve_has_arc_segments(self, curve: ifcopenshell.entity_instance) -> bool:
"""Detect whether an IfcIndexedPolyCurve perimeter includes arc segments."""
if not curve.is_a("IfcIndexedPolyCurve"):
return False
segments = getattr(curve, "Segments", None) or ()
for segment in segments:
# Different schema wrappers expose segment typing either as entities
# (IfcArcIndex / IfcLineIndex) or as index tuples.
if hasattr(segment, "is_a"):
if segment.is_a("IfcArcIndex"):
return True
continue
if len(segment[0]) == 3:
return True
return False
def _world_xy_point(self, slab_obj: bpy.types.Object, elevation: float, ifc_coord: Any) -> Vector:
return slab_obj.matrix_world @ Vector(
(ifc_coord[0] * self.unit_scale, ifc_coord[1] * self.unit_scale, elevation)
)
def _derive_points_from_arc_segments(
self, slab_obj: bpy.types.Object, elevation: float, curve: ifcopenshell.entity_instance
) -> list[Vector]:
"""Return world-space perimeter points for an indexed curve with arc segments."""
points: list[Vector] = []
coord_list = curve.Points.CoordList
arc_resolution = self.SLAB_ARC_RESOLUTION
precision = self.SLAB_POINT_MERGE_TOLERANCE
def append_point(point: Vector) -> None:
if points and (points[-1] - point).length < precision:
return
points.append(point)
for segment in curve.Segments:
segment_indices = [i - 1 for i in segment[0]]
if len(segment_indices) == 3:
p1 = self._world_xy_point(slab_obj, elevation, coord_list[segment_indices[0]])
p2 = self._world_xy_point(slab_obj, elevation, coord_list[segment_indices[1]])
p3 = self._world_xy_point(slab_obj, elevation, coord_list[segment_indices[2]])
# create_arc_segments returns (sampled_points, sampled_edges);
# only sampled_points are needed for wall generation.
sampled_arc_points, _ = tool.Cad.create_arc_segments([p1, p2, p3], num_verts=arc_resolution + 1)
sampled_arc_points = [Vector(arc_point) for arc_point in sampled_arc_points]
# Cad.create_arc_segments may return samples from end->start for a
# [start, through, end] input, so normalize to start->end to keep
# perimeter traversal contiguous with neighboring line segments.
if sampled_arc_points and (sampled_arc_points[0] - p1).length > (sampled_arc_points[-1] - p1).length:
sampled_arc_points.reverse()
for arc_point in sampled_arc_points:
append_point(arc_point)
elif len(segment_indices) >= 2:
for segment_index in segment_indices:
append_point(self._world_xy_point(slab_obj, elevation, coord_list[segment_index]))
if points and (points[0] - points[-1]).length >= precision:
points.append(points[0].copy())
return points
def derive_from_slab(self):
slab_obj = bpy.context.active_object
slab = tool.Ifc.get_entity(slab_obj)
container = ifcopenshell.util.element.get_container(slab)
self.container_obj = tool.Ifc.get_object(container)
elevation = self.container_obj.location.z
representation = ifcopenshell.util.representation.get_representation(slab, "Model", "Body", "MODEL_VIEW")
extrusion = tool.Model.get_extrusion(representation)
outer_curve = extrusion.SweptArea.OuterCurve
if self._curve_has_arc_segments(outer_curve):
polyline_points = self._derive_points_from_arc_segments(slab_obj, elevation, outer_curve)
else:
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
polyline_points = builder.get_polyline_coords(outer_curve)
polyline_points = [[(v * self.unit_scale) for v in p] for p in polyline_points]
polyline_points = [slab_obj.matrix_world @ Vector((p[0], p[1], elevation)) for p in polyline_points]
if len(polyline_points) < 3:
return []
if not tool.Cad.is_counter_clockwise_order(polyline_points[0], polyline_points[1], polyline_points[2]):
polyline_points = polyline_points[::-1]
walls = []
for i in range(len(polyline_points) - 1):
vec1 = polyline_points[i]
vec2 = polyline_points[i + 1]
coords = (vec1, vec2)
walls.append(self.create_wall_from_2_points(coords))
return walls
def create_wall_from_2_points(self, coords, should_round=False) -> Union[dict[str, Any], None]:
direction = coords[1] - coords[0]
length = direction.length
data = {"coords": coords}
self.length = length
self.rotation = math.atan2(direction[1], direction[0])
if should_round:
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
nearest_degree = math.radians(angle_snap)
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_wall()
return data
def derive_from_cursor(self) -> bpy.types.Object:
RAYCAST_PRECISION = 0.01
self.location = bpy.context.scene.cursor.location
if self.container:
for subelement in ifcopenshell.util.element.get_decomposition(self.container):
if not subelement.is_a("IfcWall"):
continue
sibling_obj = tool.Ifc.get_object(subelement)
if not sibling_obj or not isinstance(sibling_obj.data, bpy.types.Mesh):
continue
inv_obj_matrix = sibling_obj.matrix_world.inverted()
local_location = inv_obj_matrix @ self.location
try:
raycast = sibling_obj.closest_point_on_mesh(local_location, distance=RAYCAST_PRECISION)
except:
# If the mesh has no faces
raycast = [None]
if not raycast[0]:
continue
for face in sibling_obj.data.polygons:
normal = (sibling_obj.matrix_world.to_quaternion() @ face.normal).normalized()
face_center = sibling_obj.matrix_world @ face.center
if (
normal.z != 0
or abs(mathutils.geometry.distance_point_to_plane(self.location, face_center, normal)) > 0.01
):
continue
rotation = math.atan2(normal[1], normal[0])
rotated_y_axis = Matrix.Rotation(-rotation, 4, "Z")[1].xyz
# since wall thickness goes by local Y+ axis
# we find best position for the next wall
# by finding the face of another wall that will be very close to the some test point.
# test point is calculated by applying to cursor position some little offset along the face
#
# a bit different offset to be safe on raycast
test_pos = self.location + rotated_y_axis * RAYCAST_PRECISION * 1.1
test_pos_local = inv_obj_matrix @ test_pos
raycast = sibling_obj.closest_point_on_mesh(test_pos_local, distance=RAYCAST_PRECISION)
if not raycast[0]:
continue
self.rotation = rotation
break
if self.rotation != 0:
break
return self.create_wall()
def create_wall(self) -> bpy.types.Object:
props = tool.Model.get_model_props()
ifc_class = self.get_relating_type_class(self.relating_type)
mesh = bpy.data.meshes.new("Dummy")
obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(self.relating_type, ifc_class), mesh)
matrix_world = Matrix.Rotation(self.rotation, 4, "Z")
matrix_world.translation = self.location
if self.container_obj:
matrix_world.translation.z = self.container_obj.location.z + props.rl1
obj.matrix_world = matrix_world
bpy.context.view_layer.update()
element = bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=ifc_class,
should_add_representation=False,
)
ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=self.relating_type)
if self.axis_context:
representation = ifcopenshell.api.geometry.add_axis_representation(
tool.Ifc.get(),
context=self.axis_context,
axis=[(0.0, 0.0), (self.length, 0.0)],
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=representation
)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
representation = ifcopenshell.api.geometry.add_wall_representation(
tool.Ifc.get(),
context=self.body_context,
thickness=self.layers["thickness"],
direction_sense=self.layers["direction_sense"],
offset=self.layers["offset"],
length=self.length,
height=self.height,
x_angle=self.x_angle,
)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=element, representation=representation)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
material = ifcopenshell.util.element.get_material(element)
material.LayerSetDirection = "AXIS2"
tool.Blender.select_object(obj)
return obj
def get_relating_type_class(self, relating_type: ifcopenshell.entity_instance) -> str:
classes = ifcopenshell.util.type.get_applicable_entities(relating_type.is_a(), tool.Ifc.get().schema)
return next(c for c in classes if "StandardCase" not in c)
class DumbWallPlaner:
def regenerate_from_layer(self, layer: ifcopenshell.entity_instance) -> None:
for layer_set in layer.ToMaterialLayerSet:
self.regenerate_from_layer_set(layer_set)
def regenerate_from_layer_set(self, layer_set: ifcopenshell.entity_instance) -> None:
walls = []
total_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
if not total_thickness:
return
for inverse in tool.Ifc.get().get_inverse(layer_set):
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS2":
continue
if tool.Ifc.get().schema == "IFC2X3":
for rel in tool.Ifc.get().get_inverse(inverse):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
walls.extend([tool.Ifc.get_object(e) for e in rel.RelatedObjects])
else:
for rel in inverse.AssociatedTo:
walls.extend([tool.Ifc.get_object(e) for e in rel.RelatedObjects])
tool.Model.recalculate_walls([w for w in set(walls) if w])
def _opening_axis_extent(opening, axis_reference, unit_scale):
"""Return ``(min_t, max_t)``: the opening's world-space footprint
projected onto ``axis_reference`` as parametric positions along the
wall axis (``0`` is the start of the axis line, ``1`` is its end).
Used to detect openings whose footprint straddles a cut.
Computed via ``ifcopenshell.geom.create_shape`` so the result is
correct for any representation type Bonsai may produce — mapped
representations, swept-area solids, breps, boolean clips, etc. —
without needing a Blender object (Bonsai hides openings after
``bim.add_opening``). Falls back to a degenerate single-point range
at the placement origin only when the geometry kernel cannot build
a shape from the opening."""
verts = None
shape_matrix: Optional[Matrix] = None
try:
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
shape_matrix = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape).tolist())
except Exception:
verts = None
shape_matrix = None
if verts is None or shape_matrix is None or len(verts) == 0:
placement = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
placement.translation *= unit_scale
_, t = mathutils.geometry.intersect_point_line(placement.translation.to_2d(), *axis_reference)
return t, t
positions = []
for v in verts:
world = (shape_matrix @ Vector((float(v[0]), float(v[1]), float(v[2])))).to_2d()
_, t = mathutils.geometry.intersect_point_line(world, *axis_reference)
positions.append(t)
return min(positions), max(positions)
def _add_void_copy(building_element, source_opening):
"""Add an unfilled IfcOpeningElement to ``building_element`` whose
geometry and placement mirror ``source_opening``. Used when a filled
opening's void straddles a wall split — the filling stays on its wall,
but the void must also apply to the neighbour so its body gets cut."""
void_copy = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=source_opening)
for fill_rel in list(void_copy.HasFillings or ()):
tool.Ifc.get().remove(fill_rel)
void_copy.VoidsElements[0].RelatingBuildingElement = building_element
if void_copy.ObjectPlacement and void_copy.ObjectPlacement.is_a("IfcLocalPlacement"):
if building_element.ObjectPlacement:
void_copy.ObjectPlacement.PlacementRelTo = building_element.ObjectPlacement
if source_opening.Representation:
void_copy.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), source_opening.Representation, exclude=["IfcGeometricRepresentationContext"]
)
class DumbWallJoiner:
def __init__(self):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
self.axis_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
self.body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
def unjoin(self, wall1):
element1 = tool.Ifc.get_entity(wall1)
if not element1:
return
ifcopenshell.api.geometry.disconnect_path(tool.Ifc.get(), element=element1, connection_type="ATSTART")
ifcopenshell.api.geometry.disconnect_path(tool.Ifc.get(), element=element1, connection_type="ATEND")
axis1 = tool.Model.get_wall_axis(wall1)
axis = copy.deepcopy(axis1["reference"])
body = copy.deepcopy(axis1["reference"])
tool.Model.recreate_wall(element1, wall1)
def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None":
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
element1 = tool.Ifc.get_entity(wall1)
if not element1:
return
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
ref = tool.Wall.get_world_reference_line(wall1)
if ref is None:
return
axis_world_2d = (ref[0].to_2d(), ref[1].to_2d())
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis_world_2d)
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
return
wall2 = self.duplicate_wall(wall1)
element2 = tool.Ifc.get_entity(wall2)
# The duplicate inherits wall1's slab-trim boolean chain (copied by
# copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so
# ``get_manual_booleans(element2)`` returns empty and the regenerator
# rebuilds wall2's body without those clips. Strip them up front so
# wall2 starts clean before the axis + placement reshape.
tool.Model.strip_underside_booleans(element2)
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
for conn in connections:
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
relating_connection = conn.RelatedConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
connections = element1.ConnectedFrom
for conn in connections:
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatedConnectionType == "ATEND":
relating_element = conn.RelatingElement
relating_connection = conn.RelatingConnectionType
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
related_element=element2,
relating_connection=relating_connection,
related_connection="ATEND",
description=description,
)
# During the duplication process, unfilled voids are copied, so we need
# to check openings on both element1 and element2. Each wall keeps the
# opening when the opening's axis-projected extent overlaps that wall's
# portion of the axis — straddling openings are intentionally kept on
# both walls so each wall body gets the appropriate cut. Strict
# inequalities mean a boundary-only touch (or a degenerate single-point
# extent at the cut) keeps the opening on both walls — the safer
# default when the helper cannot resolve a true bounding range.
for opening in [
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
]:
min_t, _ = _opening_axis_extent(opening, axis_world_2d, unit_scale)
if min_t > cut_percentage:
# Opening lies entirely past the cut — only element2 should keep it.
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
for opening in [
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
]:
_, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
if max_t < cut_percentage:
# Opening lies entirely before the cut — only element1 should keep it.
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
# During the duplication process, filled voids are not copied. So we
# only need to check fillings on the original element1. The filling
# (door/window) belongs to whichever wall contains its center, but the
# void may need to apply to both walls when the void's extent straddles
# the cut — otherwise the neighbour wall's body would not be cut.
for opening in [
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
]:
rel = opening.HasFillings[0]
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
# Use the opening's axis-projected midpoint to classify the side.
# The filling's ``matrix_world.translation`` is flip-fragile —
# flipping rotates the filler 180° + translates so the bbox
# stays visually in place, moving the door origin to the
# opposite corner, which would mis-classify a flipped door
# centred over the cut.
opening_midpoint = (min_t + max_t) / 2
void_straddles = min_t < cut_percentage < max_t
if opening_midpoint > cut_percentage:
# The filling should be moved from element1 to element2.
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
new_opening.VoidsElements[0].RelatingBuildingElement = element2
if new_opening.ObjectPlacement and new_opening.ObjectPlacement.is_a("IfcLocalPlacement"):
if element2.ObjectPlacement:
new_opening.ObjectPlacement.PlacementRelTo = element2.ObjectPlacement
# For now, we do copy opening representations
if opening.Representation:
new_opening.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), opening.Representation, exclude=["IfcGeometricRepresentationContext"]
)
rel.RelatingOpeningElement = new_opening
if void_straddles:
# Filling moved to element2, but void straddles — add a
# pure-void copy back to element1. Read from the original
# ``opening`` whose ObjectPlacement still references
# element1; ``new_opening`` was rebound to element2 and
# would copy element2's frame instead.
_add_void_copy(element1, opening)
# Remove the old opening
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
elif void_straddles:
# Filling stays on element1, but void straddles — add a pure-void
# copy to element2 so its body gets cut.
_add_void_copy(element2, opening)
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale
self.set_axis(element1, p1, p3)
self.set_axis(element2, p3, p2)
tool.Model.recreate_wall(element1, wall1)
tool.Model.recreate_wall(element2, wall2)
return wall2
def flip(self, wall1: bpy.types.Object) -> None:
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
if (
not (element1 := tool.Ifc.get_entity(wall1))
or not (usage := ifcopenshell.util.element.get_material(element1))
or not usage.is_a("IfcMaterialLayerSetUsage")
or usage.LayerSetDirection != "AXIS2"
):
return
thickness = sum([l.LayerThickness for l in usage.ForLayerSet.MaterialLayers])
if usage.DirectionSense == "POSITIVE":
usage.DirectionSense = "NEGATIVE"
else:
thickness *= -1
usage.DirectionSense = "POSITIVE"
matrix = ifcopenshell.util.placement.get_local_placement(element1.ObjectPlacement)
offset = matrix[:, 1] * thickness
matrix[:, 3] += offset
ifcopenshell.api.geometry.edit_object_placement(
tool.Ifc.get(), product=element1, matrix=matrix, is_si=False, should_transform_children=False
)
tool.Model.recreate_wall(element1, wall1)
def merge(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
if tool.Ifc.is_moved(wall2):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall2)
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(wall2)
assert element1 and element2
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
p3, p4 = ifcopenshell.util.representation.get_reference_line(element2)
matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(element1.ObjectPlacement))
matrix2 = ifcopenshell.util.placement.get_local_placement(element2.ObjectPlacement)
p3 = (matrix1i @ matrix2 @ np.concatenate((p3, (0, 1))))[:2]
p4 = (matrix1i @ matrix2 @ np.concatenate((p4, (0, 1))))[:2]
if not np.isclose(p1[1], p4[1], atol=1e-02) or not np.isclose(p3[1], p4[1], atol=1e-02):
return
x_ordinates = tuple(co[0] for co in (p1, p2, p3, p4))
p1[0] = min(x_ordinates)
p2[0] = max(x_ordinates)
self.set_axis(element1, p1, p2)
# ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements``
# (the wall-wall joins this loop migrates) and
# ``IfcRelConnectsElements`` (the slab underside clip). Only the
# path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
# the element rels die with element2 via the trailing cascade delete.
for rel in element2.ConnectedTo:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType
)
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=element1,
related_element=rel.RelatedElement,
relating_connection=rel.RelatingConnectionType,
related_connection=rel.RelatedConnectionType,
)
for rel in element2.ConnectedFrom:
if not rel.is_a("IfcRelConnectsPathElements"):
continue
ifcopenshell.api.geometry.disconnect_path(
tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType
)
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=rel.RelatingElement,
related_element=element1,
relating_connection=rel.RelatingConnectionType,
related_connection=rel.RelatedConnectionType,
)
# Re-host openings from the discarded wall to the survivor before
# the cascade delete tears down element2's voids and any filling
# that depends on them. ``edit_object_placement`` preserves the
# opening's world position when element1 and element2 have
# different placements — a ``PlacementRelTo`` swap alone would
# shift the opening as the relative offset changes.
ifc_file = tool.Ifc.get()
for rel in list(element2.HasOpenings):
opening = rel.RelatedOpeningElement
rel.RelatingBuildingElement = element1
if opening.ObjectPlacement:
world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=opening,
matrix=world_matrix,
is_si=False,
should_transform_children=False,
)
tool.Model.recreate_wall(element1, wall1)
tool.Geometry.delete_ifc_object(wall2)
def duplicate_wall(self, wall1):
wall2 = wall1.copy()
wall2.data = wall2.data.copy()
for collection in wall1.users_collection:
collection.objects.link(wall2)
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
return wall2
def set_axis(self, wall, p1, p2):
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
item = builder.polyline([p1, p2])
rep = builder.get_representation(axis, items=[item])
if old_rep := ifcopenshell.util.representation.get_representation(wall, axis):
ifcopenshell.util.element.replace_element(old_rep, rep)
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_rep)
else:
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=wall, representation=rep)
def extend(self, wall1, target, connection=False):
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
element1 = tool.Ifc.get_entity(wall1)
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
target = (wall1.matrix_world.inverted() @ target).to_2d() / unit_scale
intersect, intersection_point = mathutils.geometry.intersect_point_line(target, p1, p2)
if not connection:
connection = "ATEND" if intersection_point > 0.5 else "ATSTART"
ifcopenshell.api.geometry.disconnect_path(tool.Ifc.get(), element=element1, connection_type=connection)
if connection == "ATEND":
self.set_axis(element1, p1, intersect)
else:
self.set_axis(element1, intersect, p2)
tool.Model.recreate_wall(element1, wall1)
def set_length(self, wall1: bpy.types.Object, si_length: float) -> None:
element1 = tool.Ifc.get_entity(wall1)
assert element1
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
ifcopenshell.api.geometry.disconnect_path(tool.Ifc.get(), element=element1, connection_type="ATEND")
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
p2[0] = p1[0] + si_length / unit_scale
self.set_axis(element1, p1, p2)
tool.Model.recreate_wall(element1, wall1)
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
wall1 = tool.Ifc.get_entity(obj1)
wall2 = tool.Ifc.get_entity(obj2)
if tool.Ifc.is_moved(obj1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj1)
if tool.Ifc.is_moved(obj2):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj2)
ifcopenshell.api.geometry.connect_wall(tool.Ifc.get(), wall1=wall1, wall2=wall2)
tool.Model.recreate_wall(wall1, obj1)
tool.Model.recreate_wall(wall2, obj2)
def create_matrix(self, p, x, y, z):
return Matrix([x, y, z, p]).to_4x4().transposed()
def get_extrusion_data(self, representation):
results = {"item": None, "height": 3.0, "x_angle": 0, "is_sloped": False, "direction": Vector((0, 0, 1))}
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
results["item"] = item
x, y, z = item.ExtrudedDirection.DirectionRatios
if not tool.Cad.is_x(x, 0) or not tool.Cad.is_x(y, 0) or not tool.Cad.is_x(z, 1):
results["direction"] = Vector(item.ExtrudedDirection.DirectionRatios)
results["x_angle"] = Vector((0, 1)).angle_signed(Vector((y, z)))
results["is_sloped"] = True
results["height"] = core.vertical_height_from_extrusion_depth(
item.Depth * self.unit_scale, results["x_angle"]
)
break
elif item.is_a("IfcBooleanClippingResult"): # should be before IfcBooleanResult check
item = item.FirstOperand
elif item.is_a("IfcBooleanResult"):
if item.FirstOperand.is_a("IfcExtrudedAreaSolid") or item.FirstOperand.is_a("IfcBooleanResult"):
item = item.FirstOperand
else:
item = item.SecondOperand
else:
break
return results
# TODO reimplement in new version and deprecate
def clip(self, wall1: bpy.types.Object, slab2: bpy.types.Object) -> float:
"""returns height of the clipped wall, adds clipping plane to `clippings`"""
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(slab2)
assert element1 and element2
layers1 = tool.Model.get_material_layer_parameters(element1)
axis1 = tool.Model.get_wall_axis(wall1, layers1)
bases = [axis1["base"][0].to_3d(), axis1["base"][1].to_3d(), axis1["side"][0].to_3d(), axis1["side"][1].to_3d()]
bases = [Vector((v[0], v[1], wall1.matrix_world.translation.z)) for v in bases] # add wall Z location
representation = tool.Geometry.get_active_representation(wall1)
assert representation
extrusion = self.get_extrusion_data(representation)
wall_dir = wall1.matrix_world.to_quaternion() @ extrusion["direction"]
slab_element = tool.Ifc.get_entity(slab2)
slab_params = tool.Model.get_material_layer_parameters(slab_element)
slab_representation = ifcopenshell.util.representation.get_representation(
slab_element, "Model", "Body", "MODEL_VIEW"
)
assert slab_representation
slab_extrusion = tool.Model.get_extrusion(slab_representation)
existing_x_angle = tool.Model.get_existing_x_angle(slab_extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
offset = slab_params["offset"]
if slab_params["direction_sense"] == "NEGATIVE":
offset -= slab_params["thickness"]
slab_pt = slab2.matrix_world @ Vector((0, 0, 0)) + Vector((0, 0, offset * abs(1 / cos(existing_x_angle))))
slab_dir = slab2.matrix_world.to_quaternion() @ Vector((0, 0, -1))
tops = [mathutils.geometry.intersect_line_plane(b, b + wall_dir, slab_pt, slab_dir) for b in bases]
top_index = max(range(4), key=lambda i: tops[i].z)
i_top = tops[top_index]
i_bottom = bases[top_index]
quaternion = slab2.matrix_world.to_quaternion()
x_axis = quaternion @ Vector((1, 0, 0))
y_axis = quaternion @ Vector((0, 1, 0))
z_axis = quaternion @ Vector((0, 0, 1))
self.clippings.append(
{
"type": "IfcBooleanClippingResult",
"operand_type": "IfcHalfSpaceSolid",
"matrix": self.create_matrix(i_top, x_axis, y_axis, z_axis),
}
)
return (i_top - i_bottom).length
class EnableEditingWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_wall"
bl_label = "Edit Wall"
bl_description = "Show wall edit gizmos"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
reason = tool.Wall.validate_for_parametric_edit(obj)
if reason:
self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}")
return {"CANCELLED"}
# If openings are currently shown for editing (via the Toggle Openings gizmo
# or the Alt+O hotkey), apply them before entering wall edit mode. Otherwise
# the wall enters edit mode with floating opening previews that don't reflect
# the IFC state the gizmos read from.
if tool.Model.get_model_props().openings:
bpy.ops.bim.edit_openings(apply_all=True)
props = tool.Model.get_wall_props(obj)
# Force is_editing False before populating so update_wall stays a no-op
# while we copy IFC state into the draft properties.
props.is_editing = False
_read_wall_state_into_props(obj, props)
# Mesh stays as the existing IFC-derived geometry until the first gizmo drag
# — that way an enable → ✓ round-trip with no drag is a true no-op.
props.mesh_dirty = False
props.is_editing = True
return {"FINISHED"}
class CancelEditingWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_wall"
bl_label = "Discard Wall Edits"
bl_description = "Discard wall edits"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_wall_props(obj)
# Disable update_wall first so the snap restores don't redraw the preview.
props.is_editing = False
props.length = props.snap_length
props.height = props.snap_height
props.thickness = props.snap_thickness
props.offset = props.snap_offset
# If the user dragged before cancelling, the visible mesh is the simplified
# preview box (openings/layers stripped). Restore the real IFC-derived geometry
# so cancel feels like a true undo — equivalent to the user hitting S_G manually.
_restore_wall_mesh_if_dirty(obj)
return {"FINISHED"}
class FinishEditingWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_wall"
bl_label = "Apply Wall Edits"
bl_description = "Apply wall edits"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element:
return {"CANCELLED"}
props = tool.Model.get_wall_props(obj)
# No edit session in progress — finish is a true no-op. Without this guard,
# an enable that failed validation (e.g. wall without IfcMaterialLayerSetUsage)
# leaves is_editing=False but a press on finish still walks the sub-ops below,
# which dereference layer-set-dependent state and crash.
if not props.is_editing:
return {"CANCELLED"}
length_changed = not tool.Cad.is_x(props.length, props.snap_length, tolerance=1e-5)
height_changed = not tool.Cad.is_x(props.height, props.snap_height, tolerance=1e-5)
x_angle_changed = not tool.Cad.is_x(props.x_angle, props.snap_x_angle, tolerance=1e-5)
baseline_changed = props.desired_offset_baseline != props.snap_offset_baseline
any_change = length_changed or height_changed or x_angle_changed or baseline_changed
# Order matters: baseline shifts the layer-set reference line, then length
# adjusts endpoints relative to that, then x_angle changes the slope (and
# recomputes extrusion direction), and height is applied LAST so it reads the
# final x_angle when converting vertical-height ↔ extrusion-depth. Running
# height before x_angle made the slope op overwrite the just-set height.
# temp_override scopes each sub-op to this wall so the delegated operators
# don't fan out to other selected walls.
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
if baseline_changed:
tool.Model.offset_wall(obj, props.desired_offset_baseline)
tool.Model.recalculate_walls([obj])
tool.Model.get_model_props().offset_type_vertical = props.desired_offset_baseline
if length_changed:
DumbWallJoiner().set_length(obj, props.length)
tool.Model.recalculate_walls([obj])
if x_angle_changed:
bpy.ops.bim.change_extrusion_x_angle(x_angle=props.x_angle)
if height_changed:
bpy.ops.bim.change_extrusion_depth(depth=props.height)
if any_change:
props.mesh_dirty = False
else:
_restore_wall_mesh_if_dirty(obj)
# Set only on success: if any sub-op above raised, the draft survives for retry.
props.is_editing = False
return {"FINISHED"}
class CycleWallOffset(bpy.types.Operator):
bl_idname = "bim.cycle_wall_offset"
bl_label = "Cycle Wall Baseline"
bl_description = "Cycle wall baseline through Exterior, Centreline, Interior. Shift+click reverses"
bl_options = {"REGISTER", "UNDO"}
# Deliberately NOT a tool.Ifc.Operator: this operator never calls into
# ifcopenshell.api. Inheriting from Ifc.Operator would drag a draft-only
# property cycle into Bonsai's IFC undo transaction system.
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
# Same order the offset_type_vertical EnumProperty uses in prop.py.
_ORDER = ("EXTERIOR", "CENTER", "INTERIOR")
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.reverse = event.shift
return self.execute(context)
def execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_wall_props(obj)
if not props.is_editing:
self.report({"WARNING"}, "Cycle wall offset only works in wall edit mode.")
return {"CANCELLED"}
current = props.desired_offset_baseline
idx = self._ORDER.index(current) if current in self._ORDER else 0
direction = -1 if self.reverse else 1
props.desired_offset_baseline = self._ORDER[(idx + direction) % len(self._ORDER)]
return {"FINISHED"}
class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_wall_edition"
bl_label = "Wall Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_wall"
finish_editing_operator = "bim.finish_editing_wall"
cancel_editing_operator = "bim.cancel_editing_wall"
# Empty disables the base class's auto-created cycle_gizmo at ICON_CYCLE_X.
# Three state-specific baseline icons (exterior / center / interior) take
# over that slot, with the active one chosen per frame from props.
cycle_type_operator = ""
# Threshold (SI meters) above which a second height gizmo is drawn at the far end of
# the wall so the user doesn't have to pan across long walls to reach a height handle.
LONG_WALL_THRESHOLD = 5.0
dimension_gizmo_props = [
# length / height / height_end positions are recomputed per frame in
# ``_update_dimension_gizmo_positions`` so they flip to the camera-facing
# side of the wall as the viewport is orbited. No static ``matrix_position``
# here means the base class falls back to Identity, which the override
# then replaces with the view-dependent coordinates.
DimensionGizmoConfig(
attr_name="length",
axis=(1, 0, 0),
min_value=0.01,
text_offset_sign=-1,
),
DimensionGizmoConfig(
attr_name="height",
axis=(0, 0, 1),
min_value=0.01,
),
# Second height gizmo at the far end of long walls. Distinct attr_name so it
# doesn't collide with the first height gizmo in self.dimension_*_gizmo storage;
# compute/apply tunnel through to the same props.height.
DimensionGizmoConfig(
attr_name="height_end",
axis=(0, 0, 1),
min_value=0.01,
# default-arg captures the class const because lambda body can't see class scope.
visibility_condition=lambda p, _t=LONG_WALL_THRESHOLD: p.length > _t,
compute_value=lambda p: p.height,
apply_value=lambda p, v: setattr(p, "height", max(0.01, v)),
color="BLUE",
),
# Slope: a Y-axis dimension at the top edge measuring horizontal displacement
# of the top face. compute/apply translate between displacement (what the user
# sees & drags) and x_angle (what's stored). Drag toward +Y → positive slope.
DimensionGizmoConfig(
attr_name="x_angle",
axis=(0, 1, 0),
prop_name="Slope",
matrix_position=lambda p: Vector((p.anchor_x + p.length / 2, p.offset + p.thickness / 2, p.height)),
compute_value=lambda p: core.displacement_from_x_angle(p.height, p.x_angle),
apply_value=lambda p, displacement: setattr(
p, "x_angle", core.x_angle_from_displacement(p.height, displacement)
),
color="GREEN",
min_value=-1e6, # apply_value clamps via atan2; allow negative displacement
text_formatter=lambda p, displacement: (
f"{'-' if displacement < 0 else ''}{tool.Unit.format_distance(abs(displacement))} "
f"({math.degrees(p.x_angle):.1f}°)"
),
),
]
props_getter = tool.Model.get_wall_props
gizmo_pref_name = "wall"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_wall(element)
def get_icon_y_extent(self, props: "BIMWallProperties") -> tuple[float, float]:
far = props.offset + props.thickness + 2 * self.GIZMO_OFFSET
near = -props.offset + 2 * self.GIZMO_OFFSET
return (far, near)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties" # noqa: ARG002
) -> None:
"""Re-position length / height / height_end dimensions to the camera-facing
Y-side of the wall every frame. Mirrors the door & stair pattern: when the
viewport is orbited past the wall, the handles jump to the visible face
instead of being stranded behind it.
- When viewing from -Y: place handles at wall-local Y = ``offset - GIZMO_OFFSET``.
- When viewing from +Y: place handles at wall-local Y = ``offset + thickness + GIZMO_OFFSET``.
Slope (``x_angle``) is intentionally NOT view-flipped — it lives at the wall
axis centerline because the gizmo IS the Y-displacement indicator. Flipping
it would invert the drag direction relative to the user's pointer motion."""
viewing_from_neg_y, _ = self._frame_view_dir
y_camera_side = self.get_camera_facing_outer_y(
viewing_from_neg_y,
props.offset,
props.offset + props.thickness,
self.GIZMO_OFFSET,
)
# Length: along X axis at half-height, on the camera-facing edge.
self.set_dimension_gizmo_position(
"length",
mw,
Vector((props.anchor_x, y_camera_side, props.height / 2)),
(1, 0, 0),
)
# Height (start of wall): along Z, at the start endpoint, camera-facing side.
self.set_dimension_gizmo_position(
"height",
mw,
Vector((props.anchor_x, y_camera_side, 0)),
(0, 0, 1),
)
# Height (far end of long walls): along Z, at the end endpoint, camera-facing side.
self.set_dimension_gizmo_position(
"height_end",
mw,
Vector((props.anchor_x + props.length, y_camera_side, 0)),
(0, 0, 1),
)
# Per-region weakref map populated at setup time. The wall-gizmo preview
# decorator dereferences this each draw to read live ``is_highlight`` state
# off the cursor icons (extend-X / extend-Z / split) in the same region
# it's currently drawing in, so the GPU axis-preview lines only render
# while the matching icon is hovered.
_active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoWallEdition]]"] = {}
# Row layout: validate / cancel / baseline-triplet / rotate / array.
# Wall has no ``cycle_type_operator``, so the cycle slot collapses and
# the baseline triplet takes the cycle X position (0.87). Rotate
# follows at 1.24. Both slots are declared here — the layout manager
# assigns the X positions from tuple order.
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="baseline",
gizmo_idname="VIEW3D_GT_offset",
variants=("exterior", "center", "interior"),
operator="bim.cycle_wall_offset",
),
IconSlot(
name="rotate",
gizmo_idname="VIEW3D_GT_cycle",
operator="bim.rotate_wall_90",
scale=0.30,
),
)
# Idle-mode pen-row extras. The base class handles setup + per-frame
# positioning + visibility gating via ``visible_when``; this declaration
# is the only wall-specific code needed for the toggle-openings icon.
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="toggle_openings",
gizmo_idname="VIEW3D_GT_add_opening",
operator="bim.toggle_host_openings",
visible_when=lambda gg: _wall_has_openings(gg),
),
)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Wall-specific gizmos.
Cursor-anchored (always visible during edit mode, conditional position):
- ``split_gizmo`` — at the 3D cursor's exact world position when cursor is
within the wall's X range. Clicking splits the wall there.
- ``extend_x_gizmo`` — at the wall-local X of the cursor, projected to the
floor plane (Z=0 in wall-local). Clicking extends/trims the wall's length.
- ``extend_z_gizmo`` — at the wall-local X of the cursor, projected to the
wall top (Z=height in wall-local). Clicking extends the wall's height to
the cursor's Z.
- ``add_perpendicular_wall_gizmo`` — visible only when the cursor is
off-axis by more than ``CURSOR_STACK_OFFSET``. Sits at the cursor's
XY (X clamped to the wall's X-range) on the wall-local floor plane.
Clicking spawns a perpendicular branch wall; shift+click forms a corner.
The baseline-state triplet (exterior/center/interior) and the rotate-90
icon live in ``feature_slots`` — the base class handles creation and
edit-row positioning; this group only picks variant visibility per
frame in ``_update_icon_row_extras``. The idle-row ``toggle_openings``
icon is declared in ``idle_slots`` and fully managed by the base."""
default_color, highlight_color = self.get_decoration_colors()
self.split_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_split",
default_color,
"bim.split_wall_at_cursor",
highlight_color,
)
self.extend_x_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_extend",
default_color,
"bim.extend_wall_to_cursor",
highlight_color,
)
self.extend_z_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_extend_vertical",
default_color,
"bim.extend_wall_height_to_cursor",
highlight_color,
)
self.add_perpendicular_wall_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_extend",
default_color,
"bim.add_perpendicular_wall",
highlight_color,
)
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Position cursor-anchored gizmos and the wall-specific icon-row extras."""
self._update_cursor_gizmos(context, mw, props)
self._update_icon_row_extras(context, mw, props)
# World-Z spacing between stacked cursor icons. ~0.3m is ~1.5× icon diameter
# at default scale, leaving a small visual gap between consecutive icons.
CURSOR_STACK_OFFSET = 0.3
def _stack_offset(self, stack_index: int, screen_up: Vector, clearance: Vector) -> Vector:
"""World-space offset for the ``stack_index``-th icon in a cursor
row: ``clearance`` (top-down only) plus a screen-up step per slot.
Single source of truth for the cursor-row stacking discipline."""
return clearance + screen_up * (stack_index * self.CURSOR_STACK_OFFSET)
def _update_cursor_gizmos(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall
axis at the cursor's projected X.
Always visible when a parametric wall is selected — not gated on edit
mode. The three operators (``bim.extend_wall_to_cursor`` /
``bim.extend_wall_height_to_cursor`` / ``bim.split_wall_at_cursor``)
all poll on wall-selected and commit any pending wall edit before
acting, so single-click without entering edit mode is the canonical
flow. The ``WallGizmoPreviewDecorator`` keeps the viewport clean by
only drawing the action's guide line on hover.
Two branches by view orientation:
- **Non-top-down**: world-Z stacking. Each icon sits at the Z its
action would land at (extend-X at floor, extend-Z at cursor Z,
split at wall top). Colliding icons stack at ``CURSOR_STACK_OFFSET``
increments; priority low → high: extend-X, extend-Z, split.
- **Top-down (plan view)**: world-Z collapses to one screen point,
so the world-Z stack would invisibly pile every icon on top of
``extend_x``. Drop ``extend_z`` (vertical intent has no readable
cue when looking down +Z) and stack the rest along screen-up at
the floor anchor."""
if not hasattr(self, "split_gizmo"):
return
all_gizmos = (
self.extend_x_gizmo,
self.extend_z_gizmo,
self.split_gizmo,
self.add_perpendicular_wall_gizmo,
)
cursor_world = context.scene.cursor.location
cursor_local = mw.inverted() @ cursor_world
# ``props.anchor_x`` / ``props.length`` mirror IFC and are only refreshed
# when an operator calls ``_maybe_resync_wall_props_from_ifc``. Reading
# the live extent from the mesh bbox makes the gizmo position robust
# against any operator path that skips that re-sync — the mesh is
# always rebuilt by ``recreate_wall`` to match the current IFC body.
bbox_x = [v[0] for v in context.active_object.bound_box] if context.active_object else None
if bbox_x:
wall_anchor_x = min(bbox_x)
wall_length = max(bbox_x) - wall_anchor_x
else:
wall_anchor_x = props.anchor_x
wall_length = props.length
in_range = wall_anchor_x < cursor_local.x < wall_anchor_x + wall_length
billboard_rot = self._frame_billboard_rot
top_down = tool.Blender.is_view_top_down(context)
perp_params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, wall_anchor_x, wall_length)
# Candidates ordered by priority (lowest first). Each is (gizmo, local_z).
candidates: list[tuple[bpy.types.Gizmo, float]] = [(self.extend_x_gizmo, 0.0)]
if not top_down:
candidates.append((self.extend_z_gizmo, cursor_local.z))
if in_range:
# Vertical (world-Z) height → wall-local Z so the icon lands on
# the slanted top edge for sloped walls (x_angle != 0).
split_local_z = core.extrusion_depth_from_vertical_height(props.height, props.x_angle)
candidates.append((self.split_gizmo, split_local_z))
# Resolve collisions: walk in priority order and ensure each gizmo's
# final Z is at least CURSOR_STACK_OFFSET above the previous one (when
# the previous one's final Z is higher).
resolved: list[tuple[bpy.types.Gizmo, float]] = []
for gz, desired_z in candidates:
final_z = desired_z
for _, prev_z in resolved:
if abs(final_z - prev_z) < self.CURSOR_STACK_OFFSET:
final_z = prev_z + self.CURSOR_STACK_OFFSET
resolved.append((gz, final_z))
for gz in all_gizmos:
gz.hide = True
screen_up = tool.Blender.get_screen_up_world(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
if top_down:
# Swap world-Z stacking for screen-up stacking so each icon stays
# individually clickable when the camera projects world Z to zero.
# The shared ``top_down_clearance`` lifts the whole stack off the
# cursor so its small crosshair stays visible for precise pointing.
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
for index, (gz, _local_z) in enumerate(resolved):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = base_world + self._stack_offset(index, screen_up, clearance)
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
else:
# World-Z stacking carries each icon's semantic Z (extend-X at
# floor, extend-Z at cursor Z, split at wall top). At shallow
# viewing angles a 0.3 m gap can still project to near-zero
# screen separation, so add a screen-up offset per stack slot
# — the world-Z position still drives the icon's meaning, the
# screen-up term is just visual insurance.
no_clearance = Vector((0.0, 0.0, 0.0))
for index, (gz, local_z) in enumerate(resolved):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) + self._stack_offset(
index, screen_up, no_clearance
)
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
if perp_params is not None:
# Stack the perpendicular gizmo one slot above the on-axis row
# along screen-up so it stays independently clickable when the
# cursor sits just past the dead zone. The arrow's in-plane
# rotation points its +X from the wall projection toward the
# cursor as a "new wall sprouts this way" cue.
clamped_x, _length, side_sign = perp_params
gz = self.add_perpendicular_wall_gizmo
gz.hide = self.is_gizmo_hidden_by_modal(gz)
perp_base = mw @ Vector((clamped_x, cursor_local.y, 0.0))
perp_world = perp_base + self._stack_offset(len(resolved), screen_up, clearance)
perp_world_dir = (mw.to_3x3().col[1] * side_sign).normalized()
screen_dir = billboard_rot.transposed() @ perp_world_dir
angle = math.atan2(screen_dir.y, screen_dir.x)
gz.matrix_basis = gizmo.billboarded_at(perp_world, billboard_rot) @ Matrix.Rotation(angle, 4, "Z")
# Map ``props.desired_offset_baseline`` (storage form) to the slot variant
# name. Centralised here so the variant strings stay aligned with the slot
# declaration in feature_slots.
_BASELINE_TO_VARIANT: ClassVar[dict[str, str]] = {
"EXTERIOR": "exterior",
"CENTER": "center",
"INTERIOR": "interior",
}
def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Pick which baseline variant is visible during edit.
Baseline triplet: the base class's slot loop already wrote a billboard
matrix on each variant member at the same X (the cycle slot, since
wall has no ``cycle_type_operator``). This hook only flips ``hide``
on each member based on ``props.desired_offset_baseline`` so exactly
one variant shows. The rotate-90 icon is a single-icon feature slot
and is fully handled by the base. The toggle-openings idle icon is
declared in ``idle_slots`` and positioned by the base."""
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
for variant in ("exterior", "center", "interior"):
gz = getattr(self, f"baseline_{variant}_gizmo", None)
if gz is None:
continue
if props.is_editing and variant == active_variant:
gz.hide = self.is_gizmo_hidden_by_modal(gz)
else:
gz.hide = True
def _apply_wall_extend_flips(
gz: bpy.types.Gizmo,
group: "GizmoWallEdition",
world_pos: Vector,
mw: Matrix,
cursor_local: Vector,
props: "BIMWallProperties",
billboard_rot: Matrix,
) -> None:
"""Mirror the wall's extend arrows so each points toward the end the click will move.
Extend-X: arrow points away from the wall endpoint that the operator would
keep fixed, accounting for the camera's screen-X orientation. Extend-Z:
arrow flips downward when the cursor sits below the wall top."""
if gz is group.extend_x_gizmo:
if props.length > 0 and cursor_local.x > props.anchor_x + props.length / 2:
reference_x = props.anchor_x
else:
reference_x = props.anchor_x + props.length
reference_world = mw @ Vector((reference_x, 0.0, 0.0))
if gizmo.should_flip_extend_arrow(world_pos, reference_world, billboard_rot):
gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X
elif gz is group.extend_z_gizmo and cursor_local.z < props.height - gizmo.EXTEND_FLIP_EPSILON:
gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_Y
def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Object | None:
"""Return the active object, committing any in-progress wall edit first.
Used by the scissors/extend gizmo operators: clicking either icon implicitly
validates the current edit (✓ semantics) before running the follow-up action.
Returns None when there's no active object — callers should treat that as CANCELLED."""
obj = context.active_object
if not obj:
return None
props = tool.Model.get_wall_props(obj)
if props.is_editing:
bpy.ops.bim.finish_editing_wall()
return obj
def _perpendicular_wall_params(
cursor_local_x: float,
cursor_local_y: float,
anchor_x: float,
length: float,
) -> tuple[float, float, float] | None:
"""Geometry of a perpendicular branch wall sprouting from the cursor's
projection on the source wall axis.
Returns ``(clamped_x, perpendicular_length, side_sign)`` — the projection
on the wall axis (clamped to ``[anchor_x, anchor_x + length]``), the
branch wall length, and the side (+1 / -1) the branch sits on. Returns
``None`` when the cursor sits within ``CURSOR_STACK_OFFSET`` of the
source wall axis (the on-wall dead zone)."""
if abs(cursor_local_y) <= GizmoWallEdition.CURSOR_STACK_OFFSET:
return None
clamped_x = max(anchor_x, min(anchor_x + length, cursor_local_x))
side_sign = 1.0 if cursor_local_y > 0 else -1.0
return clamped_x, abs(cursor_local_y), side_sign
def _commit_pending_wall_edits_for_selection(context: bpy.types.Context) -> None: # noqa: ARG001
"""Thin wall-scoped alias for ``tool.Parametric.commit_pending_edits_for_selection``.
Encapsulates the ``names=("wall",)`` filter so the registry name is
touched in exactly one place."""
tool.Parametric.commit_pending_edits_for_selection(names=("wall",))
class SplitWallAtCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.split_wall_at_cursor"
bl_label = "Split Wall at Cursor"
bl_description = "Split wall at 3D cursor location"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
# Applies any pending wall edit first so the split operates on the committed
# geometry rather than the draft preview box.
obj = _commit_active_wall_edit_if_any(context)
if obj is None:
return {"CANCELLED"}
bpy.ops.bim.split_wall()
_maybe_resync_wall_props_from_ifc(obj)
return {"FINISHED"}
class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_wall_to_cursor"
bl_label = "Extend Wall to Cursor"
bl_description = "Extend wall length to 3D cursor location"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
if _commit_active_wall_edit_if_any(context) is None:
return {"CANCELLED"}
core.extend_walls(
tool.Ifc,
tool.Blender,
tool.Geometry,
DumbWallJoiner(),
tool.Model,
context.scene.cursor.location,
)
affected = list(tool.Blender.get_selected_objects())
_resync_walls_after_mutation(affected)
_regenerate_walls(affected)
return {"FINISHED"}
class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_wall_height_to_cursor"
bl_label = "Extend Wall Height to Cursor Z"
bl_description = "Extend wall height to 3D cursor Z location"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = _commit_active_wall_edit_if_any(context)
if obj is None:
return {"CANCELLED"}
cursor_z = context.scene.cursor.location.z
base_z = obj.matrix_world.translation.z
new_height = cursor_z - base_z
if new_height <= 0:
self.report(
{"WARNING"},
f"Cursor Z ({cursor_z:.2f}m) must be above wall base ({base_z:.2f}m).",
)
return {"CANCELLED"}
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
_regenerate_walls([obj])
return {"FINISHED"}
class AddPerpendicularWall(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_perpendicular_wall"
bl_label = "Add Perpendicular Wall at Cursor"
bl_description = (
"Create a new wall perpendicular to the active wall, from the cursor's "
"orthogonal projection on the wall axis toward the cursor. "
"Shift+Click for corner junction: the source wall is trimmed at the "
"projection, keeping its longer portion."
)
bl_options = {"REGISTER", "UNDO"}
use_corner_junction: bpy.props.BoolProperty(default=False)
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def invoke(self, context, event):
self.use_corner_junction = bool(event.shift)
return self.execute(context)
def _execute(self, context: bpy.types.Context) -> set[str]:
source_obj = _commit_active_wall_edit_if_any(context)
if source_obj is None:
return {"CANCELLED"}
source_element = tool.Ifc.get_entity(source_obj)
if source_element is None:
self.report({"WARNING"}, "Active object is not an IFC element.")
return {"CANCELLED"}
source_type = ifcopenshell.util.element.get_type(source_element)
if source_type is None:
self.report({"WARNING"}, "Active wall has no IfcWallType; cannot derive branch wall.")
return {"CANCELLED"}
props = tool.Model.get_wall_props(source_obj)
cursor_local = source_obj.matrix_world.inverted() @ context.scene.cursor.location
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, props.anchor_x, props.length)
if params is None:
self.report({"INFO"}, "Cursor is on the wall axis; nothing to do.")
return {"CANCELLED"}
clamped_x, perpendicular_length, side_sign = params
start_world = source_obj.matrix_world @ Vector((clamped_x, 0.0, 0.0))
source_z_rotation = source_obj.matrix_world.to_euler().z
new_z_rotation = source_z_rotation + side_sign * (pi / 2)
# Shift+click L-corners the new wall against an endpoint of the
# source wall: the source is trimmed at the projection, keeping
# its longer of the two portions.
if self.use_corner_junction:
DumbWallJoiner().extend(source_obj, start_world)
source_layers = tool.Model.get_material_layer_parameters(source_element)
generator = DumbWallGenerator(source_type)
generator.file = tool.Ifc.get()
generator.layers = tool.Model.get_material_layer_parameters(source_type)
if not generator.layers["thickness"]:
self.report({"WARNING"}, "Wall type has no layer thickness; cannot create branch wall.")
return {"CANCELLED"}
generator.body_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Model", "Body", "MODEL_VIEW"
)
generator.axis_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW"
)
generator.container = None
generator.container_obj = None
generator.width = generator.layers["thickness"]
generator.height = props.height
generator.length = perpendicular_length
generator.rotation = new_z_rotation
generator.location = start_world
generator.x_angle = 0.0
new_obj = generator.create_wall()
new_element = tool.Ifc.get_entity(new_obj)
# Branch wall inherits the source wall's centerline / offset baseline
# so the new axis lines up with the source's authored alignment rather
# than the type's default.
source_baseline = core.baseline_from_offset(source_layers["offset"], source_layers["thickness"])
tool.Model.offset_wall(new_obj, source_baseline)
ifcopenshell.api.geometry.connect_wall(
tool.Ifc.get(),
wall1=new_element,
wall2=source_element,
is_atpath=not self.use_corner_junction,
)
source_container = ifcopenshell.util.element.get_container(source_element)
if source_container is not None:
bonsai.core.spatial.assign_container(
tool.Ifc, tool.Collector, tool.Spatial, container=source_container, objs=[new_obj]
)
tool.Model.recreate_wall(source_element, source_obj)
tool.Model.recreate_wall(new_element, new_obj)
tool.Blender.deselect_object(source_obj, ensure_active_object=False)
tool.Blender.set_active_object(new_obj)
_resync_walls_after_mutation([source_obj, new_obj])
return {"FINISHED"}
class RotateWall90(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.rotate_wall_90"
bl_label = "Rotate Wall 90°"
bl_description = "Rotate wall 90° around Z axis"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = _commit_active_wall_edit_if_any(context)
if obj is None:
return {"CANCELLED"}
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.rotate_90(axis="Z")
return {"FINISHED"}
def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]:
"""Compose the world-space axis segment from an already-read ``geom`` dict.
Used by the billboarding gizmo groups so a single cached IFC read drives both
``tool.Wall.read_geometry`` *and* the segment, avoiding two reads per wall per frame."""
p1_local = Vector((geom["anchor_x"], 0.0, 0.0))
p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0))
return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local
class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin):
"""Adds IFC-read caching to `BillboardingGizmoGroupMixin` for wall-driven
gizmo groups. ``refresh()`` is Blender's "something state-relevant changed"
signal — that's when we drop the cache. ``draw_prepare()`` (every redraw) reuses
whatever ``_get_wall_geom_cached`` populated, so plain camera orbits don't re-hit
IFC. ``_get_wall_geom_cached`` also drops entries on its own when
`tool.Parametric.get_geom_generation` advances (any ``tool.Ifc.Operator``
commit) so external ``bpy.ops`` mutations on the same selection don't leave
stale geometry behind."""
def refresh(self, context: bpy.types.Context) -> None:
self._wall_geom_cache = None
self._wall_connections_cache = None
self._wall_pair_predicate_cache = None
self.position_gizmos(context)
def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None:
"""Per-gizmo-group memoised ``tool.Wall.read_geometry``. Without this, a
billboarding gizmo group re-runs the IFC read on every camera orbit frame —
~120 IFC queries per second per wall, which is unwieldy on dense models.
Two invalidation paths:
- ``GizmoGroup.refresh()`` (Blender's state-change hook — selection,
gizmo modal exit, …) clears ``_wall_geom_cache`` directly.
- ``tool.Parametric.refresh_post_commit()`` bumps a generation counter on
every IFC operator commit; the cache stores the generation it was filled
at and drops on mismatch. This catches ``bpy.ops.bim.*`` mutations that
edit the wall while the same selection is held (the case Blender's
``refresh()`` doesn't fire on)."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_geom_cache_gen", None)
cache = getattr(group, "_wall_geom_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_geom_cache = cache
group._wall_geom_cache_gen = current_gen
key = obj.name
if key not in cache:
cache[key] = tool.Wall.read_geometry(obj)
return cache[key]
def _get_wall_connections_cached(
group: "bpy.types.GizmoGroup",
elem: ifcopenshell.entity_instance,
) -> "list[tuple[ifcopenshell.entity_instance, str, str]]":
"""Per-gizmo-group memoised ``_iter_path_connections``. Same generation-key
invalidation as ``_get_wall_geom_cached`` so an IFC mutation drops the cached
list on the next frame; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_connections_cache_gen", None)
cache = getattr(group, "_wall_connections_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_connections_cache = cache
group._wall_connections_cache_gen = current_gen
key = elem.GlobalId
if key not in cache:
cache[key] = _iter_path_connections(elem)
return cache[key]
def _get_wall_pair_predicate_cached(group: "bpy.types.GizmoGroup", key: tuple, compute):
"""Per-gizmo-group memo for wall-pair predicates (joined / collinear /
intersection). Caller supplies the cache key (typically pair GlobalIds +
relevant inputs like matrix_world tuples + thresholds) and a zero-arg
callable that computes the value on miss. Same generation invalidation as
the geom cache; ``refresh()`` drops it on selection change."""
current_gen = tool.Parametric.get_geom_generation()
cache_gen = getattr(group, "_wall_pair_predicate_cache_gen", None)
cache = getattr(group, "_wall_pair_predicate_cache", None)
if cache is None or cache_gen != current_gen:
cache = {}
group._wall_pair_predicate_cache = cache
group._wall_pair_predicate_cache_gen = current_gen
if key not in cache:
cache[key] = compute()
return cache[key]
def _wall_camera_facing_icon_y(context: bpy.types.Context, mw: Matrix, geom: dict) -> float:
"""Wall-local Y for an icon that should sit just outside the camera-facing face.
Centralised so the billboarding wall gizmos (add-opening, extend-vertically, …)
share one source of truth for "where does the icon go on the visible side"."""
viewing_from_negative_y, _ = gizmo.BaseParametricGizmoGroup.get_local_view_direction(context, mw)
return gizmo.BaseParametricGizmoGroup.get_camera_facing_outer_y(
viewing_from_negative_y,
geom["offset"],
geom["offset"] + geom["thickness"],
gizmo.BaseParametricGizmoGroup.GIZMO_OFFSET,
)
def _are_walls_joined(elem_a: ifcopenshell.entity_instance, elem_b: ifcopenshell.entity_instance) -> bool:
"""True if there's an ``IfcRelConnectsPathElements`` relating these two walls.
Bonsai's wall joiner creates ``IfcRelConnectsPathElements`` (a specialization of
``IfcRelConnectsElements``) whenever walls share a corner or mitre. We walk both
inverse arrays of the first wall and look for the second wall on the other side
of any path-element rel."""
for rel in getattr(elem_a, "ConnectedTo", []):
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_b:
return True
for rel in getattr(elem_a, "ConnectedFrom", []):
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_b:
return True
return False
def _are_walls_collinear(
seg_a: tuple[Vector, Vector],
seg_b: tuple[Vector, Vector],
parallel_threshold: float = 0.9994,
line_tolerance: float = 0.05,
) -> bool:
"""Vector wrapper around `core.are_axes_collinear` — converts Vector
endpoints to plain tuples at the boundary so the math stays unit-testable in
``test/core/`` without a mathutils dependency."""
return core.are_axes_collinear(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
parallel_threshold,
line_tolerance,
)
def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
"""Vector wrapper around `core.closest_endpoint_midpoint`."""
return Vector(
core.closest_endpoint_midpoint(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
)
)
def _classify_wall_join_state(
elem_a: ifcopenshell.entity_instance,
elem_b: ifcopenshell.entity_instance,
seg_a: tuple[Vector, Vector],
seg_b: tuple[Vector, Vector],
parallel_threshold: float,
collinear_tolerance: float,
) -> "tuple[core.WallJoinState, Optional[tuple[float, float, float]]]":
"""``(state, intersection)`` — intersection is non-``None`` only on
the ``"intersect"`` branch."""
return core.classify_wall_join_state(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
are_joined=_are_walls_joined(elem_a, elem_b),
parallel_threshold=parallel_threshold,
collinear_tolerance=collinear_tolerance,
)
def _iter_path_connections(
elem: ifcopenshell.entity_instance,
) -> list[tuple[ifcopenshell.entity_instance, str, str]]:
"""For each ``IfcRelConnectsPathElements`` involving ``elem``, yield
``(other_element, self_connection_type, other_connection_type)``.
Walks both inverse arrays (``ConnectedTo`` + ``ConnectedFrom``) so the orientation
of each rel is normalised to "self first". Non-wall partners are skipped — a wall
MAY share a path connection with non-wall elements, but the unjoin gizmo only
exposes wall-to-wall joins to match the existing two-wall gizmo's scope."""
out: list[tuple[ifcopenshell.entity_instance, str, str]] = []
for rel in getattr(elem, "ConnectedTo", []):
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatedElement
# Malformed / partial IFC files can leave a rel's element ref unset.
# The partner predicate calls `.is_a(...)` on its argument, so a None
# would raise mid-frame and silently break the gizmo group — guard
# before the predicate runs.
if other is None or not tool.Parametric.is_path_connectable_wall(other):
continue
out.append((other, rel.RelatingConnectionType, rel.RelatedConnectionType))
for rel in getattr(elem, "ConnectedFrom", []):
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatingElement
if other is None or not tool.Parametric.is_path_connectable_wall(other):
continue
out.append((other, rel.RelatedConnectionType, rel.RelatingConnectionType))
return out
def _wall_fillet_props(context: bpy.types.Context):
return preview_base.get_preview_props(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
# Project the chord to the XY plane for the local-frame X axis. The
# corner wall's Z axis is hardcoded to world Z below, so an XY-aligned
# X axis is required for an orthonormal rotation matrix. Without the
# projection, any chord Z component (walls placed at different
# elevations) leaves x_dir non-orthogonal to z_dir and Blender's
# Euler decomposition surfaces the skew as spurious sub-degree X/Y
# rotations on the corner.
chord_xy = Vector((chord.x, chord.y, 0.0))
if chord_xy.length < 1e-6:
return None
x_dir = chord_xy.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)
# The body rebuild swaps the wall's representation, so any prior underside
# clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab
# applied to a fillet wall isn't silently wiped on the next neighbour
# recalc, ChangeExtrusionDepth, or split / merge call site.
if tool.Model.has_underside_connection(element):
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj])
class EnableWallFilletPreview(bpy.types.Operator):
"""Enter wall-fillet preview mode for two selected walls. No IFC
mutation until finish."""
bl_idname = "bim.enable_wall_fillet_preview"
bl_label = "Enter Wall Fillet Preview"
bl_description = "Begin tuning the fillet radius before committing the rounded corner"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if _resolve_two_walls(context) is None:
cls.poll_message_set("Select exactly 2 LAYER2 walls with straight axes.")
return False
return True
def execute(self, context):
walls = _resolve_two_walls(context)
if walls is None:
self.report({"ERROR"}, "Selection no longer eligible for fillet preview.")
return {"CANCELLED"}
wall_a, wall_b = walls
elem_a = tool.Ifc.get_entity(wall_a)
elem_b = tool.Ifc.get_entity(wall_b)
if not _walls_have_zero_slope_for_fillet(self, wall_a, wall_b):
return {"CANCELLED"}
# Joined / intersecting only; parallel pairs have no corner to round.
seg_a = tool.Wall.get_world_reference_line(wall_a)
seg_b = tool.Wall.get_world_reference_line(wall_b)
if seg_a is None or seg_b is None:
self.report({"ERROR"}, "Could not read reference line on one of the walls.")
return {"CANCELLED"}
are_joined = _are_walls_joined(elem_a, elem_b)
state, _ = core.classify_wall_join_state(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
are_joined,
core.PARALLEL_DOT_THRESHOLD,
core.COLLINEAR_LINE_TOLERANCE,
)
if state not in {"intersect", "joined"}:
self.report({"ERROR"}, f"Fillet requires intersecting or joined walls (state was {state}).")
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([wall_a, wall_b])
props = _wall_fillet_props(context)
if props is None:
self.report({"ERROR"}, "Wall fillet preview state is unavailable.")
return {"CANCELLED"}
# Auto-cancel any prior preview before opening a fresh one — fillet
# creates a new IFC entity at finish.
if props.is_active:
bpy.ops.bim.cancel_wall_fillet_preview()
# Default radius: a fraction of the shorter available leg, clamped
# against the tangent-overshoot upper bound.
geom = tool.Wall.compute_wall_fillet_geometry(wall_a, wall_b, radius=_FILLET_DEFAULT_RADIUS_M)
default_radius = _FILLET_DEFAULT_RADIUS_M
if geom is not None and geom.get("sweep_angle") and geom["sweep_angle"] > 1e-3:
leg_a_available = geom.get("leg_a_available") or 0.0
leg_b_available = geom.get("leg_b_available") or 0.0
shortest_leg = min(leg_a_available, leg_b_available)
if shortest_leg > 1e-6:
upper = shortest_leg / max(math.tan(geom["sweep_angle"] / 2), 1e-6)
default_radius = max(
_FILLET_MIN_RADIUS_M,
min(_FILLET_DEFAULT_LEG_FRACTION * shortest_leg, upper, _FILLET_DEFAULT_RADIUS_M),
)
props.wall_a_id = elem_a.id()
props.wall_b_id = elem_b.id()
props.radius = default_radius
props.editing_corner_id = 0
props.is_active = True
return {"FINISHED"}
class FinishWallFilletPreview(bpy.types.Operator):
"""Commit the previewed fillet with the tuned radius and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_wall_fillet_preview"
bl_label = "Apply Wall Fillet"
bl_description = "Commit the rounded corner with the previewed radius"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
return preview_base.commit_preview(
self,
context,
"wall_fillet",
"create_wall_fillet",
("wall_a_id", "wall_b_id", "radius", "editing_corner_id"),
)
class CancelWallFilletPreview(bpy.types.Operator):
"""Exit wall-fillet preview without committing."""
bl_idname = "bim.cancel_wall_fillet_preview"
bl_label = "Cancel Wall Fillet"
bl_description = "Discard the previewed fillet"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "wall_fillet")
if props is None or not props.is_active:
return {"CANCELLED"}
# Clear the corner's edit flag so the connection disconnect gizmos
# disappear in lockstep with the radius preview when the user
# cancels. The id read happens BEFORE clear_preview_state wipes it.
corner_id = props.editing_corner_id
if corner_id:
ifc_file = tool.Ifc.get()
if ifc_file is not None:
try:
corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id))
except RuntimeError:
corner_obj = None
if corner_obj is not None:
tool.Model.get_wall_props(corner_obj).is_editing = False
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
"""Re-open the fillet preview on an existing corner wall (pen-icon entry).
Validate deletes and recreates the corner inside a single undo step."""
bl_idname = "bim.enable_wall_fillet_preview_from_corner"
bl_label = "Edit Wall Fillet"
bl_description = "Open the fillet preview for an existing rounded corner — drag radius to retune"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return False
element = tool.Ifc.get_entity(selected[0])
return element is not None and tool.Parametric.is_fillet_corner_wall(element)
def execute(self, context: bpy.types.Context):
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
self.report({"ERROR"}, "Select exactly one fillet corner wall.")
return {"CANCELLED"}
corner_obj = selected[0]
corner_elem = tool.Ifc.get_entity(corner_obj)
if corner_elem is None or not tool.Parametric.is_fillet_corner_wall(corner_elem):
self.report({"ERROR"}, "Selection is not a fillet corner wall.")
return {"CANCELLED"}
radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius")
if not radius:
self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.")
return {"CANCELLED"}
# The corner's own side of the rel is NOTDEFINED (see regenerate_
# fillet_corner_wall) so neighbours don't miter against the chord axis
# — read the single rel on each side of the inverse graph rather than
# filtering on connection type.
wall_a = None
for rel in getattr(corner_elem, "ConnectedFrom", []):
if rel.is_a("IfcRelConnectsPathElements"):
wall_a = rel.RelatingElement
break
wall_b = None
for rel in getattr(corner_elem, "ConnectedTo", []):
if rel.is_a("IfcRelConnectsPathElements"):
wall_b = rel.RelatedElement
break
if wall_a is None or wall_b is None:
self.report({"ERROR"}, "Corner wall is not connected to both source walls anymore.")
return {"CANCELLED"}
wall_a_obj = tool.Ifc.get_object(wall_a)
wall_b_obj = tool.Ifc.get_object(wall_b)
if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj):
return {"CANCELLED"}
props = _wall_fillet_props(context)
if props is None:
self.report({"ERROR"}, "Wall fillet preview state is unavailable.")
return {"CANCELLED"}
if props.is_active:
bpy.ops.bim.cancel_wall_fillet_preview()
props.wall_a_id = wall_a.id()
props.wall_b_id = wall_b.id()
props.radius = float(radius)
props.editing_corner_id = corner_elem.id()
props.is_active = True
# Flag the corner as "in edit mode" so the wall-side connection
# disconnect gizmos surface in parallel with the fillet preview —
# one pen-icon click enters BOTH radius retune AND connection
# inspection.
tool.Model.get_wall_props(corner_obj).is_editing = True
return {"FINISHED"}
class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator):
"""Replace the corner between two straight walls with a curved LAYER2
corner wall (banana body, inherits layer set / height / x_angle / type
from wall A)."""
bl_idname = "bim.create_wall_fillet"
bl_label = "Create Wall Fillet"
bl_description = "Replace the corner between two walls with a rounded corner of the given radius"
bl_options = {"REGISTER", "UNDO"}
wall_a_id: bpy.props.IntProperty(name="Wall A (active) IFC id")
wall_b_id: bpy.props.IntProperty(name="Wall B (other) IFC id")
radius: bpy.props.FloatProperty(
name="Radius",
default=0.5,
subtype="DISTANCE",
unit="LENGTH",
description=(
"Signed radius — positive produces a convex outward fillet, "
"negative flips the arc center to the opposite side for an "
"inverted (concave inward) corner."
),
)
editing_corner_id: bpy.props.IntProperty(
name="Existing fillet corner IFC id",
default=0,
description=(
"Non-zero on the pen-icon re-edit flow. The operator deletes this "
"corner + its path connections before recreating with the new radius."
),
)
if TYPE_CHECKING:
wall_a_id: int
wall_b_id: int
radius: float
editing_corner_id: int
def _execute(self, context):
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
try:
elem_a = ifc_file.by_id(self.wall_a_id)
elem_b = ifc_file.by_id(self.wall_b_id)
except Exception:
self.report({"ERROR"}, "One of the source walls is no longer in the IFC file.")
return {"CANCELLED"}
wall_a_obj = tool.Ifc.get_object(elem_a)
wall_b_obj = tool.Ifc.get_object(elem_b)
if wall_a_obj is None or wall_b_obj is None:
self.report({"ERROR"}, "One of the source walls has no Blender object.")
return {"CANCELLED"}
if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj):
return {"CANCELLED"}
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, self.radius)
if geom is None or not geom["valid"]:
reason = geom.get("reason") if geom else "unknown"
self.report({"ERROR"}, f"Fillet geometry rejected (reason: {reason}).")
return {"CANCELLED"}
if geom["wall_type_id"] is None:
self.report({"ERROR"}, "Active wall has no IfcWallType to inherit.")
return {"CANCELLED"}
tangent_a = Vector(geom["tangent_a"])
tangent_b = Vector(geom["tangent_b"])
side_a = geom["wall_a_join_side"]
side_b = geom["wall_b_join_side"]
chord = tangent_b - tangent_a
chord_length = chord.length
if chord_length < 1e-6:
self.report({"ERROR"}, "Tangent points coincide — invalid fillet geometry.")
return {"CANCELLED"}
# Pen-icon re-edit path: remove the existing fillet corner + its two
# path connections to A and B before recreating. The deletion +
# recreation runs in the same tool.Ifc.Operator transaction, so a
# single undo restores the pre-re-edit state.
if self.editing_corner_id:
try:
old_corner = ifc_file.by_id(self.editing_corner_id)
except Exception:
old_corner = None
if old_corner is not None:
for rel in list(getattr(old_corner, "ConnectedFrom", [])) + list(
getattr(old_corner, "ConnectedTo", [])
):
if rel.is_a("IfcRelConnectsPathElements"):
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
old_corner_obj = tool.Ifc.get_object(old_corner)
ifcopenshell.api.root.remove_product(ifc_file, product=old_corner)
if old_corner_obj is not None:
bpy.data.objects.remove(old_corner_obj)
# Drop any existing direct connection between A and B before
# retopologising — the corner wall will own the new connections at
# both ends.
for conn in list(elem_a.ConnectedTo) + list(elem_a.ConnectedFrom):
if not conn.is_a("IfcRelConnectsPathElements"):
continue
other = conn.RelatedElement if conn.RelatingElement == elem_a else conn.RelatingElement
if other == elem_b:
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
# Shorten A and B so their corner-side endpoints sit on the tangent
# points. DumbWallJoiner.extend projects the world-space target onto
# the wall's local axis and rewrites the relevant endpoint, then
# regenerates the body so it matches the new axis.
joiner = DumbWallJoiner()
joiner.extend(wall_a_obj, tangent_a, connection=side_a)
joiner.extend(wall_b_obj, tangent_b, connection=side_b)
# Instantiate the corner wall from A's wall type so it inherits the
# material layer set, height, x_angle, and IfcWallType.
bpy.ops.bim.add_occurrence(relating_type_id=geom["wall_type_id"])
corner_obj = bpy.context.active_object
if corner_obj is None:
self.report({"ERROR"}, "Failed to instantiate the corner wall.")
return {"CANCELLED"}
corner_elem = tool.Ifc.get_entity(corner_obj)
if corner_elem is None:
self.report({"ERROR"}, "Corner wall has no IFC entity after creation.")
return {"CANCELLED"}
# IfcMaterialLayerSetUsage on a wall contracts that the body is
# derived from the Axis swept along the layer-set thicknesses;
# spec-honouring importers discard an explicit body when they see a
# usage. The corner's defining geometry IS the explicit banana body,
# so neither the usage form nor the owning IfcWallType may stay
# associated. A plain IfcMaterial carries no swept-layer contract —
# the corner inherits a single material from the dominant (thickest)
# layer of wall A's effective material set for QTO / colour /
# reporting purposes without putting the explicit body at risk.
ifcopenshell.api.material.unassign_material(ifc_file, products=[corner_elem])
ifcopenshell.api.type.unassign_type(ifc_file, related_objects=[corner_elem])
dominant_material = _pick_dominant_wall_material(elem_a)
if dominant_material is not None:
ifcopenshell.api.material.assign_material(
ifc_file,
products=[corner_elem],
type="IfcMaterial",
material=dominant_material,
)
placement = _apply_fillet_corner_geometry(ifc_file, corner_obj, geom, wall_a_obj)
if placement is None:
self.report({"ERROR"}, "Could not apply fillet corner geometry (degenerate chord or missing body context).")
return {"CANCELLED"}
_, _, _, chord_length_si = placement
# Axis: 2-point straight chord polyline from (0,0) to (chord_length,0)
# in wall-local IFC units. The body curves while the axis stays
# straight — IFC viewers and downstream Bonsai code that read the
# reference line via get_reference_line get a usable 2-point result
# instead of partial samples off a 3-point arc.
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
joiner.set_axis(
corner_elem,
Vector((0.0, 0.0)),
Vector((chord_length_si / unit_scale, 0.0)),
)
# Mark the corner wall BEFORE the downstream recalculate so
# tool.Model.recreate_wall short-circuits and preserves the curved
# geometry. The pset also gates the enable poll. FilletRadius is
# stored alongside IsFilletCorner so the corner can be rebuilt later
# (neighbour move, layer-thickness edit, pen-icon re-edit).
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall")
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={"IsFilletCorner": True, "FilletRadius": float(self.radius)},
)
# Connect A and B to the corner with the corner's OWN side typed as
# NOTDEFINED rather than ATSTART/ATEND. regenerate_wall_representation
# .join() early-returns when either side is NOTDEFINED, so neighbour
# A's miter cut never reads the corner's chord-axis reference line.
# A and B end FLAT at tangent_a / tangent_b — which is perpendicular
# to their own axis AND to the curve's tangent direction at that
# point, so the neighbour cross-sections align exactly with the
# banana profile's cap.
ifcopenshell.api.geometry.connect_path(
ifc_file,
relating_element=elem_a,
related_element=corner_elem,
relating_connection=side_a,
related_connection="NOTDEFINED",
)
ifcopenshell.api.geometry.connect_path(
ifc_file,
relating_element=corner_elem,
related_element=elem_b,
relating_connection="NOTDEFINED",
related_connection=side_b,
)
# Recalculate A and B so their miter cuts pick up the new connections
# to the corner. The corner itself is skipped by tool.Model.
# recreate_wall's IsFilletCorner gate, preserving the curved body.
tool.Model.recalculate_walls([wall_a_obj, corner_obj, wall_b_obj])
_resync_walls_after_mutation([wall_a_obj, corner_obj, wall_b_obj])
return {"FINISHED"}
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 GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a LAYER3 element (typically a slab) is active and a LAYER2
wall is co-selected. Mirrors the N-panel ``Extend To Underside`` button (which
shows under the same active-LAYER3 + LAYER2-in-selection rule). Clicking
dispatches ``bim.extend_walls_to_underside``, which extends the wall up to the
active element's bottom faces.
Anchored at the wall's local X = 0 (wall origin endpoint), wall-local Y on the
camera-facing side, and the world Z of the active object — so the icon visually
sits at the elevation the wall will reach after extending."""
bl_idname = "OBJECT_GGT_bim_wall_extend_vertically"
bl_label = "Wall Extend Vertically Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
return False
active = context.active_object
if active is None or active not in selected:
return False
active_element = tool.Ifc.get_entity(active)
if not active_element or tool.Model.get_usage_type(active_element) != "LAYER3":
return False
other = next(o for o in selected if o is not active)
other_element = tool.Ifc.get_entity(other)
if not other_element or not tool.Parametric.is_path_connectable_wall(other_element):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.extend_vertical_icon = self.setup_icon_gizmo(
"VIEW3D_GT_extend_vertical",
default_color,
highlight_color,
"bim.extend_walls_to_underside",
)
def position_gizmos(self, context: bpy.types.Context) -> None:
active = context.active_object
if active is None:
return
wall_obj = next((o for o in tool.Blender.get_selected_objects() if o is not active), None)
if wall_obj is None:
return
geom = _get_wall_geom_cached(self, wall_obj)
if not geom:
return
mw = wall_obj.matrix_world
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
# X = 0 in wall-local, Y on the camera-facing outer side, world Z lifted to
# the active object's elevation — the height the wall is about to reach.
world_pos = mw @ Vector((0.0, icon_y, 0.0))
world_pos.z = active.matrix_world.translation.z
billboard_rot = gizmo.get_billboard_rotation(context)
world_pos += gizmo.top_down_clearance(context, billboard_rot)
self.extend_vertical_icon.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly two LAYER2 walls are selected. Dispatches between four
state-specific icons based on the geometric + IFC relationship of the walls:
- **Joined** (``IfcRelConnectsPathElements`` between them):
``unjoin_icon`` (``VIEW3D_GT_split``, outward arrows) at the shared corner.
Clicking dispatches ``bim.unjoin_walls``.
- **Collinear** (axes on the same infinite line, not joined):
``merge_icon`` (``VIEW3D_GT_merge``, inward arrows) at the midpoint of the
closest endpoint pair. Clicking dispatches ``bim.merge_wall``.
- **Joinable corner** (non-parallel, axes meet near endpoints, not joined):
``join_icon`` (``VIEW3D_GT_merge``) at the projected intersection on the
floor, PLUS ``extend_to_wall_icon`` (``VIEW3D_GT_extend``) at the
intersection at the active wall's Z=height. The Z difference disambiguates
"join the corner" vs "extend this wall into the other."
- **None of the above**: all icons hidden.
Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icons
keep facing the camera as the viewport is orbited."""
bl_idname = "OBJECT_GGT_bim_wall_join_intersection"
bl_label = "Wall Join Intersection Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
# Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far).
# cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose.
PARALLEL_DOT_THRESHOLD = 0.9994
# Perpendicular tolerance (m) for treating two parallel wall axes as collinear.
COLLINEAR_LINE_TOLERANCE = 0.05
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
return False
for o in selected:
element = tool.Ifc.get_entity(o)
if not element or not tool.Parametric.is_wall(element):
return False
return True
# Screen-space vertical offset between stacked icons in a state branch —
# camera's screen-up so the fillet icon sits visibly clear of the
# join/unjoin icon at any view angle.
ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4
# Per-region weakref map populated in ``setup()``. The wall-join preview
# decorator dereferences this each draw to read live ``is_highlight``
# state off the join / extend-to-wall / fillet icons in the same region
# it's currently drawing in, so the preview lines can switch to
# ``decorator_color_selected`` while the user hovers a target.
_active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoWallJoinIntersection]]"] = {}
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls")
self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall")
# L-corner glyph reads as "join at the corner"; differentiated from
# the T glyph (extend) by where the bars meet (corner vs midline).
self.join_icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_corner", default_color, highlight_color, "bim.join_walls_intersection"
)
# T-junction glyph reads as "extend this wall into the other's side".
self.extend_to_wall_icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_tee", default_color, highlight_color, "bim.extend_walls_to_wall"
)
# Fillet entry — shows in the same two states (joined / intersect)
# where rounding the corner is well-defined. Click enters the preview
# flow; GizmoWallFilletPreview takes over from there.
self.fillet_icon = self.setup_icon_gizmo(
"VIEW3D_GT_fillet", default_color, highlight_color, "bim.enable_wall_fillet_preview"
)
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]:
return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon, self.fillet_icon)
def _hide_all(self) -> None:
for icon in self._all_icons():
icon.hide = True
def position_gizmos(self, context: bpy.types.Context) -> None:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
self._hide_all()
return
elem_a = tool.Ifc.get_entity(selected[0])
elem_b = tool.Ifc.get_entity(selected[1])
geom_a = _get_wall_geom_cached(self, selected[0])
geom_b = _get_wall_geom_cached(self, selected[1])
if elem_a is None or elem_b is None or geom_a is None or geom_b is None:
self._hide_all()
return
seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a)
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
billboard_rot = gizmo.get_billboard_rotation(context)
screen_up = gizmo.get_screen_up(billboard_rot)
clearance = gizmo.top_down_clearance(context, billboard_rot)
anchor_z = self._stack_anchor_z(context, selected, geom_a, geom_b)
# Pair predicate cache key: pair GlobalIds + world-matrix tuples for
# both walls. World matrices feed _are_walls_collinear /
# project_axis_intersection, so they belong in the key.
pair_guids = tuple(sorted((elem_a.GlobalId, elem_b.GlobalId)))
mw_a_key = tuple(map(tuple, selected[0].matrix_world))
mw_b_key = tuple(map(tuple, selected[1].matrix_world))
mw_key = (mw_a_key, mw_b_key) if elem_a.GlobalId <= elem_b.GlobalId else (mw_b_key, mw_a_key)
# State 1: walls are already joined → Unjoin (bottom) + Fillet (above).
joined = _get_wall_pair_predicate_cached(
self, ("joined", pair_guids), lambda: _are_walls_joined(elem_a, elem_b)
)
if joined:
corner = _collinear_boundary_world(seg_a, seg_b)
anchor = Vector((corner.x, corner.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.unjoin_icon, self.fillet_icon))
self.merge_icon.hide = True
self.join_icon.hide = True
self.extend_to_wall_icon.hide = True
return
# State 2: walls are collinear (parallel axes on the same line) → show Merge
# at the boundary midpoint between them. No stack; single icon at the
# geometric boundary makes the merge target unambiguous.
collinear = _get_wall_pair_predicate_cached(
self,
("collinear", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
lambda: _are_walls_collinear(seg_a, seg_b, self.PARALLEL_DOT_THRESHOLD, self.COLLINEAR_LINE_TOLERANCE),
)
if collinear:
boundary = _collinear_boundary_world(seg_a, seg_b) + clearance
self.merge_icon.matrix_basis = gizmo.billboarded_at(boundary, billboard_rot)
self.merge_icon.hide = False
self.unjoin_icon.hide = True
self.join_icon.hide = True
self.extend_to_wall_icon.hide = True
self.fillet_icon.hide = True
return
# State 3: non-parallel walls → Join (L, bottom) + Extend-to-Wall (T)
# + Fillet (top) stacked along screen-up at the wall-top anchor.
# PARALLEL_DOT_THRESHOLD (cos 2°) is the only bound that matters:
# walls within 2° of parallel produce extrusion joints that race
# toward infinity, so project_axis_intersection returns None and the
# early-return below hides the whole group.
intersection_tuple = _get_wall_pair_predicate_cached(
self,
("intersection", pair_guids, mw_key, self.PARALLEL_DOT_THRESHOLD),
lambda: core.project_axis_intersection(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
self.PARALLEL_DOT_THRESHOLD,
),
)
if intersection_tuple is None:
self._hide_all()
return
intersection = Vector(intersection_tuple)
anchor = Vector((intersection.x, intersection.y, anchor_z)) + clearance
self._stack_at(anchor, screen_up, billboard_rot, (self.extend_to_wall_icon, self.join_icon, self.fillet_icon))
self.unjoin_icon.hide = True
self.merge_icon.hide = True
def _stack_anchor_z(
self,
context: bpy.types.Context,
selected: list[bpy.types.Object],
geom_a: dict,
geom_b: dict,
) -> float:
# Wall-top Z is the bottom of the screen-up stack — high enough that
# the icons sit on top of the wall instead of clipping into it.
# Prefer the active wall's top (the height the user is operating on);
# fall back to the taller of the two if the active object isn't one
# of the selected walls (mid-selection-transition frame).
active = context.active_object if context.active_object in selected else None
if active is selected[0]:
return active.matrix_world.translation.z + geom_a["height"]
if active is selected[1]:
return active.matrix_world.translation.z + geom_b["height"]
return max(
selected[0].matrix_world.translation.z + geom_a["height"],
selected[1].matrix_world.translation.z + geom_b["height"],
)
def _stack_at(
self,
anchor: Vector,
screen_up: Vector,
billboard_rot: Matrix,
icons: tuple[bpy.types.Gizmo, ...],
) -> None:
for k, icon in enumerate(icons):
icon.matrix_basis = gizmo.billboarded_at(anchor + screen_up * (self.ICON_STACK_OFFSET_Y * k), billboard_rot)
icon.hide = False
class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
"""Link-toggle glyph with a partner-wall highlight on hover. The owning
gizmo group writes the partner Blender object onto each icon every frame
via ``partner_obj``; on ``is_highlight`` the partner's bbox is outlined
inline so the user sees which wall the click will disconnect from
before committing.
The partner reference is stashed on the gizmo instance rather than
read back from the bound operator handle because Blender's Gizmo API
exposes ``target_set_operator`` for binding but no symmetric getter."""
bl_idname = "VIEW3D_GT_wall_link_toggle"
__slots__ = ("partner_obj",)
def setup(self) -> None:
super().setup()
self.partner_obj = None
def draw(self, context: bpy.types.Context) -> None:
super().draw(context)
if not self.is_highlight:
return
partner = self.partner_obj
if partner is None:
return
draw_wall_partner_bbox(context, partner)
class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at
every connection location on the wall — wall-wall path connections via
IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements
with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART
+ 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped
slab), so a pool of icons is preallocated and hidden on a per-frame basis based
on the live connection set.
Each visible icon dispatches `bim.disconnect_elements` with the active wall +
partner element GlobalIds set on the bound operator properties, so a click
removes only the single rel under that icon — the other connections on the
same wall survive.
Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group
requires len(selected) == 2; this one requires 1)."""
bl_idname = "OBJECT_GGT_bim_wall_unjoin_single"
bl_label = "Wall Unjoin (single selection) Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
# Pool size. ATSTART + ATEND + ATPATH connections are rarely more than a handful
# on real models; 16 is generous enough that excess is exceptional. Excess drops
# a one-time console warning. The cap exists because Blender only permits
# GizmoGroup to allocate gizmos inside setup() — draw_prepare / refresh-time
# creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16
ICON_SCALE = 0.35
SLAB_STACK_MAX = 5
SLAB_STACK_OFFSET_Z = 0.5
# Muted gray used for connection icons that are visible (the connection
# exists) but inert (clicking dispatches a no-op + INFO report). Fillet
# corner ↔ source-wall joins use this — disconnecting them would tear
# down the fillet's chord axis reference, so the supported teardown is
# deleting the corner wall instead.
LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
# Bypass the shared topology gate's ``any_preview_active`` block —
# ``BIMWallProperties.is_editing`` is the real gate for this gizmo
# group, and that flag is set both by the regular wall edit lifecycle
# AND by the fillet preview entry (so a fillet corner under preview
# surfaces its connections in parallel with the radius drag).
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 1:
return False
element = tool.Ifc.get_entity(active)
if not element or not tool.Parametric.is_path_connectable_wall(element):
return False
props = tool.Model.get_wall_props(active)
if not props.is_editing:
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
# Stashed so per-frame ``_bind_unjoin_icon`` can restore the active
# tone when an icon was muted in a previous frame for fillet lock.
self._default_unjoin_color = default_color
# Bind the operator on each pool icon ONCE at setup time and keep the returned
# OperatorProperties handles. target_set_operator allocates a fresh handle on
# every call, so calling it from position_gizmos (which fires every redraw
# frame via draw_prepare) would discard and re-allocate ~60Hz per visible
# icon. Stashing the handles lets per-frame work be a plain property write.
self.unjoin_icons = []
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
# Default: hide every pool slot. The visible-set is rebuilt from the live
# connection list each frame so disconnects/reconnects elsewhere in the
# session don't leave ghost icons behind.
for icon in self.unjoin_icons:
icon.hide = True
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return
wall_obj = selected[0]
elem = tool.Ifc.get_entity(wall_obj)
geom = _get_wall_geom_cached(self, wall_obj)
if elem is None or geom is None:
return
seg_self = _wall_axis_world_segment_from_geom(wall_obj, geom)
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
path_connections = _get_wall_connections_cached(self, elem)
slab_connections = list(tool.Wall.iter_wall_slab_connections(elem))
slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX)
if slab_overflow and not getattr(self, "_slab_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab "
f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked."
)
self._slab_cap_warned = True
slab_connections = slab_connections[: self.SLAB_STACK_MAX]
total = len(path_connections) + len(slab_connections)
if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections "
f"({len(path_connections)} path + {len(slab_connections)} slab); "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
slot_idx = 0
self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem)
for other_elem, self_ct, other_ct in path_connections:
if slot_idx >= self.POOL_SIZE:
break
other_obj = tool.Ifc.get_object(other_elem)
if other_obj is None:
continue
other_geom = _get_wall_geom_cached(self, other_obj)
if other_geom is None:
continue
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom)
location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct)
is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem)
self._bind_unjoin_icon(
slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked
)
slot_idx += 1
for stack_idx, (slab_elem, _rel) in enumerate(slab_connections):
if slot_idx >= self.POOL_SIZE:
break
slab_obj = tool.Ifc.get_object(slab_elem)
if slab_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
# Stack vertically so each slab gets a distinct clickable icon;
# hover-highlight then shows the user which slab they're about to
# disconnect from.
stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z))
self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj)
slot_idx += 1
def _bind_unjoin_icon(
self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj, *, is_locked=False
):
"""Place + bind one pool icon to a (active, partner) GlobalId pair.
Only the GlobalId properties are rewritten per frame; the operator
binding itself is the long-lived handle set up at setup() time. GlobalId
(not Blender object name) keeps the binding stable across renames, file
save/reload, and any sit-in-the-undo-stack interlude between dispatch
and execute. The partner Blender object is mirrored onto the icon for
its hover-outline draw, since the Gizmo API exposes
``target_set_operator`` but no symmetric reader.
``is_locked=True`` (fillet corner involvement) writes a muted color
instead of the active tone; the GUIDs still propagate so the bound
operator can surface a friendly INFO report on click."""
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color
self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId
icon.partner_obj = partner_obj
class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is
selected and at least one wall is clipped to its underside, surface an
unjoin icon at each connection point. The icons resolve at the same
world location as the wall-side gizmo (via the symmetric
tool.Wall.wall_slab_connection_location_world) so the same connection
has a single visual marker reachable from either selection.
Each visible icon dispatches bim.disconnect_elements with the slab +
wall GlobalIds, so a click removes the single rel under that icon and
re-clips the wall to whatever remaining slabs it's connected to."""
bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls"
bl_label = "Slab Unjoin Walls Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
POOL_SIZE = 16
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return _slab_connection_gizmo_poll_gate(context, require_editing=True)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.unjoin_icons = []
self.unjoin_op_props = []
for _ in range(self.POOL_SIZE):
icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
icon.hide = True
self.unjoin_icons.append(icon)
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
def position_gizmos(self, context: bpy.types.Context) -> None:
for icon in self.unjoin_icons:
icon.hide = True
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return
slab_obj = selected[0]
slab_elem = tool.Ifc.get_entity(slab_obj)
if slab_elem is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = list(tool.Wall.iter_slab_wall_connections(slab_elem))
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False):
print(
f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; "
f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
)
self._pool_cap_warned = True
slot_idx = 0
for wall_elem, _rel in connections:
if slot_idx >= self.POOL_SIZE:
break
wall_obj = tool.Ifc.get_object(wall_elem)
if wall_obj is None:
continue
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
continue
icon = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False
self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId
self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId
icon.partner_obj = wall_obj
slot_idx += 1
class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"""Pen / validate / cancel triad for slab disconnect-access mode.
Polls on a single IfcSlab with at least one wall clipped to its underside.
Pen routes through the universal ``bim.enable_editing_parametric``
dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation —
the framework requires the triad to exist by name convention even for a
pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with
other active parametric edits, gizmo prefs gating — all handled by the
base class."""
bl_idname = "OBJECT_GGT_bim_slab_edition"
bl_label = "Slab Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_slab"
finish_editing_operator = "bim.finish_editing_slab"
cancel_editing_operator = "bim.cancel_editing_slab"
cycle_type_operator = ""
props_getter = tool.Model.get_slab_props
gizmo_pref_name = "slab"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_slab(element) and any(tool.Wall.iter_slab_wall_connections(element))
class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Surfaces a disconnect icon when exactly 2 IFC elements are selected
and they share a supported rel — currently the wall + slab pair joined
by an ``IfcRelConnectsElements(TOP)``. Click dispatches
``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs,
``GizmoWallJoinIntersection``'s unjoin icon already exposes the same
affordance via ``bim.unjoin_walls``."""
bl_idname = "OBJECT_GGT_bim_pair_disconnect"
bl_label = "Disconnect Pair Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE = 0.35
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
elem_a = tool.Ifc.get_entity(selected[0])
elem_b = tool.Ifc.get_entity(selected[1])
if elem_a is None or elem_b is None:
return False
rels = tool.Connection.find_rels(elem_a, elem_b)
return any(kind == "element-top" for _, kind in rels)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.disconnect_icon = self.setup_icon_gizmo(
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
)
self.disconnect_icon.hide = True
self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements")
def position_gizmos(self, context: bpy.types.Context) -> None:
self.disconnect_icon.hide = True
pair = _resolve_active_partner_pair(context)
if pair is None:
return
active, partner_obj, active_elem, partner_elem = pair
# Helper expects wall + slab regardless of which the user marked active.
if active_elem.is_a("IfcWall"):
wall_obj, slab_obj = active, partner_obj
elif partner_elem.is_a("IfcWall"):
wall_obj, slab_obj = partner_obj, active
else:
return
location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj)
if location is None:
return
billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot)
self.disconnect_icon.matrix_basis = gizmo.billboarded_at(
location + clearance, billboard_rot, scale=self.ICON_SCALE
)
self.disconnect_icon.hide = False
self.disconnect_op.element_a_guid = active_elem.GlobalId
self.disconnect_op.element_b_guid = partner_elem.GlobalId
self.disconnect_icon.partner_obj = partner_obj
class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Gizmo group for the wall-fillet preview: radius dimension widget +
trim-length dimension widget + validate / cancel icons.
On degenerate geometry the dimensions and validate hide but cancel stays
visible so the user always has an exit. Radius and trim widgets express
the same single DOF — both read/write the canonical `props.radius`."""
bl_idname = "OBJECT_GGT_bim_wall_fillet_preview"
bl_label = "Wall Fillet Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
props = _wall_fillet_props(context)
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.wall_a_id)
ifc_file.by_id(props.wall_b_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
# Lazy-fetched closures re-resolve the Scene per call so the freed-RNA
# crash on file open / undo doesn't hit the gizmo callbacks.
_props_callback = preview_base.make_props_callback("wall_fillet")
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props_callback, "radius")
gz.move_set_cb = preview_base.make_dim_setter(_props_callback, "radius")
# Set `axis` only (NOT `local_axis`) so `get_axis_direction` falls
# through to the world-space direction set per frame on each gizmo.
# The preview spans world space independent of either wall's local
# frame, so the active-object transform that `local_axis` would go
# through is the wrong frame.
gz.axis = Vector((1, 0, 0))
gz.invert_delta = False
gz.delta_scale = 1.0
gz.prop_name = "Radius"
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
# Arrowheads at BOTH ends + extension lines make this read as a proper
# dimension annotation rather than a single-direction drag arrow.
gz.show_start_arrow = True
gz.show_end_arrow = True
gz.show_extension_lines = True
gz.text_formatter = None
self.radius_dim = gz
# Sweep angle is geometrically invariant during drag (depends only on
# the angle between the two walls). Cached per frame so the trim
# getter / setter can convert trim_length ↔ radius via tan(sweep/2)
# without re-running the full geometry pipeline on every drag tick.
self._sweep_angle = math.pi / 2
# Trim-length widget expresses the SAME single DOF as the radius
# widget via the leg setback distance (intersection → tangent point).
# Architects often think "how much of each wall do I cut back" rather
# than "what radius do I want"; this widget surfaces that mental model
# without introducing a second degree of freedom. Both widgets stay
# in sync because they read/write the same canonical `radius` field.
trim_gz = self.gizmos.new("BIM_GT_gizmo_dimension")
trim_gz.move_get_cb = self._make_trim_getter()
trim_gz.move_set_cb = self._make_trim_setter()
trim_gz.axis = Vector((1, 0, 0))
trim_gz.invert_delta = False
trim_gz.delta_scale = 1.0
trim_gz.prop_name = "Trim Length"
trim_gz.gizmo_group = self
trim_gz.color = default_color
trim_gz.color_highlight = highlight_color
trim_gz.alpha = 1.0
trim_gz.use_draw_modal = True
trim_gz.use_draw_scale = False
trim_gz.text_offset_sign = 1
trim_gz.text_alignment = gizmo.TextAlignment.CENTER
trim_gz.show_start_arrow = True
trim_gz.show_end_arrow = True
trim_gz.show_extension_lines = True
trim_gz.text_formatter = None
self.trim_dim = trim_gz
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_wall_fillet_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_wall_fillet_preview")
def _make_trim_getter(self):
"""Closure returning |radius| * tan(sweep/2) — the live leg setback
distance — from the cached sweep angle and the canonical radius."""
def _get() -> float:
props = _wall_fillet_props(bpy.context)
if props is None:
return 0.0
sweep = max(self._sweep_angle, 1e-3)
return abs(float(props.radius)) * math.tan(sweep / 2.0)
return _get
def _make_trim_setter(self):
"""Closure writing radius from a dragged trim_length, preserving the
radius sign so a concave preview stays concave when the user drags the
trim widget. Clamps to the FloatProperty's lower bound so the gizmo
can't push radius below the geometry helper's tolerance."""
def _set(value: float) -> None:
props = _wall_fillet_props(bpy.context)
if props is None:
return
sweep = max(self._sweep_angle, 1e-3)
tan_half = math.tan(sweep / 2.0)
if tan_half < 1e-9:
return
sign = -1.0 if float(props.radius) < 0 else 1.0
new_radius = sign * max(0.001, float(value)) / tan_half
props.radius = new_radius
for area in bpy.context.screen.areas if bpy.context.screen else ():
if area.type == "VIEW_3D":
area.tag_redraw()
return _set
def position_gizmos(self, context: bpy.types.Context) -> None:
wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context)
if wall_a_obj is None or wall_b_obj is None:
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = _wall_fillet_props(context)
if props is None:
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
billboard_rot = gizmo.get_billboard_rotation(context)
# Geometry helper failed outright (e.g. wall A's reference line went
# missing). No anchor to draw on — hide everything.
if geom is None:
for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
# Parallel / near-collinear axes — no defined arc at all. Drop radius
# + trim + validate; keep cancel visible at the would-be intersection
# so the user has an exit. The dim widgets have nowhere to anchor.
if not geom["valid"] and not geom.get("invalid_radius"):
self.radius_dim.hide = True
self.trim_dim.hide = True
self.validate_icon.hide = True
anchor = None
if geom.get("arc_center") is not None:
anchor = Vector(geom["arc_center"])
elif geom.get("intersection") is not None:
anchor = Vector(geom["intersection"])
if anchor is not None:
# Same screen-up lift as the valid branch so the cancel icon
# doesn't sit on top of any underlying preview lines in
# top-down view.
screen_up = gizmo.get_screen_up(billboard_rot)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(
anchor + screen_up * self.ICON_Z_OFFSET, billboard_rot, scale=self.ICON_SCALE
)
self.cancel_icon.hide = False
else:
self.cancel_icon.hide = True
return
# Both `valid=True` and `invalid_radius=True` populate arc_center,
# apex, and tangent points. Keep the radius dim visible on overshoot
# so the user can drag back to a valid radius; hide validate so a
# commit can't surface an operator-level error.
invalid_radius = bool(geom.get("invalid_radius"))
self.radius_dim.hide = False
self.trim_dim.hide = False
self.cancel_icon.hide = False
self.validate_icon.hide = invalid_radius
# Cache the sweep angle so the trim widget's getter / setter can
# convert without re-running the geometry pipeline. Falls back to a
# right angle if the helper somehow omits it.
self._sweep_angle = float(geom.get("sweep_angle") or math.pi / 2)
arc = geom["arc"]
arc_center = Vector(geom["arc_center"])
tangent_a = Vector(geom["tangent_a"])
tangent_b = Vector(geom["tangent_b"])
intersection = Vector(geom["intersection"])
# Radius dimension at the arc apex with local +X pointing INWARD
# toward the arc center. Visual line traces apex → center, matching
# the radius itself; drag in the +X direction (toward arrow tip =
# toward arc center) increases the radius. Anchored at the FLOOR of
# the wall (z=0 of the arc samples) so the gizmo reads against the
# wall geometry rather than hovering in mid-air.
apex_index = len(arc) // 2
apex = Vector(arc[apex_index])
inward = arc_center - apex
if inward.length > 1e-6:
inward.normalize()
self.radius_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(apex, inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(abs(props.radius))
else:
self.radius_dim.hide = True
# Trim dimension along wall A from intersection toward tangent_a;
# same DOF as the radius widget, both update `radius`.
along_a = tangent_a - intersection
tangent_offset = abs(float(props.radius)) * math.tan(self._sweep_angle / 2.0)
if along_a.length > 1e-6 and tangent_offset > 1e-6:
along_a_dir = along_a.normalized()
self.trim_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(intersection, along_a_dir)
self.trim_dim.axis = along_a_dir
self.trim_dim.set_dimension_length(tangent_offset)
else:
self.trim_dim.hide = True
# Validate / cancel anchored ABOVE the arc apex along the camera's
# screen-up direction so they're always visibly clear of the radius
# dim widget (which runs apex → arc_center). Screen-up keeps the
# offset perpendicular to the view plane at any angle — world +Z
# would collapse to zero on-screen in top-down view and plant the
# icons on top of the radius arrowhead.
screen_up = gizmo.get_screen_up(billboard_rot)
anchor = apex + screen_up * self.ICON_Z_OFFSET
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Pen-icon re-edit gizmo for an existing fillet corner wall.
Mutually exclusive with an active preview and with GizmoWallEdition."""
bl_idname = "OBJECT_GGT_bim_wall_fillet_reedit"
bl_label = "Wall Fillet Re-edit Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_TOP_LIFT: ClassVar[float] = 0.15
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcWall"):
return False
# IsFilletCorner pset is the authoritative signal — the re-edit
# operator separately verifies both neighbour connections exist and
# reports a user-facing error if either side has been disconnected
# since creation. Validating that here would hide the pen icon
# silently, leaving the user with no obvious next step.
return tool.Parametric.is_fillet_corner_wall(element)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.edit_icon = self.setup_icon_gizmo(
"VIEW3D_GT_pen",
default_color,
highlight_color,
"bim.enable_wall_fillet_preview_from_corner",
)
def position_gizmos(self, context: bpy.types.Context) -> None:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
self.edit_icon.hide = True
return
corner_obj = selected[0]
geom = _get_wall_geom_cached(self, corner_obj)
if geom is None:
self.edit_icon.hide = True
return
billboard_rot = gizmo.get_billboard_rotation(context)
origin = corner_obj.matrix_world.translation
top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT
anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot)
self.edit_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot)
self.edit_icon.hide = False
class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Surfaces the show / hide openings icon on a fillet-corner wall.
GizmoWallEdition's idle row already exposes this toggle for LAYER2 walls,
but its poll routes through the parametric edit pipeline which by IFC
spec rejects fillet corners (their banana body is hand-built and would be
flattened by the parametric regen). The openings toggle itself is a
viewport-state action independent of the body, so a parallel poll keeps
it available without re-opening the parametric edits."""
bl_idname = "OBJECT_GGT_bim_wall_fillet_toggle_openings"
bl_label = "Fillet Wall Toggle Openings Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_TOP_LIFT: ClassVar[float] = 0.15
# Screen-space X offset from the pen icon so the two stack horizontally
# rather than overlap at the chord midpoint.
ICON_OFFSET_X: ClassVar[float] = 0.4
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
return False
if len(list(tool.Blender.get_selected_objects())) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None or not element.is_a("IfcWall"):
return False
return tool.Parametric.is_fillet_corner_wall(element)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.toggle_openings_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening",
default_color,
highlight_color,
"bim.toggle_host_openings",
)
def position_gizmos(self, context: bpy.types.Context) -> None:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 1:
self.toggle_openings_icon.hide = True
return
corner_obj = selected[0]
geom = _get_wall_geom_cached(self, corner_obj)
if geom is None:
self.toggle_openings_icon.hide = True
return
billboard_rot = gizmo.get_billboard_rotation(context)
origin = corner_obj.matrix_world.translation
top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT
anchor = Vector((origin.x, origin.y, top_z)) + gizmo.top_down_clearance(context, billboard_rot)
offset_x = billboard_rot @ Vector((self.ICON_OFFSET_X, 0.0, 0.0))
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot)
self.toggle_openings_icon.hide = False
class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.join_walls_intersection"
bl_label = "Join Walls at Corner"
bl_description = "Join two walls at their corner"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if _poll_reject_array_children(cls):
return False
return True
def _perform(self, context: bpy.types.Context) -> set[str]:
try:
core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model)
except core.RequireTwoWallsError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
return {"FINISHED"}
def draw_wall_partner_bbox(
context: bpy.types.Context,
partner_obj: bpy.types.Object,
) -> None:
"""Paint a wireframe bbox around ``partner_obj`` in the same 3D pass.
Called inline from gizmo ``draw()`` methods so the highlight tracks the
hover cursor one-for-one — no POST_VIEW handler, no timing lag.
Silently no-ops if the object has no bounding box (e.g. Empties)."""
segments = bbox_world_edges(partner_obj)
if not segments:
return
prefs = tool.Blender.get_addon_preferences()
color = prefs.decorator_color_special[:3]
draw_polyline_segments(
context,
segments,
color,
_BBOX_HIGHLIGHT_LINE_ALPHA,
_BBOX_HIGHLIGHT_LINE_WIDTH,
)
class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
"""Hover-gated preview lines that visualise where a click-to-act wall
gizmo's operator would move the wall geometry. Four state machines:
- **Join intersection** — when exactly two non-joined, non-collinear,
non-parallel LAYER2 walls are selected, draws one line from each wall's
nearest axis endpoint to the projected XY intersection. Each line stays
at its own wall's axis Z (so for walls on different storeys the lines
stay horizontal at their own floor levels). Mirrors the visibility of
the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``.
- **Extend to cursor** — when a single LAYER2 wall is selected and the
``extend`` wall-gizmo pref is enabled, draws one line from the wall's
nearer axis endpoint to the 3D cursor's projected X on the wall axis.
Mirrors the visibility of the ``extend_x_gizmo`` icon in
``GizmoWallEdition``.
- **Extend Z to cursor** — one preview line at the cursor's projected X
from wall base to the cursor's Z, visualising the new total height.
Hover-gated on ``extend_z_gizmo``.
- **Split at cursor** — one world-vertical line at the cursor's projected X
from wall base to wall top, visualising the cut plane. Hover-gated on
``split_gizmo``.
Purely a visual cue — hidden by the same gizmo-preferences toggle as the
icons themselves."""
draw_method = "draw_lines"
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
QUAD_ALPHA = 0.45
def draw_lines(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
self._draw_join_preview(context, prefs)
self._draw_cursor_extend_preview(context, prefs)
self._draw_cursor_extend_z_preview(context, prefs)
self._draw_cursor_split_preview(context, prefs)
self._draw_cursor_perpendicular_wall_preview(context, prefs)
def _stroke(
self,
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
) -> None:
draw_polyline_segments(context, segments, color_rgb, self.LINE_ALPHA, self.LINE_WIDTH)
def _fill(
self,
context: bpy.types.Context,
quads: list[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
color_rgb: tuple[float, float, float],
) -> None:
tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA))
@staticmethod
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]:
"""4 world-space corners of a Z=0 wall-local rectangle, CCW when
viewed from +Z. Used for top-down floor-projection quads so the
extend / split previews stay legible from plan view."""
return (
tuple(mw @ Vector((x0, y0, 0.0))),
tuple(mw @ Vector((x1, y0, 0.0))),
tuple(mw @ Vector((x1, y1, 0.0))),
tuple(mw @ Vector((x0, y1, 0.0))),
)
def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render four preview lines per wall pair — two at each wall's base
Z, two at each wall's top Z — extending each axis to the projected
intersection. Two lines per wall (base + top) communicate the full
plane that the join/extend operator would weld at, not just the
floor edge.
Hover colour:
- **Join or Fillet hover** → all four lines light up (both walls
converge at the corner; fillet is a symmetric round of the same
corner).
- **Extend-to-Wall hover** → only the base+top of the non-active
wall (the wall the default-direction operator would extend).
- Otherwise → ``decorations_colour``."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return
elem_a = tool.Ifc.get_entity(selected[0])
elem_b = tool.Ifc.get_entity(selected[1])
if elem_a is None or elem_b is None:
return
if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b):
return
geom_a = tool.Wall.read_geometry(selected[0])
geom_b = tool.Wall.read_geometry(selected[1])
if geom_a is None or geom_b is None:
return
seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a)
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
parallel_threshold = core.PARALLEL_DOT_THRESHOLD
collinear_tolerance = core.COLLINEAR_LINE_TOLERANCE
state, intersection_tuple = _classify_wall_join_state(
elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance
)
if state != "intersect":
return
assert intersection_tuple is not None
floor_lines = core.wall_join_preview_lines(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
intersection_tuple,
)
height_a = geom_a.get("height", 0.0)
height_b = geom_b.get("height", 0.0)
wall_a_floor, wall_b_floor = floor_lines
def _lift(seg: tuple, dz: float) -> tuple:
(sx, sy, sz), (ex, ey, ez) = seg
return ((sx, sy, sz + dz), (ex, ey, ez + dz))
wall_a_top = _lift(wall_a_floor, height_a)
wall_b_top = _lift(wall_b_floor, height_b)
join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context)
default = tuple(prefs.decorations_colour[:3])
selected_rgb = tuple(prefs.decorator_color_selected[:3])
all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top]
if join_hovered or fillet_hovered:
self._stroke(context, all_lines, selected_rgb)
return
if extend_hovered:
extended_idx = self._extended_wall_index(context, selected)
if extended_idx is not None:
extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top]
untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top]
self._stroke(context, untouched_lines, default)
self._stroke(context, extended_lines, selected_rgb)
return
self._stroke(context, all_lines, default)
@staticmethod
def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]:
"""Index of the non-active wall in ``selected``, or ``None``."""
active = context.active_object
if active is selected[0]:
return 1
if active is selected[1]:
return 0
return None
def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]:
"""Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)``
from the ``GizmoWallJoinIntersection`` instance in **the same region**
the decorator is currently drawing in. Returns ``(False, False,
False)`` when that region has no live gizmo group (poll → False,
weakref cleared, or no setup yet). Read-only; any access exception
is swallowed so a transient bpy-state hiccup never breaks the draw
loop."""
inst = self._lookup_active_instance(gizmo_cls, context)
if inst is None:
return False, False, False
try:
return (
bool(inst.join_icon.is_highlight),
bool(inst.extend_to_wall_icon.is_highlight),
bool(inst.fillet_icon.is_highlight),
)
except (AttributeError, ReferenceError):
return False, False, False
def _active_layer2_wall_for_gizmo_preview(
self, context: bpy.types.Context, prefs: Any
) -> Optional[bpy.types.Object]:
"""Active object iff it is the sole selected object, is a LAYER2 IfcWall,
and the wall feature's gizmo prefs are enabled. Otherwise ``None``.
Shared guard for every cursor-anchored extend-preview path so each one
short-circuits on the same conditions the gizmo group itself uses."""
gizmo_prefs = getattr(prefs.gizmos, "wall", None)
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
return None
active = context.active_object
if active is None:
return None
selected = list(tool.Blender.get_selected_objects())
if active not in selected or len(selected) != 1:
return None
element = tool.Ifc.get_entity(active)
if element is None or not tool.Parametric.is_wall(element):
return None
if tool.Model.get_usage_type(element) != "LAYER2":
return None
return active
def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Hover-gated floor-plane preview for the extend-X icon. Quads sit
on the Z=0 plane spanning the wall's ``offset`` to
``offset + thickness`` Y band so the operator's effect reads from
plan view without side-view clutter:
- **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one
green ``decorator_color_selected`` quad over the extension
(nearer endpoint → cursor X).
- **Cursor inside the wall extent (shrink)**: green quad for the
portion that REMAINS (cursor X → farther endpoint) + red
``decorator_color_error`` quad for the portion the operator
REMOVES (nearer endpoint → cursor X)."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context):
return
geom = tool.Wall.read_geometry(active)
if geom is None:
return
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
offset = geom.get("offset", 0.0)
thickness = geom.get("thickness", 0.0)
if length <= 0 or thickness <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
y_floor_0 = offset
y_floor_1 = offset + thickness
start_x = anchor_x
end_x = anchor_x + length
keep_color = tuple(prefs.decorator_color_selected[:3])
nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x
def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None:
if abs(x1 - x0) < 1e-6:
return
lo, hi = (x0, x1) if x0 < x1 else (x1, x0)
self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color)
if start_x < cursor_local.x < end_x:
remove_color = tuple(prefs.decorator_color_error[:3])
farthest_x = end_x if nearest_x == start_x else start_x
emit(nearest_x, cursor_local.x, remove_color)
emit(cursor_local.x, farthest_x, keep_color)
return
emit(nearest_x, cursor_local.x, keep_color)
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render two red lines at the cursor's projected X: one vertical along
the wall's local Z (visible in elevation views), one horizontal across
the wall's thickness band at floor Z (visible in plan / top-down view).
Together they trace the cut plane the split operator would commit.
Hover-gated on the split icon; coloured with the destructive-action
warning red to match the icon's own hover signal."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context):
return
geom = tool.Wall.read_geometry(active)
if geom is None:
return
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
height = geom.get("height", 0.0)
offset = geom.get("offset", 0.0)
thickness = geom.get("thickness", 0.0)
if length <= 0 or height <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
if not (anchor_x < cursor_local.x < anchor_x + length):
return
color = tuple(prefs.decorator_color_error[:3])
bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
top_world = mw @ Vector((cursor_local.x, 0.0, height))
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [
(tuple(bottom_world), tuple(top_world))
]
if thickness > 0:
base_a = mw @ Vector((cursor_local.x, offset, 0.0))
base_b = mw @ Vector((cursor_local.x, offset + thickness, 0.0))
segments.append((tuple(base_a), tuple(base_b)))
self._stroke(context, segments, color)
def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Hover-gated vertical-line preview for the extend-Z icon at the
cursor's projected X on the wall axis (y=0 reference-line plane).
Two cases by cursor Z relative to the wall's current height:
- **Cursor Z above the wall top (grow)**: one green
``decorator_color_selected`` segment from z=height to z=cursor.z
(the new vertical material).
- **Cursor Z inside ``(0, height)`` (shrink)**: two segments —
green from z=0 to z=cursor.z (the portion that REMAINS), red
``decorator_color_error`` from z=cursor.z to z=height (the
portion the operator REMOVES)."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context):
return
geom = tool.Wall.read_geometry(active)
if geom is None:
return
length = geom.get("length", 0.0)
height = geom.get("height", 0.0)
if length <= 0 or height <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
if cursor_local.z <= 0:
return
if abs(cursor_local.z - height) < 1e-6:
return
keep_color = tuple(prefs.decorator_color_selected[:3])
cursor_x = cursor_local.x
def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None:
a = mw @ Vector((cursor_x, 0.0, z0))
b = mw @ Vector((cursor_x, 0.0, z1))
self._stroke(context, [(tuple(a), tuple(b))], color)
if cursor_local.z > height:
stroke(height, cursor_local.z, keep_color)
return
remove_color = tuple(prefs.decorator_color_error[:3])
stroke(0.0, cursor_local.z, keep_color)
stroke(cursor_local.z, height, remove_color)
def _draw_cursor_perpendicular_wall_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Hover-gated floor-plane preview of the branch wall's footprint.
Green quad on Z=0 spanning the new wall's perpendicular body band
(``offset`` to ``offset + thickness`` mapped through the perpendicular
rotation) and its length from the projection on the source wall axis
to the cursor."""
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None:
return
if not self._cursor_icon_hovered(GizmoWallEdition, "add_perpendicular_wall_gizmo", context):
return
geom = tool.Wall.read_geometry(active)
if geom is None:
return
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
offset = geom.get("offset", 0.0)
thickness = geom.get("thickness", 0.0)
if length <= 0 or thickness <= 0:
return
mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location
params = _perpendicular_wall_params(cursor_local.x, cursor_local.y, anchor_x, length)
if params is None:
return
clamped_x, _length, side_sign = params
# New wall axis sits at source-local X = clamped_x; its body extends
# perpendicular to that axis. After rotating the new wall's ±Y body
# band into the source's local frame, the band lands at source-local
# X = clamped_x side_sign · {offset, offset+thickness}.
x_a = clamped_x - side_sign * offset
x_b = clamped_x - side_sign * (offset + thickness)
x_lo, x_hi = (x_a, x_b) if x_a < x_b else (x_b, x_a)
y_lo, y_hi = (0.0, cursor_local.y) if cursor_local.y > 0 else (cursor_local.y, 0.0)
keep_color = tuple(prefs.decorator_color_selected[:3])
self._fill(context, [self._wall_floor_quad(mw, x_lo, x_hi, y_lo, y_hi)], keep_color)