Optimize DrawParametricDimension startup and MOUSEMOVE performance

- 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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-05-21 13:12:56 -05:00
parent 2fcb8c17c7
commit 40d1c20bcc
4 changed files with 100 additions and 47 deletions
@@ -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":
@@ -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))]
+15 -3
View File
@@ -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)
+13 -4
View File
@@ -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):