From 9b39dd629bd453a5365586f712cb12bf1bd8a8b2 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 16 May 2026 15:11:05 -0500 Subject: [PATCH] Add ForcePerpendicularToFace + hover-cycle UX for parametric dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetDimensionAnchor — hover-select-then-confirm: - Cursor highlights candidate IFC elements (orange Blender selection outline) before committing; Tab cycles through overlapping/coplanar candidates - _compute_candidates: ray-cast all IFC mesh objects; falls back to 2D bounding-box proximity (5 cm tolerance) for plan-view picks where the ray misses the mesh by sub-mm amounts - _write_anchor: after anchoring a face, immediately calls regenerate_dimension with placement_override (Blender matrix_world) and _update_blender_curve so the curve vertex moves to the resolved point DrawParametricDimension — ForcePerpendicularToFace live snap constraint: - Reads force_perpendicular_to_face toggle from annotation props on invoke - After anchor[0] is placed on a FACE, _update_perp_constraint extracts the face normal and stores it as the constraint axis - _apply_perp_constraint runs every modal tick after handle_snap_selection, projecting the current snap point onto pt[0] + t*normal - On finalize, _create_dimension_from_polyline writes ForcePerpendicularToFace to the BBIM_Dimension pset and calls regenerate_dimension to snap the stored curve to the constraint before the operator exits regenerate_dimension.py: - ForcePerpendicularToFace block: after resolving all anchors, projects vertices 1…n onto the line through pt[0] along anchor[0]'s face normal - _get_anchor_face_normal_world: reads normal_local from anchor fingerprint, calls _rotate_local_to_world with placement_override; falls back to stored world-space normal resolve_anchor.py: - _rotate_local_to_world: transforms an element-local direction vector to world space using the element's placement or placement_override matrix pset/operator.py: - EditPset._execute: after editing a BBIM_Dimension pset on an IfcAnnotation, auto-calls regenerate_dimension + _update_blender_curve so changes to anchors/ForcePerpendicularToFace are reflected immediately in the viewport prop.py / workspace.py: - Added force_perpendicular_to_face BoolProperty to BIMAnnotationProperties - UI toggle shown in annotation tool header for DIMENSION/RADIUS/DIAMETER/ ANGLE/PLAN_LEVEL/SECTION_LEVEL types Psets_BBIM_Annotation.ifc: - Added ForcePerpendicularToFace property template (#39) to BBIM_Dimension - Extended BBIM_Dimension applicability to ANGLE, PLAN_LEVEL, SECTION_LEVEL Co-Authored-By: Claude Sonnet 4.6 --- .../bim/data/pset/Psets_BBIM_Annotation.ifc | 8 +- .../bonsai/bim/module/drawing/handler.py | 6 +- .../bonsai/bim/module/drawing/operator.py | 497 ++++++++++++++++-- src/bonsai/bonsai/bim/module/drawing/prop.py | 5 + .../bonsai/bim/module/drawing/workspace.py | 5 + src/bonsai/bonsai/bim/module/pset/operator.py | 53 ++ .../api/drawing/regenerate_dimension.py | 67 ++- .../api/drawing/resolve_anchor.py | 266 +++++++++- 8 files changed, 813 insertions(+), 94 deletions(-) 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 393ffddf3b..045e673c7e 100644 --- a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc +++ b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc @@ -28,7 +28,7 @@ DATA; #21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); -#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34)); +#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39)); #25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -41,8 +41,8 @@ DATA; #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.); -#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.); +#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric 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.); +#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); +#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py index 2e1d7cdbf7..78459e61cc 100644 --- a/src/bonsai/bonsai/bim/module/drawing/handler.py +++ b/src/bonsai/bonsai/bim/module/drawing/handler.py @@ -33,7 +33,7 @@ import bonsai.tool as tool _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. +# Set True whenever BBIM_Dimension 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 @@ -52,7 +52,7 @@ def _rebuild_dim_guid_index(file) -> None: _dim_guid_index = {} for annotation in file.by_type("IfcAnnotation"): - pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") if not pset_data or not pset_data.get("Anchors"): continue try: @@ -172,7 +172,7 @@ def depsgraph_update_post_handler(scene, depsgraph): except Exception: continue - pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") if not pset: continue diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index ce0bdba837..271fca2c09 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5574,7 +5574,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope Click on IFC element faces to place dimension vertices one by one using the same snap system as wall and slab drawing. Each confirmed point is stored - as a parametric anchor in ``BBIM_DimensionTarget`` so the dimension + as a parametric anchor in ``BBIM_Dimension`` so the dimension recomputes automatically when the referenced elements move. RMB or ENTER to finish; ESC to cancel without creating an annotation. @@ -5603,12 +5603,15 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope PolylineOperator.__init__(self) self._anchors = [] self._shape_cache = {} + self._force_perpendicular = False + self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0] + self._anchor0_pt = None # (x, y, z) world-space position of anchor[0] # ------------------------------------------------------------------ # Snap → anchor bridge def _snap_to_anchor(self, snap: dict) -> dict: - """Convert a PolylineOperator snap candidate to a BBIM_DimensionTarget anchor dict.""" + """Convert a PolylineOperator snap candidate to a BBIM_Dimension anchor dict.""" import ifcopenshell.api.drawing as drawing_api obj = snap.get("object") @@ -5619,14 +5622,21 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z)) face_index = snap.get("face_index") + if face_index is None or face_index >= len(obj.data.polygons): + # Vertex / edge snap: seed from closest face. + local_pt = obj.matrix_world.inverted() @ pt_world + ok, _loc, _n, face_index = obj.closest_point_on_mesh(local_pt) + if not ok: + face_index = None + + # Prefer faces perpendicular to the camera rather than faces that + # directly face the camera (e.g. top of a wall in plan view). + face_index = _prefer_perp_face_index(obj, pt_world, face_index) + if face_index is not None and face_index < len(obj.data.polygons): normal_local = obj.data.polygons[face_index].normal else: - # Vertex / edge snap: find the closest face for a proper normal. - local_pt = obj.matrix_world.inverted() @ pt_world - ok, _loc, normal_local, face_index = obj.closest_point_on_mesh(local_pt) - if not ok: - normal_local = Vector((0.0, 0.0, 1.0)) + normal_local = Vector((0.0, 0.0, 1.0)) normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized() normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z)) @@ -5673,12 +5683,57 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope import ifcopenshell.api.drawing as drawing_api pt = polyline_data[0].polyline_points[-1] self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)])) + + # After anchor[0] is set, extract its face normal for the perp constraint. + if self._force_perpendicular and len(self._anchors) == 1: + self._update_perp_constraint() elif count_after < count_before and self._anchors: # BACKSPACE removed a point. self._anchors.pop() + # Reset constraint if we backspaced past anchor[0]. + if len(self._anchors) == 0: + self._anchor0_normal = None + self._anchor0_pt = None # ------------------------------------------------------------------ - # Finalize: create IfcAnnotation + BBIM_DimensionTarget pset + # Perpendicular-to-face constraint helpers + + def _update_perp_constraint(self) -> None: + """Extract the face normal from anchor[0] and store it as the constraint axis.""" + import math + a = self._anchors[0] if self._anchors else None + if not a or a.get("type") != "FACE": + return + fp = (a.get("addr") or {}).get("fingerprint") or {} + n = fp.get("normal") # world-space at build time + pt = a.get("pt") + if not n or not pt: + return + mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) + if mag < 1e-12: + return + self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag) + self._anchor0_pt = tuple(pt) + + def _apply_perp_constraint(self) -> None: + """Project the current snap point onto the constraint line when active.""" + if not self._force_perpendicular or not self._anchor0_normal or not self._anchor0_pt: + return + if not self._anchors: # constraint not yet active (no anchor[0] yet) + return + if not self.snapping_points: + return + snap = self.snapping_points[0] + if not snap or not snap.get("point"): + return + p = snap["point"] + base = self._anchor0_pt + n = self._anchor0_normal + t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2] + snap["point"] = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2])) + + # ------------------------------------------------------------------ + # Finalize: create IfcAnnotation + BBIM_Dimension pset def _create_dimension_from_polyline(self, context) -> None: import ifcopenshell.api.drawing as drawing_api @@ -5731,10 +5786,36 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)]))) file = tool.Ifc.get() - ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_DimensionTarget") - pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data: + ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") pset_entity = file.by_id(pset_data["id"]) - ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": json.dumps(anchors)}) + pset_props = {"Anchors": json.dumps(anchors)} + if self._force_perpendicular: + pset_props["ForcePerpendicularToFace"] = True + ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props) + + if self._force_perpendicular: + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + resolved_pts = drawing_api.regenerate_dimension( + file, annotation, + shape_cache=getattr(self, "_shape_cache", None), + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) from bonsai.bim.module.drawing import handler as _drawing_handler _drawing_handler.invalidate_dim_index() @@ -5762,6 +5843,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope self.handle_mouse_move(context, event) self.choose_axis(event) self.handle_snap_selection(context, event) + self._apply_perp_constraint() if ( not self.tool_state.is_input_on @@ -5789,17 +5871,62 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope def _invoke(self, context, event): super().invoke(context, event) + self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face return {"RUNNING_MODAL"} +def _prefer_perp_face_index( + obj: "bpy.types.Object", + hit_world: "Vector", + current_index: "Optional[int]", + world_matrix=None, +) -> "Optional[int]": + """Return the polygon index most perpendicular to the camera near *hit_world*. + + If the camera is unavailable or the current face is already sufficiently + perpendicular (|dot| < 0.5), returns *current_index* unchanged. + *world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh + inside a collection instance whose effective transform differs from its own + ``matrix_world``. + """ + camera = bpy.context.scene.camera + if not camera or not obj.data or not hasattr(obj.data, "polygons"): + return current_index + + cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized() + mx = world_matrix if world_matrix is not None else obj.matrix_world + mx3 = mx.to_3x3() + + if current_index is not None and current_index < len(obj.data.polygons): + current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized() + if abs(current_n.dot(cam_view)) < 0.5: + return current_index + + best_idx = current_index + best_score = -1.0 + for i, poly in enumerate(obj.data.polygons): + n_world = (mx3 @ poly.normal).normalized() + perp = 1.0 - abs(n_world.dot(cam_view)) + if perp < 0.5: + continue + dist = (mx @ poly.center - hit_world).length + score = perp - dist / 4.0 + if score > best_score: + best_score = score + best_idx = i + return best_idx + + class SetDimensionAnchor(bpy.types.Operator): """Interactively anchor dimension vertices to IFC element faces. - Two-phase modal workflow (all in Object Mode, no Tab required): + Two-phase modal workflow (all in Object Mode): 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. + 3. Hover over IFC elements — the nearest candidate is highlighted. + TAB cycles through overlapping candidates under the cursor. + Click to anchor the highlighted element face to that vertex. + ALT+Click sets a free world-point anchor instead. 4. Repeat steps 2-3 for more vertices. 5. RMB or ESC to finish. """ @@ -5816,8 +5943,17 @@ class SetDimensionAnchor(bpy.types.Operator): _phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE" _active_vertex_idx: int = -1 _shape_cache: dict + _region: Optional[bpy.types.Region] = None + _rv3d: Optional[bpy.types.RegionView3D] = None - _VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex + # Hover-cycle state (active during PICK_FACE phase) + _hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...] + _hover_index: int # which candidate is currently highlighted + _hover_last_px: tuple # last cursor pixel position where candidates were computed + _hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight + + _VERTEX_PICK_RADIUS_PX = 20 # pixels — how close the click must be to a vertex + _HOVER_THROTTLE_PX_SQ = 25 # only recompute candidates if cursor moves >5px @classmethod def poll(cls, context): @@ -5848,15 +5984,44 @@ class SetDimensionAnchor(bpy.types.Operator): self._phase = "PICK_VERTEX" self._active_vertex_idx = -1 self._shape_cache = {} + self._hover_candidates = [] + self._hover_index = 0 + self._hover_last_px = (-9999, -9999) + self._hover_highlighted_obj = None + + # When invoked from a panel, context.region_data is None. + # Walk the screen areas to find the actual 3D viewport region. + self._region, self._rv3d = None, None + for area in context.screen.areas: + if area.type == "VIEW_3D": + for region in area.regions: + if region.type == "WINDOW": + self._region = region + break + if area.spaces and area.spaces[0].type == "VIEW_3D": + self._rv3d = area.spaces[0].region_3d + break + 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"): + self._clear_hover_highlight(context) context.workspace.status_text_set(None) return {"FINISHED"} # keep any anchors already written + # Hover — recompute candidates as cursor moves (PICK_FACE phase only) + if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE": + self._handle_hover(context, event) + return {"RUNNING_MODAL"} + + # Tab — cycle through candidates under cursor + if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE": + self._cycle_hover(context) + return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.value == "PRESS": if self._phase == "PICK_VERTEX": self._handle_vertex_pick(context, event) @@ -5876,9 +6041,11 @@ class SetDimensionAnchor(bpy.types.Operator): "Click a dimension vertex | RMB / ESC: Finish" ) else: + # In PICK_FACE the hover handler writes a richer status; this is the + # fallback shown when no candidates have been computed yet. 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" + f"Vertex {self._active_vertex_idx} — hover over element | " + "TAB: cycle candidates | Click: anchor | ALT+Click: free point | RMB/ESC: Finish" ) # ------------------------------------------------------------------ @@ -5887,12 +6054,14 @@ class SetDimensionAnchor(bpy.types.Operator): def _handle_vertex_pick(self, context, event): from bpy_extras import view3d_utils - region = context.region - rv3d = context.region_data + region = self._region + rv3d = self._rv3d if not region or not rv3d: return - coord = (event.mouse_region_x, event.mouse_region_y) + # event.mouse_region_x/y is relative to the event's region (e.g. N-panel), + # not our stored 3D viewport region. Use absolute coords minus region offset. + coord = (event.mouse_x - region.x, event.mouse_y - region.y) obj = self._annotation_obj best_idx = None @@ -5921,44 +6090,61 @@ class SetDimensionAnchor(bpy.types.Operator): # Phase 2: pick a face on an IFC element def _handle_face_pick(self, context, event): - from bpy_extras import view3d_utils + from mathutils import Vector - region = context.region - rv3d = context.region_data + region = self._region + rv3d = self._rv3d 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 + # ALT+click → free world-point anchor at any mesh surface. 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) - + self._clear_hover_highlight(context) + origin, direction = self._unproject_coord( + (event.mouse_x - region.x, event.mouse_y - region.y) + ) + best_dist = float("inf") + alt_loc = None + for obj in context.scene.objects: + if obj.type != "MESH": + continue + try: + mx_inv = obj.matrix_world.inverted() + except Exception: + continue + ok, loc_l, _, _ = obj.ray_cast( + mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized() + ) + if ok: + loc_w = obj.matrix_world @ loc_l + d = (loc_w - origin).length + if d < best_dist: + best_dist = d + alt_loc = loc_w + pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0) import ifcopenshell.api.drawing as drawing_api - anchor = drawing_api.make_world_anchor(list(pt_m)) + anchor = drawing_api.make_world_anchor(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) + # Normal click — use whichever candidate is currently highlighted. + self._clear_hover_highlight(context) - hit, location, normal, face_index, hit_obj, _ = context.scene.ray_cast( - context.view_layer.depsgraph, origin, direction - ) + coord = (event.mouse_x - region.x, event.mouse_y - region.y) + dx = coord[0] - self._hover_last_px[0] + dy = coord[1] - self._hover_last_px[1] + if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates: + self._hover_candidates = self._compute_candidates(context, coord) + self._hover_index = 0 - if not hit or hit_obj is None: + if not self._hover_candidates: 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 + idx = min(self._hover_index, len(self._hover_candidates) - 1) + hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx] element = tool.Ifc.get_entity(hit_obj) if not element: @@ -5968,16 +6154,20 @@ class SetDimensionAnchor(bpy.types.Operator): 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, - ) + try: + anchor = drawing_api.build_anchor_from_hit( + file, element, hit_m, normal_m, + shape_cache=self._shape_cache, + placement_override=placement_override, + ) + except Exception as exc: + import traceback + traceback.print_exc() + self.report({"ERROR"}, f"build_anchor_from_hit failed: {exc}") + return self._write_anchor(anchor, self._active_vertex_idx) self.report( @@ -5985,6 +6175,176 @@ class SetDimensionAnchor(bpy.types.Operator): f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId}", ) self._phase = "PICK_VERTEX" + self._hover_candidates = [] + self._hover_index = 0 + + # ------------------------------------------------------------------ + # Hover / cycle helpers + + def _unproject_coord(self, coord): + """Return (origin, direction) world-space ray for a region pixel coord.""" + from mathutils import Vector + + rv3d = self._rv3d + region = self._region + persinv = rv3d.perspective_matrix.inverted() + dx = (2.0 * coord[0] / region.width) - 1.0 + dy = (2.0 * coord[1] / region.height) - 1.0 + near_h = persinv @ Vector((dx, dy, -1.0, 1.0)) + far_h = persinv @ Vector((dx, dy, 1.0, 1.0)) + origin = near_h.xyz / near_h.w + far_pt = far_h.xyz / far_h.w + direction = (far_pt - origin).normalized() + if not rv3d.is_perspective: + origin = origin - direction * 1e4 + return origin, direction + + def _compute_candidates(self, context, coord): + """Cast a ray from *coord* and return a ranked list of hit candidates. + + Each entry: (ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index) + Sorted closest-first for direct hits; by proximity distance for near-misses. + """ + import math as _math + from mathutils import Vector + + origin, direction = self._unproject_coord(coord) + + direct: list = [] # (dist, ifc_obj, mesh_obj, mx, loc_w, normal, face_index) + for ifc_obj in context.scene.objects: + if ifc_obj == self._annotation_obj: + continue + if not tool.Ifc.get_entity(ifc_obj): + continue + if ifc_obj.type != "MESH": + continue + mx = ifc_obj.matrix_world + try: + mx_inv = mx.inverted() + except Exception: + continue + ok, loc_l, nrm_l, fi = ifc_obj.ray_cast( + mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized() + ) + if not ok: + continue + loc_w = mx @ loc_l + fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx) + normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized() + dist = (loc_w - origin).length + direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) + + if direct: + direct.sort(key=lambda c: c[0]) + return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct] + + # Proximity fallback — collect ALL candidates within TOL, sorted by perp distance. + TOL = 0.05 + + def _perp(v): + return v - v.dot(direction) * direction + + prox: list = [] + for ifc_obj in context.scene.objects: + if ifc_obj == self._annotation_obj: + continue + if not tool.Ifc.get_entity(ifc_obj): + continue + if ifc_obj.type != "MESH": + continue + mx = ifc_obj.matrix_world + try: + mx_inv = mx.inverted() + except Exception: + continue + bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box] + bb_proj = [_perp(v) for v in bb_world] + op = _perp(origin) + sx = max(min(v.x for v in bb_proj) - op.x, 0.0, op.x - max(v.x for v in bb_proj)) + sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj)) + sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj)) + perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz) + if perp_dist > TOL: + continue + bb_ctr = sum((v for v in bb_world), Vector()) / 8 + t = (bb_ctr - origin).dot(direction) + query_w = origin + t * direction + found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0) + if not found: + continue + loc_w = mx @ loc_l + fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx) + normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized() + prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) + + prox.sort(key=lambda c: c[0]) + return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox] + + def _handle_hover(self, context, event): + """Recompute candidates when cursor moves; highlight the current one.""" + if not self._region: + return + coord = (event.mouse_x - self._region.x, event.mouse_y - self._region.y) + dx = coord[0] - self._hover_last_px[0] + dy = coord[1] - self._hover_last_px[1] + if dx * dx + dy * dy < self._HOVER_THROTTLE_PX_SQ: + return + self._hover_last_px = coord + self._hover_candidates = self._compute_candidates(context, coord) + self._hover_index = 0 + self._apply_hover_highlight(context) + + def _cycle_hover(self, context): + """Advance to the next candidate and update the highlight.""" + if not self._hover_candidates: + return + self._hover_index = (self._hover_index + 1) % len(self._hover_candidates) + self._apply_hover_highlight(context) + + def _apply_hover_highlight(self, context): + """Select the current candidate object for visual feedback.""" + if not self._hover_candidates: + self._clear_hover_highlight(context) + return + + ifc_obj = self._hover_candidates[self._hover_index][0] + + # Only update selection when the highlighted object changes. + if ifc_obj != self._hover_highlighted_obj: + if self._hover_highlighted_obj: + try: + self._hover_highlighted_obj.select_set(False) + except Exception: + pass + self._hover_highlighted_obj = ifc_obj + try: + ifc_obj.select_set(True) + context.view_layer.objects.active = ifc_obj + except Exception: + pass + + entity = tool.Ifc.get_entity(ifc_obj) + label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name + n = len(self._hover_candidates) + cycle_hint = f" | TAB: cycle ({self._hover_index + 1}/{n})" if n > 1 else "" + context.workspace.status_text_set( + f"Vertex {self._active_vertex_idx} — {ifc_obj.name}{cycle_hint}" + " | Click: anchor | ALT+Click: free point | RMB/ESC: Finish" + ) + + def _clear_hover_highlight(self, context): + """Deselect the highlighted object and restore the annotation as active.""" + if self._hover_highlighted_obj: + try: + self._hover_highlighted_obj.select_set(False) + except Exception: + pass + self._hover_highlighted_obj = None + try: + self._annotation_obj.select_set(True) + context.view_layer.objects.active = self._annotation_obj + except Exception: + pass # ------------------------------------------------------------------ # Pset write (shared by both face and free-point paths) @@ -5993,7 +6353,7 @@ class SetDimensionAnchor(bpy.types.Operator): file = tool.Ifc.get() annotation = self._annotation - pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") if pset_data and pset_data.get("Anchors"): try: @@ -6023,19 +6383,46 @@ class SetDimensionAnchor(bpy.types.Operator): 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") + ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") pset_entity = file.by_id(pset_data["id"]) ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) from bonsai.bim.module.drawing import handler as _drawing_handler _drawing_handler.invalidate_dim_index() + # Move the Blender curve vertex to the newly resolved anchor position. + # Build placement_override from current Blender matrix_world so that + # elements whose IFC ObjectPlacement hasn't been synced yet resolve correctly. + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + import ifcopenshell.api.drawing as drawing_api + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + shape_cache=getattr(self, "_shape_cache", None), + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + print(f"[write_anchor] resolved_pts={[(round(p[0],4),round(p[1],4),round(p[2],4)) for p in resolved_pts]}") + 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 + For every IfcAnnotation that has a BBIM_Dimension pset, resolve all anchor references from live element geometry and update the annotation's curve vertices and linked IfcMetric values. """ @@ -6084,12 +6471,12 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): else: candidates = [ a for a in file.by_type("IfcAnnotation") - if ifcopenshell.util.element.get_pset(a, "BBIM_DimensionTarget") + if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension") ] updated = 0 for annotation in candidates: - pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_DimensionTarget") + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") if not pset: continue diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index bc10632a19..1bc86df319 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -1051,6 +1051,11 @@ class BIMAnnotationProperties(PropertyGroup): ) is_adding_type: bpy.props.BoolProperty(default=False) type_name: bpy.props.StringProperty(name="Name", default="TYPEX") + force_perpendicular_to_face: bpy.props.BoolProperty( + name="Force ⊥ to Face", + description="Constrain subsequent dimension vertices to lie on the line through the first vertex along its face normal", + default=False, + ) tag_rotation_mode: bpy.props.EnumProperty( name="Tag Rotation Mode", description="How to orient the tag relative to the tagged object", diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 7ec6b08273..ee7161645e 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -251,6 +251,11 @@ class AnnotationToolUI: add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation") + _DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"} + if object_type in _DIMENSION_TYPES: + row = cls.layout.row(align=True) + row.prop(cls.props, "force_perpendicular_to_face", toggle=True) + if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP: row = cls.layout.row(align=True) row.label(text="", icon="DRIVER_ROTATIONAL_DIFFERENCE") diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index f820038807..0b431c81e7 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -88,6 +88,51 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator): props.active_pset_type = "-" +def _regenerate_parametric_dimension(file, annotation): + """Regenerate a single parametric dimension annotation after a pset edit.""" + print(f"[regen_dim] called for annotation={annotation.id()} {annotation.is_a()}") + try: + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as _tool + from bonsai.bim.module.drawing.operator import _update_blender_curve + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + print(f"[regen_dim] pset_data keys={list(pset_data.keys()) if pset_data else None}") + if not pset_data or not pset_data.get("Anchors"): + print("[regen_dim] no Anchors — skipping") + return + + anchors = json.loads(pset_data["Anchors"]) + print(f"[regen_dim] {len(anchors)} anchors") + placement_override = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = _tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + print(f"[regen_dim] placement_override added for {elem.is_a()} id={elem.id()}") + except Exception as e: + print(f"[regen_dim] placement_override error: {e}") + + resolved_pts = drawing_api.regenerate_dimension( + file, annotation, placement_override=placement_override + ) + print(f"[regen_dim] resolved_pts={resolved_pts}") + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + print("[regen_dim] _update_blender_curve done") + except Exception: + import traceback + traceback.print_exc() + + class EditPset(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_pset" bl_label = "Edit Pset" @@ -152,7 +197,15 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): ) if tool.Cost.has_schedules(): tool.Cost.update_cost_items(pset=pset) + print(f"[edit_pset] pset_name='{props.active_pset_name}' element={element.is_a()} before disable_pset_editing") + is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation") + bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type) + + print(f"[edit_pset] pset_name after disable='{props.active_pset_name}' is_bbim_dimension={is_bbim_dimension}") + if is_bbim_dimension: + _regenerate_parametric_dimension(self.file, element) + tool.Blender.update_viewport() diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py index ff74725f52..ae997671a1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -16,10 +16,10 @@ # 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. +"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors. This module operates purely on IFC data. It: - 1. Reads the ``Anchors`` JSON array from the ``BBIM_DimensionTarget`` pset on an + 1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an ``IfcAnnotation``. 2. Resolves each anchor to a world-space point (IFC project units) using ``resolve_anchor``. @@ -48,7 +48,7 @@ import ifcopenshell.util.element from .resolve_anchor import resolve_anchor -_PSET_NAME = "BBIM_DimensionTarget" +_PSET_NAME = "BBIM_Dimension" _METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_" @@ -61,12 +61,12 @@ def regenerate_dimension( ) -> list[tuple[float, float, float]]: """Regenerate a parametric dimension from its stored anchor references. - Resolves every anchor in ``BBIM_DimensionTarget.Anchors``, updates the + Resolves every anchor in ``BBIM_Dimension.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 annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` 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 @@ -99,6 +99,25 @@ def regenerate_dimension( resolved.append(pt) anchor["pt"] = list(pt) + # ForcePerpendicularToFace: project vertices 1…n onto the line through + # pt[0] in the direction of anchor[0]'s face normal, so the polyline is + # constrained perpendicular to the face the first vertex is anchored to. + if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None: + normal = _get_anchor_face_normal_world(file, anchors[0], placement_override) + if normal: + base = resolved[0] + for i in range(1, len(resolved)): + if resolved[i] is None: + continue + pt = resolved[i] + t = ((pt[0] - base[0]) * normal[0] + + (pt[1] - base[1]) * normal[1] + + (pt[2] - base[2]) * normal[2]) + resolved[i] = (base[0] + t * normal[0], + base[1] + t * normal[1], + base[2] + t * normal[2]) + anchors[i]["pt"] = list(resolved[i]) + pset_entity_id = pset_data.get("id") if pset_entity_id: pset_entity = file.by_id(pset_entity_id) @@ -122,7 +141,7 @@ def get_dimension_segment_lengths( ) -> 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`` + Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors`` (in metres, matching ifcopenshell.geom output). Returns an empty list if the pset is absent or malformed. """ @@ -236,3 +255,39 @@ def _sync_segment_metrics( 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) + + +def _get_anchor_face_normal_world( + file: ifcopenshell.file, + anchor: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Return the world-space unit face normal stored in a FACE anchor, or None. + + Prefers ``normal_local`` (element-local, rotation-invariant) transformed by + the current element placement. Falls back to the stored world-space normal. + """ + if anchor.get("type") != "FACE": + return None + guid = anchor.get("guid") + if not guid: + return None + fp = (anchor.get("addr") or {}).get("fingerprint") or {} + + normal_local = fp.get("normal_local") + if normal_local: + try: + element = file.by_guid(guid) + except Exception: + return None + from .resolve_anchor import _rotate_local_to_world + n = _rotate_local_to_world(element, normal_local, placement_override) + mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) + return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None + + normal_world = fp.get("normal") + if normal_world: + mag = math.sqrt(sum(x * x for x in normal_world)) + return tuple(x / mag for x in normal_world) if mag > 1e-12 else None # type: ignore[return-value] + + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py index 260183b6e2..36b3565df0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py @@ -26,7 +26,7 @@ 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): +Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors): { "guid": str | None, # element GlobalId; None → WORLD type (free point) @@ -135,15 +135,28 @@ def resolve_anchor( 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: + fp_normal_local = fingerprint.get("normal_local") if fingerprint else None + + # TESS_INDEX fast path — only accept when the local fingerprint normal still + # matches at that index, guarding against face-group reordering after any + # geometry edit or profile change. + tess_index = addr.get("tess_index", -1) + if 0 <= tess_index < len(groups): + candidate_local = group_props[tess_index] + if fp_normal_local is None or _dot(candidate_local["normal"], fp_normal_local) >= 1.0 - _NORMAL_MATCH_THRESHOLD: + return world_group_props[tess_index]["centroid"] + # Local-normal mismatch — face groups reordered; fall through to fingerprint. + + # TESS_FINGERPRINT — match by element-local normal (rotation-invariant). + if fp_normal_local: + pt = _find_by_local_normal(group_props, world_group_props, fp_normal_local, hint) + if pt is not None: + return pt + elif fingerprint: + # Legacy anchors built before normal_local was stored: fall back to + # world-space normal matching (not rotation-invariant, but best we can do). pt = _find_by_fingerprint(world_group_props, fingerprint, hint) if pt is not None: return pt @@ -174,7 +187,7 @@ def build_anchor_from_hit( :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. + :return: Anchor dict ready for JSON serialisation into BBIM_Dimension. """ shape = _get_shape(file, element, settings, shape_cache) @@ -201,12 +214,17 @@ def build_anchor_from_hit( if best is not None: tess_index, props = best fingerprint = { + # normal_local: element-local normal — rotation-invariant primary key. + "normal_local": list(local_group_props[tess_index]["normal"]), + # world-space fields kept for legacy / disambiguation. "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) + repr_type, repr_id, face_role = _detect_extruded_face( + file, element, hit_location_ifc, hit_normal_ifc, placement_override + ) method = "ANALYTIC" if repr_type == "IfcExtrudedAreaSolid" else "TESS_FINGERPRINT" return { @@ -322,6 +340,35 @@ def _rotate_local_to_world( ) +def _world_normal_to_elem_local( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + world_normal: tuple, + placement_override: Optional[dict] = None, +) -> tuple[float, float, float]: + """Rotate a world-space direction into element-local space (rotation only, no translation). + + Uses placement_override (Blender matrix_world) when available so that + elements moved/rotated in the viewport are handled correctly. + """ + x, y, z = float(world_normal[0]), float(world_normal[1]), float(world_normal[2]) + if placement_override is not None and element.id() in placement_override: + m = placement_override[element.id()] + # Inverse rotation = transpose of the 3×3 rotation block. + lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z + ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z + lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z + else: + m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + lx = float(m[0][0]) * x + float(m[1][0]) * y + float(m[2][0]) * z + ly = float(m[0][1]) * x + float(m[1][1]) * y + float(m[2][1]) * z + lz = float(m[0][2]) * x + float(m[1][2]) * y + float(m[2][2]) * z + mag = math.sqrt(lx * lx + ly * ly + lz * lz) + if mag > 1e-12: + return (lx / mag, ly / mag, lz / mag) + return (x, y, z) + + def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]: """Return (verts, tris) from a tessellated shape.""" vf = shape.geometry.verts @@ -511,8 +558,36 @@ def _best_group( return best +def _find_by_local_normal( + local_group_props: list[dict], + world_group_props: list[dict], + fp_normal_local: list, + hint: Optional[list], +) -> Optional[tuple[float, float, float]]: + """Return the world-space centroid of the face group whose element-local normal + best matches *fp_normal_local*. Matching in local space is rotation-invariant — + moving or rotating the element does not change local normals, so the anchor + correctly tracks the same face through placement changes and profile edits.""" + best_score = -1.0 + best_centroid = None + + for i, lp in enumerate(local_group_props): + dot_val = _dot(lp["normal"], fp_normal_local) + if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD: + continue + score = dot_val + if hint: + hint_dist = _dist(world_group_props[i]["centroid"], hint) + score -= hint_dist / max(_CENTROID_MAX_DIST, 0.001) * 0.1 + if score > best_score: + best_score = score + best_centroid = world_group_props[i]["centroid"] + + return best_centroid + + # --------------------------------------------------------------------------- -# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM +# Analytical resolution — IfcExtrudedAreaSolid TOP / BOTTOM / SIDE_* # --------------------------------------------------------------------------- @@ -522,9 +597,16 @@ def _resolve_extruded_area_solid_analytic( addr: dict, placement_override: Optional[dict] = None, ) -> Optional[tuple[float, float, float]]: - """Analytically resolve TOP or BOTTOM face centre of an IfcExtrudedAreaSolid.""" + """Analytically resolve a face centre of an IfcExtrudedAreaSolid. + + Handles TOP, BOTTOM, and SIDE_PLUS_X / SIDE_MINUS_X / SIDE_PLUS_Y / SIDE_MINUS_Y + roles. Side-face roles are only supported for IfcRectangleProfileDef; other + profile types fall back to tessellation fingerprint matching. + """ face_role = addr.get("face_role", "") - if face_role not in ("TOP", "BOTTOM"): + _top_bottom = ("TOP", "BOTTOM") + _sides = ("SIDE_PLUS_X", "SIDE_MINUS_X", "SIDE_PLUS_Y", "SIDE_MINUS_Y") + if face_role not in _top_bottom + _sides: return None repr_id = addr.get("repr_id") @@ -540,18 +622,64 @@ def _resolve_extruded_area_solid_analytic( return None try: - profile_centroid_local = _profile_centroid(solid.SweptArea) + profile = solid.SweptArea dir_ratios = solid.ExtrudedDirection.DirectionRatios - depth = solid.Depth + depth = float(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 face_role in _top_bottom: + profile_centroid_local = _profile_centroid(profile) + scale = depth if face_role == "TOP" else 0.0 + px = profile_centroid_local[0] + dir_vec[0] * scale + py = profile_centroid_local[1] + dir_vec[1] * scale + pz = dir_vec[2] * scale + + else: # SIDE_* — only for IfcRectangleProfileDef + if not profile.is_a("IfcRectangleProfileDef"): + return None + + x_dim = float(profile.XDim) + y_dim = float(profile.YDim) + half_depth = depth / 2.0 + + # Profile centre and local axes (from profile.Position 2D placement). + cx, cy = 0.0, 0.0 + px_axis = (1.0, 0.0) # profile X in profile 2D + if hasattr(profile, "Position") and profile.Position: + loc = profile.Position.Location + cx = float(loc.Coordinates[0]) + cy = float(loc.Coordinates[1]) + if profile.Position.RefDirection: + pr = profile.Position.RefDirection.DirectionRatios + pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2) + if pm > 1e-12: + px_axis = (pr[0] / pm, pr[1] / pm) + py_axis = (-px_axis[1], px_axis[0]) # 90° rotation + + half_x = x_dim / 2.0 + half_y = y_dim / 2.0 + + if face_role == "SIDE_PLUS_X": + fx = cx + half_x * px_axis[0] + fy = cy + half_x * px_axis[1] + elif face_role == "SIDE_MINUS_X": + fx = cx - half_x * px_axis[0] + fy = cy - half_x * px_axis[1] + elif face_role == "SIDE_PLUS_Y": + fx = cx + half_y * py_axis[0] + fy = cy + half_y * py_axis[1] + else: # SIDE_MINUS_Y + fx = cx - half_y * py_axis[0] + fy = cy - half_y * py_axis[1] + + # Lift from profile 2D to solid-local 3D at mid-extrusion depth. + px = fx + dir_vec[0] * half_depth + py = fy + dir_vec[1] * half_depth + pz = dir_vec[2] * half_depth if solid.Position: local_pt = _apply_axis2placement3d(solid.Position, (px, py, pz)) @@ -638,21 +766,27 @@ def _detect_extruded_face( element: ifcopenshell.entity_instance, hit_location: tuple, hit_normal: tuple, + placement_override: Optional[dict] = None, ) -> tuple[str, int, str]: - """Try to identify if the hit face is a TOP or BOTTOM of an IfcExtrudedAreaSolid. + """Identify if the hit face is a face of an IfcExtrudedAreaSolid. Returns (repr_type, repr_id, face_role). - repr_type is empty string if not detected as extruded solid. + face_role is one of: 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X', + 'SIDE_PLUS_Y', 'SIDE_MINUS_Y', or '' (not recognized). + Side roles are only returned for IfcRectangleProfileDef. """ if not hasattr(element, "Representation") or not element.Representation: return ("", -1, "") + # Transform hit_normal from world → element-local for accurate role classification. + hit_normal_elem = _world_normal_to_elem_local(file, element, hit_normal, placement_override) + 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) + role = _extruded_face_role(solid, hit_normal_elem) if role: return ("IfcExtrudedAreaSolid", solid.id(), role) @@ -667,19 +801,99 @@ def _unwrap_mapped(item): return item -def _extruded_face_role(solid, hit_normal: tuple) -> str: - """Return 'TOP', 'BOTTOM', or '' based on whether hit_normal aligns with extrusion.""" +def _apply_axis2placement3d_rotation_inv(placement, vec: tuple) -> tuple[float, float, float]: + """Apply the inverse rotation of an IfcAxis2Placement3D to a direction. + + Transforms a direction from element-local space into solid-local space. + The rotation matrix R = [x_axis | y_axis | z_axis]; its inverse for an + orthogonal matrix is R^T, computed here by dotting with each basis vector. + """ + if placement is None: + return vec + + x, y, z = float(vec[0]), float(vec[1]), float(vec[2]) + + if placement.Axis: + zr = placement.Axis.DirectionRatios + zm = math.sqrt(zr[0] ** 2 + zr[1] ** 2 + zr[2] ** 2) + zx, zy, zz = (zr[0] / zm, zr[1] / zm, zr[2] / zm) if zm > 1e-12 else (0.0, 0.0, 1.0) + else: + zx, zy, zz = 0.0, 0.0, 1.0 + + if placement.RefDirection: + xr = placement.RefDirection.DirectionRatios + xm = math.sqrt(xr[0] ** 2 + xr[1] ** 2 + xr[2] ** 2) + xx, xy, xz = (xr[0] / xm, xr[1] / xm, xr[2] / xm) if xm > 1e-12 else (1.0, 0.0, 0.0) + else: + xx, xy, xz = 1.0, 0.0, 0.0 + + # Y = Z × X + yx = zy * xz - zz * xy + yy = zz * xx - zx * xz + yz = zx * xy - zy * xx + + # R^T: dot input with each column of R (= each basis axis of the placement). + inv_x = xx * x + xy * y + xz * z + inv_y = yx * x + yy * y + yz * z + inv_z = zx * x + zy * y + zz * z + + mag = math.sqrt(inv_x ** 2 + inv_y ** 2 + inv_z ** 2) + if mag > 1e-12: + return (inv_x / mag, inv_y / mag, inv_z / mag) + return vec + + +def _extruded_face_role(solid, hit_normal_elem_local: tuple) -> str: + """Classify the hit face role on an IfcExtrudedAreaSolid. + + Returns 'TOP', 'BOTTOM', 'SIDE_PLUS_X', 'SIDE_MINUS_X', 'SIDE_PLUS_Y', + 'SIDE_MINUS_Y', or ''. Side roles require IfcRectangleProfileDef. + + :param hit_normal_elem_local: Face normal in element-local space. + """ try: + # Map from element-local to solid-local via solid.Position inverse rotation. + hit_normal_solid = _apply_axis2placement3d_rotation_inv(solid.Position, hit_normal_elem_local) + 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: + + dot_extrude = _dot(extrude_dir, hit_normal_solid) + if dot_extrude > 0.99: return "TOP" - if dot_val < -0.99: + if dot_extrude < -0.99: return "BOTTOM" + + # Side face detection — only supported for IfcRectangleProfileDef. + if not solid.SweptArea.is_a("IfcRectangleProfileDef"): + return "" + + profile = solid.SweptArea + + # Profile X axis in solid-local 2D (from profile.Position.RefDirection). + px_axis = (1.0, 0.0) + if hasattr(profile, "Position") and profile.Position and profile.Position.RefDirection: + pr = profile.Position.RefDirection.DirectionRatios + pm = math.sqrt(pr[0] ** 2 + pr[1] ** 2) + if pm > 1e-12: + px_axis = (pr[0] / pm, pr[1] / pm) + py_axis = (-px_axis[1], px_axis[0]) # 90° CCW + + # Lift 2D profile axes to solid-local 3D (profile is in the solid XY plane). + px_3d = (px_axis[0], px_axis[1], 0.0) + py_3d = (py_axis[0], py_axis[1], 0.0) + + dot_x = _dot(hit_normal_solid, px_3d) + dot_y = _dot(hit_normal_solid, py_3d) + + if abs(dot_x) > 0.99: + return "SIDE_PLUS_X" if dot_x > 0 else "SIDE_MINUS_X" + if abs(dot_y) > 0.99: + return "SIDE_PLUS_Y" if dot_y > 0 else "SIDE_MINUS_Y" + except Exception: pass return ""