From 3faa192db8c51caec9b39356fcc2f5dc04107558 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 27 Jun 2026 11:47:10 -0500 Subject: [PATCH] Add interactive PLAN_LEVEL / SECTION_LEVEL elevation annotation placement - AddElevationAnnotation: new modal operator (inherits SetDimensionAnchor) that creates the annotation only after the user clicks a face/layer/edge/vertex, placing it at the picked world position with elevation tracked via BBIM_Dimension - hotkey_S_A routes PLAN_LEVEL/SECTION_LEVEL to AddElevationAnnotation instead of the static AddAnnotation - _annotation_is_2d: check predefined type first so PLAN_LEVEL/SECTION_LEVEL always use object-placement Z (not spline-point Z) - _update_elevation_marker_z: fixed early-exit guard that skipped 2D placement update when splines were absent; now moves the object via geometry.edit_object_placement - SetDimensionAnchor._handle_face_pick: for elevation types, relocate the whole annotation (XY and Z) to the new face hit, not just Z - _zero_elevation_annotation_spline_z: flatten local spline-point Z to 0 so the visible line and the gizmo sit at the same world elevation - DimensionAnchorWidget: position gizmo at object origin for elevation annotations (anchor reference point) instead of spline.points[0] - ClickNearestDimensionAnchor: hit-test at object origin for elevation annotations so clicking the dot correctly turns it blue - SECTION_LEVEL default curve is now horizontal (camera X) instead of vertical - Remove 'Edit Elevation Anchor' button; replaced by viewport green-dot workflow - Add 'Bake to Static' and 'Make Parametric' buttons for elevation annotation types - MakeDimensionParametric: extended to handle elevation types (single world-point anchor at object origin rather than one anchor per spline vertex) Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/gizmos.py | 15 +- .../bonsai/bim/module/drawing/handler.py | 31 +- .../bonsai/bim/module/drawing/operator.py | 533 +++++++++++++++--- src/bonsai/bonsai/bim/module/drawing/prop.py | 4 +- .../bonsai/bim/module/drawing/workspace.py | 23 +- src/bonsai/bonsai/tool/drawing.py | 11 + 7 files changed, 533 insertions(+), 85 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index d980383b8c..c3f4a21aa1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -32,6 +32,7 @@ classes = ( operator.ActivateModel, operator.AddAnnotation, operator.AddAnnotationType, + operator.AddElevationAnnotation, operator.AddDrawing, operator.AddDrawingStyle, operator.AddDrawingToSheet, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index d80afdb8b8..1bf5733187 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -2744,10 +2744,19 @@ class DimensionAnchorWidget(types.GizmoGroup): spline = obj.data.splines[0] n = min(len(spline.points), len(anchors), self._MAX_ANCHORS) + import ifcopenshell.util.element as _ue_gz + _ptype_gz = _ue_gz.get_predefined_type(element) + _is_elevation_gz = _ptype_gz in ("SECTION_LEVEL", "PLAN_LEVEL") + for i in range(n): gz = self._handles[i] - raw_co = spline.points[i].co - world_co = obj.matrix_world @ raw_co.to_3d() + if _is_elevation_gz: + # The object origin IS the anchor reference point (placed at face hit). + # Spline vertices are offset from the origin and should not be used. + world_co = obj.matrix_world.translation.copy() + else: + raw_co = spline.points[i].co + world_co = obj.matrix_world @ raw_co.to_3d() gz.matrix_basis = Matrix.Translation(world_co) gz.anchor_index = i if i == _active_anchor_idx and obj is _editing_annotation_obj: @@ -2787,7 +2796,7 @@ class DimensionLinePositionWidget(types.GizmoGroup): bl_region_type = "WINDOW" bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} - _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) @classmethod def poll(cls, context: bpy.types.Context) -> bool: diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py index bc47e169d9..793b16be97 100644 --- a/src/bonsai/bonsai/bim/module/drawing/handler.py +++ b/src/bonsai/bonsai/bim/module/drawing/handler.py @@ -293,7 +293,7 @@ def depsgraph_update_post_handler(scene, depsgraph): if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"): import ifcopenshell.util.element as _ue ptype = _ue.get_predefined_type(element) - if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"): + if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"): changed = _sync_dimension_anchors_to_curve(file, element, obj) if changed: edited_annotation_ids.add(element.id()) @@ -313,7 +313,7 @@ def depsgraph_update_post_handler(scene, depsgraph): import ifcopenshell.api.drawing as drawing_api import ifcopenshell.geom - from bonsai.bim.module.drawing.operator import _update_blender_curve + from bonsai.bim.module.drawing.operator import _update_blender_curve, _update_elevation_marker_z geom_settings = ifcopenshell.geom.settings() geom_settings.set("APPLY_DEFAULT_MATERIALS", False) @@ -350,14 +350,23 @@ def depsgraph_update_post_handler(scene, depsgraph): 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) + ptype = ifcopenshell.util.element.get_predefined_type(annotation) + if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"): + _update_elevation_marker_z( + file, annotation, + settings=geom_settings, + shape_cache=_dim_shape_cache, + placement_override=placement_override, + ) + else: + 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 a54aa4d3b8..728b9ed7a3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1904,6 +1904,10 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name) +# AddElevationAnnotation is defined after SetDimensionAnchor (below) because it +# inherits from it. This placeholder keeps the module namespace tidy. + + class AddSheet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_sheet" bl_label = "Add Sheet" @@ -6628,7 +6632,7 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): 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.") + cls.poll_message_set("Annotation must be a dimension or elevation type.") return False return True @@ -6908,6 +6912,25 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): return _do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache) + + # For elevation annotations the whole object should jump to the face hit + # (XY and Z), not just update Z via the object-placement path in + # _update_elevation_marker_z. + ptype = ifcopenshell.util.element.get_predefined_type(self._annotation) + if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"): + from mathutils import Vector as _mVector + hit_pt = anchor.get("pt") + target_z = float(hit_pt[2]) if hit_pt else float(location.z) + hit_world = _mVector((float(location.x), float(location.y), target_z)) + m = self._annotation_obj.matrix_world.copy() + m.translation = hit_world + ifcopenshell.api.run("geometry.edit_object_placement", file, product=self._annotation, matrix=np.array(m)) + if self._annotation_obj.parent: + self._annotation_obj.location = self._annotation_obj.parent.matrix_world.inverted() @ hit_world + else: + self._annotation_obj.location = hit_world + _zero_elevation_annotation_spline_z(self._annotation, self._annotation_obj) + self.report( {"INFO"}, f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]", @@ -7415,30 +7438,37 @@ class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None: """Write one anchor into the BBIM_Dimension pset and regenerate the curve.""" file = tool.Ifc.get() + ptype = ifcopenshell.util.element.get_predefined_type(annotation) + _is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL") pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") - if pset_data and pset_data.get("Anchors"): - try: - anchors: list = json.loads(pset_data["Anchors"]) - except Exception: - anchors = [] + if _is_elevation: + # Elevation markers use exactly one anchor (the Z reference point). + anchors: list = [new_anchor] else: - anchors = _anchors_from_spline(annotation_obj, file) + if pset_data and pset_data.get("Anchors"): + try: + anchors = json.loads(pset_data["Anchors"]) + except Exception: + anchors = [] + else: + anchors = _anchors_from_spline(annotation_obj, file) - while len(anchors) <= vertex_index: - idx = len(anchors) - if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines: - pts = annotation_obj.data.splines[0].points - if idx < len(pts): - co = annotation_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])) + while len(anchors) <= vertex_index: + idx = len(anchors) + if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines: + pts = annotation_obj.data.splines[0].points + if idx < len(pts): + co = annotation_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[vertex_index] = new_anchor anchors_json = json.dumps(anchors) if pset_data: @@ -7466,18 +7496,227 @@ def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: except Exception: pass - import ifcopenshell.api.drawing as drawing_api - resolved_pts = drawing_api.regenerate_dimension( - file, - annotation, - shape_cache=shape_cache, - placement_override=placement_override, - ) - if resolved_pts: - _update_blender_curve(annotation, resolved_pts) + if _is_elevation: + _update_elevation_marker_z(file, annotation, shape_cache=shape_cache, placement_override=placement_override) + else: + import ifcopenshell.api.drawing as drawing_api + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + shape_cache=shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) +class AddElevationAnnotation(SetDimensionAnchor): + """Interactively place a SECTION_LEVEL or PLAN_LEVEL annotation. + + Runs the same face / layer / edge / vertex snap UI as SetDimensionAnchor but + creates the annotation only AFTER the user clicks, placing it directly at the + picked elevation. The anchor is stored in BBIM_Dimension so the annotation + tracks the element if it moves. + """ + + bl_idname = "bim.add_elevation_annotation" + bl_label = "Add Elevation Annotation" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + return False + if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera): + cls.poll_message_set("No active drawing.") + return False + if context.mode != "OBJECT": + cls.poll_message_set("Must be in Object Mode.") + return False + if not getattr(context, "space_data", None) or context.space_data.type != "VIEW_3D": + return False + ann_props = tool.Drawing.get_annotation_props() + return ann_props.object_type in ("SECTION_LEVEL", "PLAN_LEVEL") + + def _invoke(self, context, event): + props = tool.Drawing.get_annotation_props() + dprops = tool.Drawing.get_document_props() + self._create_drawing = dprops.get_active_drawing() + if not self._create_drawing: + self.report({"WARNING"}, "No active drawing.") + return {"CANCELLED"} + self._create_object_type = props.object_type + self._create_relating_type = ( + tool.Ifc.get().by_id(int(props.relating_type_id)) + if props.relating_type_id and props.relating_type_id != "0" + else None + ) + # No existing annotation — we create it on the click. + self._annotation = None + self._annotation_obj = None + self._shape_cache = {} + self._phase = "PICK_FACE" + self._active_vertex_idx = 0 + self._hover_candidates = [] + self._hover_index = 0 + self._hover_last_px = (-9999, -9999) + self._hover_highlighted_obj = None + self._snap_mode = "FACE" + _anchor_hover_draw_data.clear() + self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( + _draw_anchor_hover_global, (), "WINDOW", "POST_PIXEL" + ) + 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 + context.workspace.status_text_set( + f"Hover over an element | Click: place {self._create_object_type}" + " | TAB: cycle snap | RMB/ESC: Cancel" + ) + 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._cleanup(context) + return {"FINISHED"} + + if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE": + self._handle_hover(context, event) + return {"RUNNING_MODAL"} + + 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 == "RELEASE": + return {"RUNNING_MODAL"} + + if event.type == "LEFTMOUSE" and event.value == "PRESS": + wrote = self._handle_face_pick_create(context, event) + if wrote: + self._cleanup(context) + return {"FINISHED"} + return {"RUNNING_MODAL"} + + return {"PASS_THROUGH"} + + def _handle_face_pick_create(self, context, event): + """Pick a face, create the elevation annotation there, write the anchor.""" + region = self._region + rv3d = self._rv3d + if not region or not rv3d: + return False + + self._clear_hover_highlight(context) + + 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 self._hover_candidates: + self.report({"WARNING"}, "Nothing under cursor — click on a model element") + return False + + 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: + self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element") + return False + + file = tool.Ifc.get() + placement_override = {element.id(): np.array(hit_obj.matrix_world)} + snap = self._compute_snap_geom(hit_obj, face_index, coord) + snap_type = snap.get("type", "FACE") + + import ifcopenshell.api.drawing as drawing_api + try: + if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY": + anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap) + elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap) + elif snap_type == "EDGE" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap) + elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None: + sw = snap["snap_world"] + local_m = snap.get("local_m") + if local_m is not None: + anchor = drawing_api.build_anchor_from_local_point(element, snap_type, sw, local_m) + else: + anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])]) + else: + hit_m = (float(location.x), float(location.y), float(location.z)) + normal_m = (float(normal.x), float(normal.y), float(normal.z)) + 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 as _tb + _tb.print_exc() + self.report({"ERROR"}, f"build_anchor failed: {exc}") + return False + + obj = core.add_annotation( + tool.Ifc, tool.Collector, tool.Drawing, + drawing=self._create_drawing, + object_type=self._create_object_type, + relating_type=self._create_relating_type, + enable_editing=False, + ) + if not obj: + return False + + annotation = tool.Ifc.get_entity(obj) + self._annotation = annotation + self._annotation_obj = obj + _do_write_anchor(annotation, obj, anchor, 0, self._shape_cache) + + # Move annotation to the face hit position (XY and Z). + # obj.matrix_world may be stale (depsgraph not yet evaluated), so we + # override the translation column directly from the known hit point. + from mathutils import Vector as _mVector + hit_pt = anchor.get("pt") + target_z = float(hit_pt[2]) if hit_pt else float(location.z) + hit_world = _mVector((float(location.x), float(location.y), target_z)) + m = obj.matrix_world.copy() + m.translation = hit_world + ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m)) + if obj.parent: + obj.location = obj.parent.matrix_world.inverted() @ hit_world + else: + obj.location = hit_world + + # Spline points were built from camera-space coords and may have a large + # local Z offset. Zero it so the line sits at the same world elevation + # as the object origin (where the gizmo is drawn). + _zero_elevation_annotation_spline_z(annotation, obj) + + tool.Blender.select_and_activate_single_object(context, obj) + self.report( + {"INFO"}, + f"Vertex 0 → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]", + ) + return True + + def _execute(self, context): + pass # This operator is modal-only; always invoke with INVOKE_DEFAULT. + + class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): """Regenerate all parametric dimension annotations in the project. @@ -7541,10 +7780,13 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): if not pset: continue - # Sync anchor count to curve vertex count in case the user added or - # removed vertices in Edit Mode since the last regeneration. + ptype = ifcopenshell.util.element.get_predefined_type(annotation) + _is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL") + + # Sync anchor count to curve vertex count for true dimensions only. + # Elevation markers always have exactly one anchor regardless of vertex count. ann_obj = tool.Ifc.get_object(annotation) - if ann_obj and ann_obj.type == "CURVE": + if not _is_elevation and ann_obj and ann_obj.type == "CURVE": _sync_dimension_anchors_to_curve(file, annotation, ann_obj) # Build a placement override from each referenced element's current @@ -7572,19 +7814,27 @@ class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): 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 + if _is_elevation: + if _update_elevation_marker_z( + file, annotation, + settings=geom_settings, + shape_cache=shape_cache, + placement_override=placement_override, + ): + updated += 1 + else: + 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 - _update_blender_curve(annotation, resolved_pts) - updated += 1 - - self.report({"INFO"}, f"Regenerated {updated} parametric dimension(s).") + self.report({"INFO"}, f"Regenerated {updated} parametric annotation(s).") # --------------------------------------------------------------------------- @@ -7661,8 +7911,107 @@ def _update_blender_curve( pass +def _update_elevation_marker_z( + file: "ifcopenshell.file", + annotation: "ifcopenshell.entity_instance", + settings=None, + shape_cache: Optional[dict] = None, + placement_override: Optional[dict] = None, +) -> bool: + """For SECTION_LEVEL / PLAN_LEVEL: resolve anchor 0 elevation and slide all spline vertices to that Z. + + Unlike regenerate_dimension (which rebuilds the full polyline from N anchors), + this preserves every vertex's X/Y and only updates Z, keeping the annotation's + drawn shape intact while tracking the anchored element's elevation. + Returns True if the spline was updated. + """ + from ifcopenshell.api.drawing.resolve_anchor import resolve_anchor as _resolve_anchor + import ifcopenshell.api.pset as _pset_api + import json as _json + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data or not pset_data.get("Anchors"): + return False + + try: + anchors: list = _json.loads(pset_data["Anchors"]) + except Exception: + return False + + if not anchors: + return False + + pt = _resolve_anchor(file, anchors[0], settings, shape_cache or {}, placement_override) + if pt is None: + cached = anchors[0].get("pt") + pt = tuple(cached) if cached else None + if pt is None: + return False + + target_z = pt[2] + + # Persist the resolved pt so the index stays warm + anchors[0]["pt"] = list(pt) + pset_entity = file.by_id(pset_data["id"]) + _pset_api.edit_pset(file, pset=pset_entity, properties={"Anchors": _json.dumps(anchors)}) + + obj = tool.Ifc.get_object(annotation) + if not obj: + return False + + is_2d = _annotation_is_2d(annotation) + if is_2d: + # 2D annotations encode elevation in the object placement (world Z), not curve points. + from mathutils import Vector as _Vector + current_world = obj.matrix_world.to_translation() + new_world = _Vector((current_world.x, current_world.y, target_z)) + # Build updated world matrix (replace Z translation only, preserve rotation/scale) + m = obj.matrix_world.copy() + m[2][3] = target_z + ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m)) + # Update Blender visual position + if obj.parent: + obj.location = obj.parent.matrix_world.inverted() @ new_world + else: + obj.location = new_world + return True + + if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines: + return True + + from mathutils import Vector as _Vector + mx = obj.matrix_world + inv_mx = mx.inverted() + + for spline in obj.data.splines: + for spt in spline.points: + world_pos = mx @ spt.co.to_3d() + new_local = inv_mx @ _Vector((world_pos.x, world_pos.y, target_z)) + spt.co = (*new_local, 1.0) + + try: + world_pts = [] + for spline in obj.data.splines: + for spt in spline.points: + wp = mx @ spt.co.to_3d() + world_pts.append((wp.x, wp.y, wp.z)) + _update_ifc_polyline(file, annotation, obj, world_pts) + except Exception: + pass + + return True + + def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool: - """Return True if the annotation's representation uses 2D coordinates (plan view).""" + """Return True when elevation is encoded in the object placement (not spline Z values). + + PLAN_LEVEL and SECTION_LEVEL always encode elevation in object placement regardless + of whether the curve representation happens to use 2D or 3D coordinates. + For other annotation types we fall back to inspecting the geometry. + """ + ptype = ifcopenshell.util.element.get_predefined_type(annotation) + if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"): + return True if not getattr(annotation, "Representation", None): return False for rep in annotation.Representation.Representations: @@ -7741,6 +8090,37 @@ def _update_ifc_polyline( return +def _zero_elevation_annotation_spline_z(annotation: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Flatten all spline-point local Z to 0 for PLAN_LEVEL / SECTION_LEVEL annotations. + + For elevation annotations the elevation is encoded in the object placement (world Z). + Spline points are created from camera-space coords and may carry a large local Z + offset. Zeroing it ensures the visible line and the gizmo sit at the same elevation. + """ + if not obj.data or not hasattr(obj.data, "splines"): + return + changed = False + for spline in obj.data.splines: + for pt in spline.points: + if pt.co.z != 0.0: + pt.co = (pt.co.x, pt.co.y, 0.0, pt.co.w) + changed = True + if not changed or not getattr(annotation, "Representation", None): + return + 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 + if not pts_list.is_a("IfcCartesianPointList2D"): + pts_list.CoordList = tuple((c[0], c[1], 0.0) for c in pts_list.CoordList) + elif curve.is_a("IfcPolyline") and curve.Points: + for ifc_pt in curve.Points: + if len(ifc_pt.Coordinates) >= 3: + ifc_pt.Coordinates = (ifc_pt.Coordinates[0], ifc_pt.Coordinates[1], 0.0) + + 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: @@ -7828,17 +8208,30 @@ class ClickNearestDimensionAnchor(bpy.types.Operator): if not obj.data.splines: continue - for i, pt in enumerate(obj.data.splines[0].points): - world_pos = obj.matrix_world @ pt.co.to_3d() + ptype = _ue.get_predefined_type(element) + if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"): + # Gizmo is drawn at the object origin for elevation annotations. + world_pos = obj.matrix_world.translation.copy() sp = location_3d_to_region_2d(region, rv3d, world_pos) - if not sp: - continue - dx, dy = cx - sp.x, cy - sp.y - d2 = dx * dx + dy * dy - if d2 < r2 and d2 < best_dist_sq: - best_dist_sq = d2 - best_idx = i - best_obj = obj + if sp: + dx, dy = cx - sp.x, cy - sp.y + d2 = dx * dx + dy * dy + if d2 < r2 and d2 < best_dist_sq: + best_dist_sq = d2 + best_idx = 0 + best_obj = obj + else: + for i, pt in enumerate(obj.data.splines[0].points): + world_pos = obj.matrix_world @ pt.co.to_3d() + sp = location_3d_to_region_2d(region, rv3d, world_pos) + if not sp: + continue + dx, dy = cx - sp.x, cy - sp.y + d2 = dx * dx + dy * dy + if d2 < r2 and d2 < best_dist_sq: + best_dist_sq = d2 + best_idx = i + best_obj = obj if best_obj is None: return {"PASS_THROUGH"} @@ -7890,7 +8283,8 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Make Dimension Parametric" bl_options = {"REGISTER", "UNDO"} - _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) + _ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL")) @classmethod def poll(cls, context): @@ -7899,20 +8293,21 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator): return False obj = context.active_object if not obj or obj.type != "CURVE": - cls.poll_message_set("Active object must be a dimension curve.") + cls.poll_message_set("Active object must be an annotation curve.") 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 - if ifcopenshell.util.element.get_predefined_type(element) not in cls._DIM_TYPES: - cls.poll_message_set("Annotation must be a dimension type.") + ptype = ifcopenshell.util.element.get_predefined_type(element) + if ptype not in cls._DIM_TYPES and ptype not in cls._ELEVATION_TYPES: + cls.poll_message_set("Annotation must be a dimension or elevation type.") return False pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") if pset and pset.get("Anchors"): - cls.poll_message_set("Dimension is already parametric.") + cls.poll_message_set("Annotation is already parametric.") return False - if not obj.data.splines or len(obj.data.splines[0].points) < 2: + if ptype in cls._DIM_TYPES and (not obj.data.splines or len(obj.data.splines[0].points) < 2): cls.poll_message_set("Curve has fewer than 2 points.") return False return True @@ -7923,12 +8318,18 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object element = tool.Ifc.get_entity(obj) file = tool.Ifc.get() + ptype = ifcopenshell.util.element.get_predefined_type(element) - spline = obj.data.splines[0] - anchors = [ - drawing_api.make_world_anchor(list(obj.matrix_world @ pt.co.to_3d())) - for pt in spline.points - ] + if ptype in self._ELEVATION_TYPES: + # One free-world anchor at the object origin (the current elevation). + world_pos = list(obj.matrix_world.translation) + anchors = [drawing_api.make_world_anchor(world_pos)] + else: + spline = obj.data.splines[0] + anchors = [ + drawing_api.make_world_anchor(list(obj.matrix_world @ pt.co.to_3d())) + for pt in spline.points + ] pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") if not pset_data: @@ -7945,7 +8346,7 @@ class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator): area.tag_redraw() break - self.report({"INFO"}, f"'{obj.name}' is now parametric with {len(anchors)} free-end anchors.") + self.report({"INFO"}, f"'{obj.name}' is now parametric with {len(anchors)} free-end anchor(s).") class BakeParametricDimension(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index bae42f0e3a..f4e0edbd93 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -1052,7 +1052,7 @@ def _update_force_perpendicular(self, context): return new_value = self.force_perpendicular_to_face - _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) targets = [] for obj in context.selected_objects: @@ -1149,7 +1149,7 @@ def _set_line_position(self, value: float) -> None: if not file: return - _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) targets = [] import bpy as _bpy diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 78342e938a..3710dfacb9 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -225,7 +225,8 @@ class AnnotationToolUI: props = tool.Drawing.get_document_props() row.prop(props, "should_draw_decorations", text="Viewport Annotations") - _DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) + _ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL")) @classmethod def draw_edit_object_interface(cls, context): @@ -272,6 +273,18 @@ class AnnotationToolUI: else: row = cls.layout.row(align=True) row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED") + elif ptype in cls._ELEVATION_TYPES: + cls.layout.separator() + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") + if pset and pset.get("Anchors"): + row = cls.layout.row(align=True) + op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate") + op.active_only = True + row = cls.layout.row(align=True) + row.operator("bim.bake_parametric_dimension", text="Bake to Static", icon="UNLINKED") + else: + row = cls.layout.row(align=True) + row.operator("bim.make_dimension_parametric", text="Make Parametric", icon="LINKED") @classmethod def draw_type_selection_interface(cls): @@ -295,7 +308,7 @@ 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"} + _DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE"} if object_type in _DIMENSION_TYPES: row = cls.layout.row(align=True) row.prop(cls.props, "force_perpendicular_to_face") @@ -383,14 +396,18 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.context.view_layer.objects.active = created_objects[-1] _PARAMETRIC_DIMENSION_TYPES = frozenset( - ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL") + ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE") ) + _ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL")) def hotkey_S_A(self): props = tool.Drawing.get_annotation_props() if props.object_type in self._PARAMETRIC_DIMENSION_TYPES: if bpy.ops.bim.draw_parametric_dimension.poll(): bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT") + elif props.object_type in self._ELEVATION_TYPES: + if bpy.ops.bim.add_elevation_annotation.poll(): + bpy.ops.bim.add_elevation_annotation("INVOKE_DEFAULT") elif bpy.ops.bim.add_annotation.poll(): bpy.ops.bim.add_annotation() diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index d08faa0229..90e9da970b 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -209,6 +209,17 @@ class Drawing(bonsai.core.tool.Drawing): co_end = co1 + vec * scaled_length obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1) obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z") + elif object_type == "SECTION_LEVEL": + co1, _, co3, _ = annotation.Annotator.get_placeholder_coords() + # co3 - co1 is the camera X direction (horizontal in a section view). + vec = co3 - co1 + if vec.length == 0: + vec = Vector((1, 0, 0)) + else: + vec = vec.normalized() + scaled_length = 0.023 * scale + co_end = co1 + vec * scaled_length + obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1) elif object_type != "TEXT": obj = annotation.Annotator.add_line_to_annotation(obj)