From 40d1c20bcc9c2290944eadc74b1b229a49d40f14 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 21 May 2026 13:12:56 -0500 Subject: [PATCH] Optimize DrawParametricDimension startup and MOUSEMOVE performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove clear_snap_objs() from PolylineOperator.invoke — BVH cache now persists across invocations; per-entry staleness is checked in create_snap_obj via matrix_world equality + vertex count, eliminating the ~11 s full rebuild on every Shift+A press. - Add _init_snapping_points() hook to PolylineOperator; DrawParametricDimension overrides it with a cheap plane-intersection placeholder, deferring full BVH detection to the first MOUSEMOVE. - Cache matrix_world in SnapObj and replace O(N_vertices) validation loop with O(1) matrix equality + single sample vertex check, cutting per-call create_snap_obj cost from 22-600 ms to <0.2 ms on cache hits. - Use scene-level BVH pierce-through in SetDimensionAnchor._compute_candidates instead of per-object ray_cast loop (O(log N) vs O(N_objects)). - Guard PolylineDecorator snap_mouse_point access against empty collection to prevent IndexError before first MOUSEMOVE populates the property. - Wrap closest_point_on_mesh in try/except RuntimeError in _update_snap_draw_data for annotation objects with no internal mesh data. Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/drawing/operator.py | 108 +++++++++++------- .../bonsai/bim/module/model/decorator.py | 4 + .../bonsai/bim/module/model/polyline.py | 18 ++- src/bonsai/bonsai/tool/raycast.py | 17 ++- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index b9268b046f..4256449bc7 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5614,6 +5614,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope self._snap_mode = "FACE" self._ifc_snap_candidate = None self._draw_handler = None + self._snap_cand_obj_ptr: int = -1 # Blender object pointer for cached snap cands + self._snap_cand_cache: list = [] # cached get_layer/profile_snap_candidates result # ------------------------------------------------------------------ # Snap → anchor bridge @@ -5714,12 +5716,10 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope from mathutils import Vector a = self._anchors[0] if self._anchors else None if not a or a.get("type") != "FACE": - print(f"[perp] _update_perp_constraint: anchor type={a.get('type') if a else None} — need FACE, skipping") return addr = a.get("addr") or {} pt = a.get("pt") if not pt: - print(f"[perp] _update_perp_constraint: missing pt") return method = addr.get("method", "FACE_NORMAL") @@ -5728,7 +5728,6 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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() @@ -5736,7 +5735,6 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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": @@ -5752,13 +5750,11 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope n: tuple = (nw.x, nw.y, nw.z) else: n = normal_local - except Exception as exc: - print(f"[perp] _update_perp_constraint (LAYER): {exc}") + except Exception: 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: @@ -5770,18 +5766,14 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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}") + except Exception: + pass mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) if mag < 1e-12: - print(f"[perp] _update_perp_constraint: zero-length normal after rotation") return self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag) self._anchor0_pt = tuple(pt) - print(f"[perp] _update_perp_constraint: OK normal={[round(v,3) for v in self._anchor0_normal]} pt={[round(v,3) for v in self._anchor0_pt]}") def _apply_perp_constraint(self) -> None: """Project the current snap point onto the constraint line when active.""" @@ -5799,7 +5791,6 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope n = self._anchor0_normal t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2] constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2])) - 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 # ------------------------------------------------------------------ @@ -5823,13 +5814,23 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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)} + + # Recompute expensive candidate geometry only when the hovered object changes. + obj_ptr = hit_obj.as_pointer() + if obj_ptr != self._snap_cand_obj_ptr: + file = tool.Ifc.get() + placement_override = {element.id(): np.array(hit_obj.matrix_world)} + if self._snap_mode == "LAYER": + self._snap_cand_cache = drawing_api.get_layer_snap_candidates(file, element, placement_override) + else: + self._snap_cand_cache = drawing_api.get_profile_snap_candidates(file, element, placement_override) + self._snap_cand_obj_ptr = obj_ptr + cands = self._snap_cand_cache + + if not cands: + return None 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"])) @@ -5848,9 +5849,6 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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 @@ -5926,7 +5924,10 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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) + try: + ok, _loc, _n, face_index = hit_obj.closest_point_on_mesh(local_pt) + except RuntimeError: + return if not ok: return face_index = _prefer_perp_face_index(hit_obj, pt_world, face_index) @@ -6073,12 +6074,15 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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._snap_cand_obj_ptr = -1 # invalidate cache: LAYER vs VERTEX/EDGE differ 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) + # Only recompute on mouse moves — key events don't change the hit object. + if event.type == "MOUSEMOVE": + 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: @@ -6112,6 +6116,20 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope def invoke(self, context, event): return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + def _init_snapping_points(self, context, event): + """Skip the full BVH snap at startup — use a plane-intersection placeholder. + + handle_mouse_move populates snapping_points properly after the first few + MOUSEMOVE events, so this placeholder only needs to survive until then. + We must also populate snap_mouse_point (a Blender prop collection) because + calculate_distance_and_angle accesses it immediately after invoke. + """ + from mathutils import Vector + plane_pt = tool.Raycast.ray_cast_to_plane(context, event, Vector((0, 0, 0)), Vector((0, 0, 1))) + snap = {"type": "Plane", "point": plane_pt, "object": None, "group": "Plane", "distance": 10} + self.snapping_points = [snap] + tool.Snap.update_snapping_point(plane_pt, "Plane") + def _invoke(self, context, event): super().invoke(context, event) self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face @@ -6285,7 +6303,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): _draw_handler: object # SpaceView3D draw handler handle _VERTEX_PICK_RADIUS_PX = 20 - _HOVER_THROTTLE_PX_SQ = 25 + _HOVER_THROTTLE_PX_SQ = 144 # 12 px — enough to feel responsive without per-pixel recompute _SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX") @classmethod @@ -6613,31 +6631,39 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): from mathutils import Vector origin, direction = self._unproject_coord(coord) + depsgraph = context.evaluated_depsgraph_get() - direct: list = [] # (dist, ifc_obj, mesh_obj, mx, loc_w, normal, face_index) - for ifc_obj in context.scene.objects: + # Scene-BVH pierce-through: O(log N) vs the previous O(N) per-object loop. + # Each iteration steps past the last hit surface to reach the next object. + direct: list = [] + ray_origin = Vector(origin) + _EPS = 1e-4 + + for _ in range(8): + result, loc_w, nrm_w, fi, hit_obj_eval, hit_mx = context.scene.ray_cast( + depsgraph, ray_origin, direction + ) + if not result: + break + ray_origin = loc_w + direction * _EPS + ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval) if ifc_obj == self._annotation_obj: continue - if not tool.Ifc.get_entity(ifc_obj): + if not ifc_obj.visible_get(): continue if ifc_obj.type != "MESH": continue + if not tool.Ifc.get_entity(ifc_obj): + continue mx = ifc_obj.matrix_world - try: - mx_inv = mx.inverted() - except Exception: - continue - ok, loc_l, nrm_l, fi = ifc_obj.ray_cast( - mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized() - ) - if not ok: - continue - loc_w = mx @ loc_l fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx) - normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized() + normal = ( + (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() + if fi is not None + else nrm_w.normalized() + ) dist = (loc_w - origin).length direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) - if direct: direct.sort(key=lambda c: c[0]) return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct] @@ -6652,6 +6678,8 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): for ifc_obj in context.scene.objects: if ifc_obj == self._annotation_obj: continue + if not ifc_obj.visible_get(): + continue if not tool.Ifc.get_entity(ifc_obj): continue if ifc_obj.type != "MESH": diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 7728da27da..b126f7b7d2 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -796,6 +796,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator): rv3d = region.data polyline_props = tool.Model.get_polyline_props() + if not polyline_props.snap_mouse_point: + return snap_prop = polyline_props.snap_mouse_point[0] mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) @@ -863,6 +865,8 @@ class PolylineDecorator(tool.Blender.ViewportDecorator): gpu.state.point_size_set(6) polyline_props = tool.Model.get_polyline_props() + if not polyline_props.snap_mouse_point: + return snap_prop = polyline_props.snap_mouse_point[0] # Point related to the mouse mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))] diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index 393054d9ac..103d0c5b74 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -462,14 +462,26 @@ class PolylineOperator: self.tool_state.axis_method = None self.tool_state.plane_method = None self.tool_state.mode = "Mouse" - tool.Raycast.clear_snap_objs() + # Do not call clear_snap_objs() here — create_snap_obj() validates stale + # entries per-object (vertex count + position check), so the BVH cache can + # safely persist across invocations. Clearing it caused an 11-second stall + # on every Shift+A because SnapObj rebuilds a pure-Python BVH tree. self.visible_objs = tool.Raycast.get_visible_objects(context) for obj in self.visible_objs: if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj): self.objs_2d_bbox.append(bbox_2d) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) - self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + self._init_snapping_points(context, event) tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) tool.Blender.update_viewport() context.window_manager.modal_handler_add(self) + + def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None: + """Populate self.snapping_points at operator start. + + Override in subclasses to skip the full BVH snap detection when a cheap + placeholder is sufficient. The default runs the full detection pass. + """ + detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) + self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 2b8b2a46c7..f04b2288c5 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -971,17 +971,25 @@ class Raycast(bonsai.core.tool.Raycast): return None for i, snap_obj in enumerate(cls.snap_objs): if obj.name == snap_obj.obj.name: - # Handle objects modified while a modal operator is active. - # Example: adding a door or window alters the wall geometry. + # Fast O(1) invalidation: vertex count change (mesh edit) or + # world matrix change (object moved/rotated). if len(obj.data.vertices) != len(snap_obj.verts_3d): cls.snap_objs.pop(i) snap_obj = SnapObj(obj) cls.snap_objs.append(snap_obj) - for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d): - if (obj.matrix_world @ v1.co) != v2: + return snap_obj + if obj.matrix_world != snap_obj.matrix_world: + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + # Sample one vertex to catch mesh edits that preserve vertex count. + if obj.data.vertices and snap_obj.verts_3d: + if (obj.matrix_world @ obj.data.vertices[0].co) != snap_obj.verts_3d[0]: cls.snap_objs.pop(i) snap_obj = SnapObj(obj) cls.snap_objs.append(snap_obj) + return snap_obj return snap_obj snap_obj = SnapObj(obj) cls.snap_objs.append(snap_obj) @@ -1020,6 +1028,7 @@ class SnapObj: self.root = None self._bvh_built = False self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices] + self.matrix_world = obj.matrix_world.copy() self.snap_points = [] def _ensure_bvh(self):