diff --git a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc
index 062579edb2..393ffddf3b 100644
--- a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc
+++ b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc
@@ -37,9 +37,12 @@ DATA;
#30=IFCSIMPLEPROPERTYTEMPLATE('2TJn72t_v2cvBUG916Dpev',$,'CustomUnit','Dimension''s custom unit',.P_ENUMERATEDVALUE.,'IfcText',$,#31,$,$,$,.READWRITE.);
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
+#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
-#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
+#37=IFCPROPERTYSETTEMPLATE('3Qk8mPzT1rFoV9wXDyBnLe',$,'BBIM_DimensionTarget','Parametric anchor references that connect a dimension annotation to IFC geometry. Anchors is a JSON array (one entry per polyline vertex) encoding element GUID, geometry address, fingerprint, and fallback world point.',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL',(#38,#39));
+#38=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
+#39=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py
index 1bca9eab49..d1ac5986b6 100644
--- a/src/bonsai/bonsai/bim/module/drawing/__init__.py
+++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py
@@ -111,6 +111,8 @@ classes = (
operator.ToggleDrawingCategorySelection,
operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera,
+ operator.SetDimensionAnchor,
+ operator.RegenerateDimensions,
prop.Variable,
prop.Drawing,
prop.Document,
@@ -196,6 +198,7 @@ def register():
bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties)
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
+ bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
@@ -211,5 +214,6 @@ def unregister():
del bpy.types.TextCurve.BIMTextProperties
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler)
+ bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler)
bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button)
bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func)
diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py
index aac48b9479..9374b57fbc 100644
--- a/src/bonsai/bonsai/bim/module/drawing/handler.py
+++ b/src/bonsai/bonsai/bim/module/drawing/handler.py
@@ -16,15 +16,63 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+import json
+
import bpy
+import numpy as np
from bpy.app.handlers import persistent
import bonsai.bim.module.drawing.decoration as decoration
import bonsai.tool as tool
+# ---------------------------------------------------------------------------
+# Parametric dimension auto-regeneration state
+# ---------------------------------------------------------------------------
+
+# Maps element GUID → list of annotation STEP IDs that reference it.
+_dim_guid_index: dict = {}
+# Persistent tessellation cache for the depsgraph handler (element id → shape).
+_dim_shape_cache: dict = {}
+# Set True whenever BBIM_DimensionTarget anchors change or a new file loads.
+_dim_index_dirty: bool = True
+# Re-entry guard so curve updates don't trigger a second handler call.
+_dim_handler_running: bool = False
+
+
+def invalidate_dim_index() -> None:
+ """Mark the GUID index as stale so it is rebuilt on the next handler call."""
+ global _dim_index_dirty, _dim_shape_cache
+ _dim_index_dirty = True
+ _dim_shape_cache.clear()
+
+
+def _rebuild_dim_guid_index(file) -> None:
+ global _dim_guid_index, _dim_index_dirty
+ import ifcopenshell.util.element
+
+ _dim_guid_index = {}
+ for annotation in file.by_type("IfcAnnotation"):
+ pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
+ if not pset_data or not pset_data.get("Anchors"):
+ continue
+ try:
+ anchors = json.loads(pset_data["Anchors"])
+ except Exception:
+ continue
+ ann_id = annotation.id()
+ for anchor in anchors:
+ guid = anchor.get("guid")
+ if not guid:
+ continue
+ ids = _dim_guid_index.setdefault(guid, [])
+ if ann_id not in ids:
+ ids.append(ann_id)
+ _dim_index_dirty = False
+
@persistent
def load_post(*args):
+ invalidate_dim_index()
props = tool.Drawing.get_document_props()
if props.should_draw_decorations:
decoration.DecorationsHandler.install(bpy.context)
@@ -61,3 +109,95 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
raster_x, raster_y = props.update_camera_resolution()
scene_render.resolution_x = raster_x
scene_render.resolution_y = raster_y
+
+
+@persistent
+def depsgraph_update_post_handler(scene, depsgraph):
+ """Auto-regenerate parametric dimensions when referenced elements are moved."""
+ global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache
+
+ if _dim_handler_running:
+ return
+
+ file = tool.Ifc.get()
+ if not file:
+ return
+
+ # Collect GUIDs of IFC objects whose transform changed this update.
+ 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:
+ continue
+ element = tool.Ifc.get_entity(obj)
+ if element is None or not hasattr(element, "GlobalId"):
+ continue
+ moved_guids.add(element.GlobalId)
+
+ if not moved_guids:
+ return
+
+ if _dim_index_dirty:
+ _rebuild_dim_guid_index(file)
+
+ annotation_ids: set = set()
+ for guid in moved_guids:
+ for ann_id in _dim_guid_index.get(guid, []):
+ annotation_ids.add(ann_id)
+
+ if not annotation_ids:
+ return
+
+ 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()
+ geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
+
+ _dim_handler_running = True
+ try:
+ for ann_id in annotation_ids:
+ try:
+ annotation = file.by_id(ann_id)
+ except Exception:
+ continue
+
+ pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
+ 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_id = elem.id()
+ if elem_id in placement_override:
+ continue
+ 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)
+ finally:
+ _dim_handler_running = False
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index 27cfb90ddc..f3e5c5eec4 100644
--- a/src/bonsai/bonsai/bim/module/drawing/operator.py
+++ b/src/bonsai/bonsai/bim/module/drawing/operator.py
@@ -5560,3 +5560,521 @@ class ShowElementValuesInstructions(bpy.types.Operator):
def execute(self, context):
return {"FINISHED"}
+
+
+# ---------------------------------------------------------------------------
+# Parametric dimension operators
+# ---------------------------------------------------------------------------
+
+
+class SetDimensionAnchor(bpy.types.Operator):
+ """Interactively anchor dimension vertices to IFC element faces.
+
+ Two-phase modal workflow (all in Object Mode, no Tab required):
+ 1. Run the operator with a dimension annotation selected.
+ 2. Click a vertex ON the dimension line to select it.
+ 3. Click an IFC element face to anchor that vertex to it.
+ ALT+click sets a free world-point anchor instead.
+ 4. Repeat steps 2-3 for more vertices.
+ 5. RMB or ESC to finish.
+ """
+
+ bl_idname = "bim.set_dimension_anchor"
+ bl_label = "Set Dimension Anchor"
+ bl_options = {"REGISTER", "UNDO"}
+
+ if TYPE_CHECKING:
+ pass
+
+ _annotation: Optional[ifcopenshell.entity_instance] = None
+ _annotation_obj: Optional[bpy.types.Object] = None
+ _phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
+ _active_vertex_idx: int = -1
+ _shape_cache: dict
+
+ _VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex
+
+ @classmethod
+ def poll(cls, context):
+ if not tool.Ifc.get():
+ cls.poll_message_set("No IFC file loaded.")
+ return False
+ obj = context.active_object
+ if not obj:
+ cls.poll_message_set("No active object.")
+ return False
+ if context.mode != "OBJECT":
+ cls.poll_message_set("Must be in Object Mode.")
+ return False
+ element = tool.Ifc.get_entity(obj)
+ if not element or not element.is_a("IfcAnnotation"):
+ cls.poll_message_set("Active object must be an IfcAnnotation.")
+ return False
+ ptype = ifcopenshell.util.element.get_predefined_type(element)
+ if ptype not in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
+ cls.poll_message_set("Annotation must be a dimension type.")
+ return False
+ return True
+
+ def invoke(self, context, event):
+ obj = context.active_object
+ self._annotation = tool.Ifc.get_entity(obj)
+ self._annotation_obj = obj
+ self._phase = "PICK_VERTEX"
+ self._active_vertex_idx = -1
+ self._shape_cache = {}
+ self._set_status(context)
+ context.window_manager.modal_handler_add(self)
+ return {"RUNNING_MODAL"}
+
+ def modal(self, context, event):
+ if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
+ context.workspace.status_text_set(None)
+ return {"FINISHED"} # keep any anchors already written
+
+ if event.type == "LEFTMOUSE" and event.value == "PRESS":
+ if self._phase == "PICK_VERTEX":
+ self._handle_vertex_pick(context, event)
+ else:
+ self._handle_face_pick(context, event)
+ self._set_status(context)
+ return {"RUNNING_MODAL"}
+
+ return {"PASS_THROUGH"}
+
+ # ------------------------------------------------------------------
+ # Status bar
+
+ def _set_status(self, context):
+ if self._phase == "PICK_VERTEX":
+ context.workspace.status_text_set(
+ "Click a dimension vertex | RMB / ESC: Finish"
+ )
+ else:
+ context.workspace.status_text_set(
+ f"Vertex {self._active_vertex_idx} selected — "
+ "Click element face to anchor | ALT+Click: free world point | RMB / ESC: Finish"
+ )
+
+ # ------------------------------------------------------------------
+ # Phase 1: pick a vertex on the dimension curve
+
+ def _handle_vertex_pick(self, context, event):
+ from bpy_extras import view3d_utils
+
+ region = context.region
+ rv3d = context.region_data
+ if not region or not rv3d:
+ return
+
+ coord = (event.mouse_region_x, event.mouse_region_y)
+ obj = self._annotation_obj
+
+ best_idx = None
+ best_dist_sq = self._VERTEX_PICK_RADIUS_PX ** 2
+
+ if obj.data and hasattr(obj.data, "splines"):
+ for spline in obj.data.splines:
+ for i, pt in enumerate(spline.points):
+ world_co = obj.matrix_world @ pt.co.xyz
+ screen_co = view3d_utils.location_3d_to_region_2d(region, rv3d, world_co)
+ if screen_co is None:
+ continue
+ dist_sq = (screen_co.x - coord[0]) ** 2 + (screen_co.y - coord[1]) ** 2
+ if dist_sq < best_dist_sq:
+ best_dist_sq = dist_sq
+ best_idx = i
+
+ if best_idx is None:
+ self.report({"WARNING"}, f"Click closer to a dimension vertex (within {self._VERTEX_PICK_RADIUS_PX}px)")
+ return
+
+ self._active_vertex_idx = best_idx
+ self._phase = "PICK_FACE"
+
+ # ------------------------------------------------------------------
+ # Phase 2: pick a face on an IFC element
+
+ def _handle_face_pick(self, context, event):
+ from bpy_extras import view3d_utils
+
+ region = context.region
+ rv3d = context.region_data
+ if not region or not rv3d:
+ return
+
+ coord = (event.mouse_region_x, event.mouse_region_y)
+
+ # ALT+click → free world-point anchor at the cursor 3D location
+ if event.alt:
+ origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
+ direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
+ hit, location, *_ = context.scene.ray_cast(context.view_layer.depsgraph, origin, direction)
+ pt_m = tuple(location) if hit else tuple(origin + direction * 5.0)
+
+ import ifcopenshell.api.drawing as drawing_api
+ anchor = drawing_api.make_world_anchor(list(pt_m))
+ self._write_anchor(anchor, self._active_vertex_idx)
+ self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
+ self._phase = "PICK_VERTEX"
+ return
+
+ # Normal click → raycast for IFC element face
+ origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
+ direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
+
+ hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast(
+ context.view_layer.depsgraph, origin, direction
+ )
+
+ if not hit or hit_obj is None:
+ self.report({"WARNING"}, "Nothing under cursor — click on a model element")
+ return
+
+ if hit_obj == self._annotation_obj:
+ self.report({"WARNING"}, "Click on an element, not the dimension line itself")
+ return
+
+ element = tool.Ifc.get_entity(hit_obj)
+ if not element:
+ self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element")
+ 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))
+
+ # Pass the Blender matrix_world so face-group matching uses current position.
+ placement_override = {element.id(): np.array(hit_obj.matrix_world)}
+
+ import ifcopenshell.api.drawing as drawing_api
+ anchor = drawing_api.build_anchor_from_hit(
+ file, element, hit_m, normal_m,
+ shape_cache=self._shape_cache,
+ placement_override=placement_override,
+ )
+
+ self._write_anchor(anchor, self._active_vertex_idx)
+ self.report(
+ {"INFO"},
+ f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}",
+ )
+ self._phase = "PICK_VERTEX"
+
+ # ------------------------------------------------------------------
+ # Pset write (shared by both face and free-point paths)
+
+ def _write_anchor(self, new_anchor: dict, vertex_index: int) -> None:
+ file = tool.Ifc.get()
+ annotation = self._annotation
+
+ pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
+
+ 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)
+
+ 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]))
+
+ 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_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": anchors_json})
+
+ from bonsai.bim.module.drawing import handler as _drawing_handler
+ _drawing_handler.invalidate_dim_index()
+
+
+class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
+ """Regenerate all parametric dimension annotations in the project.
+
+ For every IfcAnnotation that has a BBIM_DimensionTarget pset, resolve all
+ anchor references from live element geometry and update the annotation's
+ curve vertices and linked IfcMetric values.
+ """
+
+ bl_idname = "bim.regenerate_dimensions"
+ bl_label = "Regenerate Dimensions"
+ bl_description = (
+ "Recompute all parametric dimension annotations from current element geometry.\n"
+ "Updates curve vertex positions and IfcMetric segment values."
+ )
+ bl_options = {"REGISTER", "UNDO"}
+
+ active_only: bpy.props.BoolProperty(
+ name="Active Only",
+ description="Only regenerate the currently selected dimension annotation",
+ default=False,
+ )
+
+ if TYPE_CHECKING:
+ active_only: bool
+
+ @classmethod
+ def poll(cls, context):
+ return bool(tool.Ifc.get())
+
+ def _execute(self, context):
+ import ifcopenshell.api.drawing as drawing_api
+ import ifcopenshell.geom
+
+ file = tool.Ifc.get()
+
+ geom_settings = ifcopenshell.geom.settings()
+ geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
+ shape_cache: dict = {}
+
+ if self.active_only:
+ obj = context.active_object
+ if not obj:
+ self.report({"WARNING"}, "No active object.")
+ return
+ element = tool.Ifc.get_entity(obj)
+ if not element or not element.is_a("IfcAnnotation"):
+ self.report({"WARNING"}, "Active object is not an IfcAnnotation.")
+ return
+ candidates = [element]
+ else:
+ candidates = [
+ a for a in file.by_type("IfcAnnotation")
+ if ifcopenshell.util.element.get_pset(a, "BBIM_DimensionTarget")
+ ]
+
+ updated = 0
+ for annotation in candidates:
+ pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget")
+ if not pset:
+ continue
+
+ # Build a placement override from each referenced element's current
+ # Blender matrix_world. Bonsai only syncs ObjectPlacement to the IFC
+ # file when the user explicitly clicks "Edit Object Placement" — so the
+ # IFC entity may be stale after a viewport G-move. Using matrix_world
+ # ensures we always see the current element position.
+ placement_override: dict[int, "np.ndarray"] = {}
+ 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_id = elem.id()
+ if elem_id in placement_override:
+ continue
+ 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=shape_cache,
+ placement_override=placement_override,
+ )
+ if not resolved_pts:
+ continue
+
+ _update_blender_curve(annotation, resolved_pts)
+ updated += 1
+
+ self.report({"INFO"}, f"Regenerated {updated} parametric dimension(s).")
+
+
+# ---------------------------------------------------------------------------
+# Helpers for dimension operators
+# ---------------------------------------------------------------------------
+
+
+def _anchors_from_spline(obj: bpy.types.Object, file: ifcopenshell.file) -> list:
+ """Build a list of WORLD anchors from the current spline points of obj.
+
+ Coordinates are stored in metres (Blender world space), which matches the
+ output of ifcopenshell.geom.create_shape regardless of IFC project unit.
+ """
+ import ifcopenshell.api.drawing as drawing_api
+
+ anchors = []
+ if not obj or not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
+ return anchors
+
+ for pt in obj.data.splines[0].points:
+ world_co = obj.matrix_world @ pt.co.xyz
+ # Store in metres (Blender world space)
+ pt_m = [float(world_co.x), float(world_co.y), float(world_co.z)]
+ anchors.append(drawing_api.make_world_anchor(pt_m))
+
+ return anchors
+
+
+def _update_blender_curve(
+ annotation: ifcopenshell.entity_instance,
+ resolved_pts_m: list,
+) -> None:
+ """Update a Blender curve object's spline points AND the backing IFC IfcPolyline.
+
+ :param resolved_pts_m: Points in metres (Blender world space).
+
+ Both the Blender curve data and the IFC representation are updated so that
+ entering Edit Mode (which reloads geometry from IFC via import_representation_items)
+ does not reset the curve back to pre-regeneration positions.
+ """
+ obj = tool.Ifc.get_object(annotation)
+ if not obj or not obj.data or not hasattr(obj.data, "splines"):
+ return
+
+ curve_data: bpy.types.Curve = obj.data
+ inv_world = obj.matrix_world.inverted()
+ n = len(resolved_pts_m)
+
+ if not curve_data.splines:
+ spline = curve_data.splines.new("POLY")
+ spline.points.add(n - 1)
+ else:
+ spline = curve_data.splines[0]
+ if len(spline.points) != n:
+ curve_data.splines.remove(spline)
+ spline = curve_data.splines.new("POLY")
+ 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
+ # For 2D (plan-view) annotations, project onto the annotation plane by
+ # zeroing local Z — matching the Annotator.add_line_to_annotation pattern.
+ if is_2d:
+ spline.points[i].co = (local_pt.x, local_pt.y, 0.0, 1.0)
+ else:
+ 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)
+
+
+def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
+ """Return True if the annotation's representation uses 2D coordinates (plan view)."""
+ if not getattr(annotation, "Representation", None):
+ return False
+ for rep in annotation.Representation.Representations:
+ curve = _find_curve_item(rep)
+ if curve is None:
+ continue
+ if curve.is_a("IfcIndexedPolyCurve"):
+ return curve.Points.is_a("IfcCartesianPointList2D")
+ if curve.is_a("IfcPolyline") and curve.Points:
+ return len(curve.Points[0].Coordinates) == 2
+ return False
+
+
+def _update_ifc_polyline(
+ file: ifcopenshell.file,
+ annotation: ifcopenshell.entity_instance,
+ obj: bpy.types.Object,
+ resolved_pts_m: list,
+) -> None:
+ """Update the curve coordinates in the annotation's IFC representation.
+
+ Converts world-space metres points → annotation-local IFC project units and
+ writes them into the existing IfcIndexedPolyCurve or IfcPolyline entities.
+ Handles both 2D (IfcCartesianPointList2D) and 3D representations.
+ """
+ if not resolved_pts_m or not getattr(annotation, "Representation", None):
+ return
+
+ import ifcopenshell.util.unit as ifc_unit
+
+ unit_scale = ifc_unit.calculate_unit_scale(file)
+ inv_world = obj.matrix_world.inverted()
+
+ def _to_ifc_local(pt_m: tuple) -> tuple:
+ blender_local = inv_world @ Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
+ return (
+ float(blender_local.x) / unit_scale,
+ float(blender_local.y) / unit_scale,
+ float(blender_local.z) / unit_scale,
+ )
+
+ new_coords = [_to_ifc_local(pt) for pt in resolved_pts_m]
+
+ for rep in annotation.Representation.Representations:
+ curve = _find_curve_item(rep)
+ if curve is None:
+ continue
+
+ if curve.is_a("IfcIndexedPolyCurve"):
+ pts_list = curve.Points # IfcCartesianPointList2D or 3D
+ n_dims = 2 if pts_list.is_a("IfcCartesianPointList2D") else 3
+ pts_list.CoordList = tuple(coords[:n_dims] for coords in new_coords)
+ # Rebuild Segments to cover all consecutive pairs. Bonsai creates
+ # explicit IfcLineIndex entries per segment; leaving a stale Segments
+ # list (e.g. [IfcLineIndex([1,2])]) after adding a 3rd point means
+ # the extra point is silently ignored on geometry reload.
+ n_pts = len(new_coords)
+ if n_pts >= 2:
+ curve.Segments = [file.createIfcLineIndex([i + 1, i + 2]) for i in range(n_pts - 1)]
+ else:
+ curve.Segments = None
+ return
+
+ if curve.is_a("IfcPolyline"):
+ existing = list(curve.Points)
+ if len(existing) == len(new_coords):
+ for ifc_pt, coords in zip(existing, new_coords):
+ n_dims = len(ifc_pt.Coordinates)
+ ifc_pt.Coordinates = coords[:n_dims]
+ else:
+ dim = len(existing[0].Coordinates) if existing else 3
+ curve.Points = [
+ file.create_entity("IfcCartesianPoint", Coordinates=coords[:dim])
+ for coords in new_coords
+ ]
+ return
+
+
+def _find_curve_item(rep: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
+ """Return the first IfcPolyline or IfcIndexedPolyCurve in a shape representation."""
+ for item in rep.Items:
+ result = _find_curve_in_item(item)
+ if result is not None:
+ return result
+ return None
+
+
+def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
+ if item.is_a("IfcPolyline") or item.is_a("IfcIndexedPolyCurve"):
+ return item
+ if item.is_a("IfcGeometricCurveSet"):
+ for element in item.Elements:
+ result = _find_curve_in_item(element)
+ if result is not None:
+ return result
+ return None
diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py
index 5efec1737e..09a62de4b8 100644
--- a/src/bonsai/bonsai/bim/module/drawing/ui.py
+++ b/src/bonsai/bonsai/bim/module/drawing/ui.py
@@ -571,6 +571,19 @@ class BIM_PT_product_assignments(Panel):
col.operator("bim.select_assigned_product", icon="RESTRICT_SELECT_OFF", text="")
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
+ # Parametric dimension controls
+ element = tool.Ifc.get_entity(obj)
+ if element:
+ import ifcopenshell.util.element
+ ptype = ifcopenshell.util.element.get_predefined_type(element)
+ if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
+ self.layout.separator()
+ self.layout.label(text="Parametric Dimension", icon="CONSTRAINT")
+ row = self.layout.row(align=True)
+ row.operator("bim.set_dimension_anchor", icon="PIVOT_CURSOR")
+ op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate")
+ op.active_only = True
+
def get_category_icon(category_name):
"""Get appropriate icon for each category"""
diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py
index 012dce92f6..4b8bbb0177 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py
@@ -25,12 +25,19 @@ annotations may have relationships which indicate smart data being populated.
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 .unassign_product import unassign_product
wrap_usecases(__path__, __name__)
__all__ = [
"assign_product",
+ "build_anchor_from_hit",
"edit_text_literal",
+ "get_dimension_segment_lengths",
+ "make_world_anchor",
+ "regenerate_dimension",
+ "resolve_anchor",
"unassign_product",
]
diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py
new file mode 100644
index 0000000000..ff74725f52
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py
@@ -0,0 +1,238 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2021 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcOpenShell is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcOpenShell. If not, see .
+
+"""Regenerate a parametric dimension annotation from its BBIM_DimensionTarget anchors.
+
+This module operates purely on IFC data. It:
+ 1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an
+ ``IfcAnnotation``.
+ 2. Resolves each anchor to a world-space point (IFC project units) using
+ ``resolve_anchor``.
+ 3. Computes per-segment distances and updates (or creates) the linked
+ ``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities.
+ 4. Returns the ordered list of resolved world-space points so that the
+ Bonsai operator layer can update the Blender curve object.
+
+Updating the Blender curve (converting IFC world coords → annotation local
+coords) is the *caller's* responsibility and does **not** happen here.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from typing import Optional
+
+import ifcopenshell
+import ifcopenshell.api.owner
+import ifcopenshell.api.pset
+import ifcopenshell.geom
+import ifcopenshell.guid
+import ifcopenshell.util.element
+
+from .resolve_anchor import resolve_anchor
+
+
+_PSET_NAME = "BBIM_DimensionTarget"
+_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_"
+
+
+def regenerate_dimension(
+ file: ifcopenshell.file,
+ annotation: ifcopenshell.entity_instance,
+ settings: Optional[ifcopenshell.geom.settings] = None,
+ shape_cache: Optional[dict] = None,
+ placement_override: Optional[dict] = None,
+) -> list[tuple[float, float, float]]:
+ """Regenerate a parametric dimension from its stored anchor references.
+
+ Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the
+ per-segment ``IfcMetric`` values (creating them when absent), and returns
+ the resolved world-space points in metres.
+
+ :param file: The open IFC file.
+ :param annotation: An ``IfcAnnotation`` with a ``BBIM_DimensionTarget`` pset.
+ :param settings: Geometry settings for tessellation (shared across calls).
+ :param shape_cache: Shape cache dict (shared across calls for performance).
+ :param placement_override: Optional dict mapping element STEP id → 4×4 numpy
+ matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}``
+ for each referenced element so that viewport moves not yet synced to the
+ IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details.
+ :return: Ordered list of ``(x, y, z)`` tuples, one per anchor.
+ Empty list if the pset is missing or malformed.
+ """
+ pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
+ if not pset_data or "Anchors" not in pset_data:
+ return []
+
+ try:
+ anchors: list[dict] = json.loads(pset_data["Anchors"])
+ except (json.JSONDecodeError, TypeError):
+ return []
+
+ if not anchors:
+ return []
+
+ if shape_cache is None:
+ shape_cache = {}
+
+ resolved: list[Optional[tuple]] = []
+ for anchor in anchors:
+ pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override)
+ if pt is None:
+ pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0)
+ resolved.append(pt)
+ anchor["pt"] = list(pt)
+
+ pset_entity_id = pset_data.get("id")
+ if pset_entity_id:
+ pset_entity = file.by_id(pset_entity_id)
+ ifcopenshell.api.pset.edit_pset(
+ file,
+ pset=pset_entity,
+ properties={"Anchors": json.dumps(anchors)},
+ )
+
+ n_segments = len(resolved) - 1
+ if n_segments >= 1:
+ existing_metrics = _get_segment_metrics(file, annotation)
+ _sync_segment_metrics(file, annotation, resolved, existing_metrics)
+
+ return [pt for pt in resolved if pt is not None]
+
+
+def get_dimension_segment_lengths(
+ file: ifcopenshell.file,
+ annotation: ifcopenshell.entity_instance,
+) -> list[float]:
+ """Return the segment lengths for a parametric dimension from stored anchor pts.
+
+ Distances are computed from the cached ``pt`` fields in ``BBIM_DimensionTarget.Anchors``
+ (in metres, matching ifcopenshell.geom output). Returns an empty list if the pset
+ is absent or malformed.
+ """
+ pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
+ if not pset_data or not pset_data.get("Anchors"):
+ return []
+ try:
+ anchors: list[dict] = json.loads(pset_data["Anchors"])
+ except Exception:
+ return []
+ lengths: list[float] = []
+ for i in range(len(anchors) - 1):
+ pt_a = anchors[i].get("pt")
+ pt_b = anchors[i + 1].get("pt")
+ if pt_a and pt_b:
+ lengths.append(_dist(tuple(pt_a), tuple(pt_b)))
+ else:
+ lengths.append(0.0)
+ return lengths
+
+
+# ---------------------------------------------------------------------------
+# IfcMetric / IfcRelAssociatesConstraint management
+# ---------------------------------------------------------------------------
+
+
+def _get_segment_metrics(
+ file: ifcopenshell.file,
+ annotation: ifcopenshell.entity_instance,
+) -> dict[int, ifcopenshell.entity_instance]:
+ """Return {segment_index: IfcMetric} for all constraint rels on the annotation."""
+ metrics: dict[int, ifcopenshell.entity_instance] = {}
+ for rel in annotation.HasAssociations:
+ if not rel.is_a("IfcRelAssociatesConstraint"):
+ continue
+ intent: str = rel.Intent or ""
+ if not intent.startswith(_METRIC_INTENT_PREFIX):
+ continue
+ try:
+ seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):])
+ except ValueError:
+ continue
+ constraint = rel.RelatingConstraint
+ if constraint.is_a("IfcMetric"):
+ metrics[seg_idx] = constraint
+ return metrics
+
+
+def _sync_segment_metrics(
+ file: ifcopenshell.file,
+ annotation: ifcopenshell.entity_instance,
+ resolved_pts: list[tuple],
+ existing: dict[int, ifcopenshell.entity_instance],
+) -> None:
+ """Create missing and update existing IfcMetric entities for each segment."""
+ n_segments = len(resolved_pts) - 1
+ seen_guids: set[str] = set()
+
+ # Build a lookup of which elements are at each anchor endpoint
+ pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME)
+ anchors: list[dict] = []
+ if pset_data and pset_data.get("Anchors"):
+ try:
+ anchors = json.loads(pset_data["Anchors"])
+ except Exception:
+ pass
+
+ for seg_idx in range(n_segments):
+ if seg_idx in existing:
+ pass # metric already exists; association is still valid
+ else:
+ # Create new IfcMetric + IfcRelAssociatesConstraint
+ # DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it;
+ # the measured distance is derivable from the anchor pt fields.
+ metric = file.create_entity(
+ "IfcMetric",
+ Name=f"seg_{seg_idx}",
+ ConstraintGrade="ADVISORY",
+ Benchmark="EQUALTO",
+ )
+ # Gather related products for this segment (the two anchor elements)
+ related: list[ifcopenshell.entity_instance] = [annotation]
+ for anchor_idx in (seg_idx, seg_idx + 1):
+ if anchor_idx < len(anchors):
+ guid = anchors[anchor_idx].get("guid")
+ if guid and guid not in seen_guids:
+ try:
+ elem = file.by_guid(guid)
+ related.append(elem)
+ seen_guids.add(guid)
+ except Exception:
+ pass
+
+ file.create_entity(
+ "IfcRelAssociatesConstraint",
+ GlobalId=ifcopenshell.guid.new(),
+ OwnerHistory=ifcopenshell.api.owner.create_owner_history(file),
+ Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}",
+ RelatingConstraint=metric,
+ RelatedObjects=related,
+ )
+
+ # Remove orphaned metrics for segments that no longer exist
+ for seg_idx, metric in existing.items():
+ if seg_idx >= n_segments:
+ for rel in file.get_inverse(metric):
+ if rel.is_a("IfcRelAssociatesConstraint"):
+ file.remove(rel)
+ file.remove(metric)
+
+
+def _dist(a: tuple, b: tuple) -> float:
+ return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py
new file mode 100644
index 0000000000..260183b6e2
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py
@@ -0,0 +1,696 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2021 Dion Moult
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcOpenShell is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcOpenShell. If not, see .
+
+"""Resolve a parametric dimension anchor to a world-space coordinate in metres.
+
+NOTE ON COORDINATE SPACE
+ifcopenshell.geom.create_shape() always outputs geometry in **metres** (its
+internal unit), regardless of the IFC project's declared length unit (feet, mm,
+etc.). All anchor coordinates (``pt``, ``hint``, fingerprint ``centroid``) are
+therefore stored in metres, which is also Blender world space. The IFC
+project's unit_scale is NOT applied here. Callers that need IFC project units
+must divide by ``ifcopenshell.util.unit.calculate_unit_scale(file)`` themselves.
+
+Anchor schema (JSON-serialisable dict stored in BBIM_DimensionTarget.Anchors):
+
+ {
+ "guid": str | None, # element GlobalId; None → WORLD type (free point)
+ "type": str, # "FACE" | "CIRCLE_CENTER" | "WORLD"
+ "addr": {
+ "method": str, # "ANALYTIC" | "TESS_INDEX" | "TESS_FINGERPRINT"
+ "repr_id": int, # STEP id of representation item (ANALYTIC / TESS_INDEX)
+ "repr_type": str, # IFC class of representation item
+ "face_role": str, # "TOP" | "BOTTOM" | "SIDE_" (IfcExtrudedAreaSolid only)
+ "tess_index": int, # coplanar face-group index (-1 = skip)
+ "fingerprint": {
+ "normal": [x, y, z], # world-space unit normal (IFC project units)
+ "area": float, # total face area
+ "centroid": [x, y, z] # area-weighted centroid
+ }
+ } | None,
+ "hint": [x, y, z] | None, # original click position for disambiguation
+ "pt": [x, y, z] # last resolved position — used as fallback
+ }
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Optional
+
+import ifcopenshell
+import ifcopenshell.geom
+import ifcopenshell.util.placement
+import ifcopenshell.util.unit
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def resolve_anchor(
+ file: ifcopenshell.file,
+ anchor: dict,
+ settings: Optional[ifcopenshell.geom.settings] = None,
+ shape_cache: Optional[dict] = None,
+ placement_override: Optional[dict] = None,
+) -> Optional[tuple[float, float, float]]:
+ """Resolve an anchor dict to a world-space point in metres.
+
+ Resolution order:
+ 1. WORLD / null guid → return stored ``pt`` directly.
+ 2. ANALYTIC for IfcExtrudedAreaSolid → analytical TOP/BOTTOM face centre.
+ 3. TESS_INDEX → centroid of a pre-recorded face group by index.
+ 4. TESS_FINGERPRINT → best face group matched by normal + centroid proximity.
+ 5. Fallback → stored ``pt``.
+
+ :param file: The open IFC file.
+ :param anchor: Anchor descriptor dict.
+ :param settings: ifcopenshell.geom settings; created automatically when None.
+ :param shape_cache: Mutable dict keyed by element STEP id to cache shapes.
+ :param placement_override: Optional dict mapping element STEP id → 4×4 numpy
+ matrix (row-major, metres). When provided, this matrix is used instead of
+ ``element.ObjectPlacement`` for the local→world transform. Pass the
+ Blender object's ``matrix_world`` here so that elements moved in the
+ viewport but not yet explicitly synced to IFC are handled correctly.
+ :return: ``(x, y, z)`` in metres, or ``None``.
+ """
+ anchor_type = anchor.get("type", "WORLD")
+ guid = anchor.get("guid")
+
+ if anchor_type == "WORLD" or not guid:
+ return _pt_or_none(anchor.get("pt"))
+
+ try:
+ element = file.by_guid(guid)
+ except Exception:
+ return _pt_or_none(anchor.get("pt"))
+
+ addr = anchor.get("addr") or {}
+ method = addr.get("method", "TESS_FINGERPRINT")
+
+ # --- 1. Analytical path (fast, exact) ---
+ if method == "ANALYTIC" and addr.get("repr_type") == "IfcExtrudedAreaSolid":
+ pt = _resolve_extruded_area_solid_analytic(file, element, addr, placement_override)
+ if pt is not None:
+ return pt
+
+ # --- 2 & 3. Tessellation path (universal) ---
+ shape = _get_shape(file, element, settings, shape_cache)
+ if shape is None:
+ return _pt_or_none(anchor.get("pt"))
+
+ verts, tris = _extract_mesh(shape)
+ if not tris:
+ return _pt_or_none(anchor.get("pt"))
+
+ groups = _group_coplanar_tris(verts, tris)
+ group_props = [_face_group_props(g, verts, tris) for g in groups]
+
+ # group_props centroids/normals are in LOCAL metres (no USE_WORLD_COORDS).
+ # Build world-space equivalents using placement_override (Blender matrix_world)
+ # when available, otherwise fall back to element.ObjectPlacement from IFC.
+ world_group_props = [
+ {
+ "centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
+ "normal": _rotate_local_to_world(element, gp["normal"], placement_override),
+ "area": gp["area"],
+ }
+ for gp in group_props
+ ]
+
+ # TESS_INDEX (fast, index into the cached face-group list)
+ tess_index = addr.get("tess_index", -1)
+ if 0 <= tess_index < len(groups):
+ return world_group_props[tess_index]["centroid"]
+
+ # TESS_FINGERPRINT (robust across topology changes)
+ fingerprint = addr.get("fingerprint")
+ hint = anchor.get("hint")
+ if fingerprint:
+ pt = _find_by_fingerprint(world_group_props, fingerprint, hint)
+ if pt is not None:
+ return pt
+
+ return _pt_or_none(anchor.get("pt"))
+
+
+def build_anchor_from_hit(
+ file: ifcopenshell.file,
+ element: ifcopenshell.entity_instance,
+ hit_location_ifc: tuple[float, float, float],
+ hit_normal_ifc: tuple[float, float, float],
+ settings: Optional[ifcopenshell.geom.settings] = None,
+ shape_cache: Optional[dict] = None,
+ placement_override: Optional[dict] = None,
+) -> dict:
+ """Build an anchor dict from a viewport ray-cast hit.
+
+ Tessellates the element, finds the best-matching face group for the hit
+ normal/location, computes the fingerprint, and optionally detects an
+ IfcExtrudedAreaSolid face role (TOP/BOTTOM) for the analytical path.
+
+ :param file: The open IFC file.
+ :param element: The IFC element that was hit.
+ :param hit_location_ifc: Hit point in metres (world space).
+ :param hit_normal_ifc: Face normal at the hit point (world space, unit vec).
+ :param settings: Geometry settings for tessellation.
+ :param shape_cache: Mutable shape-cache dict.
+ :param placement_override: Optional dict mapping element STEP id → 4×4 numpy
+ matrix (metres). See ``resolve_anchor`` for details.
+ :return: Anchor dict ready for JSON serialisation into BBIM_DimensionTarget.
+ """
+ shape = _get_shape(file, element, settings, shape_cache)
+
+ tess_index = -1
+ fingerprint: dict = {
+ "normal": list(hit_normal_ifc),
+ "area": 0.0,
+ "centroid": list(hit_location_ifc),
+ }
+
+ if shape is not None:
+ verts, tris = _extract_mesh(shape)
+ groups = _group_coplanar_tris(verts, tris)
+ local_group_props = [_face_group_props(g, verts, tris) for g in groups]
+ world_group_props = [
+ {
+ "centroid": _local_to_world_m(file, element, gp["centroid"], placement_override),
+ "normal": _rotate_local_to_world(element, gp["normal"], placement_override),
+ "area": gp["area"],
+ }
+ for gp in local_group_props
+ ]
+ best = _best_group(world_group_props, hit_normal_ifc, hit_location_ifc)
+ if best is not None:
+ tess_index, props = best
+ fingerprint = {
+ "normal": list(props["normal"]),
+ "area": props["area"],
+ "centroid": list(props["centroid"]),
+ }
+
+ repr_type, repr_id, face_role = _detect_extruded_face(file, element, hit_location_ifc, hit_normal_ifc)
+ method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT"
+
+ return {
+ "guid": element.GlobalId,
+ "type": "FACE",
+ "addr": {
+ "method": method,
+ "repr_id": repr_id,
+ "repr_type": repr_type,
+ "face_role": face_role,
+ "tess_index": tess_index,
+ "fingerprint": fingerprint,
+ },
+ "hint": list(hit_location_ifc),
+ "pt": list(hit_location_ifc),
+ }
+
+
+def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict:
+ """Build a free-floating (WORLD) anchor — not connected to any element."""
+ return {
+ "guid": None,
+ "type": "WORLD",
+ "addr": None,
+ "hint": None,
+ "pt": list(pt_ifc),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Mesh extraction helpers
+# ---------------------------------------------------------------------------
+
+
+def _get_shape(file, element, settings, shape_cache):
+ if shape_cache is None:
+ shape_cache = {}
+ elem_id = element.id()
+ if elem_id in shape_cache:
+ return shape_cache[elem_id]
+
+ if settings is None:
+ settings = ifcopenshell.geom.settings()
+ # Do NOT set USE_WORLD_COORDS — tessellate in local (element-origin) space.
+ # The geom kernel caches by representation ID; with USE_WORLD_COORDS=True,
+ # moving an element would return stale world-space coords from the cache.
+ # We apply the current placement manually via placement_override.
+ settings.set("APPLY_DEFAULT_MATERIALS", False)
+
+ try:
+ shape = ifcopenshell.geom.create_shape(settings, element)
+ except Exception:
+ shape = None
+
+ shape_cache[elem_id] = shape
+ return shape
+
+
+def _local_to_world_m(
+ file: ifcopenshell.file,
+ element: ifcopenshell.entity_instance,
+ local_pt_m: tuple,
+ placement_override: Optional[dict] = None,
+) -> tuple[float, float, float]:
+ """Convert a local-space point (metres, from create_shape without USE_WORLD_COORDS)
+ to a world-space point in metres.
+
+ When *placement_override* contains the element's STEP id, that 4×4 matrix
+ (row-major, already in metres — typically ``np.array(obj.matrix_world)``) is
+ used instead of reading ``element.ObjectPlacement`` from the IFC file. This
+ ensures that elements moved in the Blender viewport but not yet explicitly
+ synced to IFC (via "Edit Object Placement") are handled correctly.
+
+ Without an override, falls back to ``get_local_placement`` which reads the IFC
+ placement and scales IFC-unit translation to metres via ``unit_scale``.
+ """
+ x, y, z = float(local_pt_m[0]), float(local_pt_m[1]), float(local_pt_m[2])
+ if placement_override is not None and element.id() in placement_override:
+ m = placement_override[element.id()] # 4×4, metres, row-major
+ return (
+ float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
+ float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
+ float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
+ )
+ unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
+ m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
+ return (
+ float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3] * unit_scale),
+ float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3] * unit_scale),
+ float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3] * unit_scale),
+ )
+
+
+def _rotate_local_to_world(
+ element: ifcopenshell.entity_instance,
+ local_vec: tuple,
+ placement_override: Optional[dict] = None,
+) -> tuple[float, float, float]:
+ """Rotate a direction vector from local to world space (no translation)."""
+ x, y, z = float(local_vec[0]), float(local_vec[1]), float(local_vec[2])
+ if placement_override is not None and element.id() in placement_override:
+ m = placement_override[element.id()]
+ return (
+ float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
+ float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
+ float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
+ )
+ m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
+ return (
+ float(m[0][0] * x + m[0][1] * y + m[0][2] * z),
+ float(m[1][0] * x + m[1][1] * y + m[1][2] * z),
+ float(m[2][0] * x + m[2][1] * y + m[2][2] * z),
+ )
+
+
+def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]:
+ """Return (verts, tris) from a tessellated shape."""
+ vf = shape.geometry.verts
+ ff = shape.geometry.faces
+ verts = [(vf[i * 3], vf[i * 3 + 1], vf[i * 3 + 2]) for i in range(len(vf) // 3)]
+ tris = [(ff[i * 3], ff[i * 3 + 1], ff[i * 3 + 2]) for i in range(len(ff) // 3)]
+ return verts, tris
+
+
+# ---------------------------------------------------------------------------
+# Coplanar face grouping
+# ---------------------------------------------------------------------------
+
+_NORMAL_THRESHOLD = 0.005 # max angle deviation between coplanar normals (~0.3°)
+_PLANE_THRESHOLD = 1e-4 # max distance from origin along normal (metres — matches geom output)
+
+
+def _tri_normal(v0, v1, v2) -> tuple[float, float, float]:
+ ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
+ bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
+ nx = ay * bz - az * by
+ ny = az * bx - ax * bz
+ nz = ax * by - ay * bx
+ mag = math.sqrt(nx * nx + ny * ny + nz * nz)
+ if mag < 1e-12:
+ return (0.0, 0.0, 0.0)
+ return (nx / mag, ny / mag, nz / mag)
+
+
+def _dot(a, b) -> float:
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+
+
+def _group_coplanar_tris(verts: list, tris: list) -> list[list[int]]:
+ """Group triangle indices whose faces are coplanar (same normal + plane)."""
+ n_tris = len(tris)
+ normals: list[tuple] = []
+ plane_d: list[float] = []
+
+ for a, b, c in tris:
+ n = _tri_normal(verts[a], verts[b], verts[c])
+ normals.append(n)
+ # plane distance: n · centroid
+ cx = (verts[a][0] + verts[b][0] + verts[c][0]) / 3
+ cy = (verts[a][1] + verts[b][1] + verts[c][1]) / 3
+ cz = (verts[a][2] + verts[b][2] + verts[c][2]) / 3
+ plane_d.append(n[0] * cx + n[1] * cy + n[2] * cz)
+
+ assigned = [False] * n_tris
+ groups: list[list[int]] = []
+
+ for i in range(n_tris):
+ if assigned[i]:
+ continue
+ group = [i]
+ assigned[i] = True
+ ni, di = normals[i], plane_d[i]
+ if ni == (0.0, 0.0, 0.0):
+ groups.append(group)
+ continue
+ for j in range(i + 1, n_tris):
+ if assigned[j]:
+ continue
+ nj, dj = normals[j], plane_d[j]
+ if nj == (0.0, 0.0, 0.0):
+ continue
+ dot_val = _dot(ni, nj) # signed — opposite normals (dot≈-1) must NOT merge
+ if dot_val > 1.0 - _NORMAL_THRESHOLD and abs(di - dj) < _PLANE_THRESHOLD:
+ group.append(j)
+ assigned[j] = True
+ groups.append(group)
+
+ return groups
+
+
+def _tri_area(v0, v1, v2) -> float:
+ ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
+ bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
+ cx = ay * bz - az * by
+ cy = az * bx - ax * bz
+ cz = ax * by - ay * bx
+ return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
+
+
+def _face_group_props(group: list[int], verts: list, tris: list) -> dict:
+ """Compute normal, total area, and area-weighted centroid for a face group."""
+ total_area = 0.0
+ wx = wy = wz = 0.0
+ nx = ny = nz = 0.0
+
+ for idx in group:
+ a, b, c = tris[idx]
+ va, vb, vc = verts[a], verts[b], verts[c]
+ area = _tri_area(va, vb, vc)
+ total_area += area
+ cx = (va[0] + vb[0] + vc[0]) / 3
+ cy = (va[1] + vb[1] + vc[1]) / 3
+ cz = (va[2] + vb[2] + vc[2]) / 3
+ wx += cx * area
+ wy += cy * area
+ wz += cz * area
+ n = _tri_normal(va, vb, vc)
+ nx += n[0] * area
+ ny += n[1] * area
+ nz += n[2] * area
+
+ if total_area < 1e-12:
+ return {"normal": (0.0, 0.0, 1.0), "area": 0.0, "centroid": (wx, wy, wz)}
+
+ centroid = (wx / total_area, wy / total_area, wz / total_area)
+
+ mag = math.sqrt(nx * nx + ny * ny + nz * nz)
+ if mag > 1e-12:
+ normal: tuple[float, ...] = (nx / mag, ny / mag, nz / mag)
+ else:
+ normal = (0.0, 0.0, 1.0)
+
+ return {"normal": normal, "area": total_area, "centroid": centroid}
+
+
+# ---------------------------------------------------------------------------
+# Fingerprint matching
+# ---------------------------------------------------------------------------
+
+_NORMAL_MATCH_THRESHOLD = 0.02 # max dot-product deviation for normal match
+_CENTROID_MAX_DIST = 10.0 # max IFC-unit distance for centroid proximity
+
+
+def _dist(a, b) -> float:
+ return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
+
+
+def _find_by_fingerprint(
+ group_props: list[dict],
+ fingerprint: dict,
+ hint: Optional[list],
+) -> Optional[tuple[float, float, float]]:
+ """Return the centroid of the best-matching face group."""
+ fp_normal = fingerprint["normal"]
+ fp_centroid = fingerprint["centroid"]
+
+ best_score = -1.0
+ best_centroid = None
+
+ for props in group_props:
+ dot_val = _dot(props["normal"], fp_normal)
+ if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
+ continue # wrong-facing face
+
+ # Score: prefer face whose centroid is closest to stored fingerprint centroid,
+ # then to the original click hint.
+ centroid_dist = _dist(props["centroid"], fp_centroid)
+ if centroid_dist > _CENTROID_MAX_DIST:
+ continue
+
+ score = dot_val - centroid_dist / _CENTROID_MAX_DIST * 0.3
+ if hint:
+ hint_dist = _dist(props["centroid"], hint)
+ score -= hint_dist / _CENTROID_MAX_DIST * 0.1
+
+ if score > best_score:
+ best_score = score
+ best_centroid = props["centroid"]
+
+ return best_centroid
+
+
+def _best_group(
+ group_props: list[dict],
+ hit_normal: tuple,
+ hit_location: tuple,
+) -> Optional[tuple[int, dict]]:
+ """Return (index, props) for the best face group matching a ray-cast hit."""
+ best_score = -1.0
+ best = None
+
+ for i, props in enumerate(group_props):
+ dot_val = _dot(props["normal"], hit_normal)
+ if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD:
+ continue
+ dist = _dist(props["centroid"], hit_location)
+ score = dot_val - dist / max(_CENTROID_MAX_DIST, 0.001) * 0.2
+ if score > best_score:
+ best_score = score
+ best = (i, props)
+
+ return best
+
+
+# ---------------------------------------------------------------------------
+# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM
+# ---------------------------------------------------------------------------
+
+
+def _resolve_extruded_area_solid_analytic(
+ file: ifcopenshell.file,
+ element: ifcopenshell.entity_instance,
+ addr: dict,
+ placement_override: Optional[dict] = None,
+) -> Optional[tuple[float, float, float]]:
+ """Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid."""
+ face_role = addr.get("face_role", "")
+ if face_role not in ("TOP", "BOTTOM"):
+ return None
+
+ repr_id = addr.get("repr_id")
+ if not repr_id:
+ return None
+
+ try:
+ solid = file.by_id(repr_id)
+ except Exception:
+ return None
+
+ if not solid.is_a("IfcExtrudedAreaSolid"):
+ return None
+
+ try:
+ profile_centroid_local = _profile_centroid(solid.SweptArea)
+ dir_ratios = solid.ExtrudedDirection.DirectionRatios
+ depth = solid.Depth
+
+ mag = math.sqrt(sum(d * d for d in dir_ratios))
+ if mag < 1e-12:
+ return None
+ dir_vec = tuple(d / mag for d in dir_ratios)
+
+ px = profile_centroid_local[0] + dir_vec[0] * (depth if face_role == "TOP" else 0.0)
+ py = profile_centroid_local[1] + dir_vec[1] * (depth if face_role == "TOP" else 0.0)
+ pz = dir_vec[2] * (depth if face_role == "TOP" else 0.0)
+
+ if solid.Position:
+ local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz))
+ else:
+ local_pt = (px, py, pz)
+
+ # Apply element placement — use placement_override (Blender matrix_world, metres)
+ # when available so that unsync'd viewport moves are reflected.
+ return _local_to_world_m(file, element, local_pt, placement_override)
+ except Exception:
+ return None
+
+
+def _profile_centroid(profile) -> tuple[float, float]:
+ """Return (x, y) centroid of a profile def in its local 2D space."""
+ if profile.is_a("IfcRectangleProfileDef"):
+ pos = profile.Position
+ if pos:
+ loc = pos.Location
+ return (loc.Coordinates[0], loc.Coordinates[1])
+ return (0.0, 0.0)
+ if profile.is_a("IfcCircleProfileDef"):
+ pos = profile.Position
+ if pos:
+ loc = pos.Location
+ return (loc.Coordinates[0], loc.Coordinates[1])
+ return (0.0, 0.0)
+ # Fallback for arbitrary profiles — use position location if available
+ if hasattr(profile, "Position") and profile.Position:
+ loc = profile.Position.Location
+ return (loc.Coordinates[0], loc.Coordinates[1])
+ return (0.0, 0.0)
+
+
+def _apply_axis2placement3d(placement, pt: tuple) -> tuple[float, float, float]:
+ """Apply an IfcAxis2Placement3D to a local point."""
+ loc = placement.Location.Coordinates
+ ox, oy, oz = float(loc[0]), float(loc[1]), float(loc[2])
+
+ # Z axis (extrusion direction in placement space)
+ if placement.Axis:
+ zr = placement.Axis.DirectionRatios
+ zx, zy, zz = float(zr[0]), float(zr[1]), float(zr[2])
+ else:
+ zx, zy, zz = 0.0, 0.0, 1.0
+
+ # X axis (ref direction)
+ if placement.RefDirection:
+ xr = placement.RefDirection.DirectionRatios
+ xx, xy, xz = float(xr[0]), float(xr[1]), float(xr[2])
+ else:
+ xx, xy, xz = 1.0, 0.0, 0.0
+
+ # Y axis = Z × X
+ yx = zy * xz - zz * xy
+ yy = zz * xx - zx * xz
+ yz = zx * xy - zy * xx
+
+ px, py, pz = pt
+ return (
+ ox + px * xx + py * yx + pz * zx,
+ oy + px * xy + py * yy + pz * zy,
+ oz + px * xz + py * yz + pz * zz,
+ )
+
+
+def _mat_apply(m, pt: tuple) -> tuple[float, float, float]:
+ """Apply a 4×4 numpy placement matrix to a point."""
+ x, y, z = float(pt[0]), float(pt[1]), float(pt[2])
+ return (
+ float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]),
+ float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]),
+ float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]),
+ )
+
+
+# ---------------------------------------------------------------------------
+# IfcExtrudedAreaSolid face role detection
+# ---------------------------------------------------------------------------
+
+
+def _detect_extruded_face(
+ file: ifcopenshell.file,
+ element: ifcopenshell.entity_instance,
+ hit_location: tuple,
+ hit_normal: tuple,
+) -> tuple[str, int, str]:
+ """Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid.
+
+ Returns (repr_type, repr_id, face_role).
+ repr_type is empty string if not detected as extruded solid.
+ """
+ if not hasattr(element, "Representation") or not element.Representation:
+ return ("", -1, "")
+
+ for rep in element.Representation.Representations:
+ for item in rep.Items:
+ solid = _unwrap_mapped(item)
+ if not solid or not solid.is_a("IfcExtrudedAreaSolid"):
+ continue
+ role = _extruded_face_role(solid, hit_normal)
+ if role:
+ return ("IfcExtrudedAreaSolid", solid.id(), role)
+
+ return ("", -1, "")
+
+
+def _unwrap_mapped(item):
+ """Unwrap IfcMappedItem to its underlying representation item (first item)."""
+ if item.is_a("IfcMappedItem"):
+ items = item.MappingSource.MappedRepresentation.Items
+ return items[0] if items else None
+ return item
+
+
+def _extruded_face_role(solid, hit_normal: tuple) -> str:
+ """Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion."""
+ try:
+ dr = solid.ExtrudedDirection.DirectionRatios
+ mag = math.sqrt(sum(d * d for d in dr))
+ if mag < 1e-12:
+ return ""
+ extrude_dir = tuple(d / mag for d in dr)
+ dot_val = _dot(extrude_dir, hit_normal)
+ if dot_val > 0.99:
+ return "TOP"
+ if dot_val < -0.99:
+ return "BOTTOM"
+ except Exception:
+ pass
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Misc helpers
+# ---------------------------------------------------------------------------
+
+
+def _pt_or_none(pt) -> Optional[tuple[float, float, float]]:
+ if pt:
+ return (float(pt[0]), float(pt[1]), float(pt[2]))
+ return None