Merge pull request #8089 from Gorgious56/gizmos

Parametric gizmos : Support wall and wall operations
This commit is contained in:
Gorgious56
2026-05-21 11:58:54 +02:00
committed by GitHub
36 changed files with 4718 additions and 435 deletions
+18 -3
View File
@@ -15,6 +15,8 @@
#
# 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.
import importlib
import os
@@ -27,6 +29,18 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]:
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``classes`` list below. ``import bonsai.tool`` is kept local to surface
the load-order constraint: it relies on ``from . import handler, …``
above having primed the
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui)
try:
from bonsai.translations import translations_dict
except ImportError:
@@ -157,9 +171,10 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
ui.GizmoPreferencesStair, # Register before GizmoPreferences
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
*_parametric_gizmo_preference_classes(),
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
+27 -5
View File
@@ -15,11 +15,12 @@
#
# 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.
import os
import weakref
from collections.abc import Callable
from math import cos
from typing import Union
import bpy
@@ -31,6 +32,7 @@ from bpy.app.handlers import persistent
from mathutils import Vector
import bonsai.bim
import bonsai.core.model as core_model
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
@@ -133,14 +135,32 @@ def update_bim_tool_props():
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
aprops.object_type = object_type
aprops.relating_type_id = str(element_type.id())
try:
aprops.relating_type_id = str(element_type.id())
except TypeError:
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
# this assignment can race a stale item list. Skipping is harmless —
# the UI will resync on the next active_object_callback.
pass
return
if is_bim_tool:
props.ifc_class = element_type.is_a()
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
props.relating_type_id = str(element_type.id())
# Only assign when the target enum is the one that lists this type — otherwise
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
# different class than the workspace tool was built for (e.g. selecting a wall
# while the door tool is active).
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
if bim_tool_class_match or tool_class_match:
try:
props.relating_type_id = str(element_type.id())
except TypeError:
# Defensive: the enum item list can lag behind ifc_class assignment
# above. Skipping leaves the panel briefly out of sync rather than
# crashing the handler (which Blender re-fires on every selection).
pass
if is_annotation_tool:
return
@@ -165,7 +185,9 @@ def update_bim_tool_props():
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
extrusion.Depth * si_conversion, x_angle
)
props.length = (axis[1] - axis[0]).length
props.x_angle = x_angle
+1
View File
@@ -514,6 +514,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit()
if method == "MODAL":
cls.modal_in_progress = False
@@ -15,6 +15,8 @@
#
# 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.
import bpy
@@ -143,6 +145,14 @@ classes = (
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoExtend,
gizmos.GizmoExtendVertical,
gizmos.GizmoOffsetExterior,
gizmos.GizmoOffsetCenter,
gizmos.GizmoOffsetInterior,
gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
+418 -73
View File
@@ -16,6 +16,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
"""
Gizmo infrastructure for parametric BIM element editing.
@@ -511,6 +513,7 @@ class DimensionTextRenderer:
color: tuple[float, float, float],
offset_sign: int = 1,
alignment: TextAlignment | str = TextAlignment.CENTER,
display_text: str | None = None,
) -> None:
"""Draw formatted dimension value text at the given screen position.
@@ -522,15 +525,20 @@ class DimensionTextRenderer:
color: Text color (r, g, b)
offset_sign: 1 for above/right, -1 for below/left
alignment: TextAlignment enum value
display_text: Pre-formatted label. If provided, used verbatim instead of
formatting `value`.
"""
# Normalize string to enum for comparison
if isinstance(alignment, str):
alignment = TextAlignment(alignment)
is_negative = value < 0
text = tool.Unit.format_distance(abs(value))
if is_negative:
text = "-" + text
if display_text is not None:
text = display_text
else:
is_negative = value < 0
text = tool.Unit.format_distance(abs(value))
if is_negative:
text = "-" + text
font_id = 0
font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE)
@@ -795,6 +803,7 @@ class DimensionRenderer:
text_alignment: TextAlignment = TextAlignment.CENTER,
prop_name: str | None = None,
display_value: float | None = None,
display_text: str | None = None,
) -> None:
"""Draw complete dimension graphics in screen space.
@@ -816,6 +825,8 @@ class DimensionRenderer:
text_alignment: TextAlignment enum for text positioning
prop_name: Property name for tooltip (shown when highlighted)
display_value: Value to display as text (can be negative); uses dimension_length if None
display_text: Pre-formatted label string. If provided, used verbatim instead of
formatting `display_value` via tool.Unit.format_distance.
"""
if dimension_length < 0:
return
@@ -935,7 +946,14 @@ class DimensionRenderer:
)
text_color = highlight_color if is_highlight else color
DimensionTextRenderer.get_instance().draw_value_text(
context, center_screen, perpendicular, text_value, text_color, text_offset_sign, text_alignment
context,
center_screen,
perpendicular,
text_value,
text_color,
text_offset_sign,
text_alignment,
display_text,
)
if is_highlight and prop_name:
@@ -1121,6 +1139,13 @@ class DimensionGizmoConfig:
If provided, eliminates need for get_dimension_matrix_{attr_name} method.
The returned Vector is the local-space position where the gizmo origin
will be placed. Combined with axis to create the full transformation matrix.
text_formatter: Optional function(props, value) -> str for the dimension label.
Receives the props bag and the post-`compute_value` display value
(i.e. the same number `apply_value` consumes during drag — for the
wall slope gizmo this is the displacement, NOT the underlying
`x_angle`). The raw underlying attribute is accessible as
`getattr(props, attr_name)`. If None, falls back to the default
`tool.Unit.format_distance(abs(value))` with negative-sign handling.
"""
attr_name: str
@@ -1138,6 +1163,7 @@ class DimensionGizmoConfig:
apply_value: Callable[[Any, float], None] | None = None
visibility_condition: Callable[[Any], bool] | None = None
matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position
text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text
def __post_init__(self):
# Validate attr_name
@@ -1576,6 +1602,78 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix:
return rv3d.view_matrix.to_3x3().transposed().to_4x4()
def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = 0.5) -> Matrix:
"""Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard
to the camera, then uniformly scale. Replaces the repeated
``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern."""
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def setup_icon_gizmo(
gizmo_group: bpy.types.GizmoGroup,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Create and configure a stand-alone icon gizmo with the Bonsai defaults
(no draw-scale, fixed alpha, click-to-operator). Use this from any
``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments."""
gizmo = gizmo_group.gizmos.new(gizmo_type)
gizmo.use_draw_scale = False
gizmo.color = color
gizmo.color_highlight = highlight_color
gizmo.alpha = alpha
gizmo.target_set_operator(operator)
return gizmo
# --- Tris geometry helpers ----------------------------------------------------
# Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module.
# Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into
# triangles of 3; these helpers compose tris from primitives so the per-gizmo
# definitions stay small and visually readable.
def rect_tris(x0: float, y0: float, x1: float, y1: float) -> tuple[tuple[float, float, float], ...]:
"""Two triangles forming an axis-aligned rectangle from ``(x0, y0)`` to ``(x1, y1)``,
in the Z=0 plane (the convention for icon gizmos)."""
return (
(x0, y0, 0.0),
(x0, y1, 0.0),
(x1, y1, 0.0),
(x0, y0, 0.0),
(x1, y1, 0.0),
(x1, y0, 0.0),
)
def swap_xy_tris(
tris: tuple[tuple[float, float, float], ...],
) -> tuple[tuple[float, float, float], ...]:
"""Reflect a ``tris`` tuple across the Y=X diagonal — useful when a "vertical"
sibling of a "horizontal" icon should otherwise be a literal copy."""
return tuple((y, x, z) for x, y, z in tris)
class TrisGizmoMixin:
"""Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is
drawing a static ``tris`` triangle tuple. Subclasses set the class-level
``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` /
``draw_select``. Use only with gizmos that have no per-instance state beyond
``custom_shape``."""
def setup(self) -> None:
self.custom_shape = self.new_custom_shape("TRIS", self.tris)
def draw(self, context: bpy.types.Context) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self.draw_custom_shape(self.custom_shape, select_id=select_id)
def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None:
"""Get normalized direction from position towards camera."""
rv3d = context.region_data
@@ -3042,6 +3140,145 @@ class GizmoMinus(bpy.types.Gizmo):
self.draw_custom_shape(self.custom_shape, select_id=select_id)
class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
bl_idname = "VIEW3D_GT_merge"
__slots__ = ("custom_shape",)
# Two solid triangles pointing toward the center on the horizontal axis,
# plus two thin tails behind each tip to make them read as arrows rather than
# standalone triangles.
tris = (
# Left arrowhead pointing right (tip at x≈-0.05).
(-0.35, -0.20, 0.0),
(-0.35, 0.20, 0.0),
(-0.05, 0.0, 0.0),
# Left tail behind the arrowhead.
*rect_tris(-0.45, -0.06, -0.30, 0.06),
# Right arrowhead pointing left (tip at x≈0.05).
(0.35, -0.20, 0.0),
(0.35, 0.20, 0.0),
(0.05, 0.0, 0.0),
# Right tail behind the arrowhead.
*rect_tris(0.30, -0.06, 0.45, 0.06),
)
class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing outward away from each other — conveys splitting/cutting
one element into two. Visual inverse of `GizmoMerge`."""
bl_idname = "VIEW3D_GT_split"
__slots__ = ("custom_shape",)
# Two solid triangles pointing OUTWARD on the horizontal axis (tips at x=±0.35),
# with tails extending toward the centerline. The tails meet at center to form a
# short horizontal bar, suggesting the split point itself.
tris = (
# Left arrowhead pointing left (tip at x=-0.35).
(-0.05, -0.20, 0.0),
(-0.05, 0.20, 0.0),
(-0.35, 0.0, 0.0),
# Left tail extending toward the right (away from the tip, toward center).
*rect_tris(-0.05, -0.06, 0.10, 0.06),
# Right arrowhead pointing right (tip at x=0.35).
(0.05, -0.20, 0.0),
(0.05, 0.20, 0.0),
(0.35, 0.0, 0.0),
# Right tail extending toward the left.
*rect_tris(-0.10, -0.06, 0.05, 0.06),
)
class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo):
"""An arrow pointing into a vertical bar — conveys extending an element to a target
line (e.g. extending a wall to the 3D cursor)."""
bl_idname = "VIEW3D_GT_extend"
__slots__ = ("custom_shape",)
# Layout: thick vertical bar at the right edge (the "target") with a horizontal
# arrow pointing into it from the left.
tris = (
# Vertical target bar (x = 0.25 to 0.35, full height).
*rect_tris(0.25, -0.30, 0.35, 0.30),
# Arrowhead pointing right toward the bar (tip at x=0.20).
(-0.05, -0.18, 0.0),
(-0.05, 0.18, 0.0),
(0.20, 0.0, 0.0),
# Tail extending leftward from the arrowhead base.
*rect_tris(-0.35, -0.06, -0.05, 0.06),
)
class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo):
"""Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal
bar. Conveys extending an element's height to a target Z."""
bl_idname = "VIEW3D_GT_extend_vertical"
__slots__ = ("custom_shape",)
# Mechanically derived from GizmoExtend by reflecting across Y=X.
tris = swap_xy_tris(GizmoExtend.tris)
def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], ...]:
"""Shared geometry for the three offset-baseline icons: a horizontal "wall
section" bar with a vertical mark at ``mark_x`` indicating where the reference
axis sits within the wall thickness. Matches the visual convention used in the
Bonsai N-panel's wall Align row."""
return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22)
class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the exterior face (left mark)."""
bl_idname = "VIEW3D_GT_offset_exterior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(-0.24)
class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the centreline (middle mark)."""
bl_idname = "VIEW3D_GT_offset_center"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.0)
class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the interior face (right mark)."""
bl_idname = "VIEW3D_GT_offset_interior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.24)
class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo):
"""A rectangular frame (square outline with a hole in the middle) — conveys adding an
opening (window/door/void) to a wall."""
bl_idname = "VIEW3D_GT_add_opening"
__slots__ = ("custom_shape",)
# Outer 0.40 × 0.40 square with a 0.25 × 0.25 inner hole, drawn as four bars
# forming a frame, plus a small "+" in the inner hole to convey "add".
tris = (
*rect_tris(-0.20, 0.125, 0.20, 0.20), # Top bar
*rect_tris(-0.20, -0.20, 0.20, -0.125), # Bottom bar
*rect_tris(-0.20, -0.125, -0.125, 0.125), # Left bar
*rect_tris(0.125, -0.125, 0.20, 0.125), # Right bar
*rect_tris(-0.07, -0.015, 0.07, 0.015), # "+" horizontal stroke
*rect_tris(-0.015, -0.07, 0.015, 0.07), # "+" vertical stroke
)
def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]:
"""Generate circular arrow geometry covering ~300 degrees."""
triangles = []
@@ -3421,6 +3658,7 @@ class GizmoDimension(GizmoMovable):
"_original_value", # Original property value before interaction
"_click_offset", # Offset from dimension tip to click position (for snap correction)
"show_extension_lines", # Whether to show extension lines at dimension endpoints
"text_formatter", # Optional (props, value) -> str to override the default dimension label
)
ARROW_SIZE = 10
@@ -3479,6 +3717,16 @@ class GizmoDimension(GizmoMovable):
start_world = self.matrix_basis.translation.copy()
end_world = start_world + axis_world * self._dimension_length
display_value = getattr(self, "_display_value", self._dimension_length)
text_formatter = getattr(self, "text_formatter", None)
gizmo_group = getattr(self, "gizmo_group", None)
display_text: str | None = None
if text_formatter is not None and gizmo_group is not None:
obj = bpy.context.active_object
props = gizmo_group.get_props(obj) if obj is not None else None
if props is not None:
display_text = text_formatter(props, display_value)
DimensionRenderer.get_instance().draw(
context=context,
start_world=start_world,
@@ -3496,7 +3744,8 @@ class GizmoDimension(GizmoMovable):
text_offset_sign=getattr(self, "text_offset_sign", 1),
text_alignment=getattr(self, "text_alignment", TextAlignment.CENTER),
prop_name=getattr(self, "prop_name", None),
display_value=getattr(self, "_display_value", self._dimension_length),
display_value=display_value,
display_text=display_text,
)
def _calculate_screen_endpoints(self, context: bpy.types.Context) -> tuple[Vector, Vector, Vector, float] | None:
@@ -3615,6 +3864,11 @@ class GizmoDimension(GizmoMovable):
self._display_value = max(-10000.0, min(length, 10000.0))
# Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing
self._dimension_length = max(0.0, min(abs(length), 10000.0))
# Smaller dimensions win selection when hit regions overlap: a long gizmo's
# hit box fully contains a nested short one's, so without a bias the long
# one wins and the short one is unreachable. The long one stays clickable
# at its exposed ends regardless of bias.
self.select_bias = -self._dimension_length
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set:
"""Initialize dimension gizmo interaction with click-position tracking.
@@ -3913,6 +4167,59 @@ class CycleTypeMixin:
return {"FINISHED"}
class BillboardingGizmoGroupMixin:
"""Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard
(face the camera) and re-position every frame.
Blender calls ``GizmoGroup.refresh()`` only on state-change events (selection,
property change, dependency update) — not on camera rotation. A gizmo group that
only sets ``matrix_basis`` in ``refresh()`` will appear to "freeze" its rotation
at the camera angle in effect when it was last refreshed; orbiting the camera
leaves the icon facing the wrong way.
``draw_prepare()`` *is* called every redraw, so the fix is to run the same
positioning code from both events. Rather than overriding ``refresh()`` and
``draw_prepare()`` in every gizmo group that has this need, subclass this mixin
and implement a single ``position_gizmos(context)`` method.
Usage::
class MyGizmoGroup(bpy.types.GizmoGroup, BillboardingGizmoGroupMixin):
bl_idname = "..."
...
def setup(self, context):
...
def position_gizmos(self, context):
# set matrix_basis on every gizmo here, using get_billboard_rotation
# for any icon that should face the camera.
...
``position_gizmos`` should be idempotent — it's called twice when a state change
coincides with a redraw (once via ``refresh``, once via ``draw_prepare``)."""
def refresh(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def setup_icon_gizmo(
self,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Convenience wrapper over `setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def position_gizmos(self, context: bpy.types.Context) -> None:
raise NotImplementedError(
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
)
class BaseParametricGizmoGroup:
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
@@ -4129,6 +4436,32 @@ class BaseParametricGizmoGroup:
return width + (self.GIZMO_OFFSET if use_offset else 0)
return -self.GIZMO_OFFSET if use_offset else 0
@staticmethod
def get_camera_facing_outer_y(
viewing_from_negative_y: bool,
near_y: float,
far_y: float,
gizmo_offset: float = 0.0,
) -> float:
"""Y coordinate just outside the camera-facing face of an element.
Generalises `get_y_position_for_view` for elements whose near face
isn't at the local origin. ``near_y`` is the local-Y of the -Y face;
``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the
face the camera is currently looking at, pushed by ``gizmo_offset`` (use
``cls.GIZMO_OFFSET`` for the standard handle gap).
Suits walls (``near_y = props.offset``, ``far_y = props.offset + props.thickness``)
and any other element whose section sits inside a non-zero Y band. Stair /
door / window can also call this once their callers pass explicit near/far
instead of the implicit ``width_attr`` pattern, eliminating
``get_y_position_for_view``, ``get_lining_y_position_for_view`` etc. as
wrappers around the same shape — but they're left intact for now to avoid
churning code paths that already work."""
if viewing_from_negative_y:
return near_y - gizmo_offset
return far_y + gizmo_offset
def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float:
"""Get Y position for editing icons based on view direction.
@@ -4224,13 +4557,13 @@ class BaseParametricGizmoGroup:
"""
return 0.0
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None:
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002
"""Update overall_width, overall_height, and lining_offset based on view direction.
This base implementation handles the common pattern for door/window gizmos.
Subclasses can override get_casing_offset() to customize behavior.
"""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y)
self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0))
@@ -4309,21 +4642,15 @@ class BaseParametricGizmoGroup:
@classmethod
def poll(cls, context) -> bool:
prefs = tool.Blender.get_addon_preferences()
if not prefs.gizmos.draw_gizmos_in_3d_viewport:
return False
obj = tool.Blender.get_active_object(is_selected=True)
if not obj:
if obj is None:
return False
if not tool.Blender.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(obj)
if not element or not cls.is_element_type(element):
return False
return True
return bool(element) and cls.is_element_type(element)
def setup(self, context: bpy.types.Context) -> None:
"""Template method for gizmo setup.
@@ -4343,6 +4670,19 @@ class BaseParametricGizmoGroup:
"""
pass
# Frame-scoped caches primed at the top of ``refresh()`` and ``draw_prepare()``.
# Every per-frame helper — preferences access, view-direction lookup, billboard
# rotation — reads these instead of re-deriving the same values, since each
# gizmo group ends up needing them 25× per frame across its position helpers.
_frame_prefs: Any = None
_frame_view_dir: tuple[bool, bool] | None = None
_frame_billboard_rot: "Matrix | None" = None
def _prime_frame_caches(self, context: bpy.types.Context, mw: "Matrix") -> None:
self._frame_prefs = tool.Blender.get_addon_preferences()
self._frame_view_dir = self.get_local_view_direction(context, mw)
self._frame_billboard_rot = get_billboard_rotation(context)
def refresh(self, context: bpy.types.Context) -> None:
"""Template method for gizmo refresh.
@@ -4357,6 +4697,7 @@ class BaseParametricGizmoGroup:
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
self._refresh_element_specific(context, mw, props)
@@ -4364,8 +4705,10 @@ class BaseParametricGizmoGroup:
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002
"""Override for element-specific refresh logic.
Called after update_editing_gizmos and update_dimension_gizmos.
Examples: door swing gizmos, stair lock/tread/plus/minus gizmos.
Called from both refresh() (on state change) and draw_prepare() (per frame),
so any override must be idempotent and cheap. Use this to re-position or
re-billboard element-specific gizmos (door swing arcs, stair lock/+/- icons,
wall cursor icons, etc.).
"""
pass
@@ -4385,10 +4728,11 @@ class BaseParametricGizmoGroup:
return getattr(tool.Model, self.props_getter)(obj)
raise NotImplementedError("Subclass must define props_getter or override get_props()")
@staticmethod
def get_addon_prefs():
"""Get addon preferences (cached accessor)."""
return tool.Blender.get_addon_preferences()
def get_addon_prefs(self):
"""Return the addon preferences struct. Inside ``refresh`` / ``draw_prepare``
the frame cache is hit; outside (e.g. ``setup``) we fall through to a fresh
lookup so callers don't have to know which call path they're on."""
return self._frame_prefs if self._frame_prefs is not None else tool.Blender.get_addon_preferences()
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Get default and highlight colors from preferences.
@@ -4507,8 +4851,8 @@ class BaseParametricGizmoGroup:
scale: Gizmo scale factor (default 0.5)
"""
if gz := self.get_gizmo_if_visible(gizmo_name):
local_transform = Matrix.Translation(Vector((x, y, z))) @ billboard_rot @ Matrix.Scale(scale, 4)
gz.matrix_basis = mw @ local_transform
world_pos = mw @ Vector((x, y, z))
gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale)
def set_dimension_gizmo_position(
self,
@@ -4594,28 +4938,12 @@ class BaseParametricGizmoGroup:
) -> bpy.types.Gizmo:
"""Create and configure an icon gizmo with standard settings.
Reduces boilerplate in setup_editing_gizmos.
Args:
gizmo_type: Blender gizmo type identifier (e.g., "VIEW3D_GT_pen")
color: RGB color tuple
operator: Operator to invoke on click
highlight_color: Optional highlight color (defaults to prefs selection color)
alpha: Gizmo alpha (default 0.8)
Returns:
Configured gizmo instance.
Thin wrapper over `setup_icon_gizmo` that defaults ``highlight_color``
to the addon-prefs selection color via ``get_decoration_colors``.
"""
if highlight_color is None:
_, highlight_color = self.get_decoration_colors()
gizmo = self.gizmos.new(gizmo_type)
gizmo.use_draw_scale = False
gizmo.color = color
gizmo.color_highlight = highlight_color
gizmo.alpha = alpha
gizmo.target_set_operator(operator)
return gizmo
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def setup_editing_gizmos(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -4696,6 +5024,7 @@ class BaseParametricGizmoGroup:
gizmo.delta_scale = config.delta_scale
gizmo.prop_name = config.prop_name # Auto-derived in __post_init__
gizmo.gizmo_group = self
gizmo.text_formatter = config.text_formatter
gizmo.color = self.get_color_from_name(config.color)
gizmo.color_highlight = highlight_color
gizmo.alpha = 1.0
@@ -4723,10 +5052,9 @@ class BaseParametricGizmoGroup:
gizmo.hide = False
# Priority: config.matrix_position > get_dimension_matrix_* method > Identity
# Priority: config.matrix_position > get_dimension_matrix_* method > Identity.
if config.matrix_position:
position = config.matrix_position(props)
base_matrix = self.compose_gizmo_matrix(position, config.axis)
base_matrix = self.compose_gizmo_matrix(config.matrix_position(props), config.axis)
else:
matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None)
base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4)
@@ -4758,7 +5086,7 @@ class BaseParametricGizmoGroup:
"""
return (0.0, 0.0)
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float:
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002
"""Get Y offset for icons based on view direction.
Uses get_icon_y_extent() to determine how far to offset icons based on
@@ -4774,8 +5102,7 @@ class BaseParametricGizmoGroup:
props = self.get_props(obj)
positive_extent, negative_extent = self.get_icon_y_extent(props)
viewing_from_negative_y, _ = self.get_local_view_direction(context, mw)
if viewing_from_negative_y:
if self._frame_view_dir[0]:
return -negative_extent
return positive_extent
@@ -4783,34 +5110,40 @@ class BaseParametricGizmoGroup:
"""Update editing icon gizmo positions to billboard toward camera."""
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = get_billboard_rotation(context)
# This ensures icons face camera regardless of object rotation
local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z))
world_pos_validate = mw @ local_pos_validate
icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4)
billboard_rot = self._frame_billboard_rot
# set_icon_gizmo_position no-ops on hidden gizmos (via get_gizmo_if_visible),
# so the hide flag must be set first; that gates whether the matrix is written.
if props.is_editing:
self.pen_gizmo.hide = True
self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo)
self.validate_gizmo.matrix_basis = icon_matrix_base
self.set_icon_gizmo_position(
"validate_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo)
local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
world_pos_cancel = mw @ local_pos_cancel
self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4)
self.set_icon_gizmo_position(
"cancel_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self.ICON_CANCEL_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
)
if self.cycle_type_operator:
self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo)
local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z))
world_pos_cycle = mw @ local_pos_cycle
self.cycle_gizmo.matrix_basis = (
Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4)
self.set_icon_gizmo_position(
"cycle_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self.ICON_CYCLE_X,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=0.30,
)
else:
self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo)
self.pen_gizmo.matrix_basis = icon_matrix_base
self.set_icon_gizmo_position(
"pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot
)
self.validate_gizmo.hide = True
self.cancel_gizmo.hide = True
if self.cycle_type_operator:
@@ -4819,16 +5152,26 @@ class BaseParametricGizmoGroup:
def draw_prepare(self, context: bpy.types.Context) -> None:
"""Called before drawing - updates gizmos to face camera.
This method updates editing gizmos and dimension gizmos.
Subclasses can override _update_dimension_gizmo_positions() to customize
dimension gizmo positioning based on view direction.
This method updates editing gizmos, dimension gizmos, and element-specific
gizmos. Subclasses can override _update_dimension_gizmo_positions() to
customize dimension gizmo positioning, and _refresh_element_specific() to
re-billboard element-specific gizmos per frame.
"""
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
# `update_dimension_gizmos` flips the dimension gizmos' `hide` flag
# based on `props.is_editing` + per-config visibility conditions.
# `refresh()` already calls it, but `refresh()` only fires on depsgraph
# events — a `finish_editing_*` operator that toggles `is_editing` to
# False without mutating IFC (e.g. wall no-op commit, cancel) does not
# trigger a depsgraph update, so without this call the dimension gizmos
# would stay visible until the next user input.
self.update_dimension_gizmos(mw, props)
self._update_dimension_gizmo_positions(context, mw, props)
@@ -4836,6 +5179,8 @@ class BaseParametricGizmoGroup:
for _, gizmo in self.iter_visible_dimension_gizmos():
gizmo.draw_prepare(context)
self._refresh_element_specific(context, mw, props)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002
) -> None:
+24 -10
View File
@@ -15,11 +15,15 @@
#
# 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.
from typing import NamedTuple
import bpy
import bonsai.tool as tool
from . import (
array,
covering,
@@ -70,18 +74,32 @@ classes = (
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
wall.CycleWallOffset,
wall.DrawPolylineWall,
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
wall.GizmoWallAddOpening,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallJoinIntersection,
wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
wall.RecalculateWall,
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.ToggleWallOpenings,
wall.UnjoinWalls,
opening.AddBoolean,
opening.CloneOpening,
@@ -140,10 +158,12 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
ui.BIM_PT_sverchok,
ui.BIM_PT_window,
ui.BIM_PT_door,
@@ -264,12 +284,10 @@ def register():
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
tool.Parametric.register_object_properties(prop)
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
type=prop.BIMExternalParametricGeometryProperties
)
@@ -288,12 +306,8 @@ def unregister():
del bpy.types.Scene.BIMModelProperties
del bpy.types.Scene.BIMPolylineProperties
del bpy.types.Object.BIMArrayProperties
del bpy.types.Object.BIMStairProperties
del bpy.types.Object.BIMSverchokProperties
del bpy.types.Object.BIMWindowProperties
del bpy.types.Object.BIMDoorProperties
del bpy.types.Object.BIMRailingProperties
del bpy.types.Object.BIMRoofProperties
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
bpy.app.handlers.load_post.remove(handler.load_post)
+35 -79
View File
@@ -38,6 +38,7 @@ import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -566,103 +567,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class _DoorEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for door parametric-edit operators. Multi-object —
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
to every selected door at once."""
pset_name = "BBIM_Door"
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
return tool.Blender.get_selected_objects()
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_door_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_door_modifier_representation(obj)
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_door"
bl_label = "Cancel Editing Door on Selected Objects"
bl_description = "Cancel editing and revert door parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.cancel_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cancel_targets(context)
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_door"
bl_label = "Finish Editing Door on Selected Objects"
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
door_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
door_data["lining_properties"] = lining_props
door_data["panel_properties"] = panel_props
props.is_editing = False
update_door_modifier_representation(obj)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.finish_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._finish_targets(context)
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_door"
bl_label = "Enable Editing Door on Selected Objects"
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMDoorProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.edit_door_on_obj(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._enable_targets(context)
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
@@ -939,7 +895,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
prefs = tool.Blender.get_addon_preferences()
prefs = self.get_addon_prefs()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
+140
View File
@@ -15,6 +15,8 @@
#
# 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.
import math
from collections.abc import Callable
@@ -193,6 +195,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
_get_updater("stair", "regenerate_stair_mesh")(obj)
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
``offset`` itself has no ``update`` callback on purpose adding one would make
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
explicitly below)."""
obj = context.active_object
if not (obj and self.is_editing):
return
t = self.thickness
if self.desired_offset_baseline == "CENTER":
self.offset = -t / 2
elif self.desired_offset_baseline == "INTERIOR":
self.offset = -t
else: # EXTERIOR
self.offset = 0.0
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
@@ -1631,6 +1659,118 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
(preview only no IFC writes), and either committed by `bim.finish_editing_wall`
or discarded by `bim.cancel_editing_wall`.
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
current vs snap to skip unchanged params and guarantee a no-op session leaves the
IFC file byte-identical.
"""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while wall parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview box; cleared once the real "
"IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall length along its reference axis (preview value; committed on finish).",
)
height: bpy.props.FloatProperty(
name="Height",
default=3.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall vertical height (preview value; committed on finish).",
)
x_angle: bpy.props.FloatProperty(
name="Slope (X Angle)",
default=0.0,
soft_min=-math.pi / 3,
soft_max=math.pi / 3,
subtype="ANGLE",
update=update_wall,
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
)
thickness: bpy.props.FloatProperty(
name="Thickness",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
)
offset: bpy.props.FloatProperty(
name="Offset",
default=0.0,
subtype="DISTANCE",
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
)
desired_offset_baseline: bpy.props.EnumProperty(
items=[
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
("CENTER", "Center", "Reference axis at the wall centreline"),
("INTERIOR", "Interior", "Reference axis at the interior face"),
],
name="Desired Offset Baseline",
default="CENTER",
update=update_wall_offset_baseline,
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
)
anchor_x: bpy.props.FloatProperty(
default=0.0,
subtype="DISTANCE",
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
)
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
snap_thickness: bpy.props.FloatProperty(
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
)
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
snap_x_angle: bpy.props.FloatProperty(
subtype="ANGLE",
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
)
snap_offset_baseline: bpy.props.StringProperty(
default="",
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
height: float
x_angle: float
thickness: float
offset: float
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
anchor_x: float
snap_length: float
snap_height: float
snap_thickness: float
snap_offset: float
snap_x_angle: float
snap_offset_baseline: str
class SnapMousePoint(PropertyGroup):
x: bpy.props.FloatProperty(name="X")
y: bpy.props.FloatProperty(name="Y")
+47 -47
View File
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -406,66 +407,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER"}
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_railing_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
pset_name = "BBIM_Railing"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_railing_props(obj)
@classmethod
def _post_load_data(cls, data: dict) -> dict:
# BIMRailingProperties.path_data is a StringProperty holding JSON.
data["path_data"] = json.dumps(data["path_data"])
return data
# required since we could load pset from .ifc and BIMRailingProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_railing_pset(element, data)
props.is_editing = True
return {"FINISHED"}
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_ifc_data(context)
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER"}
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
props = tool.Model.get_railing_props(obj)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER"}
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_railing_props(obj)
return self._enable_targets(context)
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
path_data = pset_data["data_dict"]["path_data"]
railing_data = props.get_general_kwargs(convert_to_project_units=True)
railing_data["path_data"] = path_data
props.is_editing = False
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER", "UNDO"}
update_bbim_railing_pset(element, railing_data)
update_railing_modifier_ifc_data(context)
return {"FINISHED"}
def _execute(self, context):
return self._cancel_targets(context)
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return self._finish_targets(context)
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
+41 -42
View File
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -608,61 +609,59 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.add_body_representation(obj)
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER"}
class _RoofEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for roof parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_roof_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
pset_name = "BBIM_Roof"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_roof(element)
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER"}
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_roof_props(obj)
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
props = tool.Model.get_roof_props(obj)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_roof_pset(element, data)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER"}
class EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
props = tool.Model.get_roof_props(obj)
return self._enable_targets(context)
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
path_data = pset_data["data_dict"]["path_data"]
roof_data = props.get_general_kwargs(convert_to_project_units=True)
roof_data["path_data"] = path_data
props.is_editing = False
class CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER", "UNDO"}
update_bbim_roof_pset(element, roof_data)
update_roof_modifier_ifc_data(context)
return {"FINISHED"}
def _execute(self, context):
return self._cancel_targets(context)
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return self._finish_targets(context)
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
+16 -20
View File
@@ -15,6 +15,8 @@
#
# 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.
import json
@@ -262,7 +264,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
@@ -272,6 +273,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# update IfcStairFlight properties
update_ifc_stair_props(obj)
props.is_editing = False
return {"FINISHED"}
@@ -608,29 +610,23 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update stair-specific lock and tread count gizmos."""
billboard_rot = gizmo.get_billboard_rotation(context)
self.update_lock_gizmo(mw, props, billboard_rot)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
"""Update lock gizmo visibility, color, and position."""
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled
per-frame by the dimension-positioning hook."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip positioning
return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
total_run = props.get_total_run()
local_transform = (
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
)
self.lock_gizmo.matrix_basis = mw @ local_transform
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
@@ -650,11 +646,11 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
billboard_rot = gizmo.get_billboard_rotation(context)
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
billboard_rot = self._frame_billboard_rot
total_run = props.get_total_run()
riser_height = props.get_riser_height()
+30
View File
@@ -338,6 +338,36 @@ class BIM_PT_stair(bpy.types.Panel):
row.operator("bim.add_stair", icon="ADD", text="")
class BIM_PT_wall(bpy.types.Panel):
bl_label = "Wall"
bl_idname = "BIM_PT_wall"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_parametric_geometry"
@classmethod
def poll(cls, context):
obj = context.active_object
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Blender.Modifier.is_wall(element)
def draw(self, context):
obj = context.active_object
if obj is None:
return
props = tool.Model.get_wall_props(obj)
row = self.layout.row(align=True)
if props.is_editing:
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
else:
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
class BIM_PT_sverchok(bpy.types.Panel):
bl_label = "Sverchok"
bl_idname = "BIM_PT_sverchok"
File diff suppressed because it is too large Load Diff
+29 -65
View File
@@ -39,6 +39,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -482,90 +483,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class _WindowEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for window parametric-edit operators. Single-object
by design (window edits target the active object only)."""
pset_name = "BBIM_Window"
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_window_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_window_modifier_representation(context)
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props = tool.Model.get_window_props(obj)
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
return {"FINISHED"}
return self._cancel_targets(context)
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_window_props(obj)
window_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
window_data["lining_properties"] = lining_props
window_data["panel_properties"] = panel_props
props.is_editing = False
update_window_modifier_representation(context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
return {"FINISHED"}
return self._finish_targets(context)
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_window_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMWindowProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
return self._enable_targets(context)
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
@@ -15,6 +15,8 @@
#
# 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.
import datetime
import json
@@ -1903,11 +1905,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
if (filepath := props.ifc_file) and not self.should_save_as:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
return ExportHelper.invoke(self, context, event)
filepath = props.ifc_file
if not filepath or self.should_save_as:
return ExportHelper.invoke(self, context, event)
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
@@ -1933,6 +1935,16 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
if failed_commits:
names = ", ".join(o.name for o in failed_commits)
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
path_log = tool.Blender.get_data_dir_path("process.log")
@@ -2001,7 +2013,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
blendmetadata_path = output_file + suffix
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
)
except Exception as e:
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
@@ -2011,7 +2023,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
)
bonsai.bim.handler.refresh_ui_data()
@@ -0,0 +1,297 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Shared Enable / Finish / Cancel lifecycle mixins for parametric-edit operators.
Two mixins fit the parametric-edit triads in ``bim/module/model/``:
`FeatureModifierEditMixin`
Door, Window BBIM_<Type> pset with nested ``lining_properties`` /
``panel_properties``; Finish calls ``update_<type>_modifier_representation``
via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``.
`PathPreservingEditMixin`
Railing, Roof BBIM_<Type> pset whose ``path_data`` is preserved through
edit (only general kwargs are user-editable); Finish calls
``update_<type>_modifier_bmesh`` / ``update_<type>_modifier_ifc_data``;
Cancel re-reads the pset and rebuilds the bmesh preview.
Stair and Wall stay standalone their lifecycles diverge in ways that don't
fit either mixin without optional escape hatches (Stair has a unique
``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``;
Wall is validation-first, snapshot-driven, no preview regen in operators).
This module sits separately from `bonsai.tool.Parametric` (the registry +
auto-commit) because it imports ``bonsai.tool`` freely, while the registry
itself must stay light ``tool/blender.py`` consumes the registry at module load."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, ClassVar
import bpy
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class _ParametricEditMixinBase:
"""Common scaffolding for parametric edit-triad mixins.
Each per-type subclass provides four hooks:
``pset_name``: BBIM_<Type> pset identifier
``_is_element_type(element)``: IFC element predicate
``_get_props(obj)``: PropertyGroup accessor
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` from their ``_execute`` method."""
pset_name: ClassVar[str]
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
obj = context.active_object
return [obj] if obj else []
@classmethod
def _is_element_type(cls, element: entity_instance) -> bool:
raise NotImplementedError
@classmethod
def _get_props(cls, obj: bpy.types.Object):
raise NotImplementedError
@classmethod
def _resolve(cls, obj: bpy.types.Object):
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
Common predicate guard for every lifecycle method collapses the
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
triplet into one call."""
element = tool.Ifc.get_entity(obj)
if not element or not cls._is_element_type(element):
return None
return element, cls._get_props(obj)
class FeatureModifierEditMixin(_ParametricEditMixinBase):
"""Lifecycle for door- and window-style parametric modifier operators.
Enable:
Read BBIM_<Type> pset JSON unwrap ``lining_properties`` and
``panel_properties`` merge constituents data set draft props
``is_editing = True``.
Finish:
Gather ``general / lining / panel`` kwargs (project units) nest
``is_editing = False`` call ``_update_modifier_representation``
mark thumbnail write back to BBIM_<Type> pset via
``ifcopenshell.api.pset.edit_pset``.
Cancel:
Read BBIM_<Type> pset JSON unwrap restore draft props
``switch_representation`` to the Body representation
``is_editing = False``."""
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: call the per-type ``update_<type>_modifier_representation``.
Door's helper takes ``obj``; window's takes ``context``. The hook lets
each subclass forward to its existing helper without unifying signatures."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since the pset can be loaded from .ifc and the PropertyGroup
# would otherwise still hold its default values
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = props.get_general_kwargs(convert_to_project_units=True)
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
cls._update_modifier_representation(obj, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, cls.pset_name)
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data_text})
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj)
return {"FINISHED"}
class PathPreservingEditMixin(_ParametricEditMixinBase):
"""Lifecycle for railing- and roof-style parametric modifier operators.
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
user-editable through this triad it survives the edit untouched, only
general kwargs are diffed. (Path editing has its own separate operator
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set
draft props ``is_editing = True``. The subclass post-load hook
lets railing JSON-serialise ``path_data`` for the PropertyGroup
string field.
Finish:
Read fresh pset keep ``path_data`` gather ``general`` kwargs
(project units) reassemble ``is_editing = False`` call
``_update_pset`` (per-type pset writer) call ``_update_modifier_ifc_data``
(per-type geometry commit).
Cancel:
Read fresh pset restore draft props call
``_update_modifier_bmesh`` (per-type bmesh preview)
``is_editing = False``."""
@classmethod
def _post_load_data(cls, data: dict) -> dict:
"""Hook: optionally transform the pset data dict after loading and before
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
Railing overrides to JSON-serialise ``path_data`` (its
BIMRailingProperties.path_data is a ``StringProperty`` holding JSON)."""
return data
@classmethod
def _update_pset(cls, element: entity_instance, data: dict) -> None:
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
raise NotImplementedError
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
modified geometry to IFC. Signature accepts ``(obj, context)`` so
subclasses can forward either argument to their existing helper."""
raise NotImplementedError
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_bmesh`` — rebuilds the
bmesh preview to match the current draft props (used by Cancel)."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
path_data = pset_data["data_dict"]["path_data"]
data = props.get_general_kwargs(convert_to_project_units=True)
data["path_data"] = path_data
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
cls._update_modifier_bmesh(obj, context)
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj, context)
return {"FINISHED"}
+112 -31
View File
@@ -15,6 +15,8 @@
#
# 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.
import os
import platform
@@ -380,6 +382,76 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup):
cycle: bool
class GizmoPreferencesWall(bpy.types.PropertyGroup):
"""Property group for wall gizmo visibility settings."""
length: BoolProperty(
name="Length",
default=True,
description="Show the length dimension gizmo along the wall axis.",
)
height: BoolProperty(
name="Height",
default=True,
description="Show the height dimension gizmo at the wall's start endpoint.",
)
height_end: BoolProperty(
name="Height (far end, walls > 5m)",
default=True,
description=(
"Show a second height gizmo at the wall's far end so long walls don't "
"require panning to reach the handle."
),
)
x_angle: BoolProperty(
name="Slope",
default=True,
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
)
cycle: BoolProperty(
name="Cycle Offset Baseline",
default=True,
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
)
scissors: BoolProperty(
name="Split at cursor",
default=True,
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
)
extend: BoolProperty(
name="Extend length to cursor X",
default=True,
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
)
extend_height: BoolProperty(
name="Extend height to cursor Z",
default=True,
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
)
rotate: BoolProperty(
name="Rotate 90°",
default=True,
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
)
toggle_openings: BoolProperty(
name="Toggle Openings",
default=True,
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
)
if TYPE_CHECKING:
length: bool
height: bool
height_end: bool
x_angle: bool
cycle: bool
scissors: bool
extend: bool
extend_height: bool
rotate: bool
toggle_openings: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
@@ -391,12 +463,14 @@ class GizmoPreferences(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
wall: GizmoPreferencesWall
class DocPreferences(bpy.types.PropertyGroup):
@@ -849,49 +923,56 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
def _draw_parametric_gizmo_parameters(
self,
layout: bpy.types.UILayout,
gizmo_pg: bpy.types.PropertyGroup,
dimension_gizmo_class: type,
special_gizmo_names: frozenset[str] = frozenset(),
) -> None:
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
like baseline cycle, scissors, rotate, )."""
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
try:
annotations = gizmo_pg.__annotations__
except AttributeError:
annotations = type(gizmo_pg).__annotations__
for prop in annotations:
if prop in visible_names:
layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
door_gizmos = self.gizmos.door
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc"))
try:
annotations = door_gizmos.__annotations__
except AttributeError:
annotations = type(door_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(door_gizmos, prop)
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
try:
annotations = window_gizmos.__annotations__
except AttributeError:
annotations = type(window_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop)
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
stair_gizmos = self.gizmos.stair
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
try:
annotations = stair_gizmos.__annotations__
except AttributeError:
annotations = type(stair_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop)
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
)
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.wall import GizmoWallEdition
self._draw_parametric_gizmo_parameters(
layout,
self.gizmos.wall,
GizmoWallEdition,
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
+141
View File
@@ -15,9 +15,12 @@
#
# 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.
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Literal, Optional
if TYPE_CHECKING:
@@ -173,3 +176,141 @@ class RequireAtLeastTwoElements(Exception):
class RequireLayeredElement(Exception):
pass
# --- Wall geometry math (pure) ------------------------------------------------
# Tuple in / tuple out so these helpers run under ``pytest test/core/`` without
# ``bpy`` or ``mathutils``. Callers convert ``mathutils.Vector`` at the boundary.
def baseline_from_offset(offset: float, thickness: float, tolerance: float = 0.001) -> str:
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
Mirrors the math in ``tool.Model.offset_wall`` for both POSITIVE and NEGATIVE
direction_sense walls. Returns the closest canonical baseline; falls back to
``"CENTER"`` when nothing is within ``tolerance``."""
candidates = (
("EXTERIOR", 0.0),
("CENTER", -thickness / 2),
("INTERIOR", -thickness),
("EXTERIOR", thickness),
("CENTER", thickness / 2),
("INTERIOR", 0.0),
)
best = min(candidates, key=lambda c: abs(offset - c[1]))
return best[0] if abs(offset - best[1]) < tolerance else "CENTER"
def project_axis_intersection(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float,
) -> Optional[tuple[float, float, float]]:
"""Compute the 2D (X,Y plane) intersection of two world-space axis segments.
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
(Z is the average of the four input Zs, for visual placement) or ``None`` if
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
threshold e.g. ``cos(2°) 0.9994`` treats walls within 2° of parallel as parallel)."""
p1, p2 = seg_a
p3, p4 = seg_b
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
d2x, d2y = p4[0] - p3[0], p4[1] - p3[1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return None
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
if abs(dot) >= parallel_threshold:
return None
denom = d1x * d2y - d1y * d2x
if abs(denom) < 1e-9:
return None
t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
ix = p1[0] + t * d1x
iy = p1[1] + t * d1y
iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4
return (ix, iy, iz)
def displacement_from_x_angle(height: float, x_angle: float) -> float:
"""Top-edge horizontal displacement for a wall of given vertical ``height`` and
slope ``x_angle`` (radians). Drives the slope dimension gizmo's display value.
Inverse of :func:`x_angle_from_displacement`."""
return height * math.tan(x_angle)
def x_angle_from_displacement(height: float, displacement: float) -> float:
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
``height`` is clamped to ``max(height, 1e-6)`` so vertical walls of effectively
zero height map cleanly to ``±π/2`` via ``atan2`` rather than dividing by zero.
Inverse of :func:`displacement_from_x_angle`."""
return math.atan2(displacement, max(height, 1e-6))
def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float:
"""Vertical height of a wall given its slanted extrusion depth and slope.
``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion
direction. The vertical height the user thinks of is ``depth * cos(x_angle)``.
Unit-agnostic: the result is in the same units as ``extrusion_depth``."""
return extrusion_depth * abs(math.cos(x_angle))
def are_axes_collinear(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float = 0.9994,
line_tolerance: float = 0.05,
) -> bool:
"""True if both axis segments lie on the same infinite line in plan.
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``
(``cos(2°) 0.9994``), AND any endpoint of B must lie on A's infinite line
within ``line_tolerance``. Plan-only (Z ignored) two parallel walls at
different elevations are still considered collinear because the merge operator
handles Z resolution itself.
Used by the wall-join gizmo's state machine: collinear pair → Merge icon at the
boundary, perpendicular pair Join icon at the intersection."""
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return False
if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold:
return False
# Project seg_b[0] onto the infinite line through seg_a; the perpendicular
# distance to the original point tells us how far off the line B sits.
nx, ny = d1x / d1_len, d1y / d1_len
dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1]
t = dx * nx + dy * ny
proj_x = seg_a[0][0] + nx * t
proj_y = seg_a[0][1] + ny * t
perp_x = seg_b[0][0] - proj_x
perp_y = seg_b[0][1] - proj_y
return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance
def closest_endpoint_midpoint(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
) -> tuple[float, float, float]:
"""Midpoint of the closest pair of endpoints between two segments.
For walls that meet end-to-end this is the shared corner; for walls with a
small gap it's the midpoint of the gap. Either way it's the user-meaningful
"boundary" where a merge would graft the two segments together."""
endpoints_a = (seg_a[0], seg_a[1])
endpoints_b = (seg_b[0], seg_b[1])
def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float:
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
a, b = closest_pair
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
+6
View File
@@ -776,6 +776,12 @@ class Profile:
def get_profile(cls, element): pass
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls) -> None: pass
@interface
class Pset:
def add_proposed_property(cls, name, value, props): pass
+1
View File
@@ -51,6 +51,7 @@ from bonsai.tool.misc import Misc
from bonsai.tool.model import Model
from bonsai.tool.nest import Nest
from bonsai.tool.owner import Owner
from bonsai.tool.parametric import Parametric
from bonsai.tool.patch import Patch
from bonsai.tool.polyline import Polyline
from bonsai.tool.profile import Profile
+27 -47
View File
@@ -15,6 +15,8 @@
#
# 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.
from __future__ import annotations
@@ -55,12 +57,12 @@ from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from sun_position.properties import SunPosProperties
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import (
BIMConstraintProperties,
@@ -1137,20 +1139,18 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# roof and railing both finalize then drop into path-edit mode — handle
# them before the generic finish dispatch so the path transition runs.
if cls.is_roof(element):
if cls.is_editing_roof_parameters(obj):
bpy.ops.bim.finish_editing_roof()
if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
bpy.ops.bim.enable_editing_roof_path()
elif cls.is_railing(element):
if cls.is_editing_railing_parameters(obj):
bpy.ops.bim.finish_editing_railing()
if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
bpy.ops.bim.enable_editing_railing_path()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.finish_editing_stair()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.finish_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.finish_editing_window()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
else:
return False
return True
@@ -1161,20 +1161,13 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# Path-edit modes are distinct from parametric draft modes; handle them first.
if cls.is_editing_railing_path(obj):
bpy.ops.bim.cancel_editing_railing_path()
elif cls.is_editing_roof_path(obj):
bpy.ops.bim.cancel_editing_roof_path()
elif cls.is_editing_railing_parameters(obj):
bpy.ops.bim.cancel_editing_railing()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.cancel_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.cancel_editing_window()
elif cls.is_editing_roof_parameters(obj):
bpy.ops.bim.cancel_editing_roof()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.cancel_editing_stair()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.cancel_op)
else:
return False
return True
@@ -1221,6 +1214,17 @@ class Blender(bonsai.core.tool.Blender):
def is_stair(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Stair")
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object):
props = tool.Model.get_railing_props(obj)
@@ -1231,34 +1235,10 @@ class Blender(bonsai.core.tool.Blender):
props = tool.Model.get_roof_props(obj)
return props.is_editing_path
@classmethod
def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing
@classmethod
def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_roof_props(obj)
return props.is_editing
@classmethod
def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_window_props(obj)
return props.is_editing
@classmethod
def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_door_props(obj)
return props.is_editing
@classmethod
def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_stair_props(obj)
return props.is_editing
@classmethod
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element)
feature = tool.Parametric.find_for_element(element)
return bool(feature and feature.has_non_editable_path)
class Array:
@classmethod
+7
View File
@@ -15,6 +15,8 @@
#
# 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.
from __future__ import annotations
@@ -77,6 +79,7 @@ if TYPE_CHECKING:
BIMRoofProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
BIMWindowProperties,
)
@@ -98,6 +101,10 @@ class Model(bonsai.core.tool.Model):
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties:
return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
+460
View File
@@ -0,0 +1,460 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Registry + save-time auto-commit for parametric draft edits.
Single source of truth: adding a new parametric element type is one entry in
`Parametric.EDIT_TYPES`. Every consumer save-time auto-commit, the
finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty``
attachment in ``bim/module/model/__init__.py``, and the per-type
``GizmoPreferences<X>`` registration in ``bim/__init__.py`` derives the
class names, operator ``bl_idname``s, and predicates from the registry entry's
short ``name`` token.
Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/``
modules can consume it without crossing the layer boundary. The orchestration
helpers (``commit_object_draft``, ``commit_pending_edits``) call
``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender
rather than a Python import of ``bim/``.
----------------------------------------------------------------------
How to add a new parametric object
----------------------------------------------------------------------
End-to-end walkthrough for wiring a new IFC element type (e.g. ``IfcSlab``)
into the gizmo-driven parametric edit framework. Numbered steps are
**required** unless flagged OPTIONAL. Keep this section in sync with the
implementation files it references if a step's example code stops matching
the real registration site, the step is out of date.
STEP 1 Add the registry entry (this file)
Append to `Parametric.EDIT_TYPES`::
ParametricObject("slab", has_non_editable_path=False),
The ``name`` token drives every derived identifier:
``BIMSlabProperties``, ``bim.enable_editing_slab`` /
``bim.finish_editing_slab`` / ``bim.cancel_editing_slab``, and the
``slab`` field on ``GizmoPreferences``. Set ``has_non_editable_path=True``
if the modifier exposes no user-editable path (cf. door, window, stair).
STEP 2 Define the ``PropertyGroup`` (``bim/module/model/prop.py``)
Class name **must** be ``BIM<Name>Properties`` capitalisation matches
`ParametricObject.props_attr`::
class BIMSlabProperties(bpy.types.PropertyGroup):
is_editing: BoolProperty(...)
# ... per-type draft fields, snapshots, mesh_dirty, etc. ...
The ``is_editing`` flag is the single field every consumer of the registry
expects.
STEP 3 Register the PropertyGroup class
Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near
the existing ``prop.BIM<X>Properties`` entries). The
``bpy.types.Object.BIMSlabProperties`` attachment is automatic
`Parametric.register_object_properties` loops the registry.
STEP 4 Implement the Enable / Finish / Cancel triad
In ``bim/module/model/slab.py``, define three ``bpy.types.Operator``
subclasses with the canonical ``bl_idname``\\s:
- ``EnableEditingSlab`` ``bl_idname = "bim.enable_editing_slab"``
- ``FinishEditingSlab`` ``bl_idname = "bim.finish_editing_slab"``
- ``CancelEditingSlab`` ``bl_idname = "bim.cancel_editing_slab"``
**First, check if your new type fits one of the existing lifecycle
shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit
the matching mixin and the triad collapses to ~25 lines total:
- ``FeatureModifierEditMixin`` BBIM_<Type> pset with nested
``lining_properties`` / ``panel_properties``; Finish via
``update_<type>_modifier_representation``
``ifcopenshell.api.feature``; Cancel via
``switch_representation`` to the Body rep. Reference samples:
door (multi-object) and window (single-object).
- ``PathPreservingEditMixin`` BBIM_<Type> pset whose ``path_data``
is preserved through edit; Finish via per-type
``update_bbim_<type>_pset`` + ``update_<type>_modifier_ifc_data``;
Cancel rebuilds the bmesh preview. Reference samples: railing, roof.
If neither shape fits (the type needs validation-first lifecycle, an
explicit snapshot, delegate-to-sub-operators Finish, or a unique
post-Finish step) implement the triad standalone see ``wall.py``
(validation/snapshot/delegate) or ``stair.py`` (raw pset JSON +
``update_ifc_stair_props``) as references. Register all three in the
module's ``classes`` tuple.
STEP 5 Implement the gizmo group (same file)
Subclass ``BaseParametricGizmoGroup`` from
``bim/module/drawing/gizmos.py``::
class GizmoSlabEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_slab_edition"
@classmethod
def is_element_type(cls, element):
return tool.Blender.Modifier.is_slab(element)
dimension_gizmo_props = [DimensionGizmoConfig(...)]
Register it in the ``classes`` tuple. The classmethod makes
``tool.Blender.Modifier.is_slab(element)`` testable via the gizmo's
``poll()``.
STEP 6 Add the element-type predicate (``tool/blender.py``)
Inside the ``Blender.Modifier`` class, alongside ``is_door`` / ``is_wall``::
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Slab")
The method name **must** be ``is_<name>`` to match
`ParametricObject.name` `Parametric.find_for_element`
looks it up by string.
STEP 7 OPTIONAL: typed property accessor (``tool/model.py``)
Convenience helper for call sites that statically know the IFC type::
@classmethod
def get_slab_props(cls, obj) -> BIMSlabProperties:
return obj.BIMSlabProperties
Call sites that work generically (registry-driven) can use
``getattr(obj, feature.props_attr)`` directly and skip this step.
STEP 8 OPTIONAL: gizmo visibility preferences (``bim/ui.py``)
For per-gizmo show/hide toggles, define::
class GizmoPreferencesSlab(bpy.types.PropertyGroup):
length: BoolProperty(name="Length", default=True, ...)
# ... one BoolProperty per gizmo ...
Then add a matching field on ``GizmoPreferences``::
slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab)
Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in
``bim/__init__.py`` the registry-driven discovery in this module finds
it by name (``GizmoPreferences`` + capitalised registry token) and
registers it automatically.
STEP 9 OPTIONAL: pure geometry helpers (``core/model.py``)
Per-type math (collinearity checks, slope/displacement conversions,
intersection helpers) lives here. The hard rule: no ``bpy`` /
``ifcopenshell`` imports at module load wrap them in
``if TYPE_CHECKING:`` blocks only. Lets the helpers be unit-tested
headless via ``pytest test/core/``.
STEP 10 Verify
From ``src/bonsai/``::
ruff check .
black --check .
pytest test/core/ -x -q
blender -b -P runpytest.py -- test/bim/ -x -q -m model
The Blender-backed lane runs a registry smoke test that iterates the
EDIT_TYPES list and asserts each entry's enable/finish/cancel operator
resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object``
carries the matching ``BIM<Name>Properties`` attribute, and that the
``is_<name>`` predicate exists on ``tool.Blender.Modifier``. Forget any
of the steps above and that test fails with a precise pointer at
what's missing.
Then manually in Blender:
1. Enable Bonsai create an instance of the new IFC type.
2. Run ``bim.enable_editing_<name>`` confirm the gizmo group polls in
and the dimension handles appear.
3. Modify a draft field, save the file confirm auto-commit fires
(watch the console for the ``parametric_commit`` log line).
4. Disable + re-enable the addon no ``bpy_struct: unknown property
type`` errors in the console (validates the register/unregister
symmetry driven by the registry)."""
from __future__ import annotations
import re
import traceback
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import bpy
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
# ``name`` must be a single ASCII lowercase token starting with a letter:
# ``str.capitalize()`` only handles single-word names cleanly, so a compound
# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` —
# off the Bonsai naming convention and silently broken.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$")
@dataclass(frozen=True)
class ParametricObject:
"""One parametric element type's draft + enable + finish + cancel triad.
The short ``name`` token ("door", "window", "stair", "railing", "roof",
"wall", ) drives every derived identifier: the ``BIM<Name>Properties``
attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` /
``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` operator
``bl_idname``s. The ``name`` is validated at construction time a
multi-word IFC type would silently mis-derive through
``str.capitalize()`` and breaks the single-token assumption.
``has_non_editable_path`` flags element types whose modifier exposes no
user-editable path (door, window, stair).
The paired runtime predicate ``tool.Blender.Modifier.is_<name>(element)``
is part of the registry contract: it MUST be **total** accept any
IFC entity and return a boolean, never raise. The registry iterates
every predicate against the active element on save; a raising predicate
propagates upward and breaks the save path for *all* parametric types,
not just its own."""
name: str
has_non_editable_path: bool = False
def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name):
raise ValueError(
f"ParametricObject name {self.name!r} must be a single ASCII lowercase "
f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only "
f"handles single-word names — compound IFC types need an explicit "
f"naming override (not yet supported)."
)
@property
def props_attr(self) -> str:
return f"BIM{self.name.capitalize()}Properties"
@property
def enable_op(self) -> str:
return f"bim.enable_editing_{self.name}"
@property
def finish_op(self) -> str:
return f"bim.finish_editing_{self.name}"
@property
def cancel_op(self) -> str:
return f"bim.cancel_editing_{self.name}"
def is_editing(self, obj: bpy.types.Object) -> bool:
props = getattr(obj, self.props_attr, None)
return bool(props and getattr(props, "is_editing", False))
class Parametric(bonsai.core.tool.Parametric):
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True),
ParametricObject("window", has_non_editable_path=True),
ParametricObject("stair", has_non_editable_path=True),
ParametricObject("railing"),
ParametricObject("roof"),
ParametricObject("wall"),
]
_geom_generation: int = 0
@classmethod
def get_geom_generation(cls) -> int:
return cls._geom_generation
@classmethod
def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
``BIMModelProperties`` (workspace tool header H/L/A fields) from current
IFC state and bumps the geometry generation counter so per-gizmo-group
caches keyed off it drop their stale entries on the next draw.
Why this exists: ``update_bim_tool_props`` was historically only wired
to the active-object msgbus, so in-place IFC mutations on the current
selection (S_E, C_E, change_extrusion_*, ) left the header showing
stale values until the user changed selection. Same shape of bug for
the wall gizmo cache: ``GizmoGroup.refresh()`` only fires on Blender's
own state-change events, not on every ``bpy.ops.bim.*`` mutation.
Cheap when nothing parametric is active ``update_bim_tool_props``
early-returns when no Bonsai workspace tool is selected or the active
object isn't an IFC element."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
cls._geom_generation += 1
bonsai.bim.handler.update_bim_tool_props()
screen = getattr(bpy.context, "screen", None)
if screen is not None:
for area in screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
@classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
@classmethod
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
"""Return the registry entry whose IFC type predicate matches ``element``.
The per-type predicate lives at ``tool.Blender.Modifier.is_<name>``;
resolved here by attribute lookup at call time, which avoids a
``tool.parametric`` ``tool.blender`` import cycle."""
for feature in cls.EDIT_TYPES:
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
if predicate is not None and predicate(element):
return feature
return None
@classmethod
def is_object_editing(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
for feature in cls.EDIT_TYPES:
if feature.is_editing(obj):
return feature
return None
@classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object with
an in-progress parametric draft. The first registry match per object wins."""
return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))]
@classmethod
def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by its ``bl_idname``.
Constraint enforced via ``assert``: the operator MUST be a
``tool.Ifc.Operator`` subclass its transaction wrap is what
makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation
of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's
transaction system."""
verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
assert op_cls is not None and issubclass(
op_cls, tool.Ifc.Operator
), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
getattr(bpy.ops.bim, verb)()
@classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
"""Run ``finish_op`` scoped to ``obj`` alone. Returns True on success, False if
the operator raised (with traceback printed to the console).
Both ``temp_override`` and ``view_layer.objects.active`` are set:
``temp_override`` does not rebind ``objects.active``, and some finish
operators read it directly."""
view_layer = bpy.context.view_layer
original_active = view_layer.objects.active
try:
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
view_layer.objects.active = obj
try:
cls.run_bim_op(finish_op)
return True
except Exception as e:
print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}")
traceback.print_exc()
return False
finally:
view_layer.objects.active = original_active
@classmethod
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
"""Run each pending draft's finish operator scoped to its object.
A per-object failure does not abort the loop remaining drafts still
flush, otherwise the auto-commit would ship the exact silent-desync
it exists to prevent.
Each finish op wraps its own IFC transaction, so N pending drafts
produce N+1 undo entries (one per commit, plus the save). Ctrl+Z
walks back through commits individually intentional, each commit
is reversible on its own."""
committed = 0
failed: list[bpy.types.Object] = []
for obj, finish_op in cls.get_pending_edits():
if cls.commit_object_draft(obj, finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant of `commit_pending_edits`. ``names``
filters which registry entries to consider e.g. ``("wall",)`` to commit
only wall drafts among selected objects; ``None`` considers every type.
Used by multi-object operators (``bim.unjoin_walls``, ``bim.merge_wall``,
``bim.extend_walls_to_wall`` etc.) that must run against committed IFC
state running them with a wall whose draft hasn't been flushed leaves
stale gizmos pointing at obsolete IFC numbers."""
committed = 0
failed: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects():
feature = cls.is_object_editing(obj)
if feature is None:
continue
if names is not None and feature.name not in names:
continue
if cls.commit_object_draft(obj, feature.finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def register_object_properties(cls, prop_module) -> None:
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
parametric type, looking up the matching ``PropertyGroup`` class on
``prop_module``. Skips entries whose ``PropertyGroup`` class is absent."""
for feature in cls.EDIT_TYPES:
prop_cls = getattr(prop_module, feature.props_attr, None)
if prop_cls is None:
continue
setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
@classmethod
def unregister_object_properties(cls) -> None:
for feature in cls.EDIT_TYPES:
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
@classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry. Order matches `EDIT_TYPES`. Used by
``bim/__init__.py`` to inject the per-type ``GizmoPreferences<X>``
classes at the correct point before ``ui.GizmoPreferences``, which
references them via ``PointerProperty``."""
out: list[type] = []
for feature in cls.EDIT_TYPES:
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
if gpref is not None:
out.append(gpref)
return out
@@ -51,6 +51,62 @@ To use these tools:
2. Use the appropriate shortcut or select the tool from the top bar.
3. Follow the on-screen prompts or adjust parameters as needed.
Interactive Parametric Editing
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Selected walls expose an in-viewport parametric edit mode that mirrors the door /
window / stair pen-icon UI:
1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the
3D viewport, and a matching ``Edit Wall`` button is available in the
``Parametric Geometry`` tab of the N panel.
2. Click the pen icon (or the panel button) to enter edit mode. Dimension
gizmos for length, height, slope (x-angle) and the layer offset baseline
appear around the wall.
3. Drag any handle to update the value. Dragging only modifies the in-progress
draft — the IFC file is not touched until you commit, so dragging a length
handle through many intermediate values produces zero extra IFC entities.
4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the
✓ icon on a wall that hasn't been dragged is a true byte-identical no-op —
the IFC file is unchanged.
While editing, additional gizmos surface based on context:
- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline →
Interior). Shift+click cycles in reverse.
- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis;
clicking splits the wall at the cursor's projected X.
- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the
wall axis; clicking extends the wall to the cursor's projected X.
- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above /
below the wall; clicking extends the wall's height to the cursor's Z.
- **Rotate 90°**: rotates the wall around its Z axis.
- **Show / hide openings**: toggles opening fill visibility (doors and windows).
When two walls are selected, the gizmo switches to a state-aware icon at their
common point:
- Already joined → an Unjoin icon at the shared corner.
- Collinear (same axis line) → a Merge icon at the boundary midpoint.
- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the
active wall's top.
When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon
appears at the wall's origin / slab elevation; clicking dispatches
``bim.extend_walls_to_underside``.
When a wall and a non-wall, non-slab object are selected, an Add-Opening icon
appears above the wall at the other object's projected X.
Auto-commit on save
~~~~~~~~~~~~~~~~~~~
Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit
flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓
icon performs, scoped per wall. The IFC saved on disk reflects the values the
user dragged, not the snapshot taken when edit mode was entered. Each commit
produces its own undo entry, so Ctrl+Z walks back through commits individually.
Aligning Walls
^^^^^^^^^^^^^^
+31 -3
View File
@@ -17,18 +17,46 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Requires pytest installed under blender
Requires pytest installed under blender.
Usage: `blender -b -P runpytest.py -- ARGS`
Usage:
blender -b -P runpytest.py -- ARGS
Alternative (when the calling shell strips or reorders the ``--`` separator
before it reaches Blender observed with some PowerShell / wrapper-script
invocations on Windows): pass the same pytest args via the
``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string
and invoke without ``--``::
$env:BONSAI_TEST_ARGS = "test/bim/ -x -q"
blender -b -P runpytest.py
"""
import os
import shlex
import sys
import pytest
argv = [__file__]
if "--" in sys.argv:
env_args = os.environ.get("BONSAI_TEST_ARGS", "")
if env_args:
# POSIX-style quoting works on all three OSes — env var values are
# literal strings (no shell evaluation when Python reads them), and
# POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped)
# matches what most docs and examples use.
argv += shlex.split(env_args)
# On the env-var path the args never appear in Blender's argv at all,
# so any pytest plugin that reads ``sys.argv`` directly (instead of
# going through pytest's API) would otherwise see only Blender's own
# ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv
# so those plugins see the pytest-shaped view they expect.
sys.argv = list(argv)
elif "--" in sys.argv:
# The traditional path: Blender forwards everything after ``--`` to the
# script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender
# set it — pre-existing behavior, preserved.
i = sys.argv.index("--")
argv += sys.argv[i + 1 :]
+123
View File
@@ -673,6 +673,129 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
And I press "bim.finish_editing_door()"
Then nothing happens
Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And I press "bim.enable_editing_door()"
And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
Then "active_object.BIMDoorProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMDoorProperties.is_editing" is "False"
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "saved_height" equals "2.5"
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "post_save_height" equals "{pre_save_height}"
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Enabling and finishing a wall edit with no drag is a no-op
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And the variable "entity_count_before" is "len(list({ifc}))"
When I press "bim.enable_editing_wall()"
And I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
And "len(list({ifc}))" is "{entity_count_before}"
Scenario: Cancelling a wall edit clears is_editing
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
When I press "bim.cancel_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Rotate a wall 90° via bim.rotate_wall_90
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.rotate_wall_90()"
Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.split_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Create a door, undo and create a new door
Given an empty IFC project
And I prepare to undo
@@ -0,0 +1,100 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Regression guard: overlapping distance gizmos must let the smaller one win.
When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension
nested inside a longer one along the same axis), the longer one's hit box fully
contains the shorter one's. Without a depth bias the longer one wins the GPU
select tie-break and the shorter one becomes unreachable.
``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length``
so the smaller one writes a higher (less-negative) bias and wins. The longer one
stays clickable at its exposed ends regardless of bias.
We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace``
fake ``self``. Its body only *writes* attributes (``_display_value``,
``_dimension_length``, ``select_bias``), so it doesn't need a real
``bpy.types.Gizmo`` instance those only exist inside a registered
``GizmoGroup`` and aren't constructible in a headless test."""
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import GizmoDimension
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_smaller_dimension_wins_select_bias():
small = SimpleNamespace()
large = SimpleNamespace()
GizmoDimension.set_dimension_length(small, 0.077)
GizmoDimension.set_dimension_length(large, 0.109)
assert small.select_bias > large.select_bias
@pytest.mark.parametrize(
"lengths",
[
[0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0],
[0.001, 0.5, 2.5, 100.0, 9999.0],
],
)
def test_select_bias_is_non_increasing_in_length(lengths):
"""A monotonic mapping is all Blender's GPU select needs to break the tie."""
biases = []
for length in lengths:
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, length)
biases.append(gizmo.select_bias)
for prev, curr in zip(biases, biases[1:]):
assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}"
def test_negative_length_uses_absolute_value_for_bias():
"""Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling;
select_bias follows the same clamped magnitude so signed-direction gizmos still
obey the smaller-wins rule against their positive-sided peers."""
positive = SimpleNamespace()
negative = SimpleNamespace()
GizmoDimension.set_dimension_length(positive, 0.5)
GizmoDimension.set_dimension_length(negative, -0.5)
assert positive.select_bias == negative.select_bias
def test_nan_and_inf_length_falls_back_to_zero_bias():
"""Invalid inputs are coerced to 0.0 before the bias is written, so a malformed
update can't push a gizmo arbitrarily far forward or backward in the select buffer."""
import math
for bad in (math.nan, math.inf, -math.inf, "not a number"):
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, bad)
assert gizmo.select_bias == 0.0
@@ -0,0 +1,54 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_text_formatter_defaults_to_none():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0))
assert config.text_formatter is None
def test_text_formatter_field_stores_callable():
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
assert config.text_formatter is not None
assert callable(config.text_formatter)
def test_text_formatter_receives_props_and_value():
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
@@ -0,0 +1,19 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
@@ -0,0 +1,135 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Regression guard for the stair icon billboard fix.
Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
stair's world rotation on top of the billboard rotation. The result was
icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
to the camera for any stair rotated in plan effectively unclickable.
The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
which computes ``Translation(world_pos) @ billboard_rot @ Scale`` the
object's rotation is folded into the translation only, never the rotation."""
import math
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
for row_a, row_b in zip(a, b):
for va, vb in zip(row_a, row_b):
if abs(va - vb) > tol:
return False
return True
@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
"""Object rotation must not leak into the gizmo's rotation part."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
world_pos = mw @ Vector((1, 0, 2))
result = billboarded_at(world_pos, billboard_rot, scale=0.5)
# The rotation part of result, after stripping the 0.5 uniform scale,
# must equal billboard_rot — no contribution from mw's rotation.
rotation_part = result.to_3x3() * 2.0
assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
def test_billboarded_at_translation_is_world_pos():
"""Translation lands exactly at the world-space target."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
world_pos = Vector((1.23, 4.56, 7.89))
result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
assert (result.translation - world_pos).length < 1e-6
def test_set_icon_gizmo_position_does_not_apply_object_rotation():
"""End-to-end: the helper used by every stair icon (and shared with all
parametric gizmo groups) must produce a matrix whose rotation part is
billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
that left stair icons edge-on to the camera."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
billboarded_at,
)
# Same inputs as the real call site (stair.py:747-765), but we drive the
# helper directly so we don't need a registered GizmoGroup. We bind a
# stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
# observable output is the matrix_basis it assigns.
captured = {}
class _GizmoStub:
matrix_basis = Matrix.Identity(4)
stub = _GizmoStub()
def _fake_get(name):
captured["name"] = name
return stub
# Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
# resolves to our stub without registering a real GizmoGroup with Blender.
fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
local_pos = Vector((1, 0, 2))
BaseParametricGizmoGroup.set_icon_gizmo_position(
fake_self,
"validate_gizmo",
mw=mw,
x=local_pos.x,
y=local_pos.y,
z=local_pos.z,
billboard_rot=billboard_rot,
scale=0.5,
)
expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
assert captured["name"] == "validate_gizmo"
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6
@@ -0,0 +1,181 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Unit tests for the poll() preconditions of wall billboarding gizmo groups.
These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll
logic can be exercised without a real IFC fixture. Each test pins one of the
gates ``poll()`` walks, so any silent regression in the gate order or in the
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
import types
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _make_context(active, selected):
"""Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
return SimpleNamespace(active_object=active, selected_objects=list(selected))
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
"""Return a stack of patches that simulate one selection / IFC state for poll().
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
so the test only depends on the predicate ordering in poll()."""
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
entity_map = {}
usage_map = {}
# active_element/other_element are matched by object identity from the selected set
if len(selected) == 2:
entity_map[id(selected[0])] = active_element
entity_map[id(selected[1])] = other_element
usage_map[id(active_element)] = active_usage
usage_map[id(other_element)] = other_usage
def get_entity(obj):
return entity_map.get(id(obj))
def get_usage_type(element):
return usage_map.get(id(element))
from bonsai import tool
return [
patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
]
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = object()
wall_obj = object()
active = slab_obj if active_is_in_selected else object()
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
slab_element = object() if active_has_entity else None
wall_element = object()
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
for p in patches:
p.start()
try:
return GizmoWallExtendVertically.poll(_make_context(active, selected))
finally:
for p in patches:
p.stop()
def test_poll_accepts_layer3_active_with_layer2_other():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is True
)
def test_poll_rejects_when_gizmo_toggle_off():
assert (
_run_poll(
prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_selection_count_is_not_two():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_active_has_no_ifc_entity():
assert (
_run_poll(
prefs_on=True,
active_is_in_selected=True,
len_override=None,
active_usage="LAYER3",
other_usage="LAYER2",
active_has_entity=False,
)
is False
)
def test_poll_rejects_when_active_is_not_layer3():
# A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
# owns that case, and extend_walls_to_underside expects the slab to be active.
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
)
is False
)
# Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
is False
)
def test_poll_rejects_when_other_is_not_layer2_wall():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
)
is False
)
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
is False
)
@@ -0,0 +1,87 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
gizmo geometry cache.
Bug repro before the fix: hotkey operators that edited the active wall in
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
fired ``active_object_callback`` (no selection change), so the header H/L/A
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
per-gizmo-group geometry cache. The fix wires both refreshes through
``tool.Parametric.refresh_post_commit`` and calls it from every
``tool.Ifc.Operator`` epilogue."""
import types
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_refresh_post_commit_bumps_generation_and_resyncs_header():
"""``refresh_post_commit`` must bump the generation counter and call
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
import bonsai.bim.handler as handler
from bonsai import tool
before = tool.Parametric.get_geom_generation()
with patch.object(handler, "update_bim_tool_props") as mock_resync:
tool.Parametric.refresh_post_commit()
assert tool.Parametric.get_geom_generation() == before + 1
mock_resync.assert_called_once()
def test_geom_generation_invalidates_wall_geom_cache():
"""Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
stored entries on the next read, even when the same gizmo group instance
and the same wall object are reused (the case Blender's
``GizmoGroup.refresh()`` does not cover)."""
from bonsai import tool
from bonsai.bim.module.model import wall as wall_mod
class _FakeGroup:
pass
group = _FakeGroup()
fake_obj = types.SimpleNamespace(name="Wall/W001")
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]):
first = wall_mod._get_wall_geom_cached(group, fake_obj)
assert first is sentinel_a
# Same call without a generation bump must hit the cache (no extra read).
assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a
# Simulate an IFC commit: generation advances, cache must drop.
tool.Parametric._geom_generation += 1
second = wall_mod._get_wall_geom_cached(group, fake_obj)
assert second is sentinel_b
assert second is not first
+13
View File
@@ -15,6 +15,8 @@
#
# 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.
from __future__ import annotations
@@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value):
variables[key] = eval(replace_variables(value))
@then(parsers.parse('the variable "{key}" equals "{value}"'))
def the_variable_key_equals_value(key, value):
assert key in variables, f'Variable "{key}" was never set'
expected = eval(replace_variables(value))
actual = variables[key]
if isinstance(actual, float) and isinstance(expected, float):
assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}'
else:
assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}'
@then("nothing happens")
def nothing_happens():
pass
@@ -0,0 +1,409 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Unit coverage for the shared parametric-edit lifecycle mixins.
``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
parametric features (door, window, railing, roof). The registry smoke test
elsewhere verifies operators are wired up; the mixins' own state-transition
contracts are tested here.
The mixins are exercised through minimal in-test subclasses that supply the
abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
patched at the module attribute (not the source module) so each test sees
isolated mock state."""
import json
from typing import ClassVar
from unittest import mock
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
import bpy
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class _FakeProps:
"""Stand-in for ``BIM<Name>Properties`` — records what was set so tests can
assert state transitions without instantiating real PropertyGroups."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 1000}
self.lining = {"thickness": 50}
self.panel = {"material": "wood"}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def get_lining_kwargs(self, convert_to_project_units=True):
return dict(self.lining)
def get_panel_kwargs(self, convert_to_project_units=True):
return dict(self.panel)
def _make_obj(props):
obj = mock.Mock()
obj.props = props
obj.name = "TestObj"
return obj
def _make_pset_text(general, lining, panel):
payload = {"lining_properties": lining, "panel_properties": panel, **general}
return json.dumps(payload)
# ----------------------------------------------------------------------
# FeatureModifierEditMixin (door/window pattern)
# ----------------------------------------------------------------------
def _door_mixin_cls(match=True, raise_on_update=False):
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
raised = raise_on_update
class _TestDoorMixin(FeatureModifierEditMixin):
pset_name: ClassVar[str] = "BBIM_Door"
representations_called: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_modifier_representation(cls, obj, context):
cls.representations_called.append(obj)
if raised:
raise RuntimeError("simulated representation failure")
return _TestDoorMixin
@pytest.fixture
def patched_tool_and_ifc():
"""Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
values and assert call args."""
target = "bonsai.bim.parametric_lifecycle"
with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
f"{target}.bonsai"
) as mock_bonsai:
# Element returned by tool.Ifc.get_entity is reused across mocks.
element = mock.Mock(name="entity")
mock_tool.Ifc.get_entity.return_value = element
mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
yield {
"tool": mock_tool,
"ifc": mock_ifc,
"bonsai": mock_bonsai,
"element": element,
}
def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1234}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 1234
assert props.last_kwargs["thickness"] == 50
assert props.last_kwargs["material"] == "wood"
assert "materials" in props.last_kwargs # from get_constituents_props_data
def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
cls = _door_mixin_cls(match=False)
cls._enable_one(obj)
assert props.is_editing is False
assert props.last_kwargs is None
# get_pset must not be called when _is_element_type returns False — the
# _resolve guard short-circuits before reading pset data.
patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
"""tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is False
def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.representations_called
# edit_pset is called exactly once; properties key is "Data" wrapping JSON.
patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once()
kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs
assert "properties" in kwargs and "Data" in kwargs["properties"]
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
"""If _update_modifier_representation raises, is_editing must stay True
so the user's draft survives for retry. This is the contract called out
in parametric_lifecycle.py:161 set is_editing=False only on success."""
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True, raise_on_update=True)
with pytest.raises(RuntimeError, match="simulated representation failure"):
cls._finish_one(obj, ctx)
assert props.is_editing is True # draft survives
def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 900}, {"thickness": 60}, {"material": "steel"}
)
cls = _door_mixin_cls(match=True)
cls._cancel_one(obj)
assert props.is_editing is False
assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
# switch_representation must be called via bonsai.core.geometry.
patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
"""_enable_targets / _finish_targets / _cancel_targets iterate
_iter_targets default is [active_object]; subclasses can override."""
props_a, props_b = _FakeProps(), _FakeProps()
obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1000}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
result = cls()._enable_targets(mock.Mock())
assert result == {"FINISHED"}
assert props_a.is_editing is True
assert props_b.is_editing is True
# ----------------------------------------------------------------------
# PathPreservingEditMixin (railing/roof pattern)
# ----------------------------------------------------------------------
class _FakePathProps:
"""Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 200, "thickness": 10}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def _path_mixin_cls(match=True):
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
class _TestPathMixin(PathPreservingEditMixin):
pset_name: ClassVar[str] = "BBIM_Railing"
pset_updates: ClassVar[list] = []
ifc_data_updates: ClassVar[list] = []
bmesh_updates: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_pset(cls, element, data):
cls.pset_updates.append((element, data))
@classmethod
def _update_modifier_ifc_data(cls, obj, context):
cls.ifc_data_updates.append(obj)
@classmethod
def _update_modifier_bmesh(cls, obj, context):
cls.bmesh_updates.append(obj)
return _TestPathMixin
def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
}
cls = _path_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 250
# path_data passes through (default _post_load_data is pass-through)
assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"path_data": sentinel_path}
}
cls = _path_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert cls.pset_updates, "_update_pset must be called on Finish"
assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
assert obj in cls.ifc_data_updates
def test_path_preserving_cancel_one_calls_update_modifier_bmesh(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": []}}
}
cls = _path_mixin_cls(match=True)
cls._cancel_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.bmesh_updates
def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
"""Railing overrides _post_load_data to JSON-serialise path_data —
confirm the hook is honoured (here we drop a sentinel key)."""
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "extra": "drop_me"}
}
cls = _path_mixin_cls(match=True)
cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
cls._enable_one(obj)
assert "extra" not in props.last_kwargs
# ----------------------------------------------------------------------
# _ParametricEditMixinBase._resolve guard
# ----------------------------------------------------------------------
def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
cls = _door_mixin_cls(match=False)
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
props = _FakeProps()
obj = _make_obj(props)
resolved = cls._resolve(obj)
assert resolved is not None
element, returned_props = resolved
assert element is patched_tool_and_ifc["element"]
assert returned_props is props
@@ -0,0 +1,157 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
The registry is the single source of truth for which parametric element types
exist. Every consumer (auto-commit on save, finish/cancel chains, the
``PointerProperty`` attachment, the ``GizmoPreferences<X>`` registration) derives
identifiers from each entry's short ``name`` token. Forget any downstream
registration and the silent-desync the framework exists to prevent will ship.
These tests pin the registry-to-runtime contract: for every entry the operator
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
predicate exists on `tool.Blender.Modifier`."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
@pytest.fixture
def registry():
from bonsai import tool
return tool.Parametric.EDIT_TYPES
def test_registry_is_non_empty(registry):
assert len(registry) >= 1
def test_every_entry_has_enable_op_registered(registry):
missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
assert not missing, f"Missing enable operators: {missing}"
def test_every_entry_has_finish_op_registered(registry):
missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
assert not missing, f"Missing finish operators: {missing}"
def test_every_entry_has_cancel_op_registered(registry):
missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
assert not missing, f"Missing cancel operators: {missing}"
def test_every_entry_has_property_group_attached(registry):
# ``register_object_properties`` runs at addon enable; if any entry's
# PropertyGroup class is missing on prop module the attribute is skipped.
missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
assert not missing, (
f"bpy.types.Object missing attributes: {missing}"
f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
)
def test_every_entry_has_modifier_predicate(registry):
from bonsai import tool
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
"""Each ``is_<name>`` predicate must be **total**: accept any IFC entity
and return a truthy/falsy value, never raise.
The registry iterates every predicate against the active IFC element on
save; a raising predicate (e.g. ``AttributeError`` from a missing pset
accessor when handed a non-matching element type) propagates upward and
breaks the save path for *all* parametric types, not just its own.
This test probes each predicate with an ``IfcAnnotation`` (an element
that carries none of the BBIM_<Type> psets the predicates look up) and
asserts the call does not raise. Falsy returns are acceptable the
registry treats them as 'no match'. What's forbidden is raising."""
import ifcopenshell
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
raised = []
for feature in registry:
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
if predicate is None:
continue
try:
predicate(probe)
except Exception as e:
raised.append((feature.name, type(e).__name__, str(e)))
assert not raised, (
f"is_<name> predicates raised on a non-matching IfcAnnotation: {raised}. "
f"Predicates must be total — return bool, never raise. Add an "
f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
)
def test_gizmo_preferences_attached_when_class_exists(registry):
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
discovery: a typo in the class name or a dropped registration would
otherwise produce a missing sub-panel at runtime with no error.
Entries without a ``GizmoPreferences<Name>`` class are allowed not
every parametric type ships gizmo prefs.
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an
annotation-only assignment the attribute only materialises on the
class after Blender's metaclass installs the bpy_struct descriptor,
which depends on registration timing. Reading ``__annotations__``
pins the source-level contract independently of when register() ran."""
from bonsai.bim import ui
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = []
for feature in registry:
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
if not hasattr(ui, prefs_class_name):
continue
if feature.name not in annotations:
missing.append((feature.name, prefs_class_name))
assert not missing, (
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing}"
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
f"``ui.GizmoPreferences``"
)
+243
View File
@@ -0,0 +1,243 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# 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 generated with the assistance of an AI coding tool.
"""Tests for pure-Python math helpers in bonsai.core.model used by the wall gizmo system.
These run in the core lane (``pytest test/core/``) no Blender, no IFC file. The
helpers under test live in ``bonsai/core/model.py`` and are deliberately pure (tuple
in, tuple out) so they're exercisable without ``mathutils`` or ``bpy``."""
import math
import pytest
import bonsai.core.model as subject
class TestBaselineFromOffset:
THICKNESS = 0.2
def test_positive_direction_exterior(self):
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
def test_positive_direction_center(self):
assert subject.baseline_from_offset(-self.THICKNESS / 2, self.THICKNESS) == "CENTER"
def test_positive_direction_interior(self):
assert subject.baseline_from_offset(-self.THICKNESS, self.THICKNESS) == "INTERIOR"
def test_negative_direction_exterior(self):
assert subject.baseline_from_offset(self.THICKNESS, self.THICKNESS) == "EXTERIOR"
def test_negative_direction_center(self):
assert subject.baseline_from_offset(self.THICKNESS / 2, self.THICKNESS) == "CENTER"
def test_negative_direction_interior(self):
assert subject.baseline_from_offset(0.0, self.THICKNESS) == "EXTERIOR"
def test_within_tolerance_still_matches(self):
# A 0.5mm jitter on a 200mm wall should still classify cleanly.
assert subject.baseline_from_offset(-self.THICKNESS / 2 + 0.0005, self.THICKNESS) == "CENTER"
def test_outside_tolerance_falls_back_to_center(self):
# 50mm offset on a 200mm wall — not a canonical position.
assert subject.baseline_from_offset(0.05, self.THICKNESS) == "CENTER"
class TestProjectAxisIntersection:
PARALLEL_THRESHOLD = 0.9994 # cos(2°)
def test_perpendicular_walls_meet_at_corner(self):
# Wall A along +X from origin; wall B along +Y from (5, 0, 0).
# Axes meet exactly at (5, 0).
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[0] == pytest.approx(5.0)
assert result[1] == pytest.approx(0.0)
def test_offset_walls_intersect_at_extrapolated_point(self):
# Wall A: y=0 from x=1 to x=6.
# Wall B: x=0 from y=1 to y=4.
# Infinite-line intersection at (0, 0).
seg_a = ((1.0, 0.0, 0.0), (6.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (0.0, 4.0, 0.0))
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[0] == pytest.approx(0.0)
assert result[1] == pytest.approx(0.0)
def test_parallel_walls_return_none(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_anti_parallel_walls_return_none(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 1.0, 0.0), (0.0, 1.0, 0.0)) # opposite direction
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_nearly_parallel_walls_return_none(self):
# 1° off parallel — within the ~2° dead-band.
angle = math.radians(1)
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0 * math.cos(angle), 1.0 + 5.0 * math.sin(angle), 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_zero_length_segment_returns_none(self):
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0))
assert subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD) is None
def test_intersection_z_is_average_of_endpoint_zs(self):
# Walls at different elevations; the icon-placement Z should be the average.
seg_a = ((0.0, 0.0, 1.0), (5.0, 0.0, 1.0)) # at z=1
seg_b = ((5.0, 0.0, 3.0), (5.0, 3.0, 3.0)) # at z=3
result = subject.project_axis_intersection(seg_a, seg_b, self.PARALLEL_THRESHOLD)
assert result is not None
assert result[2] == pytest.approx(2.0)
class TestSlopeRoundTrip:
def test_zero_angle_zero_displacement(self):
assert subject.displacement_from_x_angle(3.0, 0.0) == pytest.approx(0.0)
assert subject.x_angle_from_displacement(3.0, 0.0) == pytest.approx(0.0)
def test_positive_angle_positive_displacement(self):
# 30° slope on a 3m wall → top moves ~1.732m in +Y.
displacement = subject.displacement_from_x_angle(3.0, math.radians(30))
assert displacement == pytest.approx(3.0 * math.tan(math.radians(30)))
def test_negative_angle_negative_displacement(self):
displacement = subject.displacement_from_x_angle(3.0, math.radians(-15))
assert displacement < 0
def test_round_trip_preserves_angle(self):
# Drag-to-angle-to-drag preserves the original.
original_angle = math.radians(20)
displacement = subject.displacement_from_x_angle(3.0, original_angle)
recovered = subject.x_angle_from_displacement(3.0, displacement)
assert recovered == pytest.approx(original_angle, abs=1e-9)
def test_round_trip_handles_zero_height(self):
# Walls of effectively zero height should not divide-by-zero.
recovered = subject.x_angle_from_displacement(0.0, 1.0)
assert recovered == pytest.approx(math.pi / 2, abs=1e-3)
class TestAreAxesCollinear:
PARALLEL_THRESHOLD = 0.9994
LINE_TOLERANCE = 0.05
def test_end_to_end_walls_along_x_are_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_separated_collinear_walls_with_gap(self):
# Walls with a 1m gap between them — still on the same line.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((6.0, 0.0, 0.0), (10.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_perpendicular_walls_are_not_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (0.0, 5.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_parallel_walls_offset_perpendicular_are_not_collinear(self):
# Two parallel walls 1m apart — same direction but not the same line.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((0.0, 1.0, 0.0), (5.0, 1.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_anti_parallel_collinear_walls(self):
# Reversed direction on the same line still counts as collinear.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((10.0, 0.0, 0.0), (6.0, 0.0, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_z_is_ignored_for_plan_collinearity(self):
# Walls on different floors are still considered collinear in plan.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_zero_length_segment_is_not_collinear(self):
seg_a = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
seg_b = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_slightly_off_line_within_tolerance(self):
# 2cm perpendicular offset — still within the 5cm tolerance.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.02, 0.0), (10.0, 0.02, 0.0))
assert subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
def test_too_far_off_line_fails_tolerance(self):
# 10cm perpendicular offset — outside the 5cm tolerance.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.10, 0.0), (10.0, 0.10, 0.0))
assert not subject.are_axes_collinear(seg_a, seg_b, self.PARALLEL_THRESHOLD, self.LINE_TOLERANCE)
class TestClosestEndpointMidpoint:
def test_end_to_end_walls_midpoint_is_the_shared_corner(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (10.0, 0.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
def test_walls_with_gap_midpoint_is_in_the_gap(self):
# Wall A ends at x=5; wall B starts at x=7. Boundary midpoint is at x=6.
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((7.0, 0.0, 0.0), (12.0, 0.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(6.0), pytest.approx(0.0), pytest.approx(0.0))
def test_perpendicular_walls_midpoint_is_between_nearest_endpoints(self):
# Wall A's +X endpoint (5,0,0) and wall B's origin (5,0,0) → midpoint at (5,0,0).
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 0.0), (5.0, 3.0, 0.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
assert result == (pytest.approx(5.0), pytest.approx(0.0), pytest.approx(0.0))
def test_z_averaged_when_walls_at_different_elevations(self):
seg_a = ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))
seg_b = ((5.0, 0.0, 3.0), (10.0, 0.0, 3.0))
result = subject.closest_endpoint_midpoint(seg_a, seg_b)
# Closest pair: (5,0,0) and (5,0,3); midpoint Z = 1.5.
assert result[2] == pytest.approx(1.5)
class TestVerticalHeightFromExtrusionDepth:
def test_vertical_wall_returns_depth_unchanged(self):
assert subject.vertical_height_from_extrusion_depth(3.0, 0.0) == pytest.approx(3.0)
def test_30_degree_slope(self):
# cos(30°) ≈ 0.866 → vertical height of a 3m slanted extrusion ≈ 2.598m.
result = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
assert result == pytest.approx(3.0 * math.cos(math.radians(30)))
def test_negative_angle_yields_same_magnitude(self):
positive = subject.vertical_height_from_extrusion_depth(3.0, math.radians(30))
negative = subject.vertical_height_from_extrusion_depth(3.0, math.radians(-30))
assert positive == pytest.approx(negative)