From ad9192027c530107a64c0c9037c37e19946d662d Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 18 May 2026 07:34:54 -0500 Subject: [PATCH] Add TAB snap cycling, snap decorators, and fix anchor dot activation for DrawParametricDimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../bonsai/bim/module/drawing/operator.py | 255 ++++++++++++++++-- .../bonsai/bim/module/drawing/workspace.py | 6 +- 2 files changed, 245 insertions(+), 16 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index e99ff2d965..b9268b046f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index cc99ca2983..7080ff05e5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -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")]}),