diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 64fe302de2..d276f074fb 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1960,6 +1960,43 @@ class AssignManualDrawingReference(bpy.types.Operator, tool.Ifc.Operator): area.tag_redraw() +class UpdateSectionEndpoints(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.update_section_endpoints" + bl_label = "Reset Section to Border" + bl_description = "Recompute section line endpoints to match drawing border offset, overriding any manual edits" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not tool.Ifc.get() or not context.active_object: + return False + element = tool.Ifc.get_entity(context.active_object) + return bool( + element + and element.is_a("IfcAnnotation") + and ifcopenshell.util.element.get_predefined_type(element) == "SECTION" + and context.scene.camera + ) + + def _execute(self, context): + obj = context.active_object + camera = context.scene.camera + element = tool.Ifc.get_entity(obj) + # Clear stored auto positions so both endpoints are forced back to border offset. + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {} + pset_id = pset_data.get("id") + if pset_id: + pset_entity = tool.Ifc.get().by_id(pset_id) + else: + pset_entity = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Section") + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), + pset=pset_entity, + properties={"AutoStartPosition": "", "AutoEndPosition": ""}, + ) + tool.Drawing.update_section_endpoints(obj, camera) + + class AddSheet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_sheet" bl_label = "Add Sheet" @@ -2666,6 +2703,22 @@ class ActivateDrawingBase(tool.Ifc.Operator): # Save drawing bounds to the .ifc file camera = context.scene.camera assert camera + + # Update SECTION annotation endpoints for this drawing + print(f"[SECTION] ActivateDrawing: camera={camera.name}, drawing id={self.drawing}") + drawing_element = tool.Ifc.get().by_id(self.drawing) + drawing_camera_element = tool.Ifc.get_entity(camera) + print(f"[SECTION] drawing_element={drawing_element}, camera_element={drawing_camera_element}") + group = tool.Drawing.get_drawing_group(drawing_element) + print(f"[SECTION] group={group}") + if group: + for annotation in tool.Drawing.get_group_elements(group) or []: + print(f"[SECTION] group member: {annotation.is_a()} id={annotation.id()}") + if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION": + ann_obj = tool.Ifc.get_object(annotation) + print(f"[SECTION] found SECTION annotation obj={ann_obj}") + if ann_obj: + tool.Drawing.update_section_endpoints(ann_obj, camera) camera_props = tool.Drawing.get_camera_props(camera) # Check if this is a reflected ceiling camera and preserve its scale camera_element = tool.Ifc.get_entity(camera) diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index f4e0edbd93..01cbaa3c89 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -95,6 +95,17 @@ def update_diagram_scale(self: "BIMCameraProperties", context: bpy.types.Context ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=diagram_scale) self.update_camera_resolution() + group = tool.Drawing.get_drawing_group(element) + print(f"[SECTION] update_diagram_scale: camera={camera.name}, group={group}") + if group: + for annotation in tool.Drawing.get_group_elements(group) or []: + print(f"[SECTION] checking group member: {annotation}") + if annotation.is_a("IfcAnnotation") and ifcopenshell.util.element.get_predefined_type(annotation) == "SECTION": + ann_obj = tool.Ifc.get_object(annotation) + print(f"[SECTION] found SECTION annotation, ann_obj={ann_obj}") + if ann_obj: + tool.Drawing.update_section_endpoints(ann_obj, camera) + def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None: if not self.update_props: diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index b6e18cea43..23f80ba75c 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -520,6 +520,7 @@ def add_annotation( relating_type: ifcopenshell.entity_instance, enable_editing: bool = False, ) -> bpy.types.Object: + print(f"[SECTION] core.add_annotation called: object_type={object_type}") target_view = drawing_tool.get_drawing_target_view(drawing) context = drawing_tool.get_annotation_context(target_view, object_type) if not context: @@ -542,6 +543,11 @@ def add_annotation( if relating_type: drawing_tool.run_type_assign_type(element=element, relating_type=relating_type) ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), products=[element]) + if object_type == "SECTION": + camera = ifc.get_object(drawing) + print(f"[SECTION] add_annotation: object_type=SECTION, camera={camera}") + if camera: + drawing_tool.update_section_endpoints(obj, camera) if representation := drawing_tool.get_representation(element, context): drawing_tool.reload_representation(obj=obj, representation=representation) collector.assign(obj, should_clean_users_collection=True) diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index a024eed769..df5ca8193e 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -77,6 +77,9 @@ if TYPE_CHECKING: from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties +print("[SECTION] tool/drawing.py module loaded") + + class Drawing(bonsai.core.tool.Drawing): ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"] PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY") @@ -2910,6 +2913,176 @@ class Drawing(bonsai.core.tool.Drawing): numerator, denominator = scale.split("/") return float(numerator) / float(denominator) + @classmethod + def get_camera_dimensions(cls, camera: bpy.types.Object) -> tuple[float, float]: + render = bpy.context.scene.render + assert isinstance(camera.data, bpy.types.Camera) + if render.resolution_x > render.resolution_y: + width = camera.data.ortho_scale + height = width / render.resolution_x * render.resolution_y + else: + height = camera.data.ortho_scale + width = height / render.resolution_y * render.resolution_x + return width, height + + @staticmethod + def _section_ray_rect_intersections( + origin: Vector, direction: Vector, half_w: float, half_h: float + ) -> list[float]: + """Return t values where the ray origin+t*direction intersects the ±half_w/±half_h rectangle.""" + results: list[float] = [] + eps = 1e-6 + if abs(direction.x) > eps: + for x_bound in (-half_w, half_w): + t = (x_bound - origin.x) / direction.x + if abs(origin.y + t * direction.y) <= half_h + eps: + results.append(t) + if abs(direction.y) > eps: + for y_bound in (-half_h, half_h): + t = (y_bound - origin.y) / direction.y + if abs(origin.x + t * direction.x) <= half_w + eps: + results.append(t) + return results + + @classmethod + def get_section_border_positions( + cls, + camera: bpy.types.Object, + v0_world: Vector, + v1_world: Vector, + border_offset_mm: float, + ) -> tuple[Vector, Vector]: + """Return world-space positions for section endpoints placed at the camera border + border_offset_mm (paper mm).""" + diagram_scale = cls.get_diagram_scale(camera) + if not diagram_scale: + print("[SECTION] get_section_border_positions: no diagram_scale, returning original") + return v0_world, v1_world + scale = cls.get_scale_ratio(diagram_scale["Scale"]) + model_offset = (border_offset_mm / 1000.0) / scale + print(f"[SECTION] scale={scale}, border_offset_mm={border_offset_mm}, model_offset={model_offset:.4f}m") + + width, height = cls.get_camera_dimensions(camera) + half_w, half_h = width / 2, height / 2 + print(f"[SECTION] camera dims: width={width:.3f}, height={height:.3f}, half_w={half_w:.3f}, half_h={half_h:.3f}") + + cam_inv = camera.matrix_world.inverted() + v0_local = cam_inv @ v0_world + v1_local = cam_inv @ v1_world + print(f"[SECTION] v0_local={v0_local}, v1_local={v1_local}") + + origin = Vector(((v0_local.x + v1_local.x) / 2, (v0_local.y + v1_local.y) / 2)) + dir_xy = Vector((v1_local.x - v0_local.x, v1_local.y - v0_local.y)) + if dir_xy.length < 1e-6: + print("[SECTION] get_section_border_positions: degenerate edge, returning original") + return v0_world, v1_world + dir_xy = dir_xy.normalized() + z = v0_local.z + print(f"[SECTION] origin={origin}, dir_xy={dir_xy}, z={z:.4f}") + + t_values = cls._section_ray_rect_intersections(origin, dir_xy, half_w, half_h) + print(f"[SECTION] ray-rect t_values={t_values}") + pos_ts = sorted(t for t in t_values if t >= 0) + neg_ts = sorted((t for t in t_values if t < 0), reverse=True) + print(f"[SECTION] pos_ts={pos_ts}, neg_ts={neg_ts}") + + if not pos_ts or not neg_ts: + print("[SECTION] get_section_border_positions: no valid border intersections, returning original") + return v0_world, v1_world + + t_end = pos_ts[0] + t_start = neg_ts[0] + new_v0_local = Vector(( + origin.x + (t_start + model_offset) * dir_xy.x, + origin.y + (t_start + model_offset) * dir_xy.y, + z, + )) + new_v1_local = Vector(( + origin.x + (t_end - model_offset) * dir_xy.x, + origin.y + (t_end - model_offset) * dir_xy.y, + z, + )) + return camera.matrix_world @ new_v0_local, camera.matrix_world @ new_v1_local + + @classmethod + def update_section_endpoints(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> None: + """Move section line endpoints to camera border + BorderOffset, skipping any manually moved vertex.""" + print(f"[SECTION] update_section_endpoints called: obj={obj.name}, camera={camera.name}") + element = tool.Ifc.get_entity(obj) + if not element: + print("[SECTION] SKIP: no IFC element on obj") + return + if not obj.data or not hasattr(obj.data, "edges") or not obj.data.edges: + print("[SECTION] SKIP: obj has no mesh edges") + return + + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Section") or {} + border_offset = float(pset_data.get("BorderOffset", 8.0)) + print(f"[SECTION] pset_data={pset_data}, border_offset={border_offset}") + if border_offset <= 0: + print("[SECTION] SKIP: BorderOffset <= 0") + return + + auto_v0 = cls._parse_vector3(pset_data.get("AutoStartPosition") or "") + auto_v1 = cls._parse_vector3(pset_data.get("AutoEndPosition") or "") + print(f"[SECTION] stored auto_v0={auto_v0}, auto_v1={auto_v1}") + + edge = obj.data.edges[0] + v0 = obj.data.vertices[edge.vertices[0]] + v1 = obj.data.vertices[edge.vertices[1]] + v0_world = obj.matrix_world @ v0.co + v1_world = obj.matrix_world @ v1.co + print(f"[SECTION] current v0_world={v0_world}, v1_world={v1_world}") + + # A vertex is "auto" if it has never been auto-positioned, or still sits at the stored auto position. + v0_is_auto = auto_v0 is None or (v0_world - auto_v0).length < 1e-4 + v1_is_auto = auto_v1 is None or (v1_world - auto_v1).length < 1e-4 + print(f"[SECTION] v0_is_auto={v0_is_auto}, v1_is_auto={v1_is_auto}") + + if not v0_is_auto and not v1_is_auto: + print("[SECTION] SKIP: both vertices are manually overridden") + return + + new_v0_world, new_v1_world = cls.get_section_border_positions(camera, v0_world, v1_world, border_offset) + print(f"[SECTION] new_v0_world={new_v0_world}, new_v1_world={new_v1_world}") + + if v0_is_auto: + v0.co = obj.matrix_world.inverted() @ new_v0_world + if v1_is_auto: + v1.co = obj.matrix_world.inverted() @ new_v1_world + obj.data.update() + + stored_v0 = new_v0_world if v0_is_auto else v0_world + stored_v1 = new_v1_world if v1_is_auto else v1_world + + pset_id = pset_data.get("id") + if pset_id: + pset_entity = tool.Ifc.get().by_id(pset_id) + else: + pset_entity = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Section") + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), + pset=pset_entity, + properties={ + "BorderOffset": border_offset, + "AutoStartPosition": cls._format_vector3(stored_v0), + "AutoEndPosition": cls._format_vector3(stored_v1), + }, + ) + bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="") + print(f"[SECTION] done. stored auto_v0={cls._format_vector3(stored_v0)}, auto_v1={cls._format_vector3(stored_v1)}") + + @staticmethod + def _parse_vector3(s: str) -> Optional[Vector]: + try: + x, y, z = map(float, s.split(",")) + return Vector((x, y, z)) + except Exception: + return None + + @staticmethod + def _format_vector3(v: Vector) -> str: + return f"{v.x:.6f},{v.y:.6f},{v.z:.6f}" + @classmethod def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]: props = cls.get_camera_props(camera)