mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Snap coplanar edge-on faces in FACE/LAYER mode for DrawParametricDimension
Vertical faces perpendicular to the camera cannot be hit by raycast, so the dimension snap tool missed them entirely. Fix by checking nearby objects whose 3D bbox contains the floor hit point and running mode-appropriate candidate lookup on each: - FACE mode: _snap_on_coplanar_faces finds vertical mesh faces with a bottom edge at the hovered Z, projects the cursor onto the face plane, and returns a FACE candidate (blue outline + face snap point). - LAYER mode: get_layer_snap_candidates now runs on nearby bbox objects the same way VERTEX/EDGE mode already did, using the shared _snap_cand_multi_cache (cleared on TAB mode switch).
This commit is contained in:
@@ -5616,6 +5616,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
self._draw_handler = None
|
self._draw_handler = None
|
||||||
self._snap_cand_obj_ptr: int = -1 # Blender object pointer for cached snap cands
|
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
|
self._snap_cand_cache: list = [] # cached get_layer/profile_snap_candidates result
|
||||||
|
self._snap_cand_multi_cache: dict = {} # ptr → candidates for coplanar-edge nearby objects
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Snap → anchor bridge
|
# Snap → anchor bridge
|
||||||
@@ -5796,17 +5797,68 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# IFC-native snap for LAYER / VERTEX / EDGE modes
|
# 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
normal_w = (mx.to_3x3() @ poly.normal).normalized()
|
||||||
|
if abs(normal_w.z) > 0.9:
|
||||||
|
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
|
||||||
|
candidates.append({
|
||||||
|
"type": "FACE",
|
||||||
|
"snap_world": (snapped_pt.x, snapped_pt.y, snapped_pt.z),
|
||||||
|
"snap": "FACE",
|
||||||
|
"face_verts": [tuple(v) for v in verts_w],
|
||||||
|
})
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pt_in_obj_bbox(obj, pt_world, tol=1e-3):
|
||||||
|
"""Return True if pt_world is inside obj's world-space bounding box."""
|
||||||
|
try:
|
||||||
|
local_pt = obj.matrix_world.inverted() @ pt_world
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
bb = obj.bound_box # 8 corners in local space
|
||||||
|
xs = [v[0] for v in bb]
|
||||||
|
ys = [v[1] for v in bb]
|
||||||
|
zs = [v[2] for v in bb]
|
||||||
|
return (
|
||||||
|
min(xs) - tol <= local_pt.x <= max(xs) + tol
|
||||||
|
and min(ys) - tol <= local_pt.y <= max(ys) + tol
|
||||||
|
and min(zs) - tol <= local_pt.z <= max(zs) + tol
|
||||||
|
)
|
||||||
|
|
||||||
def _compute_ifc_snap_candidate(self, context, event) -> "Optional[dict]":
|
def _compute_ifc_snap_candidate(self, context, event) -> "Optional[dict]":
|
||||||
"""Return an IFC-native snap candidate for the current snap mode, or None."""
|
"""Return an IFC-native snap candidate for the current snap mode, or None."""
|
||||||
if self._snap_mode == "FACE" or not self.snapping_points:
|
if not self.snapping_points:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
snap = self.snapping_points[0]
|
snap = self.snapping_points[0]
|
||||||
hit_obj = snap.get("object")
|
hit_obj = snap.get("object")
|
||||||
if not hit_obj:
|
hit_pt = snap.get("point")
|
||||||
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
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||||
import ifcopenshell.api.drawing as drawing_api
|
import ifcopenshell.api.drawing as drawing_api
|
||||||
@@ -5815,6 +5867,63 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
rv3d = context.region_data
|
rv3d = context.region_data
|
||||||
mx, my = event.mouse_region_x, event.mouse_region_y
|
mx, my = event.mouse_region_x, event.mouse_region_y
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# FACE mode: no IFC snap on the primary hit object, but look for
|
||||||
|
# coplanar-edge candidates from nearby objects (e.g. a wall whose
|
||||||
|
# bottom edge is coplanar with the hovered floor surface).
|
||||||
|
# Uses direct mesh edge projection rather than get_profile_snap_candidates
|
||||||
|
# so the snap tracks cursor position along the edge, not just fixed vertices.
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
if self._snap_mode == "FACE":
|
||||||
|
if hit_pt is None or not self.objs_2d_bbox:
|
||||||
|
return None
|
||||||
|
nearby_cands = []
|
||||||
|
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
|
||||||
|
face_cands = self._snap_on_coplanar_faces(obj, hit_pt)
|
||||||
|
nearby_cands.extend((c, extra_elem, obj) for c in face_cands)
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
best_d2 = d2
|
||||||
|
best_cand = cand
|
||||||
|
best_elem = elem
|
||||||
|
best_obj = obj
|
||||||
|
if best_cand is None:
|
||||||
|
return None
|
||||||
|
result = dict(best_cand)
|
||||||
|
result["element"] = best_elem
|
||||||
|
result["obj"] = best_obj
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
# Recompute expensive candidate geometry only when the hovered object changes.
|
# Recompute expensive candidate geometry only when the hovered object changes.
|
||||||
obj_ptr = hit_obj.as_pointer()
|
obj_ptr = hit_obj.as_pointer()
|
||||||
if obj_ptr != self._snap_cand_obj_ptr:
|
if obj_ptr != self._snap_cand_obj_ptr:
|
||||||
@@ -5825,14 +5934,34 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
else:
|
else:
|
||||||
self._snap_cand_cache = drawing_api.get_profile_snap_candidates(file, element, placement_override)
|
self._snap_cand_cache = drawing_api.get_profile_snap_candidates(file, element, placement_override)
|
||||||
self._snap_cand_obj_ptr = obj_ptr
|
self._snap_cand_obj_ptr = obj_ptr
|
||||||
cands = self._snap_cand_cache
|
|
||||||
|
|
||||||
if not cands:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self._snap_mode == "LAYER":
|
if self._snap_mode == "LAYER":
|
||||||
best_cand, best_d2 = None, float("inf")
|
all_layer_cands = [(c, element, hit_obj) for c in self._snap_cand_cache]
|
||||||
for cand in cands:
|
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
|
||||||
|
if not self._pt_in_obj_bbox(obj, hit_pt):
|
||||||
|
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_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
|
||||||
|
best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf")
|
||||||
|
for cand, elem, obj in all_layer_cands:
|
||||||
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
||||||
if sp is None:
|
if sp is None:
|
||||||
continue
|
continue
|
||||||
@@ -5840,20 +5969,51 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
if d2 < best_d2:
|
if d2 < best_d2:
|
||||||
best_d2 = d2
|
best_d2 = d2
|
||||||
best_cand = cand
|
best_cand = cand
|
||||||
|
best_elem = elem
|
||||||
|
best_obj = obj
|
||||||
if best_cand is None:
|
if best_cand is None:
|
||||||
return None
|
return None
|
||||||
result = dict(best_cand)
|
result = dict(best_cand)
|
||||||
result["method"] = "LAYER_BOUNDARY"
|
result["method"] = "LAYER_BOUNDARY"
|
||||||
result["element"] = element
|
result["element"] = best_elem
|
||||||
result["obj"] = hit_obj
|
result["obj"] = best_obj
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# VERTEX or EDGE — profile-based candidates
|
# VERTEX or EDGE — profile-based candidates.
|
||||||
cands_of_type = [c for c in cands if c["type"] == self._snap_mode]
|
# Build a tagged list of (candidate, element, obj) so the best match from
|
||||||
if not cands_of_type:
|
# any object carries the right element reference into _build_ifc_anchor.
|
||||||
return None
|
all_cands = [(c, element, hit_obj) for c in self._snap_cand_cache]
|
||||||
best_cand, best_d2 = None, float("inf")
|
|
||||||
for cand in cands_of_type:
|
# 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
|
||||||
|
|
||||||
|
best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf")
|
||||||
|
for cand, elem, obj in all_cands:
|
||||||
|
if cand["type"] != self._snap_mode:
|
||||||
|
continue
|
||||||
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
||||||
if sp is None:
|
if sp is None:
|
||||||
continue
|
continue
|
||||||
@@ -5861,11 +6021,13 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
if d2 < best_d2:
|
if d2 < best_d2:
|
||||||
best_d2 = d2
|
best_d2 = d2
|
||||||
best_cand = cand
|
best_cand = cand
|
||||||
|
best_elem = elem
|
||||||
|
best_obj = obj
|
||||||
if best_cand is None:
|
if best_cand is None:
|
||||||
return None
|
return None
|
||||||
result = dict(best_cand)
|
result = dict(best_cand)
|
||||||
result["element"] = element
|
result["element"] = best_elem
|
||||||
result["obj"] = hit_obj
|
result["obj"] = best_obj
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _build_ifc_anchor(self, candidate: dict) -> dict:
|
def _build_ifc_anchor(self, candidate: dict) -> dict:
|
||||||
@@ -5898,6 +6060,12 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
"seam_corners": cand.get("seam_corners", []),
|
"seam_corners": cand.get("seam_corners", []),
|
||||||
"snap_world": cand.get("snap_world"),
|
"snap_world": cand.get("snap_world"),
|
||||||
})
|
})
|
||||||
|
elif cand.get("snap") == "FACE":
|
||||||
|
_snap_draw_data.update({
|
||||||
|
"type": "FACE",
|
||||||
|
"face_verts": cand.get("face_verts", []),
|
||||||
|
"snap_world": cand.get("snap_world"),
|
||||||
|
})
|
||||||
elif cand.get("snap") == "EDGE":
|
elif cand.get("snap") == "EDGE":
|
||||||
_snap_draw_data.update({
|
_snap_draw_data.update({
|
||||||
"type": "EDGE",
|
"type": "EDGE",
|
||||||
@@ -6074,7 +6242,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
|||||||
cur = self._SNAP_MODES.index(self._snap_mode)
|
cur = self._SNAP_MODES.index(self._snap_mode)
|
||||||
self._snap_mode = self._SNAP_MODES[(cur + 1) % len(self._SNAP_MODES)]
|
self._snap_mode = self._SNAP_MODES[(cur + 1) % len(self._SNAP_MODES)]
|
||||||
self._ifc_snap_candidate = None
|
self._ifc_snap_candidate = None
|
||||||
self._snap_cand_obj_ptr = -1 # invalidate cache: LAYER vs VERTEX/EDGE differ
|
self._snap_cand_obj_ptr = -1 # invalidate cache: LAYER vs VERTEX/EDGE differ
|
||||||
|
self._snap_cand_multi_cache = {} # invalidate nearby-object cache too
|
||||||
self._set_status(context)
|
self._set_status(context)
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user