mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Add anchor gizmo dots, keymap click handler, and auto-sync for parametric dimensions
- GizmoAnchorHandle + DimensionAnchorWidget: colored dot gizmos at each dimension curve vertex (green=anchored, orange=free); color changes to blue while SetDimensionAnchor is in PICK_FACE mode for that vertex - ClickNearestDimensionAnchor (LMB keymap): Python proximity operator that fires SetDimensionAnchor pre-targeted at the nearest anchor dot within 120px, returning PASS_THROUGH for misses so normal viewport clicks are unaffected - SetDimensionAnchor: added anchor_index prop to enter PICK_FACE directly; set_active_anchor called at all phase transitions (invoke, vertex-pick, face-pick, alt-click free, ESC/RMB) so gizmo color tracks state correctly - handler._sync_dimension_anchors_to_curve: proximity-based anchor sync when curve vertex count changes in Edit Mode (subdivide / delete) - depsgraph_update_post_handler: regenerates dimensions when referenced elements move; also handles annotation curve edits directly - Remove standalone Set Anchor button from annotation tool UI (replaced by clicking a gizmo dot) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,8 @@ classes = (
|
||||
operator.DrawParametricDimension,
|
||||
operator.SetDimensionAnchor,
|
||||
operator.RegenerateDimensions,
|
||||
operator.ClickNearestDimensionAnchor,
|
||||
operator.DebugDimensionClicks,
|
||||
prop.Variable,
|
||||
prop.Drawing,
|
||||
prop.Document,
|
||||
@@ -175,12 +177,17 @@ classes = (
|
||||
gizmos.UglyDotGizmo,
|
||||
gizmos.ExtrusionGuidesGizmo,
|
||||
gizmos.ExtrusionWidget,
|
||||
gizmos.GizmoAnchorHandle,
|
||||
gizmos.DimensionAnchorWidget,
|
||||
gizmos.DimensionLinePositionWidget,
|
||||
workspace.LaunchAnnotationTypeManager,
|
||||
workspace.Hotkey,
|
||||
)
|
||||
|
||||
|
||||
_keymaps = []
|
||||
|
||||
|
||||
def menu_func(self, context):
|
||||
active_obj = context.active_object
|
||||
if active_obj:
|
||||
@@ -204,6 +211,15 @@ def register():
|
||||
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
|
||||
|
||||
wm = bpy.context.window_manager
|
||||
kc = wm.keyconfigs.addon
|
||||
if kc:
|
||||
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
||||
kmi = km.keymap_items.new(
|
||||
"bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS"
|
||||
)
|
||||
_keymaps.append((km, kmi))
|
||||
|
||||
|
||||
def unregister():
|
||||
if not bpy.app.background:
|
||||
@@ -217,5 +233,9 @@ def unregister():
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
|
||||
bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
|
||||
|
||||
for km, kmi in _keymaps:
|
||||
km.keymap_items.remove(kmi)
|
||||
_keymaps.clear()
|
||||
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
|
||||
|
||||
@@ -2297,6 +2297,19 @@ DISC = (
|
||||
(1.0, 0.0, 0),
|
||||
)
|
||||
|
||||
# Anchor index currently being edited by SetDimensionAnchor (-1 = none).
|
||||
_active_anchor_idx: int = -1
|
||||
# The annotation curve object being edited (kept so the gizmo group stays
|
||||
# visible even when SetDimensionAnchor temporarily changes the active object).
|
||||
_editing_annotation_obj = None
|
||||
|
||||
|
||||
def set_active_anchor(idx: int, annotation_obj=None) -> None:
|
||||
global _active_anchor_idx, _editing_annotation_obj
|
||||
_active_anchor_idx = idx
|
||||
_editing_annotation_obj = annotation_obj if idx >= 0 else None
|
||||
|
||||
|
||||
X3DISC = (
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0),
|
||||
@@ -2622,6 +2635,152 @@ class ExtrusionWidget(types.GizmoGroup):
|
||||
self.guides.target_set_prop("depth", prop, "value")
|
||||
|
||||
|
||||
class GizmoAnchorHandle(bpy.types.Gizmo):
|
||||
"""Dot gizmo positioned at one vertex of a parametric dimension curve.
|
||||
|
||||
Clicking it invokes ``bim.set_dimension_anchor`` pre-scoped to that vertex
|
||||
index, skipping the manual vertex-pick phase of the operator.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_anchor_handle"
|
||||
|
||||
__slots__ = ("anchor_index", "custom_shape", "custom_shape_select")
|
||||
|
||||
def setup(self):
|
||||
self.anchor_index = 0
|
||||
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
|
||||
# 4× scaled version used only for hit-detection — bigger target, same visual size.
|
||||
_sel = tuple((x * 4.0, y * 4.0, z) for x, y, z in X3DISC)
|
||||
self.custom_shape_select = self.new_custom_shape(type="TRIS", verts=_sel)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context, select_id):
|
||||
self.draw_custom_shape(self.custom_shape_select, select_id=select_id)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def modal(self, context, event, tweak):
|
||||
anchor_index = self.anchor_index
|
||||
|
||||
def _launch():
|
||||
try:
|
||||
bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=anchor_index)
|
||||
except Exception as e:
|
||||
print(f"[DimensionAnchorWidget] {e}")
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_launch, first_interval=0.0)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DimensionAnchorWidget(types.GizmoGroup):
|
||||
"""Anchor handle gizmos at each vertex of the active parametric dimension.
|
||||
|
||||
Green dots indicate vertices that are anchored to an IFC element face;
|
||||
orange dots are free world-point anchors. Clicking any dot fires
|
||||
``bim.set_dimension_anchor`` pre-targeted at that vertex index.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GGT_dimension_anchors"
|
||||
bl_label = "Dimension Anchor Handles"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
|
||||
|
||||
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"))
|
||||
_MAX_ANCHORS = 16
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
# Stay visible while SetDimensionAnchor is running (active obj may be a temp element).
|
||||
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
|
||||
return True
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return False
|
||||
import ifcopenshell.util.element as _ue
|
||||
if _ue.get_predefined_type(element) not in cls._DIM_TYPES:
|
||||
return False
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
return bool(pset and pset.get("Anchors"))
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
self._handles: list = []
|
||||
for _ in range(self._MAX_ANCHORS):
|
||||
gz = self.gizmos.new("BIM_GT_anchor_handle")
|
||||
gz.scale_basis = 0.18
|
||||
gz.select_bias = -32.0
|
||||
gz.use_draw_modal = True
|
||||
gz.hide = True
|
||||
self._handles.append(gz)
|
||||
|
||||
def refresh(self, context: bpy.types.Context) -> None:
|
||||
import json
|
||||
import ifcopenshell.util.element as _ue
|
||||
|
||||
obj = _editing_annotation_obj if _active_anchor_idx >= 0 and _editing_annotation_obj else context.active_object
|
||||
if not obj or not obj.data or not getattr(obj.data, "splines", None):
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
try:
|
||||
anchors = json.loads(pset["Anchors"])
|
||||
except Exception:
|
||||
for gz in self._handles:
|
||||
gz.hide = True
|
||||
return
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
n = min(len(spline.points), len(anchors), self._MAX_ANCHORS)
|
||||
|
||||
for i in range(n):
|
||||
gz = self._handles[i]
|
||||
world_co = obj.matrix_world @ spline.points[i].co.to_3d()
|
||||
gz.matrix_basis = Matrix.Translation(world_co)
|
||||
gz.anchor_index = i
|
||||
if i == _active_anchor_idx and obj is _editing_annotation_obj:
|
||||
print(f"[refresh] setting anchor[{i}] BLUE (obj={obj.name} editing={_editing_annotation_obj.name if _editing_annotation_obj else None})")
|
||||
gz.color = (0.2, 0.7, 1.0)
|
||||
gz.color_highlight = (0.4, 0.85, 1.0)
|
||||
elif anchors[i].get("guid"):
|
||||
gz.color = (0.2, 0.85, 0.2)
|
||||
gz.color_highlight = (0.4, 1.0, 0.4)
|
||||
else:
|
||||
gz.color = (0.9, 0.6, 0.1)
|
||||
gz.color_highlight = (1.0, 0.85, 0.2)
|
||||
gz.alpha = 0.85
|
||||
gz.alpha_highlight = 1.0
|
||||
gz.hide = False
|
||||
|
||||
for i in range(n, self._MAX_ANCHORS):
|
||||
self._handles[i].hide = True
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
print(f"[draw_prepare] DimensionAnchorWidget _active_anchor_idx={_active_anchor_idx}")
|
||||
self.refresh(context)
|
||||
|
||||
|
||||
class DimensionLinePositionWidget(types.GizmoGroup):
|
||||
"""Drag handle for the LinePosition of a parametric dimension annotation.
|
||||
|
||||
@@ -2725,6 +2884,7 @@ class DimensionLinePositionWidget(types.GizmoGroup):
|
||||
return self._midpoint(obj).dot(od)
|
||||
|
||||
def _set_pos(self, value: float) -> None:
|
||||
bpy.ops.ed.undo_push(message="Set Line Position")
|
||||
import json
|
||||
import numpy as np
|
||||
import ifcopenshell.util.element as _ue
|
||||
|
||||
@@ -111,6 +111,80 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
scene_render.resolution_y = raster_y
|
||||
|
||||
|
||||
def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
||||
"""Sync BBIM_Dimension.Anchors length to match the curve's spline point count.
|
||||
|
||||
Called when the user adds or removes vertices from a dimension annotation in
|
||||
Edit Mode. New vertices get a free WORLD-type anchor at their current world
|
||||
position; removed tail vertices simply lose their anchor entries.
|
||||
|
||||
Returns True if the pset was changed.
|
||||
"""
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.pset
|
||||
|
||||
if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines:
|
||||
return False
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
return False
|
||||
|
||||
try:
|
||||
anchors: list = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
spline = obj.data.splines[0]
|
||||
spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points]
|
||||
n_pts = len(spline_world)
|
||||
n_anchors = len(anchors)
|
||||
|
||||
print(f"[sync_anchors] obj={obj.name} spline_pts={n_pts} anchors={n_anchors}")
|
||||
|
||||
if n_pts == n_anchors:
|
||||
print("[sync_anchors] counts match — no change needed")
|
||||
return False
|
||||
|
||||
# Match each spline point to the nearest unused anchor by proximity.
|
||||
# This handles insertions (subdivide) and deletions correctly regardless
|
||||
# of where in the polyline the edit happened.
|
||||
_MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints
|
||||
used: set = set()
|
||||
new_anchors: list = []
|
||||
|
||||
for pt in spline_world:
|
||||
best_idx, best_sq = None, float("inf")
|
||||
for i, anc in enumerate(anchors):
|
||||
if i in used:
|
||||
continue
|
||||
stored = anc.get("pt")
|
||||
if not stored:
|
||||
continue
|
||||
dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z
|
||||
sq = dx * dx + dy * dy + dz * dz
|
||||
if sq < best_sq:
|
||||
best_sq, best_idx = sq, i
|
||||
if best_idx is not None and best_sq < _MATCH_THRESH_SQ:
|
||||
new_anchors.append(anchors[best_idx])
|
||||
used.add(best_idx)
|
||||
else:
|
||||
new_anchors.append({
|
||||
"guid": None,
|
||||
"type": "WORLD",
|
||||
"addr": {},
|
||||
"hint": None,
|
||||
"pt": [pt.x, pt.y, pt.z],
|
||||
})
|
||||
|
||||
print(f"[sync_anchors] rebuilt {len(new_anchors)} anchors (was {n_anchors})")
|
||||
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
||||
invalidate_dim_index()
|
||||
return True
|
||||
|
||||
|
||||
@persistent
|
||||
def depsgraph_update_post_handler(scene, depsgraph):
|
||||
"""Auto-regenerate parametric dimensions when referenced elements are moved."""
|
||||
@@ -128,6 +202,8 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
# serialised the new mesh back to IFC via update_representation, so the
|
||||
# tessellation will reflect the edited shape.
|
||||
moved_guids: set = set()
|
||||
edited_annotation_ids: set = set()
|
||||
|
||||
for update in depsgraph.updates:
|
||||
obj = update.id
|
||||
if not isinstance(obj, bpy.types.Object):
|
||||
@@ -137,18 +213,28 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not hasattr(element, "GlobalId"):
|
||||
continue
|
||||
|
||||
# If the dimension annotation curve itself was edited (vertex added/removed),
|
||||
# sync the anchor list before regenerating.
|
||||
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
||||
import ifcopenshell.util.element as _ue
|
||||
ptype = _ue.get_predefined_type(element)
|
||||
print(f"[handler] dimension curve geometry updated: {obj.name} ptype={ptype} is_updated_geometry={update.is_updated_geometry}")
|
||||
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
||||
_sync_dimension_anchors_to_curve(file, element, obj)
|
||||
edited_annotation_ids.add(element.id())
|
||||
continue
|
||||
|
||||
moved_guids.add(element.GlobalId)
|
||||
# Geometry edits invalidate the cached tessellation for this element.
|
||||
if update.is_updated_geometry:
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
if not moved_guids:
|
||||
return
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
annotation_ids: set = set()
|
||||
print(f"[handler] edited_annotation_ids={edited_annotation_ids} moved_guids={moved_guids}")
|
||||
annotation_ids: set = set(edited_annotation_ids)
|
||||
for guid in moved_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
@@ -196,6 +282,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[handler] regenerating ann_id={ann_id} anchors_in_pset={len(json.loads(pset.get('Anchors') or '[]'))}")
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
@@ -203,7 +290,13 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
print(f"[handler] resolved_pts count={len(resolved_pts)} pts={[(round(p[0],3),round(p[1],3),round(p[2],3)) for p in resolved_pts]}")
|
||||
if resolved_pts:
|
||||
obj = tool.Ifc.get_object(annotation)
|
||||
if obj:
|
||||
print(f"[handler] curve spline pts before update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
if obj:
|
||||
print(f"[handler] curve spline pts after update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
||||
finally:
|
||||
_dim_handler_running = False
|
||||
|
||||
@@ -5935,8 +5935,10 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
bl_label = "Set Dimension Anchor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
anchor_index: bpy.props.IntProperty(default=-1) # -1 = begin with vertex-pick phase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
anchor_index: int
|
||||
|
||||
_annotation: Optional[ifcopenshell.entity_instance] = None
|
||||
_annotation_obj: Optional[bpy.types.Object] = None
|
||||
@@ -5981,9 +5983,15 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
obj = context.active_object
|
||||
self._annotation = tool.Ifc.get_entity(obj)
|
||||
self._annotation_obj = obj
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._active_vertex_idx = -1
|
||||
self._shape_cache = {}
|
||||
if self.anchor_index >= 0:
|
||||
self._phase = "PICK_FACE"
|
||||
self._active_vertex_idx = self.anchor_index
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(self.anchor_index, obj)
|
||||
else:
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._active_vertex_idx = -1
|
||||
self._hover_candidates = []
|
||||
self._hover_index = 0
|
||||
self._hover_last_px = (-9999, -9999)
|
||||
@@ -6010,6 +6018,8 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
||||
self._clear_hover_highlight(context)
|
||||
context.workspace.status_text_set(None)
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
return {"FINISHED"} # keep any anchors already written
|
||||
|
||||
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
||||
@@ -6085,6 +6095,8 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
|
||||
self._active_vertex_idx = best_idx
|
||||
self._phase = "PICK_FACE"
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(best_idx, obj)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 2: pick a face on an IFC element
|
||||
@@ -6127,6 +6139,8 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
self._write_anchor(anchor, self._active_vertex_idx)
|
||||
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
||||
self._phase = "PICK_VERTEX"
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
return
|
||||
|
||||
# Normal click — use whichever candidate is currently highlighted.
|
||||
@@ -6177,6 +6191,8 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._hover_candidates = []
|
||||
self._hover_index = 0
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hover / cycle helpers
|
||||
@@ -6474,12 +6490,20 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension")
|
||||
]
|
||||
|
||||
from bonsai.bim.module.drawing.handler import _sync_dimension_anchors_to_curve
|
||||
|
||||
updated = 0
|
||||
for annotation in candidates:
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
|
||||
# Sync anchor count to curve vertex count in case the user added or
|
||||
# removed vertices in Edit Mode since the last regeneration.
|
||||
ann_obj = tool.Ifc.get_object(annotation)
|
||||
if ann_obj and ann_obj.type == "CURVE":
|
||||
_sync_dimension_anchors_to_curve(file, annotation, ann_obj)
|
||||
|
||||
# Build a placement override from each referenced element's current
|
||||
# Blender matrix_world. Bonsai only syncs ObjectPlacement to the IFC
|
||||
# file when the user explicitly clicks "Edit Object Placement" — so the
|
||||
@@ -6690,3 +6714,169 @@ def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopens
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
||||
"""LMB handler: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot.
|
||||
|
||||
Registered as a keymap item so it runs before Blender's object-selection handler.
|
||||
Returns PASS_THROUGH when the cursor is not near any anchor, so normal viewport
|
||||
clicks are unaffected.
|
||||
"""
|
||||
|
||||
bl_idname = "bim.click_nearest_dimension_anchor"
|
||||
bl_label = "Click Nearest Dimension Anchor"
|
||||
|
||||
RADIUS_PX = 120
|
||||
|
||||
def invoke(self, context, event):
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
print(f"[AnchorClick] invoke called")
|
||||
|
||||
if not tool.Ifc.get():
|
||||
print(f"[AnchorClick] PASS_THROUGH — no IFC file")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
print(f"[AnchorClick] PASS_THROUGH — active obj is {obj} type={getattr(obj,'type',None)}")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
print(f"[AnchorClick] PASS_THROUGH — not an IfcAnnotation: {element}")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
import ifcopenshell.util.element as _ue
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
print(f"[AnchorClick] PASS_THROUGH — no BBIM_Dimension pset or Anchors")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
if not obj.data.splines:
|
||||
print(f"[AnchorClick] PASS_THROUGH — no splines")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Always use the 3D viewport WINDOW region — context.region may be a header,
|
||||
# sidebar, or toolbar depending on where the click landed in the area.
|
||||
region = None
|
||||
rv3d = None
|
||||
for area in context.screen.areas:
|
||||
if area.type != "VIEW_3D":
|
||||
continue
|
||||
for r in area.regions:
|
||||
if r.type == "WINDOW":
|
||||
region = r
|
||||
break
|
||||
if region:
|
||||
for space in area.spaces:
|
||||
if space.type == "VIEW_3D":
|
||||
rv3d = space.region_3d
|
||||
break
|
||||
break
|
||||
|
||||
if not region or not rv3d:
|
||||
print(f"[AnchorClick] PASS_THROUGH — no 3D region")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Convert absolute mouse position to WINDOW region-local coordinates.
|
||||
cx = event.mouse_x - region.x
|
||||
cy = event.mouse_y - region.y
|
||||
|
||||
r2 = self.RADIUS_PX ** 2
|
||||
best_idx = -1
|
||||
best_dist_sq = float("inf")
|
||||
for i, pt in enumerate(obj.data.splines[0].points):
|
||||
world_pos = obj.matrix_world @ pt.co.to_3d()
|
||||
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
if not sp:
|
||||
continue
|
||||
dx, dy = cx - sp.x, cy - sp.y
|
||||
d2 = dx * dx + dy * dy
|
||||
print(f"[AnchorClick] click=({cx},{cy}) anchor[{i}]=({sp.x:.0f},{sp.y:.0f}) dist={d2**0.5:.1f}px radius={self.RADIUS_PX}px")
|
||||
if d2 < r2 and d2 < best_dist_sq:
|
||||
best_dist_sq = d2
|
||||
best_idx = i
|
||||
|
||||
if best_idx < 0:
|
||||
print(f"[AnchorClick] MISS — no anchor within {self.RADIUS_PX}px")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
print(f"[AnchorClick] HIT anchor[{best_idx}] dist={best_dist_sq**0.5:.1f}px")
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(best_idx, obj)
|
||||
# Force viewport redraw so gizmo colors update before the modal starts.
|
||||
for area in context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
break
|
||||
bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=best_idx)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DebugDimensionClicks(bpy.types.Operator):
|
||||
"""Debug modal: logs every LMB click in the 3D viewport vs anchor gizmo positions.
|
||||
|
||||
Run from the Python console:
|
||||
bpy.ops.bim.debug_dimension_clicks('INVOKE_DEFAULT')
|
||||
Press ESC or RMB to stop.
|
||||
"""
|
||||
|
||||
bl_idname = "bim.debug_dimension_clicks"
|
||||
bl_label = "Debug Dimension Clicks"
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
||||
# Use absolute window coords — mouse_region_x/y is relative to whichever
|
||||
# region caught the event, which may differ from the 3D viewport region.
|
||||
click_x = event.mouse_x
|
||||
click_y = event.mouse_y
|
||||
|
||||
# Find the 3D viewport region — context.region_data is None in modal.
|
||||
region = None
|
||||
rv3d = None
|
||||
for area in context.screen.areas:
|
||||
if area.type != "VIEW_3D":
|
||||
continue
|
||||
for r in area.regions:
|
||||
if r.type == "WINDOW":
|
||||
region = r
|
||||
break
|
||||
if region:
|
||||
for space in area.spaces:
|
||||
if space.type == "VIEW_3D":
|
||||
rv3d = space.region_3d
|
||||
break
|
||||
break
|
||||
|
||||
obj = context.active_object
|
||||
if obj and obj.type == "CURVE" and obj.data and obj.data.splines and region and rv3d:
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) region_offset=({region.x},{region.y})")
|
||||
for i, pt in enumerate(obj.data.splines[0].points):
|
||||
world_pos = obj.matrix_world @ pt.co.to_3d()
|
||||
screen_pos = location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
if screen_pos:
|
||||
# Convert region-local pos to absolute window coords for comparison.
|
||||
abs_x = screen_pos.x + region.x
|
||||
abs_y = screen_pos.y + region.y
|
||||
dist = ((click_x - abs_x) ** 2 + (click_y - abs_y) ** 2) ** 0.5
|
||||
print(f" anchor[{i}] abs={abs_x:.0f},{abs_y:.0f} dist={dist:.1f}px")
|
||||
else:
|
||||
print(f" anchor[{i}] (off screen)")
|
||||
else:
|
||||
print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) — no active curve or no 3D region")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
if event.type in {"ESC", "RIGHTMOUSE"}:
|
||||
print("[ClickDebug] Stopped.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
context.window_manager.modal_handler_add(self)
|
||||
print("[ClickDebug] Dimension click logger started. Click near anchor dots; press ESC to stop.")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
@@ -243,7 +243,6 @@ class AnnotationToolUI:
|
||||
row.prop(ann_props, "line_position")
|
||||
cls.layout.separator()
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.set_dimension_anchor", icon="PIVOT_CURSOR")
|
||||
op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
|
||||
op.active_only = True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user