mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user