mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 23:12:34 +00:00
Improve SetDimensionAnchor snap: hover indicator, visibility, face outline
- Add dedicated POST_PIXEL GPU callback (_draw_anchor_hover_global) using pre-converted 2D screen coords, replacing the shared POST_VIEW callback that caused GPU state issues and Blender freezes - Add LAYER snap mode hover indicator showing full seam-corner outline - Remove select_set calls from hover highlight to prevent green object outline - Add _is_hidden() using hide_get/hide_viewport/visible_get so only scene- visible objects are snap candidates - Add _face_perp_ok() filter (camera-based) to prefer wall faces over floor/ceiling faces in FACE mode; non-perp hits tracked in ray_hit_objs so directly-hit elements always rank above proximity-found neighbours - Add _get_current_anchor_guid() to promote the currently-bound element to the front of the candidate list when re-picking an anchor vertex - Add _coplanar_face_outline() to merge tessellated triangles (including walls with window/door voids) into the correct outer face boundary; walks all disconnected loops and returns the largest (outer perimeter), skips meshes > 500 polygons to avoid freezing on terrain objects - Skip _prefer_perp_face_index in FACE mode so the exact hit face is used rather than the face most perpendicular to the camera - Sort proximity candidates so ray-hit objects rank before bbox-only matches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6453,6 +6453,82 @@ def _draw_snap_indicator_global():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Separate draw data dict and POST_PIXEL callback for SetDimensionAnchor hover —
|
||||||
|
# avoids GPU matrix state issues by working in pre-converted 2D screen coords.
|
||||||
|
_anchor_hover_draw_data: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_anchor_hover_global():
|
||||||
|
"""POST_PIXEL callback — draws the face/edge/vertex hover indicator for SetDimensionAnchor."""
|
||||||
|
data = _anchor_hover_draw_data
|
||||||
|
if not data or not data.get("type"):
|
||||||
|
return
|
||||||
|
import gpu
|
||||||
|
from gpu_extras.batch import batch_for_shader
|
||||||
|
try:
|
||||||
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||||
|
gpu.state.blend_set("ALPHA")
|
||||||
|
snap_type = data["type"]
|
||||||
|
|
||||||
|
if snap_type == "FACE":
|
||||||
|
verts = data.get("face_verts_2d", [])
|
||||||
|
if len(verts) >= 3:
|
||||||
|
lines = []
|
||||||
|
for i in range(len(verts)):
|
||||||
|
lines.append(verts[i])
|
||||||
|
lines.append(verts[(i + 1) % len(verts)])
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9))
|
||||||
|
gpu.state.line_width_set(4.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "EDGE":
|
||||||
|
v0, v1 = data.get("v0_2d"), data.get("v1_2d")
|
||||||
|
if v0 and v1:
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0))
|
||||||
|
gpu.state.line_width_set(6.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader)
|
||||||
|
gpu.state.point_size_set(12.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "LAYER":
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0))
|
||||||
|
corners = data.get("seam_corners_2d", [])
|
||||||
|
n = len(corners)
|
||||||
|
if n >= 2:
|
||||||
|
lines = []
|
||||||
|
for i in range(n):
|
||||||
|
lines.append(corners[i])
|
||||||
|
lines.append(corners[(i + 1) % n])
|
||||||
|
gpu.state.line_width_set(5.0)
|
||||||
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
||||||
|
gpu.state.point_size_set(10.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader)
|
||||||
|
pt = data.get("snap_2d")
|
||||||
|
if pt:
|
||||||
|
gpu.state.point_size_set(20.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
||||||
|
|
||||||
|
elif snap_type == "VERTEX":
|
||||||
|
pt = data.get("snap_2d")
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0))
|
||||||
|
if pt:
|
||||||
|
gpu.state.point_size_set(20.0)
|
||||||
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
gpu.state.blend_set("NONE")
|
||||||
|
gpu.state.line_width_set(1.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
"""Interactively anchor dimension vertices to IFC element faces.
|
"""Interactively anchor dimension vertices to IFC element faces.
|
||||||
|
|
||||||
@@ -6544,9 +6620,9 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
self._hover_last_px = (-9999, -9999)
|
self._hover_last_px = (-9999, -9999)
|
||||||
self._hover_highlighted_obj = None
|
self._hover_highlighted_obj = None
|
||||||
self._snap_mode = "FACE"
|
self._snap_mode = "FACE"
|
||||||
_snap_draw_data.clear()
|
_anchor_hover_draw_data.clear()
|
||||||
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||||
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
_draw_anchor_hover_global, (), "WINDOW", "POST_PIXEL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# When invoked from a panel, context.region_data is None.
|
# When invoked from a panel, context.region_data is None.
|
||||||
@@ -6577,7 +6653,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
context.workspace.status_text_set(None)
|
context.workspace.status_text_set(None)
|
||||||
_snap_draw_data.clear()
|
_anchor_hover_draw_data.clear()
|
||||||
if self._draw_handler:
|
if self._draw_handler:
|
||||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
self._draw_handler = None
|
self._draw_handler = None
|
||||||
@@ -6618,7 +6694,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
def _cleanup(self, context):
|
def _cleanup(self, context):
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
context.workspace.status_text_set(None)
|
context.workspace.status_text_set(None)
|
||||||
_snap_draw_data.clear()
|
_anchor_hover_draw_data.clear()
|
||||||
if self._draw_handler:
|
if self._draw_handler:
|
||||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
self._draw_handler = None
|
self._draw_handler = None
|
||||||
@@ -6813,6 +6889,20 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
origin = origin - direction * 1e4
|
origin = origin - direction * 1e4
|
||||||
return origin, direction
|
return origin, direction
|
||||||
|
|
||||||
|
def _get_current_anchor_guid(self) -> "Optional[str]":
|
||||||
|
"""Return the element GUID the active anchor is currently bound to, or None."""
|
||||||
|
if self._active_vertex_idx < 0 or not self._annotation:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pset_data = ifcopenshell.util.element.get_pset(self._annotation, "BBIM_Dimension")
|
||||||
|
if not pset_data or not pset_data.get("Anchors"):
|
||||||
|
return None
|
||||||
|
anchors = json.loads(pset_data["Anchors"])
|
||||||
|
anchor = anchors[self._active_vertex_idx]
|
||||||
|
return anchor.get("guid") or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _compute_candidates(self, context, coord):
|
def _compute_candidates(self, context, coord):
|
||||||
"""Cast a ray from *coord* and return a ranked list of hit candidates.
|
"""Cast a ray from *coord* and return a ranked list of hit candidates.
|
||||||
|
|
||||||
@@ -6824,10 +6914,33 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
|
|
||||||
origin, direction = self._unproject_coord(coord)
|
origin, direction = self._unproject_coord(coord)
|
||||||
depsgraph = context.evaluated_depsgraph_get()
|
depsgraph = context.evaluated_depsgraph_get()
|
||||||
|
view_layer = context.view_layer
|
||||||
|
|
||||||
|
def _is_hidden(obj):
|
||||||
|
h = obj.hide_get(view_layer=view_layer)
|
||||||
|
hv = obj.hide_viewport
|
||||||
|
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.
|
||||||
|
_face_cam_view = None
|
||||||
|
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()
|
||||||
|
|
||||||
|
def _face_perp_ok(normal_w):
|
||||||
|
"""Return True when the face is acceptably perpendicular to the camera."""
|
||||||
|
if _face_cam_view is None:
|
||||||
|
return True
|
||||||
|
return abs(normal_w.dot(_face_cam_view)) < 0.5
|
||||||
|
|
||||||
# Scene-BVH pierce-through: O(log N) vs the previous O(N) per-object loop.
|
# 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.
|
# Each iteration steps past the last hit surface to reach the next object.
|
||||||
direct: list = []
|
direct: list = []
|
||||||
|
ray_hit_objs: set = set() # all IFC objects the ray passed through (any face)
|
||||||
ray_origin = Vector(origin)
|
ray_origin = Vector(origin)
|
||||||
_EPS = 1e-4
|
_EPS = 1e-4
|
||||||
|
|
||||||
@@ -6841,23 +6954,39 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval)
|
ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval)
|
||||||
if ifc_obj == self._annotation_obj:
|
if ifc_obj == self._annotation_obj:
|
||||||
continue
|
continue
|
||||||
if not ifc_obj.visible_get():
|
if _is_hidden(ifc_obj):
|
||||||
continue
|
continue
|
||||||
if ifc_obj.type != "MESH":
|
if ifc_obj.type != "MESH":
|
||||||
continue
|
continue
|
||||||
if not tool.Ifc.get_entity(ifc_obj):
|
if not tool.Ifc.get_entity(ifc_obj):
|
||||||
continue
|
continue
|
||||||
|
ray_hit_objs.add(ifc_obj) # track even if face is non-perp
|
||||||
mx = ifc_obj.matrix_world
|
mx = ifc_obj.matrix_world
|
||||||
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
# In FACE mode use the exact hit face; _prefer_perp_face_index is only
|
||||||
|
# needed for VERTEX/EDGE profile snapping.
|
||||||
|
if self._snap_mode != "FACE":
|
||||||
|
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
||||||
normal = (
|
normal = (
|
||||||
(mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized()
|
(mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized()
|
||||||
if fi is not None
|
if fi is not None
|
||||||
else nrm_w.normalized()
|
else nrm_w.normalized()
|
||||||
)
|
)
|
||||||
|
if not _face_perp_ok(normal):
|
||||||
|
continue
|
||||||
dist = (loc_w - origin).length
|
dist = (loc_w - origin).length
|
||||||
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
||||||
if direct:
|
if direct:
|
||||||
direct.sort(key=lambda c: c[0])
|
direct.sort(key=lambda c: c[0])
|
||||||
|
# Prefer the element the anchor is currently bound to so that
|
||||||
|
# re-picking a gizmo dot defaults to the same element rather than
|
||||||
|
# whatever happened to be closest along the ray.
|
||||||
|
preferred_guid = self._get_current_anchor_guid()
|
||||||
|
if preferred_guid:
|
||||||
|
for _pi, _pc in enumerate(direct):
|
||||||
|
if getattr(tool.Ifc.get_entity(_pc[1]), "GlobalId", None) == preferred_guid:
|
||||||
|
if _pi > 0:
|
||||||
|
direct.insert(0, direct.pop(_pi))
|
||||||
|
break
|
||||||
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct]
|
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct]
|
||||||
|
|
||||||
# Proximity fallback — collect ALL candidates within TOL, sorted by perp distance.
|
# Proximity fallback — collect ALL candidates within TOL, sorted by perp distance.
|
||||||
@@ -6870,9 +6999,10 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
for ifc_obj in context.scene.objects:
|
for ifc_obj in context.scene.objects:
|
||||||
if ifc_obj == self._annotation_obj:
|
if ifc_obj == self._annotation_obj:
|
||||||
continue
|
continue
|
||||||
if not ifc_obj.visible_get():
|
if _is_hidden(ifc_obj):
|
||||||
continue
|
continue
|
||||||
if not tool.Ifc.get_entity(ifc_obj):
|
elem = tool.Ifc.get_entity(ifc_obj)
|
||||||
|
if not elem:
|
||||||
continue
|
continue
|
||||||
if ifc_obj.type != "MESH":
|
if ifc_obj.type != "MESH":
|
||||||
continue
|
continue
|
||||||
@@ -6900,11 +7030,17 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
if not found:
|
if not found:
|
||||||
continue
|
continue
|
||||||
loc_w = mx @ loc_l
|
loc_w = mx @ loc_l
|
||||||
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
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()
|
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):
|
||||||
|
continue
|
||||||
prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
||||||
|
|
||||||
prox.sort(key=lambda c: c[0])
|
# Objects the ray directly passed through get priority over objects that
|
||||||
|
# are merely nearby — prevents adjacent windows/walls stealing the snap
|
||||||
|
# from an element the cursor is actually over.
|
||||||
|
prox.sort(key=lambda c: (0 if c[1] in ray_hit_objs else 1, c[0]))
|
||||||
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox]
|
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox]
|
||||||
|
|
||||||
def _handle_hover(self, context, event):
|
def _handle_hover(self, context, event):
|
||||||
@@ -6934,31 +7070,45 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
self._apply_hover_highlight(context)
|
self._apply_hover_highlight(context)
|
||||||
|
|
||||||
def _apply_hover_highlight(self, context):
|
def _apply_hover_highlight(self, context):
|
||||||
"""Select the current candidate object; compute snap geometry; update status."""
|
"""Compute snap geometry for the current candidate and update the status bar."""
|
||||||
if not self._hover_candidates:
|
if not self._hover_candidates:
|
||||||
self._clear_hover_highlight(context)
|
self._clear_hover_highlight(context)
|
||||||
_snap_draw_data.clear()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index]
|
ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index]
|
||||||
|
|
||||||
# Only update selection when the highlighted object changes.
|
sg = self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px)
|
||||||
if ifc_obj != self._hover_highlighted_obj:
|
_anchor_hover_draw_data.clear()
|
||||||
if self._hover_highlighted_obj:
|
if sg and self._region and self._rv3d:
|
||||||
try:
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||||
self._hover_highlighted_obj.select_set(False)
|
snap_type = sg.get("type")
|
||||||
except Exception:
|
_anchor_hover_draw_data["type"] = snap_type
|
||||||
pass
|
if snap_type == "FACE":
|
||||||
self._hover_highlighted_obj = ifc_obj
|
verts_2d = [
|
||||||
try:
|
tuple(location_3d_to_region_2d(self._region, self._rv3d, v) or (0, 0))
|
||||||
ifc_obj.select_set(True)
|
for v in sg.get("face_verts", [])
|
||||||
context.view_layer.objects.active = ifc_obj
|
]
|
||||||
except Exception:
|
_anchor_hover_draw_data["face_verts_2d"] = verts_2d
|
||||||
pass
|
elif snap_type == "EDGE":
|
||||||
|
v0 = location_3d_to_region_2d(self._region, self._rv3d, sg["v0"])
|
||||||
_snap_draw_data.clear()
|
v1 = location_3d_to_region_2d(self._region, self._rv3d, sg["v1"])
|
||||||
_snap_draw_data.update(self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px))
|
_anchor_hover_draw_data["v0_2d"] = tuple(v0) if v0 else None
|
||||||
|
_anchor_hover_draw_data["v1_2d"] = tuple(v1) if v1 else None
|
||||||
|
elif snap_type == "LAYER":
|
||||||
|
pt = sg.get("snap_world")
|
||||||
|
if pt:
|
||||||
|
sp = location_3d_to_region_2d(self._region, self._rv3d, pt)
|
||||||
|
_anchor_hover_draw_data["snap_2d"] = tuple(sp) if sp else None
|
||||||
|
corners_3d = sg.get("seam_corners", [])
|
||||||
|
_anchor_hover_draw_data["seam_corners_2d"] = [
|
||||||
|
tuple(location_3d_to_region_2d(self._region, self._rv3d, c) or (0, 0))
|
||||||
|
for c in corners_3d
|
||||||
|
]
|
||||||
|
elif snap_type == "VERTEX":
|
||||||
|
pt = sg.get("snap_world")
|
||||||
|
if pt:
|
||||||
|
sp = location_3d_to_region_2d(self._region, self._rv3d, pt)
|
||||||
|
_anchor_hover_draw_data["snap_2d"] = tuple(sp) if sp else None
|
||||||
entity = tool.Ifc.get_entity(ifc_obj)
|
entity = tool.Ifc.get_entity(ifc_obj)
|
||||||
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
||||||
n = len(self._hover_candidates)
|
n = len(self._hover_candidates)
|
||||||
@@ -6974,13 +7124,9 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _clear_hover_highlight(self, context):
|
def _clear_hover_highlight(self, context):
|
||||||
"""Deselect the highlighted object and restore the annotation as active."""
|
"""Clear hover draw data and restore the annotation as the active object."""
|
||||||
if self._hover_highlighted_obj:
|
self._hover_highlighted_obj = None
|
||||||
try:
|
_anchor_hover_draw_data.clear()
|
||||||
self._hover_highlighted_obj.select_set(False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._hover_highlighted_obj = None
|
|
||||||
try:
|
try:
|
||||||
self._annotation_obj.select_set(True)
|
self._annotation_obj.select_set(True)
|
||||||
context.view_layer.objects.active = self._annotation_obj
|
context.view_layer.objects.active = self._annotation_obj
|
||||||
@@ -6989,7 +7135,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
|
|
||||||
def cancel(self, context):
|
def cancel(self, context):
|
||||||
"""Called when the operator is cancelled externally — clean up GPU handler."""
|
"""Called when the operator is cancelled externally — clean up GPU handler."""
|
||||||
_snap_draw_data.clear()
|
_anchor_hover_draw_data.clear()
|
||||||
if self._draw_handler:
|
if self._draw_handler:
|
||||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||||
self._draw_handler = None
|
self._draw_handler = None
|
||||||
@@ -7003,6 +7149,82 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Snap geometry helpers
|
# Snap geometry helpers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coplanar_face_outline(obj, mx, seed_face):
|
||||||
|
"""Return ordered world-space vertices forming the outline of the planar region.
|
||||||
|
|
||||||
|
Finds all polygons on *obj* coplanar with *seed_face*, collects their
|
||||||
|
boundary edges (edges shared by only one polygon in the group), then
|
||||||
|
walks those edges into a single ordered loop and converts to world space.
|
||||||
|
Falls back to the seed face's own vertices if the walk fails.
|
||||||
|
"""
|
||||||
|
# Skip the full boundary-walk for highly-tessellated meshes (e.g. terrain).
|
||||||
|
# The O(N) polygon scan freezes Blender on objects with thousands of faces.
|
||||||
|
if len(obj.data.polygons) > 500:
|
||||||
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
||||||
|
|
||||||
|
target_n = seed_face.normal.copy()
|
||||||
|
target_d = seed_face.center.dot(target_n)
|
||||||
|
tol_n = 1e-3
|
||||||
|
tol_d = 1e-3
|
||||||
|
|
||||||
|
# Collect all coplanar polygon indices
|
||||||
|
coplanar = [
|
||||||
|
i for i, p in enumerate(obj.data.polygons)
|
||||||
|
if abs(p.normal.dot(target_n) - 1.0) <= tol_n
|
||||||
|
and abs(p.center.dot(target_n) - target_d) <= tol_d
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count edge appearances; boundary edges appear exactly once
|
||||||
|
edge_count: dict = {}
|
||||||
|
for fi in coplanar:
|
||||||
|
poly = obj.data.polygons[fi]
|
||||||
|
verts = list(poly.vertices)
|
||||||
|
n = len(verts)
|
||||||
|
for i in range(n):
|
||||||
|
e = (min(verts[i], verts[(i + 1) % n]), max(verts[i], verts[(i + 1) % n]))
|
||||||
|
edge_count[e] = edge_count.get(e, 0) + 1
|
||||||
|
boundary = [e for e, cnt in edge_count.items() if cnt == 1]
|
||||||
|
|
||||||
|
if not boundary:
|
||||||
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
||||||
|
|
||||||
|
# Build adjacency map
|
||||||
|
adj: dict = {}
|
||||||
|
for a, b in boundary:
|
||||||
|
adj.setdefault(a, []).append(b)
|
||||||
|
adj.setdefault(b, []).append(a)
|
||||||
|
|
||||||
|
# Walk ALL disconnected loops (outer perimeter + any window/door hole loops).
|
||||||
|
# A wall with a window void has two loops: the outer wall outline and the
|
||||||
|
# inner opening perimeter. We want the largest loop (outer boundary).
|
||||||
|
unvisited = set(v for e in boundary for v in e)
|
||||||
|
loops: list = []
|
||||||
|
while unvisited:
|
||||||
|
start = next(iter(unvisited))
|
||||||
|
ring = [start]
|
||||||
|
unvisited.discard(start)
|
||||||
|
prev, cur = None, start
|
||||||
|
for _ in range(len(boundary) + 1):
|
||||||
|
nxts = [v for v in adj.get(cur, []) if v != prev]
|
||||||
|
if not nxts or nxts[0] == start:
|
||||||
|
break
|
||||||
|
prev, cur = cur, nxts[0]
|
||||||
|
if cur in unvisited:
|
||||||
|
unvisited.discard(cur)
|
||||||
|
ring.append(cur)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if len(ring) >= 3:
|
||||||
|
loops.append(ring)
|
||||||
|
|
||||||
|
if not loops:
|
||||||
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
||||||
|
|
||||||
|
# The outer perimeter has the most vertices; window/door holes are smaller.
|
||||||
|
best_ring = max(loops, key=len)
|
||||||
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in best_ring]
|
||||||
|
|
||||||
def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
|
def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
|
||||||
"""Return snap draw-data dict for the current snap mode and hit face.
|
"""Return snap draw-data dict for the current snap mode and hit face.
|
||||||
|
|
||||||
@@ -7028,6 +7250,10 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
||||||
|
|
||||||
if self._snap_mode == "FACE":
|
if self._snap_mode == "FACE":
|
||||||
|
# For tessellated meshes a single polygon may be a tiny micro-triangle.
|
||||||
|
# Find all coplanar faces on the same plane and walk their boundary edges
|
||||||
|
# to produce the full planar face outline.
|
||||||
|
face_verts_world = self._coplanar_face_outline(hit_obj, mx, face)
|
||||||
return {"type": "FACE", "face_verts": face_verts_world}
|
return {"type": "FACE", "face_verts": face_verts_world}
|
||||||
|
|
||||||
# Try profile-based snap candidates first (IFC-native, index-stable).
|
# Try profile-based snap candidates first (IFC-native, index-stable).
|
||||||
|
|||||||
Reference in New Issue
Block a user