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:
Ryan Schultz
2026-05-18 06:36:00 -05:00
parent 49ecfbf494
commit b787d76ad6
10 changed files with 1342 additions and 707 deletions
@@ -215,9 +215,7 @@ def register():
kc = wm.keyconfigs.addon kc = wm.keyconfigs.addon
if kc: if kc:
km = kc.keymaps.new(name="3D View", space_type="VIEW_3D") km = kc.keymaps.new(name="3D View", space_type="VIEW_3D")
kmi = km.keymap_items.new( kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS")
"bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS"
)
_keymaps.append((km, kmi)) _keymaps.append((km, kmi))
@@ -2146,4 +2146,8 @@ class DecorationsHandler:
object_decorators = DecoratorData.data.get("object_decorators", []) object_decorators = DecoratorData.data.get("object_decorators", [])
for obj, decorator in object_decorators: for obj, decorator in object_decorators:
decorator.decorate(context, obj) try:
decorator.decorate(context, obj)
except ReferenceError:
DecoratorData.is_loaded = False
break
+21 -32
View File
@@ -2636,44 +2636,25 @@ class ExtrusionWidget(types.GizmoGroup):
class GizmoAnchorHandle(bpy.types.Gizmo): 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 No draw_select/invoke any draw_select entry puts the gizmo in Blender's
index, skipping the manual vertex-pick phase of the operator. 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" bl_idname = "BIM_GT_anchor_handle"
__slots__ = ("anchor_index", "custom_shape", "custom_shape_select") __slots__ = ("anchor_index", "custom_shape")
def setup(self): def setup(self):
self.anchor_index = 0 self.anchor_index = 0
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC) 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): def draw(self, context):
self.draw_custom_shape(self.custom_shape) 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): class DimensionAnchorWidget(types.GizmoGroup):
@@ -2697,12 +2678,22 @@ class DimensionAnchorWidget(types.GizmoGroup):
def poll(cls, context: bpy.types.Context) -> bool: def poll(cls, context: bpy.types.Context) -> bool:
if not tool.Ifc.get(): if not tool.Ifc.get():
return False 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: 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 obj = context.active_object
if not obj or obj.type != "CURVE": if not obj or obj.type != "CURVE":
return False return False
if not obj.select_get():
return False
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcAnnotation"): if not element or not element.is_a("IfcAnnotation"):
return False return False
@@ -2716,8 +2707,7 @@ class DimensionAnchorWidget(types.GizmoGroup):
self._handles: list = [] self._handles: list = []
for _ in range(self._MAX_ANCHORS): for _ in range(self._MAX_ANCHORS):
gz = self.gizmos.new("BIM_GT_anchor_handle") gz = self.gizmos.new("BIM_GT_anchor_handle")
gz.scale_basis = 0.18 gz.scale_basis = 0.2
gz.select_bias = -32.0
gz.use_draw_modal = True gz.use_draw_modal = True
gz.hide = True gz.hide = True
self._handles.append(gz) self._handles.append(gz)
@@ -2756,11 +2746,11 @@ class DimensionAnchorWidget(types.GizmoGroup):
for i in range(n): for i in range(n):
gz = self._handles[i] 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.matrix_basis = Matrix.Translation(world_co)
gz.anchor_index = i gz.anchor_index = i
if i == _active_anchor_idx and obj is _editing_annotation_obj: 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 = (0.2, 0.7, 1.0)
gz.color_highlight = (0.4, 0.85, 1.0) gz.color_highlight = (0.4, 0.85, 1.0)
elif anchors[i].get("guid"): elif anchors[i].get("guid"):
@@ -2777,7 +2767,6 @@ class DimensionAnchorWidget(types.GizmoGroup):
self._handles[i].hide = True self._handles[i].hide = True
def draw_prepare(self, context: bpy.types.Context) -> None: def draw_prepare(self, context: bpy.types.Context) -> None:
print(f"[draw_prepare] DimensionAnchorWidget _active_anchor_idx={_active_anchor_idx}")
self.refresh(context) self.refresh(context)
+87 -26
View File
@@ -70,6 +70,84 @@ def _rebuild_dim_guid_index(file) -> None:
_dim_index_dirty = False _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 @persistent
def load_post(*args): def load_post(*args):
invalidate_dim_index() invalidate_dim_index()
@@ -140,10 +218,7 @@ def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool:
n_pts = len(spline_world) n_pts = len(spline_world)
n_anchors = len(anchors) n_anchors = len(anchors)
print(f"[sync_anchors] obj={obj.name} spline_pts={n_pts} anchors={n_anchors}")
if n_pts == n_anchors: if n_pts == n_anchors:
print("[sync_anchors] counts match — no change needed")
return False return False
# Match each spline point to the nearest unused anchor by proximity. # 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], "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"]) pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)}) 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: if not file:
return return
# Collect GUIDs of IFC objects whose transform or geometry changed. if _dim_index_dirty:
# is_updated_geometry fires on Edit Mode exit after Bonsai has already _rebuild_dim_guid_index(file)
# serialised the new mesh back to IFC via update_representation, so the
# tessellation will reflect the edited shape. import ifcopenshell.util.element
moved_guids: set = set() moved_guids: set = set()
edited_annotation_ids: 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"): if element is None or not hasattr(element, "GlobalId"):
continue 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"): if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"):
import ifcopenshell.util.element as _ue import ifcopenshell.util.element as _ue
ptype = _ue.get_predefined_type(element) 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"): if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
_sync_dimension_anchors_to_curve(file, element, obj) changed = _sync_dimension_anchors_to_curve(file, element, obj)
edited_annotation_ids.add(element.id()) if changed:
edited_annotation_ids.add(element.id())
continue continue
moved_guids.add(element.GlobalId) moved_guids.add(element.GlobalId)
# Geometry edits invalidate the cached tessellation for this element.
if update.is_updated_geometry: if update.is_updated_geometry:
_dim_shape_cache.pop(element.id(), None) _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) annotation_ids: set = set(edited_annotation_ids)
for guid in moved_guids: for guid in moved_guids:
for ann_id in _dim_guid_index.get(guid, []): 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.api.drawing as drawing_api
import ifcopenshell.geom import ifcopenshell.geom
import ifcopenshell.util.element
from bonsai.bim.module.drawing.operator import _update_blender_curve from bonsai.bim.module.drawing.operator import _update_blender_curve
geom_settings = ifcopenshell.geom.settings() geom_settings = ifcopenshell.geom.settings()
@@ -282,7 +350,6 @@ def depsgraph_update_post_handler(scene, depsgraph):
except Exception: except Exception:
pass 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( resolved_pts = drawing_api.regenerate_dimension(
file, file,
annotation, annotation,
@@ -290,13 +357,7 @@ def depsgraph_update_post_handler(scene, depsgraph):
shape_cache=_dim_shape_cache, shape_cache=_dim_shape_cache,
placement_override=placement_override, 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: 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) _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: finally:
_dim_handler_running = False _dim_handler_running = False
+449 -146
View File
@@ -5701,19 +5701,42 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope
def _update_perp_constraint(self) -> None: def _update_perp_constraint(self) -> None:
"""Extract the face normal from anchor[0] and store it as the constraint axis.""" """Extract the face normal from anchor[0] and store it as the constraint axis."""
import math import math
from mathutils import Vector
a = self._anchors[0] if self._anchors else None a = self._anchors[0] if self._anchors else None
if not a or a.get("type") != "FACE": 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 return
fp = (a.get("addr") or {}).get("fingerprint") or {} addr = a.get("addr") or {}
n = fp.get("normal") # world-space at build time normal_local = addr.get("normal_local")
pt = a.get("pt") 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 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) mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
if mag < 1e-12: if mag < 1e-12:
print(f"[perp] _update_perp_constraint: zero-length normal after rotation")
return return
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag) self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
self._anchor0_pt = tuple(pt) 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: def _apply_perp_constraint(self) -> None:
"""Project the current snap point onto the constraint line when active.""" """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 base = self._anchor0_pt
n = self._anchor0_normal n = self._anchor0_normal
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2] 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 # Finalize: create IfcAnnotation + BBIM_Dimension pset
@@ -5917,7 +5942,85 @@ def _prefer_perp_face_index(
return best_idx 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. """Interactively anchor dimension vertices to IFC element faces.
Two-phase modal workflow (all in Object Mode): 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-cycle state (active during PICK_FACE phase)
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...] _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_last_px: tuple # last cursor pixel position where candidates were computed
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight _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 # Snap-mode cycle state (FACE → EDGE → VERTEX, TAB)
_HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px _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 @classmethod
def poll(cls, context): def poll(cls, context):
@@ -5980,6 +6088,12 @@ class SetDimensionAnchor(bpy.types.Operator):
return True return True
def invoke(self, context, event): 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 obj = context.active_object
self._annotation = tool.Ifc.get_entity(obj) self._annotation = tool.Ifc.get_entity(obj)
self._annotation_obj = obj self._annotation_obj = obj
@@ -5996,6 +6110,11 @@ class SetDimensionAnchor(bpy.types.Operator):
self._hover_index = 0 self._hover_index = 0
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"
_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. # When invoked from a panel, context.region_data is None.
# Walk the screen areas to find the actual 3D viewport region. # 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) context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"} 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"): 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()
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 from bonsai.bim.module.drawing.gizmos import set_active_anchor
set_active_anchor(-1) 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) # Hover — recompute candidates as cursor moves (PICK_FACE phase only)
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE": if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
@@ -6036,12 +6170,32 @@ class SetDimensionAnchor(bpy.types.Operator):
if self._phase == "PICK_VERTEX": if self._phase == "PICK_VERTEX":
self._handle_vertex_pick(context, event) self._handle_vertex_pick(context, event)
else: 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) self._set_status(context)
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
return {"PASS_THROUGH"} 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 # 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) pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
import ifcopenshell.api.drawing as drawing_api import ifcopenshell.api.drawing as drawing_api
anchor = drawing_api.make_world_anchor(pt_m) 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.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
self._phase = "PICK_VERTEX" return True
from bonsai.bim.module.drawing.gizmos import set_active_anchor
set_active_anchor(-1)
return
# Normal click — use whichever candidate is currently highlighted. # Normal click — use whichever candidate is currently highlighted.
self._clear_hover_highlight(context) self._clear_hover_highlight(context)
@@ -6166,33 +6317,47 @@ class SetDimensionAnchor(bpy.types.Operator):
return return
file = tool.Ifc.get() 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)} 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 import ifcopenshell.api.drawing as drawing_api
try: try:
anchor = drawing_api.build_anchor_from_hit( if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
file, element, hit_m, normal_m, anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
shape_cache=self._shape_cache, elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
placement_override=placement_override, 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: except Exception as exc:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}") self.report({"ERROR"}, f"build_anchor failed: {exc}")
return 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( self.report(
{"INFO"}, {"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" return True
self._hover_candidates = []
self._hover_index = 0
from bonsai.bim.module.drawing.gizmos import set_active_anchor
set_active_anchor(-1)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Hover / cycle helpers # Hover / cycle helpers
@@ -6285,7 +6450,10 @@ class SetDimensionAnchor(bpy.types.Operator):
bb_ctr = sum((v for v in bb_world), Vector()) / 8 bb_ctr = sum((v for v in bb_world), Vector()) / 8
t = (bb_ctr - origin).dot(direction) t = (bb_ctr - origin).dot(direction)
query_w = origin + t * 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: if not found:
continue continue
loc_w = mx @ loc_l loc_w = mx @ loc_l
@@ -6311,19 +6479,25 @@ class SetDimensionAnchor(bpy.types.Operator):
self._apply_hover_highlight(context) self._apply_hover_highlight(context)
def _cycle_hover(self, 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: if not self._hover_candidates:
return 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) self._apply_hover_highlight(context)
def _apply_hover_highlight(self, 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: if not self._hover_candidates:
self._clear_hover_highlight(context) self._clear_hover_highlight(context)
_snap_draw_data.clear()
return 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. # Only update selection when the highlighted object changes.
if ifc_obj != self._hover_highlighted_obj: if ifc_obj != self._hover_highlighted_obj:
@@ -6339,14 +6513,22 @@ class SetDimensionAnchor(bpy.types.Operator):
except Exception: except Exception:
pass 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) 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)
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( context.workspace.status_text_set(
f"Vertex {self._active_vertex_idx}{ifc_obj.name}{cycle_hint}" f"Dim vertex {self._active_vertex_idx}{label} [{mode_label}{elem_hint}]"
" | Click: anchor | ALT+Click: free point | RMB/ESC: Finish" " | 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): def _clear_hover_highlight(self, context):
"""Deselect the highlighted object and restore the annotation as active.""" """Deselect the highlighted object and restore the annotation as active."""
@@ -6362,77 +6544,199 @@ class SetDimensionAnchor(bpy.types.Operator):
except Exception: except Exception:
pass 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: def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
file = tool.Ifc.get() """Return snap draw-data dict for the current snap mode and hit face.
annotation = self._annotation
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"): ``coord`` is a (x, y) tuple in region-local pixels. Returns an empty
try: dict when the face index is invalid or the region is unavailable.
anchors: list = json.loads(pset_data["Anchors"]) """
except Exception: from bpy_extras.view3d_utils import location_3d_to_region_2d
anchors = []
else:
anchors = _anchors_from_spline(self._annotation_obj, file)
while len(anchors) <= vertex_index: region = self._region
obj = self._annotation_obj rv3d = self._rv3d
idx = len(anchors) if not region or not rv3d or face_index is None:
if obj and obj.data and hasattr(obj.data, "splines") and obj.data.splines: return {}
pts = obj.data.splines[0].points try:
if idx < len(pts): face = hit_obj.data.polygons[face_index]
co = obj.matrix_world @ pts[idx].co.xyz except (IndexError, AttributeError):
import ifcopenshell.api.drawing as drawing_api return {}
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]))
anchors[vertex_index] = new_anchor mx = hit_obj.matrix_world
anchors_json = json.dumps(anchors) face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
if pset_data: if self._snap_mode == "FACE":
pset_entity = file.by_id(pset_data["id"]) return {"type": "FACE", "face_verts": face_verts_world}
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 # Try profile-based snap candidates first (IFC-native, index-stable).
_drawing_handler.invalidate_dim_index() 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. # Fallback: Blender tessellation snap for elements without an
# Build placement_override from current Blender matrix_world so that # IfcExtrudedAreaSolid profile. Returns snap_world for the visual
# elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly. # indicator; no pt_idx so the click handler creates a face anchor.
placement_override: dict = {} screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world]
for a in anchors: n = len(face_verts_world)
guid = a.get("guid")
if not guid: 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 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 import ifcopenshell.api.drawing as drawing_api
resolved_pts = drawing_api.regenerate_dimension( anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
file,
annotation, anchors[vertex_index] = new_anchor
shape_cache=getattr(self, "_shape_cache", None), anchors_json = json.dumps(anchors)
placement_override=placement_override,
) if pset_data:
if resolved_pts: pset_entity = file.by_id(pset_data["id"])
_update_blender_curve(annotation, resolved_pts) ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}") 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): class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
@@ -6601,7 +6905,6 @@ def _update_blender_curve(
spline.points.add(n - 1) spline.points.add(n - 1)
is_2d = _annotation_is_2d(annotation) is_2d = _annotation_is_2d(annotation)
for i, pt_m in enumerate(resolved_pts_m): for i, pt_m in enumerate(resolved_pts_m):
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2]))) blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
local_pt = inv_world @ blender_world local_pt = inv_world @ blender_world
@@ -6613,7 +6916,10 @@ def _update_blender_curve(
spline.points[i].co = (*local_pt, 1.0) spline.points[i].co = (*local_pt, 1.0)
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions. # 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: 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 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 class ClickNearestDimensionAnchor(bpy.types.Operator):
clicks are unaffected. """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_idname = "bim.click_nearest_dimension_anchor"
bl_label = "Click Nearest Dimension Anchor" bl_label = "Click Nearest Dimension Anchor"
RADIUS_PX = 120 RADIUS_PX = 60
def invoke(self, context, event): def invoke(self, context, event):
from bpy_extras.view3d_utils import location_3d_to_region_2d from bpy_extras.view3d_utils import location_3d_to_region_2d
print(f"[AnchorClick] invoke called")
if not tool.Ifc.get(): 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"} return {"PASS_THROUGH"}
# Always use the 3D viewport WINDOW region — context.region may be a header, # Always use the 3D viewport WINDOW region — context.region may be a header,
@@ -6777,35 +7061,54 @@ class ClickNearestDimensionAnchor(bpy.types.Operator):
break break
if not region or not rv3d: if not region or not rv3d:
print(f"[AnchorClick] PASS_THROUGH — no 3D region")
return {"PASS_THROUGH"} return {"PASS_THROUGH"}
# Convert absolute mouse position to WINDOW region-local coordinates. # Convert absolute mouse position to WINDOW region-local coordinates.
cx = event.mouse_x - region.x cx = event.mouse_x - region.x
cy = event.mouse_y - region.y cy = event.mouse_y - region.y
import ifcopenshell.util.element as _ue
r2 = self.RADIUS_PX ** 2 r2 = self.RADIUS_PX ** 2
best_obj = None
best_idx = -1 best_idx = -1
best_dist_sq = float("inf") 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: # Scan ALL selected objects — context.active_object may have changed to an
print(f"[AnchorClick] MISS — no anchor within {self.RADIUS_PX}px") # 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"} 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 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. # Force viewport redraw so gizmo colors update before the modal starts.
for area in context.screen.areas: for area in context.screen.areas:
if area.type == "VIEW_3D": if area.type == "VIEW_3D":
@@ -834,6 +834,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator):
) )
slab.DumbSlabPlaner().regenerate_from_layer(layer) slab.DumbSlabPlaner().regenerate_from_layer(layer)
wall.DumbWallPlaner().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"): elif material.is_a("IfcMaterialProfileSet"):
profile_def = None profile_def = None
if mprops.profiles: if mprops.profiles:
+2 -12
View File
@@ -90,7 +90,6 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator):
def _regenerate_parametric_dimension(file, annotation): def _regenerate_parametric_dimension(file, annotation):
"""Regenerate a single parametric dimension annotation after a pset edit.""" """Regenerate a single parametric dimension annotation after a pset edit."""
print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}")
try: try:
import json import json
import numpy as np import numpy as np
@@ -100,13 +99,10 @@ def _regenerate_parametric_dimension(file, annotation):
from bonsai.bim.module.drawing.operator import _update_blender_curve from bonsai.bim.module.drawing.operator import _update_blender_curve
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") 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"): if not pset_data or not pset_data.get("Anchors"):
print("[regen_dim] no Anchors — skipping")
return return
anchors = json.loads(pset_data["Anchors"]) anchors = json.loads(pset_data["Anchors"])
print(f"[regen_dim] {len(anchors)} anchors")
placement_override = {} placement_override = {}
for a in anchors: for a in anchors:
guid = a.get("guid") guid = a.get("guid")
@@ -117,17 +113,14 @@ def _regenerate_parametric_dimension(file, annotation):
elem_obj = _tool.Ifc.get_object(elem) elem_obj = _tool.Ifc.get_object(elem)
if elem_obj: if elem_obj:
placement_override[elem.id()] = np.array(elem_obj.matrix_world) 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:
except Exception as e: pass
print(f"[regen_dim] placement_override error: {e}")
resolved_pts = drawing_api.regenerate_dimension( resolved_pts = drawing_api.regenerate_dimension(
file, annotation, placement_override=placement_override file, annotation, placement_override=placement_override
) )
print(f"[regen_dim] resolved_pts={resolved_pts}")
if resolved_pts: if resolved_pts:
_update_blender_curve(annotation, resolved_pts) _update_blender_curve(annotation, resolved_pts)
print("[regen_dim] _update_blender_curve done")
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -197,12 +190,9 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
) )
if tool.Cost.has_schedules(): if tool.Cost.has_schedules():
tool.Cost.update_cost_items(pset=pset) 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") 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) 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: if is_bbim_dimension:
_regenerate_parametric_dimension(self.file, element) _regenerate_parametric_dimension(self.file, element)
@@ -26,7 +26,7 @@ from .. import wrap_usecases
from .assign_product import assign_product from .assign_product import assign_product
from .edit_text_literal import edit_text_literal from .edit_text_literal import edit_text_literal
from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths 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 from .unassign_product import unassign_product
wrap_usecases(__path__, __name__) wrap_usecases(__path__, __name__)
@@ -34,8 +34,13 @@ wrap_usecases(__path__, __name__)
__all__ = [ __all__ = [
"assign_product", "assign_product",
"build_anchor_from_hit", "build_anchor_from_hit",
"build_anchor_from_layer_boundary",
"build_anchor_from_profile_edge",
"build_anchor_from_profile_vert",
"edit_text_literal", "edit_text_literal",
"get_dimension_segment_lengths", "get_dimension_segment_lengths",
"get_layer_snap_candidates",
"get_profile_snap_candidates",
"make_world_anchor", "make_world_anchor",
"regenerate_dimension", "regenerate_dimension",
"resolve_anchor", "resolve_anchor",
@@ -296,33 +296,44 @@ def _get_anchor_face_normal_world(
) -> Optional[tuple[float, float, float]]: ) -> Optional[tuple[float, float, float]]:
"""Return the world-space unit face normal stored in a FACE anchor, or None. """Return the world-space unit face normal stored in a FACE anchor, or None.
Prefers ``normal_local`` (element-local, rotation-invariant) transformed by Reads ``normal_local`` (element-local, rotation-invariant) from the anchor
the current element placement. Falls back to the stored world-space normal. 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": if anchor.get("type") != "FACE":
return None return None
guid = anchor.get("guid") guid = anchor.get("guid")
if not guid: if not guid:
return None 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") addr = anchor.get("addr") or {}
if normal_local: from .resolve_anchor import _rotate_local_to_world
try:
element = file.by_guid(guid) if addr.get("method") == "LAYER_BOUNDARY":
except Exception: 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 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") n = _rotate_local_to_world(element, normal_local, placement_override)
if normal_world: mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
mag = math.sqrt(sum(x * x for x in normal_world)) return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None
return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value]
return None
def _get_line_offset_direction( def _get_line_offset_direction(
File diff suppressed because it is too large Load Diff