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