Prioritize camera-perpendicular faces for parametric dimension snapping

In section/elevation views, face snapping previously preferred camera-facing
surfaces (front/back of walls), causing dimensions to anchor on the wrong
geometry. Fix by using 1-dot_abs scoring uniformly for all view types, so
edge-on faces (wall sides in section, wall faces in plan) are always preferred.

Also fix stale hit_pt: snapping_points[0]["point"] could carry a previous
IFC-override position for several frames after mousemove_count resets, causing
snap candidates to project from an outdated cursor position. Fix by deriving
hit_pt fresh from a camera-facing plane intersection on every FACE-mode frame.

Fix _init_snapping_points to use the camera forward vector as plane normal in
section view (z=0 plane is parallel to horizontal camera rays → returns origin).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-08-04 12:49:57 -05:00
parent 44bf8527f6
commit f63d02b2b4
@@ -5749,8 +5749,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
if not ok:
face_index = None
# Prefer faces perpendicular to the camera rather than faces that
# directly face the camera (e.g. top of a wall in plan view).
# Prefer the most useful face for the current camera orientation:
# side-faces in plan view, front-faces in section/elevation.
face_index = _prefer_perp_face_index(obj, pt_world, face_index)
if face_index is not None and face_index < len(obj.data.polygons):
@@ -5945,7 +5945,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
return candidates
@staticmethod
def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3):
def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3, cam_view=None):
"""Return FACE snap candidates by projecting hit_pt onto each vertical face plane.
Accepts any face with a near-horizontal normal (wall-like faces) regardless of
@@ -5953,16 +5953,28 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
final per-candidate screen-distance gate already prevent false positives, so no
Z-based filtering is needed here. (Z is also irrelevant for 2D annotation
projections.)
cam_view: normalized camera forward vector. When provided, back-facing polygons
(normal.dot(cam_view) > 0) are skipped so section/elevation views don't snap to
the rear surface of a cut wall.
"""
from mathutils import Vector
mx = obj.matrix_world
mesh = obj.data
hit_pt = Vector(hit_pt_world)
candidates = []
cam_is_plan = abs(cam_view.z) > 0.7 if cam_view is not None else True
for poly in mesh.polygons:
normal_w = (mx.to_3x3() @ poly.normal).normalized()
if abs(normal_w.z) > 0.9:
continue
if cam_view is not None:
dot = normal_w.dot(cam_view)
if dot > 0:
continue
score = _face_score_for_cam(abs(dot), cam_is_plan)
if score < 0.5:
continue
verts_w = [mx @ mesh.vertices[vi].co for vi in poly.vertices]
n = len(verts_w)
face_center_w = sum(verts_w, Vector((0.0, 0.0, 0.0))) / n
@@ -6026,13 +6038,33 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
if self._snap_mode == "FACE":
if hit_pt is None or not self.objs_2d_bbox:
return None
_face_cam_view = None
_cam = bpy.context.scene.camera
if _cam:
from mathutils import Vector as _Vec
_face_cam_view = (_cam.matrix_world.to_3x3() @ _Vec((0.0, 0.0, -1.0))).normalized()
# snapping_points[0]["point"] can be stale: the previous frame's IFC snap
# overrides it, and mousemove_count resets on any non-MOUSEMOVE event so
# Blender's snap doesn't refresh for several frames. Using that stale value as
# hit_pt makes _snap_on_coplanar_faces project from the old cursor position,
# freezing snap_world while the cursor moves → d2 grows → snap disappears.
# Fix: always recompute hit_pt from the current cursor via a camera-facing plane
# intersection (cheap, no BVH required). x,z track the cursor exactly; the face
# projection corrects y to the wall surface.
if _face_cam_view is not None:
_plane_n = _face_cam_view if abs(_face_cam_view.z) < 0.7 else _Vec((0.0, 0.0, 1.0))
_fresh = tool.Raycast.ray_cast_to_plane(context, event, _Vec((0, 0, 0)), _plane_n)
if _fresh is not None:
hit_pt = _fresh
nearby_cands = [] # (cand, elem, obj, max_screen_tol)
# Check hit_obj itself first: handles the case where the cursor
# lands exactly on the wall/edge boundary (hit_obj IS the wall).
if hit_obj and hit_obj.data and isinstance(hit_obj.data, bpy.types.Mesh):
hit_elem = tool.Ifc.get_entity(hit_obj)
if hit_elem and hasattr(hit_elem, "GlobalId"):
for c in self._snap_on_coplanar_faces(hit_obj, hit_pt):
for c in self._snap_on_coplanar_faces(hit_obj, hit_pt, cam_view=_face_cam_view):
nearby_cands.append((c, hit_elem, hit_obj, 30))
extra_count = 0
for obj, _bbox2d in self.objs_2d_bbox:
@@ -6051,7 +6083,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
continue
nearby_cands.extend(
(c, extra_elem, obj, _SCREEN_TOL)
for c in self._snap_on_coplanar_faces(obj, hit_pt)
for c in self._snap_on_coplanar_faces(obj, hit_pt, cam_view=_face_cam_view)
)
extra_count += 1
@@ -6460,9 +6492,22 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
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.
In section/elevation view the camera looks horizontally, so the default z=0
horizontal plane has no intersection with horizontal camera rays (returns origin).
Instead use the plane whose normal IS the camera forward vector this is always
intersectable and gives correct (x, z) initial hit_pt so snap fires on frame 1.
"""
from mathutils import Vector
plane_pt = tool.Raycast.ray_cast_to_plane(context, event, Vector((0, 0, 0)), Vector((0, 0, 1)))
plane_normal = Vector((0, 0, 1)) # default: horizontal plane for plan view
cam = bpy.context.scene.camera
if cam:
cv = (cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
if abs(cv.z) < 0.7: # section/elevation: camera looks mostly horizontally
plane_normal = cv
plane_pt = tool.Raycast.ray_cast_to_plane(context, event, Vector((0, 0, 0)), plane_normal)
if plane_pt is None:
plane_pt = Vector((0, 0, 0))
snap = {"type": "Plane", "point": plane_pt, "object": None, "group": "Plane", "distance": 10}
self.snapping_points = [snap]
tool.Snap.update_snapping_point(plane_pt, "Plane")
@@ -6478,16 +6523,39 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
return {"RUNNING_MODAL"}
def _face_score_for_cam(dot_abs: float, cam_is_plan: bool) -> float:
"""Score how 'useful' a face is for snapping given the camera orientation.
Prefer faces whose normals are *perpendicular* to the camera direction
(score = 1 |dot|, high when dot 0). These are the faces that appear
as visible edge lines in the drawing wall side faces in plan, wall end
faces in section which is where users want dimension anchors to land.
Faces that face the camera head-on (|dot| 1) score 0 and are rejected
by the score < 0.5 gate in the callers. The cam_is_plan parameter is kept
for future use (e.g. Blender orthographic NUM1/NUM3/NUM7 views).
TODO: extend to Blender's orthographic side-views (NUM1/NUM3/NUM7) which
use region_data.view_matrix rather than scene.camera.
"""
return 1.0 - dot_abs
def _prefer_perp_face_index(
obj: "bpy.types.Object",
hit_world: "Vector",
current_index: "Optional[int]",
world_matrix=None,
) -> "Optional[int]":
"""Return the polygon index most perpendicular to the camera near *hit_world*.
"""Return the polygon index best suited for snapping near *hit_world*.
If the camera is unavailable or the current face is already sufficiently
perpendicular (|dot| < 0.5), returns *current_index* unchanged.
In plan view (camera mostly vertical) this returns the face most
perpendicular to the camera direction (wall side-faces). In
section/elevation view (camera mostly horizontal) it returns the face
most parallel to the camera direction (faces visible in the section).
If the camera is unavailable or the current face already scores > 0.5,
returns *current_index* unchanged.
*world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh
inside a collection instance whose effective transform differs from its own
``matrix_world``.
@@ -6497,23 +6565,26 @@ def _prefer_perp_face_index(
return current_index
cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
cam_is_plan = abs(cam_view.z) > 0.7
mx = world_matrix if world_matrix is not None else obj.matrix_world
mx3 = mx.to_3x3()
if current_index is not None and current_index < len(obj.data.polygons):
current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized()
if abs(current_n.dot(cam_view)) < 0.5:
current_dot = abs(current_n.dot(cam_view))
current_score = _face_score_for_cam(current_dot, cam_is_plan)
if current_score > 0.5:
return current_index
best_idx = current_index
best_score = -1.0
for i, poly in enumerate(obj.data.polygons):
n_world = (mx3 @ poly.normal).normalized()
perp = 1.0 - abs(n_world.dot(cam_view))
if perp < 0.5:
score = _face_score_for_cam(abs(n_world.dot(cam_view)), cam_is_plan)
if score < 0.5:
continue
dist = (mx @ poly.center - hit_world).length
score = perp - dist / 4.0
score -= dist / 4.0
if score > best_score:
best_score = score
best_idx = i
@@ -7096,25 +7167,28 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
vis = obj.visible_get()
return h or hv or not vis
# In FACE mode only snap to faces whose normal is roughly perpendicular to
# the camera view direction (i.e., wall/vertical faces in plan view, not
# floor/ceiling faces). |dot| < 0.5 ≈ within 60° of perpendicular.
# In FACE mode filter faces by camera orientation so that only
# "useful" faces pass through: in plan view prefer wall side-faces
# (edge-on to camera), in section/elevation prefer faces that face
# the camera. See _face_score_for_cam for the scoring logic.
# TODO: extend to Blender's orthographic side-views (NUM1/NUM3/NUM7).
_face_cam_view = None
_face_cam_is_plan = False
if self._snap_mode == "FACE":
_cam = bpy.context.scene.camera
if _cam:
_face_cam_view = (_cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
_face_cam_is_plan = abs(_face_cam_view.z) > 0.7
def _face_perp_ok(normal_w):
"""Return True when the face is acceptably perpendicular to the camera."""
"""Return True when the face is useful for snapping in the current camera."""
if _face_cam_view is None:
return True
return abs(normal_w.dot(_face_cam_view)) < 0.5
dot = abs(normal_w.dot(_face_cam_view))
return _face_score_for_cam(dot, _face_cam_is_plan) > 0.5
# 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.
_DBG_GUIDS = {"1kGw8dvBT2zgE3OsifqnY8", "3YfgKSYh971wjlK2f3vaxy"}
direct: list = []
ray_hit_objs: set = set() # all IFC objects the ray passed through (any face)
ray_origin = Vector(origin)
@@ -7128,20 +7202,13 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
break
ray_origin = loc_w + direction * _EPS
ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval)
_dbg_guid = getattr(tool.Ifc.get_entity(ifc_obj), "GlobalId", None)
if _dbg_guid in _DBG_GUIDS:
print(f"[dbg-ray] hit {_dbg_guid} obj={ifc_obj.name}")
if ifc_obj == self._annotation_obj:
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: is annotation obj")
continue
if _is_hidden(ifc_obj):
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: hidden h={ifc_obj.hide_get(view_layer=view_layer)} hv={ifc_obj.hide_viewport} vis={ifc_obj.visible_get()}")
continue
if ifc_obj.type != "MESH":
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: type={ifc_obj.type}")
continue
if not tool.Ifc.get_entity(ifc_obj):
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: no IFC entity")
continue
ray_hit_objs.add(ifc_obj) # track even if face is non-perp
mx = ifc_obj.matrix_world
@@ -7155,10 +7222,8 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
else nrm_w.normalized()
)
if not _face_perp_ok(normal):
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: face not perp normal={normal} dot={abs(normal.dot(_face_cam_view)) if _face_cam_view else 'N/A'}")
continue
dist = (loc_w - origin).length
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} ACCEPTED dist={dist:.4f}")
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
if direct:
direct.sort(key=lambda c: c[0])