mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 16:31:37 +00:00
Add tool.Parametric registry and lifecycle mixins
Establish a single source of truth for parametric element types (door, window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives: - BIM<Name>Properties PointerProperty attachment via the registry - GizmoPreferences<Name> class registration in bim/__init__.py - save-time auto-commit of pending draft edits - the refresh_post_commit epilogue called from IfcStore after every IFC mutation, which fixes the stale-header bug where in-place hotkey mutations (S_E / C_E) left BIMModelProperties and the gizmo cache pointing at obsolete values. Refactors door/window/railing/roof onto shared mixins from bim/parametric_lifecycle.py (FeatureModifierEditMixin and PathPreservingEditMixin); stair gets the lock-gizmo refactor and frame-cache integration. Behavior preserved. Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric gizmos stop re-deriving preferences, view direction, and billboard rotation per frame; reorders poll() to short-circuit on the cheapest predicate first. Adds the icon library + BillboardingGizmoGroupMixin that the wall feature in the next commit will consume. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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]:
|
||||
"""Deferred lookup. Importing ``bonsai.tool`` at module top would cold-start
|
||||
``tool.blender`` → ``bim.ifc`` before the ``from . import handler, …`` above
|
||||
has primed the ``bim.ifc`` ↔ ``bim.handler`` partial-import dance, crashing
|
||||
addon registration. Resolved at classes-tuple build time below — by then the
|
||||
relative imports have settled."""
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 :class:`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 :class:`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:
|
||||
@@ -3913,6 +4162,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 :func:`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 +4431,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 :meth:`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 +4552,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 +4637,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 +4665,20 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
pass
|
||||
|
||||
# Frame-scoped caches populated by :meth:`_prime_frame_caches` 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
|
||||
# 2–5× 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 +4693,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 +4701,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 +4724,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.
|
||||
@@ -4594,28 +4934,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 :func:`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 +5020,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 +5048,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 +5082,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 +5098,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,7 +5106,7 @@ 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)
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
|
||||
# This ensures icons face camera regardless of object rotation
|
||||
local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z))
|
||||
@@ -4819,16 +5142,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 +5169,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:
|
||||
|
||||
@@ -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,
|
||||
@@ -264,12 +268,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 +290,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,24 @@ 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 :py:meth:`_update_lock_gizmo_position`."""
|
||||
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 in
|
||||
:py:meth:`_update_lock_gizmo_position` (called per frame via
|
||||
:py:meth:`_update_dimension_gizmo_positions`)."""
|
||||
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 +647,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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1873,6 +1875,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
confirm_parametric_edits: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
description="Internal: routes draw() to the parametric-commit confirm body instead of the file dialog.",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -1880,6 +1887,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_compact: bool
|
||||
should_save_as: bool
|
||||
use_relative_path: bool
|
||||
confirm_parametric_edits: bool
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -1887,6 +1895,9 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
if self.confirm_parametric_edits:
|
||||
self._draw_parametric_confirm(layout)
|
||||
return
|
||||
layout.prop(self, "json_version")
|
||||
layout.prop(self, "json_compact")
|
||||
if bpy.data.is_saved:
|
||||
@@ -1896,6 +1907,33 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
layout.label(text="Supported formats for export:")
|
||||
layout.label(text=",".join(self.supported_filexts))
|
||||
|
||||
def _draw_parametric_confirm(self, layout: bpy.types.UILayout) -> None:
|
||||
col = layout.column(align=True)
|
||||
col.label(text="Saving will commit all in-progress parametric edits to IFC")
|
||||
col.label(text="before writing the file.")
|
||||
layout.separator()
|
||||
# Auto-derive the noun list from the parametric registry so the dialog stays
|
||||
# in sync as new parametric element types are added.
|
||||
nouns = [feature.name for feature in tool.Parametric.EDIT_TYPES]
|
||||
if len(nouns) > 1:
|
||||
noun_list = ", ".join(nouns[:-1]) + " or " + nouns[-1]
|
||||
else:
|
||||
noun_list = nouns[0] if nouns else ""
|
||||
col = layout.column(align=True)
|
||||
col.label(text="For example, if you are editing a parametric")
|
||||
col.label(text=f"{noun_list}, all pending changes will be applied")
|
||||
col.label(text="to the IFC file first.")
|
||||
layout.separator()
|
||||
col = layout.column(align=True)
|
||||
col.label(text='Click "Commit & Save" to apply the pending edits and save,')
|
||||
col.label(text="or press Esc to abort the save.")
|
||||
layout.separator()
|
||||
box = layout.box()
|
||||
col = box.column(align=True)
|
||||
col.label(text="To disable this prompt and always auto-commit silently,", icon="INFO")
|
||||
col.label(text='turn off "Confirm Before Auto-Committing Parametric Edits')
|
||||
col.label(text='on Save" in the Bonsai add-on preferences.')
|
||||
|
||||
def invoke(self, context, event):
|
||||
if not tool.Ifc.get():
|
||||
bpy.ops.wm.save_mainfile("INVOKE_DEFAULT")
|
||||
@@ -1903,11 +1941,24 @@ 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)
|
||||
filepath = props.ifc_file
|
||||
if not filepath or self.should_save_as:
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
if prefs.prompt_auto_commit_parametric_edits and tool.Parametric.get_pending_edits():
|
||||
# `invoke_props_dialog` fires `execute()` on OK using current properties,
|
||||
# so `self.filepath` must already be set above. The `confirm_parametric_edits`
|
||||
# flag routes `draw()` to the multi-line confirm body instead of the file dialog.
|
||||
self.confirm_parametric_edits = True
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self,
|
||||
width=460,
|
||||
title="Pending Parametric Edits",
|
||||
confirm_text="Commit & Save",
|
||||
)
|
||||
return self.execute(context)
|
||||
|
||||
def check(self, context):
|
||||
# ExportHelper is automatically adjusting suffix to `filename_ext`.
|
||||
@@ -1933,6 +1984,12 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return {"FINISHED"}
|
||||
|
||||
def _execute(self, context):
|
||||
_, failed_commits = tool.Parametric.commit_pending_edits()
|
||||
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")
|
||||
|
||||
@@ -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/``:
|
||||
|
||||
:class:`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``.
|
||||
|
||||
:class:`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 :class:`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``. Subclass override
|
||||
:meth:`_post_load_data` 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"}
|
||||
@@ -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,189 @@ 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 segments lie on the same infinite line in plan (X,Y).
|
||||
|
||||
Two conditions: their directions must be (anti-)parallel within
|
||||
``parallel_threshold`` (cos ~2°), AND any endpoint of B must lie on A's
|
||||
infinite line within ``line_tolerance`` (~5cm). Z is ignored — two parallel
|
||||
walls at different elevations are still considered collinear."""
|
||||
p1, p2 = seg_a
|
||||
q1, q2 = seg_b
|
||||
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
|
||||
d2x, d2y = q2[0] - q1[0], q2[1] - q1[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
|
||||
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
|
||||
if abs(dot) < parallel_threshold:
|
||||
return False
|
||||
# Project q1 onto the infinite line through seg_a; perpendicular distance
|
||||
# from q1 to its projection tells us how far off the line B sits.
|
||||
ux, uy = d1x / d1_len, d1y / d1_len
|
||||
rx, ry = q1[0] - p1[0], q1[1] - p1[1]
|
||||
t = rx * ux + ry * uy
|
||||
proj_x = p1[0] + t * ux
|
||||
proj_y = p1[1] + t * uy
|
||||
perp_dist = ((q1[0] - proj_x) ** 2 + (q1[1] - proj_y) ** 2) ** 0.5
|
||||
return perp_dist < 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 is the midpoint of the gap. Either way it is the user-meaningful
|
||||
"boundary" where a merge would graft the two segments together."""
|
||||
pairs = ((a, b) for a in seg_a for b in seg_b)
|
||||
pa, pb = min(pairs, key=lambda pair: sum((pair[0][i] - pair[1][i]) ** 2 for i in range(3)))
|
||||
return ((pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2, (pa[2] + pb[2]) / 2)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 @@ 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
|
||||
|
||||
# Type-only — imported lazily to avoid a circular load when ``bim/__init__.py``
|
||||
# imports ``bonsai.tool`` before ``bim.ifc`` has reached its line-43 definition
|
||||
# of ``IFC_CONNECTED_TYPE`` (the chain re-enters ``bim.ifc`` through
|
||||
# ``bim.handler`` and trips on a still-undefined ``IfcStore``). The file has
|
||||
# ``from __future__ import annotations``, so the type hint at line 1884 is a
|
||||
# deferred string and needs no runtime binding.
|
||||
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 +1145,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 +1167,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 +1220,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 +1241,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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# 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
|
||||
:attr:`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 :attr:`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
|
||||
:attr:`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 —
|
||||
:meth:`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 :mod:`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
|
||||
:attr:`ParametricObject.name` — :meth:`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`` — :meth:`Parametric.iter_gizmo_preference_classes`
|
||||
discovers it from the registry automatically by its name
|
||||
(``GizmoPreferences`` + capitalised registry token).
|
||||
|
||||
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 the registration smoke test in
|
||||
``test/bim/test_parametric_registry.py`` — it iterates
|
||||
:attr:`Parametric.EDIT_TYPES` and asserts each ``enable_op`` /
|
||||
``finish_op`` / ``cancel_op`` resolves to a registered operator, that
|
||||
``bpy.types.Object`` carries the matching ``BIM<Name>Properties``
|
||||
attribute, and that ``tool.Blender.Modifier.is_<name>`` exists. 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 —
|
||||
multi-word IFC types (e.g. ``IfcCurtainWall``) would silently mis-derive
|
||||
through ``str.capitalize()`` and need a different approach than
|
||||
appending to :data:`Parametric.EDIT_TYPES` directly.
|
||||
|
||||
``has_non_editable_path`` flags element types whose modifier exposes no
|
||||
user-editable path (door, window, stair) — historically queried via
|
||||
``tool.Blender.Modifier.is_modifier_with_non_editable_path``."""
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
_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: only use with operators that are themselves
|
||||
``tool.Ifc.Operator`` subclasses — their 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."""
|
||||
getattr(bpy.ops.bim, bl_idname.removeprefix("bim."))()
|
||||
|
||||
@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 :meth:`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 :attr:`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
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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 :attr:`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 :class:`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_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 attached to
|
||||
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
|
||||
|
||||
Catches the silent-skip behaviour of
|
||||
``Parametric.iter_gizmo_preference_classes``: 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."""
|
||||
from bonsai.bim import ui
|
||||
|
||||
missing = []
|
||||
for feature in registry:
|
||||
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
|
||||
if not hasattr(ui, prefs_class_name):
|
||||
continue
|
||||
if not hasattr(ui.GizmoPreferences, feature.name):
|
||||
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``"
|
||||
)
|
||||
Reference in New Issue
Block a user