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