From c92451e0fc9d458818338bbd00a163a80c661383 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 14 Jun 2026 11:20:13 -0500 Subject: [PATCH] Fix FACE/VERTEX/EDGE snap for thin edge-on walls in DrawParametricDimension Walls viewed edge-on in plan (2-7 px screen bbox) were never hit by Blender's raycast, so all three snap modes silently returned nothing. - FACE: remove has_coplanar_edge Z-gate; vertical faces are now snappable regardless of what elevation the native snap lands on (sub-floor surfaces at Z~-7.5m were blocking all candidates) - All modes: replace hardcoded 30 px _FACE_THRESH_D2 with a per-candidate max_tol that matches the adaptive _SCREEN_TOL used for bbox inclusion (~98 px for 2 px-wide walls) - VERTEX/EDGE/LAYER: remove early `if not hit_obj: return None`; all modes now search objs_2d_bbox with adaptive tolerance when the primary raycast misses - Add _get_mesh_snap_candidates fallback for tessellated elements (IfcFacetedBrep etc.) where get_profile_snap_candidates returns [] - Add LOCAL_POINT anchor method (build_anchor_from_local_point + resolve_anchor handler) so mesh-derived anchors store element-local coords and follow the element through moves/rotations rather than becoming free-floating WORLD anchors Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/drawing/operator.py | 249 +++++++++++------- 1 file changed, 154 insertions(+), 95 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 5eb1e6c512..f524804f20 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5798,17 +5798,56 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope # IFC-native snap for LAYER / VERTEX / EDGE modes @staticmethod - def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3): - """Return FACE snap candidates for vertical mesh faces with an edge at hit_pt_world's Z. + def _get_mesh_snap_candidates(obj, snap_mode): + """Derive VERTEX or EDGE snap candidates directly from the Blender mesh. - Finds faces that are edge-on to the camera (perpendicular to the floor plane) and - whose bottom edge is coplanar with the hovered floor surface, then projects the - hit point onto each such face's plane to get the snap position. + Fallback for tessellated IFC elements (IfcFacetedBrep, IfcTessellatedFaceSet, + etc.) that have no IfcExtrudedAreaSolid, causing get_profile_snap_candidates + to return []. Each candidate carries ``local_m`` (element-local coordinates + in metres) so _build_ifc_anchor can create a LOCAL_POINT anchor that follows + the element through moves and rotations. + """ + mx = obj.matrix_world + mesh = obj.data + candidates = [] + if snap_mode == "VERTEX": + for vert in mesh.vertices: + wp = mx @ vert.co + candidates.append({ + "type": "VERTEX", "snap": "VERTEX", + "snap_world": (wp.x, wp.y, wp.z), + "local_m": (vert.co.x, vert.co.y, vert.co.z), + }) + elif snap_mode == "EDGE": + for edge in mesh.edges: + v0c = mesh.vertices[edge.vertices[0]].co + v1c = mesh.vertices[edge.vertices[1]].co + v0 = mx @ v0c + v1 = mx @ v1c + mid_w = ((v0.x + v1.x) * 0.5, (v0.y + v1.y) * 0.5, (v0.z + v1.z) * 0.5) + mid_l = ((v0c.x + v1c.x) * 0.5, (v0c.y + v1c.y) * 0.5, (v0c.z + v1c.z) * 0.5) + candidates.append({ + "type": "EDGE", "snap": "EDGE", + "snap_world": mid_w, + "local_m": mid_l, + "v0": (v0.x, v0.y, v0.z), + "v1": (v1.x, v1.y, v1.z), + }) + return candidates + + @staticmethod + def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3): + """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 + the face's Z elevation. The screen-space bbox filter on the caller side and the + 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.) """ from mathutils import Vector mx = obj.matrix_world mesh = obj.data - target_z = float(hit_pt_world.z) hit_pt = Vector(hit_pt_world) candidates = [] for poly in mesh.polygons: @@ -5817,12 +5856,6 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope continue verts_w = [mx @ mesh.vertices[vi].co for vi in poly.vertices] n = len(verts_w) - has_coplanar_edge = any( - abs(verts_w[i].z - target_z) <= tol_z and abs(verts_w[(i + 1) % n].z - target_z) <= tol_z - for i in range(n) - ) - if not has_coplanar_edge: - continue face_center_w = sum(verts_w, Vector((0.0, 0.0, 0.0))) / n dist = (hit_pt - face_center_w).dot(normal_w) snapped_pt = hit_pt - normal_w * dist @@ -5884,14 +5917,14 @@ 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 - nearby_cands = [] + 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): - nearby_cands.append((c, hit_elem, hit_obj)) + nearby_cands.append((c, hit_elem, hit_obj, 30)) extra_count = 0 for obj, _bbox2d in self.objs_2d_bbox: if extra_count >= 4: @@ -5904,22 +5937,22 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope if not extra_elem or not hasattr(extra_elem, "GlobalId"): continue sx0, sx1, sy0, sy1 = _bbox2d - _SCREEN_TOL = 30 - if not (sx0 - _SCREEN_TOL <= mx <= sx1 + _SCREEN_TOL and - sy0 - _SCREEN_TOL <= my <= sy1 + _SCREEN_TOL): + _SCREEN_TOL = max(30, 100 - min(sx1 - sx0, sy1 - sy0)) + if not (sx0 - _SCREEN_TOL <= mx <= sx1 + _SCREEN_TOL and sy0 - _SCREEN_TOL <= my <= sy1 + _SCREEN_TOL): continue - face_cands = self._snap_on_coplanar_faces(obj, hit_pt) - nearby_cands.extend((c, extra_elem, obj) for c in face_cands) + nearby_cands.extend( + (c, extra_elem, obj, _SCREEN_TOL) + for c in self._snap_on_coplanar_faces(obj, hit_pt) + ) extra_count += 1 - _FACE_THRESH_D2 = 30 * 30 best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") - for cand, elem, obj in nearby_cands: + for cand, elem, obj, max_tol in nearby_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 and d2 < _FACE_THRESH_D2: + if d2 < best_d2 and d2 < max_tol * max_tol: best_d2 = d2 best_cand = cand best_elem = elem @@ -5934,56 +5967,84 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope # ---------------------------------------------------------------- # LAYER / VERTEX / EDGE modes # ---------------------------------------------------------------- - if not hit_obj: - return None - element = tool.Ifc.get_entity(hit_obj) - if not element or not hasattr(element, "GlobalId"): - return None + # Candidates are stored as (cand, elem, obj, max_screen_tol): + # - Direct-hit object → max_screen_tol = inf (no distance gate) + # - Nearby objects found via screen bbox → max_screen_tol = _SCREEN_TOL + # This lets thin edge-on walls (2-7 px wide bbox, ~98 px tolerance) be + # included without relaxing the gate for normal objects. + file = tool.Ifc.get() + all_cands = [] # (cand, elem, obj, max_screen_tol) - # 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 + # Build initial candidates from the directly-hit object (if any). + # When hit_obj is None (thin walls are never the raycast target) we skip + # this block and rely entirely on the screen-bbox search below. + if hit_obj: + element = tool.Ifc.get_entity(hit_obj) + if element and hasattr(element, "GlobalId"): + obj_ptr = hit_obj.as_pointer() + if obj_ptr != self._snap_cand_obj_ptr: + 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 + all_cands = [(c, element, hit_obj, float("inf")) for c in self._snap_cand_cache] - if self._snap_mode == "LAYER": - all_layer_cands = [(c, element, hit_obj) for c in self._snap_cand_cache] - if hit_pt is not None and self.objs_2d_bbox: - file = tool.Ifc.get() - extra_count = 0 - for obj, _bbox2d in self.objs_2d_bbox: - if extra_count >= 4: - break - if obj is hit_obj: - continue - if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): - continue - extra_elem = tool.Ifc.get_entity(obj) - if not extra_elem or not hasattr(extra_elem, "GlobalId"): - continue - _sx0, _sx1, _sy0, _sy1 = _bbox2d - if not (_sx0 - 30 <= mx <= _sx1 + 30 and _sy0 - 30 <= my <= _sy1 + 30): - continue - extra_ptr = obj.as_pointer() - if extra_ptr not in self._snap_cand_multi_cache: - placement_override = {extra_elem.id(): np.array(obj.matrix_world)} + # Extend with candidates from nearby objects using adaptive screen-bbox. + # Replaces the old 30-px hardcoded check (LAYER) and _pt_in_obj_bbox + # (VERTEX/EDGE), so thin edge-on walls are reachable in all modes. + if self.objs_2d_bbox: + extra_count = 0 + for obj, _bbox2d in self.objs_2d_bbox: + if extra_count >= 4: + break + if obj is hit_obj: + continue + if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): + continue + extra_elem = tool.Ifc.get_entity(obj) + if not extra_elem or not hasattr(extra_elem, "GlobalId"): + continue + sx0, sx1, sy0, sy1 = _bbox2d + _bbox_w = sx1 - sx0 + _bbox_h = sy1 - sy0 + _SCREEN_TOL = max(30, 100 - min(_bbox_w, _bbox_h)) + if not (sx0 - _SCREEN_TOL <= mx <= sx1 + _SCREEN_TOL and sy0 - _SCREEN_TOL <= my <= sy1 + _SCREEN_TOL): + continue + extra_ptr = obj.as_pointer() + if extra_ptr not in self._snap_cand_multi_cache: + placement_override = {extra_elem.id(): np.array(obj.matrix_world)} + if self._snap_mode == "LAYER": self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_layer_snap_candidates( file, extra_elem, placement_override ) - all_layer_cands.extend((c, extra_elem, obj) for c in self._snap_cand_multi_cache[extra_ptr]) - extra_count += 1 + else: + self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_profile_snap_candidates( + file, extra_elem, placement_override + ) + ifc_cands = self._snap_cand_multi_cache[extra_ptr] + if ifc_cands: + all_cands.extend((c, extra_elem, obj, _SCREEN_TOL) for c in ifc_cands) + elif self._snap_mode in ("VERTEX", "EDGE"): + # Tessellated element with no IfcExtrudedAreaSolid: fall back to mesh + all_cands.extend( + (c, extra_elem, obj, _SCREEN_TOL) + for c in self._get_mesh_snap_candidates(obj, self._snap_mode) + ) + extra_count += 1 + + if not all_cands: + return None + + if self._snap_mode == "LAYER": best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") - for cand, elem, obj in all_layer_cands: + for cand, elem, obj, max_tol in all_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: + if d2 < best_d2 and d2 < max_tol * max_tol: best_d2 = d2 best_cand = cand best_elem = elem @@ -5996,46 +6057,16 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope result["obj"] = best_obj return result - # VERTEX or EDGE — profile-based candidates. - # Build a tagged list of (candidate, element, obj) so the best match from - # any object carries the right element reference into _build_ifc_anchor. - all_cands = [(c, element, hit_obj) for c in self._snap_cand_cache] - - # Also query nearby objects whose 3D bbox contains the hit point. - if hit_pt is not None and self.objs_2d_bbox: - file = tool.Ifc.get() - extra_count = 0 - for obj, _bbox2d in self.objs_2d_bbox: - if extra_count >= 4: - break - if obj is hit_obj: - continue - if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): - continue - extra_elem = tool.Ifc.get_entity(obj) - if not extra_elem or not hasattr(extra_elem, "GlobalId"): - continue - in_bbox = self._pt_in_obj_bbox(obj, hit_pt) - if not in_bbox: - continue - extra_ptr = obj.as_pointer() - if extra_ptr not in self._snap_cand_multi_cache: - placement_override = {extra_elem.id(): np.array(obj.matrix_world)} - self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_profile_snap_candidates( - file, extra_elem, placement_override - ) - all_cands.extend((c, extra_elem, obj) for c in self._snap_cand_multi_cache[extra_ptr]) - extra_count += 1 - + # VERTEX or EDGE best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") - for cand, elem, obj in all_cands: + for cand, elem, obj, max_tol in all_cands: if cand["type"] != self._snap_mode: continue 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: + if d2 < best_d2 and d2 < max_tol * max_tol: best_d2 = d2 best_cand = cand best_elem = elem @@ -6062,6 +6093,10 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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) + if snap_kind in ("VERTEX", "EDGE") and candidate.get("local_m") is not None: + return drawing_api.build_anchor_from_local_point( + element, snap_kind, candidate["snap_world"], candidate["local_m"] + ) if snap_kind == "FACE": hit_location = candidate.get("snap_world", (0.0, 0.0, 0.0)) hit_normal = candidate.get("face_normal_world") @@ -6939,6 +6974,8 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): # 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) @@ -6952,13 +6989,20 @@ 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 @@ -6972,8 +7016,10 @@ 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]) @@ -6997,19 +7043,26 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): prox: list = [] for ifc_obj in context.scene.objects: + _dbg_guid2 = getattr(tool.Ifc.get_entity(ifc_obj), "GlobalId", None) + _is_dbg = _dbg_guid2 in _DBG_GUIDS if ifc_obj == self._annotation_obj: + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: is annotation obj") continue if _is_hidden(ifc_obj): + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: hidden") continue elem = tool.Ifc.get_entity(ifc_obj) if not elem: + if _is_dbg: print(f"[dbg-prox] {ifc_obj.name} SKIP: no IFC entity") continue if ifc_obj.type != "MESH": + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: type={ifc_obj.type}") continue mx = ifc_obj.matrix_world try: mx_inv = mx.inverted() except Exception: + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: matrix not invertible") continue bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box] bb_proj = [_perp(v) for v in bb_world] @@ -7018,7 +7071,9 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj)) sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj)) perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz) + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} perp_dist={perp_dist:.4f} TOL={TOL}") if perp_dist > TOL: + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: perp_dist too large") continue bb_ctr = sum((v for v in bb_world), Vector()) / 8 t = (bb_ctr - origin).dot(direction) @@ -7026,15 +7081,19 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): try: found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0) except RuntimeError: + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: closest_point_on_mesh RuntimeError") continue if not found: + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: closest_point_on_mesh not found") continue loc_w = mx @ loc_l if self._snap_mode != "FACE": 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() if not _face_perp_ok(normal): + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} SKIP: face not perp normal={normal} dot={abs(normal.dot(_face_cam_view)) if _face_cam_view else 'N/A'}") continue + if _is_dbg: print(f"[dbg-prox] {_dbg_guid2} ACCEPTED perp_dist={perp_dist:.4f} ray_hit={ifc_obj in ray_hit_objs}") prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) # Objects the ray directly passed through get priority over objects that