Files
IfcOpenShell/src/bonsai/bonsai/tool/drawing.py
T
Petru Conduraru 69d12aa87e Bonsai: uniquify drawing name on rename so SVGs don't overwrite (#5635)
A drawing's SVG filename is derived from its Name
(get_default_drawing_path -> drawings/<name>.svg). New drawings are
de-duplicated by ensure_unique_drawing_name in core.add_drawing, but the
rename path core.update_drawing_name applied the raw name with no
uniqueness check. Renaming one drawing to another's name then made both
resolve to the same SVG path: update_drawing_name moved one SVG onto the
other and pointed both IfcDocumentReference.Location values at the same
file, overwriting the drawing and crossing the sheet references.

Give ensure_unique_drawing_name an optional `ignore` entity (so a drawing
can keep its own name) and call it from update_drawing_name with
ignore=drawing, mirroring add_drawing. Default stays None, so existing
callers are unchanged.

Verified live in headless Blender: renaming a second drawing to a taken
name now yields "<name>-X" and a distinct SVG path (paths no longer
collide). Core test_drawing.py::TestUpdateDrawingName passes; the tool
ensure_unique_drawing_name ignore case is confirmed live.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 07:51:35 +03:00

3036 lines
133 KiB
Python

# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
import logging
import math
import os
import platform
import re
import shutil
import subprocess
from collections.abc import Iterable, Sequence
from fractions import Fraction
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Optional, Union
import bmesh
import bpy
import ifcopenshell.api
import ifcopenshell.api.context
import ifcopenshell.api.document
import ifcopenshell.api.drawing
import ifcopenshell.api.geometry
import ifcopenshell.api.group
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import ifcopenshell.util.selector
import ifcopenshell.util.shape
import ifcopenshell.util.unit
import lark
import mathutils
import numpy as np
import shapely
from ifcopenshell.util.shape_builder import ShapeBuilder
from lxml import etree
from mathutils import Matrix, Vector
from shapely.ops import unary_union
import bonsai.bim.helper
import bonsai.bim.import_ifc
import bonsai.core.geometry
import bonsai.core.root
import bonsai.core.tool
import bonsai.core.type
import bonsai.tool as tool
if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import (
BIMAnnotationProperties,
BIMAssignedProductProperties,
BIMCameraProperties,
BIMTextProperties,
DocProperties,
Sheet,
)
from bonsai.bim.module.drawing.prop import Drawing as DrawingProperties
class Drawing(bonsai.core.tool.Drawing):
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
DOCUMENT_TYPE = Literal["SCHEDULE", "REFERENCE"]
LocationHintLiteral = Literal["PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST"]
LOCATION_HINT_LITERALS = ("PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST")
LocationHintType = Union[LocationHintLiteral, str]
# ObjectType: annotation_name, description, icon, data_type
# fmt: off
class AnnotationObjectType(NamedTuple):
annotation_name: str
description: str
icon: str
data_type: Drawing.ANNOTATION_DATA_TYPE
ANNOTATION_TYPES_DATA: dict[str, AnnotationObjectType] = {
"DIMENSION": AnnotationObjectType("Dimension", "Add dimensions annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "FIXED_SIZE", "curve"),
"ANGLE": AnnotationObjectType("Angle", "", "DRIVER_ROTATIONAL_DIFFERENCE", "curve"),
"RADIUS": AnnotationObjectType("Radius", "", "FORWARD", "curve"),
"DIAMETER": AnnotationObjectType("Diameter", "Add diameter annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "ARROW_LEFTRIGHT", "curve"),
"TEXT": AnnotationObjectType("Text", "", "SMALL_CAPS", "empty"),
"TEXT_LEADER": AnnotationObjectType("Leader", "", "TRACKING_BACKWARDS", "curve"),
"STAIR_ARROW": AnnotationObjectType("Stair Arrow", "Add stair arrow annotation.\nIf you have IfcStairFlight object selected, it will be used as a reference for the annotation", "SCREEN_BACK", "curve"),
"PLAN_LEVEL": AnnotationObjectType("Level (Plan)", "", "SORTBYEXT", "curve"),
"SECTION_LEVEL": AnnotationObjectType("Level (Section)", "", "TRIA_DOWN", "curve"),
"BREAKLINE": AnnotationObjectType("Breakline", "", "FCURVE", "mesh"),
"SYMBOL": AnnotationObjectType("Symbol", "", "KEYFRAME", "empty"),
"MULTI_SYMBOL": AnnotationObjectType("Multi-Symbol", "", "OUTLINER_DATA_POINTCLOUD", "mesh"),
"LINEWORK": AnnotationObjectType("Line", "", "SNAP_MIDPOINT", "mesh"),
"BATTING": AnnotationObjectType("Batting", "Add batting annotation.\nThickness could be changed through Thickness property of BBIM_Batting property set", "FORCE_FORCE", "mesh"),
"REVISION_CLOUD":AnnotationObjectType("Revision Cloud", "Add revision cloud", "VOLUME_DATA", "mesh"),
"FILL_AREA": AnnotationObjectType("Fill Area", "", "NODE_TEXTURE", "mesh"),
"FALL": AnnotationObjectType("Fall", "", "SORT_ASC", "curve"),
"IMAGE": AnnotationObjectType("Image", "Add reference image attached to the drawing", "TEXTURE", "mesh"),
}
# fmt: on
DEFAULT_SYMBOLS = [
"rectangle-tag",
"triangle-tag",
"hexagon-tag",
"capsule-tag",
"circle-tag",
"door-tag",
"window-tag",
"space-tag",
"elevation-arrow",
"elevation-tag",
"section-arrow",
"section-tag",
"dot",
"setout-tag",
"setout-point",
"control-point",
"traverse-point",
"spot-elevation",
]
@classmethod
def get_document_props(cls) -> DocProperties:
assert (scene := bpy.context.scene)
return scene.DocProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_annotation_props(cls) -> BIMAnnotationProperties:
assert (scene := bpy.context.scene)
return scene.BIMAnnotationProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_text_props(cls, obj: bpy.types.Object) -> BIMTextProperties:
return obj.BIMTextProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_camera_props(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> BIMCameraProperties:
"""
:param camera: Camera object or camera.
"""
if isinstance(camera, bpy.types.Camera):
data = camera
else:
assert isinstance(data := camera.data, bpy.types.Camera)
return data.BIMCameraProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_object_assigned_product_props(cls, obj: bpy.types.Object) -> BIMAssignedProductProperties:
return obj.BIMAssignedProductProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def canonicalise_class_name(cls, name: str) -> str:
return re.sub("[^0-9a-zA-Z]+", "", name)
@classmethod
def copy_representation(cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance) -> None:
if source.Representation:
dest.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), source.Representation, exclude=["IfcGeometricRepresentationContext"]
)
@classmethod
def get_annotation_data_type(cls, object_type: str) -> ANNOTATION_DATA_TYPE:
return cls.ANNOTATION_TYPES_DATA[object_type].data_type
@classmethod
def create_annotation_object(cls, drawing: ifcopenshell.entity_instance, object_type: str) -> bpy.types.Object:
import bonsai.bim.module.drawing.annotation as annotation
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
scale = 1 / float(Fraction(pset["Scale"]))
data_type = cls.get_annotation_data_type(object_type)
obj = annotation.Annotator.get_annotation_obj(drawing, object_type, data_type)
if object_type == "FILL_AREA":
obj = annotation.Annotator.add_plane_to_annotation(obj)
elif object_type == "REVISION_CLOUD":
obj = annotation.Annotator.add_plane_to_annotation(obj, remove_face=True)
elif object_type == "MULTI_SYMBOL":
obj = annotation.Annotator.add_vertex_to_annotation(obj)
elif object_type == "TEXT_LEADER":
co1, _, co2, _ = annotation.Annotator.get_placeholder_coords()
obj = annotation.Annotator.add_line_to_annotation(obj, co2, co1)
elif object_type == "PLAN_LEVEL":
co1, co2, _, _ = annotation.Annotator.get_placeholder_coords()
vec = co2 - co1
if vec.length == 0:
vec = Vector((1, 0, 0)) # Fallback to a unit vector
else:
vec = vec.normalized()
scaled_length = 0.023 * scale # 0.023 could probably be a preference
co_end = co1 + vec * scaled_length
obj = annotation.Annotator.add_line_to_annotation(obj, co_end, co1)
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(math.radians(-90), 4, "Z")
elif object_type != "TEXT":
obj = annotation.Annotator.add_line_to_annotation(obj)
return obj
@classmethod
def get_annotation_drawing(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
for e in rel.RelatedObjects:
if e.ObjectType == "DRAWING":
return e
@classmethod
def exclude_annotation_from_drawing(
cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance
) -> None:
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
exclude = ifcopenshell.util.element.get_property_definition(pset, prop="Exclude") or ""
if exclude:
exclude += "+"
exclude += element.GlobalId
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": exclude})
@classmethod
def ensure_annotation_in_drawing_plane(
cls, obj: bpy.types.Object, camera: Optional[bpy.types.Object] = None
) -> None:
"""Make sure annotation object is going to be visible in the camera view"""
def get_camera_from_annotation_object(obj):
entity = tool.Ifc.get_entity(obj)
if not entity:
return
return tool.Ifc.get_object(cls.get_annotation_drawing(entity))
if not camera:
camera = get_camera_from_annotation_object(obj) or bpy.context.scene.camera
current_pos = camera.matrix_world.inverted() @ obj.location
current_pos.z = -camera.data.clip_start - 0.05
current_pos = camera.matrix_world @ current_pos
obj.location = current_pos
ANNOTATION_TYPES_SUPPORT_SETUP = ("STAIR_ARROW", "TEXT", "REVISION_CLOUD", "FILL_AREA")
@classmethod
def setup_annotation_object(
cls,
obj: bpy.types.Object,
object_type: str,
related_object: Optional[bpy.types.Object] = None,
rotation_mode: str = "NONE",
) -> None:
"""Finish object's adjustments after both object and entity are created"""
if not related_object:
related_object = bpy.context.active_object
if not related_object or not (related_entity := tool.Ifc.get_entity(related_object)):
return
ifc_file = tool.Ifc.get()
obj_entity = tool.Ifc.get_entity(obj)
assert obj_entity
assign_product = False
if object_type == "STAIR_ARROW":
if related_entity.is_a("IfcStairFlight"):
stair, arrow = related_object, obj
assert isinstance(stair.data, bpy.types.Mesh)
assert isinstance(arrow.data, bpy.types.Mesh)
# place the arrow
# NOTE: may not work correctly in EDIT mode
bbox = tool.Blender.get_object_bounding_box(stair)
arrow.location = stair.matrix_world @ Vector(
(bbox["min_x"], (bbox["max_y"] - bbox["min_y"]) / 2, bbox["max_z"])
)
last_step_x = max(v.co.x for v in stair.data.vertices if tool.Cad.is_x(v.co.z - bbox["max_z"], 0))
arrow.data.splines[0].points[0].co = Vector((0, 0, 0, 1))
arrow.data.splines[0].points[1].co = Vector((last_step_x, 0, 0, 1))
cls.ensure_annotation_in_drawing_plane(obj)
assign_product = True
elif object_type == "TEXT":
bbox = tool.Blender.get_object_bounding_box(related_object)
obj.location = related_object.matrix_world @ bbox["center"]
cls.ensure_annotation_in_drawing_plane(obj)
assign_product = True
elif object_type == "REVISION_CLOUD":
revised_object, cloud = related_object, obj
assert isinstance(revised_object.data, bpy.types.Mesh)
assert isinstance(obj.data, bpy.types.Mesh)
verts = [np.array(revised_object.matrix_world @ v.co) for v in revised_object.data.vertices]
verts = [(np.around(v[[0, 1]], decimals=3)).tolist() for v in verts]
edges = [e.vertices for e in revised_object.data.edges]
# shapely magic
boundary_lines = [shapely.LineString([verts[v] for v in e]) for e in edges]
unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines))
all_polygons = shapely.polygonize(unioned_boundaries.geoms).geoms
outer_shell = unary_union(all_polygons)
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
new_verts = list(outer_shell.exterior.coords)
bm_verts = [bm.verts.new(v + (0,)) for v in new_verts]
bm_edges = [bm.edges.new([bm_verts[i], bm_verts[i + 1]]) for i in range(len(new_verts) - 1)]
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
tool.Blender.apply_bmesh(obj.data, bm, obj)
cloud.location = Vector((0, 0, 0))
cls.ensure_annotation_in_drawing_plane(cloud)
assign_product = True
if assign_product and not cls.get_assigned_product(obj_entity):
ifcopenshell.api.drawing.assign_product(
ifc_file, relating_product=related_entity, related_object=obj_entity
)
if rotation_mode != "NONE" and related_object:
cls.apply_annotation_rotation(obj, related_object, rotation_mode)
@classmethod
def apply_annotation_rotation(
cls, tag_obj: bpy.types.Object, related_object: bpy.types.Object, rotation_mode: str
) -> None:
"""Apply rotation to annotation based on the selected rotation mode"""
camera = bpy.context.scene.camera
camera_right = camera.matrix_world.to_3x3() @ mathutils.Vector((1, 0, 0))
if rotation_mode == "CAMERA_Horizontal":
location = tag_obj.location.copy()
camera_matrix = camera.matrix_world.copy()
camera_matrix.translation = location
tag_obj.matrix_world = camera_matrix
elif rotation_mode == "CAMERA_Vertical":
location = tag_obj.location.copy()
camera_matrix = camera.matrix_world.copy()
camera_matrix.translation = location
rotation_90z = mathutils.Matrix.Rotation(math.pi / 2, 4, "Z")
camera_matrix = camera_matrix @ rotation_90z
tag_obj.matrix_world = camera_matrix
elif rotation_mode == "LOCAL_X":
local_x = related_object.matrix_world.to_3x3() @ mathutils.Vector((1, 0, 0))
local_x = local_x.normalized()
if local_x.dot(camera_right) < 0:
local_x = -local_x
tag_obj.rotation_euler = local_x.to_track_quat("X", "Z").to_euler()
elif rotation_mode == "LOCAL_Y":
local_y = related_object.matrix_world.to_3x3() @ mathutils.Vector((0, 1, 0))
local_y = local_y.normalized()
if local_y.dot(camera_right) < 0:
local_y = -local_y
tag_obj.rotation_euler = local_y.to_track_quat("X", "Z").to_euler()
elif rotation_mode == "LOCAL_Z":
local_z = related_object.matrix_world.to_3x3() @ mathutils.Vector((0, 0, 1))
local_z = local_z.normalized()
if local_z.dot(camera_right) < 0:
local_z = -local_z
tag_obj.rotation_euler = local_z.to_track_quat("X", "Z").to_euler()
@classmethod
def is_annotation_object_type(
cls, element: ifcopenshell.entity_instance, object_types: Union[str, Sequence[str]]
) -> bool:
if isinstance(object_types, str):
object_types = [object_types]
element_type = element.is_a()
if element_type == "IfcAnnotation" and ifcopenshell.util.element.get_predefined_type(element) in object_types:
return True
if element_type == "IfcTypeProduct" and (
applicable_object_type := cls.get_annotation_type_object_type(element)
):
if applicable_object_type in object_types:
return True
return False
@classmethod
def get_annotation_type_object_type(cls, element_type: ifcopenshell.entity_instance) -> Union[str, None]:
applicable_occurrence: Union[str, None]
applicable_occurrence = element_type.ApplicableOccurrence
if not applicable_occurrence or not applicable_occurrence.startswith("IfcAnnotation/"):
return
return applicable_occurrence.split("/", 1)[1]
@classmethod
def get_annotation_representation(
cls, element: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
rep = ifcopenshell.util.representation.get_representation(
element, "Plan", "Annotation"
) or ifcopenshell.util.representation.get_representation(element, "Model", "Annotation")
if not rep:
return None
rep = tool.Geometry.resolve_mapped_representation(rep)
return rep
@classmethod
def create_camera(
cls,
name: str,
matrix: Matrix,
location_hint: Union[LocationHintLiteral, int],
target_view: ifcopenshell.util.representation.TARGET_VIEW,
) -> bpy.types.Object:
camera = bpy.data.objects.new(name, (camera_data := bpy.data.cameras.new(name)))
props = cls.get_camera_props(camera_data)
camera_data.show_limits = True
if location_hint == "PERSPECTIVE":
props.camera_type = "PERSP"
else:
props.camera_type = "ORTHO"
camera_data.ortho_scale = 50 # The default of 6m is too small
camera_data.clip_start = 0.002 # 2mm is close to zero but allows any GPU-drawn lines to be visible.
if target_view == "MODEL_VIEW":
assert (space := tool.Blender.get_view3d_space())
camera_data.clip_end = max(space.clip_end, 10)
else:
camera_data.clip_end = 10 # A slightly more reasonable default
if bpy.context.scene.unit_settings.system == "IMPERIAL":
props.diagram_scale = '1/8"=1\'-0"|1/96'
else:
props.diagram_scale = "1:100|1/100"
camera.matrix_world = matrix
return camera
@classmethod
def get_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance) -> dict[str, float]:
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") or {}
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
return {
"shift_x": float(pset.get(shift_x_prop, 0.0) or 0.0),
"shift_y": float(pset.get(shift_y_prop, 0.0) or 0.0),
}
@classmethod
def sync_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None:
if camera.type != "PERSP":
return
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
current_shifts = cls.get_perspective_camera_shifts(drawing)
new_shifts = {"shift_x": float(camera.shift_x or 0.0), "shift_y": float(camera.shift_y or 0.0)}
if tool.Cad.is_x(current_shifts["shift_x"], new_shifts["shift_x"]) and tool.Cad.is_x(
current_shifts["shift_y"], new_shifts["shift_y"]
):
return
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={
shift_x_prop: new_shifts["shift_x"],
shift_y_prop: new_shifts["shift_y"],
},
)
@classmethod
def create_svg_schedule(cls, schedule: ifcopenshell.entity_instance) -> None:
import bonsai.bim.module.drawing.scheduler as scheduler
schedule_creator = scheduler.Scheduler()
schedule_creator.schedule(
cls.get_document_uri(schedule), cls.get_path_with_ext(cls.get_document_uri(schedule), "svg")
)
@classmethod
def create_svg_sheet(cls, document: ifcopenshell.entity_instance, titleblock: str) -> str:
import bonsai.bim.module.drawing.sheeter as sheeter
sheet_builder = sheeter.SheetBuilder()
uri = cls.get_document_uri(document, "LAYOUT")
assert uri
sheet_builder.create(uri, titleblock)
return uri
@classmethod
def add_drawings(cls, sheet: ifcopenshell.entity_instance) -> None:
import bonsai.bim.module.drawing.sheeter as sheeter
sheet_builder = sheeter.SheetBuilder()
drawing_references = {}
drawing_names = []
for reference in cls.get_document_references(sheet):
reference_description = cls.get_reference_description(reference)
if reference_description == "DRAWING":
drawing_references[Path(reference.Location).stem] = reference
drawing_names.append(Path(reference.Location).stem)
for drawing_annotation in [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]:
if drawing_annotation.Name in drawing_names:
sheet_builder.add_drawing(drawing_references[drawing_annotation.Name], drawing_annotation, sheet)
@classmethod
def delete_collection(cls, collection: bpy.types.Collection) -> None:
bpy.data.collections.remove(collection, do_unlink=True)
@classmethod
def delete_drawing_elements(cls, elements: Iterable[ifcopenshell.entity_instance]) -> None:
for element in elements:
obj = tool.Ifc.get_object(element)
ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=element)
if obj:
obj_data = obj.data
bpy.data.objects.remove(obj)
if obj_data and obj_data.users == 0: # in case we have drawing element types
tool.Blender.remove_data_block(obj_data)
@classmethod
def delete_object(cls, obj: bpy.types.Object) -> None:
bpy.data.objects.remove(obj)
@classmethod
def disable_editing_drawings(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_drawings = False
@classmethod
def disable_editing_schedules(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_schedules = False
@classmethod
def disable_editing_references(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_references = False
@classmethod
def disable_editing_sheets(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_sheets = False
@classmethod
def disable_editing_text(cls, obj: bpy.types.Object) -> None:
props = tool.Drawing.get_text_props(obj)
obj.property_unset(tool.Blender.get_props_attribute_name(props))
@classmethod
def disable_editing_assigned_product(cls, obj: bpy.types.Object) -> None:
props = cls.get_object_assigned_product_props(obj)
props.is_editing_product = False
@classmethod
def enable_editing(cls, obj: bpy.types.Object) -> None:
from bonsai.bim.module.geometry.data import ViewportData
tool.Blender.select_and_activate_single_object(bpy.context, obj)
if not obj.data:
return
ViewportData.load() # Reload valid modes
bpy.ops.bim.override_mode_set_edit() # Enter item mode
ViewportData.load() # Reload valid modes
bpy.ops.bim.override_mode_set_edit() # Enter edit mode
@classmethod
def enable_editing_drawings(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_drawings = True
@classmethod
def enable_editing_schedules(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_schedules = True
@classmethod
def enable_editing_references(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_references = True
@classmethod
def enable_editing_sheets(cls) -> None:
props = tool.Drawing.get_document_props()
props.is_editing_sheets = True
@classmethod
def enable_editing_text(cls, obj: bpy.types.Object) -> None:
props = cls.get_text_props(obj)
props.is_editing = True
@classmethod
def enable_editing_assigned_product(cls, obj: bpy.types.Object) -> None:
props = cls.get_object_assigned_product_props(obj)
props.is_editing_product = True
@classmethod
def ensure_unique_drawing_name(
cls, name: str, ignore: Optional[ifcopenshell.entity_instance] = None
) -> str:
names = [
e.Name
for e in tool.Ifc.get().by_type("IfcAnnotation")
if e.ObjectType == "DRAWING" and e != ignore
]
while name in names:
name += "-X"
return name
@classmethod
def ensure_unique_identification(cls, identification: str) -> str:
ids = [
cls.get_sheet_identification(d)
for d in tool.Ifc.get().by_type("IfcDocumentInformation")
if d.Scope == "SHEET"
]
while identification in ids:
identification += "-X"
return identification
@classmethod
def export_text_literal_attributes(cls, obj: bpy.types.Object) -> list[dict[str, Any]]:
literals: list[dict[str, Any]] = []
props = tool.Drawing.get_text_props(obj)
for literal_props in props.literals:
literal_data = bonsai.bim.helper.export_attributes(literal_props.attributes)
alignment = literal_props.align_vertical + "-" + literal_props.align_horizontal
if alignment == "middle-middle":
alignment = "center"
literal_data["BoxAlignment"] = alignment
literals.append(literal_data)
return literals
@classmethod
def export_font_size(cls, obj: bpy.types.Object) -> str:
return float(cls.get_text_props(obj).font_size)
@classmethod
def export_alignment(cls, obj: bpy.types.Object) -> str:
props = cls.get_text_props(obj)
if (alignment := props.align_vertical + "-" + props.align_horizontal) == "middle-middle":
return "center"
return alignment
@classmethod
def export_wrap_length(cls, obj: bpy.types.Object) -> str:
return cls.get_text_props(obj).newline_at
@classmethod
def export_symbol(cls, obj: bpy.types.Object) -> str:
return cls.get_text_props(obj).get_symbol()
@classmethod
def create_annotation_context(
cls, target_view: str, object_type: Optional[str] = None
) -> ifcopenshell.entity_instance:
# checking PLAN target view and annotation type that doesn't require 3d
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") and object_type not in (
"FALL",
"SECTION_LEVEL",
"PLAN_LEVEL",
):
parent = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan")
if not parent:
parent = ifcopenshell.api.context.add_context(tool.Ifc.get(), context_type="Plan")
else:
parent = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model")
if not parent:
parent = ifcopenshell.api.context.add_context(tool.Ifc.get(), context_type="Model")
return ifcopenshell.api.context.add_context(
tool.Ifc.get(),
context_type=parent.ContextType,
context_identifier="Annotation",
target_view=target_view,
parent=parent,
)
@classmethod
def get_annotation_context(
cls, target_view: str, object_type: Optional[str] = None
) -> Union[ifcopenshell.entity_instance, None]:
# checking PLAN target view and annotation type that doesn't require 3d
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") and object_type not in (
"FALL",
"SECTION_LEVEL",
"PLAN_LEVEL",
):
return ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Annotation", target_view)
return ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Annotation", target_view)
@classmethod
def get_body_context(cls) -> ifcopenshell.entity_instance:
return ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@classmethod
def get_document_uri(
cls, document: ifcopenshell.entity_instance, description: Optional[str] = None
) -> Union[str, None]:
if getattr(document, "Location", None):
if os.path.isabs(document.Location):
return document.Location
ifc_path = tool.Ifc.get_path()
if os.path.isfile(ifc_path):
ifc_path = os.path.dirname(ifc_path)
return os.path.abspath(os.path.join(ifc_path, document.Location))
if document.is_a("IfcDocumentInformation"):
if tool.Ifc.get_schema() == "IFC2X3":
references = document.DocumentReferences
else:
references = document.HasDocumentReferences
for reference in references:
if description and cls.get_reference_description(reference) != description:
continue
location = cls.get_document_uri(reference)
if location:
return location
@classmethod
def get_path_filename(cls, path: str) -> str:
return os.path.splitext(os.path.basename(path))[0]
@classmethod
def get_path_with_ext(cls, path: str, ext: str) -> str:
return os.path.splitext(path)[0] + f".{ext}"
@classmethod
def get_unit_system(cls) -> Literal["NONE", "METRIC", "IMPERIAL"]:
return bpy.context.scene.unit_settings.system
@classmethod
def get_drawing_collection(cls, drawing: ifcopenshell.entity_instance) -> Union[bpy.types.Collection, None]:
obj = tool.Ifc.get_object(drawing)
if obj:
return tool.Blender.get_object_bim_props(obj).collection
@classmethod
def get_drawing_group(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
for rel in drawing.HasAssignments or []:
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
return rel.RelatingGroup
@classmethod
def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Get the drawing that owns this group, if the group represents a drawing."""
if group.ObjectType != "DRAWING":
return None
for rel in group.IsGroupedBy or []:
for related_object in rel.RelatedObjects:
if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING":
return related_object
return None
@classmethod
def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
for rel in drawing.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
return rel.RelatingDocument
@classmethod
def get_drawing_references(cls, drawing: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]:
results: set[ifcopenshell.entity_instance] = set()
for inverse in tool.Ifc.get().get_inverse(drawing):
if inverse.is_a("IfcRelAssignsToProduct") and inverse.RelatingProduct == drawing:
results.update(inverse.RelatedObjects)
return results
@classmethod
def get_drawing_target_view(cls, drawing: ifcopenshell.entity_instance) -> str:
return ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}).get("TargetView", "MODEL_VIEW")
@classmethod
def ensure_drawings_parent_document(cls) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
for document in ifc_file.by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
return document
document = ifcopenshell.api.document.add_information(ifc_file)
if ifc_file.schema == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifcopenshell.api.document.edit_information(ifc_file, information=document, attributes=attributes)
return document
@classmethod
def ensure_drawings_parent_group(cls) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
for group in ifc_file.by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
return group
group = ifcopenshell.api.group.add_group(ifc_file)
ifcopenshell.api.group.edit_group(
ifc_file, group=group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
return group
@classmethod
def get_group_elements(cls, group: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
for rel in group.IsGroupedBy or []:
return rel.RelatedObjects
@classmethod
def get_ifc_representation_class(cls, object_type: str) -> str:
if object_type == "TEXT":
return "IfcTextLiteral"
elif object_type == "TEXT_LEADER":
return "IfcGeometricCurveSet/IfcTextLiteral"
return ""
@classmethod
def get_name(cls, element: ifcopenshell.entity_instance) -> Union[str, None]:
return element.Name
@classmethod
def update_camera_matrix(
cls,
matrix: Matrix,
*,
camera_dir: Vector,
up: Vector,
) -> None:
# Camera dir is actually Z-.
camera_dir = -camera_dir
right = up.cross(camera_dir)
assert isinstance(right, Vector)
matrix.col[0][:3] = right
matrix.col[1][:3] = up
matrix.col[2][:3] = camera_dir
@classmethod
def generate_drawing_matrix(
cls,
target_view: ifcopenshell.util.representation.TARGET_VIEW,
location_hint: Union[LocationHintLiteral, int],
) -> Matrix:
assert bpy.context.scene
m = Matrix()
x, y, z = (0, 0, 0) if location_hint == 0 else bpy.context.scene.cursor.matrix.translation
X, Y, Z = m.to_3x3()
if isinstance(location_hint, int):
if target_view == "REFLECTED_PLAN_VIEW":
# Flip Z axis.
m.col[2] *= -1
if location_hint:
storey = tool.Ifc.get_object(tool.Ifc.get().by_id(location_hint))
assert isinstance(storey, bpy.types.Object)
z = storey.matrix_world.translation.z
if target_view == "PLAN_VIEW":
# Keep default camera direction - Z-.
m.translation = (x, y, z + 1.6)
return m
elif target_view == "REFLECTED_PLAN_VIEW":
m.translation = (x, y, z + 1.6)
else:
assert False, target_view
return m
elif target_view == "ELEVATION_VIEW":
m.translation = (x, y, z)
if location_hint == "NORTH":
cls.update_camera_matrix(m, camera_dir=-Y, up=Z)
elif location_hint == "SOUTH":
cls.update_camera_matrix(m, camera_dir=Y, up=Z)
elif location_hint == "EAST":
cls.update_camera_matrix(m, camera_dir=-X, up=Z)
elif location_hint == "WEST":
cls.update_camera_matrix(m, camera_dir=X, up=Z)
return m
elif target_view == "SECTION_VIEW":
m.translation = (x, y, z)
X, Y, Z = m.to_3x3()
if location_hint == "NORTH":
cls.update_camera_matrix(m, camera_dir=Y, up=Z)
elif location_hint == "SOUTH":
cls.update_camera_matrix(m, camera_dir=-Y, up=Z)
elif location_hint == "EAST":
cls.update_camera_matrix(m, camera_dir=X, up=Z)
elif location_hint == "WEST":
cls.update_camera_matrix(m, camera_dir=-X, up=Z)
return m
elif target_view == "MODEL_VIEW":
assert (space := tool.Blender.get_view3d_space())
assert (r3d := space.region_3d)
return r3d.view_matrix.inverted()
return m
@classmethod
def generate_sheet_identification(cls) -> str:
number = len([d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "SHEET"])
number += 1
return "A" + str(number).zfill(2)
@classmethod
def get_text_literal(
cls, obj: bpy.types.Object, return_list: bool = False
) -> Union[ifcopenshell.entity_instance, None, list[ifcopenshell.entity_instance]]:
element = tool.Ifc.get_entity(obj)
if not element:
return
rep = cls.get_annotation_representation(element)
if not rep:
return [] if return_list else None
items = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
if not items:
return [] if return_list else None
if return_list:
return items
return items[0]
@classmethod
def is_editing_sheets(cls) -> bool:
props = tool.Drawing.get_document_props()
return props.is_editing_sheets
@classmethod
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
if not literal_attributes:
return
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
new_literals = [cls.add_literal(**a) for a in literal_attributes]
rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals
for literal in to_remove:
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
@classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
builder = ShapeBuilder(ifc_file)
origin = builder.create_axis2_placement_3d()
ifc_literal = ifc_file.create_entity(
"IfcTextLiteralWithExtent",
attributes.get("Literal", "Literal"),
origin,
attributes.get("Path", "RIGHT"),
ifc_file.create_entity("IfcPlanarExtent", 1000, 1000),
attributes.get("BoxAlignment", "bottom-left"),
)
return ifc_literal
@classmethod
def get_assigned_product(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct"):
product = rel.RelatingProduct
if product.is_a("IfcGrid") and rel.Name:
for attribute in ("UAxes", "VAxes", "WAxes"):
for axis in getattr(product, attribute) or []:
if axis.AxisTag == rel.Name:
return axis
return product
@classmethod
def get_assigned_product_workaround(
cls, element: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
"""Get all products assigned to the element.
A workaround allowing to unassign accumulated products until we properly resolve #4014.
In theory annotations should have more than one product assigned,
but there's still undefined bug causing that in some cases.
"""
assigned_products: list[ifcopenshell.entity_instance] = []
for rel in element.HasAssignments:
if not rel.is_a("IfcRelAssignsToProduct"):
continue
assigned_products.append(rel.RelatingProduct)
if len(assigned_products) > 1:
print(
f"WARNING. Detected multiple assigned products ({len(assigned_products)}) for annotation '{element}'."
"\nIf you can reproduce this, please report it to Bonsai developers "
"at https://github.com/IfcOpenShell/IfcOpenShell/issues/4014."
"\nAssigned products:\n" + "\n".join([str(p) for p in assigned_products])
)
return assigned_products
@classmethod
def import_annotations_in_group(cls, group: ifcopenshell.entity_instance) -> None:
elements = set(
[
e
for e in cls.get_group_elements(group)
if e.is_a("IfcAnnotation") and e.ObjectType != "DRAWING" and not tool.Ifc.get_object(e)
]
)
logger = logging.getLogger("ImportIFC")
ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger)
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = tool.Ifc.get()
ifc_importer.calculate_unit_scale()
ifc_importer.process_context_filter()
ifc_importer.load_existing_meshes()
ifc_importer.material_creator.load_existing_materials()
ifc_importer.create_generic_elements(elements)
ifc_importer.setup_arrays(annotations_to_import=elements)
for obj in ifc_importer.added_data.values():
tool.Collector.assign(obj)
@classmethod
def get_camera_shape_matrix(
cls, drawing: ifcopenshell.entity_instance, shape: ifcopenshell.geom.ShapeElementType
) -> Matrix:
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
if cls.get_drawing_target_view(drawing) == "REFLECTED_PLAN_VIEW":
mat[1][1] *= -1
return mat
# NOTE: EPsetDrawing pset is completely synced with BIMCameraProperties
# but BIMCameraProperties are only synced with EPsetDrawing at drawing import
# therefore camera props can differ from pset if the user changed them from pset.
@classmethod
def import_drawing(cls, drawing: ifcopenshell.entity_instance) -> bpy.types.Object:
settings = ifcopenshell.geom.settings()
representation = ifcopenshell.util.representation.get_representation(drawing, "Model", "Body", "MODEL_VIEW")
assert representation
shape = ifcopenshell.geom.create_shape(settings, drawing)
camera = tool.Loader.create_camera(drawing, representation, shape)
tool.Loader.link_mesh(shape, camera)
obj = bpy.data.objects.new(tool.Loader.get_name(drawing), camera)
cls.import_camera_props(drawing, camera)
tool.Ifc.link(drawing, obj)
obj.matrix_world = cls.get_camera_shape_matrix(drawing, shape)
tool.Geometry.record_object_position(obj)
tool.Collector.assign(obj)
return obj
@classmethod
def import_temporary_drawing_camera(cls, drawing: ifcopenshell.entity_instance) -> bpy.types.Object:
settings = ifcopenshell.geom.settings()
representation = ifcopenshell.util.representation.get_representation(drawing, "Model", "Body", "MODEL_VIEW")
assert representation
shape = ifcopenshell.geom.create_shape(settings, drawing)
camera = tool.Loader.create_camera(drawing, representation, shape)
if obj := bpy.data.objects.get("TemporaryDrawingCamera"):
obj.data = camera
else:
obj = bpy.data.objects.new(tool.Loader.get_name(drawing), camera)
obj.matrix_world = cls.get_camera_shape_matrix(drawing, shape)
return obj
@classmethod
def import_camera_props(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None:
from bonsai.bim.module.drawing.prop import get_diagram_scales
# Temporarily clear the definition id to prevent prop update callbacks to IFC.
camera_props = cls.get_camera_props(camera)
update_props = camera_props.update_props
camera_props.update_props = False
camera_props.has_underlay = False
camera_props.has_linework = True
camera_props.has_annotation = True
camera_props.target_view = "PLAN_VIEW"
camera_props.is_nts = False
camera_props.use_edge_classification = False
camera_props.render_creases = True
camera_props.valley_angle_min_degrees = 12.0
camera_props.render_sharp = True
camera_props.ridge_angle_min_degrees = 45.0
camera_props.render_flush = False
camera.shift_x = 0.0
camera.shift_y = 0.0
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
if pset:
if "TargetView" in pset:
camera_props.target_view = pset["TargetView"]
if "Scale" in pset:
valid_scales = [
i[0] for i in get_diagram_scales(None, bpy.context) if pset["Scale"] == i[0].split("|")[-1]
]
if valid_scales:
camera_props.diagram_scale = valid_scales[0]
else:
camera_props.diagram_scale = "CUSTOM"
if ":" in pset["HumanScale"]:
numerator, denominator = pset["HumanScale"].split(":")
else:
numerator, denominator = pset["HumanScale"].split("=")
camera_props.custom_scale_numerator = numerator
camera_props.custom_scale_denominator = denominator
if "HasUnderlay" in pset:
camera_props.has_underlay = bool(pset["HasUnderlay"])
if "HasLinework" in pset:
camera_props.has_linework = bool(pset["HasLinework"])
if "HasAnnotation" in pset:
camera_props.has_annotation = bool(pset["HasAnnotation"])
if "IsNTS" in pset:
camera_props.is_nts = bool(pset["IsNTS"])
if "UseEdgeClassification" in pset:
camera_props.use_edge_classification = bool(pset["UseEdgeClassification"])
if "RenderCreases" in pset:
camera_props.render_creases = bool(pset["RenderCreases"])
if "ValleyAngleMinDegrees" in pset:
camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"])
if "RenderSharp" in pset:
camera_props.render_sharp = bool(pset["RenderSharp"])
if "RidgeAngleMinDegrees" in pset:
camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"])
if "RenderFlush" in pset:
camera_props.render_flush = bool(pset["RenderFlush"])
if "DPI" in pset:
camera_props.dpi = int(pset["DPI"])
if "LineworkMode" in pset:
camera_props.linework_mode = str(pset["LineworkMode"])
if "FillMode" in pset:
camera_props.fill_mode = str(pset["FillMode"])
if "CutMode" in pset:
camera_props.cut_mode = str(pset["CutMode"])
if camera.type == "PERSP":
shifts = cls.get_perspective_camera_shifts(drawing)
camera.shift_x = shifts["shift_x"]
camera.shift_y = shifts["shift_y"]
camera_props.update_props = update_props
drawing_selected_states: dict[int, bool] = {}
@classmethod
def import_drawings(cls) -> None:
props = tool.Drawing.get_document_props()
expanded_target_views = {d.target_view for d in props.drawings if not d.is_drawing and d.is_expanded}
cls.drawing_selected_states.update({d.ifc_definition_id: d.is_selected for d in props.drawings if d.is_drawing})
props.drawings.clear()
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
grouped_drawings: dict[str, list[ifcopenshell.entity_instance]] = {
"MODEL_VIEW": [],
"PLAN_VIEW": [],
"SECTION_VIEW": [],
"ELEVATION_VIEW": [],
"REFLECTED_PLAN_VIEW": [],
}
for drawing in drawings:
target_view = cls.get_drawing_target_view(drawing)
grouped_drawings.setdefault(target_view, []).append(drawing)
for target_view, drawings in grouped_drawings.items():
new = props.drawings.add()
new.name = target_view.replace("_", " ").title() + f" ({len(drawings)})"
new.target_view = target_view
new.is_drawing = False
new.is_expanded = target_view in expanded_target_views
if not new.is_expanded:
continue
for drawing in sorted(drawings, key=lambda x: x.Name or "Unnamed"):
new = props.drawings.add()
new.name = drawing.Name or "Unnamed"
new.is_selected = cls.drawing_selected_states.setdefault(drawing.id(), True)
new.is_drawing = True
new.ifc_definition_id = drawing.id() # Last, to prevent unnecessary prop callbacks
@classmethod
def import_documents(cls, document_type: DOCUMENT_TYPE) -> None:
dprops = cls.get_document_props()
if document_type == "SCHEDULE":
documents_collection = dprops.schedules
elif document_type == "REFERENCE":
documents_collection = dprops.references
documents_collection.clear()
schedules = [d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == document_type]
for schedule in schedules:
new = documents_collection.add()
new.ifc_definition_id = schedule.id()
new.name = schedule.Name or "Unnamed"
new.identification = tool.Document.get_document_information_id(schedule) or ""
@classmethod
def get_sheet_identification(cls, sheet: ifcopenshell.entity_instance) -> str:
"""Schema agnostic method to get IfcDocumentInformation.Identification."""
attr = "DocumentId" if sheet.file.schema == "IFC2X3" else "Identification"
return getattr(sheet, attr)
@classmethod
def import_sheets(cls) -> None:
props = cls.get_document_props()
expanded_sheets = {s.ifc_definition_id for s in props.sheets if s.is_expanded}
if not hasattr(cls, "sheet_selected_states"):
cls.sheet_selected_states = {}
cls.sheet_selected_states.update({s.ifc_definition_id: s.is_selected for s in props.sheets if s.is_sheet})
props.sheets.clear()
sheets = [d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "SHEET"]
for sheet in sorted(sheets, key=lambda s: cls.get_sheet_identification(s)):
new = props.sheets.add()
new.ifc_definition_id = sheet.id()
if tool.Ifc.get_schema() == "IFC2X3":
new.identification = sheet.DocumentId
else:
new.identification = sheet.Identification
new.name = sheet.Name
new.is_sheet = True
new.is_expanded = sheet.id() in expanded_sheets
new.is_selected = cls.sheet_selected_states.setdefault(sheet.id(), True)
if not new.is_expanded:
continue
for reference in cls.get_document_references(sheet):
reference_description = cls.get_reference_description(reference)
if reference_description in ("SHEET", "LAYOUT", "RASTER"):
# These references are an internal detail and should not be visible to users
continue
new = props.sheets.add()
new.ifc_definition_id = reference.id()
new.is_sheet = False
new.identification = tool.Document.get_external_reference_id(reference) or ""
new.name = os.path.basename(reference.Location)
new.reference_type = reference_description
@classmethod
def get_active_sheet(cls) -> Sheet:
props = cls.get_document_props()
# Will also get active sheet even if one of it's subitems selected (drawings, etc).
return next(s for s in props.sheets[: props.active_sheet_index + 1][::-1] if s.is_sheet)
@classmethod
def get_active_drawing_item(cls) -> Union[DrawingProperties, None]:
props = cls.get_document_props()
drawing_index = props.active_drawing_index
if len(props.drawings) > drawing_index >= 0:
item = props.drawings[drawing_index]
if item.is_drawing:
return item
@classmethod
def get_active_sheet_item(cls, *, is_sheet: bool = False, reference_type: str = "") -> Union[Sheet, None]:
props = cls.get_document_props()
sheet_index = props.active_sheet_index
if len(props.sheets) > sheet_index >= 0:
item = props.sheets[sheet_index]
if not is_sheet and not reference_type:
return item
if is_sheet:
if item.is_sheet:
return item
elif reference_type:
if item.reference_type == reference_type:
return item
@classmethod
def import_text_attributes(cls, obj: bpy.types.Object) -> None:
props = cls.get_text_props(obj)
props.literals.clear()
ifc_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(ifc_literals, list)
if ifc_literals:
first_alignment = getattr(ifc_literals[0], "BoxAlignment", None) or "bottom-left"
if first_alignment == "center":
first_alignment = "middle-middle"
props.align_vertical, props.align_horizontal = first_alignment.split("-")
for ifc_literal in ifc_literals:
literal_props = props.literals.add()
bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)
alignment = getattr(ifc_literal, "BoxAlignment", None) or "bottom-left"
if alignment == "center":
alignment = "middle-middle"
literal_props.align_vertical, literal_props.align_horizontal = alignment.split("-")
literal_props.ifc_definition_id = ifc_literal.id()
from bonsai.bim.module.drawing.data import DecoratorData
text_data = DecoratorData.get_text_data(obj)
props.font_size = str(text_data["FontSize"])
props.newline_at = text_data["Newline_At"]
props.set_symbol(text_data["Symbol"])
@classmethod
def import_assigned_product(cls, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
product = cls.get_assigned_product(element)
props = cls.get_object_assigned_product_props(obj)
if product:
assert isinstance(product_obj := tool.Ifc.get_object(product), bpy.types.Object)
props.relating_product = product_obj
else:
props.relating_product = None
@classmethod
def open_with_user_command(cls, user_command: str, path: str) -> None:
if user_command:
commands = json.loads(user_command)
replacements = {"path": path}
for command in commands:
command[0] = shutil.which(command[0]) or command[0]
subprocess.Popen([replacements.get(c, c) for c in command])
else:
if platform.system() == "Darwin":
subprocess.call(("open", path))
elif platform.system() == "Windows":
os.startfile(os.path.normpath(path))
else:
subprocess.call(("xdg-open", path))
@classmethod
def open_spreadsheet(cls, uri: str) -> None:
cls.open_with_user_command(tool.Blender.get_addon_preferences().spreadsheet_command, uri)
@classmethod
def open_svg(cls, uri: str) -> None:
cls.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, uri)
@classmethod
def open_layout_svg(cls, uri: str) -> None:
cls.open_with_user_command(tool.Blender.get_addon_preferences().layout_svg_command, uri)
@classmethod
def run_root_assign_class(
cls,
obj=None,
ifc_class=None,
predefined_type=None,
should_add_representation=True,
context=None,
ifc_representation_class=None,
) -> Union[ifcopenshell.entity_instance, None]:
return bonsai.core.root.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=ifc_class,
predefined_type=predefined_type,
should_add_representation=should_add_representation,
context=context,
ifc_representation_class=ifc_representation_class,
)
@classmethod
def run_type_assign_type(cls, element: ifcopenshell.entity_instance, relating_type: ifcopenshell.entity_instance):
return bonsai.core.type.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
@classmethod
def reload_representation(cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance):
return bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
@classmethod
def get_representation(cls, element, context):
return ifcopenshell.util.representation.get_representation(element, context)
@classmethod
def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None:
camera = tool.Ifc.get_object(drawing)
if camera and camera.name != name:
camera.name = name
@classmethod
def set_drawing_collection_name(
cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection
) -> None:
collection.name = tool.Loader.get_name(drawing)
@classmethod
def set_name(cls, element: ifcopenshell.entity_instance, name: str) -> None:
element.Name = name
@classmethod
def show_decorations(cls) -> None:
props = tool.Drawing.get_document_props()
props.should_draw_decorations = True
@classmethod
def edit_text_font_size(cls, obj: bpy.types.Object, font_size: float) -> None:
"""updates pset `EPset_Annotation.Classes` value
based on current font size from `obj.BIMTextProperties.font_size`
"""
from bonsai.bim.module.drawing.data import FONT_SIZES
props = cls.get_text_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
# updating text font size in EPset_Annotation.Classes
font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None)
classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes")
assert isinstance(classes, Union[str, None])
classes_split = classes.split() if classes else []
different_font_sizes = [c for c in classes_split if c in FONT_SIZES and c != font_size_str]
# We do need to change pset value in ifc,
# but only if there are different font sizes in classes already
# or if the current font size is not present in classes
# (except regular font size because it's default).
if different_font_sizes or (font_size_str not in classes_split and font_size_str != "regular"):
assert font_size_str is not None
classes_split = [c for c in classes_split if c not in FONT_SIZES] + [font_size_str]
classes = " ".join(classes_split)
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": classes})
@classmethod
def edit_text_wrap_length(cls, obj: bpy.types.Object, wrap_length: int) -> None:
element = tool.Ifc.get_entity(obj)
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Newline_At": wrap_length})
@classmethod
def edit_text_symbol(cls, obj: bpy.types.Object, symbol: str) -> None:
element = tool.Ifc.get_entity(obj)
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Symbol": symbol})
@classmethod
def edit_text_alignment(cls, obj: bpy.types.Object, alignment: str) -> None:
ifc_literals = cls.get_text_literal(obj, return_list=True)
for ifc_literal in ifc_literals or []:
if ifc_literal.is_a("IfcTextLiteralWithExtent"):
ifc_literal.BoxAlignment = alignment
# TODO below this point is highly experimental prototype code with no tests
class SheetWarningType(NamedTuple):
warning_type: Literal["MISSING_LAYOUT", "MISSING_TITLEBLOCK"]
message: str
def __str__(self) -> str:
return f"{self.warning_type:<20} - {self.message}"
@classmethod
def validate_sheet_files(cls, sheet: ifcopenshell.entity_instance) -> list[SheetWarningType]:
warnings: list[tool.Drawing.SheetWarningType] = []
layout_path = cls.get_document_uri(sheet, "LAYOUT")
assert layout_path
sheet_id = cls.get_sheet_identification(sheet)
if not Path(layout_path).exists():
warnings.append(
cls.SheetWarningType("MISSING_LAYOUT", f"Sheet '{sheet_id}' - missing layout '{layout_path}'.")
)
titleblock_path = cls.get_document_uri(sheet, "TITLEBLOCK")
assert titleblock_path
if not Path(titleblock_path).exists():
warnings.append(
cls.SheetWarningType(
"MISSING_TITLEBLOCK", f"Sheet '{sheet_id}' - missing titleblock '{titleblock_path}'."
)
)
return warnings
@classmethod
def does_file_exist(cls, uri: str) -> bool:
return os.path.exists(uri)
@classmethod
def delete_file(cls, uri: str) -> None:
os.remove(uri)
@classmethod
def move_file(cls, src: str, dest: str) -> None:
try:
shutil.move(src, dest)
except:
# Perhaps the file is locked in Windows?
shutil.copy(src, dest)
@classmethod
def generate_drawing_name(
cls,
target_view: ifcopenshell.util.representation.TARGET_VIEW,
location_hint: Union[LocationHintLiteral, int],
) -> str:
if isinstance(location_hint, int):
if location_hint:
location = tool.Ifc.get().by_id(location_hint)
target_view_ = target_view
if target_view == "REFLECTED_PLAN_VIEW":
target_view_ = "RCP_VIEW"
return (location.Name or "UNNAMED").upper() + " " + target_view_.split("_")[0]
elif target_view in ("SECTION_VIEW", "ELEVATION_VIEW") and location_hint:
return location_hint + " " + target_view.split("_")[0]
elif target_view == "MODEL_VIEW" and location_hint:
return location_hint
return target_view
@classmethod
def get_default_layout_path(cls, identification: str, name: str) -> str:
project = tool.Ifc.get().by_type("IfcProject")[0]
prefs = tool.Blender.get_addon_preferences()
layouts_dir = (
ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "LayoutsDir") or prefs.doc.layouts_dir
)
return os.path.join(layouts_dir, cls.sanitise_filename(f"{identification} - {name}.svg")).replace("\\", "/")
@classmethod
def get_default_sheet_path(cls, identification: str, name: str) -> str:
project = tool.Ifc.get().by_type("IfcProject")[0]
prefs = tool.Blender.get_addon_preferences()
sheets_dir = (
ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "SheetsDir") or prefs.doc.sheets_dir
)
return os.path.join(sheets_dir, cls.sanitise_filename(f"{identification} - {name}.svg")).replace("\\", "/")
@classmethod
def get_default_titleblock_path(cls, name: str) -> str:
project = tool.Ifc.get().by_type("IfcProject")[0]
prefs = tool.Blender.get_addon_preferences()
titleblocks_dir = (
ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "TitleblocksDir")
or prefs.doc.titleblocks_dir
)
return os.path.join(titleblocks_dir, cls.sanitise_filename(f"{name}.svg")).replace("\\", "/")
@classmethod
def get_default_drawing_path(cls, name: str) -> str:
project = tool.Ifc.get().by_type("IfcProject")[0]
prefs = tool.Blender.get_addon_preferences()
drawings_dir = (
ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "DrawingsDir") or prefs.doc.drawings_dir
)
return os.path.join(drawings_dir, cls.sanitise_filename(f"{name}.svg")).replace("\\", "/")
@classmethod
def sanitise_filename(cls, name: str) -> str:
return "".join(x for x in name if (x.isalnum() or x in "._- "))
ResourceType = Literal["Stylesheet", "Markers", "Symbols", "Patterns", "ShadingStyles"]
RESOURCE_TYPES = ("Stylesheet", "Markers", "Symbols", "Patterns", "ShadingStyles")
@classmethod
def get_default_drawing_resource_path(cls, resource: ResourceType) -> Union[str, None]:
project = tool.Ifc.get().by_type("IfcProject")[0]
doc_prefs = tool.Blender.get_addon_preferences().doc
resource_path = ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", f"{resource}Path") or getattr(
doc_prefs, f"{resource.lower()}_path"
)
if resource_path:
assert isinstance(resource_path, str)
return resource_path.replace("\\", "/")
@classmethod
def get_default_shading_style(cls) -> str:
prefs = tool.Blender.get_addon_preferences()
return prefs.doc.shadingstyle_default
@classmethod
def setup_shading_styles_path(cls, resource_path: str) -> None:
resource_path = tool.Ifc.resolve_uri(resource_path)
os.makedirs(os.path.dirname(resource_path), exist_ok=True)
if not os.path.exists(resource_path):
resource_basename = os.path.basename(resource_path)
ootb_resource = tool.Blender.get_data_dir_path(Path("assets") / resource_basename)
if ootb_resource.is_file():
shutil.copy(ootb_resource, resource_path)
@classmethod
def get_potential_reference_elements(
cls, drawing: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
elements = []
existing_references = set(cls.get_group_elements(cls.get_drawing_group(drawing)))
if exclude := ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "Exclude"):
existing_references.update(ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), exclude))
for element in tool.Ifc.get().by_type("IfcAnnotation"):
if element in existing_references or element == drawing:
continue
if element.ObjectType == "DRAWING":
pset = ifcopenshell.util.element.get_psets(element).get("EPset_Drawing", {})
if pset.get("TargetView", None) in ("SECTION_VIEW", "ELEVATION_VIEW") and pset.get(
"GlobalReferencing", False
):
elements.append(element)
for element in tool.Ifc.get().by_type("IfcGrid"):
if element in existing_references:
continue
for axis in element.UAxes + element.VAxes + (element.WAxes or tuple()):
if axis in existing_references:
continue
elements.append(axis)
target_view = tool.Drawing.get_drawing_target_view(drawing)
if target_view in ("SECTION_VIEW", "ELEVATION_VIEW"):
for element in tool.Ifc.get().by_type("IfcBuildingStorey"):
if element in existing_references:
continue
elements.append(element)
return elements
@classmethod
def is_auto_annotation(cls, element: ifcopenshell.entity_instance):
return element.is_a("IfcAnnotation") and element.ObjectType in ("GRID", "SECTION", "ELEVATION", "SECTION_LEVEL")
@classmethod
def get_drawing_reference_annotation(
cls, drawing: ifcopenshell.entity_instance, reference_element: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, bool, None]:
if drawing == reference_element:
return True
if reference_element.is_a("IfcGridAxis"):
# We cannot associate IfcGridAxis directly, so we establish a convention:
# IfcRelAssignsToProduct.RelatingProduct = IfcGrid
# IfcRelAssignsToProduct.Name = IfcGridAxis.AxisTag
grid = None
for attribute in ("PartOfU", "PartOfV", "PartOfW"):
if getattr(reference_element, attribute, None):
grid = getattr(reference_element, attribute)[0]
break
for element in cls.get_group_elements(cls.get_drawing_group(drawing)):
if not element.is_a("IfcAnnotation"):
continue
for rel in element.HasAssignments:
if (
rel.is_a("IfcRelAssignsToProduct")
and rel.RelatingProduct == grid
and rel.Name == reference_element.AxisTag
):
return element
return
for element in cls.get_group_elements(cls.get_drawing_group(drawing)):
if element.is_a("IfcAnnotation"):
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct == reference_element:
return element
@classmethod
def regenerate_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
annotation: ifcopenshell.entity_instance,
reference_element: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if reference_element.is_a("IfcGridAxis"):
return cls.regenerate_grid_axis_reference_annotation(drawing, annotation, reference_element, context)
elif reference_element.is_a("IfcAnnotation") and reference_element.ObjectType == "DRAWING":
target_view = ifcopenshell.util.element.get_pset(reference_element, "EPset_Drawing", "TargetView")
if target_view == "ELEVATION_VIEW":
return cls.regenerate_elevation_reference_annotation(drawing, annotation, reference_element, context)
elif target_view == "SECTION_VIEW":
return cls.regenerate_section_reference_annotation(drawing, annotation, reference_element, context)
elif reference_element.is_a("IfcBuildingStorey"):
return cls.regenerate_storey_annotation(drawing, annotation, reference_element, context)
return annotation
@classmethod
def generate_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
reference_element: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if reference_element.is_a("IfcGridAxis"):
return cls.generate_grid_axis_reference_annotation(drawing, reference_element, context)
elif reference_element.is_a("IfcAnnotation") and reference_element.ObjectType == "DRAWING":
target_view = ifcopenshell.util.element.get_pset(reference_element, "EPset_Drawing", "TargetView")
if target_view == "ELEVATION_VIEW":
return cls.generate_elevation_reference_annotation(drawing, reference_element, context)
elif target_view == "SECTION_VIEW":
return cls.generate_section_reference_annotation(drawing, reference_element, context)
elif reference_element.is_a("IfcBuildingStorey"):
return cls.generate_storey_annotation(drawing, reference_element, context)
@classmethod
def generate_storey_points(
cls, drawing: ifcopenshell.entity_instance, storey: ifcopenshell.entity_instance
) -> list | None:
import bonsai.bim.module.drawing.helper as helper
camera = tool.Ifc.get_object(drawing)
if camera.data.type != "ORTHO":
return
if not cls.is_matrix_perpendicular(camera.matrix_world, Matrix()):
return
xmin, xmax, ymin, ymax = helper.ortho_view_frame(camera.data)[:4]
rl = ifcopenshell.util.placement.get_local_placement(storey.ObjectPlacement)[2][3]
# Convert RL from project units (feet) to meters for Blender world space
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
rl_meters = rl * unit_scale
y = (camera.matrix_world.inverted() @ Vector((0.0, 0.0, rl_meters))).y
if y < ymin or y > ymax:
return
return (Vector((xmax, y, 0.0)), Vector((xmin, y, 0.0)))
@classmethod
def generate_storey_annotation(
cls,
drawing: ifcopenshell.entity_instance,
storey: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if not (points := cls.generate_storey_points(drawing, storey)):
return
camera = tool.Ifc.get_object(drawing)
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new(storey.Name or "Unnamed", mesh)
obj.matrix_world = cls.get_default_annotation_matrix(camera)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION_LEVEL", should_add_representation=False
)
tool.Geometry.run_edit_object_placement(obj)
element.Name = storey.Name or "Unnamed"
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
points = [p / unit_scale for p in points]
representation = builder.get_representation(context, [builder.polyline(points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
return element
@classmethod
def regenerate_storey_annotation(
cls,
drawing: ifcopenshell.entity_instance,
annotation: ifcopenshell.entity_instance,
storey: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if not (points := cls.generate_storey_points(drawing, storey)):
return
camera = tool.Ifc.get_object(drawing)
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, annotation)
m = ifcopenshell.util.shape.get_shape_matrix(shape)
mw = cls.get_default_annotation_matrix(camera)
existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)]
new_points = None
if not np.allclose(m, np.array(mw), atol=1e-4):
new_points = points
elif len(existing_verts) != 2:
new_points = points
else:
existing_verts = sorted(existing_verts, key=lambda v: v.x)
xmin, xmax = [v.x for v in existing_verts]
y = points[0].y
if not tool.Cad.is_x(y, existing_verts[0].y) or not tool.Cad.is_x(y, existing_verts[1].y):
new_points = (Vector((xmax, y, 0.0)), Vector((xmin, y, 0.0)))
if new_points:
if representation := ifcopenshell.util.representation.get_representation(annotation, context):
ifcopenshell.api.geometry.unassign_representation(
tool.Ifc.get(), product=annotation, representation=representation
)
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
new_points = [p / unit_scale for p in new_points]
representation = builder.get_representation(context, [builder.polyline(new_points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation)
if obj := tool.Ifc.get_object(annotation):
obj.matrix_world = mw
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
else:
ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw))
annotation.Name = storey.Name or "Unnamed"
return annotation
@classmethod
def generate_section_reference_points(
cls, drawing: ifcopenshell.entity_instance, section: ifcopenshell.entity_instance
) -> list | None:
import bonsai.bim.module.drawing.helper as helper
camera = tool.Ifc.get_object(drawing)
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, section)
m = ifcopenshell.util.shape.get_shape_matrix(shape)
if not cls.is_matrix_perpendicular(camera.matrix_world, Matrix(m)):
return
if not cls.does_shape_intersect_camera(shape, camera):
return
# Get cutting plane as a line
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
cutting_plane_verts = sorted(verts, key=lambda v: v[2])[-4:]
v1, *_, v2 = sorted(cutting_plane_verts, key=lambda v: v[0]) # Cut is in +X direction
im = camera.matrix_world.inverted()
v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in [v1, v2]]
target_view = cls.get_drawing_target_view(drawing)
bounds = helper.ortho_view_frame(camera.data)
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
# For plan views, clip to XY bounds and set Z=0
if not (points := helper.clip_segment(bounds, [v1, v2])):
return
for v in points:
v.z = 0
elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"):
# For section/elevation views, elevate the segment vertically
if not (points := helper.elevate_segment(bounds, [v1, v2])):
return
elif target_view == "MODEL_VIEW":
# For model views, clip to XY bounds and keep Z (3D line at true elevation)
if not (points := helper.clip_segment(bounds, [v1, v2])):
return
else:
return
return points
@classmethod
def generate_section_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
section: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if not (points := cls.generate_section_reference_points(drawing, section)):
return
camera = tool.Ifc.get_object(drawing)
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new(section.Name or "Unnamed", mesh)
obj.matrix_world = cls.get_default_annotation_matrix(camera)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="SECTION", should_add_representation=False
)
element.Name = section.Name or "Unnamed"
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
points = [p / unit_scale for p in points]
representation = builder.get_representation(context, [builder.polyline(points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
return element
@classmethod
def regenerate_section_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
annotation: ifcopenshell.entity_instance,
section: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
if not (points := cls.generate_section_reference_points(drawing, section)):
return
camera = tool.Ifc.get_object(drawing)
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, annotation)
m = ifcopenshell.util.shape.get_shape_matrix(shape)
mw = cls.get_default_annotation_matrix(camera)
existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)]
new_points = None
if not np.allclose(m, np.array(mw), atol=1e-4):
new_points = points
elif len(existing_verts) != 2:
new_points = points
else:
if not tool.Cad.are_edges_collinear(existing_verts, points):
# Attempt to update the section line by projecting existing verts onto the new line
v1 = tool.Cad.point_on_edge(existing_verts[0], points)
v2 = tool.Cad.point_on_edge(existing_verts[1], points)
existing_length = (existing_verts[0] - existing_verts[1]).length
new_length = (v2 - v1).length
if abs((existing_length - new_length) / existing_length) <= 0.10:
# If the projected line is within 10% of the previous length ...
new_points = (v1, v2)
else:
new_points = points
if new_points:
if representation := ifcopenshell.util.representation.get_representation(annotation, context):
ifcopenshell.api.geometry.unassign_representation(
tool.Ifc.get(), product=annotation, representation=representation
)
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
new_points = [p / unit_scale for p in new_points]
representation = builder.get_representation(context, [builder.polyline(new_points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation)
if obj := tool.Ifc.get_object(annotation):
obj.matrix_world = mw
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
else:
ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw))
annotation.Name = section.Name or "Unnamed"
return annotation
@classmethod
def generate_elevation_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
elevation: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
camera = tool.Ifc.get_object(drawing)
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, elevation)
m = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
if cls.is_matrix_perpendicular(camera.matrix_world, m) and cls.does_shape_intersect_camera(shape, camera):
obj = bpy.data.objects.new(elevation.Name or "Unnamed", None)
obj.empty_display_size = 0.1
obj.matrix_world = cls.get_default_annotation_matrix(camera, matrix_world=m)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="ELEVATION", should_add_representation=False
)
element.Name = elevation.Name or "Unnamed"
return element
@classmethod
def regenerate_elevation_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
annotation: ifcopenshell.entity_instance,
elevation: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> ifcopenshell.entity_instance:
camera = tool.Ifc.get_object(drawing)
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, elevation)
m = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
if cls.is_matrix_perpendicular(camera.matrix_world, m) and cls.does_shape_intersect_camera(shape, camera):
existing_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(annotation.ObjectPlacement))
# The user is allowed to shift the elevation, but not rotate it
if not np.allclose(np.array(m.to_3x3()), np.array(existing_matrix.to_3x3()), atol=1e-4):
mw = cls.get_default_annotation_matrix(camera, matrix_world=m)
if obj := tool.Ifc.get_object(annotation):
obj.matrix_world = mw
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
else:
ifcopenshell.api.geometry.edit_object_placement(
tool.Ifc.get(), product=annotation, matrix=np.array(mw)
)
annotation.Name = elevation.Name or "Unnamed"
return annotation
@classmethod
def generate_grid_axis_reference_points(
cls, drawing: ifcopenshell.entity_instance, axis: ifcopenshell.entity_instance
) -> list | None:
import bonsai.bim.module.drawing.helper as helper
camera = tool.Ifc.get_object(drawing)
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
verts = ifcopenshell.util.shape.get_vertices(geometry)
grid = (axis.PartOfU or axis.PartOfV or axis.PartOfW)[0]
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
im = camera.matrix_world.inverted()
v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in verts[:2]]
target_view = tool.Drawing.get_drawing_target_view(drawing)
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
if not (points := helper.clip_segment(bounds, [v1, v2])):
return
elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
if not (points := helper.elevate_segment(bounds, [v1, v2])):
return
else:
return
for v in points:
v.z = 0
return points
@classmethod
def generate_grid_axis_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
axis: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
if not (points := cls.generate_grid_axis_reference_points(drawing, axis)):
return
camera = tool.Ifc.get_object(drawing)
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new(axis.AxisTag or "-", mesh)
obj.matrix_world = cls.get_default_annotation_matrix(camera)
element = cls.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="GRID", should_add_representation=False
)
element.Name = axis.AxisTag or "-"
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
points = [p / unit_scale for p in points]
representation = builder.get_representation(context, [builder.polyline(points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
return element
@classmethod
def regenerate_grid_axis_reference_annotation(
cls,
drawing: ifcopenshell.entity_instance,
annotation: ifcopenshell.entity_instance,
axis: ifcopenshell.entity_instance,
context: ifcopenshell.entity_instance,
) -> Union[ifcopenshell.entity_instance, None]:
if not (points := cls.generate_grid_axis_reference_points(drawing, axis)):
return
camera = tool.Ifc.get_object(drawing)
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
shape = ifcopenshell.geom.create_shape(settings, annotation)
m = ifcopenshell.util.shape.get_shape_matrix(shape)
mw = cls.get_default_annotation_matrix(camera)
existing_verts = [Vector(v) for v in ifcopenshell.util.shape.get_vertices(shape.geometry)]
new_points = None
if not np.allclose(m, np.array(mw), atol=1e-4):
new_points = points
elif len(existing_verts) != 2:
new_points = points
else:
if not tool.Cad.are_edges_collinear(existing_verts, points):
# Attempt to update the section line by projecting existing verts onto the new line
v1 = tool.Cad.point_on_edge(existing_verts[0], points)
v2 = tool.Cad.point_on_edge(existing_verts[1], points)
existing_length = (existing_verts[0] - existing_verts[1]).length
new_length = (v2 - v1).length
if abs((existing_length - new_length) / existing_length) <= 0.10:
# If the projected line is within 10% of the previous length ...
new_points = (v1, v2)
else:
new_points = points
if new_points:
if representation := ifcopenshell.util.representation.get_representation(annotation, context):
ifcopenshell.api.geometry.unassign_representation(
tool.Ifc.get(), product=annotation, representation=representation
)
builder = ShapeBuilder(tool.Ifc.get())
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
new_points = [p / unit_scale for p in new_points]
representation = builder.get_representation(context, [builder.polyline(new_points)])
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), annotation, representation)
if obj := tool.Ifc.get_object(annotation):
obj.matrix_world = mw
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
else:
ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), product=annotation, matrix=np.array(mw))
annotation.Name = axis.AxisTag or "-"
return annotation
@classmethod
def get_default_annotation_matrix(cls, camera, matrix_world=None):
if matrix_world is None:
# Use normalized camera matrix (without scale) for RCP compatibility
matrix_world = cls.get_camera_matrix(camera)
# Check if this is an RCP (reflected ceiling plan) by checking for negative scale
camera_scale = camera.matrix_world.to_scale()
is_rcp = camera_scale.x < 0 or camera_scale.y < 0 or camera_scale.z < 0
# For RCP, the offset needs to be positive instead of negative
# because the Z axis is flipped 180° in get_camera_matrix
offset_direction = 1 if is_rcp else -1
annotation_offset = Vector((0, 0, offset_direction * (camera.data.clip_start + 0.05)))
annotation_offset = matrix_world.to_quaternion() @ annotation_offset
matrix_world.translation += annotation_offset
return matrix_world
@classmethod
def is_perpendicular(cls, a: bpy.types.Object, b: bpy.types.Object) -> bool:
axes = [mathutils.Vector((1, 0, 0)), mathutils.Vector((0, 1, 0)), mathutils.Vector((0, 0, 1))]
a_quaternion = a.matrix_world.to_quaternion()
b_quaternion = b.matrix_world.to_quaternion()
for axis in axes:
if abs((a_quaternion @ axis).angle(b_quaternion @ axis) - (math.pi / 2)) < 1e-5:
return True
return False
@classmethod
def is_matrix_perpendicular(cls, a: Matrix, b: Matrix) -> bool:
axes = [mathutils.Vector((1, 0, 0)), mathutils.Vector((0, 1, 0)), mathutils.Vector((0, 0, 1))]
a_quaternion = a.to_quaternion()
b_quaternion = b.to_quaternion()
for axis in axes:
if abs((a_quaternion @ axis).angle(b_quaternion @ axis) - (math.pi / 2)) < 1e-5:
return True
return False
@classmethod
def get_camera_block(cls, obj: bpy.types.Object) -> dict:
assert isinstance(camera := obj.data, bpy.types.Camera)
props = tool.Drawing.get_camera_props(camera)
raster_x = props.raster_x
raster_y = props.raster_y
if raster_x > raster_y:
width = camera.ortho_scale
height = width / raster_x * raster_y
else:
height = camera.ortho_scale
width = height / raster_y * raster_x
depth = camera.clip_end
verts = (
obj.matrix_world @ mathutils.Vector((-width / 2, -height / 2, -depth)),
obj.matrix_world @ mathutils.Vector((-width / 2, -height / 2, 0)),
obj.matrix_world @ mathutils.Vector((-width / 2, height / 2, -depth)),
obj.matrix_world @ mathutils.Vector((-width / 2, height / 2, 0)),
obj.matrix_world @ mathutils.Vector((width / 2, -height / 2, -depth)),
obj.matrix_world @ mathutils.Vector((width / 2, -height / 2, 0)),
obj.matrix_world @ mathutils.Vector((width / 2, height / 2, -depth)),
obj.matrix_world @ mathutils.Vector((width / 2, height / 2, 0)),
)
faces = [
[0, 1, 3, 2],
[2, 3, 7, 6],
[6, 7, 5, 4],
[4, 5, 1, 0],
[2, 6, 4, 0],
[7, 3, 1, 5],
]
return {"verts": verts, "faces": faces}
@classmethod
def is_intersecting(cls, a: bpy.types.Object, b: bpy.types.Object) -> bool:
a_block = cls.get_camera_block(a)
a_tree = mathutils.bvhtree.BVHTree.FromPolygons(a_block["verts"], a_block["faces"])
b_block = cls.get_camera_block(b)
b_tree = mathutils.bvhtree.BVHTree.FromPolygons(b_block["verts"], b_block["faces"])
return bool(a_tree.overlap(b_tree))
@classmethod
def does_shape_intersect_camera(cls, shape, camera) -> bool:
a_block = cls.get_camera_block(camera)
a_tree = mathutils.bvhtree.BVHTree.FromPolygons(a_block["verts"], a_block["faces"])
m = ifcopenshell.util.shape.get_shape_matrix(shape)
verts = [(m @ np.append(v, 1.0))[:3] for v in ifcopenshell.util.shape.get_vertices(shape.geometry)]
faces = ifcopenshell.util.shape.get_faces(shape.geometry)
b_tree = mathutils.bvhtree.BVHTree.FromPolygons(verts, faces)
return bool(a_tree.overlap(b_tree))
@classmethod
def replace_text_literal_variables(
cls,
text: str,
product: Optional[ifcopenshell.entity_instance] = None,
) -> str:
if not product:
return text
for command in re.findall("``.+?``", text):
original_command = command
command_content = command[2:-2]
try:
text = text.replace(original_command, ifcopenshell.util.selector.format(command_content, product))
except Exception:
text = text.replace(original_command, "")
for variable in re.findall("{{.*?}}", text):
value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2])
if isinstance(value, (list, tuple)):
value = ", ".join(str(v) for v in value)
text = text.replace(variable, str(value))
return text
@classmethod
def sync_object_representation(cls, obj: bpy.types.Object) -> None:
bpy.ops.bim.update_representation(obj=obj.name)
@classmethod
def sync_object_placement(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
blender_matrix = np.array(obj.matrix_world)
element = tool.Ifc.get_entity(obj)
if tool.Geometry.is_scaled(obj):
bpy.ops.bim.update_representation(obj=obj.name)
return element
if element.is_a("IfcGridAxis"):
return cls.sync_grid_axis_object_placement(obj, element)
if not hasattr(element, "ObjectPlacement"):
return
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
return element
@classmethod
def sync_grid_axis_object_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
grid = (element.PartOfU or element.PartOfV or element.PartOfW)[0]
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
cls.sync_object_placement(grid_obj)
if grid_obj.matrix_world != obj.matrix_world:
bpy.ops.bim.update_representation(obj=obj.name)
tool.Geometry.record_object_position(obj)
@classmethod
def get_document_references(cls, document: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
if tool.Ifc.get_schema() == "IFC2X3":
return document.DocumentReferences or []
return document.HasDocumentReferences or []
@classmethod
def get_references_with_location(cls, location: Union[str, None]) -> list[ifcopenshell.entity_instance]:
return [r for r in tool.Ifc.get().by_type("IfcDocumentReference") if r.Location == location]
@classmethod
def update_embedded_svg_location(cls, uri: str, reference: ifcopenshell.entity_instance, new_location: str) -> None:
tree = etree.parse(uri)
root = tree.getroot()
rel_location = os.path.relpath(new_location, os.path.dirname(uri))
for g in root.findall(
'.//{http://www.w3.org/2000/svg}g[@data-type="drawing"][@data-id="' + str(reference.id()) + '"]'
):
for foreground in g.findall('.//{http://www.w3.org/2000/svg}image[@data-type="foreground"]'):
foreground.attrib["{http://www.w3.org/1999/xlink}href"] = rel_location
for background in g.findall('.//{http://www.w3.org/2000/svg}image[@data-type="background"]'):
background.attrib["{http://www.w3.org/1999/xlink}href"] = rel_location[0:-4] + "-underlay.png"
tree.write(uri, pretty_print=True, xml_declaration=True, encoding="utf-8")
@classmethod
def get_reference_description(cls, reference: ifcopenshell.entity_instance) -> Union[str, None]:
if reference.file.schema == "IFC2X3":
return reference.Name
return reference.Description
@classmethod
def generate_reference_attributes(
cls, reference: ifcopenshell.entity_instance, **attributes: Any
) -> dict[str, Any]:
"""will automatically convert attributes below for IFC2X3 compatibility:
- Identification -> ItemReference
- Description -> Name
"""
if reference.file.schema == "IFC2X3":
if "Description" in attributes:
attributes["Name"] = attributes["Description"]
del attributes["Description"]
if "Identification" in attributes:
attributes["ItemReference"] = attributes["Identification"]
del attributes["Identification"]
return attributes
@classmethod
def get_reference_location(cls, reference: ifcopenshell.entity_instance) -> Union[str, None]:
return reference.Location
@classmethod
def get_reference_element(
cls, reference: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
if tool.Ifc.get_schema() == "IFC2X3":
refs = [r for r in tool.Ifc.get().by_type("IfcRelAssociatesDocument") if r.RelatingDocument == reference]
else:
refs = reference.DocumentRefForObjects
if refs:
return refs[0].RelatedObjects[0]
@classmethod
def get_drawing_human_scale(cls, drawing: ifcopenshell.entity_instance) -> str:
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") or {}
return "NTS" if pset.get("IsNTS", False) else pset.get("HumanScale", "NTS")
@classmethod
def get_drawing_metadata(cls, drawing: ifcopenshell.entity_instance) -> list[str]:
pset_data = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
metadata_str = pset_data.get("Metadata", "") or ""
return [v_ for v in metadata_str.split(",") if (v_ := v.strip())]
@classmethod
def get_annotation_z_index(cls, drawing: ifcopenshell.entity_instance) -> float:
return ifcopenshell.util.element.get_pset(drawing, "EPset_Annotation", "ZIndex") or 0
@classmethod
def get_annotation_symbol(cls, element: ifcopenshell.entity_instance) -> Union[str, None]:
symbol = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Symbol")
if not symbol:
# EPset_AnnotationSurveyArea is not standard! See bSI-4.3 proposal #660.
symbol = ifcopenshell.util.element.get_pset(element, "EPset_AnnotationSurveyArea", "PointType")
return symbol
@classmethod
def get_newline_at(cls, element: ifcopenshell.entity_instance) -> Union[int, 0]:
newline_at = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Newline_At")
return newline_at
@classmethod
def has_linework(cls, drawing: ifcopenshell.entity_instance) -> bool:
return ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}).get("HasLinework", False)
@classmethod
def has_annotation(cls, drawing: ifcopenshell.entity_instance) -> bool:
return ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}).get("HasAnnotation", False)
@classmethod
def get_drawing_elements(
cls, drawing: ifcopenshell.entity_instance, ifc_file: Optional[ifcopenshell.file] = None
) -> set[ifcopenshell.entity_instance]:
"""returns a set of elements that are included in the drawing"""
param_was_none = ifc_file is None
if param_was_none:
ifc_file = tool.Ifc.get()
pset = ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {})
include = pset.get("Include", None)
# Only the active IFC file has Blender objects we can test against the
# camera's view frustum, which lets us drop elements - including those
# picked by an Include filter - that fall outside the drawing boundary.
camera_view_elements = None
if (param_was_none or include) and ifc_file is tool.Ifc.get():
camera_view_elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects)
if include:
try:
data = json.loads(include)
if isinstance(data, dict) and "filter_structure" in data:
elements = tool.Search.execute_filter_groups_from_json(data, ifc_file)
elif isinstance(data, dict) and "query" in data:
elements = ifcopenshell.util.selector.filter_elements(ifc_file, data["query"])
else:
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
except (json.JSONDecodeError, ValueError):
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
# The Include filter chooses which elements may appear, but they must
# still fall within the drawing's camera boundary.
if camera_view_elements is not None:
elements &= camera_view_elements
else:
if param_was_none:
elements = camera_view_elements
else:
# This can probably be smarter
elements = set(ifc_file.by_type("IfcElement"))
if ifc_file.schema == "IFC2X3":
base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialStructureElement"))
else:
base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialElement"))
elements = {e for e in (elements & base_elements) if e.is_a() != "IfcSpace"}
updated_set = set()
for i in elements:
# exclude annotations to avoid including annotations from other drawings
if not i.is_a("IfcAnnotation"):
updated_set.add(i)
# add aggregate too, if element is host by one
if hasattr(i, "Decomposes") and (decomposes := i.Decomposes):
aggregate = decomposes[0].RelatingObject
# remove IfcProject for class iterator. See https://github.com/IfcOpenShell/IfcOpenShell/issues/4361#issuecomment-2081223615
if aggregate.is_a("IfcProduct"):
updated_set.add(aggregate)
elements = updated_set
# add annotations from the current drawing
annotations = tool.Drawing.get_group_elements(tool.Drawing.get_drawing_group(drawing))
elements.update(annotations)
exclude = pset.get("Exclude", None)
if exclude:
try:
data = json.loads(exclude)
if isinstance(data, dict) and "filter_structure" in data:
exclude_elements = tool.Search.execute_filter_groups_from_json(data, ifc_file)
elements -= exclude_elements
elif isinstance(data, dict) and "query" in data:
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, data["query"])
else:
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
except (json.JSONDecodeError, ValueError):
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
elements -= set(ifc_file.by_type("IfcOpeningElement"))
return elements
@classmethod
def get_drawing_spaces(cls, drawing: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]:
ifc_file = tool.Ifc.get()
pset = ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {})
elements = cls.get_elements_in_camera_view(
tool.Ifc.get_object(drawing), [tool.Ifc.get_object(e) for e in ifc_file.by_type("IfcSpace")]
)
# NOTE: EPset_Drawing.Include is not used to avoid adding other elements besides spaces
exclude = pset.get("Exclude", None)
if exclude:
try:
data = json.loads(exclude)
if isinstance(data, dict) and "filter_structure" in data:
exclude_elements = tool.Search.execute_filter_groups_from_json(data, ifc_file)
elements -= exclude_elements
elif isinstance(data, dict) and "query" in data:
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, data["query"])
else:
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
except (json.JSONDecodeError, ValueError):
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
elements -= ifcopenshell.util.selector.filter_elements(ifc_file, exclude)
return elements
@classmethod
def get_annotation_element(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct"):
return rel.RelatingProduct
@classmethod
def get_drawing_reference(cls, drawing: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
for rel in drawing.HasAssociations:
if rel.is_a("IfcRelAssociatesDocument"):
return rel.RelatingDocument
@classmethod
def get_reference_document(
cls, reference: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
# TODO: migrate to document.get_reference_document.
return tool.Document.get_reference_document(reference)
@classmethod
def select_assigned_product(cls, context: bpy.types.Context) -> None:
obj = context.active_object
element = tool.Ifc.get_entity(obj)
product = cls.get_assigned_product(element)
if product:
product_obj = tool.Ifc.get_object(product)
product_obj.select_set(True)
context.view_layer.objects.active = product_obj
@classmethod
def is_drawing_active(cls) -> bool:
camera = bpy.context.scene.camera
if not (camera is not None and camera.type == "CAMERA" and tool.Blender.get_ifc_definition_id(camera)):
return False
# A VIEW_3D area is meaningless (and unobtainable) in background
# mode, but isn't otherwise required to generate a drawing.
if bpy.app.background:
return True
return tool.Blender.get_view3d_area() is not None
@classmethod
def is_camera_orthographic(cls) -> bool:
camera = bpy.context.scene.camera
return True if (camera and camera.data.type == "ORTHO") else False
@classmethod
def is_active_drawing(cls, drawing: ifcopenshell.entity_instance) -> bool:
props = tool.Drawing.get_document_props()
return drawing.id() == props.active_drawing_id
@classmethod
def run_drawing_activate_model(cls) -> None:
bpy.ops.bim.activate_model()
@classmethod
def isolate_camera_collection(cls, camera: bpy.types.Object) -> None:
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
drawing_collections = []
camera_collection = tool.Blender.get_object_bim_props(camera).collection
for drawing in drawings:
if not (drawing_obj := tool.Ifc.get_object(drawing)):
continue
oprops = tool.Blender.get_object_bim_props(drawing_obj)
if not (drawing_collection := oprops.collection):
continue
if drawing_obj == camera:
drawing_collection.hide_render = False
else:
drawing_collection.hide_render = True
project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
project_collection = tool.Blender.get_object_bim_props(project).collection
for layer_collection in bpy.context.view_layer.layer_collection.children:
if layer_collection.collection == project_collection:
for layer_collection2 in layer_collection.children:
if layer_collection2.collection in drawing_collections:
layer_collection2.hide_viewport = True
elif layer_collection2.collection == camera_collection:
layer_collection2.hide_viewport = False
@classmethod
def get_drawing_context_filters(cls, target_view: str) -> list[tuple[str, str, str]]:
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
return [
("Plan", "Body", target_view),
("Plan", "Body", "MODEL_VIEW"),
("Plan", "Facetation", target_view),
("Plan", "Facetation", "MODEL_VIEW"),
("Model", "Body", target_view),
("Model", "Body", "MODEL_VIEW"),
("Model", "Facetation", target_view),
("Model", "Facetation", "MODEL_VIEW"),
("Plan", "Annotation", target_view),
("Plan", "Annotation", "MODEL_VIEW"),
("Model", "Annotation", target_view),
("Model", "Annotation", "MODEL_VIEW"),
]
else:
return [
("Model", "Body", target_view),
("Model", "Body", "MODEL_VIEW"),
("Model", "Facetation", target_view),
("Model", "Facetation", "MODEL_VIEW"),
("Model", "Annotation", target_view),
("Model", "Annotation", "MODEL_VIEW"),
]
@classmethod
def get_drawing_subcontexts(cls, target_view: str) -> list[tuple[str, str, str]]:
context_filters = cls.get_drawing_context_filters(target_view)
subcontexts = []
for context_filter in context_filters:
subcontext = ifcopenshell.util.representation.get_context(tool.Ifc.get(), *context_filter)
if subcontext:
subcontexts.append(context_filter)
return subcontexts
@classmethod
def get_active_drawing_subcontexts(cls) -> list[tuple[str, str, str]] | None:
props = cls.get_document_props()
target_view = props.get_active_target_view()
if not target_view:
return None
return cls.get_drawing_subcontexts(target_view)
@classmethod
def activate_drawing(cls, camera: bpy.types.Object) -> None:
# Sync viewport objects visibility with selectors from EPset_Drawing/Include and /Exclude
drawing = tool.Ifc.get_entity(camera)
assert drawing and isinstance(camera.data, bpy.types.Camera)
cls.import_annotations_in_group(cls.get_drawing_group(drawing))
filtered_elements = cls.get_drawing_elements(drawing) | cls.get_drawing_spaces(drawing)
filtered_elements.add(drawing)
target_view = cls.get_drawing_target_view(drawing)
subcontexts = cls.get_drawing_subcontexts(target_view)
# Switch representations
for element in filtered_elements:
obj = tool.Ifc.get_object(element)
if not obj:
continue
current_representation = tool.Geometry.get_active_representation(obj)
current_representation_subcontext = None
if current_representation:
subcontext = current_representation.ContextOfItems
current_representation_subcontext = tool.Geometry.get_subcontext_parameters(subcontext)
for subcontext in subcontexts:
# prioritize already active representation if it matches the subcontext
# (element could have multiple representations in the same subcontext)
if current_representation_subcontext and subcontext == current_representation_subcontext:
break
priority_representation = ifcopenshell.util.representation.get_representation(element, *subcontext)
if priority_representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=priority_representation,
)
break
linked_handles: set[bpy.types.Object] = set()
for link in tool.Project.get_project_props().get_loaded_links_for_drawings():
try:
handle = tool.Project.get_link_empty_handle(link)
except Exception:
continue
if handle:
linked_handles.add(handle)
visible_objects = []
for obj in bpy.context.view_layer.objects:
if element := tool.Ifc.get_entity(obj):
if element in filtered_elements or obj in linked_handles:
visible_objects.append(obj)
else:
if obj.hide_get() is False:
visible_objects.append(obj)
tool.Blender.isolate_objects(visible_objects)
cls.import_camera_props(drawing, camera.data)
@classmethod
def get_elements_in_camera_view(
cls, camera: bpy.types.Object, objs: list[bpy.types.Object]
) -> set[ifcopenshell.entity_instance]:
props = tool.Drawing.get_camera_props(camera)
x = props.width
y = props.height
camera_inverse_matrix = camera.matrix_world.inverted()
return set(
[
tool.Ifc.get_entity(o)
for o in objs
if o
and cls.is_in_camera_view(o, camera_inverse_matrix, x, y, camera.data.clip_start, camera.data.clip_end)
and tool.Ifc.get_entity(o)
]
)
@classmethod
def is_in_camera_view(
cls,
obj: bpy.types.Object,
camera_inverse_matrix: Matrix,
x: float,
y: float,
clip_start: float,
clip_end: float,
) -> bool:
local_bbox = [camera_inverse_matrix @ obj.matrix_world @ Vector(v) for v in obj.bound_box]
local_x = [v.x for v in local_bbox]
local_y = [v.y for v in local_bbox]
local_z = [v.z for v in local_bbox]
aabb1_min = (-x / 2, -y / 2, -clip_end)
aabb1_max = (x / 2, y / 2, -clip_start)
aabb2_min = (min(local_x), min(local_y), min(local_z))
aabb2_max = (max(local_x), max(local_y), max(local_z))
for i in range(3):
if aabb1_max[i] < aabb2_min[i] or aabb1_min[i] > aabb2_max[i]:
return False
return True
@classmethod
def is_intersecting_camera(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> bool:
# Based on separating axis theorem
plane_co = camera.matrix_world.translation
plane_no = camera.matrix_world.col[2].xyz
return cls.is_intersecting_plane(obj, plane_co, plane_no)
@classmethod
def is_intersecting_plane(cls, obj: bpy.types.Object, plane_co: Vector, plane_no: Vector) -> bool:
# Broadphase check using the bounding box
bounding_box_world_coords = [obj.matrix_world @ Vector(coord) for coord in obj.bound_box]
bounding_box_signed_distances = [plane_no.dot(v - plane_co) for v in bounding_box_world_coords]
pos_exists_bb = any(d > 0 for d in bounding_box_signed_distances)
neg_exists_bb = any(d < 0 for d in bounding_box_signed_distances)
if not (pos_exists_bb and neg_exists_bb):
return False
bm = bmesh.new()
bm.from_mesh(obj.data)
# Transform the vertices to world space
mesh_mat = obj.matrix_world
bm.transform(mesh_mat)
# Calculate the signed distances of all vertices from the plane
signed_distances = [plane_no.dot(v.co - plane_co) for v in bm.verts]
bm.free()
# Check for intersection
pos_exists = any(d > 0 for d in signed_distances)
neg_exists = any(d < 0 for d in signed_distances)
return pos_exists and neg_exists
@classmethod
def bisect_mesh(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> tuple[list[Vector], list[list[int]]]:
# TODO consolidate with other bisect functions
camera_matrix = obj.matrix_world.inverted() @ camera.matrix_world
plane_co = camera_matrix.translation
plane_no = camera_matrix.col[2].xyz
# Bisect verts are offset by the clip (with 5mm tolerance) to ensure it is visible in the viewport.
global_offset = camera.matrix_world.col[2].xyz * (-camera.data.clip_start - 0.005)
return cls.bisect_mesh_with_plane(obj, plane_co, plane_no, global_offset=global_offset)
@classmethod
def bisect_bmesh(cls, obj, bm, geom, camera):
# TODO consolidate with other bisect functions
camera_matrix = obj.matrix_world.inverted() @ camera.matrix_world
plane_co = camera_matrix.translation
plane_no = camera_matrix.col[2].xyz
global_offset = camera.matrix_world.col[2].xyz * -camera.data.clip_start
# Run the bisect operation
results = bmesh.ops.bisect_plane(bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no)
vert_map = {}
verts = []
edges = []
i = 0
for geom in results["geom_cut"]:
if isinstance(geom, bmesh.types.BMVert):
verts.append(tuple((obj.matrix_world @ geom.co) + global_offset))
vert_map[geom.index] = i
i += 1
else:
# It seems as though edges always appear after verts
edges.append([vert_map[v.index] for v in geom.verts])
return verts, edges
@classmethod
def bisect_mesh_with_plane(
cls, obj: bpy.types.Object, plane_co: Vector, plane_no: Vector, global_offset: Optional[Vector] = None
) -> tuple[list[Vector], list[list[int]]]:
# TODO consolidate with other bisect functions
if global_offset is None:
global_offset = Vector()
bm = bmesh.new()
bm.from_mesh(obj.data)
# Run the bisect operation
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
results = bmesh.ops.bisect_plane(bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no)
vert_map: dict[int, int] = {}
verts: list[Vector] = []
edges: list[list[int]] = []
i = 0
for geom in results["geom_cut"]:
if isinstance(geom, bmesh.types.BMVert):
verts.append(tuple((obj.matrix_world @ geom.co) + global_offset))
vert_map[geom.index] = i
i += 1
else:
# It seems as though edges always appear after verts
edges.append([vert_map[v.index] for v in geom.verts])
bm.free()
return verts, edges
@classmethod
def get_extrusion_vector(cls, wall):
if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
return Vector(item.ExtrudedDirection.DirectionRatios)
return Vector([0.0, 0.0, 1.0])
@classmethod
def get_scale_ratio(cls, scale: str) -> float:
numerator, denominator = scale.split("/")
return float(numerator) / float(denominator)
@classmethod
def get_diagram_scale(cls, camera: Union[bpy.types.Object, bpy.types.Camera]) -> dict[str, str]:
props = cls.get_camera_props(camera)
scale = props.diagram_scale
if scale != "CUSTOM":
human_scale, scale = scale.split("|")
return {"HumanScale": human_scale, "Scale": scale}
numerator_string = props.custom_scale_numerator
denominator_string = props.custom_scale_denominator
numerator = tool.Drawing.convert_scale_string(numerator_string)
denominator = tool.Drawing.convert_scale_string(denominator_string)
if not numerator or not denominator:
return
scale = str(Fraction(numerator / denominator).limit_denominator(1000)) # Any ratio >1000 is stupid.
if "'" in scale or '"' in scale:
human_separator = "=" # Imperial scales use "=", like 1" = 1' - 0"
# If for some crazy reason we mix metric and imperial, assume metric is SI units, like 1m = 1'
if "'" not in numerator_string and '"' not in numerator_string:
numerator_string += "m"
if "'" not in denominator_string and '"' not in denominator_string:
denominator_string += "m"
else:
human_separator = ":" # Metric scales use ":", like 1:100
human_scale = f"{numerator_string}{human_separator}{denominator_string}"
return {"HumanScale": human_scale, "Scale": scale}
@classmethod
def convert_scale_string(cls, value: str) -> float:
try:
return float(value)
except:
pass # Perhaps it's imperial?
l = lark.Lark("""start: feet? "-"? inches?
feet: NUMBER? "-"? fraction? "'"
inches: NUMBER? "-"? fraction? "\\""
fraction: NUMBER "/" NUMBER
%import common.NUMBER
%import common.WS
%ignore WS // Disregard spaces in text
""")
try:
start = l.parse(value)
except:
return 0
result = 0
for dimension in start.children:
factor = 12 if dimension.data == "feet" else 1
for child in dimension.children:
if getattr(child, "data", None) == "fraction":
result += (float(child.children[0]) / float(child.children[1])) * factor
else:
result += float(child) * factor
return result * 0.0254
@classmethod
def extend_line(cls, start: Vector, end: Vector, distance: float) -> tuple[list[float], list[float]]:
start = np.array(start)
end = np.array(end)
direction = end - start
offset = distance * (direction / np.linalg.norm(direction))
return (start - offset).tolist(), (end + offset).tolist()
@classmethod
def get_sheet_references(cls, drawing: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
sheet_references: list[ifcopenshell.entity_instance] = []
drawing_reference = cls.get_drawing_document(drawing)
for sheet in tool.Ifc.get().by_type("IfcDocumentInformation"):
if not sheet.Scope == "SHEET":
continue
references = cls.get_document_references(sheet)
for reference in references:
if reference.Location == drawing_reference.Location:
sheet_references.append(reference)
break
return sheet_references
@classmethod
def get_sheeted_drawing_ids(cls) -> set[int]:
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
ifc_file = tool.Ifc.get()
sheet_locations: set[Union[str, None]] = set()
for sheet in ifc_file.by_type("IfcDocumentInformation"):
if sheet.Scope != "SHEET":
continue
for reference in cls.get_document_references(sheet):
sheet_locations.add(reference.Location)
if not sheet_locations:
return set()
result: set[int] = set()
for drawing in ifc_file.by_type("IfcAnnotation"):
if drawing.ObjectType != "DRAWING":
continue
drawing_document = cls.get_drawing_document(drawing)
if drawing_document and drawing_document.Location in sheet_locations:
result.add(drawing.id())
return result
@classmethod
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
"""Get the drawing items in a target view category that are currently visible in the drawing list.
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
the most recent header item above them. Only expanded categories contribute drawing items to the
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
filter so that select-all only affects visible drawings.
"""
props = cls.get_document_props()
drawings: list[DrawingProperties] = []
in_category = False
for item in props.drawings:
if not item.is_drawing:
# Header row: we're inside the requested category until the next header.
in_category = item.target_view == target_view
elif in_category:
drawings.append(item)
if props.show_drawings_on_sheets_only:
sheeted_ids = cls.get_sheeted_drawing_ids()
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
return drawings
@classmethod
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
matrix_world = camera.matrix_world.copy().normalized()
location, rotation, scale = matrix_world.decompose()
if scale.x < 0 or scale.y < 0 or scale.z < 0:
# RCPs may be inversely scaled. We discard the scale and rotate the Z to compensate.
rotate180z = mathutils.Matrix.Rotation(math.radians(180.0), 4, "Z")
return mathutils.Matrix.Translation(location) @ rotation.to_matrix().to_4x4() @ rotate180z
return mathutils.Matrix.Translation(location) @ rotation.to_matrix().to_4x4()
@classmethod
def convert_svg_to_dxf(cls, svg_filepath: Path, dxf_filepath: Path) -> None:
import xml.etree.ElementTree as ET
import ezdxf
SVG = "{http://www.w3.org/2000/svg}"
IFC = "{http://www.ifcopenshell.org/ns}"
doc = ezdxf.new("R2010")
msp = doc.modelspace()
svg = ET.parse(svg_filepath).getroot()
def finalize_dxf():
doc.saveas(dxf_filepath)
drawing = svg.findall(f"{SVG}g[@{IFC}name]")
if not drawing:
finalize_dxf()
return
drawing = drawing[0]
NUMBER = r"-?\d+\.?\d+"
COORD = rf"{NUMBER},{NUMBER}"
POLYLINE_PATTERN = rf"M{COORD} (?:L{COORD} ?)+Z? ?"
MULTI_POLYLINE_PATTERN = rf"^({POLYLINE_PATTERN})+$"
for element_g in drawing.findall(f"{SVG}g"):
paths = element_g.findall(f"{SVG}path")
for path in paths:
# For some reason `<path/>` without "d" attribute can occur too. See #6871.
# It's unclear whether this issue is still present with the updated ifcopenshell core,
# but adding this fix for now. Could be reveted later.
if "d" not in path.attrib:
continue
path = path.attrib["d"]
if not re.match(MULTI_POLYLINE_PATTERN, path):
# print(f'Path "{path}" doesn\'t match expected pattern {MULTI_POLYLINE_PATTERN}')
continue
for polyline_path in re.findall(POLYLINE_PATTERN, path):
points = re.findall(rf"{NUMBER}", polyline_path)
points = [float(p) for p in points]
POINT_SIZE = 2
grouped_points = []
for i in range(0, len(points), POINT_SIZE):
point = points[i : i + POINT_SIZE]
point[1] *= -1
grouped_points.append(point)
points = grouped_points
# Z marks closed polylines
is_closed_polyline = polyline_path.rstrip().endswith("Z")
if is_closed_polyline or len(points) > 2:
msp.add_lwpolyline(points, close=is_closed_polyline)
else: # LINE
msp.add_line(*points)
finalize_dxf()
@classmethod
def remove_drawing_from_sheet(cls, reference: ifcopenshell.entity_instance) -> None:
import bonsai.bim.module.drawing.sheeter as sheeter
sheet = tool.Drawing.get_reference_document(reference)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.remove_drawing(reference, sheet)
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference=reference)
tool.Drawing.import_sheets()
@classmethod
def hide_all_drawing_collections(cls) -> None:
for element in tool.Ifc.get().by_type("IfcAnnotation"):
if element.ObjectType == "DRAWING" and (obj := tool.Ifc.get_object(element)):
tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True
@classmethod
def clear_annotation_relationships(cls, drawing: ifcopenshell.entity_instance) -> None:
for rel in drawing.ReferencedBy:
tool.Ifc.get().remove(rel)