From 288b7165740c89af5eb7b6de79a36ee9327b8edb Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 15 May 2026 20:49:10 -0500 Subject: [PATCH] Add BBIM_DimensionTarget: parametric dimensions anchored to element geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modal operator (bim.set_dimension_anchor) anchors dimension vertices to IFC element faces. Anchors are stored as JSON in a BBIM_DimensionTarget pset on the IfcAnnotation and resolved via tessellation at regeneration time. - resolve_anchor.py / regenerate_dimension.py: new ifcopenshell API modules - bim.set_dimension_anchor: 2-phase Object Mode modal (pick vertex → pick face) - bim.regenerate_dimensions: recomputes all parametric dimensions - Auto-regeneration via depsgraph_update_post when referenced elements move - placement_override reads Blender matrix_world for G-moved elements - Plan-view annotations flattened to annotation plane (Z=0 in local space) - IfcIndexedPolyCurve.Segments rebuilt to handle n-point chains correctly --- src/bonsai/bonsai/bim/module/drawing/ui.py | 13 +++++++ .../ifcopenshell/api/drawing/__init__.py | 3 +- .../api/drawing/regenerate_dimension.py | 39 ++++++++++++------- .../api/drawing/resolve_anchor.py | 39 +++++++++++++++++++ 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 3e7f386ea4..22a111cdc0 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): diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py index d24e45cbea..a654f7c186 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py @@ -26,7 +26,7 @@ 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, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor +from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_local_point, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor from .unassign_product import unassign_product wrap_usecases(__path__, __name__) @@ -35,6 +35,7 @@ __all__ = [ "assign_product", "build_anchor_from_hit", "build_anchor_from_layer_boundary", + "build_anchor_from_local_point", "build_anchor_from_profile_edge", "build_anchor_from_profile_vert", "edit_text_literal", diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py index 313e430a5c..6cd34100fa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -58,6 +58,7 @@ def regenerate_dimension( settings: Optional[ifcopenshell.geom.settings] = None, shape_cache: Optional[dict] = None, placement_override: Optional[dict] = None, + camera_dir: Optional[tuple[float, float, float]] = None, ) -> list[tuple[float, float, float]]: """Regenerate a parametric dimension from its stored anchor references. @@ -136,11 +137,10 @@ def regenerate_dimension( # horizontal offset axis (perpendicular to the dimension direction). Applied after # the pset write so anchor["pt"] always stores the true geometry surface hit. # Because it is absolute, the dimension line stays put even if the geometry moves. - # Only active when ForcePerpendicularToFace is also set — the two are semantically coupled. line_position = pset_data.get("LinePosition") - if line_position is not None and pset_data.get("ForcePerpendicularToFace") and resolved: + if line_position is not None and resolved: face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override) - offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None]) + offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None], camera_dir) if offset_dir: resolved = [ _project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None @@ -339,36 +339,47 @@ def _get_anchor_face_normal_world( def _get_line_offset_direction( face_normal: Optional[tuple[float, float, float]], resolved_pts: list[tuple], + camera_dir: Optional[tuple[float, float, float]] = None, ) -> Optional[tuple[float, float, float]]: - """Return the direction to apply LineOffset — parallel to the first face. + """Return the direction to slide the dimension line (perpendicular to it, in-view). - Uses cross(world_Z, dim_direction) to get the horizontal direction - perpendicular to the dimension line, which slides the line sideways - (parallel to the face) rather than into/out of it. + For plan views (camera mostly vertical) uses cross(world_Z, dim_dir) — + unchanged from the original behaviour, so existing stored LinePosition + values continue to work. + + For section/elevation views (camera mostly horizontal) uses + cross(camera_dir, dim_dir) so the offset lies in the camera's view plane. + This makes dragging the gizmo move the line visually up/down (or + left/right) rather than in/out of the screen. Falls back to cross(face_normal, world_Z) when the dimension line is - nearly vertical (e.g. elevation dimensions). + nearly parallel to the reference vector (e.g. vertical elevation dims). """ world_z = (0.0, 0.0, 1.0) - # Primary: use the dimension line direction (anchor[0] → anchor[1]) + # In section/elevation (camera mostly horizontal) use camera_dir as the + # reference so the offset axis lies in the view plane. + cam_is_plan = camera_dir is None or abs(camera_dir[2]) > 0.7 + ref = world_z if cam_is_plan else camera_dir + + # Primary: cross(ref, dim_dir) if len(resolved_pts) >= 2: a, b = resolved_pts[0], resolved_pts[1] dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2] dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz) if dim_mag > 1e-10: dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag) - # cross(world_Z, dim_dir) — horizontal direction perp to dimension d = ( - world_z[1] * dim_dir[2] - world_z[2] * dim_dir[1], - world_z[2] * dim_dir[0] - world_z[0] * dim_dir[2], - world_z[0] * dim_dir[1] - world_z[1] * dim_dir[0], + ref[1] * dim_dir[2] - ref[2] * dim_dir[1], + ref[2] * dim_dir[0] - ref[0] * dim_dir[2], + ref[0] * dim_dir[1] - ref[1] * dim_dir[0], ) mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) if mag > 1e-6: return (d[0] / mag, d[1] / mag, d[2] / mag) - # Fallback for vertical dims: cross(face_normal, world_Z) + # Fallback for dims parallel to ref (e.g. vertical dims in plan): + # cross(face_normal, world_Z) if face_normal: n = face_normal d = ( diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py index 25bcec5b65..ea84c98d9e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py @@ -131,6 +131,11 @@ def resolve_anchor( pt = _resolve_profile_vert_anchor(file, element, addr, placement_override) elif method == "PROFILE_EDGE": pt = _resolve_profile_edge_anchor(file, element, addr, placement_override) + elif method == "LOCAL_POINT": + lx = addr.get("local_x_m") + ly = addr.get("local_y_m") + lz = addr.get("local_z_m") + pt = _local_to_world_m(file, element, (lx, ly, lz), placement_override) if None not in (lx, ly, lz) else None else: pt = None return pt if pt is not None else _pt_or_none(anchor.get("pt")) @@ -236,6 +241,40 @@ def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict: } +def build_anchor_from_local_point( + element: ifcopenshell.entity_instance, + snap_kind: str, + world_pos: tuple, + local_pos_m: tuple, +) -> dict: + """Build a VERTEX/EDGE anchor using element-local coordinates (metres). + + Used as a fallback for tessellated elements (IfcFacetedBrep, etc.) that have + no IfcExtrudedAreaSolid. The stored ``local_m`` is resolved back to world + space via ``_local_to_world_m`` so the anchor follows the element through + moves and rotations. + + :param element: The IFC element the snap landed on. + :param snap_kind: ``"VERTEX"`` or ``"EDGE"``. + :param world_pos: Current world-space snap position in metres (stored as hint/pt). + :param local_pos_m: Element-local position in metres (rotation-invariant). + :return: Anchor dict ready for JSON serialisation. + """ + return { + "guid": element.GlobalId, + "type": snap_kind, + "addr": { + "method": "LOCAL_POINT", + "local_x_m": float(local_pos_m[0]), + "local_y_m": float(local_pos_m[1]), + "local_z_m": float(local_pos_m[2]), + "snap": snap_kind, + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + def build_anchor_from_profile_vert( file: ifcopenshell.file, element: ifcopenshell.entity_instance,