diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 4f250065dd..9d7f4757fb 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -5714,6 +5714,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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] + self._force_parallel = False + self._anchor0_parallel_dir = None # (tx, ty, tz) cross(face_normal, camera_dir) self._snap_mode = "FACE" self._ifc_snap_candidate = None self._draw_handler = None @@ -5800,16 +5802,20 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope 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() + # After anchor[0] is set, extract its face normal for the perp/parallel constraints. + if len(self._anchors) == 1: + if self._force_perpendicular: + self._update_perp_constraint() + if self._force_parallel: + self._update_parallel_constraint() elif count_after < count_before and self._anchors: # BACKSPACE removed a point. self._anchors.pop() - # Reset constraint if we backspaced past anchor[0]. + # Reset constraints if we backspaced past anchor[0]. if len(self._anchors) == 0: self._anchor0_normal = None self._anchor0_pt = None + self._anchor0_parallel_dir = None # ------------------------------------------------------------------ # Perpendicular-to-face constraint helpers @@ -5897,6 +5903,53 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2])) snap["point"] = constrained + # ------------------------------------------------------------------ + # Parallel-to-face constraint helpers + + def _update_parallel_constraint(self) -> None: + """Compute the tangent direction (cross of face normal × camera forward) for anchor[0].""" + import math + from mathutils import Vector + # Reuse _update_perp_constraint to obtain the face normal first. + if not self._anchor0_normal: + self._update_perp_constraint() + if not self._anchor0_normal: + return + cam = bpy.context.scene.camera + if not cam: + return + cam_fwd = cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0)) + cam_fwd.normalize() + n = self._anchor0_normal + cd = (cam_fwd.x, cam_fwd.y, cam_fwd.z) + tang = ( + n[1] * cd[2] - n[2] * cd[1], + n[2] * cd[0] - n[0] * cd[2], + n[0] * cd[1] - n[1] * cd[0], + ) + mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2) + if mag < 1e-12: + return + self._anchor0_parallel_dir = (tang[0] / mag, tang[1] / mag, tang[2] / mag) + + def _apply_parallel_constraint(self) -> None: + """Project the current snap point onto the tangent line (parallel to face) when active.""" + if not self._force_parallel or not self._anchor0_parallel_dir or not self._anchor0_pt: + return + if not self._anchors: + 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 + t = self._anchor0_parallel_dir + proj = (p.x - base[0]) * t[0] + (p.y - base[1]) * t[1] + (p.z - base[2]) * t[2] + constrained = Vector((base[0] + proj * t[0], base[1] + proj * t[1], base[2] + proj * t[2])) + snap["point"] = constrained + # ------------------------------------------------------------------ # IFC-native snap for LAYER / VERTEX / EDGE modes @@ -6375,6 +6428,8 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope pset_props = {"Anchors": json.dumps(anchors)} if self._force_perpendicular: pset_props["ForcePerpendicularToFace"] = True + if self._force_parallel: + pset_props["ForceParallelToFace"] = True ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props) # Always regenerate from anchor data so the curve reflects the true IFC @@ -6431,6 +6486,7 @@ class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Ope self.choose_axis(event) self.handle_snap_selection(context, event) self._apply_perp_constraint() + self._apply_parallel_constraint() # TAB: cycle snap mode when not in keyboard-input mode. # Consume both PRESS and RELEASE so the RELEASE never reaches @@ -6513,7 +6569,9 @@ 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 + props = tool.Drawing.get_annotation_props() + self._force_perpendicular = props.force_perpendicular_to_face + self._force_parallel = props.force_parallel_to_face and not self._force_perpendicular _snap_draw_data.clear() self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( _draw_snap_indicator_global, (), "WINDOW", "POST_VIEW" diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index eb18866fdb..1f87a0c2a6 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -1046,60 +1046,154 @@ def update_sheet_data(self, context): SheetsData.is_loaded = False +# Guard against re-entrant calls when one constraint callback clears the other property. +_face_constraint_updating = False + + def _update_force_perpendicular(self, context): """Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them.""" - import json - import numpy as np - import ifcopenshell.util.element - import ifcopenshell.api.pset - import ifcopenshell.api.drawing as drawing_api - import bonsai.tool as tool - - file = tool.Ifc.get() - if not file: + global _face_constraint_updating + if _face_constraint_updating: return + _face_constraint_updating = True + try: + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.pset + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as tool - new_value = self.force_perpendicular_to_face - _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) + file = tool.Ifc.get() + if not file: + return - targets = [] - for obj in context.selected_objects: - element = tool.Ifc.get_entity(obj) - if not element or not element.is_a("IfcAnnotation"): - continue - if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES: - continue - pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") - if not pset_data: - continue - targets.append((obj, element, pset_data)) + new_value = self.force_perpendicular_to_face + if new_value and self.force_parallel_to_face: + self.force_parallel_to_face = False + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) - if not targets: - return - - from bonsai.bim.module.drawing.operator import _update_blender_curve - - for obj, element, pset_data in targets: - pset_entity = file.by_id(pset_data["id"]) - ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value}) - - anchors = json.loads(pset_data.get("Anchors") or "[]") - placement_override = {} - for a in anchors: - guid = a.get("guid") - if not guid: + targets = [] + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): 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 + if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES: + continue + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") + if not pset_data: + continue + targets.append((obj, element, pset_data)) - resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) - if resolved_pts: - _update_blender_curve(element, resolved_pts) + if not targets: + return + + from bonsai.bim.module.drawing.operator import _update_blender_curve + + for obj, element, pset_data in targets: + pset_entity = file.by_id(pset_data["id"]) + pset_props = {"ForcePerpendicularToFace": new_value} + if new_value: + pset_props["ForceParallelToFace"] = False + ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props) + + anchors = json.loads(pset_data.get("Anchors") or "[]") + 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) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) + if resolved_pts: + _update_blender_curve(element, resolved_pts) + finally: + _face_constraint_updating = False + + +def _update_force_parallel(self, context): + """Apply ForceParallelToFace to all selected dimension annotations and regenerate them.""" + global _face_constraint_updating + if _face_constraint_updating: + return + _face_constraint_updating = True + try: + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.pset + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as tool + from mathutils import Vector + + file = tool.Ifc.get() + if not file: + return + + new_value = self.force_parallel_to_face + if new_value and self.force_perpendicular_to_face: + self.force_perpendicular_to_face = False + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE")) + + targets = [] + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + continue + if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES: + continue + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") + if not pset_data: + continue + targets.append((obj, element, pset_data)) + + if not targets: + return + + cam = context.scene.camera + cam_dir_tuple = None + if cam: + cd = cam.matrix_world.to_3x3() @ Vector((0, 0, -1)) + cd.normalize() + cam_dir_tuple = (cd.x, cd.y, cd.z) + + from bonsai.bim.module.drawing.operator import _update_blender_curve + + for obj, element, pset_data in targets: + pset_entity = file.by_id(pset_data["id"]) + pset_props = {"ForceParallelToFace": new_value} + if new_value: + pset_props["ForcePerpendicularToFace"] = False + ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties=pset_props) + + anchors = json.loads(pset_data.get("Anchors") or "[]") + 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) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension( + file, element, placement_override=placement_override, camera_dir=cam_dir_tuple + ) + if resolved_pts: + _update_blender_curve(element, resolved_pts) + finally: + _face_constraint_updating = False def _get_line_position(self) -> float: @@ -1241,6 +1335,12 @@ class BIMAnnotationProperties(PropertyGroup): default=False, update=_update_force_perpendicular, ) + force_parallel_to_face: bpy.props.BoolProperty( + name="Force ∥ to Face", + description="Constrain dimension vertices to run parallel to the face of the first anchor (along the face, perpendicular to its normal). When dimensions are selected, toggling this updates them all.", + default=False, + update=_update_force_parallel, + ) line_position: bpy.props.FloatProperty( name="Line Position", description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.", diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 1d323af157..87d20bb930 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -313,6 +313,8 @@ class AnnotationToolUI: if object_type in _DIMENSION_TYPES: row = cls.layout.row(align=True) row.prop(cls.props, "force_perpendicular_to_face") + row = cls.layout.row(align=True) + row.prop(cls.props, "force_parallel_to_face") if object_type in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP: row = cls.layout.row(align=True) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py index 6cd34100fa..7b3db6e6ed 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -119,6 +119,34 @@ def regenerate_dimension( base[2] + t * normal[2]) anchors[i]["pt"] = list(resolved[i]) + # ForceParallelToFace: project vertices 1…n onto the line through + # pt[0] in the direction cross(face_normal, camera_dir), so the polyline + # runs parallel to the face (perpendicular to the face normal). + if pset_data.get("ForceParallelToFace") and len(resolved) >= 2 and resolved[0] is not None: + face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override) + if face_normal and camera_dir: + fn, cd = face_normal, camera_dir + tang = ( + fn[1] * cd[2] - fn[2] * cd[1], + fn[2] * cd[0] - fn[0] * cd[2], + fn[0] * cd[1] - fn[1] * cd[0], + ) + mag = math.sqrt(tang[0] ** 2 + tang[1] ** 2 + tang[2] ** 2) + if mag > 1e-12: + tang = (tang[0] / mag, tang[1] / mag, tang[2] / mag) + base = resolved[0] + for i in range(1, len(resolved)): + if resolved[i] is None: + continue + pt = resolved[i] + t = ((pt[0] - base[0]) * tang[0] + + (pt[1] - base[1]) * tang[1] + + (pt[2] - base[2]) * tang[2]) + resolved[i] = (base[0] + t * tang[0], + base[1] + t * tang[1], + base[2] + t * tang[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)