Bonsai: position drawings on sheets without leaving Blender

Sheet items could only be placed by the automatic row-packing layout in
SheetBuilder.add_drawing/add_document. Any correction meant opening the
layout SVG in Inkscape via bim.open_layout, which is the round trip that
D&D Phase 1 Item 07 asks to remove.

Adds bim.edit_sheet_item_position, which reads the current position of
the selected drawing, schedule or reference from the sheet layout SVG,
offers it as X/Y in millimetres, and writes the whole group back so the
view title keeps its offset from the content.

Scale is deliberately not exposed. build_drawings/build_documents inline
the referenced SVG under a plain translate(), so the image width/height
only drives the clip rect and cannot scale the view.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-20 12:41:53 +03:00
parent 55a2430d71
commit 3a0e8ccd31
4 changed files with 116 additions and 0 deletions
@@ -63,6 +63,7 @@ classes = (
operator.EditAssignedProduct, operator.EditAssignedProduct,
operator.EditElementFilter, operator.EditElementFilter,
operator.EditSheet, operator.EditSheet,
operator.EditSheetItemPosition,
operator.EditText, operator.EditText,
operator.EditTextPopup, operator.EditTextPopup,
operator.EnableAddAnnotationType, operator.EnableAddAnnotationType,
@@ -2165,6 +2165,54 @@ class RemoveDrawingFromSheet(bpy.types.Operator, tool.Ifc.Operator):
tool.Drawing.remove_drawing_from_sheet(ifc_file.by_id(self.reference)) tool.Drawing.remove_drawing_from_sheet(ifc_file.by_id(self.reference))
class EditSheetItemPosition(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_sheet_item_position"
bl_label = "Edit Position On Sheet"
bl_description = "Set the position of the selected drawing, schedule or reference on its sheet layout"
bl_options = {"REGISTER", "UNDO"}
x: bpy.props.FloatProperty(name="X", description="Distance in mm from the left edge of the sheet", unit="NONE")
y: bpy.props.FloatProperty(name="Y", description="Distance in mm from the top edge of the sheet", unit="NONE")
@classmethod
def poll(cls, context):
active_item = tool.Drawing.get_active_sheet_item()
if active_item is None or active_item.is_sheet:
cls.poll_message_set("No sheet item selected.")
return False
if active_item.reference_type not in ("DRAWING", "SCHEDULE", "REFERENCE"):
cls.poll_message_set("Only drawings, schedules and references can be positioned.")
return False
return True
def invoke(self, context, event):
assert context.window_manager
position = self.get_position()
if position is None:
self.report({"ERROR"}, "Item was not found in the sheet layout.")
return {"CANCELLED"}
self.x, self.y = position
return context.window_manager.invoke_props_dialog(self)
def get_position(self):
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
reference = tool.Ifc.get().by_id(sheet_item.ifc_definition_id)
sheet = tool.Drawing.get_reference_document(reference)
if sheet is None:
return None
return sheeter.SheetBuilder().get_sheet_item_position(reference, sheet)
def _execute(self, context):
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
reference = tool.Ifc.get().by_id(sheet_item.ifc_definition_id)
sheet = tool.Drawing.get_reference_document(reference)
if sheet is None or not sheeter.SheetBuilder().set_sheet_item_position(reference, sheet, self.x, self.y):
self.report({"ERROR"}, "Item was not found in the sheet layout.")
return {"CANCELLED"}
class CreateSheets(bpy.types.Operator, tool.Ifc.Operator): class CreateSheets(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.create_sheets" bl_idname = "bim.create_sheets"
bl_label = "Create Sheets" bl_label = "Create Sheets"
@@ -24,6 +24,7 @@ import urllib.parse
import uuid import uuid
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from typing import Union
from xml.dom import minidom from xml.dom import minidom
import ifcopenshell.util.geolocation import ifcopenshell.util.geolocation
@@ -211,6 +212,71 @@ class SheetBuilder:
layout_tree.write(layout_path) layout_tree.write(layout_path)
def find_sheet_item(
self, layout_root: ET.Element, reference: ifcopenshell.entity_instance
) -> Union[ET.Element, None]:
for g in layout_root.findall(f"{SVG}g"):
if g.attrib.get("data-id") == str(reference.id()):
return g
return None
def get_sheet_item_position(
self, reference: ifcopenshell.entity_instance, sheet: ifcopenshell.entity_instance
) -> Union[Vector, None]:
"""Position in mm of a drawing/schedule/reference placed on a sheet layout."""
layout_path = tool.Drawing.get_document_uri(sheet, "LAYOUT")
assert layout_path
if not os.path.exists(layout_path):
return None
layout_root = ET.parse(layout_path).getroot()
view = self.find_sheet_item(layout_root, reference)
if view is None:
return None
content = self.get_sheet_item_content(view)
if content is None:
return None
return Vector((self.convert_to_mm(content.attrib["x"]), self.convert_to_mm(content.attrib["y"])))
def set_sheet_item_position(
self, reference: ifcopenshell.entity_instance, sheet: ifcopenshell.entity_instance, x: float, y: float
) -> bool:
"""Move a sheet item and its view title to an absolute position in mm."""
ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
layout_path = tool.Drawing.get_document_uri(sheet, "LAYOUT")
assert layout_path
if not os.path.exists(layout_path):
return False
layout_tree = ET.parse(layout_path)
view = self.find_sheet_item(layout_tree.getroot(), reference)
if view is None:
return False
content = self.get_sheet_item_content(view)
if content is None:
return False
offset = Vector((x, y)) - Vector(
(self.convert_to_mm(content.attrib["x"]), self.convert_to_mm(content.attrib["y"]))
)
for image in view.findall(f"{SVG}image"):
image.attrib["x"] = str(round(self.convert_to_mm(image.attrib["x"]) + offset.x, 4))
image.attrib["y"] = str(round(self.convert_to_mm(image.attrib["y"]) + offset.y, 4))
layout_tree.write(layout_path)
return True
def get_sheet_item_content(self, view: ET.Element) -> Union[ET.Element, None]:
for image in view.findall(f"{SVG}image"):
if image.attrib.get("data-type") in ("foreground", "content"):
return image
return None
def remove_drawing(self, reference: ifcopenshell.entity_instance, sheet: ifcopenshell.entity_instance) -> None: def remove_drawing(self, reference: ifcopenshell.entity_instance, sheet: ifcopenshell.entity_instance) -> None:
ET.register_namespace("", "http://www.w3.org/2000/svg") ET.register_namespace("", "http://www.w3.org/2000/svg")
@@ -516,6 +516,7 @@ class BIM_PT_sheets(Panel):
row3.separator(factor=0.5, type="SPACE") row3.separator(factor=0.5, type="SPACE")
row3.operator("bim.edit_sheet", icon="GREASEPENCIL", text="") row3.operator("bim.edit_sheet", icon="GREASEPENCIL", text="")
row3.operator("bim.edit_sheet_item_position", icon="ORIENTATION_VIEW", text="")
row3.operator("bim.add_drawing_to_sheet", icon="IMAGE_PLANE", text="") row3.operator("bim.add_drawing_to_sheet", icon="IMAGE_PLANE", text="")
row3.operator("bim.add_schedule_to_sheet", icon="PRESET_NEW", text="") row3.operator("bim.add_schedule_to_sheet", icon="PRESET_NEW", text="")
row3.operator("bim.add_reference_to_sheet", icon="IMAGE_REFERENCE", text="") row3.operator("bim.add_reference_to_sheet", icon="IMAGE_REFERENCE", text="")