Add TAB snap cycling, snap decorators, and fix anchor dot activation for DrawParametricDimension

- DrawParametricDimension: TAB cycles snap mode FACE→LAYER→EDGE→VERTEX during
  placement; consumes both PRESS and RELEASE when not in input mode to avoid
  conflict with polyline Cycle Input
- DrawParametricDimension: IFC-native snap candidate overrides polyline cursor
  position in LAYER/EDGE/VERTEX modes; FACE mode shows polygon outline; reuses
  _snap_draw_data / _draw_snap_indicator_global infrastructure from SetDimensionAnchor
- DrawParametricDimension: LAYER_BOUNDARY support in _update_perp_constraint,
  deriving normal from LayerSetDirection (AXIS1/2/3)
- ClickNearestDimensionAnchor: scan all visible annotations instead of only
  selected ones — view3d.select deselects the dimension before this operator
  runs, so pre-selection check caused dots to never activate
- AnnotationTool keymap: bim.click_nearest_dimension_anchor placed before
  view3d.select so it fires first when the annotation tool is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-05-18 07:34:54 -05:00
parent b787d76ad6
commit ad9192027c
2 changed files with 245 additions and 16 deletions
+240 -15
View File
@@ -5584,9 +5584,14 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
bl_label = "Draw Parametric Dimension"
bl_options = {"REGISTER", "UNDO"}
_SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX")
if TYPE_CHECKING:
_anchors: list
_shape_cache: dict
_snap_mode: str
_ifc_snap_candidate: object # Optional[dict]
_draw_handler: object # SpaceView3D draw handler handle
@classmethod
def poll(cls, context):
@@ -5606,6 +5611,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
self._force_perpendicular = False
self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0]
self._anchor0_pt = None # (x, y, z) world-space position of anchor[0]
self._snap_mode = "FACE"
self._ifc_snap_candidate = None
self._draw_handler = None
# ------------------------------------------------------------------
# Snap → anchor bridge
@@ -5676,7 +5684,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
if count_after > count_before:
# A point was inserted — build its anchor.
if is_mouse_click and snap and snap.get("type") not in {"Axis", "Plane"}:
if is_mouse_click and self._ifc_snap_candidate:
self._anchors.append(self._build_ifc_anchor(self._ifc_snap_candidate))
elif is_mouse_click and snap and snap.get("type") not in {"Axis", "Plane"}:
self._anchors.append(self._snap_to_anchor(snap))
else:
# Keyboard-typed coordinate or close-loop: world anchor at the stored point.
@@ -5707,28 +5717,63 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
print(f"[perp] _update_perp_constraint: anchor type={a.get('type') if a else None} — need FACE, skipping")
return
addr = a.get("addr") or {}
normal_local = addr.get("normal_local")
pt = a.get("pt")
if not normal_local or not pt:
print(f"[perp] _update_perp_constraint: missing normal_local={normal_local} or pt={pt}")
if not pt:
print(f"[perp] _update_perp_constraint: missing pt")
return
# Rotate element-local normal to world space via the Blender object's matrix.
n = normal_local
method = addr.get("method", "FACE_NORMAL")
guid = a.get("guid")
if guid:
if method == "LAYER_BOUNDARY":
# Derive the thickness-axis normal from the element's LayerSetDirection.
if not guid:
print(f"[perp] _update_perp_constraint (LAYER): no guid")
return
try:
file = tool.Ifc.get()
element = file.by_guid(guid)
import ifcopenshell.util.element as _ifc_elem
usage = _ifc_elem.get_material(element, should_inherit=True)
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
print(f"[perp] _update_perp_constraint (LAYER): no IfcMaterialLayerSetUsage")
return
axis = getattr(usage, "LayerSetDirection", None) or "AXIS2"
if axis == "AXIS1":
normal_local = (1.0, 0.0, 0.0)
elif axis == "AXIS3":
normal_local = (0.0, 0.0, 1.0)
else:
normal_local = (0.0, 1.0, 0.0)
obj = tool.Ifc.get_object(element)
if obj:
nw = obj.matrix_world.to_3x3() @ Vector(normal_local)
nw.normalize()
n = (nw.x, nw.y, nw.z)
n: tuple = (nw.x, nw.y, nw.z)
else:
print(f"[perp] _update_perp_constraint: no Blender obj for guid={guid}, using normal_local as-is")
n = normal_local
except Exception as exc:
print(f"[perp] _update_perp_constraint: exception rotating normal: {exc}")
print(f"[perp] _update_perp_constraint (LAYER): {exc}")
return
else:
normal_local = addr.get("normal_local")
if not normal_local:
print(f"[perp] _update_perp_constraint: missing normal_local")
return
n = normal_local
if guid:
try:
file = tool.Ifc.get()
element = file.by_guid(guid)
obj = tool.Ifc.get_object(element)
if obj:
nw = obj.matrix_world.to_3x3() @ Vector(normal_local)
nw.normalize()
n = (nw.x, nw.y, nw.z)
else:
print(f"[perp] _update_perp_constraint: no Blender obj for guid={guid}, using normal_local as-is")
except Exception as exc:
print(f"[perp] _update_perp_constraint: exception rotating normal: {exc}")
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
if mag < 1e-12:
@@ -5757,6 +5802,155 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
print(f"[perp] _apply_perp_constraint: raw=({p.x:.3f},{p.y:.3f},{p.z:.3f}) t={t:.4f} constrained=({constrained.x:.3f},{constrained.y:.3f},{constrained.z:.3f})")
snap["point"] = constrained
# ------------------------------------------------------------------
# IFC-native snap for LAYER / VERTEX / EDGE modes
def _compute_ifc_snap_candidate(self, context, event) -> "Optional[dict]":
"""Return an IFC-native snap candidate for the current snap mode, or None."""
if self._snap_mode == "FACE" or not self.snapping_points:
return None
snap = self.snapping_points[0]
hit_obj = snap.get("object")
if not hit_obj:
return None
element = tool.Ifc.get_entity(hit_obj)
if not element or not hasattr(element, "GlobalId"):
return None
from bpy_extras.view3d_utils import location_3d_to_region_2d
import ifcopenshell.api.drawing as drawing_api
region = context.region
rv3d = context.region_data
mx, my = event.mouse_region_x, event.mouse_region_y
file = tool.Ifc.get()
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
if self._snap_mode == "LAYER":
cands = drawing_api.get_layer_snap_candidates(file, element, placement_override)
if not cands:
return None
best_cand, best_d2 = None, float("inf")
for cand in cands:
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
if sp is None:
continue
d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2
if d2 < best_d2:
best_d2 = d2
best_cand = cand
if best_cand is None:
return None
result = dict(best_cand)
result["method"] = "LAYER_BOUNDARY"
result["element"] = element
result["obj"] = hit_obj
return result
# VERTEX or EDGE — profile-based candidates
cands = drawing_api.get_profile_snap_candidates(file, element, placement_override)
if not cands:
return None
cands_of_type = [c for c in cands if c["type"] == self._snap_mode]
if not cands_of_type:
return None
best_cand, best_d2 = None, float("inf")
for cand in cands_of_type:
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
if sp is None:
continue
d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2
if d2 < best_d2:
best_d2 = d2
best_cand = cand
if best_cand is None:
return None
result = dict(best_cand)
result["element"] = element
result["obj"] = hit_obj
return result
def _build_ifc_anchor(self, candidate: dict) -> dict:
"""Build the correct anchor type from an IFC snap candidate."""
import ifcopenshell.api.drawing as drawing_api
element = candidate.get("element")
if not element:
pt = candidate.get("snap_world", (0.0, 0.0, 0.0))
return drawing_api.make_world_anchor(list(pt))
file = tool.Ifc.get()
if candidate.get("method") == "LAYER_BOUNDARY":
return drawing_api.build_anchor_from_layer_boundary(file, element, candidate)
snap_kind = candidate.get("snap")
if snap_kind == "VERTEX" and candidate.get("profile_x_m") is not None:
return drawing_api.build_anchor_from_profile_vert(file, element, candidate)
if snap_kind == "EDGE" and candidate.get("profile_x_m") is not None:
return drawing_api.build_anchor_from_profile_edge(file, element, candidate)
pt = candidate.get("snap_world", (0.0, 0.0, 0.0))
return drawing_api.make_world_anchor(list(pt))
def _update_snap_draw_data(self) -> None:
"""Populate _snap_draw_data so _draw_snap_indicator_global draws the right indicator."""
_snap_draw_data.clear()
if self._ifc_snap_candidate:
cand = self._ifc_snap_candidate
if cand.get("method") == "LAYER_BOUNDARY":
_snap_draw_data.update({
"type": "LAYER",
"seam_corners": cand.get("seam_corners", []),
"snap_world": cand.get("snap_world"),
})
elif cand.get("snap") == "EDGE":
_snap_draw_data.update({
"type": "EDGE",
"v0": cand.get("v0"),
"v1": cand.get("v1"),
"snap_world": cand.get("snap_world"),
})
else:
_snap_draw_data.update({
"type": "VERTEX",
"snap_world": cand.get("snap_world"),
})
return
# FACE mode: outline the hovered face polygon
if not self.snapping_points:
return
snap = self.snapping_points[0]
hit_obj = snap.get("object")
if not hit_obj or not hasattr(hit_obj.data, "polygons"):
return
pt_world = snap.get("point")
face_index = snap.get("face_index")
if face_index is None or face_index >= len(hit_obj.data.polygons):
if pt_world is not None:
local_pt = hit_obj.matrix_world.inverted() @ pt_world
ok, _loc, _n, face_index = hit_obj.closest_point_on_mesh(local_pt)
if not ok:
return
face_index = _prefer_perp_face_index(hit_obj, pt_world, face_index)
if face_index is None or face_index >= len(hit_obj.data.polygons):
return
face = hit_obj.data.polygons[face_index]
mx = hit_obj.matrix_world
face_verts = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
_snap_draw_data.update({"type": "FACE", "face_verts": face_verts})
def _cleanup(self, context) -> None:
_snap_draw_data.clear()
if self._draw_handler:
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
self._draw_handler = None
context.workspace.status_text_set(None)
def _set_status(self, context) -> None:
mode_label = self._snap_mode.capitalize()
context.workspace.status_text_set(
f"[Snap: {mode_label}] TAB: cycle snap | LMB: place point"
" | 0-9: enter value | BACKSPACE: undo last | RMB/ENTER: finish | ESC: cancel"
)
# ------------------------------------------------------------------
# Finalize: create IfcAnnotation + BBIM_Dimension pset
@@ -5870,6 +6064,28 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
self.handle_snap_selection(context, event)
self._apply_perp_constraint()
# TAB: cycle snap mode when not in keyboard-input mode.
# Consume both PRESS and RELEASE so the RELEASE never reaches
# handle_keyboard_input (which would activate input mode on TAB RELEASE).
# When is_input_on is True, TAB passes through to cycle input fields as usual.
if event.type == "TAB" and not self.tool_state.is_input_on:
if event.value == "PRESS":
cur = self._SNAP_MODES.index(self._snap_mode)
self._snap_mode = self._SNAP_MODES[(cur + 1) % len(self._SNAP_MODES)]
self._ifc_snap_candidate = None
self._set_status(context)
return {"RUNNING_MODAL"}
# For LAYER / VERTEX / EDGE modes, compute an IFC-native snap candidate and
# override the polyline cursor position so the visual tracks the IFC point.
self._ifc_snap_candidate = self._compute_ifc_snap_candidate(context, event)
if self._ifc_snap_candidate and self.snapping_points:
wp = self._ifc_snap_candidate.get("snap_world")
if wp:
self.snapping_points[0]["point"] = Vector(wp)
self._update_snap_draw_data()
if (
not self.tool_state.is_input_on
and event.value == "RELEASE"
@@ -5879,6 +6095,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Polyline.clear_polyline()
self._cleanup(context)
tool.Blender.update_viewport()
return {"FINISHED"}
@@ -5887,6 +6104,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
cancel = self.handle_cancelation(context, event)
if cancel is not None:
self._cleanup(context)
return cancel
return {"RUNNING_MODAL"}
@@ -5897,6 +6115,11 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
def _invoke(self, context, event):
super().invoke(context, event)
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
_snap_draw_data.clear()
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
)
self._set_status(context)
return {"RUNNING_MODAL"}
@@ -7074,14 +7297,13 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
best_idx = -1
best_dist_sq = float("inf")
# Scan ALL selected objects — context.active_object may have changed to an
# underlying IFC element due to Blender's hover pre-selection, so we can't
# rely on it being the dimension we want to click.
# Scan all visible dimension annotations — not just selected ones.
# view3d.select may deselect the annotation before this operator runs.
for obj in context.scene.objects:
if not obj.select_get():
continue
if obj.type != "CURVE":
continue
if not obj.visible_get():
continue
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"):
continue
@@ -7106,6 +7328,9 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
if best_obj is None:
return {"PASS_THROUGH"}
for o in list(context.selected_objects):
o.select_set(False)
best_obj.select_set(True)
context.view_layer.objects.active = best_obj
from bonsai.bim.module.drawing.gizmos import set_active_anchor
set_active_anchor(best_idx, best_obj)
@@ -114,7 +114,11 @@ class AnnotationTool(WorkSpaceTool):
bl_description = "Gives you Annotation related superpowers"
bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation")
bl_widget = None
bl_keymap = tool.Blender.get_default_selection_keypmap() + (
bl_keymap = (
# Before view3d.select: tool keymaps take priority over the addon keymap
# where ClickNearestDimensionAnchor is also registered.
("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None),
) + tool.Blender.get_default_selection_keypmap() + (
("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}),
("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),