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:
Ryan Schultz
2026-05-17 07:09:45 -05:00
parent 22a17cd287
commit 49ecfbf494
5 changed files with 470 additions and 8 deletions
@@ -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