Closes #6321: Auto-snap SECTION endpoints to drawing border

When a SECTION annotation is created or a drawing is activated,
endpoint vertices are automatically placed at a configurable
BorderOffset (paper-space mm, default 8) inside the camera border,
scaled by the drawing scale. BorderOffset is stored in the
BBIM_Section pset and visible in the Property Sets panel.
An "UpdateSectionEndpoints" operator (bim.update_section_endpoints)
resets endpoints back to the border offset on demand. Endpoints are
also recomputed when the diagram scale is changed.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Ryan Schultz
2026-04-18 15:01:00 -05:00
parent ca866b3669
commit 904a4df651
4 changed files with 243 additions and 0 deletions
@@ -1960,6 +1960,43 @@ class AssignManualDrawingReference(bpy.types.Operator, tool.Ifc.Operator):
area.tag_redraw() 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): class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_sheet" bl_idname = "bim.add_sheet"
bl_label = "Add Sheet" bl_label = "Add Sheet"
@@ -2666,6 +2703,22 @@ class ActivateDrawingBase(tool.Ifc.Operator):
# Save drawing bounds to the .ifc file # Save drawing bounds to the .ifc file
camera = context.scene.camera camera = context.scene.camera
assert 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) camera_props = tool.Drawing.get_camera_props(camera)
# Check if this is a reflected ceiling camera and preserve its scale # Check if this is a reflected ceiling camera and preserve its scale
camera_element = tool.Ifc.get_entity(camera) camera_element = tool.Ifc.get_entity(camera)
@@ -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) ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties=diagram_scale)
self.update_camera_resolution() 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: def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None:
if not self.update_props: if not self.update_props:
+6
View File
@@ -520,6 +520,7 @@ def add_annotation(
relating_type: ifcopenshell.entity_instance, relating_type: ifcopenshell.entity_instance,
enable_editing: bool = False, enable_editing: bool = False,
) -> bpy.types.Object: ) -> bpy.types.Object:
print(f"[SECTION] core.add_annotation called: object_type={object_type}")
target_view = drawing_tool.get_drawing_target_view(drawing) target_view = drawing_tool.get_drawing_target_view(drawing)
context = drawing_tool.get_annotation_context(target_view, object_type) context = drawing_tool.get_annotation_context(target_view, object_type)
if not context: if not context:
@@ -542,6 +543,11 @@ def add_annotation(
if relating_type: if relating_type:
drawing_tool.run_type_assign_type(element=element, relating_type=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]) 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): if representation := drawing_tool.get_representation(element, context):
drawing_tool.reload_representation(obj=obj, representation=representation) drawing_tool.reload_representation(obj=obj, representation=representation)
collector.assign(obj, should_clean_users_collection=True) collector.assign(obj, should_clean_users_collection=True)
+173
View File
@@ -77,6 +77,9 @@ if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
print("[SECTION] tool/drawing.py module loaded")
class Drawing(bonsai.core.tool.Drawing): class Drawing(bonsai.core.tool.Drawing):
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"] ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY") PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
@@ -2910,6 +2913,176 @@ class Drawing(bonsai.core.tool.Drawing):
numerator, denominator = scale.split("/") numerator, denominator = scale.split("/")
return float(numerator) / float(denominator) 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 @classmethod
def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]: def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]:
props = cls.get_camera_props(camera) props = cls.get_camera_props(camera)