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
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
+21 -32
View File
@@ -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)
+87 -26
View File
@@ -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
+449 -146
View File
@@ -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:
+2 -12
View File
@@ -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