Add interactive gizmo system for editing parmeterized door

Implement visual parameter manipulation in 3D viewport with reusable
gizmo components. Features snap-to-mesh (Ctrl), precision mode (Shift),
and real-time feedback. Includes cone gizmos for dimensions,
arc gizmo for swing direction, and icon gizmos for edit controls. Gizmos are globally are individually toggleable in the addon preferences.

May be expanded upon later to add gizmos to other paramaterized elements (window, railing, roof, stairs, array, ...).

Unfortunately gizmos can't tap into the builtin snap system or bonsai snap system which is built for modal operators so gizmos implement yet another custom rather naive snap system.
This commit is contained in:
Gorgious
2025-11-23 20:16:27 +01:00
parent 3dd7290ee1
commit 592c51b526
6 changed files with 1174 additions and 9 deletions
+11 -1
View File
@@ -21,7 +21,7 @@ import bpy
import bpy.utils.previews
import importlib
from bpy_extras.io_utils import ImportHelper, ExportHelper
from . import handler, ui, prop, operator
from . import handler, ui, prop, operator, gizmo
from typing import Union
from collections.abc import Callable
@@ -148,6 +148,8 @@ classes = [
ui.BIM_UL_clipping_plane,
ui.BIM_UL_generic,
ui.DocPreferences,
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
ui.BIM_PT_tabs,
@@ -202,6 +204,14 @@ classes = [
ui.BIM_PT_section_with_cappings,
ui.BIM_PT_decorators_overlay,
ui.BIM_PT_snappping,
# Gizmos
gizmo.GizmoArrow,
gizmo.GizmoCone,
gizmo.GizmoLock,
gizmo.GizmoArc,
gizmo.GizmoPen,
gizmo.GizmoValidate,
gizmo.GizmoCancel,
]
for mod in modules.values():
+770
View File
@@ -0,0 +1,770 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Shared gizmo components for reuse across modules.
"""
import bpy
import math
from typing import Optional, Tuple, List
from mathutils import Vector
from mathutils.bvhtree import BVHTree
from mathutils.geometry import intersect_line_line
from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d
import gpu
from gpu_extras.batch import batch_for_shader
SNAP_CIRCLE_SEGMENTS = 16
SNAP_CIRCLE_RADIUS = 0.1
SNAP_CIRCLE_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_LINE_WIDTH = 3.0
SNAP_MAX_RADIUS = 10.0
ARROW_SHAFT_LENGTH = 0.8
ARROW_HEAD_LENGTH = 0.2
ARROW_WIDTH = 0.015
ARROW_HEAD_WIDTH_MULTIPLIER = 10
ARROW_CIRCLE_SEGMENTS = 8
CONE_LENGTH = 1.0
CONE_RADIUS = 0.35
CONE_SEGMENTS = 16
ARC_SEGMENTS = 24
ARC_LINE_WIDTH = 0.015
PRECISION_MODE_MULTIPLIER = 0.1
class SnapManager:
"""Manages snap point visualization and mesh snapping."""
def __init__(self):
self._snap_point: Optional[Tuple[float, float, float]] = None
self._draw_handler = None
self._shader = None
def set_snap_point(self, point: Optional[Tuple[float, float, float]]) -> None:
"""Set snap point and register draw handler if needed."""
self._snap_point = point
if self._draw_handler is None and point is not None:
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
self._draw, (), 'WINDOW', 'POST_VIEW'
)
self._redraw_viewport()
def clear(self) -> None:
"""Clear snap point and unregister handler."""
self._snap_point = None
if self._draw_handler is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, 'WINDOW')
self._draw_handler = None
self._redraw_viewport()
def _draw(self) -> None:
"""Draw snap point circles."""
if self._snap_point is None:
return
if self._shader is None:
self._shader = gpu.shader.from_builtin('UNIFORM_COLOR')
self._shader.bind()
self._shader.uniform_float("color", SNAP_CIRCLE_COLOR)
gpu.state.line_width_set(SNAP_LINE_WIDTH)
for plane in ('XY', 'XZ', 'YZ'):
vertices = generate_circle_vertices(
self._snap_point, SNAP_CIRCLE_RADIUS, SNAP_CIRCLE_SEGMENTS, plane
)
batch = batch_for_shader(self._shader, 'LINE_STRIP', {"pos": vertices})
batch.draw(self._shader)
gpu.state.line_width_set(1.0)
@staticmethod
def _redraw_viewport() -> None:
"""Force 3D viewport redraw."""
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
@staticmethod
def snap_to_mesh(
location: Vector,
context: bpy.types.Context,
axis_vector: Vector,
active_obj: bpy.types.Object
) -> Vector:
"""Snap a location to the nearest mesh element if snapping is enabled.
Args:
location: The world space location to snap
context: The Blender context
axis_vector: The axis direction of the gizmo
active_obj: The active object to exclude from snapping
Returns:
The snapped location or original location if snapping is disabled
"""
tool_settings = context.scene.tool_settings
if not tool_settings.use_snap:
return location
snap_elements = tool_settings.snap_elements_base
mesh_objects = [
obj
for obj in context.visible_objects
if obj.type == "MESH" and obj != active_obj and obj.visible_get()
]
if not mesh_objects:
return location
nearby_objects = []
for obj in mesh_objects:
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
for corner in bbox_corners:
if (corner - location).length <= SNAP_MAX_RADIUS:
nearby_objects.append(obj)
break
if not nearby_objects:
return location
closest_point = None
closest_distance = float("inf")
for obj in nearby_objects:
if not obj.data.vertices:
continue
depsgraph = context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh()
if not mesh.vertices:
obj_eval.to_mesh_clear()
continue
world_vertices = [obj.matrix_world @ v.co for v in mesh.vertices]
if 'VERTEX' in snap_elements:
for v_co in world_vertices:
dist = (v_co - location).length
if dist < closest_distance:
closest_distance = dist
closest_point = v_co
if 'EDGE' in snap_elements:
for edge in mesh.edges:
v1 = world_vertices[edge.vertices[0]]
v2 = world_vertices[edge.vertices[1]]
edge_vec = v2 - v1
edge_len_sq = edge_vec.length_squared
if edge_len_sq > 0:
t = max(0, min(1, (location - v1).dot(edge_vec) / edge_len_sq))
closest_on_edge = v1 + t * edge_vec
dist = (closest_on_edge - location).length
if dist < closest_distance:
closest_distance = dist
closest_point = closest_on_edge
if 'FACE' in snap_elements:
bvh = BVHTree.FromPolygons(
world_vertices, [p.vertices for p in mesh.polygons]
)
nearest_loc, normal, index, dist = bvh.find_nearest(location)
if nearest_loc and dist < closest_distance:
closest_distance = dist
closest_point = nearest_loc
obj_eval.to_mesh_clear()
if closest_point and closest_distance < SNAP_MAX_RADIUS:
return closest_point
return location
_snap_manager = SnapManager()
def set_snap_point(point: Optional[Tuple[float, float, float]]) -> None:
_snap_manager.set_snap_point(point)
def clear_snap_point() -> None:
_snap_manager.clear()
def snap_to_mesh(
location: Vector,
context: bpy.types.Context,
axis_vector: Vector,
active_obj: bpy.types.Object
) -> Vector:
return _snap_manager.snap_to_mesh(location, context, axis_vector, active_obj)
def generate_circle_vertices(
center: Tuple[float, float, float],
radius: float,
segments: int,
plane: str = 'XY'
) -> List[Tuple[float, float, float]]:
"""Generate circle vertices in specified plane.
Args:
center: (x, y, z) tuple for circle center
radius: Circle radius
segments: Number of segments
plane: 'XY', 'XZ', or 'YZ'
Returns:
List of (x, y, z) vertices
"""
vertices = []
for i in range(segments + 1):
angle = (2 * math.pi * i) / segments
cos_a = radius * math.cos(angle)
sin_a = radius * math.sin(angle)
if plane == 'XY':
vertices.append((center[0] + cos_a, center[1] + sin_a, center[2]))
elif plane == 'XZ':
vertices.append((center[0] + cos_a, center[1], center[2] + sin_a))
else:
vertices.append((center[0], center[1] + cos_a, center[2] + sin_a))
return vertices
def create_quarter_circle_arc(
radius: float = 1.0,
segments: int = ARC_SEGMENTS,
direction: str = "LEFT",
line_width: float = ARC_LINE_WIDTH
) -> Tuple[Tuple[float, float, float], ...]:
"""Create a quarter circle arc with cross-section thickness for visibility from all angles.
Args:
radius: Radius of the arc
segments: Number of segments for smoothness
direction: 'LEFT' for counterclockwise (0 to 90°), 'RIGHT' for clockwise (0 to -90°)
line_width: Width of the arc line in both perpendicular directions
Returns:
Tuple of arc triangles for drawing geometry visible from all angles
"""
half_width = line_width / 2
arc_points = []
if direction == "LEFT":
for i in range(segments + 1):
angle = (math.pi / 2) * (i / segments)
x = radius * math.cos(angle)
y = radius * math.sin(angle)
arc_points.append((x, y))
else:
for i in range(segments + 1):
angle = (math.pi / 2) * (i / segments)
x = -radius * math.cos(angle)
y = radius * math.sin(angle)
arc_points.append((x, y))
arc_triangles = []
for i in range(len(arc_points) - 1):
x1, y1 = arc_points[i]
x2, y2 = arc_points[i + 1]
dx, dy = x2 - x1, y2 - y1
length = (dx**2 + dy**2) ** 0.5
if length > 0:
px, py = -dy / length * half_width, dx / length * half_width
arc_triangles.extend([
(x1 + px, y1 + py, 0.0),
(x1 - px, y1 - py, 0.0),
(x2 + px, y2 + py, 0.0),
])
arc_triangles.extend([
(x2 + px, y2 + py, 0.0),
(x1 - px, y1 - py, 0.0),
(x2 - px, y2 - py, 0.0),
])
arc_triangles.extend([
(x1, y1, -half_width),
(x2, y2, -half_width),
(x1, y1, +half_width),
])
arc_triangles.extend([
(x1, y1, +half_width),
(x2, y2, -half_width),
(x2, y2, +half_width),
])
return tuple(arc_triangles)
class GizmoMovable(bpy.types.Gizmo):
"""Base class for movable gizmos with snapping and precision controls.
This class provides common functionality for gizmos that can be dragged
to modify values, including:
- Snap to mesh support (toggle with Ctrl)
- Precision mode (hold Shift for 10x slower movement)
- Header text with value and modifier hints
- Consistent invoke/exit/modal behavior
"""
__slots__ = (
"custom_shape",
"init_value",
"move_get_cb",
"move_set_cb",
"axis",
"local_axis",
"start_location",
"active_obj",
"initial_snap_state",
)
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set:
self.init_value = self.move_get_cb() if self.move_get_cb else 0.0
self.start_location = self.matrix_basis.translation.copy()
self.active_obj = context.active_object
self.initial_snap_state = context.scene.tool_settings.use_snap
return {"RUNNING_MODAL"}
def exit(self, context: bpy.types.Context, cancel: bool) -> None:
context.area.header_text_set(None)
if cancel and self.move_set_cb:
self.move_set_cb(self.init_value)
if hasattr(self, "initial_snap_state"):
context.scene.tool_settings.use_snap = self.initial_snap_state
clear_snap_point()
def get_axis_direction(self) -> Vector:
return self.axis
def modal(self, context: bpy.types.Context, event: bpy.types.Event, tweak) -> set:
region = context.region
rv3d = context.region_data
tool_settings = context.scene.tool_settings
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
current_coord = (event.mouse_region_x, event.mouse_region_y)
view_origin = region_2d_to_origin_3d(region, rv3d, current_coord)
view_direction = region_2d_to_vector_3d(region, rv3d, current_coord)
axis_direction = self.get_axis_direction()
result = intersect_line_line(
view_origin, view_origin + view_direction * 1000,
self.start_location, self.start_location + axis_direction * 1000
)
current_3d = result[1] if result else self.start_location
delta = (current_3d - self.start_location).dot(axis_direction)
if tool_settings.use_snap and self.active_obj:
snapped_pos = snap_to_mesh(current_3d, context, axis_direction, self.active_obj)
if snapped_pos != current_3d:
delta = (snapped_pos - self.start_location).dot(axis_direction)
set_snap_point(snapped_pos)
else:
clear_snap_point()
else:
clear_snap_point()
if event.shift:
delta *= PRECISION_MODE_MULTIPLIER
if self.move_set_cb:
self.move_set_cb(self.init_value + delta)
self._update_header(context, self.init_value + delta, tool_settings.use_snap, event.shift)
return {"RUNNING_MODAL"}
def _update_header(
self,
context: bpy.types.Context,
value: float,
is_snapping: bool,
is_precision: bool
) -> None:
header_text = f"Value: {value:.3f}m"
hints = []
if is_snapping:
hints.append("Snapping: ON")
if is_precision:
hints.append("Precision Mode (0.1x)")
hints.extend(["Ctrl: Toggle Snap", "Shift: Precision"])
if hints:
header_text += " | " + " | ".join(hints)
context.area.header_text_set(header_text)
class GizmoLock(bpy.types.Gizmo):
"""Reusable lock icon gizmo that switches between closed and open states."""
bl_idname = "VIEW3D_GT_lock"
__slots__ = (
"custom_shape_closed",
"custom_shape_open",
"prop_path",
)
tris_closed = (
(0.16803650558, 0.18791499734, 0.0), (-0.07805634290, 0.18791499734, 0.0), (0.16803650558, 0.44701099396, 0.0),
(0.16803650558, 0.44701099396, 0.0), (-0.07805634290, 0.18791499734, 0.0), (-0.07805634290, 0.44701099396, 0.0),
(0.20165449381, 0.13603900373, 0.0), (-0.11167433113, 0.13603900373, 0.0), (0.20165449381, -0.44701099396, 0.0),
(0.20165449381, -0.44701099396, 0.0), (-0.11167433113, 0.13603900373, 0.0), (-0.11167433113, -0.44701099396, 0.0),
(-0.07805634290, 0.18791499734, 0.0), (-0.39353451133, 0.18791499734, 0.0), (-0.07805634290, 0.44701099396, 0.0),
(-0.07805634290, 0.44701099396, 0.0), (-0.39353451133, 0.18791499734, 0.0), (-0.39353451133, 0.30746498704, 0.0),
(-0.44701099396, 0.18791499734, 0.0), (-0.44701099396, -0.04477182776, 0.0), (-0.39353451133, 0.18791499734, 0.0),
(-0.39353451133, 0.18791499734, 0.0), (-0.44701099396, -0.04477182776, 0.0), (-0.39353451133, -0.04477182776, 0.0),
)
tris_open = (
(0.16803650558, 0.18791499734, 0.0), (-0.07805634290, 0.18791499734, 0.0), (0.16803650558, 0.44701099396, 0.0),
(0.16803650558, 0.44701099396, 0.0), (-0.07805634290, 0.18791499734, 0.0), (-0.07805634290, 0.44701099396, 0.0),
(0.20165449381, 0.13603900373, 0.0), (-0.11167433113, 0.13603900373, 0.0), (0.20165449381, -0.44701099396, 0.0),
(0.20165449381, -0.44701099396, 0.0), (-0.11167433113, 0.13603900373, 0.0), (-0.11167433113, -0.44701099396, 0.0),
(0.16803650558, 0.44701099396, 0.0), (-0.07805634290, 0.44701099396, 0.0), (0.16803650558, 0.70610702038, 0.0),
(0.16803650558, 0.70610702038, 0.0), (-0.07805634290, 0.44701099396, 0.0), (-0.07805634290, 0.58656096458, 0.0),
(-0.11167433113, 0.44701099396, 0.0), (-0.11167433113, 0.73432201147, 0.0), (-0.07805634290, 0.44701099396, 0.0),
(-0.07805634290, 0.44701099396, 0.0), (-0.11167433113, 0.73432201147, 0.0), (-0.07805634290, 0.73432201147, 0.0),
)
def get_custom_shape(self, context: bpy.types.Context):
obj = context.active_object
if not obj:
return self.custom_shape_closed
try:
is_open = obj.path_resolve(self.prop_path)
return self.custom_shape_open if is_open else self.custom_shape_closed
except:
return self.custom_shape_closed
def setup(self) -> None:
self.custom_shape_closed = self.new_custom_shape("TRIS", self.tris_closed)
self.custom_shape_open = self.new_custom_shape("TRIS", self.tris_open)
def draw(self, context: bpy.types.Context) -> None:
self.draw_custom_shape(self.get_custom_shape(context))
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self.draw_custom_shape(self.get_custom_shape(context), select_id=select_id)
class GizmoArc(bpy.types.Gizmo):
"""Reusable quarter circle arc gizmo."""
bl_idname = "VIEW3D_GT_arc"
__slots__ = (
"custom_shape_left",
"custom_shape_right",
"prop_path",
)
def setup(self) -> None:
"""Create quarter circle shapes for both LEFT and RIGHT directions."""
arc_left = create_quarter_circle_arc(radius=1.0, direction="LEFT")
arc_right = create_quarter_circle_arc(radius=1.0, direction="RIGHT")
self.custom_shape_left = self.new_custom_shape(type="TRIS", verts=arc_left)
self.custom_shape_right = self.new_custom_shape(type="TRIS", verts=arc_right)
def _get_shape_for_direction(self, context: bpy.types.Context):
obj = context.active_object
if not obj:
return self.custom_shape_left
try:
direction_value = obj.path_resolve(self.prop_path)
if "RIGHT" in str(direction_value):
return self.custom_shape_right
except:
pass
return self.custom_shape_left
def draw(self, context: bpy.types.Context) -> None:
self.draw_custom_shape(self._get_shape_for_direction(context))
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self.draw_custom_shape(self._get_shape_for_direction(context), select_id=select_id)
class GizmoPen(bpy.types.Gizmo):
"""Reusable pen/edit icon gizmo for entering edit mode."""
bl_idname = "VIEW3D_GT_pen"
__slots__ = ("custom_shape",)
tris = (
(-0.07595771551132202, -0.2948460578918457, 0.0),
(0.16886109113693237, 0.23203276097774506, 0.0),
(0.062240585684776306, 0.28157487511634827, 0.0),
(0.07201281189918518, 0.30260589718818665, 0.0),
(0.21042980253696442, 0.321493536233902, 0.0),
(0.17863331735134125, 0.25306373834609985, 0.0),
(0.062240585684776306, 0.28157487511634827, 0.0),
(-0.1825782209634781, -0.2453039139509201, 0.0),
(-0.07595771551132202, -0.2948460578918457, 0.0),
(-0.1825782209634781, -0.2453039139509201, 0.0),
(-0.19114767014980316, -0.4032624065876007, 0.0),
(-0.07595771551132202, -0.2948460578918457, 0.0),
(0.07201281189918518, 0.30260589718818665, 0.0),
(0.10380929708480835, 0.371035635471344, 0.0),
(0.21042980253696442, 0.321493536233902, 0.0),
)
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)
class GizmoValidate(bpy.types.Gizmo):
"""Reusable validate/checkmark icon gizmo for confirming edits."""
bl_idname = "VIEW3D_GT_validate"
__slots__ = ("custom_shape",)
tris = (
(0.36775994300842285, 0.205583393573761, 0.0),
(0.030080009251832962, -0.1881658434867859, 0.0),
(0.030080009251832962, -0.3380376696586609, 0.0),
(-0.22017201781272888, -0.16090886294841766, 0.0),
(0.030080009251832962, -0.1881658434867859, 0.0),
(-0.22017201781272888, -0.011037036776542664, 0.0),
(0.36775994300842285, 0.205583393573761, 0.0),
(0.36775994300842285, 0.355455219745636, 0.0),
(0.030080009251832962, -0.1881658434867859, 0.0),
(-0.22017201781272888, -0.16090886294841766, 0.0),
(0.030080009251832962, -0.3380376696586609, 0.0),
(0.030080009251832962, -0.1881658434867859, 0.0),
)
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)
class GizmoCancel(bpy.types.Gizmo):
"""Reusable cancel/X icon gizmo for canceling edits."""
bl_idname = "VIEW3D_GT_cancel"
__slots__ = ("custom_shape",)
tris = (
(0.0, 0.048707593232393265, 0.0),
(0.0, -0.048707593232393265, 0.0),
(0.048707593232393265, 0.0, 0.0),
(0.0, 0.048707593232393265, 0.0),
(-0.21918421983718872, 0.2678918242454529, 0.0),
(-0.048707593232393265, 0.0, 0.0),
(-0.21918421983718872, 0.2678918242454529, 0.0),
(-0.2678918242454529, 0.21918421983718872, 0.0),
(-0.048707593232393265, 0.0, 0.0),
(-0.048707593232393265, 0.0, 0.0),
(-0.2678918242454529, -0.21918421983718872, 0.0),
(-0.21918421983718872, -0.2678918242454529, 0.0),
(0.2678918242454529, 0.21918421983718872, 0.0),
(0.21918421983718872, 0.2678918242454529, 0.0),
(0.0, 0.048707593232393265, 0.0),
(0.21918421983718872, -0.2678918242454529, 0.0),
(0.2678918242454529, -0.21918421983718872, 0.0),
(0.048707593232393265, 0.0, 0.0),
(0.048707593232393265, 0.0, 0.0),
(0.2678918242454529, 0.21918421983718872, 0.0),
(0.0, 0.048707593232393265, 0.0),
(0.0, 0.048707593232393265, 0.0),
(-0.048707593232393265, 0.0, 0.0),
(0.0, -0.048707593232393265, 0.0),
(-0.048707593232393265, 0.0, 0.0),
(-0.21918421983718872, -0.2678918242454529, 0.0),
(0.0, -0.048707593232393265, 0.0),
(0.0, -0.048707593232393265, 0.0),
(0.21918421983718872, -0.2678918242454529, 0.0),
(0.048707593232393265, 0.0, 0.0),
)
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)
class GizmoArrow(GizmoMovable):
"""Arrow gizmo for directional value editing."""
bl_idname = "BIM_GT_gizmo_arrow"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
def _get_arrow_triangles(self) -> Tuple[Tuple[float, float, float], ...]:
"""Generate arrow geometry along +X axis."""
triangles = []
triangles.extend([
(0, -ARROW_WIDTH, 0),
(ARROW_SHAFT_LENGTH, -ARROW_WIDTH, 0),
(0, +ARROW_WIDTH, 0),
])
triangles.extend([
(0, +ARROW_WIDTH, 0),
(ARROW_SHAFT_LENGTH, -ARROW_WIDTH, 0),
(ARROW_SHAFT_LENGTH, +ARROW_WIDTH, 0),
])
triangles.extend([
(0, 0, -ARROW_WIDTH),
(ARROW_SHAFT_LENGTH, 0, -ARROW_WIDTH),
(0, 0, +ARROW_WIDTH),
])
triangles.extend([
(0, 0, +ARROW_WIDTH),
(ARROW_SHAFT_LENGTH, 0, -ARROW_WIDTH),
(ARROW_SHAFT_LENGTH, 0, +ARROW_WIDTH),
])
head_width = ARROW_WIDTH * ARROW_HEAD_WIDTH_MULTIPLIER
triangles.extend([
(ARROW_SHAFT_LENGTH, -head_width, 0),
(ARROW_SHAFT_LENGTH + ARROW_HEAD_LENGTH, 0, 0),
(ARROW_SHAFT_LENGTH, +head_width, 0),
])
triangles.extend([
(ARROW_SHAFT_LENGTH, 0, -head_width),
(ARROW_SHAFT_LENGTH + ARROW_HEAD_LENGTH, 0, 0),
(ARROW_SHAFT_LENGTH, 0, +head_width),
])
for i in range(ARROW_CIRCLE_SEGMENTS):
angle1 = (2 * math.pi * i) / ARROW_CIRCLE_SEGMENTS
angle2 = (2 * math.pi * (i + 1)) / ARROW_CIRCLE_SEGMENTS
y1 = head_width * math.cos(angle1)
z1 = head_width * math.sin(angle1)
y2 = head_width * math.cos(angle2)
z2 = head_width * math.sin(angle2)
triangles.extend([
(ARROW_SHAFT_LENGTH, 0, 0),
(ARROW_SHAFT_LENGTH, y1, z1),
(ARROW_SHAFT_LENGTH, y2, z2),
])
return tuple(triangles)
def setup(self) -> None:
self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_triangles())
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)
class GizmoCone(GizmoMovable):
"""Cone gizmo for directional value editing with local axis support."""
bl_idname = "BIM_GT_gizmo_cone"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
def get_axis_direction(self) -> Vector:
if hasattr(self, "local_axis") and self.active_obj:
obj_rotation = self.active_obj.matrix_world.to_3x3()
axis_direction = obj_rotation @ self.local_axis
axis_direction.normalize()
return axis_direction
return self.axis
def _get_cone_triangles(self) -> Tuple[Tuple[float, float, float], ...]:
"""Generate cone geometry along +X axis."""
triangles = []
cone_tip_x = CONE_LENGTH
for i in range(CONE_SEGMENTS):
angle1 = (2 * math.pi * i) / CONE_SEGMENTS
angle2 = (2 * math.pi * (i + 1)) / CONE_SEGMENTS
y1 = CONE_RADIUS * math.cos(angle1)
z1 = CONE_RADIUS * math.sin(angle1)
y2 = CONE_RADIUS * math.cos(angle2)
z2 = CONE_RADIUS * math.sin(angle2)
triangles.extend([
(cone_tip_x, 0, 0),
(0, y1, z1),
(0, y2, z2),
])
triangles.extend([
(0, 0, 0),
(0, y2, z2),
(0, y1, z1),
])
return tuple(triangles)
def setup(self) -> None:
self.custom_shape = self.new_custom_shape("TRIS", self._get_cone_triangles())
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)
@@ -178,6 +178,8 @@ classes = (
door.FinishEditingDoor,
door.EnableEditingDoor,
door.RemoveDoor,
door.ToggleDoorSwing,
door.GizmoDoorEdition,
railing.BIM_OT_add_railing,
railing.CopyRailingParameters,
railing.AddRailing,
+325 -4
View File
@@ -34,6 +34,7 @@ import bonsai.core.geometry as core
import bonsai.core.root
from bonsai.bim.module.model.window import create_bm_window, create_bm_box
import math
from mathutils import Vector, Matrix
from typing import Union, Any, Optional
@@ -278,7 +279,7 @@ def create_bm_door_lining(
new_faces = [bm.faces.new([new_verts[vi] for vi in face[1]]) for face in faces]
extruded = bmesh.ops.extrude_face_region(bm, geom=new_faces)
extrusion_vector = Vector((0, 1, 0)) * depth
extrusion_vector = V_(0, 1, 0) * depth
translate_verts = [v for v in extruded["geom"] if isinstance(v, bmesh.types.BMVert)]
bmesh.ops.translate(bm, vec=extrusion_vector, verts=translate_verts)
@@ -487,10 +488,10 @@ class BIM_OT_add_door(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
def poll(cls, context: bpy.types.Context) -> bool:
return tool.Ifc.get() and context.mode == "OBJECT"
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if not ifc_file:
self.report({"ERROR"}, "You need to start IFC project first to create a door.")
@@ -553,7 +554,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
)
update_door_modifier_representation(obj)
def _execute(self, context):
def _execute(self, context: bpy.types.Context) -> set[str]:
for obj in tool.Blender.get_selected_objects():
if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj):
continue
@@ -564,6 +565,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
class CancelEditingDoor(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:
@@ -598,6 +600,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingDoor(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):
@@ -634,6 +637,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingDoor(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):
@@ -677,3 +681,320 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
for obj in tool.Blender.get_selected_objects():
self.remove_door_on_object(obj)
return {"FINISHED"}
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
"""Toggle door swing direction between LEFT and RIGHT"""
bl_idname = "bim.toggle_door_swing"
bl_label = "Toggle Door Swing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = tool.Blender.get_active_object()
if not obj:
return {"CANCELLED"}
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_door(element):
return {"CANCELLED"}
props = tool.Model.get_door_props(obj)
current_type = props.door_type
if "LEFT" in current_type:
props.door_type = current_type.replace("LEFT", "RIGHT")
elif "RIGHT" in current_type:
props.door_type = current_type.replace("RIGHT", "LEFT")
return {"FINISHED"}
class GizmoDoorEdition(bpy.types.GizmoGroup):
bl_idname = "OBJECT_GGT_bim_door_edition"
bl_label = "Door Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
COLOR_RED = (1.0, 0.2, 0.2)
COLOR_GREEN = (0.1, 0.8, 0.1)
COLOR_BLUE = (0.2, 0.2, 1.0)
ARROW_SCALE = 0.25
@classmethod
def get_arrow_color_from_axis(cls, axis):
"""Get arrow color based on axis direction (X=red, Y=green, Z=blue)."""
if axis[0] != 0:
return cls.COLOR_RED
elif axis[1] != 0:
return cls.COLOR_GREEN
else:
return cls.COLOR_BLUE
gizmo_props = [
{"attr_name": "overall_height", "axis": (0, 0, 1)},
{"attr_name": "overall_width", "axis": (1, 0, 0)},
{"attr_name": "threshold_thickness", "axis": (0, 0, 1)},
{"attr_name": "threshold_depth", "axis": (0, 1, 0)},
{"attr_name": "lining_depth", "axis": (0, 1, 0)},
{"attr_name": "lining_thickness", "axis": (1, 0, 0)},
{"attr_name": "transom_offset", "axis": (0, 0, 1)},
{"attr_name": "transom_thickness", "axis": (0, 0, 1)},
]
@classmethod
def poll(cls, context):
"""Show gizmo only when a single door object is selected and active."""
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:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_door(element):
return False
return True
def get_axis_rotation_matrix(self, axis):
"""Get rotation matrix to align arrow (default +X direction) with the given axis.
Args:
axis: Tuple of (x, y, z) representing the target axis direction
Returns:
Rotation matrix to align arrow with the axis
"""
axis_vec = V_(*axis).normalized()
default_dir = V_(1, 0, 0)
return default_dir.rotation_difference(axis_vec).to_matrix().to_4x4()
def get_gizmo_matrix_overall_height(self, props):
translation = Matrix.Translation(V_(props.overall_width - 0.05, 0.0, props.overall_height))
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_overall_width(self, props):
translation = Matrix.Translation(V_(props.overall_width, 0.0, props.overall_height - 0.05))
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_threshold_thickness(self, props):
translation = Matrix.Translation(
V_(
props.overall_width / 2,
props.threshold_offset + props.threshold_depth / 2,
props.threshold_thickness,
)
)
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_threshold_depth(self, props):
translation = Matrix.Translation(
V_(props.overall_width / 2, props.threshold_depth, props.threshold_thickness / 2)
)
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_lining_depth(self, props):
translation = Matrix.Translation(
V_(props.overall_width / 2, props.lining_depth + props.lining_offset, props.overall_height)
)
rotation = self.get_axis_rotation_matrix((0, 1, 0))
return translation @ rotation
def get_gizmo_matrix_lining_thickness(self, props):
translation = Matrix.Translation(
V_(props.lining_thickness, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2)
)
rotation = self.get_axis_rotation_matrix((1, 0, 0))
return translation @ rotation
def get_gizmo_matrix_transom_offset(self, props):
translation = Matrix.Translation(V_(props.overall_width / 2, 0.0, props.transom_offset))
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def get_gizmo_matrix_transom_thickness(self, props):
translation = Matrix.Translation(
V_(props.overall_width / 2, 0.0, props.transom_offset + props.transom_thickness)
)
rotation = self.get_axis_rotation_matrix((0, 0, 1))
return translation @ rotation
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
inactive_color = prefs.decorator_color_background[:3]
special_color = prefs.decorator_color_special[:3]
def add_gizmo_prop(prop_config):
attr_name = prop_config["attr_name"]
gizmo = self.gizmos.new("BIM_GT_gizmo_cone")
def move_get():
obj = bpy.context.active_object
if not obj:
return 0.0
props = tool.Model.get_door_props(obj)
return getattr(props, attr_name)
def move_set(value):
obj = bpy.context.active_object
if not obj:
return
props = tool.Model.get_door_props(obj)
setattr(props, attr_name, max(0.0, value))
# Set callbacks on the gizmo instance
gizmo.move_get_cb = move_get
gizmo.move_set_cb = move_set
gizmo.axis = V_(*prop_config["axis"])
gizmo.local_axis = V_(*prop_config["axis"])
gizmo.color = self.get_arrow_color_from_axis(prop_config["axis"])
gizmo.color_highlight = highlight_color
gizmo.alpha = 0.99
gizmo.use_draw_modal = True
gizmo.use_draw_scale = True
setattr(self, f"gizmo_{attr_name}", gizmo)
for prop_config in self.gizmo_props:
add_gizmo_prop(prop_config)
self.gizmo_door_type = self.gizmos.new("VIEW3D_GT_arc")
self.gizmo_door_type.use_draw_scale = False
self.gizmo_door_type.color = special_color
self.gizmo_door_type.alpha = 0.5
self.gizmo_door_type.color_highlight = highlight_color
self.gizmo_door_type.prop_path = "BIMDoorProperties.door_type"
self.gizmo_door_type.target_set_operator("bim.toggle_door_swing")
# Flip arc gizmo - mirrors the swing arc along X axis, flips door when clicked
self.gizmo_flip_arc = self.gizmos.new("VIEW3D_GT_arc")
self.gizmo_flip_arc.use_draw_scale = False
self.gizmo_flip_arc.color = inactive_color
self.gizmo_flip_arc.alpha = 0.5
self.gizmo_flip_arc.color_highlight = highlight_color
self.gizmo_flip_arc.prop_path = "BIMDoorProperties.door_type"
op = self.gizmo_flip_arc.target_set_operator("bim.flip_object")
op.flip_local_axes = "XY"
self.pen_gizmo = self.gizmos.new("VIEW3D_GT_pen")
self.pen_gizmo.use_draw_scale = False
self.pen_gizmo.color = default_color
self.pen_gizmo.color_highlight = highlight_color
self.pen_gizmo.alpha = 0.8
self.pen_gizmo.target_set_operator("bim.enable_editing_door")
self.validate_gizmo = self.gizmos.new("VIEW3D_GT_validate")
self.validate_gizmo.use_draw_scale = False
self.validate_gizmo.color = self.COLOR_GREEN
self.validate_gizmo.color_highlight = highlight_color
self.validate_gizmo.alpha = 0.8
self.validate_gizmo.target_set_operator("bim.finish_editing_door")
self.cancel_gizmo = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_gizmo.use_draw_scale = False
self.cancel_gizmo.color = self.COLOR_RED
self.cancel_gizmo.color_highlight = highlight_color
self.cancel_gizmo.alpha = 0.8
self.cancel_gizmo.target_set_operator("bim.cancel_editing_door")
def refresh(self, context):
obj = context.active_object
if not obj:
return
props = tool.Model.get_door_props(obj)
mw = obj.matrix_world
self.update_arrows(mw, props)
self.update_swing_gizmo(mw, props)
self.update_editing_gizmos(mw, props)
def update_arrows(self, mw, props):
"""Update arrow gizmos position and visibility based on editing state."""
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
for prop_config in self.gizmo_props:
attr_name = prop_config["attr_name"]
gizmo = getattr(self, f"gizmo_{attr_name}", None)
if gizmo is None:
continue
if not getattr(door_gizmo_prefs, attr_name, True):
gizmo.hide = True
continue
if (
not props.is_editing
or (attr_name == "threshold_depth" and props.threshold_thickness == 0.0)
or (attr_name == "transom_offset" and props.transom_thickness == 0.0)
):
gizmo.hide = True
continue
# Update position and show arrow when editing
gizmo.hide = False
matrix_method = getattr(self, f"get_gizmo_matrix_{attr_name}", None)
if matrix_method:
gizmo.matrix_basis = mw @ matrix_method(props)
gizmo.matrix_offset = Matrix.Scale(self.ARROW_SCALE, 4)
def update_swing_gizmo(self, mw, props):
"""Update swing gizmo position and color based on editing state."""
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
self.gizmo_door_type.hide = not props.is_editing or not door_gizmo_prefs.swing_arc
self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc
# If both are hidden, no need to calculate transforms
if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide:
return
# Calculate base swing transformation (common to both arcs)
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
base_swing_transform = Matrix.Translation(V_(swing_x_offset, 0, 0)) @ Matrix.Scale(props.overall_width, 4)
if not self.gizmo_door_type.hide:
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
self.gizmo_door_type.color = prefs.decorations_colour[:3]
if not self.gizmo_flip_arc.hide:
mirror_x = Matrix.Scale(-1, 4, (0, 1, 0))
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_x
def update_editing_gizmos(self, mw, props):
"""Update editing control gizmos (pen/validate/cancel) visibility and position."""
icon_z = props.overall_height + 0.5
local_transform = (
Matrix.Translation(V_(0, 0.0, icon_z))
@ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0))
@ Matrix.Scale(0.5, 4)
)
icon_matrix_base = mw @ local_transform
if props.is_editing:
self.pen_gizmo.hide = True
self.validate_gizmo.hide = False
self.validate_gizmo.matrix_basis = icon_matrix_base
self.cancel_gizmo.hide = False
cancel_local = Matrix.Translation(V_(0.5, 0.0, 0.0)) @ local_transform
self.cancel_gizmo.matrix_basis = mw @ cancel_local
else:
self.pen_gizmo.hide = False
self.pen_gizmo.matrix_basis = icon_matrix_base
self.validate_gizmo.hide = True
self.cancel_gizmo.hide = True
+4 -4
View File
@@ -928,14 +928,14 @@ class BIMDoorProperties(PropertyGroup):
lining_depth: bpy.props.FloatProperty(
name="Lining Depth",
default=0.050,
min=0,
min=0.001,
subtype="DISTANCE",
update=update_door,
)
lining_thickness: bpy.props.FloatProperty(
name="Lining Thickness",
default=0.050,
min=0,
min=0.001,
subtype="DISTANCE",
update=update_door,
)
@@ -983,7 +983,7 @@ class BIMDoorProperties(PropertyGroup):
casing_depth: bpy.props.FloatProperty(
name="Casing Depth",
default=0.005,
min=0,
min=0.001,
subtype="DISTANCE",
update=update_door,
)
@@ -999,7 +999,7 @@ class BIMDoorProperties(PropertyGroup):
threshold_depth: bpy.props.FloatProperty(
name="Threshold Depth",
default=0.1,
min=0,
min=0.001,
subtype="DISTANCE",
update=update_door,
)
+62
View File
@@ -242,6 +242,48 @@ class BIM_UL_generic(bpy.types.UIList):
layout.label(text="", translate=False)
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
"""Property group for door gizmo visibility settings."""
overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True)
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
transom_offset: BoolProperty(name="Transom Offset", default=True)
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
if TYPE_CHECKING:
overall_height: bool
overall_width: bool
threshold_thickness: bool
threshold_depth: bool
lining_depth: bool
lining_thickness: bool
transom_offset: bool
transom_thickness: bool
swing_arc: bool
flip_arc: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
draw_gizmos_in_3d_viewport: BoolProperty(
name="Draw Gizmos In 3D Viewport",
default=True,
description="Show interactive gizmos in the 3D viewport for parametric elements",
)
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
class DocPreferences(bpy.types.PropertyGroup):
sheets_dir: StringProperty(
default=os.path.join("sheets") + os.path.sep,
@@ -515,6 +557,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
name="Occurrence Name Function",
description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style",
)
gizmos: bpy.props.PointerProperty(type=GizmoPreferences)
def update_data_dir(self, context: bpy.types.Context) -> None:
import bonsai.bim.schema
@@ -580,6 +623,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
should_always_cache: bool
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
occurrence_name_function: str
gizmos: GizmoPreferences
data_dir: str
cache_dir: str
pset_dir: str
@@ -627,6 +671,24 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.prop(self, "should_use_snap")
layout.prop(self, "should_play_chaching_sound")
layout.prop(self.gizmos, "draw_gizmos_in_3d_viewport")
if self.gizmos.draw_gizmos_in_3d_viewport:
bonsai.bim.helper.draw_expandable_panel(
layout,
context,
"Gizmos Parameters",
self.draw_gizmo_parameters,
)
def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
box = layout.box()
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
door_gizmos = self.gizmos.door
for prop in door_gizmos.__annotations__:
layout.prop(door_gizmos, prop)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
if self.occurrence_name_style == "CUSTOM":