mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Fix anchor click, undo, layer regen, and ForcePerpendicularToFace for layer anchors
- ClickNearestDimensionAnchor: scan all selected objects instead of active object so clicking a green dot doesn't lose to the underlying IFC geometry - GizmoAnchorHandle: remove draw_select entirely (any entry in the select buffer causes Blender's gizmo system to consume clicks); keep purely visual - Scale anchor dots to scale_basis = 0.2 - Fix ReferenceError in decoration.py draw loop after undo by catching ReferenceError and resetting DecoratorData.is_loaded - SetDimensionAnchor: inherit tool.Ifc.Operator so IFC pset writes are tracked for undo; finish the modal after each face write so each anchor gets its own undo step - Fix ReferenceError in _modal after undo when annotation RNA is freed - handler.py: add regenerate_dims_for_layer; call it from EditMaterialSetItem._execute so dimensions update when layer thickness changes - regenerate_dimension.py: fix ForcePerpendicularToFace for LAYER_BOUNDARY anchors by deriving the thickness-axis normal from LayerSetDirection (AXIS2→Y, AXIS1→X, AXIS3→Z) instead of requiring a stored normal_local Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -215,9 +215,7 @@ def register():
|
||||
kc = wm.keyconfigs.addon
|
||||
if kc:
|
||||
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
|
||||
kmi = km.keymap_items.new(
|
||||
"bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS"
|
||||
)
|
||||
kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
|
||||
_keymaps.append((km, kmi))
|
||||
|
||||
|
||||
|
||||
@@ -2146,4 +2146,8 @@ class DecorationsHandler:
|
||||
|
||||
object_decorators = DecoratorData.data.get("object_decorators", [])
|
||||
for obj, decorator in object_decorators:
|
||||
decorator.decorate(context, obj)
|
||||
try:
|
||||
decorator.decorate(context, obj)
|
||||
except ReferenceError:
|
||||
DecoratorData.is_loaded = False
|
||||
break
|
||||
|
||||
@@ -2636,44 +2636,25 @@ class ExtrusionWidget(types.GizmoGroup):
|
||||
|
||||
|
||||
class GizmoAnchorHandle(bpy.types.Gizmo):
|
||||
"""Dot gizmo positioned at one vertex of a parametric dimension curve.
|
||||
"""Visual-only dot at a parametric dimension vertex.
|
||||
|
||||
Clicking it invokes ``bim.set_dimension_anchor`` pre-scoped to that vertex
|
||||
index, skipping the manual vertex-pick phase of the operator.
|
||||
No draw_select/invoke — any draw_select entry puts the gizmo in Blender's
|
||||
select buffer, which causes the gizmo system to consume the click even
|
||||
without an explicit invoke. All click handling is done by the
|
||||
bim.click_nearest_dimension_anchor keymap operator.
|
||||
"""
|
||||
|
||||
bl_idname = "BIM_GT_anchor_handle"
|
||||
|
||||
__slots__ = ("anchor_index", "custom_shape", "custom_shape_select")
|
||||
__slots__ = ("anchor_index", "custom_shape")
|
||||
|
||||
def setup(self):
|
||||
self.anchor_index = 0
|
||||
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
|
||||
# 4× scaled version used only for hit-detection — bigger target, same visual size.
|
||||
_sel = tuple((x * 4.0, y * 4.0, z) for x, y, z in X3DISC)
|
||||
self.custom_shape_select = self.new_custom_shape(type="TRIS", verts=_sel)
|
||||
|
||||
def draw(self, context):
|
||||
self.draw_custom_shape(self.custom_shape)
|
||||
|
||||
def draw_select(self, context, select_id):
|
||||
self.draw_custom_shape(self.custom_shape_select, select_id=select_id)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def modal(self, context, event, tweak):
|
||||
anchor_index = self.anchor_index
|
||||
|
||||
def _launch():
|
||||
try:
|
||||
bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=anchor_index)
|
||||
except Exception as e:
|
||||
print(f"[DimensionAnchorWidget] {e}")
|
||||
return None
|
||||
|
||||
bpy.app.timers.register(_launch, first_interval=0.0)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DimensionAnchorWidget(types.GizmoGroup):
|
||||
@@ -2697,12 +2678,22 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
# Stay visible while SetDimensionAnchor is running (active obj may be a temp element).
|
||||
# Stay visible while SetDimensionAnchor is running (active obj may temporarily
|
||||
# be an IFC element in the face-picking phase rather than the annotation).
|
||||
if _active_anchor_idx >= 0 and _editing_annotation_obj is not None:
|
||||
return True
|
||||
active = context.active_object
|
||||
if active is _editing_annotation_obj:
|
||||
return True # annotation still active
|
||||
if active is not None and tool.Ifc.get_entity(active) is not None:
|
||||
return True # face-picking phase: active obj is a target element
|
||||
# Active object is None or a non-IFC object — the modal ended without
|
||||
# calling set_active_anchor(-1). Reset stale state and fall through.
|
||||
set_active_anchor(-1)
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
return False
|
||||
if not obj.select_get():
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
return False
|
||||
@@ -2716,8 +2707,7 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
||||
self._handles: list = []
|
||||
for _ in range(self._MAX_ANCHORS):
|
||||
gz = self.gizmos.new("BIM_GT_anchor_handle")
|
||||
gz.scale_basis = 0.18
|
||||
gz.select_bias = -32.0
|
||||
gz.scale_basis = 0.2
|
||||
gz.use_draw_modal = True
|
||||
gz.hide = True
|
||||
self._handles.append(gz)
|
||||
@@ -2756,11 +2746,11 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
||||
|
||||
for i in range(n):
|
||||
gz = self._handles[i]
|
||||
world_co = obj.matrix_world @ spline.points[i].co.to_3d()
|
||||
raw_co = spline.points[i].co
|
||||
world_co = obj.matrix_world @ raw_co.to_3d()
|
||||
gz.matrix_basis = Matrix.Translation(world_co)
|
||||
gz.anchor_index = i
|
||||
if i == _active_anchor_idx and obj is _editing_annotation_obj:
|
||||
print(f"[refresh] setting anchor[{i}] BLUE (obj={obj.name} editing={_editing_annotation_obj.name if _editing_annotation_obj else None})")
|
||||
gz.color = (0.2, 0.7, 1.0)
|
||||
gz.color_highlight = (0.4, 0.85, 1.0)
|
||||
elif anchors[i].get("guid"):
|
||||
@@ -2777,7 +2767,6 @@ class DimensionAnchorWidget(types.GizmoGroup):
|
||||
self._handles[i].hide = True
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
print(f"[draw_prepare] DimensionAnchorWidget _active_anchor_idx={_active_anchor_idx}")
|
||||
self.refresh(context)
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,84 @@ def _rebuild_dim_guid_index(file) -> None:
|
||||
_dim_index_dirty = False
|
||||
|
||||
|
||||
def regenerate_dims_for_layer(file, layer) -> None:
|
||||
"""Regenerate all parametric dimensions anchored to elements that use *layer*."""
|
||||
global _dim_shape_cache, _dim_index_dirty, _dim_guid_index
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
affected_guids: set = set()
|
||||
for layer_set in file.get_inverse(layer):
|
||||
if not layer_set.is_a("IfcMaterialLayerSet"):
|
||||
continue
|
||||
for inv in file.get_inverse(layer_set):
|
||||
if inv.is_a("IfcRelAssociatesMaterial"):
|
||||
rels = [inv]
|
||||
elif inv.is_a("IfcMaterialLayerSetUsage"):
|
||||
rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")]
|
||||
else:
|
||||
continue
|
||||
for rel in rels:
|
||||
for element in rel.RelatedObjects:
|
||||
if hasattr(element, "GlobalId"):
|
||||
affected_guids.add(element.GlobalId)
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
if not affected_guids:
|
||||
return
|
||||
|
||||
annotation_ids: set = set()
|
||||
for guid in affected_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
annotation_ids.add(ann_id)
|
||||
|
||||
if not annotation_ids:
|
||||
return
|
||||
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
||||
|
||||
for ann_id in annotation_ids:
|
||||
try:
|
||||
annotation = file.by_id(ann_id)
|
||||
except Exception:
|
||||
continue
|
||||
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
if not pset:
|
||||
continue
|
||||
placement_override: dict = {}
|
||||
try:
|
||||
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
||||
for anchor in anchors_raw:
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
settings=geom_settings,
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
|
||||
|
||||
@persistent
|
||||
def load_post(*args):
|
||||
invalidate_dim_index()
|
||||
@@ -140,10 +218,7 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
||||
n_pts = len(spline_world)
|
||||
n_anchors = len(anchors)
|
||||
|
||||
print(f"[sync_anchors] obj={obj.name} spline_pts={n_pts} anchors={n_anchors}")
|
||||
|
||||
if n_pts == n_anchors:
|
||||
print("[sync_anchors] counts match — no change needed")
|
||||
return False
|
||||
|
||||
# Match each spline point to the nearest unused anchor by proximity.
|
||||
@@ -177,7 +252,6 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
|
||||
"pt": [pt.x, pt.y, pt.z],
|
||||
})
|
||||
|
||||
print(f"[sync_anchors] rebuilt {len(new_anchors)} anchors (was {n_anchors})")
|
||||
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)})
|
||||
@@ -197,10 +271,11 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
if not file:
|
||||
return
|
||||
|
||||
# Collect GUIDs of IFC objects whose transform or geometry changed.
|
||||
# is_updated_geometry fires on Edit Mode exit after Bonsai has already
|
||||
# serialised the new mesh back to IFC via update_representation, so the
|
||||
# tessellation will reflect the edited shape.
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
import ifcopenshell.util.element
|
||||
|
||||
moved_guids: set = set()
|
||||
edited_annotation_ids: set = set()
|
||||
|
||||
@@ -214,26 +289,20 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
if element is None or not hasattr(element, "GlobalId"):
|
||||
continue
|
||||
|
||||
# If the dimension annotation curve itself was edited (vertex added/removed),
|
||||
# sync the anchor list before regenerating.
|
||||
|
||||
if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
|
||||
import ifcopenshell.util.element as _ue
|
||||
ptype = _ue.get_predefined_type(element)
|
||||
print(f"[handler] dimension curve geometry updated: {obj.name} ptype={ptype} is_updated_geometry={update.is_updated_geometry}")
|
||||
if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
||||
_sync_dimension_anchors_to_curve(file, element, obj)
|
||||
edited_annotation_ids.add(element.id())
|
||||
changed = _sync_dimension_anchors_to_curve(file, element, obj)
|
||||
if changed:
|
||||
edited_annotation_ids.add(element.id())
|
||||
continue
|
||||
|
||||
moved_guids.add(element.GlobalId)
|
||||
# Geometry edits invalidate the cached tessellation for this element.
|
||||
if update.is_updated_geometry:
|
||||
_dim_shape_cache.pop(element.id(), None)
|
||||
|
||||
if _dim_index_dirty:
|
||||
_rebuild_dim_guid_index(file)
|
||||
|
||||
print(f"[handler] edited_annotation_ids={edited_annotation_ids} moved_guids={moved_guids}")
|
||||
annotation_ids: set = set(edited_annotation_ids)
|
||||
for guid in moved_guids:
|
||||
for ann_id in _dim_guid_index.get(guid, []):
|
||||
@@ -244,7 +313,6 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.element
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
geom_settings = ifcopenshell.geom.settings()
|
||||
@@ -282,7 +350,6 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[handler] regenerating ann_id={ann_id} anchors_in_pset={len(json.loads(pset.get('Anchors') or '[]'))}")
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
@@ -290,13 +357,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
|
||||
shape_cache=_dim_shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
print(f"[handler] resolved_pts count={len(resolved_pts)} pts={[(round(p[0],3),round(p[1],3),round(p[2],3)) for p in resolved_pts]}")
|
||||
if resolved_pts:
|
||||
obj = tool.Ifc.get_object(annotation)
|
||||
if obj:
|
||||
print(f"[handler] curve spline pts before update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
if obj:
|
||||
print(f"[handler] curve spline pts after update={len(obj.data.splines[0].points) if obj.data.splines else 0}")
|
||||
finally:
|
||||
_dim_handler_running = False
|
||||
|
||||
@@ -5701,19 +5701,42 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
def _update_perp_constraint(self) -> None:
|
||||
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
|
||||
import math
|
||||
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
|
||||
fp = (a.get("addr") or {}).get("fingerprint") or {}
|
||||
n = fp.get("normal") # world-space at build time
|
||||
addr = a.get("addr") or {}
|
||||
normal_local = addr.get("normal_local")
|
||||
pt = a.get("pt")
|
||||
if not n or not pt:
|
||||
if not normal_local or not pt:
|
||||
print(f"[perp] _update_perp_constraint: missing normal_local={normal_local} or pt={pt}")
|
||||
return
|
||||
|
||||
# Rotate element-local normal to world space via the Blender object's matrix.
|
||||
n = normal_local
|
||||
guid = a.get("guid")
|
||||
if guid:
|
||||
try:
|
||||
file = tool.Ifc.get()
|
||||
element = file.by_guid(guid)
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj:
|
||||
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}")
|
||||
|
||||
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."""
|
||||
@@ -5730,7 +5753,9 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
|
||||
base = self._anchor0_pt
|
||||
n = self._anchor0_normal
|
||||
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2]
|
||||
snap["point"] = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Finalize: create IfcAnnotation + BBIM_Dimension pset
|
||||
@@ -5917,7 +5942,85 @@ def _prefer_perp_face_index(
|
||||
return best_idx
|
||||
|
||||
|
||||
class SetDimensionAnchor(bpy.types.Operator):
|
||||
# Module-level draw data so the GPU callback never touches the operator RNA struct.
|
||||
_snap_draw_data: dict = {}
|
||||
|
||||
|
||||
|
||||
def _draw_snap_indicator_global():
|
||||
"""GPU draw callback (POST_VIEW) — draws face outline, edge, or vertex dot."""
|
||||
data = _snap_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")
|
||||
gpu.state.depth_test_set("ALWAYS")
|
||||
snap_type = data["type"]
|
||||
|
||||
if snap_type == "FACE":
|
||||
verts = data.get("face_verts", [])
|
||||
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"), data.get("v1")
|
||||
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 == "VERTEX":
|
||||
pt = data.get("snap_world")
|
||||
if pt:
|
||||
shader.bind()
|
||||
shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0))
|
||||
gpu.state.point_size_set(20.0)
|
||||
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
||||
|
||||
elif snap_type == "LAYER":
|
||||
corners = data.get("seam_corners", [])
|
||||
pt = data.get("snap_world")
|
||||
shader.bind()
|
||||
shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0))
|
||||
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)
|
||||
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.depth_test_set("LESS_EQUAL")
|
||||
gpu.state.blend_set("NONE")
|
||||
gpu.state.line_width_set(1.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Interactively anchor dimension vertices to IFC element faces.
|
||||
|
||||
Two-phase modal workflow (all in Object Mode):
|
||||
@@ -5950,12 +6053,17 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
|
||||
# Hover-cycle state (active during PICK_FACE phase)
|
||||
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
|
||||
_hover_index: int # which candidate is currently highlighted
|
||||
_hover_index: int # which element candidate is currently highlighted
|
||||
_hover_last_px: tuple # last cursor pixel position where candidates were computed
|
||||
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight
|
||||
|
||||
_VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
|
||||
_HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px
|
||||
# Snap-mode cycle state (FACE → EDGE → VERTEX, TAB)
|
||||
_snap_mode: str # "FACE" | "EDGE" | "VERTEX"
|
||||
_draw_handler: object # SpaceView3D draw handler handle
|
||||
|
||||
_VERTEX_PICK_RADIUS_PX = 20
|
||||
_HOVER_THROTTLE_PX_SQ = 25
|
||||
_SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX")
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
@@ -5980,6 +6088,12 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
|
||||
|
||||
def modal(self, context, event):
|
||||
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
|
||||
|
||||
def _invoke(self, context, event):
|
||||
obj = context.active_object
|
||||
self._annotation = tool.Ifc.get_entity(obj)
|
||||
self._annotation_obj = obj
|
||||
@@ -5996,6 +6110,11 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
self._hover_index = 0
|
||||
self._hover_last_px = (-9999, -9999)
|
||||
self._hover_highlighted_obj = None
|
||||
self._snap_mode = "FACE"
|
||||
_snap_draw_data.clear()
|
||||
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
||||
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
||||
)
|
||||
|
||||
# When invoked from a panel, context.region_data is None.
|
||||
# Walk the screen areas to find the actual 3D viewport region.
|
||||
@@ -6014,13 +6133,28 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
context.window_manager.modal_handler_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def modal(self, context, event):
|
||||
def _modal(self, context, event):
|
||||
# Undo while the modal is running can free the annotation object.
|
||||
try:
|
||||
_ = self._annotation_obj.name
|
||||
except ReferenceError:
|
||||
self._cleanup(context)
|
||||
return {"FINISHED"}
|
||||
|
||||
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
||||
self._clear_hover_highlight(context)
|
||||
context.workspace.status_text_set(None)
|
||||
_snap_draw_data.clear()
|
||||
if self._draw_handler:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||
self._draw_handler = None
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
return {"FINISHED"} # keep any anchors already written
|
||||
obj = context.active_object
|
||||
if obj:
|
||||
obj.select_set(False)
|
||||
context.view_layer.objects.active = None
|
||||
return {"FINISHED"}
|
||||
|
||||
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
||||
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
||||
@@ -6036,12 +6170,32 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
if self._phase == "PICK_VERTEX":
|
||||
self._handle_vertex_pick(context, event)
|
||||
else:
|
||||
self._handle_face_pick(context, event)
|
||||
wrote = self._handle_face_pick(context, event)
|
||||
if wrote:
|
||||
# Finish here so this anchor write is its own undo step.
|
||||
# The dimension stays selected so the user can click
|
||||
# another dot immediately.
|
||||
self._cleanup(context)
|
||||
return {"FINISHED"}
|
||||
self._set_status(context)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def _cleanup(self, context):
|
||||
self._clear_hover_highlight(context)
|
||||
context.workspace.status_text_set(None)
|
||||
_snap_draw_data.clear()
|
||||
if self._draw_handler:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||
self._draw_handler = None
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
for area in context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
break
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status bar
|
||||
|
||||
@@ -6136,12 +6290,9 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchor = drawing_api.make_world_anchor(pt_m)
|
||||
self._write_anchor(anchor, self._active_vertex_idx)
|
||||
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
||||
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
||||
self._phase = "PICK_VERTEX"
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
return
|
||||
return True
|
||||
|
||||
# Normal click — use whichever candidate is currently highlighted.
|
||||
self._clear_hover_highlight(context)
|
||||
@@ -6166,33 +6317,47 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
return
|
||||
|
||||
file = tool.Ifc.get()
|
||||
hit_m = (float(location.x), float(location.y), float(location.z))
|
||||
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
||||
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
||||
|
||||
# Recompute snap geometry at the exact click position for accuracy.
|
||||
snap = self._compute_snap_geom(hit_obj, face_index, coord)
|
||||
snap_type = snap.get("type", "FACE")
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
try:
|
||||
anchor = drawing_api.build_anchor_from_hit(
|
||||
file, element, hit_m, normal_m,
|
||||
shape_cache=self._shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
|
||||
anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
|
||||
elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
|
||||
anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap)
|
||||
elif snap_type == "EDGE" and snap.get("profile_x_m") is not None:
|
||||
anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap)
|
||||
elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None:
|
||||
# Tessellation fallback — no IFC profile data available.
|
||||
# Use the snap position as a static WORLD anchor rather than a
|
||||
# FACE fingerprint, so the endpoint stays at the correct vertex/
|
||||
# edge position instead of drifting to the face centre.
|
||||
sw = snap["snap_world"]
|
||||
anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])])
|
||||
else:
|
||||
hit_m = (float(location.x), float(location.y), float(location.z))
|
||||
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
||||
anchor = drawing_api.build_anchor_from_hit(
|
||||
file, element, hit_m, normal_m,
|
||||
shape_cache=self._shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}")
|
||||
self.report({"ERROR"}, f"build_anchor failed: {exc}")
|
||||
return
|
||||
|
||||
self._write_anchor(anchor, self._active_vertex_idx)
|
||||
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}",
|
||||
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
|
||||
)
|
||||
self._phase = "PICK_VERTEX"
|
||||
self._hover_candidates = []
|
||||
self._hover_index = 0
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hover / cycle helpers
|
||||
@@ -6285,7 +6450,10 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
bb_ctr = sum((v for v in bb_world), Vector()) / 8
|
||||
t = (bb_ctr - origin).dot(direction)
|
||||
query_w = origin + t * direction
|
||||
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
|
||||
try:
|
||||
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
|
||||
except RuntimeError:
|
||||
continue
|
||||
if not found:
|
||||
continue
|
||||
loc_w = mx @ loc_l
|
||||
@@ -6311,19 +6479,25 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
self._apply_hover_highlight(context)
|
||||
|
||||
def _cycle_hover(self, context):
|
||||
"""Advance to the next candidate and update the highlight."""
|
||||
"""Cycle snap mode (FACE → EDGE → VERTEX); advance element on wrap-around."""
|
||||
if not self._hover_candidates:
|
||||
return
|
||||
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
||||
modes = self._SNAP_MODES
|
||||
cur = modes.index(self._snap_mode)
|
||||
nxt = (cur + 1) % len(modes)
|
||||
self._snap_mode = modes[nxt]
|
||||
if nxt == 0 and len(self._hover_candidates) > 1:
|
||||
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
||||
self._apply_hover_highlight(context)
|
||||
|
||||
def _apply_hover_highlight(self, context):
|
||||
"""Select the current candidate object for visual feedback."""
|
||||
"""Select the current candidate object; compute snap geometry; update status."""
|
||||
if not self._hover_candidates:
|
||||
self._clear_hover_highlight(context)
|
||||
_snap_draw_data.clear()
|
||||
return
|
||||
|
||||
ifc_obj = self._hover_candidates[self._hover_index][0]
|
||||
ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index]
|
||||
|
||||
# Only update selection when the highlighted object changes.
|
||||
if ifc_obj != self._hover_highlighted_obj:
|
||||
@@ -6339,14 +6513,22 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_snap_draw_data.clear()
|
||||
_snap_draw_data.update(self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px))
|
||||
|
||||
entity = tool.Ifc.get_entity(ifc_obj)
|
||||
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
||||
n = len(self._hover_candidates)
|
||||
cycle_hint = f" | TAB: cycle ({self._hover_index + 1}/{n})" if n > 1 else ""
|
||||
mode_label = self._snap_mode.capitalize()
|
||||
elem_hint = f" ({self._hover_index + 1}/{n})" if n > 1 else ""
|
||||
context.workspace.status_text_set(
|
||||
f"Vertex {self._active_vertex_idx} — {ifc_obj.name}{cycle_hint}"
|
||||
" | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
|
||||
f"Dim vertex {self._active_vertex_idx} — {label} [{mode_label}{elem_hint}]"
|
||||
" | TAB: cycle snap | Click: anchor | ALT+Click: free | RMB/ESC: Finish"
|
||||
)
|
||||
for area in context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
break
|
||||
|
||||
def _clear_hover_highlight(self, context):
|
||||
"""Deselect the highlighted object and restore the annotation as active."""
|
||||
@@ -6362,77 +6544,199 @@ class SetDimensionAnchor(bpy.types.Operator):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel(self, context):
|
||||
"""Called when the operator is cancelled externally — clean up GPU handler."""
|
||||
_snap_draw_data.clear()
|
||||
if self._draw_handler:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
||||
self._draw_handler = None
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(-1)
|
||||
obj = context.active_object
|
||||
if obj:
|
||||
obj.select_set(False)
|
||||
context.view_layer.objects.active = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pset write (shared by both face and free-point paths)
|
||||
# Snap geometry helpers
|
||||
|
||||
def _write_anchor(self, new_anchor: dict, vertex_index: int) -> None:
|
||||
file = tool.Ifc.get()
|
||||
annotation = self._annotation
|
||||
def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
|
||||
"""Return snap draw-data dict for the current snap mode and hit face.
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
When the hit element has an IfcExtrudedAreaSolid, VERTEX and EDGE snaps
|
||||
are resolved from the IFC profile geometry (stable across mesh reloads)
|
||||
rather than from Blender tessellation vertex indices.
|
||||
|
||||
if pset_data and pset_data.get("Anchors"):
|
||||
try:
|
||||
anchors: list = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
anchors = []
|
||||
else:
|
||||
anchors = _anchors_from_spline(self._annotation_obj, file)
|
||||
``coord`` is a (x, y) tuple in region-local pixels. Returns an empty
|
||||
dict when the face index is invalid or the region is unavailable.
|
||||
"""
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
while len(anchors) <= vertex_index:
|
||||
obj = self._annotation_obj
|
||||
idx = len(anchors)
|
||||
if obj and obj.data and hasattr(obj.data, "splines") and obj.data.splines:
|
||||
pts = obj.data.splines[0].points
|
||||
if idx < len(pts):
|
||||
co = obj.matrix_world @ pts[idx].co.xyz
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
|
||||
continue
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
|
||||
region = self._region
|
||||
rv3d = self._rv3d
|
||||
if not region or not rv3d or face_index is None:
|
||||
return {}
|
||||
try:
|
||||
face = hit_obj.data.polygons[face_index]
|
||||
except (IndexError, AttributeError):
|
||||
return {}
|
||||
|
||||
anchors[vertex_index] = new_anchor
|
||||
anchors_json = json.dumps(anchors)
|
||||
mx = hit_obj.matrix_world
|
||||
face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
||||
|
||||
if pset_data:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||
else:
|
||||
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||
if self._snap_mode == "FACE":
|
||||
return {"type": "FACE", "face_verts": face_verts_world}
|
||||
|
||||
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||
_drawing_handler.invalidate_dim_index()
|
||||
# Try profile-based snap candidates first (IFC-native, index-stable).
|
||||
if self._snap_mode in ("VERTEX", "EDGE"):
|
||||
element = tool.Ifc.get_entity(hit_obj)
|
||||
if element:
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
placement_override = {element.id(): np.array(mx)}
|
||||
candidates = drawing_api.get_profile_snap_candidates(
|
||||
tool.Ifc.get(), element, placement_override=placement_override
|
||||
)
|
||||
want_type = self._snap_mode
|
||||
best_cand = None
|
||||
best_d2 = float("inf")
|
||||
for cand in candidates:
|
||||
if cand["type"] != want_type:
|
||||
continue
|
||||
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
||||
if sp is None:
|
||||
continue
|
||||
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||
d2 = dx * dx + dy * dy
|
||||
if d2 < best_d2:
|
||||
best_d2, best_cand = d2, cand
|
||||
if best_cand is not None:
|
||||
return best_cand
|
||||
|
||||
# Move the Blender curve vertex to the newly resolved anchor position.
|
||||
# Build placement_override from current Blender matrix_world so that
|
||||
# elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly.
|
||||
placement_override: dict = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
# Fallback: Blender tessellation snap for elements without an
|
||||
# IfcExtrudedAreaSolid profile. Returns snap_world for the visual
|
||||
# indicator; no pt_idx so the click handler creates a face anchor.
|
||||
screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world]
|
||||
n = len(face_verts_world)
|
||||
|
||||
if self._snap_mode == "VERTEX":
|
||||
best_i, best_d2 = 0, float("inf")
|
||||
for i, sp in enumerate(screen_pts):
|
||||
if sp is not None:
|
||||
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||
d2 = dx * dx + dy * dy
|
||||
if d2 < best_d2:
|
||||
best_d2, best_i = d2, i
|
||||
return {"type": "VERTEX", "snap_world": face_verts_world[best_i]}
|
||||
|
||||
if self._snap_mode == "EDGE":
|
||||
best_e, best_d2 = 0, float("inf")
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
sp0, sp1 = screen_pts[i], screen_pts[j]
|
||||
if sp0 is not None and sp1 is not None:
|
||||
mid_x = (sp0.x + sp1.x) * 0.5
|
||||
mid_y = (sp0.y + sp1.y) * 0.5
|
||||
dx, dy = mid_x - coord[0], mid_y - coord[1]
|
||||
d2 = dx * dx + dy * dy
|
||||
if d2 < best_d2:
|
||||
best_d2, best_e = d2, i
|
||||
i0, i1 = best_e, (best_e + 1) % n
|
||||
v0_w, v1_w = face_verts_world[i0], face_verts_world[i1]
|
||||
mid_w = ((v0_w[0] + v1_w[0]) * 0.5, (v0_w[1] + v1_w[1]) * 0.5, (v0_w[2] + v1_w[2]) * 0.5)
|
||||
return {"type": "EDGE", "v0": v0_w, "v1": v1_w, "snap_world": mid_w}
|
||||
|
||||
if self._snap_mode == "LAYER":
|
||||
element = tool.Ifc.get_entity(hit_obj)
|
||||
if element:
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
placement_override = {element.id(): np.array(mx)}
|
||||
candidates = drawing_api.get_layer_snap_candidates(
|
||||
tool.Ifc.get(), element, placement_override=placement_override
|
||||
)
|
||||
best_cand = None
|
||||
best_d2 = float("inf")
|
||||
for cand in candidates:
|
||||
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
||||
if sp is None:
|
||||
continue
|
||||
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
||||
d2 = dx * dx + dy * dy
|
||||
if d2 < best_d2:
|
||||
best_d2, best_cand = d2, cand
|
||||
if best_cand is not None:
|
||||
result = dict(best_cand)
|
||||
result["type"] = "LAYER"
|
||||
result["method"] = "LAYER_BOUNDARY"
|
||||
return result
|
||||
return {"type": "FACE", "face_verts": face_verts_world}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None:
|
||||
"""Write one anchor into the BBIM_Dimension pset and regenerate the curve."""
|
||||
file = tool.Ifc.get()
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
|
||||
if pset_data and pset_data.get("Anchors"):
|
||||
try:
|
||||
anchors: list = json.loads(pset_data["Anchors"])
|
||||
except Exception:
|
||||
anchors = []
|
||||
else:
|
||||
anchors = _anchors_from_spline(annotation_obj, file)
|
||||
|
||||
while len(anchors) <= vertex_index:
|
||||
idx = len(anchors)
|
||||
if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines:
|
||||
pts = annotation_obj.data.splines[0].points
|
||||
if idx < len(pts):
|
||||
co = annotation_obj.matrix_world @ pts[idx].co.xyz
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
shape_cache=getattr(self, "_shape_cache", None),
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}")
|
||||
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
|
||||
|
||||
anchors[vertex_index] = new_anchor
|
||||
anchors_json = json.dumps(anchors)
|
||||
|
||||
if pset_data:
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||
else:
|
||||
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
pset_entity = file.by_id(pset_data["id"])
|
||||
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
||||
|
||||
from bonsai.bim.module.drawing import handler as _drawing_handler
|
||||
_drawing_handler.invalidate_dim_index()
|
||||
|
||||
placement_override: dict = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
if not guid:
|
||||
continue
|
||||
try:
|
||||
elem = file.by_guid(guid)
|
||||
elem_obj = tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import ifcopenshell.api.drawing as drawing_api
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file,
|
||||
annotation,
|
||||
shape_cache=shape_cache,
|
||||
placement_override=placement_override,
|
||||
)
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
|
||||
|
||||
|
||||
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -6601,7 +6905,6 @@ def _update_blender_curve(
|
||||
spline.points.add(n - 1)
|
||||
|
||||
is_2d = _annotation_is_2d(annotation)
|
||||
|
||||
for i, pt_m in enumerate(resolved_pts_m):
|
||||
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
|
||||
local_pt = inv_world @ blender_world
|
||||
@@ -6613,7 +6916,10 @@ def _update_blender_curve(
|
||||
spline.points[i].co = (*local_pt, 1.0)
|
||||
|
||||
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions.
|
||||
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
|
||||
try:
|
||||
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
|
||||
@@ -6716,46 +7022,24 @@ def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopens
|
||||
return None
|
||||
|
||||
|
||||
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
||||
"""LMB handler: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot.
|
||||
|
||||
Registered as a keymap item so it runs before Blender's object-selection handler.
|
||||
Returns PASS_THROUGH when the cursor is not near any anchor, so normal viewport
|
||||
clicks are unaffected.
|
||||
|
||||
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
||||
"""LMB fallback: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot.
|
||||
|
||||
The gizmo handles exact hits; this catches near-misses where the cursor
|
||||
is close to a dot but didn't land inside the gizmo hit shape.
|
||||
"""
|
||||
|
||||
bl_idname = "bim.click_nearest_dimension_anchor"
|
||||
bl_label = "Click Nearest Dimension Anchor"
|
||||
|
||||
RADIUS_PX = 120
|
||||
RADIUS_PX = 60
|
||||
|
||||
def invoke(self, context, event):
|
||||
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
||||
|
||||
print(f"[AnchorClick] invoke called")
|
||||
|
||||
if not tool.Ifc.get():
|
||||
print(f"[AnchorClick] PASS_THROUGH — no IFC file")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
obj = context.active_object
|
||||
if not obj or obj.type != "CURVE":
|
||||
print(f"[AnchorClick] PASS_THROUGH — active obj is {obj} type={getattr(obj,'type',None)}")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
print(f"[AnchorClick] PASS_THROUGH — not an IfcAnnotation: {element}")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
import ifcopenshell.util.element as _ue
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
print(f"[AnchorClick] PASS_THROUGH — no BBIM_Dimension pset or Anchors")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
if not obj.data.splines:
|
||||
print(f"[AnchorClick] PASS_THROUGH — no splines")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Always use the 3D viewport WINDOW region — context.region may be a header,
|
||||
@@ -6777,35 +7061,54 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
|
||||
break
|
||||
|
||||
if not region or not rv3d:
|
||||
print(f"[AnchorClick] PASS_THROUGH — no 3D region")
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
# Convert absolute mouse position to WINDOW region-local coordinates.
|
||||
cx = event.mouse_x - region.x
|
||||
cy = event.mouse_y - region.y
|
||||
|
||||
import ifcopenshell.util.element as _ue
|
||||
|
||||
r2 = self.RADIUS_PX ** 2
|
||||
best_obj = None
|
||||
best_idx = -1
|
||||
best_dist_sq = float("inf")
|
||||
for i, pt in enumerate(obj.data.splines[0].points):
|
||||
world_pos = obj.matrix_world @ pt.co.to_3d()
|
||||
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
if not sp:
|
||||
continue
|
||||
dx, dy = cx - sp.x, cy - sp.y
|
||||
d2 = dx * dx + dy * dy
|
||||
print(f"[AnchorClick] click=({cx},{cy}) anchor[{i}]=({sp.x:.0f},{sp.y:.0f}) dist={d2**0.5:.1f}px radius={self.RADIUS_PX}px")
|
||||
if d2 < r2 and d2 < best_dist_sq:
|
||||
best_dist_sq = d2
|
||||
best_idx = i
|
||||
|
||||
if best_idx < 0:
|
||||
print(f"[AnchorClick] MISS — no anchor within {self.RADIUS_PX}px")
|
||||
# Scan ALL selected objects — context.active_object may have changed to an
|
||||
# underlying IFC element due to Blender's hover pre-selection, so we can't
|
||||
# rely on it being the dimension we want to click.
|
||||
for obj in context.scene.objects:
|
||||
if not obj.select_get():
|
||||
continue
|
||||
if obj.type != "CURVE":
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcAnnotation"):
|
||||
continue
|
||||
pset = _ue.get_pset(element, "BBIM_Dimension")
|
||||
if not pset or not pset.get("Anchors"):
|
||||
continue
|
||||
if not obj.data.splines:
|
||||
continue
|
||||
|
||||
for i, pt in enumerate(obj.data.splines[0].points):
|
||||
world_pos = obj.matrix_world @ pt.co.to_3d()
|
||||
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
if not sp:
|
||||
continue
|
||||
dx, dy = cx - sp.x, cy - sp.y
|
||||
d2 = dx * dx + dy * dy
|
||||
if d2 < r2 and d2 < best_dist_sq:
|
||||
best_dist_sq = d2
|
||||
best_idx = i
|
||||
best_obj = obj
|
||||
|
||||
if best_obj is None:
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
print(f"[AnchorClick] HIT anchor[{best_idx}] dist={best_dist_sq**0.5:.1f}px")
|
||||
context.view_layer.objects.active = best_obj
|
||||
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
||||
set_active_anchor(best_idx, obj)
|
||||
set_active_anchor(best_idx, best_obj)
|
||||
# Force viewport redraw so gizmo colors update before the modal starts.
|
||||
for area in context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
|
||||
@@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
slab.DumbSlabPlaner().regenerate_from_layer(layer)
|
||||
wall.DumbWallPlaner().regenerate_from_layer(layer)
|
||||
from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer
|
||||
regenerate_dims_for_layer(self.file, layer)
|
||||
elif material.is_a("IfcMaterialProfileSet"):
|
||||
profile_def = None
|
||||
if mprops.profiles:
|
||||
|
||||
@@ -90,7 +90,6 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def _regenerate_parametric_dimension(file, annotation):
|
||||
"""Regenerate a single parametric dimension annotation after a pset edit."""
|
||||
print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}")
|
||||
try:
|
||||
import json
|
||||
import numpy as np
|
||||
@@ -100,13 +99,10 @@ def _regenerate_parametric_dimension(file, annotation):
|
||||
from bonsai.bim.module.drawing.operator import _update_blender_curve
|
||||
|
||||
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
||||
print(f"[regen_dim] pset_data keys={list(pset_data.keys()) if pset_data else None}")
|
||||
if not pset_data or not pset_data.get("Anchors"):
|
||||
print("[regen_dim] no Anchors — skipping")
|
||||
return
|
||||
|
||||
anchors = json.loads(pset_data["Anchors"])
|
||||
print(f"[regen_dim] {len(anchors)} anchors")
|
||||
placement_override = {}
|
||||
for a in anchors:
|
||||
guid = a.get("guid")
|
||||
@@ -117,17 +113,14 @@ def _regenerate_parametric_dimension(file, annotation):
|
||||
elem_obj = _tool.Ifc.get_object(elem)
|
||||
if elem_obj:
|
||||
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
||||
print(f"[regen_dim] placement_override added for {elem.is_a()} id={elem.id()}")
|
||||
except Exception as e:
|
||||
print(f"[regen_dim] placement_override error: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved_pts = drawing_api.regenerate_dimension(
|
||||
file, annotation, placement_override=placement_override
|
||||
)
|
||||
print(f"[regen_dim] resolved_pts={resolved_pts}")
|
||||
if resolved_pts:
|
||||
_update_blender_curve(annotation, resolved_pts)
|
||||
print("[regen_dim] _update_blender_curve done")
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -197,12 +190,9 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
if tool.Cost.has_schedules():
|
||||
tool.Cost.update_cost_items(pset=pset)
|
||||
print(f"[edit_pset] pset_name='{props.active_pset_name}' element={element.is_a()} before disable_pset_editing")
|
||||
is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation")
|
||||
|
||||
bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type)
|
||||
|
||||
print(f"[edit_pset] pset_name after disable='{props.active_pset_name}' is_bbim_dimension={is_bbim_dimension}")
|
||||
if is_bbim_dimension:
|
||||
_regenerate_parametric_dimension(self.file, element)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from .. import wrap_usecases
|
||||
from .assign_product import assign_product
|
||||
from .edit_text_literal import edit_text_literal
|
||||
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths
|
||||
from .resolve_anchor import build_anchor_from_hit, make_world_anchor, resolve_anchor
|
||||
from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor
|
||||
from .unassign_product import unassign_product
|
||||
|
||||
wrap_usecases(__path__, __name__)
|
||||
@@ -34,8 +34,13 @@ wrap_usecases(__path__, __name__)
|
||||
__all__ = [
|
||||
"assign_product",
|
||||
"build_anchor_from_hit",
|
||||
"build_anchor_from_layer_boundary",
|
||||
"build_anchor_from_profile_edge",
|
||||
"build_anchor_from_profile_vert",
|
||||
"edit_text_literal",
|
||||
"get_dimension_segment_lengths",
|
||||
"get_layer_snap_candidates",
|
||||
"get_profile_snap_candidates",
|
||||
"make_world_anchor",
|
||||
"regenerate_dimension",
|
||||
"resolve_anchor",
|
||||
|
||||
@@ -296,33 +296,44 @@ def _get_anchor_face_normal_world(
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Return the world-space unit face normal stored in a FACE anchor, or None.
|
||||
|
||||
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by
|
||||
the current element placement. Falls back to the stored world-space normal.
|
||||
Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
|
||||
addr and rotates it to world space via the current element placement.
|
||||
Also accepts the legacy ``addr.fingerprint.normal_local`` format.
|
||||
"""
|
||||
if anchor.get("type") != "FACE":
|
||||
return None
|
||||
guid = anchor.get("guid")
|
||||
if not guid:
|
||||
return None
|
||||
fp = (anchor.get("addr") or {}).get("fingerprint") or {}
|
||||
try:
|
||||
element = file.by_guid(guid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
normal_local = fp.get("normal_local")
|
||||
if normal_local:
|
||||
try:
|
||||
element = file.by_guid(guid)
|
||||
except Exception:
|
||||
addr = anchor.get("addr") or {}
|
||||
from .resolve_anchor import _rotate_local_to_world
|
||||
|
||||
if addr.get("method") == "LAYER_BOUNDARY":
|
||||
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"):
|
||||
return None
|
||||
axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2")
|
||||
if axis == "AXIS1":
|
||||
normal_local: tuple = (1.0, 0.0, 0.0)
|
||||
elif axis == "AXIS3":
|
||||
normal_local = (0.0, 0.0, 1.0)
|
||||
else:
|
||||
normal_local = (0.0, 1.0, 0.0)
|
||||
else:
|
||||
# FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy).
|
||||
normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local")
|
||||
if not normal_local:
|
||||
return None
|
||||
from .resolve_anchor import _rotate_local_to_world
|
||||
n = _rotate_local_to_world(element, normal_local, placement_override)
|
||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
||||
|
||||
normal_world = fp.get("normal")
|
||||
if normal_world:
|
||||
mag = math.sqrt(sum(x * x for x in normal_world))
|
||||
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
|
||||
|
||||
return None
|
||||
n = _rotate_local_to_world(element, normal_local, placement_override)
|
||||
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
||||
return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
|
||||
|
||||
|
||||
def _get_line_offset_direction(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user