Files
IfcOpenShell/src/bonsai/bonsai/bim/module/drawing/operator.py
T
Thomas Krijnen aed70c8a02 Run black
2025-10-25 15:20:36 +02:00

3787 lines
157 KiB
Python

# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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/>.
import os
import bpy
import json
import time
import bmesh
import shutil
import hashlib
import shapely
import shapely.ops
import subprocess
import numpy as np
import multiprocessing
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.document
import ifcopenshell.api.pset
import ifcopenshell.api.style
import ifcopenshell.ifcopenshell_wrapper
import ifcopenshell.geom
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.selector
import ifcopenshell.util.unit
import bonsai.bim.helper
import bonsai.bim.handler
import bonsai.tool as tool
import bonsai.core.geometry
import bonsai.core.drawing as core
import bonsai.bim.module.drawing.svgwriter as svgwriter
import bonsai.bim.module.drawing.annotation as annotation
import bonsai.bim.module.drawing.sheeter as sheeter
import bonsai.bim.export_ifc
from bpy_extras.io_utils import ImportHelper
from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.drawing.data import DecoratorData
from typing import NamedTuple, Union, Optional, Literal, TYPE_CHECKING, Any, TypedDict, get_args
from lxml import etree
from math import radians
from mathutils import Vector, Color, Matrix
from timeit import default_timer as timer
from bonsai.bim.module.drawing.prop import RasterStyleProperty, RASTER_STYLE_PROPERTIES_EXCLUDE
from bonsai.bim.ifc import IfcStore
from pathlib import Path
from bpy_extras.image_utils import load_image
if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import RenderType
from bonsai.bim.module.project.prop import Link
from bpy.stub_internal import rna_enums
cwd = os.path.dirname(os.path.realpath(__file__))
class profile:
"""
A python context manager timing utility
"""
def __init__(self, task):
self.task = task
def __enter__(self):
self.start = timer()
def __exit__(self, *args):
print(self.task, timer() - self.start)
class LineworkContexts(NamedTuple):
body: list[list[int]]
annotation: list[list[int]]
class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_annotation_type"
bl_label = "Add Annotation Type"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
props = tool.Drawing.get_annotation_props()
object_type = props.object_type
has_representation = props.create_representation_for_type
drawing = tool.Ifc.get_entity(bpy.context.scene.camera)
if props.create_representation_for_type:
obj = tool.Drawing.create_annotation_object(drawing, object_type)
else:
obj = bpy.data.objects.new(object_type, None)
obj.name = props.type_name
ifc_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Annotation", "MODEL_VIEW")
element = tool.Drawing.run_root_assign_class(
obj=obj,
ifc_class="IfcTypeProduct",
predefined_type=object_type,
should_add_representation=has_representation,
context=ifc_context,
ifc_representation_class=tool.Drawing.get_ifc_representation_class(object_type),
)
if representation := tool.Drawing.get_representation(element, ifc_context):
tool.Drawing.reload_representation(obj=obj, representation=representation)
element.ApplicableOccurrence = f"IfcAnnotation/{object_type}"
if props.create_representation_for_type and object_type == "IMAGE":
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
class EnableAddAnnotationType(bpy.types.Operator):
bl_idname = "bim.enable_add_annotation_type"
bl_label = "Enable Add Annotation Type"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context) -> "set[rna_enums.OperatorReturnItems]":
tool.Drawing.get_annotation_props().is_adding_type = True
return {"FINISHED"}
class DisableAddAnnotationType(bpy.types.Operator):
bl_idname = "bim.disable_add_annotation_type"
bl_label = "Disable Add Annotation Type"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context) -> "set[rna_enums.OperatorReturnItems]":
tool.Drawing.get_annotation_props().is_adding_type = False
return {"FINISHED"}
class AddDrawing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_drawing"
bl_label = "Add Drawing"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Add a drawing view to the IFC project.\n\n"
"For all views besides MODEL_VIEW camera will be placed at the current cursor position,\n"
"for MODEL_VIEW camera will be aligned to the current viewport position and orientation."
)
def _execute(self, context):
props = tool.Drawing.get_document_props()
hint = props.location_hint
if props.target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]:
hint = int(hint)
else:
assert hint in tool.Drawing.LOCATION_HINT_LITERALS
core.add_drawing(
tool.Ifc,
tool.Collector,
tool.Drawing,
target_view=props.target_view,
location_hint=hint,
)
# TODO: Why need to resync active drawing, if it wasn't changed.
drawing = props.get_active_drawing()
if drawing is None:
return
core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=drawing)
class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.duplicate_drawing"
bl_label = "Duplicate Drawing"
bl_description = "Make a copy of currently selected drawing"
bl_options = {"REGISTER", "UNDO"}
drawing: bpy.props.IntProperty()
should_duplicate_annotations: bpy.props.BoolProperty(name="Should Duplicate Annotations", default=False)
@classmethod
def poll(cls, context):
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
return True
def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
row = self.layout
row.prop(self, "should_duplicate_annotations")
def _execute(self, context):
props = tool.Drawing.get_document_props()
core.duplicate_drawing(
tool.Ifc,
tool.Blender,
tool.Drawing,
tool.Geometry,
drawing=tool.Ifc.get().by_id(self.drawing),
should_duplicate_annotations=self.should_duplicate_annotations,
)
class CreateDrawing(bpy.types.Operator):
"""Creates/refreshes a .svg drawing
Only available if :
- IFC file is created
- Camera is in Orthographic mode"""
bl_idname = "bim.create_drawing"
bl_label = "Create Drawing"
bl_description = (
"Creates/refreshes a .svg drawing based on currently active camera\n"
+ 'and open with default system viewer or using "svg_command" or\n'
+ '"pdf_command" from the Bonsai preferences (if provided).\n\n'
+ "SHIFT+CLICK to create/refresh all shown checked drawings, but doesn't\n"
+ "open them for viewing.\n\n"
+ "Add the CTRL modifier to optionally open drawings to view them as\n"
+ "they are created"
)
print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Print All",
default=False,
options={"SKIP_SAVE"},
)
open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Open in Viewer",
default=False,
options={"SKIP_SAVE"},
)
sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Sync Before Creating Drawing",
description="Could save some time if you're sure IFC and current Blender session are already in sync",
default=True,
)
if TYPE_CHECKING:
print_all: bool
open_viewer: bool
sync: bool
drawing_name: str
is_manifold_cache: dict[str, bool]
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not tool.Drawing.is_drawing_active():
cls.poll_message_set("No active drawing.")
return False
assert context.scene
assert (camera_obj := context.scene.camera)
if tool.Drawing.get_camera_props(camera_obj).linework_mode == "FREESTYLE" and not hasattr(
context.scene, "svg_export"
):
cls.poll_message_set(
"Freestyle SVG Exporter extension is not installed (required for 'Freestyle' linework mode)."
)
return False
return True
def invoke(self, context, event):
# printing all drawings on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.print_all = True
if event.type == "LEFTMOUSE" and event.ctrl:
self.open_viewer = True
return self.execute(context)
def execute(self, context):
self.props = tool.Drawing.get_document_props()
assert context.scene and context.scene.camera
active_drawing_id = tool.Blender.get_ifc_definition_id(context.scene.camera)
original_drawing_id = None
if self.print_all:
original_drawing_id = active_drawing_id
drawings_to_print = [d.ifc_definition_id for d in self.props.drawings if d.is_selected and d.is_drawing]
else:
drawings_to_print = [active_drawing_id]
for drawing_i, drawing_id in enumerate(drawings_to_print):
self.drawing_index = drawing_i
if self.print_all:
bpy.ops.bim.activate_drawing(drawing=drawing_id, should_view_from_camera=False)
self.camera = context.scene.camera
assert (camera_element := tool.Ifc.get_entity(self.camera))
self.camera_element = camera_element
self.camera_document = tool.Drawing.get_drawing_document(self.camera_element)
self.file = tool.Ifc.get()
with profile("Drawing generation process"):
with profile("Initialize drawing generation process"):
self.cprops = tool.Drawing.get_camera_props(self.camera)
self.drawing = self.file.by_id(drawing_id)
self.drawing_name = self.drawing.Name
self.metadata = tool.Drawing.get_drawing_metadata(self.camera_element)
self.get_scale(context)
if self.cprops.update_representation(self.camera.matrix_world):
bpy.ops.bim.update_representation(obj=self.camera.name, ifc_representation_class="")
# Reassign props as data is recreated during the update.
self.cprops = tool.Drawing.get_camera_props(self.camera)
camera_dims = self.get_camera_dimensions()
self.svg_writer = svgwriter.SvgWriter(
camera_width=camera_dims[0],
camera_height=camera_dims[1],
camera=self.camera,
camera_projection=(self.camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))),
scale=self.scale,
human_scale=self.human_scale,
)
self.svg_writer.setup_drawing_resource_paths(self.camera_element)
underlay_svg = None
linework_svg = None
annotation_svg = None
with profile("Generate underlay"):
if ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasUnderlay"):
drawing_style = self.cprops.get_active_drawing_style()
if not drawing_style:
self.report(
{"ERROR"},
f"Failed to create drawing '{self.drawing.Name}' - drawing has underlay but there's no active drawing underlay style.",
)
return {"FINISHED"}
underlay_svg = self.generate_underlay(context)
with profile("Generate linework"):
if tool.Drawing.is_camera_orthographic():
if self.cprops.linework_mode == "OPENCASCADE":
linework_svg = self.generate_linework(context)
elif self.cprops.linework_mode == "FREESTYLE":
linework_svg = self.generate_freestyle_linework(context)
elif self.cprops.linework_mode == "FREESTYLE":
linework_svg = self.generate_freestyle_linework(context)
with profile("Generate annotation"):
if tool.Drawing.is_camera_orthographic():
annotation_svg = self.generate_annotation(context)
with profile("Combine SVG layers"):
svg_path = self.combine_svgs(context, underlay_svg, linework_svg, annotation_svg)
if self.open_viewer:
drawing_uri = tool.Drawing.get_document_uri(tool.Drawing.get_drawing_document(self.drawing))
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, drawing_uri)
if not self.open_viewer:
self.report({"INFO"}, f"{len(drawings_to_print)} drawings created...")
if self.print_all:
assert original_drawing_id is not None
bpy.ops.bim.activate_drawing(drawing=original_drawing_id, should_view_from_camera=False)
return {"FINISHED"}
def get_camera_dimensions(self) -> tuple[float, float]:
assert bpy.context.scene
render = bpy.context.scene.render
assert isinstance(self.camera.data, bpy.types.Camera)
if self.is_landscape(render):
width = self.camera.data.ortho_scale
height = width / render.resolution_x * render.resolution_y
else:
height = self.camera.data.ortho_scale
width = height / render.resolution_y * render.resolution_x
return width, height
def combine_svgs(
self, context: bpy.types.Context, underlay: Optional[str], linework: Optional[str], annotation: Optional[str]
) -> str:
# Hacky :)
svg_path = self.get_svg_path()
with open(svg_path, "w") as outfile:
self.svg_writer.create_blank_svg(svg_path).define_boilerplate()
boilerplate = self.svg_writer.svg.tostring()
outfile.write(boilerplate.replace("</svg>", ""))
if underlay:
with open(underlay) as infile:
for i, line in enumerate(infile):
if i < 2:
continue
elif "</svg>" in line:
continue
outfile.write(line)
shutil.copyfile(os.path.splitext(underlay)[0] + ".png", os.path.splitext(svg_path)[0] + "-underlay.png")
if linework:
with open(linework) as infile:
should_skip = False
for i, line in enumerate(infile):
if i == 0:
continue
if "</svg>" in line:
continue
elif "<defs>" in line:
should_skip = True
continue
elif "</defs>" in line:
should_skip = False
continue
elif should_skip:
continue
outfile.write(line)
if annotation:
with open(annotation) as infile:
for i, line in enumerate(infile):
if i < 2:
continue
if "</svg>" in line:
continue
outfile.write(line)
outfile.write("</svg>")
return svg_path
def generate_underlay(self, context: bpy.types.Context) -> Union[str, None]:
if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasUnderlay"):
return
svg_path = self.get_svg_path(cache_type="underlay")
if os.path.isfile(svg_path) and self.props.should_use_underlay_cache:
return svg_path
assert context.scene and context.view_layer and context.screen
context.scene.render.filepath = str(Path(svg_path).with_suffix(".png"))
assert (drawing_style := self.cprops.get_active_drawing_style())
tool.Blender.sync_render_visibility()
if drawing_style.render_type == "DEFAULT":
bpy.ops.render.render(write_still=True)
else:
previous_visibility: dict[str, bool] = {}
collection = tool.Blender.get_object_bim_props(self.camera).collection
assert collection
# Hide annotations.
for obj in collection.objects:
if context.view_layer.objects.get(obj.name):
previous_visibility[obj.name] = obj.hide_get()
obj.hide_set(True)
# Hide objects that shouldn't appear on render - empties, camera, grids, etc.
for obj in context.visible_objects:
if (
(not obj.data and not obj.instance_collection)
or isinstance(obj.data, bpy.types.Camera)
or "IfcGrid/" in obj.name
or "IfcGridAxis/" in obj.name
or "IfcOpeningElement/" in obj.name
):
if context.view_layer.objects.get(obj.name):
previous_visibility[obj.name] = obj.hide_get()
obj.hide_set(True)
assert (space := tool.Blender.get_view3d_space())
previous_shading = space.shading.type
previous_format = context.scene.render.image_settings.file_format
space.shading.type = "RENDERED"
context.scene.render.image_settings.file_format = "PNG"
with tool.Blender.bonsai_crash_txt("render.opengl"):
bpy.ops.render.opengl(write_still=True)
space.shading.type = previous_shading
context.scene.render.image_settings.file_format = previous_format
for name, value in previous_visibility.items():
bpy.data.objects[name].hide_set(value)
self.svg_writer.create_blank_svg(svg_path).draw_underlay(context.scene.render.filepath).save()
return svg_path
def get_linework_contexts(self, ifc: ifcopenshell.file, target_view: str) -> LineworkContexts:
plan_body_target_contexts: list[int] = []
plan_body_model_contexts: list[int] = []
model_body_target_contexts: list[int] = []
model_body_model_contexts: list[int] = []
plan_annotation_target_contexts: list[int] = []
plan_annotation_model_contexts: list[int] = []
model_annotation_target_contexts: list[int] = []
model_annotation_model_contexts: list[int] = []
for rep_context in ifc.by_type("IfcGeometricRepresentationContext"):
if rep_context.is_a("IfcGeometricRepresentationSubContext"):
if rep_context.ContextType == "Plan":
if rep_context.ContextIdentifier in ["Body", "Facetation"]:
if rep_context.TargetView == target_view:
plan_body_target_contexts.append(rep_context.id())
elif rep_context.TargetView == "MODEL_VIEW":
plan_body_model_contexts.append(rep_context.id())
elif rep_context.ContextIdentifier == "Annotation":
if rep_context.TargetView == target_view:
plan_annotation_target_contexts.append(rep_context.id())
elif rep_context.TargetView == "MODEL_VIEW":
plan_annotation_model_contexts.append(rep_context.id())
elif rep_context.ContextType == "Model":
if rep_context.ContextIdentifier in ["Body", "Facetation"]:
if rep_context.TargetView == target_view:
model_body_target_contexts.append(rep_context.id())
elif rep_context.TargetView == "MODEL_VIEW":
model_body_model_contexts.append(rep_context.id())
elif rep_context.ContextIdentifier == "Annotation":
if rep_context.TargetView == target_view:
model_annotation_target_contexts.append(rep_context.id())
elif rep_context.TargetView == "MODEL_VIEW":
model_annotation_model_contexts.append(rep_context.id())
elif rep_context.ContextType == "Model":
# You should never purely assign to a "Model" context, but
# if you do, this is what we assume your intention is.
model_body_model_contexts.append(rep_context.id())
continue
body_contexts = (
[
plan_body_target_contexts,
plan_body_model_contexts,
model_body_target_contexts,
model_body_model_contexts,
]
if target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]
else [
model_body_target_contexts,
model_body_model_contexts,
]
)
annotation_contexts = (
[
plan_annotation_target_contexts,
plan_annotation_model_contexts,
model_annotation_target_contexts,
model_annotation_model_contexts,
]
if target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]
else [
model_annotation_target_contexts,
model_annotation_model_contexts,
]
)
return LineworkContexts(body_contexts, annotation_contexts)
def serialize_contexts_elements(
self,
ifc: ifcopenshell.file,
tree: ifcopenshell.geom.tree,
contexts: LineworkContexts,
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
for context in contexts_:
with profile(f"Processing {context_type} context"):
if not context or not drawing_elements:
continue
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, 0.002 if target_view == "PLAN_VIEW" else -0.002))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
geom_settings, ifc, multiprocessing.cpu_count(), include=drawing_elements
)
processed = set()
for elem in it:
processed.add(ifc.by_id(elem.id))
self.serialiser.write(elem)
tree.add_element(elem)
drawing_elements -= processed
def generate_bisect_linework(self, context: bpy.types.Context, root) -> None:
camera_matrix_i = context.scene.camera.matrix_world.inverted()
group = root.find("{http://www.w3.org/2000/svg}g")
raw_width, raw_height = self.get_camera_dimensions()
x_offset = raw_width / 2
y_offset = raw_height / 2
svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm
for obj in context.visible_objects:
if obj.type != "MESH":
continue
if not (element := tool.Ifc.get_entity(obj)):
continue
if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
continue
verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera)
g = etree.SubElement(root, "{http://www.w3.org/2000/svg}g")
g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId
g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or ""
lines = []
for edge in edges:
start = [o for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy]
end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy]
coords = [start, end]
d = " ".join(
["L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) for p in coords]
)
d = "M{}".format(d[1:])
path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path")
path.attrib["d"] = d
group.append(g)
def generate_material_layers(self, context: bpy.types.Context, root) -> None:
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
if "projection" in el.get("class", "").split():
continue
element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
if not (obj := tool.Ifc.get_object(element)):
continue
if not (material := ifcopenshell.util.element.get_material(element)):
continue
if material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialLayerSetUsage"):
continue
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine * self.unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
elif material.is_a("IfcMaterialLayerSet"):
usage = None
layer_set = material
offset = 0
sense_factor = 1
camera_matrix_i = context.scene.camera.matrix_world.inverted()
group = root.find("{http://www.w3.org/2000/svg}g")
raw_width, raw_height = self.get_camera_dimensions()
x_offset = raw_width / 2
y_offset = raw_height / 2
svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
# Slice our mesh into a 2D drawing cut (2D is always easier)
camera_matrix = obj.matrix_world.inverted() @ context.scene.camera.matrix_world
plane_co = camera_matrix.translation
plane_no = camera_matrix.col[2].xyz
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
bmesh.ops.bisect_plane(
bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no, clear_outer=True, clear_inner=True
)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges)
prev_co = None
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = tool.Drawing.get_extrusion_vector(element).normalized()
co = Vector((0.0, 0.0, offset))
elif usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
elif usage.LayerSetDirection == "AXIS3":
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
for i, layer in enumerate(layer_set.MaterialLayers):
prev_co = co.copy()
co += no * layer.LayerThickness * self.unit_scale
bm_fill = bm.copy()
if i != last_i:
geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:]
bmesh.ops.bisect_plane(bm_fill, geom=geom, dist=0.0001, plane_co=co, plane_no=no, clear_outer=True)
if i != 0:
geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:]
bmesh.ops.bisect_plane(
bm_fill, geom=geom, dist=0.0001, plane_co=prev_co, plane_no=no, clear_inner=True
)
bm_fill.verts.ensure_lookup_table()
bm_fill.edges.ensure_lookup_table()
verts = [tuple(obj.matrix_world @ v.co) for v in bm_fill.verts]
edges = [[v.index for v in e.verts] for e in bm_fill.edges]
g = etree.SubElement(root, "{http://www.w3.org/2000/svg}g")
g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId
g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or ""
g.attrib["{http://www.ifcopenshell.org/ns}layer-id"] = str(layer.id())
lines = []
for edge in edges:
start = [o for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy]
end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy]
coords = [start, end]
d = " ".join(
["L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) for p in coords]
)
d = "M{}".format(d[1:])
path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path")
path.attrib["d"] = d
group.append(g)
bm.free()
def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None:
if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"):
return
svg_path = self.get_svg_path(cache_type="linework")
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
return svg_path
context.scene.render.use_freestyle = True
context.scene.svg_export.use_svg_export = True
linesets = context.view_layer.freestyle_settings.linesets
if len(linesets) == 1 and linesets[0].name == "LineSet":
context.view_layer.freestyle_settings.crease_angle = radians(140)
context.view_layer.freestyle_settings.use_culling = True
lineset = linesets[0]
lineset.edge_type_negation = "EXCLUSIVE"
lineset.select_silhouette = False
lineset.select_crease = False
lineset.select_border = False
lineset.select_edge_mark = False
lineset.select_contour = False
lineset.select_external_contour = False
lineset.select_material_boundary = False
lineset.select_suggestive_contour = True
lineset.select_ridge_valley = True
edge_mesh = bpy.data.meshes.new("Temp Merged Edges")
edge_obj = bpy.data.objects.new("Temp Merged Edges", edge_mesh)
context.scene.collection.objects.link(edge_obj)
edge_bm = bmesh.new()
visible_object_names = {obj.name for obj in bpy.context.visible_objects}
for obj in bpy.context.view_layer.objects:
is_visible = obj.name in visible_object_names
obj.hide_render = not is_visible
if (
is_visible
and obj.type == "MESH"
and len(obj.data.edges)
and not len(obj.data.polygons)
and not obj.name.startswith("IfcAnnotation")
):
tmp_mesh = None
try:
tmp_mesh = obj.data.copy()
tmp_mesh.transform(obj.matrix_world)
edge_bm.from_mesh(tmp_mesh)
finally:
if tmp_mesh:
bpy.data.meshes.remove(tmp_mesh)
ret = bmesh.ops.extrude_edge_only(edge_bm, edges=edge_bm.edges)
verts_extruded = [e for e in ret["geom"] if isinstance(e, bmesh.types.BMVert)]
cam_z = self.camera.matrix_world.to_3x3() @ self.camera.data.view_frame(scene=None)[-1].normalized()
cam_z *= 0.001
for v in verts_extruded:
v.co += cam_z
edge_bm.to_mesh(edge_mesh)
edge_bm.free()
freestyle_svg_exporter = tool.Blender.get_addon("freestyle_svg_exporter")
context.scene.render.filepath = svg_path[0:-4]
actual_path = freestyle_svg_exporter.create_path(bpy.context.scene)
bpy.ops.render.render(write_still=False)
os.replace(actual_path, svg_path)
bpy.data.objects.remove(edge_obj)
bpy.data.meshes.remove(edge_mesh)
context.scene.render.use_freestyle = False
context.scene.svg_export.use_svg_export = False
tree = etree.parse(svg_path)
root = tree.getroot()
freestyle_width = float(root.attrib["width"])
freestyle_height = float(root.attrib["height"])
svg_width = self.svg_writer.width
svg_height = self.svg_writer.height
group = root.find(".//{http://www.w3.org/2000/svg}g")
group.attrib["class"] = "projection"
# Resize Freestyle to our proper width / height and purge all other attributes
for path in root.findall(".//{http://www.w3.org/2000/svg}path"):
for key in path.attrib:
if key == "fill":
continue
elif key != "d":
del path.attrib[key]
continue
d = path.attrib[key]
coords = d.strip().split()[1:]
new_d = "M"
for i in range(0, len(coords), 2):
x = float(coords[i][:-1])
y = float(coords[i + 1])
x = x / freestyle_width * svg_width
y = y / freestyle_height * svg_height
new_d += f" {x},{y}"
path.attrib["d"] = new_d
pass
if tool.Drawing.is_camera_orthographic():
self.generate_bisect_linework(context, root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
with open(svg_path, "wb") as svg:
svg.write(etree.tostring(root))
return svg_path
def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"):
return
svg_path = self.get_svg_path(cache_type="linework")
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
return svg_path
# in case of printing multiple drawings we need to sync just once
if self.sync and self.drawing_index == 0:
with profile("sync"):
# All very hackish whilst prototyping
exporter = bonsai.bim.export_ifc.IfcExporter(None)
exporter.file = tool.Ifc.get()
invalidated_elements = exporter.sync_all_objects()
invalidated_guids = [e.GlobalId for e in invalidated_elements if hasattr(e, "GlobalId")]
if cache := IfcStore.get_cache():
[cache.remove(guid) for guid in invalidated_guids]
# If we have already calculated it in the SVG in the past, don't recalculate
edited_guids = set()
for obj in IfcStore.edited_objs:
element = tool.Ifc.get_entity(obj)
edited_guids.add(element.GlobalId) if hasattr(element, "GlobalId") else None
cached_linework = set()
if os.path.isfile(svg_path):
tree = etree.parse(svg_path)
root = tree.getroot()
cached_linework = {
el.get("{http://www.ifcopenshell.org/ns}guid")
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]")
}
cached_linework -= edited_guids
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
files = {bim_props.ifc_file: tool.Ifc.get()}
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
files[link.name] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
for ifc_path, ifc in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
ifc_cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
# The camera must always be included, regardless of any include/exclude filters.
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
it = ifcopenshell.geom.iterator(geom_settings, ifc, include=[self.camera_element])
for elem in it:
self.serialiser.write(elem)
with profile("Finalizing"):
self.serialiser.finalize()
results = self.svg_buffer.get_value()
root = etree.fromstring(results)
group = root.find("{http://www.w3.org/2000/svg}g")
if group is None:
with open(svg_path, "wb") as svg:
svg.write(etree.tostring(root))
return svg_path
if self.cprops.cut_mode == "BISECT":
self.remove_cut_linework(root)
self.generate_bisect_linework(context, root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
elif self.cprops.cut_mode == "OPENCASCADE":
self.move_projection_to_bottom(root)
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root)
if self.cprops.fill_mode == "SHAPELY":
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
boundary_lines = []
for projection in projections:
global_id = projection.attrib["{http://www.ifcopenshell.org/ns}guid"]
if global_id not in elements_with_faces:
continue
for path in projection.findall("./{http://www.w3.org/2000/svg}path"):
start, end = [[float(o) for o in co[1:].split(",")] for co in path.attrib["d"].split()]
if start == end:
continue
# Extension by 0.5mm is necessary to ensure lines overlap with other diagonal lines
start, end = tool.Drawing.extend_line(start, end, 0.5)
boundary_lines.append(shapely.LineString([start, end]))
unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines))
closed_polygons = shapely.polygonize(unioned_boundaries.geoms)
for polygon in closed_polygons.geoms:
# Less than 1mm2 is not worth styling on sheet
if polygon.area < 1:
continue
centroid = polygon.centroid
centroid = centroid if polygon.contains(centroid) else polygon.representative_point()
if centroid:
centroid3d = self.drawing_to_model_co(centroid.x, centroid.y)
inside_elements = [
e for e in tree.select(self.pythonize(centroid3d)) if not e.is_a("IfcAnnotation")
]
if not inside_elements:
camera_dir = self.camera.matrix_world.col[2].to_3d() * -1
# We previously used tree.select_ray, but raycasting in Blender is 100x faster.
raycast_results = self.cast_rays_and_get_best_object(raycast_objs, centroid3d, camera_dir)
raycast_element = None
if raycast_obj := raycast_results[0]:
raycast_element = tool.Ifc.get_entity(raycast_obj)
if raycast_element:
path = etree.Element("path")
d = (
"M"
+ " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]])
+ " Z"
)
for interior in polygon.interiors:
d += (
" M"
+ " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]])
+ " Z"
)
path.attrib["d"] = d
classes = self.get_svg_classes(raycast_element)
classes.append("surface")
path.set("class", " ".join(list(classes)))
group.insert(0, path)
if self.cprops.fill_mode == "SVGFILL":
results = etree.tostring(root).decode("utf8")
svg_data_1 = results
from xml.dom.minidom import parseString
def yield_groups(n):
if n.nodeType == n.ELEMENT_NODE and n.tagName == "g":
yield n
for c in n.childNodes:
yield from yield_groups(c)
dom1 = parseString(svg_data_1)
svg1 = dom1.childNodes[0]
groups1 = [g for g in yield_groups(svg1) if "projection" in g.getAttribute("class")]
ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection")
for i, (ls, g1) in enumerate(zip(ls_groups, groups1)):
projection, g1 = g1, g1.parentNode
svgfill_context = ifcopenshell.ifcopenshell_wrapper.context(
ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3
)
# EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT
# remove duplicates (without tolerance)
ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]]
svgfill_context.add(ls)
num_passes = 0
for iteration in range(num_passes + 1):
# initialize empty group, note that in the current approach only one
# group is stored
ps = ifcopenshell.ifcopenshell_wrapper.svg_groups_of_polygons()
if iteration != 0 or svgfill_context.build():
svgfill_context.write(ps)
if iteration != num_passes:
pairs = svgfill_context.get_face_pairs()
semantics = [None] * (max(pairs) + 1)
# Reserialize cells into an SVG string
svg_data_2 = ifcopenshell.ifcopenshell_wrapper.polygons_to_svg(ps, True)
# We parse both SVG files to create on document with the combination of sections from
# the output directly from the serializer and the cells found from the hidden line
# rendering
dom2 = parseString(svg_data_2)
svg2 = dom2.childNodes[0]
# file 2 only has the groups we are interested in.
# in fact in the approach, it's only a single group
g2 = next(yield_groups(svg2))
# Loop over the cell paths
for pi, p in enumerate(g2.getElementsByTagName("path")):
d = p.getAttribute("d")
coords = [co[1:].split(",") for co in d.split() if co[1:]]
polygon = shapely.Polygon(coords)
# 1mm2 polygons aren't worth styling in paperspace. Raycasting is expensive!
if polygon.area < 1:
continue
# point inside is an attribute that comes from line_segments_to_polygons() polygons_to_svg?
# it is an arbitrary point guaranteed to be inside the polygon and outside
# of any potential inner bounds. We can use this to construct a ray to find
# the face of the IFC element that the cell belongs to.
assert p.hasAttribute("ifc:pointInside")
xy = list(map(float, p.getAttribute("ifc:pointInside").split(",")))
centroid3d = self.drawing_to_model_co(*xy)
inside_elements = [
e for e in tree.select(self.pythonize(centroid3d)) if not e.is_a("IfcAnnotation")
]
if inside_elements:
elements = None
if iteration != num_passes:
semantics[pi] = (inside_elements[0], -1)
else:
camera_dir = self.camera.matrix_world.col[2].to_3d() * -1
elements = [
e
for e in tree.select_ray(self.pythonize(centroid3d), self.pythonize(camera_dir))
if not e.instance.is_a("IfcAnnotation")
]
if elements:
classes = self.get_svg_classes(ifc.by_id(elements[0].instance.id()))
classes.append("projection")
classes.append("surface")
if iteration != num_passes:
semantics[pi] = elements[0]
else:
classes = ["projection"]
p.setAttribute("style", "foo")
p.setAttribute("class", " ".join(classes))
if iteration != num_passes:
to_remove = []
for he_idx in range(0, len(pairs), 2):
# @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal)
# to see if they're coplanar, because ray-distance will be different in case
# of element surfaces non-orthogonal to the view direction
def format(x):
if x is None:
return None
elif isinstance(x, tuple):
# found to be inside element using tree.select() no face or style info
return x
else:
return (x.instance.is_a(), x.ray_distance, tuple(x.position))
pp = pairs[he_idx : he_idx + 2]
if pp == (-1, -1):
continue
data = list(map(format, map(semantics.__getitem__, pp)))
if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5:
to_remove.append(he_idx // 2)
# Print edge index and semantic data
# print(he_idx // 2, *data)
svgfill_context.merge(to_remove)
# Swap the XML nodes from the files
# Remove the original hidden line node we still have in the serializer output
g1.removeChild(projection)
g2.setAttribute("class", "projection")
# Find the children of the projection node parent
children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE]
if children:
# Insert the new semantically enriched cell-based projection node
# *before* the node with sections from the serializer. SVG derives
# draw order from node order in the DOM so sections are draw over
# the projections.
g1.insertBefore(g2, children[0])
else:
# This generally shouldn't happen
g1.appendChild(g2)
results = dom1.toxml()
results = results.encode("ascii", "xmlcharrefreplace")
root = etree.fromstring(results)
# Spaces are handled as a special case, since they are often overlayed
# in addition to elements. For example, a space should not obscure
# other elements in projection. Spaces should also not override cut
# elements but instead be drawn in addition to cut elements.
spaces = tool.Drawing.get_drawing_spaces(self.camera_element)
group = root.findall(".//{http://www.w3.org/2000/svg}g")[0]
x_offset = self.svg_writer.raw_width / 2
y_offset = self.svg_writer.raw_height / 2
for space in spaces:
obj = tool.Ifc.get_object(space)
if not obj or not tool.Drawing.is_intersecting_camera(obj, self.camera):
continue
verts, edges = tool.Drawing.bisect_mesh(obj, self.camera)
verts = [self.svg_writer.project_point_onto_camera(Vector(v)) for v in verts]
line_strings = [
shapely.LineString(
[
(
(x_offset + verts[e[0]][0]) * self.svg_writer.svg_scale,
(y_offset - verts[e[0]][1]) * self.svg_writer.svg_scale,
),
(
(x_offset + verts[e[1]][0]) * self.svg_writer.svg_scale,
(y_offset - verts[e[1]][1]) * self.svg_writer.svg_scale,
),
]
)
for e in edges
]
closed_polygons = shapely.polygonize(line_strings)
for polygon in closed_polygons.geoms:
classes = self.get_svg_classes(space)
path = etree.Element("path")
d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z"
for interior in polygon.interiors:
d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z"
path.attrib["d"] = d
path.set("class", " ".join(list(classes)))
group.append(path)
with open(svg_path, "wb") as svg:
svg.write(etree.tostring(root))
return svg_path
def setup_serialiser(self, target_view):
self.svg_settings = ifcopenshell.geom.settings()
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser = ifcopenshell.geom.serializers.svg(
self.svg_buffer, self.svg_settings, self.serialiser_settings
)
self.serialiser.setWithoutStoreys(True)
self.serialiser.setPolygonal(True)
self.serialiser.setUseHlrPoly(True)
# Objects with more than these edges are rendered as wireframe instead of HLR for optimisation
self.serialiser.setProfileThreshold(10000)
self.serialiser.setUseNamespace(True)
self.serialiser.setAlwaysProject(True)
self.serialiser.setAutoElevation(False)
self.serialiser.setAutoSection(False)
self.serialiser.setPrintSpaceNames(False)
self.serialiser.setPrintSpaceAreas(False)
self.serialiser.setDrawDoorArcs(False)
self.serialiser.setNoCSS(True)
self.serialiser.setElevationRefGuid(self.camera_element.GlobalId)
self.serialiser.setScale(self.scale)
self.serialiser.setSubtractionSettings(ifcopenshell.ifcopenshell_wrapper.ALWAYS)
self.serialiser.setUsePrefiltering(True) # See #3359
self.serialiser.setUnifyInputs(True)
self.serialiser.setSegmentProjection(True)
if target_view == "REFLECTED_PLAN_VIEW":
self.serialiser.setMirrorY(True)
# tree = ifcopenshell.geom.tree()
# This instructs the tree to explode BReps into faces and return
# the style of the face when running tree.select_ray()
# tree.enable_face_styles(True)
def get_svg_classes(self, element, layer=None):
classes = [element.is_a()]
# ─── Material ──────────────────────────────────────────────
material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
material_name = ""
if material:
if material.is_a("IfcMaterialLayerSet"):
material_name = material.LayerSetName or "null"
else:
material_name = getattr(material, "Name", "null") or "null"
material_name = tool.Drawing.canonicalise_class_name(material_name)
classes.append(f"material-{material_name}")
else:
classes.append("material-null")
# ─── Layer ─────────────────────────────────────────────────
if layer:
classes.append(layer.is_a())
layer_material = layer.Material
layer_material_name = layer_material.Name or "null"
layer_material_name = tool.Drawing.canonicalise_class_name(layer_material_name)
classes.append(f"layer-material-{layer_material_name}")
# Add material category if available
if hasattr(layer_material, "Category") and layer_material.Category:
layer_material_category = tool.Drawing.canonicalise_class_name(layer_material.Category)
classes.append(f"layer-material-category-{layer_material_category}")
# ─── Metadata ──────────────────────────────────────────────
for key in self.metadata:
value = ifcopenshell.util.selector.get_element_value(element, key)
if value:
classes.append(
tool.Drawing.canonicalise_class_name(key) + "-" + tool.Drawing.canonicalise_class_name(str(value))
)
# ─── Target View ───────────────────────────────────────────
if getattr(self.cprops, "target_view", None):
target_view_class = tool.Drawing.canonicalise_class_name(str(self.cprops.target_view))
classes.append(f"target-view-{target_view_class}")
return classes
def is_manifold(self, obj) -> bool:
result = self.is_manifold_cache.get(obj.data.name, None)
if result is not None:
return result
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
for edge in bm.edges:
if not edge.is_manifold:
bm.free()
self.is_manifold_cache[obj.data.name] = False
return False
self.is_manifold_cache[obj.data.name] = True
return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
link_path = link.name
ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None:
return ifc_file
resolved_path = tool.Ifc.resolve_uri(link_path)
ifc_file = IfcStore.session_files[link_path] = ifcopenshell.open(resolved_path)
return ifc_file
def get_element_by_guid(self, guid: str) -> Union[ifcopenshell.entity_instance, None]:
try:
return tool.Ifc.get().by_guid(guid)
except RuntimeError:
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
ifc_file = self.get_linked_file(link)
try:
return ifc_file.by_guid(guid)
except RuntimeError:
continue
def get_element_by_id(self, step_id: Any) -> Union[ifcopenshell.entity_instance, None]:
try:
step_id = int(step_id)
except:
return
try:
return tool.Ifc.get().by_id(step_id)
except RuntimeError:
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
ifc_file = self.get_linked_file(link)
try:
return ifc_file.by_id(step_id)
except RuntimeError:
continue
def remove_cut_linework(self, root):
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
if "projection" not in el.get("class", "").split():
el.getparent().remove(el)
def merge_linework_and_add_metadata(self, root):
join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria")
if join_criteria:
join_criteria = join_criteria.split(",")
else:
# Drawing convention states that same objects classes with the same material are merged when cut.
join_criteria = [
"class",
"material.Name",
"/Pset_.*Common/.Status",
"EPset_Status.Status",
"EPset_Status.UserDefinedStatus",
]
group = root.find("{http://www.w3.org/2000/svg}g")
joined_paths = {}
self.is_manifold_cache = {}
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
layer = self.get_element_by_id(el.get("{http://www.ifcopenshell.org/ns}layer-id"))
if "projection" in el.get("class", "").split():
classes = self.get_svg_classes(element)
classes.append("projection")
el.set("class", " ".join(classes))
continue
else:
classes = self.get_svg_classes(element, layer)
classes.append("cut")
el.set("class", " ".join(classes))
obj = tool.Ifc.get_object(element)
if not obj: # This is a linked model object. For now, do nothing.
continue
if (material := ifcopenshell.util.element.get_material(element)) and material.is_a() in (
"IfcMaterialLayerSet",
"IfcMaterialLayerSetUsage",
):
pass # These are always manifold
elif not self.is_manifold(obj):
continue
# An element group will contain a bunch of paths representing the
# cut of that element. However IfcOpenShell may not correctly
# create closed paths. We post-process all paths with shapely to
# ensure things that should be closed (i.e.
# shapely.polygonize_full) are, and things which aren't are left
# alone (e.g. dangles, cuts, invalids). See #3421.
line_strings = []
old_paths = []
has_open_paths = False
for path in el.findall("{http://www.w3.org/2000/svg}path"):
for subpath in path.attrib["d"].split("M")[1:]:
subpath = "M" + subpath.strip()
coords = [[float(o) for o in co[1:].split(",")] for co in subpath.split()]
if coords[0] != coords[-1]:
has_open_paths = True
line_strings.append(shapely.LineString(coords))
old_paths.append(path)
results = []
if has_open_paths:
unioned_line_strings = shapely.union_all(shapely.GeometryCollection(line_strings))
if hasattr(unioned_line_strings, "geoms"):
results = shapely.polygonize_full(unioned_line_strings.geoms)
# If we succeeded in generating new path geometry, remove all the
# old paths and add new ones.
if results:
for path in old_paths:
path.getparent().remove(path)
# polygonize_full will create polygons for everything, including
# interior "holes". As a result we do two passes. The first pass
# records polygon interior rings. The second pass uses this to
# check if the exterior ring matches an interior ring. If it does,
# it's a hole. Skip it!
interior_hashes = set()
for result in results:
for geom in result.geoms:
if isinstance(geom, shapely.Polygon):
for interior in geom.interiors:
# Sorted because coordinate ordering may differ,
# and frozenset because shapely sometimes emits
# duplicate coordinates.
interior_hashes.add(hash(frozenset(sorted(interior.coords))))
elif isinstance(geom, shapely.LineString):
path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path")
d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z"
path.attrib["d"] = d
for result in results:
for geom in result.geoms:
if isinstance(geom, shapely.Polygon):
path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path")
if hash(frozenset(sorted(geom.exterior.coords))) in interior_hashes:
# This is a "hole", as its exterior perfectly matches an interior.
continue
d = (
"M"
+ " L".join([",".join([str(o) for o in co]) for co in geom.exterior.coords[0:-1]])
+ " Z"
)
for interior in geom.interiors:
d += (
" M"
+ " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]])
+ " Z"
)
path.attrib["d"] = d
# Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge.
if not element.is_a("IfcWall") and not element.is_a("IfcSlab"):
continue
keys = []
for query in join_criteria:
key = ifcopenshell.util.selector.get_element_value(element, query)
if isinstance(key, (list, tuple)):
keys.extend(key)
else:
keys.append(key)
if layer:
for query in join_criteria:
key = ifcopenshell.util.selector.get_element_value(layer, query)
if isinstance(key, (list, tuple)):
keys.extend(key)
else:
keys.append(key)
hash_keys = hash(tuple(keys))
if el.findall("{http://www.w3.org/2000/svg}path"):
joined_paths.setdefault(hash_keys, []).append(el)
for key, els in joined_paths.items():
queue = []
for el in els:
classes = set(el.attrib["class"].split())
classes.add(el.attrib["{http://www.ifcopenshell.org/ns}guid"])
is_closed_polygon = False
for path in el.findall("{http://www.w3.org/2000/svg}path"):
# Temporary hack.
if "d" not in path.attrib:
continue
for subpath in path.attrib["d"].split("M")[1:]:
subpath_co = "M" + subpath.strip(" Z")
# Round due to inaccuracies from Blender meshes and bisection
coords = [[round(float(o), 3) for o in co[1:].split(",")] for co in subpath_co.split()]
if subpath.strip().lower().endswith("z"):
coords.append(coords[0])
if len(coords) > 2 and coords[0] == coords[-1]:
is_closed_polygon = True
queue.append((shapely.Polygon(coords), classes))
if is_closed_polygon:
el.getparent().remove(el)
while queue:
polygon, polygon_classes = queue.pop()
for polygon2, polygon2_classes in queue[:]:
try:
merged_polygon = shapely.union(polygon, polygon2)
except:
print("Warning. Portions of the merge failed. Please report a bug!", polygon, polygon2)
continue
if type(merged_polygon) == shapely.Polygon:
polygon = merged_polygon
polygon_classes.update(polygon2_classes)
queue.remove((polygon2, polygon2_classes))
g = etree.Element("g")
path = etree.SubElement(g, "path")
d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z"
for interior in polygon.interiors:
d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z"
path.attrib["d"] = d
g.set("class", " ".join(list(polygon_classes)))
group.append(g)
def drawing_to_model_co(self, x: float, y: float) -> Vector:
camera_xy = np.array((x, -y)) / self.scale / 1000
camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset
return self.camera.matrix_world @ Vector(camera_xy).to_3d()
def pythonize(self, arr):
return tuple(map(float, arr))
def cast_rays_and_get_best_object(
self, objs_to_raycast: list[bpy.types.Object], ray_origin, ray_direction
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
# This could be optimised even further with 2D box culling
best_length_squared = 1.0
best_obj = None
best_hit = None
best_face_index = None
for obj in objs_to_raycast:
matrix_inv = obj.matrix_world.inverted()
ray_origin_obj = matrix_inv @ ray_origin
ray_direction_obj = ray_direction.to_4d()
ray_direction_obj[3] = 0.0
ray_direction_obj = (matrix_inv @ ray_direction_obj).to_3d()
success, location, normal, face_index = obj.ray_cast(ray_origin_obj, ray_direction_obj)
if success:
hit = obj.matrix_world @ location
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
best_obj = obj
best_hit = hit
best_face_index = face_index
if best_obj is not None:
return best_obj, best_hit, best_face_index
return None, None, None
def move_projection_to_bottom(self, root):
# IfcConvert puts the projection afterwards which is not correct since
# projection should be drawn underneath the cut.
group = root.find("{http://www.w3.org/2000/svg}g")
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
for projection in projections:
projection.getparent().remove(projection)
group.insert(0, projection)
def move_elements_to_top(self, root):
group = root.find("{http://www.w3.org/2000/svg}g")
bringtofront = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "BringToFront") or ""
bringtofront = [item.strip() for item in bringtofront.split(",") if item.strip()]
# Iterate through classes in order of preference
for class_name in bringtofront:
xpath_query = f".//svg:g[contains(@class, '{class_name}')]"
elements_to_move = root.xpath(xpath_query, namespaces={"svg": "http://www.w3.org/2000/svg"})
# Move each element to the end of the group (effectively placing them at the top visually)
for element in elements_to_move:
element.getparent().remove(element)
group.append(element)
def generate_annotation(self, context: bpy.types.Context) -> Union[str, None]:
if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasAnnotation"):
return
svg_path = self.get_svg_path(cache_type="annotation")
if os.path.isfile(svg_path) and self.props.should_use_annotation_cache:
return svg_path
elements = tool.Drawing.get_group_elements(tool.Drawing.get_drawing_group(self.camera_element))
filtered_drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element)
filtered_drawing_annotations = {e for e in filtered_drawing_elements if e.is_a("IfcAnnotation")}
elements = {e for e in elements if e in filtered_drawing_elements}
elements = list(elements | filtered_drawing_annotations)
annotations = sorted(
elements,
key=lambda a: (
tool.Drawing.get_annotation_z_index(a),
1 if ifcopenshell.util.element.get_predefined_type(a) == "TEXT" else 0,
),
)
precision = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "MetricPrecision")
if not precision:
precision = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "ImperialPrecision")
decimal_places = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "DecimalPlaces")
self.svg_writer.metadata = self.metadata
self.svg_writer.create_blank_svg(svg_path).draw_annotations(annotations, precision, decimal_places).save()
return svg_path
def get_scale(self, context):
diagram_scale = tool.Drawing.get_diagram_scale(self.camera)
self.human_scale = diagram_scale["HumanScale"]
self.scale = tool.Drawing.get_scale_ratio(diagram_scale["Scale"])
if ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "IsNTS"):
self.human_scale = "NTS"
def is_landscape(self, render):
return render.resolution_x > render.resolution_y
def get_material_name(self, element: ifcopenshell.entity_instance) -> str:
if hasattr(element, "Name") and element.Name:
return element.Name
elif hasattr(element, "LayerSetName") and element.LayerSetName:
return element.LayerSetName
return "mat-" + str(element.id())
def get_svg_path(self, cache_type: Optional[str] = None) -> str:
drawing_path = tool.Drawing.get_document_uri(self.camera_document)
assert drawing_path
drawings_dir = os.path.dirname(drawing_path)
if cache_type:
drawings_dir = os.path.join(drawings_dir, "cache")
os.makedirs(drawings_dir, exist_ok=True)
filename = tool.Drawing.sanitise_filename(f"{self.drawing_name}-{cache_type}.svg")
return os.path.join(drawings_dir, filename)
os.makedirs(drawings_dir, exist_ok=True)
return drawing_path
class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_annotation"
bl_label = "Add Annotation"
bl_options = {"REGISTER", "UNDO"}
description: bpy.props.StringProperty()
@classmethod
def poll(cls, context):
return tool.Ifc.get() and context.scene.camera
@classmethod
def description(cls, context, operator):
return operator.description or ""
def _execute(self, context):
props = tool.Drawing.get_annotation_props()
dprops = tool.Drawing.get_document_props()
if not (drawing := dprops.get_active_drawing()):
self.report({"WARNING"}, "No active drawing.")
return
obj = core.add_annotation(
tool.Ifc,
tool.Collector,
tool.Drawing,
drawing=drawing,
object_type=props.object_type,
relating_type=tool.Ifc.get().by_id(int(props.relating_type_id)) if props.relating_type_id != "0" else None,
enable_editing=True,
)
if props.object_type == "IMAGE":
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_sheet"
bl_label = "Add Sheet"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add a sheet to the project"
def _execute(self, context):
props = tool.Drawing.get_document_props()
core.add_sheet(tool.Ifc, tool.Drawing, titleblock=props.titleblock)
class DuplicateSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.duplicate_sheet"
bl_label = "Duplicate Sheet"
bl_description = "Make a copy of currently selected sheet"
bl_options = {"REGISTER", "UNDO"}
drawing: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
# Unconditionally disable until implemented
cls.poll_message_set("Not implemented yet.")
return False
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
return True
def _execute(self, context):
pass
"""
self.props = tool.Drawing.get_document_props()
core.duplicate_sheet(
tool.Ifc,
tool.Drawing,
sheet=tool.Ifc.get().by_id(self.sheet),
)
try:
sheet = tool.Ifc.get().by_id(self.props.active_sheet_id)
core.sync_references(tool.Ifc, tool.Collector, tool.Sheet, drawing=sheet)
except:
pass
"""
class OpenLayout(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.open_layout"
bl_label = "Open Sheet Layout"
bl_description = (
"Opens selected .svg layout with default system viewer\n"
+ 'or using "layout_svg_command" from the Bonsai preferences\n'
+ "(if provided)"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
self.props = tool.Drawing.get_document_props()
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
sheet = tool.Ifc.get().by_id(sheet_item.ifc_definition_id)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.update_sheet_drawing_sizes(sheet)
core.open_layout(tool.Drawing, sheet=sheet)
class SelectAllSheets(bpy.types.Operator):
bl_idname = "bim.select_all_sheets"
bl_label = "Select All Sheets"
view: bpy.props.StringProperty()
bl_description = "Select all sheets in the sheet list.\n\n" + "SHIFT+CLICK to deselect all sheets"
select_all: bpy.props.BoolProperty(name="Open All", default=True, options={"SKIP_SAVE"})
def invoke(self, context, event):
# deselect all sheets on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.select_all = False
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
for sheet in props.sheets:
if sheet.is_selected != self.select_all:
sheet.is_selected = self.select_all
return {"FINISHED"}
class OpenSheet(bpy.types.Operator):
bl_idname = "bim.open_sheet"
bl_label = "Open Sheet"
bl_description = (
"Opens selected sheet with default system viewer\n"
+ 'or using "svg_command" or "pdf_command" from\n'
+ "the Bonsai preferences (if provided).\n\n"
+ "SHIFT+CLICK to open all shown checked sheets"
)
bl_options = {"REGISTER", "UNDO"}
open_all: bpy.props.BoolProperty(name="Open All", default=False, options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_sheet_item(is_sheet=True):
cls.poll_message_set("No sheet selected.")
return False
return True
def invoke(self, context, event):
# opening all sheets on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.open_all = True
return self.execute(context)
def execute(self, context):
self.props = tool.Drawing.get_document_props()
svg2pdf_command = tool.Blender.get_addon_preferences().svg2pdf_command
if self.open_all:
sheets = [
tool.Ifc.get().by_id(s.ifc_definition_id) for s in self.props.sheets if s.is_sheet and s.is_selected
]
else:
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
sheets = [tool.Ifc.get().by_id(sheet_item.ifc_definition_id)]
sheet_uris: list[str] = []
sheets_not_found: list[str] = []
warnings: list[tool.Drawing.SheetWarningType] = []
for sheet in sheets:
if not sheet.is_a("IfcDocumentInformation"):
continue
warnings.extend(sheets_warnings := tool.Drawing.validate_sheet_files(sheet))
if sheets_warnings:
continue
sheet_builder = sheeter.SheetBuilder()
references = sheet_builder.build(sheet)
sheet_uri = references["SHEET"]
if svg2pdf_command:
sheet_uri = os.path.splitext(sheet_uri)[0] + ".pdf"
sheet_uris.append(sheet_uri)
if not os.path.exists(sheet_uri):
sheets_not_found.append(sheet.Name)
if warnings:
self.report({"ERROR"}, f"There were errors opening sheets. See system console for the details.")
print("-" * 10)
print("\n".join(str(w) for w in warnings))
if sheets_not_found:
msg = "Some sheets .svg/.pdf files were not found, need to create them first: \n{}.".format(
"\n".join(sheets_not_found)
)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
for sheet_uri in sheet_uris:
if svg2pdf_command:
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().pdf_command, sheet_uri)
else:
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, sheet_uri)
return {"FINISHED"}
class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_drawing_to_sheet"
bl_label = "Add Selected Drawing To Sheet"
bl_description = "Add the drawing selected in the\nDrawings list below to the sheet"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
props = tool.Drawing.get_document_props()
active_drawing = props.drawings[props.active_drawing_index]
assert active_drawing
ifc_file = tool.Ifc.get()
active_sheet = tool.Drawing.get_active_sheet()
drawing = tool.Ifc.get().by_id(active_drawing.ifc_definition_id)
drawing_reference = tool.Drawing.get_drawing_document(drawing)
sheet = tool.Ifc.get().by_id(active_sheet.ifc_definition_id)
if not sheet.is_a("IfcDocumentInformation"):
return
references = tool.Drawing.get_document_references(sheet)
has_drawing = False
for reference in references:
if reference.Location == drawing_reference.Location:
has_drawing = True
break
if has_drawing:
return
if not tool.Drawing.does_file_exist(tool.Drawing.get_document_uri(drawing_reference)):
self.report({"ERROR"}, "The drawing must be generated before adding to a sheet.")
return
reference = ifcopenshell.api.document.add_reference(ifc_file, information=sheet)
attributes = tool.Drawing.generate_reference_attributes(
reference,
Identification=str(
len(
[
r
for r in references
if tool.Drawing.get_reference_description(r) in ("DRAWING", "SCHEDULE", "REFERENCE")
]
)
+ 1
),
Location=drawing_reference.Location,
Description="DRAWING",
)
ifcopenshell.api.document.edit_reference(ifc_file, reference=reference, attributes=attributes)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.add_drawing(reference, drawing, sheet)
tool.Drawing.import_sheets()
class RemoveDrawingFromSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_drawing_from_sheet"
bl_label = "Remove Drawing From Sheet"
bl_description = "Remove currently selected drawing from sheet"
bl_options = {"REGISTER", "UNDO"}
reference: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
active_item = tool.Drawing.get_active_sheet_item()
if active_item is None:
return False
if active_item.reference_type == "TITLEBLOCK":
cls.poll_message_set("No effect deleting titleblock reference.")
return False
return True
def _execute(self, context):
ifc_file = tool.Ifc.get()
tool.Drawing.remove_drawing_from_sheet(ifc_file.by_id(self.reference))
class CreateSheets(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.create_sheets"
bl_label = "Create Sheets"
bl_description = (
"Build and open selected sheet from the sheet layout and\n"
+ "optionally create .pdf and .dxf from commands in\n"
+ "the Bonsai preferences (if provided).\n\n"
+ "SHIFT+CLICK to create all shown checked sheets, but doesn't\n"
+ "open them for viewing\n\n"
+ "Add the CTRL modifier to optionally open sheets to view them as\n"
+ "they are created"
)
bl_options = {"REGISTER", "UNDO"}
create_all: bpy.props.BoolProperty(name="Create All", default=False, options={"SKIP_SAVE"})
open_viewer: bpy.props.BoolProperty(name="Open in Viewer", default=False, options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_sheet_item(is_sheet=True):
cls.poll_message_set("No sheet selected.")
return False
prefs = tool.Blender.get_addon_preferences()
return props.sheets and prefs.data_dir
def invoke(self, context, event):
# opening all sheets on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.create_all = True
if event.type == "LEFTMOUSE" and event.ctrl:
self.open_viewer = True
return self.execute(context)
def _execute(self, context):
scene = context.scene
props = tool.Drawing.get_document_props()
svg2pdf_command = tool.Blender.get_addon_preferences().svg2pdf_command
svg2dxf_command = tool.Blender.get_addon_preferences().svg2dxf_command
ifc_file = tool.Ifc.get()
if self.create_all:
sheets = [tool.Ifc.get().by_id(s.ifc_definition_id) for s in props.sheets if s.is_sheet and s.is_selected]
else:
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
sheets = [tool.Ifc.get().by_id(sheet_item.ifc_definition_id)]
warnings: list[tool.Drawing.SheetWarningType] = []
n_sheets_created = 0
for sheet in sheets:
warnings.extend(sheet_warnings := tool.Drawing.validate_sheet_files(sheet))
if sheet_warnings:
continue
# Update any drawing boundary changes
sheet_builder = sheeter.SheetBuilder()
sheet_builder.update_sheet_drawing_sizes(sheet)
if not sheet.is_a("IfcDocumentInformation"):
return
name = os.path.splitext(os.path.basename(tool.Drawing.get_document_uri(sheet)))[0]
sheet_builder = sheeter.SheetBuilder()
references = sheet_builder.build(sheet)
# These variables will be made available to the evaluated commands
svg = references["SHEET"]
pdf = os.path.splitext(svg)[0] + ".pdf"
replacements = {
"svg": svg,
"basename": os.path.basename(svg),
"path": os.path.dirname(svg),
"pdf": pdf,
"eps": os.path.splitext(svg)[0] + ".eps",
"dxf": os.path.splitext(svg)[0] + ".dxf",
}
has_sheet_reference = False
for reference in tool.Drawing.get_document_references(sheet):
reference_description = tool.Drawing.get_reference_description(reference)
if reference_description == "SHEET":
has_sheet_reference = True
if not has_sheet_reference:
reference = ifcopenshell.api.document.add_reference(ifc_file, information=sheet)
ifcopenshell.api.document.edit_reference(
ifc_file,
reference=reference,
attributes=tool.Drawing.generate_reference_attributes(
reference, Location=tool.Ifc.get_uri(svg, use_relative_path=True), Description="SHEET"
),
)
if svg2pdf_command:
# With great power comes great responsibility. Example:
# [["inkscape", "svg", "-o", "pdf"]]
commands = json.loads(svg2pdf_command)
for command in commands:
subprocess.run([replacements.get(c, c) for c in command])
if svg2dxf_command:
# With great power comes great responsibility. Example:
# [["inkscape", "svg", "-o", "eps"], ["pstoedit", "-dt", "-f", "dxf:-polyaslines -mm", "eps", "dxf", "-psarg", "-dNOSAFER"]]
commands = json.loads(svg2dxf_command)
for command in commands:
command[0] = shutil.which(command[0]) or command[0]
subprocess.run([replacements.get(c, c) for c in command])
if self.open_viewer:
if svg2pdf_command:
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().pdf_command, pdf)
else:
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, svg)
n_sheets_created += 1
if not self.open_viewer:
self.report({"INFO"}, f"{n_sheets_created} sheets created...")
if warnings:
self.report({"ERROR"}, f"There were errors creating sheets. See system console for the details.")
print("-" * 10)
print("\n".join(str(w) for w in warnings))
class SelectAllDrawings(bpy.types.Operator):
bl_idname = "bim.select_all_drawings"
bl_label = "Select All Drawings"
view: bpy.props.StringProperty()
bl_description = "Select all drawings in the drawing list.\n\n" + "SHIFT+CLICK to deselect all drawings"
select_all: bpy.props.BoolProperty(name="Open All", default=True, options={"SKIP_SAVE"})
def invoke(self, context, event):
# deselect all drawings on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.select_all = False
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
for drawing in props.drawings:
if drawing.is_selected != self.select_all:
drawing.is_selected = self.select_all
return {"FINISHED"}
class OpenDrawing(bpy.types.Operator):
bl_idname = "bim.open_drawing"
bl_label = "Open Drawing"
view: bpy.props.StringProperty()
bl_description = (
"Opens selected .svg drawing with default system viewer\n"
+ 'or using "svg_command" from the Bonsai preferences (if provided).\n\n'
+ "SHIFT+CLICK to open all shown checked drawings"
)
open_all: bpy.props.BoolProperty(name="Open All", default=False, options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
return True
def invoke(self, context, event):
# opening all drawings on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.open_all = True
return self.execute(context)
def execute(self, context):
self.props = tool.Drawing.get_document_props()
if self.open_all:
drawings = [
tool.Ifc.get().by_id(d.ifc_definition_id) for d in self.props.drawings if d.is_drawing and d.is_selected
]
else:
drawings = [tool.Ifc.get().by_id(self.props.drawings.get(self.view).ifc_definition_id)]
drawing_uris = []
drawings_not_found = []
for drawing in drawings:
drawing_uri = tool.Drawing.get_document_uri(tool.Drawing.get_drawing_document(drawing))
drawing_uris.append(drawing_uri)
if not os.path.exists(drawing_uri):
drawings_not_found.append(drawing.Name)
if drawings_not_found:
msg = "Some drawings .svg files were not found, need to create them first: \n{}.".format(
"\n".join(drawings_not_found)
)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
for drawing_uri in drawing_uris:
tool.Drawing.open_with_user_command(tool.Blender.get_addon_preferences().svg_command, drawing_uri)
return {"FINISHED"}
class ActivateModel(bpy.types.Operator):
bl_idname = "bim.activate_model"
bl_label = "Activate Model"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Activate the model view.\n\n"
"Show all objects (and apply status filters if they were enabled before) and hide all annotations."
)
def execute(self, context):
start_time = time.time()
dprops = tool.Drawing.get_document_props()
dprops.active_drawing_id = 0
model_props = tool.Model.get_model_props()
if model_props.show_cut_decorator:
CutDecorator.uninstall()
ifc_file = tool.Ifc.get()
if not bpy.app.background:
with context.temp_override(**tool.Blender.get_viewport_context()):
bpy.ops.bim.activate_status_filters(only_if_enabled=True)
elements = {e for obj in context.visible_objects if (e := tool.Ifc.get_entity(obj))}
def refine_elements(
elements_mutable: set[ifcopenshell.entity_instance],
) -> dict[ifcopenshell.entity_instance, tuple[ifcopenshell.entity_instance, bpy.types.Object]]:
"""
:return: element -> (representation, obj)
"""
# TODO: in the future reimport_element_representations should have an option
# not recalculate elements completely, but get the from cache, to speed up the process further.
refined_elements: dict[
ifcopenshell.entity_instance, tuple[ifcopenshell.entity_instance, bpy.types.Object]
] = {}
elements = elements_mutable
while elements:
element = elements.pop()
model = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not model:
continue
assert isinstance(obj := tool.Ifc.get_object(element), bpy.types.Object)
current_representation = tool.Geometry.get_active_representation(obj)
if current_representation == model:
continue
# reimport_element_representations automatically reloads all elements sharing representation.
# So we should avoid reloading same elements twice.
resolved_model = ifcopenshell.util.representation.resolve_representation(model)
elements_sharing_representation = ifcopenshell.util.element.get_elements_by_representation(
ifc_file, resolved_model
)
refined_elements[element] = (model, obj)
elements = elements - elements_sharing_representation
return refined_elements
refined_elements = refine_elements(elements)
for _, (model, obj) in refined_elements.items():
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=model,
)
tool.Blender.reset_object_visibility()
tool.Drawing.hide_all_drawing_collections()
tool.Blender.update_viewport()
bonsai.bim.handler.refresh_ui_data()
operator_time = time.time() - start_time
if operator_time > 10:
self.report({"INFO"}, f"{self.bl_label} was finished in {operator_time:.2f} seconds.")
return {"FINISHED"}
class ActivateDrawingBase(tool.Ifc.Operator):
# Ifc Operator is necessary, because sync_references may create or remove IFC elements.
bl_label = "Activate Drawing"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Should View From Camera",
description="Move view to the activated drawing's camera position.",
default=True,
options={"SKIP_SAVE"},
)
use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Use Quick Preview",
description="Just move the camera to the drawing view, without loading anything else.",
default=False,
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
drawing: int
should_view_from_camera: bool
use_quick_preview: bool
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if event.type == "LEFTMOUSE" and event.alt:
self.should_view_from_camera = False
if event.type == "LEFTMOUSE" and event.shift:
self.use_quick_preview = True
return self.execute(context)
def _execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
assert context.scene
props = tool.Drawing.get_document_props()
if props.is_editing_drawings == False:
bpy.ops.bim.load_drawings()
drawing = tool.Ifc.get().by_id(self.drawing)
dprops = tool.Drawing.get_document_props()
if self.use_quick_preview:
tool.Blender.activate_camera(tool.Drawing.import_temporary_drawing_camera(drawing))
return {"FINISHED"}
viewport_position = None
v3d_space = tool.Blender.get_view3d_space()
assert v3d_space
if self.should_view_from_camera:
# Since we must switch to drawing camera, undo local camera in viewport.
# The code is needed to undo `else` branch from activating other cameras.
v3d_space.use_local_camera = False
else:
# Since we use `scene.camera` to store active drawing,
# the only way to preserve previous drawing camera position is making it local.
r3d = v3d_space.region_3d
assert r3d
if r3d.view_perspective == "CAMERA":
if v3d_space.use_local_camera:
# Nothing to do, local camera is already set by user.
pass
else:
previous_camera = context.scene.camera
if previous_camera:
v3d_space.camera = previous_camera
v3d_space.use_local_camera = True
else:
viewport_position = tool.Blender.get_viewport_position()
core.activate_drawing_view(tool.Ifc, tool.Blender, tool.Drawing, drawing=drawing)
if not self.should_view_from_camera:
if viewport_position is None:
# Local camera takes priority over active scene camera,
# so nothing to do after drawing view activation.
pass
else:
tool.Blender.set_viewport_position(viewport_position)
dprops.active_drawing_id = self.drawing
dprops.drawing_styles.clear()
bpy.ops.bim.reload_drawing_styles()
bpy.ops.bim.activate_drawing_style()
if tool.Drawing.is_camera_orthographic():
core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing))
model_props = tool.Model.get_model_props()
if model_props.show_cut_decorator:
CutDecorator.install(context)
tool.Drawing.show_decorations()
# Save drawing bounds to the .ifc file
camera = context.scene.camera
assert camera
camera_props = tool.Drawing.get_camera_props(camera)
# Check if this is a reflected ceiling camera and preserve its scale
camera_element = tool.Ifc.get_entity(camera)
is_reflected = False
if camera_element:
is_reflected = (
ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView")
== "REFLECTED_PLAN_VIEW"
)
if is_reflected and camera.scale != (-1, -1, -1):
camera.scale = (-1, -1, -1)
if camera_props.update_representation(camera.matrix_world):
bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="")
# Restore the scale after update if needed
if is_reflected:
camera.scale = (-1, -1, -1)
return {"FINISHED"}
class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
bl_idname = "bim.activate_drawing"
bl_label = "Activate Drawing"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
class ActivateDrawingFromSheet(bpy.types.Operator, ActivateDrawingBase):
bl_idname = "bim.activate_drawing_from_sheet"
bl_label = "Activate Drawing"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_sheet_item(reference_type="DRAWING"):
cls.poll_message_set("No drawing selected.")
return False
return True
class RemoveDrawing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_drawing"
bl_label = "Remove Drawing"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove currently selected drawing.\n\n" + "SHIFT+CLICK to remove all selected drawings"
drawing: bpy.props.IntProperty()
remove_all: bpy.props.BoolProperty(name="Remove All", default=False, options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
return True
def invoke(self, context, event):
# removing all selected drawings on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.remove_all = True
return self.execute(context)
def _execute(self, context):
props = tool.Drawing.get_document_props()
if self.remove_all:
drawings = [
tool.Ifc.get().by_id(d.ifc_definition_id) for d in props.drawings if d.is_drawing and d.is_selected
]
else:
if not self.drawing:
self.report({"ERROR"}, "No drawing selected")
return {"CANCELLED"}
drawings = [tool.Ifc.get().by_id(self.drawing)]
for drawing in drawings:
sheet_references = tool.Drawing.get_sheet_references(drawing)
for reference in sheet_references:
tool.Drawing.remove_drawing_from_sheet(reference)
core.remove_drawing(tool.Ifc, tool.Drawing, drawing=drawing)
# In case we removed the active drawing.
if not context.scene.camera and props.should_draw_decorations:
props.should_draw_decorations = False
class DrawingStyleJson(TypedDict):
render_type: "RenderType"
raster_style: dict[str, Any]
class ReloadDrawingStyles(bpy.types.Operator):
bl_idname = "bim.reload_drawing_styles"
bl_label = "Reload Drawing Styles"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload drawing styles for the active camera from the related JSON file."
def execute(self, context):
props = tool.Drawing.get_document_props()
assert (drawing := props.get_active_drawing())
drawing_pset_data = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
assert context.scene and (camera := context.scene.camera)
camera_props = tool.Drawing.get_camera_props(camera)
if "ShadingStyles" not in drawing_pset_data:
self.report({"ERROR"}, "Could not find shading styles path in EPset_Drawing.ShadingStyles.")
return {"CANCELLED"}
rel_path = drawing_pset_data["ShadingStyles"]
current_style = drawing_pset_data.get("CurrentShadingStyle", None)
json_path = Path(tool.Ifc.resolve_uri(rel_path))
if not json_path.exists():
ootb_resource = tool.Blender.get_data_dir_path(Path("assets") / "shading_styles.json")
print(
f"WARNING. Couldn't find shading_styles for the drawing by the path: {json_path}. "
f"Default BBIM resource will be copied from {ootb_resource}"
)
if ootb_resource.exists():
os.makedirs(json_path.parent, exist_ok=True)
shutil.copy(ootb_resource, json_path)
with open(json_path, "r") as fi:
shading_styles_json: dict[str, DrawingStyleJson] = json.load(fi)
drawing_styles = props.drawing_styles
drawing_styles.clear()
styles = [style for style in shading_styles_json]
for style_name in styles:
style_data = shading_styles_json[style_name]
drawing_style = drawing_styles.add()
drawing_style["name"] = style_name # setting as attribute to avoid triggering setter
drawing_style.render_type = style_data["render_type"]
drawing_style.raster_style = json.dumps(style_data["raster_style"])
if current_style is not None:
if current_style not in styles:
self.report({"WARNING"}, f"Could not find style {current_style} in EPset_Drawing.ShadingStyles.")
else:
camera_props.active_drawing_style_index = styles.index(current_style)
return {"FINISHED"}
# NOTE: Ifc Operator is not necessary for add and remove,
# as underlying save operator creates ifc undo step for us,
# but we keep it to make it more safe in case operators composition will change.
class AddDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_drawing_style"
bl_label = "Add Drawing Style"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
assert context.scene and (camera_obj := context.scene.camera)
props = tool.Drawing.get_document_props()
drawing_styles = props.drawing_styles
new = drawing_styles.add()
# drawing style is saved to ifc on rename
new.name = tool.Blender.ensure_unique_name("New Drawing Style", drawing_styles)
camera_props = tool.Drawing.get_camera_props(camera_obj)
camera_props.active_drawing_style_index = len(drawing_styles) - 1
return {"FINISHED"}
class RemoveDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_drawing_style"
bl_label = "Remove Drawing Style"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty()
def _execute(self, context):
assert context.scene and (camera_obj := context.scene.camera)
props = tool.Drawing.get_document_props()
props.drawing_styles.remove(self.index)
camera_props = tool.Drawing.get_camera_props(camera_obj)
camera_props.active_drawing_style_index = max(self.index - 1, 0)
bpy.ops.bim.save_drawing_styles_data()
return {"FINISHED"}
class SaveDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.save_drawing_style"
bl_label = "Save Drawing Style"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Save current render settings to currently selected drawing style. Also resaves styles data to IFC"
index: bpy.props.StringProperty()
# TODO: check undo redo
def _execute(self, context):
assert (space := tool.Blender.get_view3d_space()) # Do not remove. It is used later in eval
scene = context.scene
assert scene
style = {}
eval_namespace = {"context": context, "scene": scene, "space": space}
def add_prop_to_style(
prop_path: str, context: bpy.types.Context, scene: bpy.types.Scene, space: bpy.types.SpaceView3D
) -> None:
value = eval(prop_path)
if not isinstance(value, str):
try:
value = tuple(value)
except TypeError:
pass
style[prop_path] = value
for prop in RasterStyleProperty:
if prop.name.startswith("EVAL_PROP"):
prop_path = prop.value
add_prop_to_style(prop_path, **eval_namespace)
else:
props_source_path = prop.value
props_source = eval(props_source_path)
for prop_name in dir(props_source):
if prop_name.startswith("__"):
continue
prop_path = f"{props_source_path}.{prop_name}"
prop_value = eval(prop_path)
if (
not isinstance(prop_value, (int, float, bool, str, Color, Vector))
or props_source.is_property_readonly(prop_name)
or prop_path in RASTER_STYLE_PROPERTIES_EXCLUDE
):
continue
add_prop_to_style(prop_path, **eval_namespace)
if self.index:
index = int(self.index)
else:
assert (camera := scene.camera)
props = tool.Drawing.get_camera_props(camera)
index = props.active_drawing_style_index
props = tool.Drawing.get_document_props()
props.drawing_styles[index].raster_style = json.dumps(style)
bpy.ops.bim.save_drawing_styles_data()
return {"FINISHED"}
# TODO: operator is not exposed to UI, move it to tool.
class SaveDrawingStylesData(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.save_drawing_styles_data"
bl_label = "Save Drawing Styles Data"
bl_description = "Save current drawing styles settings to IFC and JSON."
bl_options = {"REGISTER", "UNDO"}
skip_updating_current_style: bpy.props.BoolProperty(default=False)
rename_style: bpy.props.BoolProperty(default=False)
rename_style_from: bpy.props.StringProperty(default="")
rename_style_to: bpy.props.StringProperty(default="")
def _execute(self, context):
props = tool.Drawing.get_document_props()
assert (drawing := props.get_active_drawing())
drawing_pset_data = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
drawing_styles = props.drawing_styles
rel_path = drawing_pset_data["ShadingStyles"]
current_style = drawing_pset_data.get("CurrentShadingStyle", None)
json_path = Path(tool.Ifc.resolve_uri(rel_path))
if not json_path.exists():
self.report({"ERROR"}, "Shading styles file not found: {}".format(json_path))
return {"CANCELLED"}
styles_data: dict[str, DrawingStyleJson] = {}
for style in drawing_styles:
styles_data[style.name] = {"render_type": style.render_type, "raster_style": json.loads(style.raster_style)}
with open(json_path, "w") as fo:
json.dump(styles_data, fo, indent=4)
# TODO: currently it doesn't update current style for the other drawings
# handling case when current style is not present in styles saved in ifc
if not self.skip_updating_current_style and current_style not in styles_data and current_style is not None:
# style was renamed
if self.rename_style and current_style == self.rename_style_from:
new_style_name = self.rename_style_to
# style was removed
else:
new_style_name = None
ifc_file = tool.Ifc.get()
assert (drawing := props.get_active_drawing())
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
assert pset
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"CurrentShadingStyle": new_style_name})
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.activate_drawing_style"
bl_label = "Activate Drawing Style"
bl_options = {"REGISTER", "UNDO"}
has_errors_during_activation = False
has_warnings_during_activation = False
def _execute(self, context):
scene = context.scene
assert scene and (camera := scene.camera)
camera_props = tool.Drawing.get_camera_props(camera)
ifc_file = tool.Ifc.get()
drawing_style = camera_props.get_active_drawing_style()
if drawing_style is None:
self.report({"ERROR"}, "Could not find active drawing style")
return {"CANCELLED"}
self.drawing_style = drawing_style
self.set_raster_style(context)
props = tool.Drawing.get_document_props()
assert (drawing := props.get_active_drawing())
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
assert pset
ifcopenshell.api.pset.edit_pset(
ifc_file, pset=pset, properties={"CurrentShadingStyle": self.drawing_style.name}
)
bonsai.bim.handler.refresh_ui_data()
if self.has_warnings_during_activation:
self.report(
{"WARNING"},
"There were warnings setting some drawing style properies, see system console for the details.",
)
if self.has_errors_during_activation:
self.report(
{"WARNING"},
"There were errors setting some drawing style properies, see system console for the details.",
)
return {"FINISHED"}
def set_raster_style(self, context: bpy.types.Context) -> None:
scene = context.scene # Do not remove. It is used in exec later
assert (space := tool.Blender.get_view3d_space()) # Do not remove. It is used in exec later
style = json.loads(self.drawing_style.raster_style)
def preprocess(path: str, value: Any) -> tuple[str, Any, bool, bool]:
warning = False
skip = False
# @25.05.12
if path == "scene.render.engine" and value == "BLENDER_EEVEE":
value = "BLENDER_EEVEE_NEXT"
print(
f"Warning: Value 'BLENDER_EEVEE' is outdated for property '{path}' "
"since Blender 4.2 and should be replaced with 'BLENDER_EEVEE_NEXT' in shading_styles.json."
)
warning = True
# @25.05.12
if path == "scene.display.shading.wireframe_color_type" and value == "MATERIAL":
value = "THEME"
print(
f"Warning: Value 'MATERIAL' is outdated for property '{path}' "
"since Blender 4.0 and should be replaced with 'THEME' in shading_styles.json."
)
warning = True
# @25.05.12
elif path in ("scene.render.simplify_shadows", "scene.render.simplify_shadows_render"):
print(
f"Warning: Property '{path}' is removed "
"since Blender 4.2 and should be also removed from shading_styles.json."
)
warning = True
skip = True
# @25.05.12
elif path in ("space.overlay.backwire_opacity", "space.overlay.show_edges"):
print(
f"Warning: Property '{path}' is removed "
"since Blender 4.1 and should be also removed from shading_styles.json."
)
warning = True
skip = True
# @25.05.12
elif path in ("space.overlay.show_occlude_wire",):
print(
f"Warning: Property '{path}' is removed "
"since Blender 3.6 and should be also removed from shading_styles.json."
)
warning = True
skip = True
return path, value, warning, skip
for path, value in style.items():
path, value, warning, skip = preprocess(path, value)
self.has_warnings_during_activation |= warning
if skip:
continue
try:
if isinstance(value, str):
exec(f"{path} = '{value}'")
else:
exec(f"{path} = {value}")
except Exception as e:
# Differences in Blender versions mean result in failures here
print(f"Failed to set drawing style property '{path}' to '{value}'. Error: '{str(e)}'")
self.has_errors_during_activation = True
if "PYTEST_VERSION" in os.environ:
raise
class RemoveSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_sheet"
bl_label = "Remove Sheet"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove currently selected sheet"
sheet: bpy.props.IntProperty()
def _execute(self, context):
core.remove_sheet(tool.Ifc, tool.Drawing, sheet=tool.Ifc.get().by_id(self.sheet))
class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.add_schedule"
bl_label = "Add Schedule"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add an .ods, .xls or .xlsx file as a schedule"
filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
def _execute(self, context):
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_schedule"
bl_label = "Remove Schedule"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the currently selected schedule"
schedule: bpy.props.IntProperty()
def _execute(self, context):
core.remove_document(tool.Ifc, tool.Drawing, "SCHEDULE", document=tool.Ifc.get().by_id(self.schedule))
class OpenSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.open_schedule"
bl_label = "Open Schedule"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Open the currently selected schedule \nin the default system viewer"
schedule: bpy.props.IntProperty()
def _execute(self, context):
core.open_schedule(tool.Drawing, schedule=tool.Ifc.get().by_id(self.schedule))
class BuildSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.build_schedule"
bl_label = "Build Schedule"
bl_description = "Create a .svg file of the selected schedule\nand open it with default system viewer"
schedule: bpy.props.IntProperty()
def _execute(self, context):
core.build_schedule(tool.Drawing, schedule=tool.Ifc.get().by_id(self.schedule))
class AddScheduleToSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_schedule_to_sheet"
bl_label = "Add Schedule To Sheet"
bl_description = "Add the schedule selected in the\nSchedules list below to the sheet"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not props.schedules:
cls.poll_message_set("No schedule selected.")
return False
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
props = tool.Drawing.get_document_props()
active_schedule = props.schedules[props.active_schedule_index]
active_sheet = tool.Drawing.get_active_sheet()
ifc_file = tool.Ifc.get()
schedule = tool.Ifc.get().by_id(active_schedule.ifc_definition_id)
if tool.Ifc.get_schema() == "IFC2X3":
schedule_location = tool.Drawing.get_path_with_ext(schedule.DocumentReferences[0].Location, "svg")
else:
schedule_location = tool.Drawing.get_path_with_ext(schedule.HasDocumentReferences[0].Location, "svg")
sheet = tool.Ifc.get().by_id(active_sheet.ifc_definition_id)
if not sheet.is_a("IfcDocumentInformation"):
return
references = tool.Drawing.get_document_references(sheet)
has_schedule = False
for reference in references:
if reference.Location == schedule_location:
has_schedule = True
break
if has_schedule:
return
if not tool.Drawing.does_file_exist(tool.Ifc.resolve_uri(schedule_location)):
self.report({"ERROR"}, "The schedule must be generated before adding to a sheet.")
return
reference = ifcopenshell.api.document.add_reference(ifc_file, information=sheet)
attributes = tool.Drawing.generate_reference_attributes(
reference,
Identification=str(
len(
[
r
for r in references
if tool.Drawing.get_reference_description(r) in ("DRAWING", "SCHEDULE", "REFERENCE")
]
)
+ 1
),
Location=schedule_location,
Description="SCHEDULE",
)
ifcopenshell.api.document.edit_reference(ifc_file, reference=reference, attributes=attributes)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.add_document(reference, schedule, sheet)
tool.Drawing.import_sheets()
class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_reference_to_sheet"
bl_label = "Add Reference To Sheet"
bl_description = "Add the reference selected in the\nReferences list below to the sheet"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not props.references:
cls.poll_message_set("No reference selected.")
return False
if not props.sheets:
cls.poll_message_set("No sheets available.")
return False
if not tool.Blender.get_user_data_dir():
cls.poll_message_set("BIM data directory not set.")
return False
return True
def _execute(self, context):
props = tool.Drawing.get_document_props()
active_reference = props.references[props.active_reference_index]
active_sheet = tool.Drawing.get_active_sheet()
ifc_file = tool.Ifc.get()
extref = tool.Ifc.get().by_id(active_reference.ifc_definition_id)
if tool.Ifc.get_schema() == "IFC2X3":
extref_location = tool.Drawing.get_path_with_ext(extref.DocumentReferences[0].Location, "svg")
else:
extref_location = tool.Drawing.get_path_with_ext(extref.HasDocumentReferences[0].Location, "svg")
sheet = tool.Ifc.get().by_id(active_sheet.ifc_definition_id)
if not sheet.is_a("IfcDocumentInformation"):
return
references = tool.Drawing.get_document_references(sheet)
has_extref = False
for reference in references:
if reference.Location == extref_location:
has_extref = True
break
if has_extref:
return
if not tool.Drawing.does_file_exist(tool.Ifc.resolve_uri(extref_location)):
self.report({"ERROR"}, f"Cannot find reference svg by path {extref_location}.")
return
reference = ifcopenshell.api.document.add_reference(ifc_file, information=sheet)
attributes = tool.Drawing.generate_reference_attributes(
reference,
Identification=str(
len(
[
r
for r in references
if tool.Drawing.get_reference_description(r) in ("DRAWING", "SCHEDULE", "REFERENCE")
]
)
+ 1
),
Location=extref_location,
Description="REFERENCE",
)
ifcopenshell.api.document.edit_reference(ifc_file, reference=reference, attributes=attributes)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.add_document(reference, extref, sheet)
tool.Drawing.import_sheets()
class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.add_reference"
bl_label = "Add Reference"
bl_description = "Import a .svg file to the project as a reference"
bl_options = {"REGISTER", "UNDO"}
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg"
def _execute(self, context):
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath)
class RemoveReference(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_reference"
bl_label = "Remove Reference"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the currently selected reference\nfrom the project"
reference: bpy.props.IntProperty()
def _execute(self, context):
core.remove_document(tool.Ifc, tool.Drawing, "REFERENCE", document=tool.Ifc.get().by_id(self.reference))
class OpenReference(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.open_reference"
bl_label = "Open Reference"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Open the reference into default system viewer"
reference: bpy.props.IntProperty()
def _execute(self, context):
core.open_reference(tool.Drawing, reference=tool.Ifc.get().by_id(self.reference))
class CleanWireframes(bpy.types.Operator):
bl_idname = "bim.clean_wireframes"
bl_label = "Clean Wireframes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.selected_objects:
objects = context.selected_objects
else:
objects = context.scene.objects
for obj in (o for o in objects if o.type == "MESH"):
if "EDGE_SPLIT" not in (m.type for m in obj.modifiers):
obj.modifiers.new("EdgeSplit", "EDGE_SPLIT")
return {"FINISHED"}
class EditTextPopup(bpy.types.Operator):
bl_idname = "bim.edit_text_popup"
bl_label = "Edit Text"
first_run: bpy.props.BoolProperty(default=True)
def draw(self, context):
from bonsai.bim.module.drawing.ui import BIM_PT_text
BIM_PT_text.draw_text_editing_ui(self, context, popup_mode=True)
def cancel(self, context):
# disable editing when dialog is closed
bpy.ops.bim.disable_editing_text()
def execute(self, context):
# can't use invoke() because this operator
# will be run indirectly by hotkey
# so we use execute() and track whether it's the first run of the operator
if self.first_run:
bpy.ops.bim.enable_editing_text()
self.first_run = False
return context.window_manager.invoke_props_dialog(self)
else:
bpy.ops.bim.edit_text()
return {"FINISHED"}
class EditText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_text"
bl_label = "Edit Text"
bl_description = "Save changes to the text annotation and\ndisable the text editing options"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.edit_text(tool.Drawing, obj=context.active_object)
tool.Blender.update_viewport()
class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_text"
bl_label = "Enable Editing Text"
bl_description = "Enable the text editing options for this\ntext annotation"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.enable_editing_text(tool.Drawing, obj=context.active_object)
class DisableEditingText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_text"
bl_label = "Disable Editing Text"
bl_description = "Discard changes to the text annotation\nand disable the text editing options"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
core.disable_editing_text(tool.Drawing, obj=obj)
# force update this object's font size for viewport display
DecoratorData.data.pop(obj.name, None)
tool.Blender.update_viewport()
class AddTextLiteral(bpy.types.Operator):
bl_idname = "bim.add_text_literal"
bl_label = "Add Text Literal"
bl_description = "Add another text literal to the\ntext annotation"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
assert obj
# similar to `tool.Drawing.import_text_attributes`
props = tool.Drawing.get_text_props(obj)
literal_props = props.literals.add()
literal_attributes = literal_props.attributes
literal_attr_values = {
"Literal": "Literal",
"Path": "RIGHT",
"BoxAlignment": "bottom_left",
}
# emulates `bonsai.bim.helper.import_attributes(ifc_literal, literal_props.attributes)`
for attr_name in literal_attr_values:
attr = literal_attributes.add()
attr.name = attr_name
if attr_name == "Path":
attr.data_type = "enum"
attr.enum_items = '["DOWN", "LEFT", "RIGHT", "UP"]'
attr.enum_value = literal_attr_values[attr_name]
else:
attr.data_type = "string"
attr.string_value = literal_attr_values[attr_name]
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
return {"FINISHED"}
class RemoveTextLiteral(bpy.types.Operator):
bl_idname = "bim.remove_text_literal"
bl_label = "Remove Text Literal"
bl_description = "Delete the text literal from the\ntext annotation"
bl_options = {"REGISTER", "UNDO"}
literal_prop_id: bpy.props.IntProperty()
def execute(self, context):
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.remove(self.literal_prop_id)
tool.Blender.update_viewport()
return {"FINISHED"}
class OrderTextLiteralUp(bpy.types.Operator):
bl_idname = "bim.order_text_literal_up"
bl_label = "Move Text Literal Up"
bl_description = "Move the text literal up in the\norder of literals"
bl_options = {"REGISTER", "UNDO"}
literal_prop_id: bpy.props.IntProperty()
def execute(self, context):
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.move(self.literal_prop_id, self.literal_prop_id - 1)
tool.Blender.update_viewport()
return {"FINISHED"}
class OrderTextLiteralDown(bpy.types.Operator):
bl_idname = "bim.order_text_literal_down"
bl_label = "Move Text Literal Down"
bl_description = "Move the text literal down in the\norder of literals"
bl_options = {"REGISTER", "UNDO"}
literal_prop_id: bpy.props.IntProperty()
def execute(self, context):
obj = context.active_object
assert obj
props = tool.Drawing.get_text_props(obj)
props.literals.move(self.literal_prop_id, self.literal_prop_id + 1)
tool.Blender.update_viewport()
return {"FINISHED"}
# Ifc Operator is unnecessary, because suboperator is handling IFC changes.
class AssignSelectedObjectAsProduct(bpy.types.Operator):
bl_idname = "bim.assign_selected_as_product"
bl_label = "Assign Selected Object As Product"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if len(context.selected_objects) != 2:
cls.poll_message_set("2 objects need to be selected")
return False
return True
def execute(self, context):
assert bpy.context.view_layer
objs = context.selected_objects[:]
obj1, obj2 = objs
element1 = tool.Ifc.get_entity(obj1)
element2 = tool.Ifc.get_entity(obj2)
assert element1 and element2
if element1.is_a("IfcAnnotation"):
other_selected_object = obj2
bpy.context.view_layer.objects.active = obj1
elif element2.is_a("IfcAnnotation"):
other_selected_object = obj1
bpy.context.view_layer.objects.active = obj2
else:
self.report({"ERROR"}, "One of the selected objects must be IfcAnnotation.")
return {"CANCELLED"}
assert (active_obj := context.active_object)
props = tool.Drawing.get_object_assigned_product_props(active_obj)
props.relating_product = other_selected_object
bpy.ops.bim.edit_assigned_product()
return {"FINISHED"}
class EditAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_assigned_product"
bl_label = "Edit Text Product"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
product = None
assert (obj := context.active_object)
props = tool.Drawing.get_object_assigned_product_props(obj)
if props.relating_product:
product = tool.Ifc.get_entity(props.relating_product)
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=obj, product=product)
tool.Blender.update_viewport()
class EnableEditingAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_assigned_product"
bl_label = "Enable Editing Assigned Product"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
assert context.active_object
core.enable_editing_assigned_product(tool.Drawing, obj=context.active_object)
class DisableEditingAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_assigned_product"
bl_label = "Disable Editing Assigned Product"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
assert context.active_object
core.disable_editing_assigned_product(tool.Drawing, obj=context.active_object)
class LoadSheets(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_sheets"
bl_label = "Load Sheets"
bl_description = "Load the saved sheets in this IFC project"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.load_sheets(tool.Drawing)
props = tool.Drawing.get_document_props()
warnings: list[tool.Drawing.SheetWarningType] = []
for sheet_prop in props.sheets:
if not sheet_prop.is_sheet:
continue
sheet = tool.Ifc.get().by_id(sheet_prop.ifc_definition_id)
document_uri = tool.Drawing.get_document_uri(sheet)
assert document_uri is not None
filepath = Path(document_uri)
if not filepath.is_file():
res = core.regenerate_sheet(tool.Drawing, sheet)
if res:
warnings.extend(res)
if warnings:
self.report({"WARNING"}, f"There were warnings loading sheets. See system console for the details.")
print("-" * 10)
print("\n".join(str(w) for w in warnings))
class EditSheet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_sheet"
bl_label = "Edit Sheet / Drawing"
bl_description = "Edit details of sheet or drawing"
bl_options = {"REGISTER", "UNDO"}
identification: bpy.props.StringProperty()
name: bpy.props.StringProperty()
document_type: Literal["SHEET", "TITLEBLOCK", "EMBEDDED"]
def invoke(self, context, event):
assert context.window_manager
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
sheet = tool.Ifc.get().by_id(sheet_item.ifc_definition_id)
if sheet.is_a("IfcDocumentInformation"):
self.document_type = "SHEET"
self.name = sheet.Name
self.identification = sheet.DocumentId if tool.Ifc.get_schema() == "IFC2X3" else sheet.Identification
elif sheet.is_a("IfcDocumentReference") and tool.Drawing.get_reference_description(sheet) == "TITLEBLOCK":
self.document_type = "TITLEBLOCK"
else:
self.document_type = "EMBEDDED"
self.identification = sheet.Identification
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
props = tool.Drawing.get_document_props()
if self.document_type == "SHEET":
row = self.layout.row()
row.prop(self, "identification", text="Identification")
row = self.layout.row()
row.prop(self, "name", text="Name")
elif self.document_type == "TITLEBLOCK":
row = self.layout.row()
row.prop(props, "titleblock", text="Titleblock")
elif self.document_type == "EMBEDDED":
row = self.layout.row()
row.prop(self, "identification", text="Identification")
def _execute(self, context):
props = tool.Drawing.get_document_props()
ifc_file = tool.Ifc.get()
sheet_item = tool.Drawing.get_active_sheet_item()
assert sheet_item
sheet = tool.Ifc.get().by_id(sheet_item.ifc_definition_id)
if self.document_type == "SHEET":
core.rename_sheet(tool.Ifc, tool.Drawing, sheet=sheet, identification=self.identification, name=self.name)
elif self.document_type == "EMBEDDED":
core.rename_reference(tool.Ifc, tool.Drawing, reference=sheet, identification=self.identification)
elif self.document_type == "TITLEBLOCK":
titleblock = props.titleblock
reference = sheet
sheet = tool.Drawing.get_reference_document(reference)
assert sheet
ifcopenshell.api.document.edit_reference(
ifc_file,
reference=reference,
attributes={"Location": tool.Drawing.get_default_titleblock_path(titleblock)},
)
sheet_builder = sheeter.SheetBuilder()
sheet_builder.change_titleblock(sheet, titleblock)
tool.Drawing.import_sheets()
class DisableEditingSheets(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_sheets"
bl_label = "Disable Editing Sheets"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.disable_editing_sheets(tool.Drawing)
class LoadSchedules(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_schedules"
bl_label = "Load Schedules"
bl_description = "Load the saved schedules in this IFC project"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.load_schedules(tool.Drawing)
class DisableEditingSchedules(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_schedules"
bl_label = "Disable Editing Schedules"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.disable_editing_schedules(tool.Drawing)
class LoadReferences(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_references"
bl_label = "Load References"
bl_description = "Load the saved references in this IFC project"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.load_references(tool.Drawing)
class DisableEditingReferences(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_references"
bl_label = "Disable Editing References"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.disable_editing_references(tool.Drawing)
class LoadDrawings(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_drawings"
bl_label = "Load Drawings"
bl_description = "Load the saved drawings in this IFC project"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.load_drawings(tool.Drawing)
class DisableEditingDrawings(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.disable_editing_drawings"
bl_label = "Disable Editing Drawings"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.disable_editing_drawings(tool.Drawing)
ToggleOption = Literal["EXPAND", "CONTRACT"]
class ToggleTargetView(bpy.types.Operator):
bl_idname = "bim.toggle_target_view"
bl_label = "Toggle Target View"
bl_options = {"REGISTER", "UNDO"}
target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
default=False,
options={"SKIP_SAVE"},
)
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(ToggleOption)]
)
if TYPE_CHECKING:
target_view: str
toggle_all: bool
option: ToggleOption
@classmethod
def description(cls, context, properties) -> str:
option: ToggleOption = properties.option
if option == "EXPAND":
return "Expand target view.\n\nSHIFT+CLICK to expand all view categories."
else:
return "Contract target view.\n\nSHIFT+CLICK to contract all view categories."
def invoke(self, context, event):
# Toggling all categories on shift+click.
# Make sure to use SKIP_SAVE on property, otherwise it might get stuck (copied from #4771).
if event.type == "LEFTMOUSE" and event.shift:
self.toggle_all = True
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
expanded = self.option == "EXPAND"
for drawing in props.drawings:
if drawing.is_drawing:
continue
if self.toggle_all or drawing.target_view == self.target_view:
drawing.is_expanded = expanded
core.load_drawings(tool.Drawing)
return {"FINISHED"}
class ExpandSheet(bpy.types.Operator):
bl_idname = "bim.expand_sheet"
bl_label = "Expand Sheet"
bl_description = "Show views, schedules, references etc\nplaced on this sheet.\n\nShift+click to expand all sheets."
bl_options = {"REGISTER", "UNDO"}
sheet: bpy.props.IntProperty()
expand_all: bpy.props.BoolProperty(name="Expand All", default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.shift:
self.expand_all = True
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
for sheet in [s for s in props.sheets if self.expand_all or s.ifc_definition_id == self.sheet]:
sheet.is_expanded = True
core.load_sheets(tool.Drawing)
return {"FINISHED"}
class ContractSheet(bpy.types.Operator):
bl_idname = "bim.contract_sheet"
bl_label = "Contract Sheet"
bl_description = (
"Hide views, schedules, references etc\nplaced on this sheet.\n\nShift+click to contract all sheets."
)
bl_options = {"REGISTER", "UNDO"}
sheet: bpy.props.IntProperty()
expand_all: bpy.props.BoolProperty(name="Expand All", default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.shift:
self.expand_all = True
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
for sheet in [s for s in props.sheets if self.expand_all or s.ifc_definition_id == self.sheet]:
sheet.is_expanded = False
core.load_sheets(tool.Drawing)
return {"FINISHED"}
class SelectAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.select_assigned_product"
bl_label = "Select Assigned Product"
bl_description = "Select the product this element is assigned to"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
core.select_assigned_product(tool.Drawing, context)
class EnableEditingElementFilter(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_element_filter"
bl_label = "Element Filter Mode"
bl_options = {"REGISTER", "UNDO"}
filter_mode: bpy.props.StringProperty()
@classmethod
def description(cls, context, properties):
if properties.filter_mode == "NONE":
return "Cancel filter"
return "Enable editing options for the include or exclude filter"
def _execute(self, context):
assert context.scene
obj = context.scene.camera
if not obj:
return
assert (camera := context.scene.camera)
props = tool.Drawing.get_camera_props(camera)
props.filter_mode = self.filter_mode
element = tool.Ifc.get_entity(obj)
assert element
if query := ifcopenshell.util.element.get_pset(element, "EPset_Drawing", self.filter_mode.title()):
filter_groups = tool.Search.get_filter_groups(f"drawing_{self.filter_mode.lower()}")
try:
tool.Search.import_filter_query(query, filter_groups)
except:
pass
class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_element_filter"
bl_label = "Edit Element Filter"
bl_description = "Saves the filter"
bl_options = {"REGISTER", "UNDO"}
filter_mode: bpy.props.StringProperty()
def _execute(self, context):
assert context.scene
obj = context.scene.camera
assert obj
props = tool.Drawing.get_camera_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
pset = tool.Pset.get_element_pset(element, "EPset_Drawing")
assert pset
if self.filter_mode == "INCLUDE":
query = tool.Search.export_filter_query(props.include_filter_groups) or None
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Include": query})
elif self.filter_mode == "EXCLUDE":
query = tool.Search.export_filter_query(props.exclude_filter_groups) or None
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": query})
props.filter_mode = "NONE"
bpy.ops.bim.activate_drawing(drawing=element.id(), should_view_from_camera=False)
class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_idname = "bim.add_reference_image"
bl_label = "Add Reference Image"
bl_description = "Add or import reference image to the IFC project"
bl_options = {"REGISTER", "UNDO"}
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
override_existing_image: bpy.props.BoolProperty(
name="Override Existing Image",
default=True,
description=(
"Override image if it was previously loaded to Blender. If disabled, will always create a new image"
),
)
use_existing_object_by_name: bpy.props.StringProperty(
name="Use Existing Object By Name",
description="Existing object name to add a style with reference image to. If not provided will create a new object.",
options={"SKIP_SAVE"},
)
def draw(self, context):
layout = self.layout
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
else:
self.use_relative_path = False
layout.label(text="Save the .ifc file first ")
layout.label(text="to use relative paths.")
layout.prop(self, "override_existing_image")
layout.prop(self, "use_existing_object_by_name")
def _execute(self, context):
abs_path = Path(self.filepath).absolute().resolve()
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
if self.override_existing_image:
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
image = load_image(abs_path.name, abs_path.parent, **params)
def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
plane_scale = (Vector(image.size) / min(image.size)).to_3d()
matrix = Matrix.LocRotScale(None, None, plane_scale)
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
tool.Blender.apply_bmesh(mesh, bm)
if self.use_existing_object_by_name:
obj = bpy.data.objects[self.use_existing_object_by_name]
bm_add_image_plane(obj.data)
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
else:
temp_mesh = bpy.data.meshes.new("temp_mesh")
bm_add_image_plane(temp_mesh)
obj = bpy.data.objects.new(image_filepath.stem, temp_mesh)
tool.Drawing.run_root_assign_class(
obj=obj,
ifc_class="IfcAnnotation",
predefined_type="IMAGE",
should_add_representation=True,
context=ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW"),
ifc_representation_class=None,
)
tool.Blender.remove_data_block(temp_mesh)
tool.Blender.set_active_object(obj)
material = bpy.data.materials.new(name=image_filepath.stem)
obj.data.materials.append(None) # new slot
obj.material_slots[0].material = material
bpy.ops.bim.add_style()
style = tool.Ifc.get_entity(material)
assert style
tool.Style.assign_style_to_object(style, obj)
# TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because
# we don't support IfcSurfaceStyleWithTextures without Rendering yet
shading_attributes = {
"SurfaceColour": {
"Red": 1.0,
"Green": 1.0,
"Blue": 1.0,
},
"Transparency": 0.0,
"ReflectanceMethod": "NOTDEFINED",
}
ifcopenshell.api.style.add_surface_style(
tool.Ifc.get(),
style=style,
ifc_class="IfcSurfaceStyleRendering",
attributes=shading_attributes,
)
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
textures = [texture]
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=textures, Mode="COORD") # UV map
ifcopenshell.api.style.add_surface_style(
ifc_file,
style=style,
ifc_class="IfcSurfaceStyleWithTextures",
attributes={"Textures": textures},
)
tool.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj)
class ConvertSVGToDXF(bpy.types.Operator):
bl_idname = "bim.convert_svg_to_dxf"
bl_label = "Convert SVG to DXF"
bl_options = {"REGISTER", "UNDO"}
view: bpy.props.StringProperty()
bl_description = "Convert selected drawing's .svg to .dxf.\n\nSHIFT+CLICK to convert all shown checked drawings"
convert_all: bpy.props.BoolProperty(name="Convert All", default=False, options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
props = tool.Drawing.get_document_props()
if not tool.Drawing.get_active_drawing_item():
cls.poll_message_set("No drawing selected.")
return False
return True
def invoke(self, context, event):
# convert all drawings on shift+click
# make sure to use SKIP_SAVE on property, otherwise it might get stuck
if event.type == "LEFTMOUSE" and event.shift:
self.convert_all = True
return self.execute(context)
def execute(self, context):
props = tool.Drawing.get_document_props()
if self.convert_all:
drawings = [
tool.Ifc.get().by_id(d.ifc_definition_id) for d in props.drawings if d.is_drawing and d.is_selected
]
else:
drawings = [tool.Ifc.get().by_id(props.drawings.get(self.view).ifc_definition_id)]
drawing_uris: list[Path] = []
drawings_not_found: list[str] = []
for drawing in drawings:
drawing_uri = tool.Drawing.get_document_uri(tool.Drawing.get_drawing_document(drawing))
if drawing_uri is None or not os.path.exists(drawing_uri):
drawings_not_found.append(drawing.Name)
else:
drawing_uris.append(Path(drawing_uri))
if drawings_not_found:
msg = "Some drawings .svg files were not found, need to print them first: \n{}.".format(
"\n".join(drawings_not_found)
)
self.report({"ERROR"}, msg)
return {"CANCELLED"}
for drawing_uri in drawing_uris:
tool.Drawing.convert_svg_to_dxf(drawing_uri, drawing_uri.with_suffix(".dxf"))
self.report({"INFO"}, f"{len(drawing_uris)} drawings were converted to .dxf.")
return {"FINISHED"}
class OpenDocumentationWebUi(bpy.types.Operator):
bl_idname = "bim.open_documentation_web_ui"
bl_label = "Open Documentation Web UI"
bl_description = "Open the documentation web UI page"
def execute(self, context):
if not tool.Web.get_web_props().is_connected:
bpy.ops.bim.connect_websocket_server(page="documentation")
else:
bpy.ops.bim.open_web_browser(page="documentation")
return {"FINISHED"}
class ExcludeAnnotation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.exclude_annotation"
bl_label = "Exclude Annotation"
bl_description = "Excludes the automatic annotation reference from the drawing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
if not (obj := bpy.context.scene.camera) or not (drawing := tool.Ifc.get_entity(obj)):
return
for obj in tool.Blender.get_selected_objects(include_active=False):
if (element := tool.Ifc.get_entity(obj)) and tool.Drawing.is_auto_annotation(element):
if referenced_element := tool.Drawing.get_annotation_element(element):
tool.Drawing.exclude_annotation_from_drawing(referenced_element, drawing)
core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=drawing)