mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user