Add bim.draw_parametric_dimension: snap-based polyline operator for dimensions

Extends parametric dimension support with a modal polyline operator that uses
Bonsai's existing snap infrastructure (same as walls/slabs) for placing anchor
points.  Shift+A in the Annotation tool now routes dimension types through this
operator instead of the generic add_annotation path.

- DrawParametricDimension: PolylineOperator subclass; each confirmed snap point
  is converted to a BBIM_DimensionTarget anchor via _snap_to_anchor, which reads
  face_index from the snap dict for face hits and falls back to closest_point_on_mesh
  for vertex/edge hits
- handle_inserting_polyline override tracks anchor list in sync with polyline
  points (insert on count increase, pop on BACKSPACE)
- hotkey_S_A dispatches to bim.draw_parametric_dimension for DIMENSION/RADIUS/
  DIAMETER/ANGLE/PLAN_LEVEL/SECTION_LEVEL types; all other types keep existing path
- depsgraph_update_post_handler extended to also watch is_updated_geometry so
  dimensions auto-regenerate when a referenced mesh is edited in Edit Mode; the
  affected element's tessellation is evicted from _dim_shape_cache so resolve_anchor
  re-tessellates from the updated IFC representation on the next pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-05-16 07:35:51 -05:00
parent d71b3de87c
commit a31de52f3c
4 changed files with 243 additions and 3 deletions
@@ -107,6 +107,7 @@ classes = (
operator.ToggleTargetView,
operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera,
operator.DrawParametricDimension,
operator.SetDimensionAnchor,
operator.RegenerateDimensions,
prop.Variable,
@@ -120,18 +120,24 @@ def depsgraph_update_post_handler(scene, depsgraph):
if not file:
return
# Collect GUIDs of IFC objects whose transform changed this update.
# 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.
moved_guids: set = set()
for update in depsgraph.updates:
obj = update.id
if not isinstance(obj, bpy.types.Object):
continue
if not update.is_updated_transform:
if not (update.is_updated_transform or update.is_updated_geometry):
continue
element = tool.Ifc.get_entity(obj)
if element is None or not hasattr(element, "GlobalId"):
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 not moved_guids:
return
@@ -68,6 +68,8 @@ import bonsai.core.drawing as core
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model.decorator import PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.drawing.data import DecoratorData, ElementValuesData
from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.drawing.prop import (
@@ -5347,6 +5349,229 @@ class ShowElementValuesInstructions(bpy.types.Operator):
# ---------------------------------------------------------------------------
class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
"""Draw a parametric dimension string anchored to IFC element geometry.
Click on IFC element faces to place dimension vertices one by one using the
same snap system as wall and slab drawing. Each confirmed point is stored
as a parametric anchor in ``BBIM_DimensionTarget`` so the dimension
recomputes automatically when the referenced elements move.
RMB or ENTER to finish; ESC to cancel without creating an annotation.
"""
bl_idname = "bim.draw_parametric_dimension"
bl_label = "Draw Parametric Dimension"
bl_options = {"REGISTER", "UNDO"}
if TYPE_CHECKING:
_anchors: list
_shape_cache: dict
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded.")
return False
if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera):
cls.poll_message_set("No active drawing.")
return False
return context.space_data.type == "VIEW_3D"
def __init__(self, *args, **kwargs):
bpy.types.Operator.__init__(self, *args, **kwargs)
PolylineOperator.__init__(self)
self._anchors = []
self._shape_cache = {}
# ------------------------------------------------------------------
# Snap → anchor bridge
def _snap_to_anchor(self, snap: dict) -> dict:
"""Convert a PolylineOperator snap candidate to a BBIM_DimensionTarget anchor dict."""
import ifcopenshell.api.drawing as drawing_api
obj = snap.get("object")
element = tool.Ifc.get_entity(obj) if obj else None
pt_world = snap["point"]
if element and hasattr(element, "GlobalId") and obj.data and hasattr(obj.data, "polygons"):
hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z))
face_index = snap.get("face_index")
if face_index is not None and face_index < len(obj.data.polygons):
normal_local = obj.data.polygons[face_index].normal
else:
# Vertex / edge snap: find the closest face for a proper normal.
local_pt = obj.matrix_world.inverted() @ pt_world
ok, _loc, normal_local, face_index = obj.closest_point_on_mesh(local_pt)
if not ok:
normal_local = Vector((0.0, 0.0, 1.0))
normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z))
placement_override = {element.id(): np.array(obj.matrix_world)}
return drawing_api.build_anchor_from_hit(
tool.Ifc.get(), element, hit_m, normal_m,
shape_cache=self._shape_cache,
placement_override=placement_override,
)
# Axis / plane snap, or non-IFC object: store a free world point.
import ifcopenshell.api.drawing as drawing_api
return drawing_api.make_world_anchor([float(pt_world.x), float(pt_world.y), float(pt_world.z)])
# ------------------------------------------------------------------
# Point insertion — capture anchor in sync with polyline point
def handle_inserting_polyline(self, context, event):
polyline_props = tool.Model.get_polyline_props()
polyline_data = polyline_props.insertion_polyline
count_before = len(polyline_data[0].polyline_points) if polyline_data else 0
# Capture snap state BEFORE super() so we have it even if event processing clears it.
snap = self.snapping_points[0] if self.snapping_points else None
is_mouse_click = (
not self.tool_state.is_input_on
and event.value == "RELEASE"
and event.type == "LEFTMOUSE"
)
super().handle_inserting_polyline(context, event)
if not polyline_data:
return
count_after = len(polyline_data[0].polyline_points)
if count_after > count_before:
# A point was inserted — build its anchor.
if is_mouse_click and snap and snap.get("type") not in {"Axis", "Plane"}:
self._anchors.append(self._snap_to_anchor(snap))
else:
# Keyboard-typed coordinate or close-loop: world anchor at the stored point.
import ifcopenshell.api.drawing as drawing_api
pt = polyline_data[0].polyline_points[-1]
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
elif count_after < count_before and self._anchors:
# BACKSPACE removed a point.
self._anchors.pop()
# ------------------------------------------------------------------
# Finalize: create IfcAnnotation + BBIM_DimensionTarget pset
def _create_dimension_from_polyline(self, context) -> None:
import ifcopenshell.api.drawing as drawing_api
polyline_props = tool.Model.get_polyline_props()
polyline_data = polyline_props.insertion_polyline
if not polyline_data or len(polyline_data[0].polyline_points) < 2:
self.report({"WARNING"}, "Need at least 2 points for a dimension.")
return
polyline_points = list(polyline_data[0].polyline_points)
dprops = tool.Drawing.get_document_props()
drawing = dprops.get_active_drawing()
if not drawing:
self.report({"WARNING"}, "No active drawing.")
return
props = tool.Drawing.get_annotation_props()
relating_type = (
tool.Ifc.get().by_id(int(props.relating_type_id))
if props.relating_type_id and props.relating_type_id != "0"
else None
)
obj = core.add_annotation(
tool.Ifc, tool.Collector, tool.Drawing,
drawing=drawing,
object_type="DIMENSION",
relating_type=relating_type,
enable_editing=False,
)
if not obj:
return
annotation = tool.Ifc.get_entity(obj)
if not annotation:
return
# World-space metres points straight from the polyline.
resolved_pts_m = [(pt.x, pt.y, pt.z) for pt in polyline_points]
# Update Blender curve spline AND the IFC IfcIndexedPolyCurve/IfcPolyline.
_update_blender_curve(annotation, resolved_pts_m)
# Pad anchors to match point count (e.g. if first point was keyboard-typed
# before any snap data was available).
anchors = list(self._anchors)
while len(anchors) < len(resolved_pts_m):
anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)])))
file = tool.Ifc.get()
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget")
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
pset_entity = file.by_id(pset_data["id"])
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": json.dumps(anchors)})
from bonsai.bim.module.drawing import handler as _drawing_handler
_drawing_handler.invalidate_dim_index()
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
context.view_layer.objects.active = obj
# ------------------------------------------------------------------
# Modal loop — same pattern as DrawPolylineWall
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
def _modal(self, context, event):
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
tool.Blender.update_viewport()
self.handle_lock_axis(context, event)
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.handle_mouse_move(context, event)
return {"PASS_THROUGH"}
self.handle_mouse_move(context, event)
self.choose_axis(event)
self.handle_snap_selection(context, event)
if (
not self.tool_state.is_input_on
and event.value == "RELEASE"
and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}
):
self._create_dimension_from_polyline(context)
self.tool_state.plane_method = None
PolylineDecorator.uninstall()
tool.Polyline.clear_polyline()
tool.Blender.update_viewport()
return {"FINISHED"}
self.handle_keyboard_input(context, event)
self.handle_inserting_polyline(context, event)
cancel = self.handle_cancelation(context, event)
if cancel is not None:
return cancel
return {"RUNNING_MODAL"}
def invoke(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
def _invoke(self, context, event):
super().invoke(context, event)
return {"RUNNING_MODAL"}
class SetDimensionAnchor(bpy.types.Operator):
"""Interactively anchor dimension vertices to IFC element faces.
@@ -330,8 +330,16 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
if created_objects:
bpy.context.view_layer.objects.active = created_objects[-1]
_PARAMETRIC_DIMENSION_TYPES = frozenset(
("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")
)
def hotkey_S_A(self):
if bpy.ops.bim.add_annotation.poll():
props = tool.Drawing.get_annotation_props()
if props.object_type in self._PARAMETRIC_DIMENSION_TYPES:
if bpy.ops.bim.draw_parametric_dimension.poll():
bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT")
elif bpy.ops.bim.add_annotation.poll():
bpy.ops.bim.add_annotation()
def hotkey_S_E(self):