mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user