mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-17 19:09:07 +00:00
28219973f6
Use get_default_annotation_matrix() for manual SECTION annotations so the object is placed in the camera's annotation plane with the correct rotation, matching how auto-generated section annotations are created. Without this, annotations placed in elevation or section camera views had identity rotation, causing the IFC representation to be in the wrong coordinate system and failing to tessellate. Also guard draw_edit_object_interface against non-IFC active objects to prevent AssertionError during toolbar redraws, and skip IFC item edit mode for ELEVATION/SECTION annotation types on placement.
8520 lines
356 KiB
Python
8520 lines
356 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 hashlib
|
|
import json
|
|
import logging
|
|
import multiprocessing
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from math import radians
|
|
from pathlib import Path
|
|
from timeit import default_timer as timer
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Literal,
|
|
NamedTuple,
|
|
Optional,
|
|
TypedDict,
|
|
Union,
|
|
get_args,
|
|
)
|
|
|
|
import bmesh
|
|
import bpy
|
|
import ifcopenshell
|
|
import ifcopenshell.api.document
|
|
import ifcopenshell.api.geometry
|
|
import ifcopenshell.api.pset
|
|
import ifcopenshell.api.style
|
|
import ifcopenshell.geom
|
|
import ifcopenshell.ifcopenshell_wrapper
|
|
import ifcopenshell.util.element
|
|
import ifcopenshell.util.representation
|
|
import ifcopenshell.util.selector
|
|
import ifcopenshell.util.shape_builder
|
|
import ifcopenshell.util.unit
|
|
import numpy as np
|
|
import shapely
|
|
from bpy_extras.image_utils import load_image
|
|
from bpy_extras.io_utils import ImportHelper
|
|
from lxml import etree
|
|
from mathutils import Color, Matrix, Vector
|
|
|
|
import bonsai.bim.export_ifc
|
|
import bonsai.bim.handler
|
|
import bonsai.bim.import_ifc
|
|
import bonsai.bim.module.drawing.sheeter as sheeter
|
|
import bonsai.bim.module.drawing.svgwriter as svgwriter
|
|
import bonsai.core.drawing as core
|
|
import bonsai.core.geometry
|
|
import bonsai.tool as tool
|
|
from bonsai.bim.ifc import IfcStore
|
|
from bonsai.bim.module.model.decorator import PolylineDecorator
|
|
from bonsai.bim.module.model.polyline import PolylineOperator
|
|
from bonsai.bim.module.drawing.data import DecoratorData, ElementValuesData
|
|
from bonsai.bim.module.drawing.decoration import CutDecorator
|
|
from bonsai.bim.module.drawing.prop import (
|
|
RASTER_STYLE_PROPERTIES_EXCLUDE,
|
|
RasterStyleProperty,
|
|
)
|
|
from bonsai.bim.module.drawing.ui import get_current_product_for_element_values
|
|
from bonsai.bim.prop import StrProperty
|
|
|
|
if TYPE_CHECKING:
|
|
from bpy.stub_internal import rna_enums
|
|
|
|
from bonsai.bim.module.drawing.prop import RenderType
|
|
from bonsai.bim.module.project.prop import Link
|
|
|
|
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", 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,
|
|
)
|
|
|
|
|
|
def get_copy_annotation_target_drawings(self, context):
|
|
global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
|
|
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
|
|
drawings.sort(key=lambda d: d.Name or "")
|
|
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings]
|
|
return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
|
|
|
|
|
|
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = []
|
|
|
|
|
|
class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.copy_annotation_to_drawing"
|
|
bl_label = "Copy Annotation To Drawing"
|
|
bl_description = (
|
|
"Copy the selected annotations to another drawing.\n\n"
|
|
"The copies become independent annotations assigned to the chosen drawing, "
|
|
"placed in its view plane. The originals stay in their current drawing"
|
|
)
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings)
|
|
|
|
if TYPE_CHECKING:
|
|
target_drawing: str
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC project loaded.")
|
|
return False
|
|
if not cls.get_selected_annotations(context):
|
|
cls.poll_message_set("No annotation selected.")
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]:
|
|
return [
|
|
element
|
|
for obj in context.selected_objects
|
|
if (element := tool.Ifc.get_entity(obj))
|
|
and element.is_a("IfcAnnotation")
|
|
and element.ObjectType != "DRAWING"
|
|
]
|
|
|
|
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()
|
|
row.prop(self, "target_drawing")
|
|
|
|
def _execute(self, context):
|
|
if not self.target_drawing:
|
|
self.report({"ERROR"}, "No target drawing selected.")
|
|
return {"CANCELLED"}
|
|
target_drawing = tool.Ifc.get().by_id(int(self.target_drawing))
|
|
annotations = self.get_selected_annotations(context)
|
|
previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))]
|
|
previous_active = context.view_layer.objects.active
|
|
copied = core.copy_annotations_to_drawing(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Drawing,
|
|
tool.Geometry,
|
|
annotations=annotations,
|
|
target_drawing=target_drawing,
|
|
)
|
|
for obj in context.selected_objects:
|
|
obj.select_set(False)
|
|
for obj in previous_selection:
|
|
if obj.name in context.view_layer.objects:
|
|
obj.select_set(True)
|
|
if previous_active and previous_active.name in context.view_layer.objects:
|
|
context.view_layer.objects.active = previous_active
|
|
skipped = len(annotations) - len(copied)
|
|
message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}."
|
|
if skipped:
|
|
message += f" Skipped {skipped} already in that drawing."
|
|
self.report({"INFO"}, message)
|
|
|
|
|
|
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(
|
|
name="Print All",
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
open_viewer: bpy.props.BoolProperty(
|
|
name="Open in Viewer",
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
sync: bpy.props.BoolProperty(
|
|
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)
|
|
original_cache_setting = self.props.should_use_underlay_cache
|
|
self.props.should_use_underlay_cache = False
|
|
|
|
# Force Blender to process all pending operations
|
|
for area in context.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
# Process events to let Blender finish internal cleanup
|
|
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
|
|
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"}
|
|
|
|
# Clear any local camera setup and force viewport to use scene camera
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
for space in area.spaces:
|
|
if space.type == "VIEW_3D":
|
|
# Clear local camera to ensure we use scene.camera
|
|
space.use_local_camera = False
|
|
space.camera = context.scene.camera
|
|
space.region_3d.view_perspective = "CAMERA"
|
|
print(f"Set viewport camera to: {context.scene.camera.name}")
|
|
break
|
|
|
|
# Force complete scene update
|
|
context.view_layer.update()
|
|
context.evaluated_depsgraph_get()
|
|
|
|
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,
|
|
link_matrix: Optional[Matrix] = None,
|
|
) -> 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)
|
|
|
|
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
|
|
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
|
|
|
|
if link_matrix is not None:
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
|
|
t = link_matrix.to_translation()
|
|
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
|
|
geom_settings.set("model-offset", offset)
|
|
q = link_matrix.to_quaternion()
|
|
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
|
|
elif z_offset:
|
|
# A 2mm Z offset to combat Z-fighting in plan or RCPs
|
|
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
|
|
|
|
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
|
|
else:
|
|
assert False, material
|
|
|
|
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)
|
|
|
|
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])
|
|
else:
|
|
assert False, usage
|
|
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
|
|
|
|
ifc = tool.Ifc.get()
|
|
semantics = None
|
|
pairs = None
|
|
|
|
# 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()
|
|
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
|
|
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
|
|
|
|
props = tool.Project.get_project_props()
|
|
for link in props.get_loaded_links_for_drawings():
|
|
try:
|
|
link_matrix = tool.Project.calculate_link_matrix(link)
|
|
except Exception:
|
|
link_matrix = None
|
|
files[link.filepath] = (self.get_linked_file(link), link_matrix)
|
|
|
|
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)
|
|
|
|
# Accumulated across every file in the loop below (main model plus any
|
|
# linked models) so the SHAPELY fill pass after the loop covers all of
|
|
# them, not just whichever file happened to be processed last.
|
|
raycast_objs = set()
|
|
elements_with_faces = set()
|
|
|
|
for ifc_path, (ifc, link_matrix) 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)
|
|
|
|
if self.cprops.fill_mode == "SHAPELY":
|
|
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)
|
|
|
|
# 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, link_matrix)
|
|
self.serialize_contexts_elements(
|
|
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
|
|
)
|
|
|
|
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
|
|
|
|
# Add target_view and scale classes to the parent group from IFC data
|
|
if group is not None:
|
|
existing_classes = group.get("class", "").split()
|
|
|
|
# Add target_view class
|
|
if hasattr(self, "cprops") and getattr(self.cprops, "target_view", None):
|
|
target_view_class = tool.Drawing.canonicalise_class_name(str(self.cprops.target_view))
|
|
target_view_full_class = f"target-view-{target_view_class}"
|
|
if target_view_full_class not in existing_classes:
|
|
existing_classes.append(target_view_full_class)
|
|
|
|
# Add scale class from EPset_Drawing.Scale
|
|
drawing_pset = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing")
|
|
if drawing_pset and drawing_pset.get("Scale"):
|
|
scale_value = drawing_pset["Scale"]
|
|
# Remove "1/" prefix if it exists
|
|
if isinstance(scale_value, str) and scale_value.startswith("1/"):
|
|
scale_value = scale_value[2:]
|
|
scale_class = tool.Drawing.canonicalise_class_name(str(scale_value))
|
|
scale_full_class = f"scale-{scale_class}"
|
|
if scale_full_class not in existing_classes:
|
|
existing_classes.append(scale_full_class)
|
|
|
|
group.set("class", " ".join(existing_classes))
|
|
|
|
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")
|
|
|
|
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 0.1mm2 is not worth styling on sheet
|
|
if polygon.area < 0.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)
|
|
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
|
|
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
|
|
try:
|
|
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
|
|
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
|
|
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
|
|
self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp)
|
|
self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees)
|
|
self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush)
|
|
except Exception:
|
|
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
|
|
pass
|
|
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))
|
|
)
|
|
|
|
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.filepath
|
|
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",
|
|
"Material.Name",
|
|
]
|
|
|
|
join_classes = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinClasses")
|
|
if join_classes:
|
|
join_classes = tuple(c.strip() for c in join_classes.split(",") if c.strip())
|
|
else:
|
|
# Architectural convention only merges these objects by default. E.g. pipe
|
|
# segments and fittings shouldn't merge. Users may override this per-drawing
|
|
# via the EPset_Drawing.JoinClasses property (e.g. to also join IfcCovering).
|
|
join_classes = ("IfcWall", "IfcSlab")
|
|
|
|
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
|
|
|
|
if not any(element.is_a(c) for c in join_classes):
|
|
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,
|
|
# Deterministic tiebreaker so equal-priority annotations keep a
|
|
# stable order across sessions. Without it the order comes from
|
|
# the set union above, which depends on entity hashes (and thus
|
|
# the file pointer), shuffling annotations between Blender
|
|
# restarts. See #6608.
|
|
a.id(),
|
|
),
|
|
)
|
|
|
|
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,
|
|
# ELEVATION/SECTION annotations use simple empty/line geometry that
|
|
# cannot be tessellated by the IFC geometry engine, so skip IFC
|
|
# item edit mode for these types.
|
|
enable_editing=object_type not in ("ELEVATION", "SECTION"),
|
|
)
|
|
if props.object_type == "IMAGE":
|
|
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
|
|
|
|
|
|
# AddElevationAnnotation is defined after SetDimensionAnchor (below) because it
|
|
# inherits from it. This placeholder keeps the module namespace tidy.
|
|
|
|
|
|
def _get_drawing_enum_items(self, context):
|
|
items = [("0", "None", "Clear the drawing assignment")]
|
|
if ifc := tool.Ifc.get():
|
|
for d in ifc.by_type("IfcAnnotation"):
|
|
if d.ObjectType == "DRAWING":
|
|
items.append((str(d.id()), d.Name or f"Drawing {d.id()}", ""))
|
|
return items
|
|
|
|
|
|
class AssignManualDrawingReference(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.assign_manual_drawing_reference"
|
|
bl_label = "Assign Drawing"
|
|
bl_description = "Assign a target drawing to this manual drawing reference tag"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
drawing_id: bpy.props.EnumProperty(name="Drawing", items=_get_drawing_enum_items)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get() or not context.active_object:
|
|
return False
|
|
element = tool.Ifc.get_entity(context.active_object)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
return False
|
|
if element.ObjectType not in ("ELEVATION", "SECTION"):
|
|
cls.poll_message_set("Active object is not an elevation or section annotation.")
|
|
return False
|
|
if not ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "IsManualDrawingReference"):
|
|
cls.poll_message_set("Active object is not a manual drawing reference.")
|
|
return False
|
|
return True
|
|
|
|
def invoke(self, context, event):
|
|
element = tool.Ifc.get_entity(context.active_object)
|
|
current = tool.Drawing.get_annotation_element(element)
|
|
self.drawing_id = str(current.id()) if current else "0"
|
|
return context.window_manager.invoke_props_dialog(self)
|
|
|
|
def draw(self, context):
|
|
self.layout.prop(self, "drawing_id")
|
|
|
|
def _execute(self, context):
|
|
element = tool.Ifc.get_entity(context.active_object)
|
|
drawing = tool.Ifc.get().by_id(int(self.drawing_id)) if self.drawing_id != "0" else None
|
|
core.assign_manual_drawing_reference(tool.Ifc, tool.Drawing, element=element, drawing=drawing)
|
|
for area in context.screen.areas:
|
|
if area.type == "PROPERTIES":
|
|
area.tag_redraw()
|
|
|
|
|
|
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()
|
|
# When filtering to sheeted drawings only, act on the visible drawings only.
|
|
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() if props.show_drawings_on_sheets_only else None
|
|
for drawing in props.drawings:
|
|
if sheeted_ids is not None and drawing.is_drawing and drawing.ifc_definition_id not in sheeted_ids:
|
|
continue
|
|
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.\n\n"
|
|
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
|
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
|
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
|
)
|
|
|
|
drawing: bpy.props.IntProperty()
|
|
should_view_from_camera: bpy.props.BoolProperty(
|
|
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(
|
|
name="Use Quick Preview",
|
|
description="Just move the camera to the drawing view, without loading anything else.",
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
load_selected_annotations: bpy.props.BoolProperty(
|
|
name="Load Selected Annotations",
|
|
description="Load the annotations of all selected drawings without switching the active view.",
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
include_annotations_in_selection: bpy.props.BoolProperty(
|
|
name="Include Annotations In Selection",
|
|
description="Also select the loaded annotation objects, not just the drawing cameras.",
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
drawing: int
|
|
should_view_from_camera: bool
|
|
use_quick_preview: bool
|
|
load_selected_annotations: bool
|
|
include_annotations_in_selection: bool
|
|
|
|
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
|
|
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
|
|
self.load_selected_annotations = True
|
|
if event.alt:
|
|
self.include_annotations_in_selection = True
|
|
return self.execute(context)
|
|
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()
|
|
|
|
if self.load_selected_annotations:
|
|
objs_to_select = []
|
|
active_camera = None
|
|
for d in props.drawings:
|
|
if not (d.is_drawing and d.is_selected):
|
|
continue
|
|
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
|
|
# Importing the camera (if missing) ensures the drawing's
|
|
# collection exists so the annotations get collected into it.
|
|
if not (camera := tool.Ifc.get_object(selected_drawing)):
|
|
camera = tool.Drawing.import_drawing(selected_drawing)
|
|
group = tool.Drawing.get_drawing_group(selected_drawing)
|
|
tool.Drawing.import_annotations_in_group(group)
|
|
|
|
if active_camera is None:
|
|
active_camera = camera
|
|
objs_to_select.append(camera)
|
|
if self.include_annotations_in_selection:
|
|
for element in tool.Drawing.get_group_elements(group) or []:
|
|
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
|
|
if annotation_obj := tool.Ifc.get_object(element):
|
|
objs_to_select.append(annotation_obj)
|
|
|
|
# Select the checked drawings' objects, with the first drawing's camera as active.
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for obj in objs_to_select:
|
|
obj.select_set(True)
|
|
if active_camera is not None:
|
|
context.view_layer.objects.active = active_camera
|
|
return {"FINISHED"}
|
|
|
|
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.\n\n"
|
|
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
|
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
|
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
|
)
|
|
|
|
|
|
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: dict[str, Any] = json.loads(self.drawing_style.raster_style)
|
|
|
|
VIEWPORT_SHADING_TYPE = "scene.display.shading.type"
|
|
|
|
def preprocess(path: str, value: Any) -> tuple[str, Any, bool, bool]:
|
|
warning = False
|
|
skip = False
|
|
|
|
BLENDER_5_REMOVED = (
|
|
"scene.render.bake_bias",
|
|
"scene.render.bake_margin",
|
|
"scene.render.bake_margin_type",
|
|
"scene.render.bake_samples",
|
|
"scene.render.bake_type",
|
|
"scene.render.bake_user_scale",
|
|
"scene.render.use_bake_clear",
|
|
"scene.render.use_bake_lores_mesh",
|
|
"scene.render.use_bake_multires",
|
|
"scene.render.use_bake_selected_to_active",
|
|
"scene.render.use_bake_user_scale",
|
|
)
|
|
|
|
# 25.11.07, Blender 5+
|
|
if path == "scene.render.engine" and value in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"):
|
|
if value == "BLENDER_EEVEE_NEXT":
|
|
print(
|
|
f"Warning: Value 'BLENDER_EEVEE_NEXT' is outdated for property '{path}' "
|
|
"since Blender 5.0 and should be replaced with 'BLENDER_EEVEE' in shading_styles.json."
|
|
)
|
|
warning = True
|
|
value = tool.Blender.get_eevee_name()
|
|
elif tool.Blender.BLENDER_5 and path in BLENDER_5_REMOVED:
|
|
print(
|
|
f"Warning: Property '{path}' is removed "
|
|
"since Blender 5.0 and should be also removed from shading_styles.json."
|
|
)
|
|
warning = True
|
|
skip = True
|
|
# @25.11.11
|
|
elif (
|
|
path == "scene.display.shading.studio_light"
|
|
and value == "Default"
|
|
and style[VIEWPORT_SHADING_TYPE] in ("RENDERED", "MATERIAL")
|
|
):
|
|
value = "forest.exr"
|
|
print(
|
|
f"Warning: Value 'Default' for property '{path}' and "
|
|
f"'{VIEWPORT_SHADING_TYPE}' = '{style[VIEWPORT_SHADING_TYPE]}' is outdated "
|
|
"and should be replaced with 'forest.exr' in shading_styles.json."
|
|
)
|
|
warning = True
|
|
# @25.05.12
|
|
elif 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
|
|
|
|
paths = list(style.keys())
|
|
PRIORITY_PATHS = (
|
|
# `scene.display.shading.studio_light` values depend on `.type`
|
|
# so we got to set it first.
|
|
VIEWPORT_SHADING_TYPE,
|
|
)
|
|
paths.sort(key=lambda p: p not in PRIORITY_PATHS)
|
|
|
|
for path in paths:
|
|
value = style[path]
|
|
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"
|
|
|
|
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
|
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
|
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):
|
|
for filepath in tool.Blender.get_selected_files(
|
|
self.directory, self.files, 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"}
|
|
|
|
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
|
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
|
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):
|
|
for filepath in tool.Blender.get_selected_files(
|
|
self.directory, self.files, 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):
|
|
# TODO: check for possible subtle undo bug here
|
|
# 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=tool.Blender.get_active_object())
|
|
tool.Blender.update_viewport()
|
|
|
|
|
|
class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.copy_text_to_selection"
|
|
bl_label = "Copy Text To Selection"
|
|
bl_description = "Copy text formatting or literals to selected objects"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
attribute: bpy.props.StringProperty()
|
|
|
|
def _execute(self, context):
|
|
apply_objs = [
|
|
obj
|
|
for obj in tool.Blender.get_selected_objects()
|
|
if (element := tool.Ifc.get_entity(obj))
|
|
and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
|
|
]
|
|
core.copy_text_to_selection(
|
|
tool.Drawing,
|
|
attribute=self.attribute,
|
|
attribute_obj=tool.Blender.get_active_object(),
|
|
apply_objs=apply_objs,
|
|
)
|
|
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):
|
|
obj = context.active_object
|
|
props = tool.Drawing.get_text_props(obj)
|
|
core.enable_editing_text(tool.Drawing, obj=obj)
|
|
|
|
text_element = tool.Ifc.get_entity(obj)
|
|
assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None
|
|
assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None
|
|
|
|
if "_bonsai_element_value_rows_backup" in obj:
|
|
try:
|
|
literals_backup = json.loads(obj["_bonsai_element_value_rows_backup"])
|
|
for i, literal_backup in enumerate(literals_backup):
|
|
if i < len(props.literals):
|
|
literal_props = props.literals[i]
|
|
|
|
if assigned_product_obj:
|
|
literal_props.product_used = assigned_product_obj
|
|
elif "product_used" in literal_backup and literal_backup["product_used"]:
|
|
product_name = literal_backup["product_used"]
|
|
if product_name in bpy.data.objects:
|
|
literal_props.product_used = bpy.data.objects[product_name]
|
|
|
|
literal_props.element_value_rows.clear()
|
|
if "element_value_rows" in literal_backup:
|
|
for row_data in literal_backup["element_value_rows"]:
|
|
new_row = literal_props.element_value_rows.add()
|
|
new_row.category = row_data.get("category", "")
|
|
new_row.element_key = row_data.get("element_key", "")
|
|
new_row.formatted_value = row_data.get("formatted_value", "")
|
|
new_row.separator = row_data.get("separator", "")
|
|
except (json.JSONDecodeError, KeyError) as e:
|
|
print(f"Failed to restore element_value_rows: {e}")
|
|
else:
|
|
if assigned_product_obj:
|
|
for literal_props in props.literals:
|
|
literal_props.product_used = assigned_product_obj
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
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]
|
|
|
|
literal_props.align_vertical = "bottom"
|
|
literal_props.align_horizontal = "left"
|
|
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"}
|
|
|
|
|
|
class AssignSelectedObjectAsProduct(bpy.types.Operator, tool.Ifc.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("At least 2 objects need to be selected")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
objs = context.selected_objects[:]
|
|
ifc_objs = [(o, tool.Ifc.get_entity(o)) for o in objs if tool.Ifc.get_entity(o)]
|
|
|
|
annotations = [(o, e) for o, e in ifc_objs if e.is_a("IfcAnnotation")]
|
|
non_annotations = [(o, e) for o, e in ifc_objs if not e.is_a("IfcAnnotation")]
|
|
|
|
if not annotations:
|
|
self.report({"ERROR"}, "At least one selected object must be an IfcAnnotation.")
|
|
return {"CANCELLED"}
|
|
|
|
if len(non_annotations) == 1:
|
|
# One product, one or more annotations — assign all annotations to the product.
|
|
product = non_annotations[0][1]
|
|
elif len(non_annotations) == 0 and len(annotations) == 2:
|
|
# Both objects are annotations — use the non-active one as the relating product.
|
|
active_obj = context.active_object
|
|
if annotations[0][0] == active_obj:
|
|
annotation_obj, annotation = annotations[0]
|
|
product = annotations[1][1]
|
|
else:
|
|
annotation_obj, annotation = annotations[1]
|
|
product = annotations[0][1]
|
|
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
|
|
tool.Blender.update_viewport()
|
|
return
|
|
else:
|
|
self.report(
|
|
{"ERROR"},
|
|
"Select exactly one product object and one or more IfcAnnotation objects.",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
for annotation_obj, _ in annotations:
|
|
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
|
|
|
|
tool.Blender.update_viewport()
|
|
|
|
|
|
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 = tool.Document.get_document_information_id(sheet)
|
|
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"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
# Operator is not accessible through UI if IFC project is not saved,
|
|
# but adding poll check to avoid using Drawings UI in scripts with unsaved project.
|
|
if tool.Ifc.get_path():
|
|
return True
|
|
cls.poll_message_set("IFC project is not saved.")
|
|
return False
|
|
|
|
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()
|
|
toggle_all: bpy.props.BoolProperty(
|
|
default=False,
|
|
options={"SKIP_SAVE"},
|
|
)
|
|
option: bpy.props.EnumProperty(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 ToggleDrawingCategorySelection(bpy.types.Operator):
|
|
bl_idname = "bim.toggle_drawing_category_selection"
|
|
bl_label = "Toggle Category Selection"
|
|
bl_description = "Select or deselect all drawings in this view category"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
target_view: bpy.props.StringProperty()
|
|
|
|
if TYPE_CHECKING:
|
|
target_view: str
|
|
|
|
def execute(self, context):
|
|
drawings = tool.Drawing.get_visible_drawings_in_category(self.target_view)
|
|
# If everything visible in the category is already selected, deselect all; otherwise select all.
|
|
new_state = not all(d.is_selected for d in drawings)
|
|
for drawing in drawings:
|
|
drawing.is_selected = new_state
|
|
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"})
|
|
x_length: bpy.props.FloatProperty(
|
|
name="X Length",
|
|
description="Width of the reference image",
|
|
default=1.0,
|
|
min=0.001,
|
|
soft_min=0.01,
|
|
precision=3,
|
|
unit="LENGTH",
|
|
)
|
|
y_length: bpy.props.FloatProperty(
|
|
name="Y Length",
|
|
description="Height of the reference image",
|
|
default=1.0,
|
|
min=0.001,
|
|
soft_min=0.01,
|
|
precision=3,
|
|
unit="LENGTH",
|
|
)
|
|
show_texture_solid_mode: bpy.props.BoolProperty(
|
|
name="Show Texture in Solid mode (slow)",
|
|
description="Show Texture in Solid mode (slow)",
|
|
default=False,
|
|
)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC project is loaded.")
|
|
return False
|
|
return True
|
|
|
|
def invoke(self, context, event):
|
|
self._last_filepath = ""
|
|
return super().invoke(context, event)
|
|
|
|
def check(self, context):
|
|
if not hasattr(self, "_last_filepath"):
|
|
self._last_filepath = ""
|
|
|
|
if self.filepath and self.filepath != self._last_filepath:
|
|
self._last_filepath = self.filepath
|
|
|
|
abs_path = Path(self.filepath).absolute().resolve()
|
|
if abs_path.exists() and abs_path.is_file():
|
|
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
|
|
image_width_px = image.size[0]
|
|
image_height_px = image.size[1]
|
|
aspect_ratio = image_width_px / image_height_px
|
|
|
|
if aspect_ratio >= 1.0:
|
|
self.x_length = 1.0
|
|
self.y_length = 1.0 / aspect_ratio
|
|
else:
|
|
self.x_length = aspect_ratio
|
|
self.y_length = 1.0
|
|
|
|
bpy.data.images.remove(image)
|
|
return True
|
|
|
|
return False
|
|
|
|
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.prop(self, "show_texture_solid_mode")
|
|
layout.prop(self, "x_length")
|
|
layout.prop(self, "y_length")
|
|
|
|
def _execute(self, context):
|
|
project_props = tool.Project.get_project_props()
|
|
project_props.load_indexed_maps = self.show_texture_solid_mode
|
|
space = tool.Blender.get_view3d_space()
|
|
if space.shading.color_type != "TEXTURE":
|
|
space.shading.color_type = "TEXTURE"
|
|
self.report(
|
|
{"WARNING"},
|
|
'"Object Color" for Viewport Shading: Solid changed to "Texture" to see the reference image properly.',
|
|
)
|
|
|
|
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()
|
|
|
|
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
|
|
|
|
mesh = bpy.data.meshes.new(image_filepath.stem)
|
|
obj = bpy.data.objects.new(image_filepath.stem, mesh)
|
|
element = tool.Drawing.run_root_assign_class(
|
|
obj=obj, ifc_class="IfcAnnotation", predefined_type="IMAGE", should_add_representation=False
|
|
)
|
|
|
|
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
|
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
|
hx = self.x_length * 0.5 / unit_scale
|
|
hy = self.y_length * 0.5 / unit_scale
|
|
verts = [(-hx, -hy, 0.0), (hx, -hy, 0.0), (hx, hy, 0.0), (-hx, hy, 0.0)]
|
|
item = builder.mesh(verts, [[0, 1, 2, 3]])
|
|
|
|
ifc_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
|
representation = builder.get_representation(ifc_context, [item])
|
|
ifcopenshell.api.geometry.assign_representation(ifc_file, element, representation)
|
|
|
|
style = ifcopenshell.api.style.add_style(tool.Ifc.get(), name=image_filepath.stem)
|
|
ifcopenshell.api.style.assign_representation_styles(
|
|
ifc_file, shape_representation=representation, styles=[style]
|
|
)
|
|
|
|
# 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
|
|
)
|
|
|
|
if tool.Ifc.get_schema() == "IFC2X3":
|
|
texture = ifc_file.create_entity(
|
|
"IfcImageTexture",
|
|
RepeatS=True,
|
|
RepeatT=True,
|
|
TextureType="TEXTURE",
|
|
UrlReference=image_filepath.as_posix(),
|
|
)
|
|
else:
|
|
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
|
|
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=[texture], Mode="COORD")
|
|
|
|
textures = [texture]
|
|
ifcopenshell.api.style.add_surface_style(
|
|
ifc_file, style=style, ifc_class="IfcSurfaceStyleWithTextures", attributes={"Textures": textures}
|
|
)
|
|
|
|
logger = logging.getLogger("ImportIFC")
|
|
ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger)
|
|
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
|
|
ifc_importer.file = tool.Ifc.get()
|
|
ifc_importer.create_style(style)
|
|
|
|
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
|
|
|
|
|
|
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)
|
|
|
|
|
|
class ActivateDrawingByAnnotation(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.activate_drawing_by_annotation"
|
|
bl_label = "Activate Drawing"
|
|
bl_description = "Activate the drawing corresponding to the selected annotation"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
# Check if an annotation object is selected
|
|
if not context.selected_objects:
|
|
cls.poll_message_set("No object selected")
|
|
return False
|
|
|
|
active_obj = context.active_object
|
|
if not active_obj:
|
|
cls.poll_message_set("No active object")
|
|
return False
|
|
|
|
element = tool.Ifc.get_entity(active_obj)
|
|
if not element:
|
|
cls.poll_message_set("Selected object is not an IFC element")
|
|
return False
|
|
|
|
# Check if it's an IfcAnnotation with ObjectType = "SECTION" or "ELEVATION"
|
|
if not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
|
|
cls.poll_message_set("Selected object is not a drawing annotation")
|
|
return False
|
|
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
active_obj = context.active_object
|
|
element = tool.Ifc.get_entity(active_obj)
|
|
|
|
if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
|
|
self.report({"ERROR"}, "Selected object is not a drawing annotation")
|
|
return {"CANCELLED"}
|
|
|
|
# Find the drawing/camera element that this annotation references
|
|
drawing_element = self.find_drawing_from_annotation(element)
|
|
|
|
if not drawing_element:
|
|
self.report({"ERROR"}, "Could not find drawing element for this annotation")
|
|
return {"CANCELLED"}
|
|
|
|
# Use the existing ActivateDrawing operator with the drawing element's ID
|
|
bpy.ops.bim.activate_drawing(drawing=drawing_element.id())
|
|
|
|
return {"FINISHED"}
|
|
|
|
def find_drawing_from_annotation(self, annotation_element):
|
|
"""Find the drawing/camera element that this annotation references."""
|
|
ifc = tool.Ifc.get()
|
|
|
|
# Check IfcRelAssignsToProduct relationships
|
|
for rel in ifc.get_inverse(annotation_element):
|
|
if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct:
|
|
if rel.RelatingProduct.is_a("IfcAnnotation"):
|
|
# Found the drawing element!
|
|
return rel.RelatingProduct
|
|
|
|
return None
|
|
|
|
|
|
class SelectSimilarTextLiteralValue(bpy.types.Operator):
|
|
bl_idname = "bim.select_similar_text_literal_value"
|
|
bl_label = ""
|
|
bl_description = "Click to select all text annotations with this value\n\nSHIFT+CLICK to remove from selection"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_value: bpy.props.StringProperty()
|
|
literal_index: bpy.props.IntProperty(default=0)
|
|
attribute_type: bpy.props.StringProperty(default="text")
|
|
display_text: bpy.props.StringProperty()
|
|
remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
|
|
|
def invoke(self, context, event):
|
|
if hasattr(event, "type") and event.type == "LEFTMOUSE":
|
|
self.remove_from_selection = event.shift
|
|
elif hasattr(event, "shift"):
|
|
self.remove_from_selection = event.shift
|
|
|
|
return self.execute(context)
|
|
|
|
def execute(self, context):
|
|
if not self.literal_value and self.attribute_type in ["text", "path", "box_alignment", "font_size"]:
|
|
return {"CANCELLED"}
|
|
|
|
editing_status = {}
|
|
for obj in context.visible_objects:
|
|
obj_props = tool.Drawing.get_text_props(obj)
|
|
editing_status[obj] = obj_props.is_editing if hasattr(obj_props, "is_editing") else False
|
|
|
|
count = 0
|
|
for obj in context.visible_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
|
|
continue
|
|
|
|
obj_props = tool.Drawing.get_text_props(obj)
|
|
should_select = False
|
|
|
|
if self.attribute_type in ["text", "path", "box_alignment", "font_size", "literal", "resolved_text"]:
|
|
was_editing = obj_props.is_editing if hasattr(obj_props, "is_editing") else False
|
|
if not was_editing:
|
|
core.enable_editing_text(tool.Drawing, obj=obj)
|
|
|
|
if self.attribute_type == "font_size":
|
|
should_select = str(obj_props.font_size) == self.literal_value
|
|
elif self.attribute_type == "literal":
|
|
for idx, literal in enumerate(obj_props.literals):
|
|
if len(literal.attributes) > 0:
|
|
if literal.attributes[0].string_value == self.literal_value:
|
|
should_select = True
|
|
break
|
|
elif self.attribute_type == "resolved_text":
|
|
for idx, literal in enumerate(obj_props.literals):
|
|
if len(literal.attributes) > 0:
|
|
raw_value = literal.attributes[0].string_value
|
|
assigned_element = tool.Drawing.get_assigned_product(element) or element
|
|
resolved_value = tool.Drawing.replace_text_literal_variables(raw_value, assigned_element)
|
|
if resolved_value == self.literal_value:
|
|
should_select = True
|
|
break
|
|
else:
|
|
for idx, literal in enumerate(obj_props.literals):
|
|
if self.attribute_type == "text" and len(literal.attributes) > 0:
|
|
raw_value = literal.attributes[0].string_value
|
|
assigned_element = tool.Drawing.get_assigned_product(element) or element
|
|
resolved_value = tool.Drawing.replace_text_literal_variables(raw_value, assigned_element)
|
|
if resolved_value == self.literal_value:
|
|
should_select = True
|
|
break
|
|
elif self.attribute_type == "path" and len(literal.attributes) > 1:
|
|
attr = literal.attributes[1]
|
|
if attr.data_type == "enum":
|
|
if attr.enum_value == self.literal_value:
|
|
should_select = True
|
|
break
|
|
else:
|
|
if attr.string_value == self.literal_value:
|
|
should_select = True
|
|
break
|
|
elif self.attribute_type == "box_alignment":
|
|
if literal.get_box_alignment() == self.literal_value:
|
|
should_select = True
|
|
break
|
|
|
|
if should_select:
|
|
obj.select_set(not self.remove_from_selection)
|
|
count += 1
|
|
|
|
for obj, was_editing in editing_status.items():
|
|
obj_props = tool.Drawing.get_text_props(obj)
|
|
if hasattr(obj_props, "is_editing"):
|
|
if was_editing and not obj_props.is_editing:
|
|
core.enable_editing_text(tool.Drawing, obj=obj)
|
|
elif not was_editing and obj_props.is_editing:
|
|
core.disable_editing_text(tool.Drawing, obj=obj)
|
|
|
|
if self.attribute_type in ["text", "path", "box_alignment"]:
|
|
result = f'literal[{self.literal_index}].{self.attribute_type} = "{self.literal_value}"'
|
|
else:
|
|
result = f'{self.attribute_type} = "{self.literal_value}"'
|
|
|
|
verb = "Deselected" if self.remove_from_selection else "Selected"
|
|
self.report(
|
|
{"INFO"},
|
|
f"{verb} {count} objects with {self.attribute_type} '{self.literal_value}'.",
|
|
)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FilterSelectedObjectsIfIntersectedByCamera(bpy.types.Operator):
|
|
bl_idname = "bim.filter_selected_objects_if_intersected_by_camera"
|
|
bl_label = "Filter Selected Objects If Intersected by Camera"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
bl_description = "Deselect objects that are not intersected by the active camera view"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return context.scene.camera is not None and len(context.selected_objects) > 0
|
|
|
|
def execute(self, context):
|
|
self.filter_selected_objects_if_intersected_by_camera(context)
|
|
return {"FINISHED"}
|
|
|
|
def filter_selected_objects_if_intersected_by_camera(self, context: bpy.types.Context) -> None:
|
|
camera_obj = context.scene.camera
|
|
if not camera_obj:
|
|
return
|
|
|
|
camera = camera_obj.data
|
|
if not isinstance(camera, bpy.types.Camera):
|
|
return
|
|
|
|
cam_matrix = camera_obj.matrix_world
|
|
cam_origin = cam_matrix.translation
|
|
cam_direction = cam_matrix.to_quaternion() @ Vector((0.0, 0.0, -1.0))
|
|
plane_normal = cam_direction.normalized()
|
|
plane_point = cam_origin
|
|
|
|
def point_plane_distance(point):
|
|
return (point - plane_point).dot(plane_normal)
|
|
|
|
selected_objects = [obj for obj in context.selected_objects if obj != camera_obj]
|
|
|
|
deselected = 0
|
|
for obj in selected_objects:
|
|
bbox_world = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
|
distances = [point_plane_distance(p) for p in bbox_world]
|
|
min_d = min(distances)
|
|
max_d = max(distances)
|
|
|
|
intersects = (min_d <= 0.0 <= max_d) or (max_d <= 0.0 <= min_d)
|
|
|
|
if not intersects:
|
|
obj.select_set(False)
|
|
deselected += 1
|
|
|
|
remaining_selected = len([obj for obj in context.selected_objects if obj != camera_obj])
|
|
self.report(
|
|
{"INFO"}, f"Filtered to {remaining_selected} object(s) intersecting camera plane (deselected {deselected})"
|
|
)
|
|
return {"FINISHED"}
|
|
|
|
|
|
class SelectElementValues(bpy.types.Operator):
|
|
bl_idname = "bim.select_element_values"
|
|
bl_label = "Select Element Values"
|
|
bl_description = "Select a property or quantity from the assigned product to insert into the text literal"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
expanded_category: bpy.props.StringProperty(default="Basic")
|
|
|
|
def invoke(self, context, event):
|
|
return context.window_manager.invoke_props_dialog(self, width=600)
|
|
|
|
def draw(self, context):
|
|
obj = context.active_object
|
|
element = tool.Ifc.get_entity(obj)
|
|
assigned_product = tool.Drawing.get_assigned_product(element)
|
|
if not assigned_product:
|
|
self.layout.label(text="No product assigned to this annotation", icon="ERROR")
|
|
return
|
|
|
|
if not ElementValuesData.is_loaded:
|
|
ElementValuesData.load()
|
|
available_keys = ElementValuesData.get_available_element_value_keys(assigned_product)
|
|
|
|
for category_name, keys in available_keys.items():
|
|
if not keys:
|
|
continue
|
|
|
|
box = self.layout.box()
|
|
box.label(text=category_name, icon=self.get_category_icon(category_name))
|
|
|
|
for key, description in keys:
|
|
row = box.row()
|
|
row.label(text=description)
|
|
|
|
def execute(self, context):
|
|
return {"FINISHED"}
|
|
|
|
def get_category_icon(self, category_name):
|
|
icons = {
|
|
"Basic": "OBJECT_DATA",
|
|
"Attributes": "PROPERTIES",
|
|
"Property Sets": "PROPERTIES",
|
|
"Quantity Sets": "SNAP_VOLUME",
|
|
"Type": "OUTLINER_OB_MESH",
|
|
"Spatial": "HOME",
|
|
"Parent": "FILE_PARENT",
|
|
"Classification": "BOOKMARKS",
|
|
"Groups": "GROUP",
|
|
"Systems": "SYSTEM",
|
|
"Zones": "MESH_CIRCLE",
|
|
"Material": "MATERIAL",
|
|
"Coordinates": "EMPTY_ARROWS",
|
|
}
|
|
return icons.get(category_name, "DOT")
|
|
|
|
|
|
class ToggleElementValuesPanel(bpy.types.Operator):
|
|
bl_idname = "bim.toggle_element_values_panel"
|
|
bl_label = "Toggle Element Values Panel"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
|
|
current_state = getattr(literal_props, "show_element_values", False)
|
|
literal_props.show_element_values = not current_state
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ToggleElementValuesCategory(bpy.types.Operator):
|
|
bl_idname = "bim.toggle_element_values_category"
|
|
bl_label = "Toggle Element Values Category"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
category_name: bpy.props.StringProperty()
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
is_currently_expanded: bpy.props.BoolProperty()
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
|
|
if self.is_currently_expanded:
|
|
literal_props.expanded_category = ""
|
|
else:
|
|
literal_props.expanded_category = self.category_name
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class InsertFormattedLiteralPopup(bpy.types.Operator):
|
|
bl_idname = "bim.insert_formatted_literal_popup"
|
|
bl_label = "Insert Formatted Element Value"
|
|
bl_description = "Insert element value with formatting options"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
element_value_key: bpy.props.StringProperty()
|
|
value_number: bpy.props.IntProperty()
|
|
|
|
formatting_type: bpy.props.EnumProperty(
|
|
name="Formatting",
|
|
items=[
|
|
("NONE", "No Formatting", "Insert value as-is"),
|
|
("UPPER", "Uppercase", "Convert to uppercase"),
|
|
("LOWER", "Lowercase", "Convert to lowercase"),
|
|
("TITLE", "Title Case", "Convert to title case"),
|
|
("ROUND", "Round Number", "Round to specified precision"),
|
|
("INT", "Integer", "Truncate decimal part"),
|
|
("NUMBER", "Format Number", "Format with separators"),
|
|
("METRIC_LENGTH", "Metric Length", "Format as metric length"),
|
|
("IMPERIAL_LENGTH", "Imperial Length", "Format as imperial length"),
|
|
("CUSTOM", "Custom Expression", "Create custom expression with functions"),
|
|
],
|
|
default="NONE",
|
|
)
|
|
|
|
round_precision: bpy.props.StringProperty(
|
|
name="Precision", description="Rounding precision (e.g. 0.1, 0.01, 0.001, 1, 10, 100)", default="0.01"
|
|
)
|
|
|
|
decimal_separator: bpy.props.StringProperty(
|
|
name="Decimal Separator", description="Decimal separator character", default=".", maxlen=1
|
|
)
|
|
|
|
thousands_separator: bpy.props.StringProperty(
|
|
name="Thousands Separator", description="Thousands separator character", default=",", maxlen=1
|
|
)
|
|
|
|
metric_decimals: bpy.props.IntProperty(
|
|
name="Decimal Places", description="Number of decimal places to show", default=2, min=0, max=10
|
|
)
|
|
|
|
metric_precision: bpy.props.StringProperty(
|
|
name="Metric Precision",
|
|
description="Rounding precision for metric length (e.g. 0.1, 0.01, 0.001)",
|
|
default="0.01",
|
|
)
|
|
|
|
imperial_precision: bpy.props.IntProperty(
|
|
name="Fraction Precision", description="Imperial fraction precision (1/N)", default=4, min=1, max=64
|
|
)
|
|
|
|
imperial_input_unit: bpy.props.EnumProperty(
|
|
name="Input Unit",
|
|
items=[
|
|
("foot", "Feet", "Input value is in feet"),
|
|
("inch", "Inches", "Input value is in inches"),
|
|
],
|
|
default="foot",
|
|
)
|
|
|
|
imperial_output_unit: bpy.props.EnumProperty(
|
|
name="Output Format",
|
|
items=[
|
|
("foot", "Feet and Inches", "Display as feet and inches"),
|
|
("inch", "Inches Only", "Display as inches only"),
|
|
],
|
|
default="foot",
|
|
)
|
|
|
|
custom_expression: bpy.props.StringProperty(
|
|
name="Custom Expression",
|
|
description=(
|
|
"Custom expression using functions like concat(), upper(), round(), etc.\n"
|
|
"Use {{value}} as placeholder for the selected element value.\n"
|
|
"Examples:\n"
|
|
'- concat("Name: ", {{value}})\n'
|
|
'- upper(concat("Type: ", {{value}}))'
|
|
),
|
|
default='concat({{value}}, " - additional text")',
|
|
)
|
|
|
|
def invoke(self, context, event):
|
|
return context.window_manager.invoke_props_dialog(self, width=450)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
box = layout.box()
|
|
box.label(text=f"Element Value: {self.element_value_key}", icon="PROPERTIES")
|
|
|
|
layout.prop(self, "formatting_type")
|
|
|
|
if self.formatting_type == "ROUND":
|
|
layout.prop(self, "round_precision")
|
|
elif self.formatting_type == "NUMBER":
|
|
col = layout.column()
|
|
col.prop(self, "decimal_separator")
|
|
col.prop(self, "thousands_separator")
|
|
elif self.formatting_type == "METRIC_LENGTH":
|
|
col = layout.column()
|
|
col.prop(self, "metric_precision")
|
|
col.prop(self, "metric_decimals")
|
|
elif self.formatting_type == "IMPERIAL_LENGTH":
|
|
col = layout.column()
|
|
col.prop(self, "imperial_precision")
|
|
col.prop(self, "imperial_input_unit")
|
|
col.prop(self, "imperial_output_unit")
|
|
elif self.formatting_type == "CUSTOM":
|
|
col = layout.column()
|
|
col.prop(self, "custom_expression", text="")
|
|
|
|
obj = bpy.context.active_object
|
|
if obj:
|
|
element = tool.Ifc.get_entity(obj)
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
product = None
|
|
|
|
if props.literals:
|
|
for literal_props in props.literals:
|
|
if hasattr(literal_props, "product_used") and literal_props.product_used:
|
|
product = tool.Ifc.get_entity(literal_props.product_used)
|
|
break
|
|
|
|
if not product:
|
|
product = tool.Drawing.get_assigned_product(element)
|
|
|
|
if not product:
|
|
product = element
|
|
|
|
if product:
|
|
all_categories = ElementValuesData.get_available_element_value_keys(product)
|
|
|
|
available_keys = []
|
|
for cat_name, cat_keys in all_categories.items():
|
|
for cat_key, cat_desc in cat_keys:
|
|
available_keys.append(
|
|
(
|
|
cat_key,
|
|
f"[{cat_name}] {cat_desc.split(': ', 1)[-1] if ': ' in cat_desc else cat_desc}",
|
|
)
|
|
)
|
|
|
|
if available_keys:
|
|
help_box = col.box()
|
|
help_box.scale_y = 0.7
|
|
help_box.label(text="Available attributes:", icon="INFO")
|
|
|
|
for i, (key, desc) in enumerate(available_keys[:5]):
|
|
help_box.label(text="{{value{}}} = {} ({})".format(i + 1, key, desc))
|
|
|
|
if len(available_keys) > 5:
|
|
help_box.label(text=f"... and {len(available_keys) - 5} more attributes")
|
|
|
|
help_box = col.box()
|
|
help_box.scale_y = 0.8
|
|
help_box.label(text="Available functions:", icon="INFO")
|
|
help_box.label(text="• concat(text1, text2, ...) - combine values")
|
|
help_box.label(text="• upper(value) - uppercase")
|
|
help_box.label(text="• lower(value) - lowercase")
|
|
help_box.label(text="• round(value, precision) - round number")
|
|
help_box.label(text="• Use {{value}} for current element value")
|
|
|
|
preview_box = layout.box()
|
|
preview_box.label(text="Preview:", icon="PROPERTIES")
|
|
formatted_syntax = self._generate_formatted_syntax()
|
|
|
|
preview_text = formatted_syntax
|
|
if len(preview_text) > 60:
|
|
words = preview_text.split()
|
|
lines = []
|
|
current_line = ""
|
|
for word in words:
|
|
if len(current_line + " " + word) > 60 and current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
else:
|
|
current_line = (current_line + " " + word).strip()
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
for line in lines:
|
|
preview_box.label(text=line)
|
|
else:
|
|
preview_box.label(text=preview_text)
|
|
|
|
def _get_all_available_keys(self, element):
|
|
"""Get all available element value keys in a flat list with their descriptions"""
|
|
available_keys = []
|
|
|
|
all_categories = ElementValuesData.get_available_element_value_keys(element)
|
|
|
|
for category_name, keys in all_categories.items():
|
|
for key, description in keys:
|
|
available_keys.append(
|
|
(key, f"[{category_name}] {description.split(': ', 1)[-1] if ': ' in description else description}")
|
|
)
|
|
|
|
return available_keys
|
|
|
|
def _generate_formatted_syntax(self) -> str:
|
|
"""Generate the formatted selector syntax based on current settings"""
|
|
base_value = f"{{{{{self.element_value_key}}}}}"
|
|
|
|
if self.formatting_type == "NONE":
|
|
return base_value
|
|
elif self.formatting_type == "UPPER":
|
|
return f"``upper({base_value})`` "
|
|
elif self.formatting_type == "LOWER":
|
|
return f"``lower({base_value})`` "
|
|
elif self.formatting_type == "TITLE":
|
|
return f"``title({base_value})`` "
|
|
elif self.formatting_type == "ROUND":
|
|
return f"``round({base_value}, {self.round_precision})`` "
|
|
elif self.formatting_type == "INT":
|
|
return f"``int({base_value})`` "
|
|
elif self.formatting_type == "NUMBER":
|
|
return f"``number({base_value}, {self.decimal_separator}, {self.thousands_separator})`` "
|
|
elif self.formatting_type == "METRIC_LENGTH":
|
|
return f"``metric_length({base_value}, {self.metric_precision}, {self.metric_decimals})`` "
|
|
elif self.formatting_type == "IMPERIAL_LENGTH":
|
|
return f'``imperial_length({base_value}, {self.imperial_precision}, "{self.imperial_input_unit}", "{self.imperial_output_unit}")`` '
|
|
elif self.formatting_type == "CUSTOM":
|
|
custom_expr = self.custom_expression.replace("{{value}}", base_value)
|
|
return f"``{custom_expr}``"
|
|
|
|
return base_value
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
assert obj
|
|
props = tool.Drawing.get_text_props(obj)
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
|
|
formatted_syntax = self._generate_formatted_syntax()
|
|
|
|
for attr in literal_props.attributes:
|
|
if attr.name == "Literal":
|
|
current_text = attr.string_value or ""
|
|
|
|
if current_text:
|
|
attr.string_value = f"{current_text} {formatted_syntax}"
|
|
else:
|
|
attr.string_value = formatted_syntax
|
|
break
|
|
|
|
tool.Blender.update_viewport()
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ShowCategoryHelp(bpy.types.Operator):
|
|
bl_idname = "bim.show_category_help"
|
|
bl_label = "Category Help"
|
|
bl_description = "Show help for this element value category"
|
|
bl_options = {"REGISTER"}
|
|
|
|
category_name: bpy.props.StringProperty()
|
|
|
|
def invoke(self, context, event):
|
|
return context.window_manager.invoke_props_dialog(self, width=600)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
category_help = {
|
|
"Basic": {
|
|
"title": "BASIC KEYS",
|
|
"icon": "DOT",
|
|
"items": [
|
|
("id", "IFC entity ID", "{{id}} → '12345'"),
|
|
("class", "IFC class name", "{{class}} → 'IfcWall'"),
|
|
("predefined_type", "Predefined type", "{{predefined_type}} → 'SOLIDWALL'"),
|
|
],
|
|
},
|
|
"Attributes": {
|
|
"title": "ATTRIBUTES",
|
|
"icon": "PROPERTIES",
|
|
"items": [
|
|
("Name", "Element name", "{{Name}} → 'Wall-001'"),
|
|
("Description", "Element description", "{{Description}} → 'Exterior wall'"),
|
|
("Tag", "Element tag", "{{Tag}} → 'W-01'"),
|
|
("ObjectType", "Object type", "{{ObjectType}} → 'Load Bearing'"),
|
|
],
|
|
"note": "All IFC attributes are accessible by their name",
|
|
},
|
|
"Property Sets": {
|
|
"title": "PROPERTY SETS",
|
|
"icon": "ALIGN_JUSTIFY",
|
|
"items": [
|
|
("Pset_*.PropertyName", "Property value", "{{Pset_WallCommon.FireRating}} → 'REI 120'"),
|
|
("Pset_*.IsExternal", "Boolean property", "{{Pset_WallCommon.IsExternal}} → 'True'"),
|
|
],
|
|
"note": "Access any property from any property set using Pset_Name.PropertyName syntax",
|
|
},
|
|
"Quantity Sets": {
|
|
"title": "QUANTITY SETS",
|
|
"icon": "ALIGN_JUSTIFY",
|
|
"items": [
|
|
("Qto_*.QuantityName", "Quantity value", "{{Qto_WallBaseQuantities.NetArea}} → '45.5'"),
|
|
("Qto_*.NetVolume", "Volume quantity", "{{Qto_WallBaseQuantities.NetVolume}} → '9.1'"),
|
|
],
|
|
"note": "Access any quantity from quantity sets using Qto_Name.QuantityName syntax",
|
|
},
|
|
"Type": {
|
|
"title": "TYPE INFORMATION",
|
|
"icon": "OUTLINER_OB_MESH",
|
|
"items": [
|
|
("type.Name", "Type element name", "{{type.Name}} → 'WT-200mm-Concrete'"),
|
|
("types.count", "Number of instances of this type", "{{types.count}} → '15'"),
|
|
("occurrences.count", "Same as types.count", "{{occurrences.count}} → '15'"),
|
|
],
|
|
"example": "Type: {{type.Name}} ({{types.count}} instances)",
|
|
},
|
|
"Spatial": {
|
|
"title": "SPATIAL HIERARCHY",
|
|
"icon": "HOME",
|
|
"items": [
|
|
("container.Name", "Immediate spatial container", "{{container.Name}} → 'Level 2'"),
|
|
("space.Name", "Containing space", "{{space.Name}} → 'Office 205'"),
|
|
("storey.Name", "Building storey", "{{storey.Name}} → 'Level 2'"),
|
|
("building.Name", "Building", "{{building.Name}} → 'Building A'"),
|
|
("site.Name", "Site", "{{site.Name}} → 'Main Campus'"),
|
|
],
|
|
"example": "{{building.Name}} / {{storey.Name}} / {{space.Name}}",
|
|
},
|
|
"Parent": {
|
|
"title": "PARENT (AGGREGATION)",
|
|
"icon": "OUTLINER_DATA_GP_LAYER",
|
|
"items": [
|
|
("parent.name", "Aggregate parent element", "{{parent.name}} → 'Curtain Wall-01'"),
|
|
],
|
|
"note": "Used for aggregated elements like mullions in curtain walls",
|
|
},
|
|
"Material": {
|
|
"title": "MATERIALS",
|
|
"icon": "MATERIAL",
|
|
"items": [
|
|
("material.Name", "Material name", "{{material.Name}} → 'Concrete'"),
|
|
("materials.count", "Number of layers/profiles", "{{materials.count}} → '3'"),
|
|
(
|
|
"material.item.Material.Name.0",
|
|
"First layer material",
|
|
"{{material.item.Material.Name.0}} → 'Brick'",
|
|
),
|
|
(
|
|
"material.item.Material.Name.1",
|
|
"Second layer material",
|
|
"{{material.item.Material.Name.1}} → 'Insulation'",
|
|
),
|
|
("material.item.0.LayerThickness", "Layer thickness", "{{material.item.0.LayerThickness}} → '0.1'"),
|
|
],
|
|
"example": "{{materials.count}} layers: {{material.item.Material.Name.0}}",
|
|
},
|
|
"Styles": {
|
|
"title": "PRESENTATION STYLES",
|
|
"icon": "COLOR",
|
|
"items": [
|
|
("styles.count", "Number of styles", "{{styles.count}} → '1'"),
|
|
("styles.0.Name", "Style name (indexed)", "{{styles.0.Name}} → 'Red'"),
|
|
("styles.0.Color", "RGB color value", "{{styles.0.Color}} → 'RGB(1.00, 0.00, 0.00)'"),
|
|
],
|
|
},
|
|
"Profiles": {
|
|
"title": "PROFILES",
|
|
"icon": "OUTLINER_DATA_CURVES",
|
|
"items": [
|
|
("profiles.count", "Number of profiles", "{{profiles.count}} → '2'"),
|
|
("profiles.0.ProfileName", "Profile name (indexed)", "{{profiles.0.ProfileName}} → 'HEA200'"),
|
|
("profiles.0.ProfileType", "Profile type (indexed)", "{{profiles.0.ProfileType}} → 'AREA'"),
|
|
("profile.ProfileName", "Single swept profile", "{{profile.ProfileName}} → 'Rectangle'"),
|
|
],
|
|
},
|
|
"Groups": {
|
|
"title": "GROUPS",
|
|
"icon": "OUTLINER_OB_GROUP_INSTANCE",
|
|
"items": [
|
|
("group.Name", "Group name", "{{group.Name}} → 'Phase 1'"),
|
|
("groups.count", "Number of group assignments", "{{groups.count}} → '2'"),
|
|
],
|
|
},
|
|
"Systems": {
|
|
"title": "SYSTEMS",
|
|
"icon": "OUTLINER_OB_GROUP_INSTANCE",
|
|
"items": [
|
|
("system.Name", "System name", "{{system.Name}} → 'HVAC-01'"),
|
|
("systems.count", "Number of system assignments", "{{systems.count}} → '1'"),
|
|
],
|
|
},
|
|
"Zones": {
|
|
"title": "ZONES",
|
|
"icon": "OUTLINER_OB_GROUP_INSTANCE",
|
|
"items": [
|
|
("zone.Name", "Zone name", "{{zone.Name}} → 'Fire Zone A'"),
|
|
("zones.count", "Number of zone assignments", "{{zones.count}} → '1'"),
|
|
],
|
|
},
|
|
"Classification": {
|
|
"title": "CLASSIFICATION",
|
|
"icon": "PRESET",
|
|
"items": [
|
|
("classification.0.Name", "Classification name (indexed)", "{{classification.0.Name}} → 'Walls'"),
|
|
(
|
|
"classification.0.Identification",
|
|
"Classification code (indexed)",
|
|
"{{classification.0.Identification}} → 'E20'",
|
|
),
|
|
("classification.count", "Number of classification references", "{{classification.count}} → '2'"),
|
|
],
|
|
},
|
|
"Coordinates": {
|
|
"title": "COORDINATES",
|
|
"icon": "ORIENTATION_VIEW",
|
|
"items": [
|
|
("x", "Local X coordinate", "{{x}} → '10.5'"),
|
|
("y", "Local Y coordinate", "{{y}} → '5.2'"),
|
|
("z", "Local Z coordinate", "{{z}} → '3.0'"),
|
|
("easting", "Map easting coordinate", "{{easting}} → '500123.45'"),
|
|
("northing", "Map northing coordinate", "{{northing}} → '6750234.56'"),
|
|
("elevation", "Map elevation", "{{elevation}} → '123.45'"),
|
|
],
|
|
},
|
|
}
|
|
|
|
if self.category_name in category_help:
|
|
info = category_help[self.category_name]
|
|
layout.label(text=info["title"], icon=info["icon"])
|
|
|
|
box = layout.box()
|
|
for key, desc, example in info["items"]:
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.85
|
|
col.label(text=f"{key} - {desc}")
|
|
col.label(text=f" {example}")
|
|
box.separator(factor=0.3)
|
|
|
|
if "note" in info:
|
|
note_box = layout.box()
|
|
note_box.label(text="Note:", icon="INFO")
|
|
note_box.label(text=info["note"])
|
|
|
|
if "example" in info:
|
|
ex_box = layout.box()
|
|
ex_box.label(text="Example:", icon="SCRIPTPLUGINS")
|
|
ex_box.label(text=info["example"])
|
|
else:
|
|
layout.label(text=f"No help available for '{self.category_name}'")
|
|
|
|
def execute(self, context):
|
|
return {"FINISHED"}
|
|
|
|
|
|
class AddElementValueRow(bpy.types.Operator):
|
|
bl_idname = "bim.add_element_value_row"
|
|
bl_label = "Add Element Value Row"
|
|
bl_description = "Add a new element value row"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
new_row = literal_props.element_value_rows.add()
|
|
new_row.category = literal_props.category_for_adding
|
|
new_row.element_key = ""
|
|
new_row.formatted_value = ""
|
|
|
|
if len(literal_props.element_value_rows) == 1:
|
|
new_row.separator = ""
|
|
else:
|
|
new_row.separator = " - "
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class RemoveElementValueRow(bpy.types.Operator):
|
|
bl_idname = "bim.remove_element_value_row"
|
|
bl_label = "Remove Element Value Row"
|
|
bl_description = "Remove this element value row"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
row_index: bpy.props.IntProperty()
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
if self.row_index < len(literal_props.element_value_rows):
|
|
literal_props.element_value_rows.remove(self.row_index)
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ElementValueSuggestionsPopup(bpy.types.Operator):
|
|
bl_idname = "bim.element_value_suggestions_popup"
|
|
bl_label = "Element Value Suggestions"
|
|
bl_description = "Show suggestions for element values in the selected category"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
row_index: bpy.props.IntProperty()
|
|
category: bpy.props.StringProperty()
|
|
search_query: bpy.props.StringProperty(name="Search", description="Search for element values")
|
|
|
|
collection_keys: bpy.props.CollectionProperty(type=StrProperty)
|
|
collection_descriptions: bpy.props.CollectionProperty(type=StrProperty)
|
|
|
|
selected_key: bpy.props.StringProperty()
|
|
|
|
def invoke(self, context, event):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
current_product = get_current_product_for_element_values(obj, literal_props)
|
|
|
|
if not current_product:
|
|
self.report({"ERROR"}, "No product selected. Use eyedropper to select an object.")
|
|
return {"CANCELLED"}
|
|
|
|
element = tool.Ifc.get_entity(current_product)
|
|
if not element:
|
|
self.report({"ERROR"}, "Selected object has no IFC data")
|
|
return {"CANCELLED"}
|
|
|
|
if not ElementValuesData.is_loaded:
|
|
ElementValuesData.load()
|
|
|
|
available_keys = ElementValuesData.get_available_element_value_keys(element)
|
|
|
|
if self.category not in available_keys:
|
|
self.report({"ERROR"}, f"Category '{self.category}' not found")
|
|
return {"CANCELLED"}
|
|
|
|
keys = available_keys[self.category]
|
|
if not keys:
|
|
self.report({"INFO"}, f"No values available for category '{self.category}'")
|
|
return {"CANCELLED"}
|
|
|
|
self.collection_keys.clear()
|
|
self.collection_descriptions.clear()
|
|
for key, description in keys:
|
|
self.collection_keys.add().name = key
|
|
self.collection_descriptions.add().name = description
|
|
|
|
return context.window_manager.invoke_props_dialog(self, width=500)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
|
|
layout.prop_search(self, "selected_key", self, "collection_descriptions", text="Value")
|
|
|
|
def execute(self, context):
|
|
if not self.selected_key:
|
|
return {"CANCELLED"}
|
|
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
if self.row_index >= len(literal_props.element_value_rows):
|
|
return {"CANCELLED"}
|
|
|
|
value_row = literal_props.element_value_rows[self.row_index]
|
|
|
|
for idx, desc_item in enumerate(self.collection_descriptions):
|
|
if desc_item.name == self.selected_key:
|
|
actual_key = self.collection_keys[idx].name
|
|
value_row.element_key = actual_key
|
|
value_row.formatted_value = f"{{{{{actual_key}}}}}"
|
|
break
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class FormatElementValueRow(bpy.types.Operator):
|
|
bl_idname = "bim.format_element_value_row"
|
|
bl_label = "Format Element Value"
|
|
bl_description = "Format element value with functions"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
row_index: bpy.props.IntProperty()
|
|
|
|
formatting_type: bpy.props.EnumProperty(
|
|
name="Formatting",
|
|
items=[
|
|
("NONE", "No Formatting", "Insert value as-is"),
|
|
("UPPER", "Uppercase", "Convert to uppercase"),
|
|
("LOWER", "Lowercase", "Convert to lowercase"),
|
|
("TITLE", "Title Case", "Convert to title case"),
|
|
("ROUND", "Round Number", "Round to specified precision"),
|
|
("INT", "Integer", "Truncate decimal part"),
|
|
("NUMBER", "Format Number", "Format with separators"),
|
|
("METRIC_LENGTH", "Metric Length", "Format as metric length"),
|
|
("IMPERIAL_LENGTH", "Imperial Length", "Format as imperial length"),
|
|
("CUSTOM", "Custom Expression", "Create custom expression with functions"),
|
|
],
|
|
default="NONE",
|
|
)
|
|
|
|
round_precision: bpy.props.StringProperty(
|
|
name="Precision", description="Rounding precision (e.g. 0.1, 0.01, 0.001, 1, 10, 100)", default="0.01"
|
|
)
|
|
|
|
decimal_separator: bpy.props.StringProperty(
|
|
name="Decimal Separator", description="Decimal separator character", default=".", maxlen=1
|
|
)
|
|
|
|
thousands_separator: bpy.props.StringProperty(
|
|
name="Thousands Separator", description="Thousands separator character", default=",", maxlen=1
|
|
)
|
|
|
|
metric_decimals: bpy.props.IntProperty(
|
|
name="Decimal Places", description="Number of decimal places to show", default=2, min=0, max=10
|
|
)
|
|
|
|
metric_precision: bpy.props.StringProperty(
|
|
name="Metric Precision",
|
|
description="Rounding precision for metric length (e.g. 0.1, 0.01, 0.001)",
|
|
default="0.01",
|
|
)
|
|
|
|
imperial_precision: bpy.props.IntProperty(
|
|
name="Fraction Precision", description="Imperial fraction precision (1/N)", default=4, min=1, max=64
|
|
)
|
|
|
|
imperial_input_unit: bpy.props.EnumProperty(
|
|
name="Input Unit",
|
|
items=[
|
|
("foot", "Feet", "Input value is in feet"),
|
|
("inch", "Inches", "Input value is in inches"),
|
|
],
|
|
default="foot",
|
|
)
|
|
|
|
imperial_output_unit: bpy.props.EnumProperty(
|
|
name="Output Format",
|
|
items=[
|
|
("foot", "Feet and Inches", "Display as feet and inches"),
|
|
("inch", "Inches Only", "Display as inches only"),
|
|
],
|
|
default="foot",
|
|
)
|
|
|
|
custom_expression: bpy.props.StringProperty(
|
|
name="Custom Expression",
|
|
description=("Custom expression using functions\n" "Use {{value}} as placeholder for the current row's value."),
|
|
default='concat({{value}}, " - additional text")',
|
|
)
|
|
|
|
def invoke(self, context, event):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
if self.row_index >= len(literal_props.element_value_rows):
|
|
return {"CANCELLED"}
|
|
|
|
row = literal_props.element_value_rows[self.row_index]
|
|
self._load_formatting_from_row(row)
|
|
|
|
return context.window_manager.invoke_props_dialog(self, width=450)
|
|
|
|
def _load_formatting_from_row(self, row):
|
|
"""Parse the formatted_value to load existing formatting settings"""
|
|
import re
|
|
|
|
formatted_value = row.formatted_value
|
|
|
|
if not formatted_value or formatted_value == f"{{{{{row.element_key}}}}}":
|
|
self.formatting_type = "NONE"
|
|
return
|
|
|
|
if formatted_value.startswith("``") and formatted_value.endswith("``"):
|
|
expression = formatted_value[2:-2].strip()
|
|
else:
|
|
self.formatting_type = "NONE"
|
|
return
|
|
|
|
if match := re.match(r"upper\(\{\{[^}]+\}\}\)", expression):
|
|
self.formatting_type = "UPPER"
|
|
|
|
elif match := re.match(r"lower\(\{\{[^}]+\}\}\)", expression):
|
|
self.formatting_type = "LOWER"
|
|
|
|
elif match := re.match(r"title\(\{\{[^}]+\}\}\)", expression):
|
|
self.formatting_type = "TITLE"
|
|
|
|
elif match := re.match(r"int\(\{\{[^}]+\}\}\)", expression):
|
|
self.formatting_type = "INT"
|
|
|
|
elif match := re.match(r"round\(\{\{[^}]+\}\},\s*([^)]+)\)", expression):
|
|
self.formatting_type = "ROUND"
|
|
self.round_precision = match.group(1).strip()
|
|
|
|
elif match := re.match(r"number\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
|
|
self.formatting_type = "NUMBER"
|
|
self.decimal_separator = match.group(1).strip()
|
|
self.thousands_separator = match.group(2).strip()
|
|
|
|
elif match := re.match(r"metric_length\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
|
|
self.formatting_type = "METRIC_LENGTH"
|
|
self.metric_precision = match.group(1).strip()
|
|
self.metric_decimals = int(match.group(2).strip())
|
|
|
|
elif match := re.match(r'imperial_length\(\{\{[^}]+\}\},\s*(\d+),\s*"([^"]+)",\s*"([^"]+)"\)', expression):
|
|
self.formatting_type = "IMPERIAL_LENGTH"
|
|
self.imperial_precision = int(match.group(1).strip())
|
|
self.imperial_input_unit = match.group(2).strip()
|
|
self.imperial_output_unit = match.group(3).strip()
|
|
|
|
else:
|
|
self.formatting_type = "CUSTOM"
|
|
self.custom_expression = expression
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
obj = context.active_object
|
|
props = tool.Drawing.get_text_props(obj)
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
row = literal_props.element_value_rows[self.row_index]
|
|
|
|
box = layout.box()
|
|
box.label(text=f"Element Value: {row.element_key}", icon="PROPERTIES")
|
|
|
|
layout.prop(self, "formatting_type")
|
|
|
|
if self.formatting_type == "ROUND":
|
|
layout.prop(self, "round_precision")
|
|
elif self.formatting_type == "NUMBER":
|
|
col = layout.column()
|
|
col.prop(self, "decimal_separator")
|
|
col.prop(self, "thousands_separator")
|
|
elif self.formatting_type == "METRIC_LENGTH":
|
|
col = layout.column()
|
|
col.prop(self, "metric_precision")
|
|
col.prop(self, "metric_decimals")
|
|
elif self.formatting_type == "IMPERIAL_LENGTH":
|
|
col = layout.column()
|
|
col.prop(self, "imperial_precision")
|
|
col.prop(self, "imperial_input_unit")
|
|
col.prop(self, "imperial_output_unit")
|
|
elif self.formatting_type == "CUSTOM":
|
|
col = layout.column()
|
|
col.prop(self, "custom_expression", text="")
|
|
|
|
preview_box = layout.box()
|
|
preview_box.label(text="Preview:", icon="PROPERTIES")
|
|
formatted_syntax = self._generate_formatted_syntax(row.element_key)
|
|
preview_box.label(text=formatted_syntax)
|
|
|
|
def _generate_formatted_syntax(self, element_key: str) -> str:
|
|
"""Generate the formatted selector syntax based on current settings"""
|
|
base_value = f"{{{{{element_key}}}}}"
|
|
|
|
if self.formatting_type == "NONE":
|
|
return base_value
|
|
elif self.formatting_type == "UPPER":
|
|
return f"``upper({base_value})``"
|
|
elif self.formatting_type == "LOWER":
|
|
return f"``lower({base_value})``"
|
|
elif self.formatting_type == "TITLE":
|
|
return f"``title({base_value})``"
|
|
elif self.formatting_type == "ROUND":
|
|
return f"``round({base_value}, {self.round_precision})``"
|
|
elif self.formatting_type == "INT":
|
|
return f"``int({base_value})``"
|
|
elif self.formatting_type == "NUMBER":
|
|
return f"``number({base_value}, {self.decimal_separator}, {self.thousands_separator})``"
|
|
elif self.formatting_type == "METRIC_LENGTH":
|
|
return f"``metric_length({base_value}, {self.metric_precision}, {self.metric_decimals})``"
|
|
elif self.formatting_type == "IMPERIAL_LENGTH":
|
|
return f'``imperial_length({base_value}, {self.imperial_precision}, "{self.imperial_input_unit}", "{self.imperial_output_unit}")``'
|
|
elif self.formatting_type == "CUSTOM":
|
|
custom_expr = self.custom_expression.replace("{{value}}", base_value)
|
|
return f"``{custom_expr}``"
|
|
|
|
return base_value
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
row = literal_props.element_value_rows[self.row_index]
|
|
|
|
formatted_syntax = self._generate_formatted_syntax(row.element_key)
|
|
row.formatted_value = formatted_syntax
|
|
|
|
tool.Blender.update_viewport()
|
|
return {"FINISHED"}
|
|
|
|
|
|
class ApplyElementValueRowsToLiteral(bpy.types.Operator):
|
|
bl_idname = "bim.apply_element_value_rows_to_literal"
|
|
bl_label = "Apply Element Values to Literal"
|
|
bl_description = "Concatenate all element value rows and apply to the literal"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
literal_prop_id: bpy.props.IntProperty()
|
|
|
|
def execute(self, context):
|
|
obj = context.active_object
|
|
if not obj:
|
|
return {"CANCELLED"}
|
|
|
|
props = tool.Drawing.get_text_props(obj)
|
|
if self.literal_prop_id >= len(props.literals):
|
|
return {"CANCELLED"}
|
|
|
|
literal_props = props.literals[self.literal_prop_id]
|
|
|
|
parts = []
|
|
for row in literal_props.element_value_rows:
|
|
if row.element_key:
|
|
if row.category == "Custom String":
|
|
parts.append(row.element_key)
|
|
else:
|
|
if row.formatted_value and row.formatted_value != f"{{{{{row.element_key}}}}}":
|
|
updated_formatted = self._update_formatted_value(row.element_key, row.formatted_value)
|
|
row.formatted_value = updated_formatted
|
|
value_part = updated_formatted
|
|
else:
|
|
default_format = f"{{{{{row.element_key}}}}}"
|
|
row.formatted_value = default_format
|
|
value_part = default_format
|
|
|
|
parts.append(row.separator + value_part)
|
|
|
|
concatenated_value = "".join(parts)
|
|
|
|
for attr in literal_props.attributes:
|
|
if attr.name == "Literal":
|
|
attr.string_value = concatenated_value
|
|
break
|
|
|
|
tool.Blender.update_viewport()
|
|
return {"FINISHED"}
|
|
|
|
def _update_formatted_value(self, new_element_key: str, old_formatted_value: str) -> str:
|
|
"""
|
|
Update formatted value by replacing old element key placeholders with new one.
|
|
This preserves formatting functions like upper(), round(), etc.
|
|
"""
|
|
import re
|
|
|
|
pattern = r"\{\{[^}]+\}\}"
|
|
|
|
new_base_value = f"{{{{{new_element_key}}}}}"
|
|
updated_value = re.sub(pattern, new_base_value, old_formatted_value)
|
|
|
|
return updated_value
|
|
|
|
|
|
class ShowElementValuesInstructions(bpy.types.Operator):
|
|
bl_idname = "bim.show_element_values_instructions"
|
|
bl_label = "Element Values - Quick Start Guide"
|
|
bl_description = "Show general tips and formatting instructions for element values"
|
|
bl_options = {"REGISTER"}
|
|
|
|
def invoke(self, context, event):
|
|
return context.window_manager.invoke_props_dialog(self, width=700)
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
layout.label(text="Element Values - Building Custom Literals", icon="INFO")
|
|
|
|
box = layout.box()
|
|
row = box.row()
|
|
row.label(text="Full Documentation:", icon="URL")
|
|
row.operator("wm.url_open", text="IFC Selector Syntax Guide", icon="URL").url = (
|
|
"https://docs.ifcopenshell.org/ifcopenshell-python/selector_syntax.html#getting-element-values"
|
|
)
|
|
|
|
box = layout.box()
|
|
box.label(text="WORKFLOW: BUILDING LITERALS WITH ROWS", icon="SEQUENCE")
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.85
|
|
col.label(text="1. Select a category from the dropdown (Basic, Property Sets, etc.)")
|
|
col.label(text="2. Click 'Add Element' to create a new row")
|
|
col.label(text="3. Use the magnifying glass icon to browse available values for that category")
|
|
col.label(text="4. Optionally, click the format icon (star) to apply formatting (uppercase, round, etc.)")
|
|
col.label(text="5. Repeat to add more rows, each with its own separator text")
|
|
col.label(text="6. Click 'Apply to Literal' to concatenate all rows into the final text")
|
|
|
|
box = layout.box()
|
|
box.label(text="SEPARATORS & CUSTOM TEXT", icon="THREE_DOTS")
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.85
|
|
col.label(text="• Each row has a separator field (shown before the value)")
|
|
col.label(text="• Default separator is ' - ' but you can change it to spaces, commas, newlines, etc.")
|
|
col.label(text="• Use 'Custom String' category to add plain text without any element key")
|
|
col.label(text="• Example: 'Custom String' row with 'Wall: ' → 'Custom String' with 'Type-' → 'Name' key")
|
|
col.label(text="• Result: 'Wall: Type-WALL-001' (combining custom text with element values)")
|
|
|
|
box = layout.box()
|
|
box.label(text="SELECTING SOURCE ELEMENT", icon="EYEDROPPER")
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.85
|
|
col.label(text="• By default, uses the assigned product (if any) or the text annotation object itself")
|
|
col.label(text="• Use the eyedropper next to 'Element Values:' to select a different object")
|
|
col.label(text="• Useful for referencing values from related elements (like types, spaces, storeys)")
|
|
col.label(text="• The 'Source:' label shows which object is currently being used")
|
|
|
|
box = layout.box()
|
|
box.label(text="FORMATTING FUNCTIONS (click star icon on any row)", icon="SHADERFX")
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.8
|
|
|
|
col.label(text="Text Case:")
|
|
col.label(text=" • Uppercase, Lowercase, Title Case")
|
|
col.label(text=" Example: upper({{Name}}) → 'WALL-001'")
|
|
|
|
col.separator(factor=0.5)
|
|
col.label(text="Numbers:")
|
|
col.label(text=" • Round - Round to precision (0.01, 0.1, 1, 10, etc.)")
|
|
col.label(text=" • Integer - Remove decimal part")
|
|
col.label(text=" • Number - Format with separators (1,234.56)")
|
|
|
|
col.separator(factor=0.5)
|
|
col.label(text="Lengths:")
|
|
col.label(text=" • Metric Length - Format as metric with units (45.50 m²)")
|
|
col.label(text=" • Imperial Length - Format as feet-inches (10'-6\")")
|
|
|
|
col.separator(factor=0.5)
|
|
col.label(text="Custom Expression:")
|
|
col.label(text=" • Write your own using functions: concat(), upper(), round(), etc.")
|
|
col.label(text=" • Use {{value}} as placeholder for the current row's value")
|
|
|
|
box = layout.box()
|
|
box.label(text="TIPS & ADVANCED USAGE", icon="LIGHTPROBE_VOLUME")
|
|
col = box.column(align=True)
|
|
col.scale_y = 0.8
|
|
col.label(text="• Category counts show available values: 'Property Sets (12)'")
|
|
col.label(text="• Regex patterns work in property set names: /Pset_.*Common/")
|
|
col.label(text="• Combine multiple formatted rows for complex labels")
|
|
col.label(text="• Each row remembers its own formatting when you re-edit it")
|
|
|
|
def execute(self, context):
|
|
return {"FINISHED"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parametric dimension operators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
|
|
"""Draw a parametric dimension string anchored to IFC element geometry.
|
|
|
|
Click on IFC element faces to place dimension vertices one by one using the
|
|
same snap system as wall and slab drawing. Each confirmed point is stored
|
|
as a parametric anchor in ``BBIM_Dimension`` so the dimension
|
|
recomputes automatically when the referenced elements move.
|
|
|
|
RMB or ENTER to finish; ESC to cancel without creating an annotation.
|
|
"""
|
|
|
|
bl_idname = "bim.draw_parametric_dimension"
|
|
bl_label = "Draw Parametric Dimension"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
_SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX")
|
|
|
|
if TYPE_CHECKING:
|
|
_anchors: list
|
|
_shape_cache: dict
|
|
_snap_mode: str
|
|
_ifc_snap_candidate: object # Optional[dict]
|
|
_draw_handler: object # SpaceView3D draw handler handle
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC file loaded.")
|
|
return False
|
|
if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera):
|
|
cls.poll_message_set("No active drawing.")
|
|
return False
|
|
return context.space_data.type == "VIEW_3D"
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
bpy.types.Operator.__init__(self, *args, **kwargs)
|
|
PolylineOperator.__init__(self)
|
|
self._anchors = []
|
|
self._shape_cache = {}
|
|
self._force_perpendicular = False
|
|
self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0]
|
|
self._anchor0_pt = None # (x, y, z) world-space position of anchor[0]
|
|
self._snap_mode = "FACE"
|
|
self._ifc_snap_candidate = None
|
|
self._draw_handler = None
|
|
self._snap_cand_obj_ptr: int = -1 # Blender object pointer for cached snap cands
|
|
self._snap_cand_cache: list = [] # cached get_layer/profile_snap_candidates result
|
|
self._snap_cand_multi_cache: dict = {} # ptr → candidates for coplanar-edge nearby objects
|
|
|
|
# ------------------------------------------------------------------
|
|
# Snap → anchor bridge
|
|
|
|
def _snap_to_anchor(self, snap: dict) -> dict:
|
|
"""Convert a PolylineOperator snap candidate to a BBIM_Dimension anchor dict."""
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
|
|
obj = snap.get("object")
|
|
element = tool.Ifc.get_entity(obj) if obj else None
|
|
pt_world = snap["point"]
|
|
|
|
if element and hasattr(element, "GlobalId") and obj.data and hasattr(obj.data, "polygons"):
|
|
hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z))
|
|
|
|
face_index = snap.get("face_index")
|
|
if face_index is None or face_index >= len(obj.data.polygons):
|
|
# Vertex / edge snap: seed from closest face.
|
|
local_pt = obj.matrix_world.inverted() @ pt_world
|
|
ok, _loc, _n, face_index = obj.closest_point_on_mesh(local_pt)
|
|
if not ok:
|
|
face_index = None
|
|
|
|
# Prefer faces perpendicular to the camera rather than faces that
|
|
# directly face the camera (e.g. top of a wall in plan view).
|
|
face_index = _prefer_perp_face_index(obj, pt_world, face_index)
|
|
|
|
if face_index is not None and face_index < len(obj.data.polygons):
|
|
normal_local = obj.data.polygons[face_index].normal
|
|
else:
|
|
normal_local = Vector((0.0, 0.0, 1.0))
|
|
|
|
normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized()
|
|
normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z))
|
|
placement_override = {element.id(): np.array(obj.matrix_world)}
|
|
|
|
return drawing_api.build_anchor_from_hit(
|
|
tool.Ifc.get(), element, hit_m, normal_m,
|
|
shape_cache=self._shape_cache,
|
|
placement_override=placement_override,
|
|
)
|
|
|
|
# Axis / plane snap, or non-IFC object: store a free world point.
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
return drawing_api.make_world_anchor([float(pt_world.x), float(pt_world.y), float(pt_world.z)])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Point insertion — capture anchor in sync with polyline point
|
|
|
|
def handle_inserting_polyline(self, context, event):
|
|
polyline_props = tool.Model.get_polyline_props()
|
|
polyline_data = polyline_props.insertion_polyline
|
|
count_before = len(polyline_data[0].polyline_points) if polyline_data else 0
|
|
|
|
# Capture snap state BEFORE super() so we have it even if event processing clears it.
|
|
snap = self.snapping_points[0] if self.snapping_points else None
|
|
is_mouse_click = (
|
|
not self.tool_state.is_input_on
|
|
and event.value == "RELEASE"
|
|
and event.type == "LEFTMOUSE"
|
|
)
|
|
|
|
super().handle_inserting_polyline(context, event)
|
|
|
|
if not polyline_data:
|
|
return
|
|
count_after = len(polyline_data[0].polyline_points)
|
|
|
|
if count_after > count_before:
|
|
# A point was inserted — build its anchor.
|
|
if is_mouse_click and self._ifc_snap_candidate:
|
|
self._anchors.append(self._build_ifc_anchor(self._ifc_snap_candidate))
|
|
elif is_mouse_click and snap and snap.get("type") not in {"Axis", "Plane"}:
|
|
self._anchors.append(self._snap_to_anchor(snap))
|
|
else:
|
|
# Keyboard-typed coordinate or close-loop: world anchor at the stored point.
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
pt = polyline_data[0].polyline_points[-1]
|
|
self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)]))
|
|
|
|
# After anchor[0] is set, extract its face normal for the perp constraint.
|
|
if self._force_perpendicular and len(self._anchors) == 1:
|
|
self._update_perp_constraint()
|
|
elif count_after < count_before and self._anchors:
|
|
# BACKSPACE removed a point.
|
|
self._anchors.pop()
|
|
# Reset constraint if we backspaced past anchor[0].
|
|
if len(self._anchors) == 0:
|
|
self._anchor0_normal = None
|
|
self._anchor0_pt = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Perpendicular-to-face constraint helpers
|
|
|
|
def _update_perp_constraint(self) -> None:
|
|
"""Extract the face normal from anchor[0] and store it as the constraint axis."""
|
|
import math
|
|
from mathutils import Vector
|
|
a = self._anchors[0] if self._anchors else None
|
|
if not a or a.get("type") != "FACE":
|
|
return
|
|
addr = a.get("addr") or {}
|
|
pt = a.get("pt")
|
|
if not pt:
|
|
return
|
|
|
|
method = addr.get("method", "FACE_NORMAL")
|
|
guid = a.get("guid")
|
|
|
|
if method == "LAYER_BOUNDARY":
|
|
# Derive the thickness-axis normal from the element's LayerSetDirection.
|
|
if not guid:
|
|
return
|
|
try:
|
|
file = tool.Ifc.get()
|
|
element = file.by_guid(guid)
|
|
import ifcopenshell.util.element as _ifc_elem
|
|
usage = _ifc_elem.get_material(element, should_inherit=True)
|
|
if not usage or not usage.is_a("IfcMaterialLayerSetUsage"):
|
|
return
|
|
axis = getattr(usage, "LayerSetDirection", None) or "AXIS2"
|
|
if axis == "AXIS1":
|
|
normal_local = (1.0, 0.0, 0.0)
|
|
elif axis == "AXIS3":
|
|
normal_local = (0.0, 0.0, 1.0)
|
|
else:
|
|
normal_local = (0.0, 1.0, 0.0)
|
|
obj = tool.Ifc.get_object(element)
|
|
if obj:
|
|
nw = obj.matrix_world.to_3x3() @ Vector(normal_local)
|
|
nw.normalize()
|
|
n: tuple = (nw.x, nw.y, nw.z)
|
|
else:
|
|
n = normal_local
|
|
except Exception:
|
|
return
|
|
else:
|
|
normal_local = addr.get("normal_local")
|
|
if not normal_local:
|
|
return
|
|
n = normal_local
|
|
if guid:
|
|
try:
|
|
file = tool.Ifc.get()
|
|
element = file.by_guid(guid)
|
|
obj = tool.Ifc.get_object(element)
|
|
if obj:
|
|
nw = obj.matrix_world.to_3x3() @ Vector(normal_local)
|
|
nw.normalize()
|
|
n = (nw.x, nw.y, nw.z)
|
|
except Exception:
|
|
pass
|
|
|
|
mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
|
if mag < 1e-12:
|
|
return
|
|
self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag)
|
|
self._anchor0_pt = tuple(pt)
|
|
|
|
def _apply_perp_constraint(self) -> None:
|
|
"""Project the current snap point onto the constraint line when active."""
|
|
if not self._force_perpendicular or not self._anchor0_normal or not self._anchor0_pt:
|
|
return
|
|
if not self._anchors: # constraint not yet active (no anchor[0] yet)
|
|
return
|
|
if not self.snapping_points:
|
|
return
|
|
snap = self.snapping_points[0]
|
|
if not snap or not snap.get("point"):
|
|
return
|
|
p = snap["point"]
|
|
base = self._anchor0_pt
|
|
n = self._anchor0_normal
|
|
t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2]
|
|
constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2]))
|
|
snap["point"] = constrained
|
|
|
|
# ------------------------------------------------------------------
|
|
# IFC-native snap for LAYER / VERTEX / EDGE modes
|
|
|
|
@staticmethod
|
|
def _get_mesh_snap_candidates(obj, snap_mode):
|
|
"""Derive VERTEX or EDGE snap candidates directly from the Blender mesh.
|
|
|
|
Fallback for tessellated IFC elements (IfcFacetedBrep, IfcTessellatedFaceSet,
|
|
etc.) that have no IfcExtrudedAreaSolid, causing get_profile_snap_candidates
|
|
to return []. Each candidate carries ``local_m`` (element-local coordinates
|
|
in metres) so _build_ifc_anchor can create a LOCAL_POINT anchor that follows
|
|
the element through moves and rotations.
|
|
"""
|
|
mx = obj.matrix_world
|
|
mesh = obj.data
|
|
candidates = []
|
|
if snap_mode == "VERTEX":
|
|
for vert in mesh.vertices:
|
|
wp = mx @ vert.co
|
|
candidates.append({
|
|
"type": "VERTEX", "snap": "VERTEX",
|
|
"snap_world": (wp.x, wp.y, wp.z),
|
|
"local_m": (vert.co.x, vert.co.y, vert.co.z),
|
|
})
|
|
elif snap_mode == "EDGE":
|
|
for edge in mesh.edges:
|
|
v0c = mesh.vertices[edge.vertices[0]].co
|
|
v1c = mesh.vertices[edge.vertices[1]].co
|
|
v0 = mx @ v0c
|
|
v1 = mx @ v1c
|
|
mid_w = ((v0.x + v1.x) * 0.5, (v0.y + v1.y) * 0.5, (v0.z + v1.z) * 0.5)
|
|
mid_l = ((v0c.x + v1c.x) * 0.5, (v0c.y + v1c.y) * 0.5, (v0c.z + v1c.z) * 0.5)
|
|
candidates.append({
|
|
"type": "EDGE", "snap": "EDGE",
|
|
"snap_world": mid_w,
|
|
"local_m": mid_l,
|
|
"v0": (v0.x, v0.y, v0.z),
|
|
"v1": (v1.x, v1.y, v1.z),
|
|
})
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3):
|
|
"""Return FACE snap candidates by projecting hit_pt onto each vertical face plane.
|
|
|
|
Accepts any face with a near-horizontal normal (wall-like faces) regardless of
|
|
the face's Z elevation. The screen-space bbox filter on the caller side and the
|
|
final per-candidate screen-distance gate already prevent false positives, so no
|
|
Z-based filtering is needed here. (Z is also irrelevant for 2D annotation
|
|
projections.)
|
|
"""
|
|
from mathutils import Vector
|
|
mx = obj.matrix_world
|
|
mesh = obj.data
|
|
hit_pt = Vector(hit_pt_world)
|
|
candidates = []
|
|
for poly in mesh.polygons:
|
|
normal_w = (mx.to_3x3() @ poly.normal).normalized()
|
|
if abs(normal_w.z) > 0.9:
|
|
continue
|
|
verts_w = [mx @ mesh.vertices[vi].co for vi in poly.vertices]
|
|
n = len(verts_w)
|
|
face_center_w = sum(verts_w, Vector((0.0, 0.0, 0.0))) / n
|
|
dist = (hit_pt - face_center_w).dot(normal_w)
|
|
snapped_pt = hit_pt - normal_w * dist
|
|
candidates.append({
|
|
"type": "FACE",
|
|
"snap_world": (snapped_pt.x, snapped_pt.y, snapped_pt.z),
|
|
"snap": "FACE",
|
|
"face_verts": [tuple(v) for v in verts_w],
|
|
"face_normal_world": (normal_w.x, normal_w.y, normal_w.z),
|
|
})
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def _pt_in_obj_bbox(obj, pt_world, tol=1e-3, tol_z=None):
|
|
"""Return True if pt_world is inside obj's world-space bounding box.
|
|
|
|
tol_z overrides tol for the Z axis — pass a large value to skip Z
|
|
checking when Z is already constrained by the caller (e.g. FACE mode).
|
|
"""
|
|
if tol_z is None:
|
|
tol_z = tol
|
|
try:
|
|
local_pt = obj.matrix_world.inverted() @ pt_world
|
|
except Exception:
|
|
return False
|
|
bb = obj.bound_box # 8 corners in local space
|
|
xs = [v[0] for v in bb]
|
|
ys = [v[1] for v in bb]
|
|
zs = [v[2] for v in bb]
|
|
return (
|
|
min(xs) - tol <= local_pt.x <= max(xs) + tol
|
|
and min(ys) - tol <= local_pt.y <= max(ys) + tol
|
|
and min(zs) - tol_z <= local_pt.z <= max(zs) + tol_z
|
|
)
|
|
|
|
def _compute_ifc_snap_candidate(self, context, event) -> "Optional[dict]":
|
|
"""Return an IFC-native snap candidate for the current snap mode, or None."""
|
|
if not self.snapping_points:
|
|
return None
|
|
|
|
snap = self.snapping_points[0]
|
|
hit_obj = snap.get("object")
|
|
hit_pt = snap.get("point")
|
|
|
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
|
|
region = context.region
|
|
rv3d = context.region_data
|
|
mx, my = event.mouse_region_x, event.mouse_region_y
|
|
|
|
# ----------------------------------------------------------------
|
|
# FACE mode: no IFC snap on the primary hit object, but look for
|
|
# coplanar-edge candidates from nearby objects (e.g. a wall whose
|
|
# bottom edge is coplanar with the hovered floor surface).
|
|
# Uses direct mesh edge projection rather than get_profile_snap_candidates
|
|
# so the snap tracks cursor position along the edge, not just fixed vertices.
|
|
# ----------------------------------------------------------------
|
|
if self._snap_mode == "FACE":
|
|
if hit_pt is None or not self.objs_2d_bbox:
|
|
return None
|
|
nearby_cands = [] # (cand, elem, obj, max_screen_tol)
|
|
# Check hit_obj itself first: handles the case where the cursor
|
|
# lands exactly on the wall/edge boundary (hit_obj IS the wall).
|
|
if hit_obj and hit_obj.data and isinstance(hit_obj.data, bpy.types.Mesh):
|
|
hit_elem = tool.Ifc.get_entity(hit_obj)
|
|
if hit_elem and hasattr(hit_elem, "GlobalId"):
|
|
for c in self._snap_on_coplanar_faces(hit_obj, hit_pt):
|
|
nearby_cands.append((c, hit_elem, hit_obj, 30))
|
|
extra_count = 0
|
|
for obj, _bbox2d in self.objs_2d_bbox:
|
|
if extra_count >= 4:
|
|
break
|
|
if obj is hit_obj:
|
|
continue
|
|
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
|
continue
|
|
extra_elem = tool.Ifc.get_entity(obj)
|
|
if not extra_elem or not hasattr(extra_elem, "GlobalId"):
|
|
continue
|
|
sx0, sx1, sy0, sy1 = _bbox2d
|
|
_SCREEN_TOL = max(30, 100 - min(sx1 - sx0, sy1 - sy0))
|
|
if not (sx0 - _SCREEN_TOL <= mx <= sx1 + _SCREEN_TOL and sy0 - _SCREEN_TOL <= my <= sy1 + _SCREEN_TOL):
|
|
continue
|
|
nearby_cands.extend(
|
|
(c, extra_elem, obj, _SCREEN_TOL)
|
|
for c in self._snap_on_coplanar_faces(obj, hit_pt)
|
|
)
|
|
extra_count += 1
|
|
|
|
best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf")
|
|
for cand, elem, obj, max_tol in nearby_cands:
|
|
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
|
if sp is None:
|
|
continue
|
|
d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2
|
|
if d2 < best_d2 and d2 < max_tol * max_tol:
|
|
best_d2 = d2
|
|
best_cand = cand
|
|
best_elem = elem
|
|
best_obj = obj
|
|
if best_cand is None:
|
|
return None
|
|
result = dict(best_cand)
|
|
result["element"] = best_elem
|
|
result["obj"] = best_obj
|
|
return result
|
|
|
|
# ----------------------------------------------------------------
|
|
# LAYER / VERTEX / EDGE modes
|
|
# ----------------------------------------------------------------
|
|
# Candidates are stored as (cand, elem, obj, max_screen_tol):
|
|
# - Direct-hit object → max_screen_tol = inf (no distance gate)
|
|
# - Nearby objects found via screen bbox → max_screen_tol = _SCREEN_TOL
|
|
# This lets thin edge-on walls (2-7 px wide bbox, ~98 px tolerance) be
|
|
# included without relaxing the gate for normal objects.
|
|
file = tool.Ifc.get()
|
|
all_cands = [] # (cand, elem, obj, max_screen_tol)
|
|
|
|
# Build initial candidates from the directly-hit object (if any).
|
|
# When hit_obj is None (thin walls are never the raycast target) we skip
|
|
# this block and rely entirely on the screen-bbox search below.
|
|
if hit_obj:
|
|
element = tool.Ifc.get_entity(hit_obj)
|
|
if element and hasattr(element, "GlobalId"):
|
|
obj_ptr = hit_obj.as_pointer()
|
|
if obj_ptr != self._snap_cand_obj_ptr:
|
|
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
|
if self._snap_mode == "LAYER":
|
|
self._snap_cand_cache = drawing_api.get_layer_snap_candidates(file, element, placement_override)
|
|
else:
|
|
self._snap_cand_cache = drawing_api.get_profile_snap_candidates(file, element, placement_override)
|
|
self._snap_cand_obj_ptr = obj_ptr
|
|
all_cands = [(c, element, hit_obj, float("inf")) for c in self._snap_cand_cache]
|
|
|
|
# Extend with candidates from nearby objects using adaptive screen-bbox.
|
|
# Replaces the old 30-px hardcoded check (LAYER) and _pt_in_obj_bbox
|
|
# (VERTEX/EDGE), so thin edge-on walls are reachable in all modes.
|
|
if self.objs_2d_bbox:
|
|
extra_count = 0
|
|
for obj, _bbox2d in self.objs_2d_bbox:
|
|
if extra_count >= 4:
|
|
break
|
|
if obj is hit_obj:
|
|
continue
|
|
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
|
continue
|
|
extra_elem = tool.Ifc.get_entity(obj)
|
|
if not extra_elem or not hasattr(extra_elem, "GlobalId"):
|
|
continue
|
|
sx0, sx1, sy0, sy1 = _bbox2d
|
|
_bbox_w = sx1 - sx0
|
|
_bbox_h = sy1 - sy0
|
|
_SCREEN_TOL = max(30, 100 - min(_bbox_w, _bbox_h))
|
|
if not (sx0 - _SCREEN_TOL <= mx <= sx1 + _SCREEN_TOL and sy0 - _SCREEN_TOL <= my <= sy1 + _SCREEN_TOL):
|
|
continue
|
|
extra_ptr = obj.as_pointer()
|
|
if extra_ptr not in self._snap_cand_multi_cache:
|
|
placement_override = {extra_elem.id(): np.array(obj.matrix_world)}
|
|
if self._snap_mode == "LAYER":
|
|
self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_layer_snap_candidates(
|
|
file, extra_elem, placement_override
|
|
)
|
|
else:
|
|
self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_profile_snap_candidates(
|
|
file, extra_elem, placement_override
|
|
)
|
|
ifc_cands = self._snap_cand_multi_cache[extra_ptr]
|
|
if ifc_cands:
|
|
all_cands.extend((c, extra_elem, obj, _SCREEN_TOL) for c in ifc_cands)
|
|
elif self._snap_mode in ("VERTEX", "EDGE"):
|
|
# Tessellated element with no IfcExtrudedAreaSolid: fall back to mesh
|
|
all_cands.extend(
|
|
(c, extra_elem, obj, _SCREEN_TOL)
|
|
for c in self._get_mesh_snap_candidates(obj, self._snap_mode)
|
|
)
|
|
extra_count += 1
|
|
|
|
if not all_cands:
|
|
return None
|
|
|
|
if self._snap_mode == "LAYER":
|
|
best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf")
|
|
for cand, elem, obj, max_tol in all_cands:
|
|
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
|
if sp is None:
|
|
continue
|
|
d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2
|
|
if d2 < best_d2 and d2 < max_tol * max_tol:
|
|
best_d2 = d2
|
|
best_cand = cand
|
|
best_elem = elem
|
|
best_obj = obj
|
|
if best_cand is None:
|
|
return None
|
|
result = dict(best_cand)
|
|
result["method"] = "LAYER_BOUNDARY"
|
|
result["element"] = best_elem
|
|
result["obj"] = best_obj
|
|
return result
|
|
|
|
# VERTEX or EDGE
|
|
best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf")
|
|
for cand, elem, obj, max_tol in all_cands:
|
|
if cand["type"] != self._snap_mode:
|
|
continue
|
|
sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"]))
|
|
if sp is None:
|
|
continue
|
|
d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2
|
|
if d2 < best_d2 and d2 < max_tol * max_tol:
|
|
best_d2 = d2
|
|
best_cand = cand
|
|
best_elem = elem
|
|
best_obj = obj
|
|
if best_cand is None:
|
|
return None
|
|
result = dict(best_cand)
|
|
result["element"] = best_elem
|
|
result["obj"] = best_obj
|
|
return result
|
|
|
|
def _build_ifc_anchor(self, candidate: dict) -> dict:
|
|
"""Build the correct anchor type from an IFC snap candidate."""
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
element = candidate.get("element")
|
|
if not element:
|
|
pt = candidate.get("snap_world", (0.0, 0.0, 0.0))
|
|
return drawing_api.make_world_anchor(list(pt))
|
|
file = tool.Ifc.get()
|
|
if candidate.get("method") == "LAYER_BOUNDARY":
|
|
return drawing_api.build_anchor_from_layer_boundary(file, element, candidate)
|
|
snap_kind = candidate.get("snap")
|
|
if snap_kind == "VERTEX" and candidate.get("profile_x_m") is not None:
|
|
return drawing_api.build_anchor_from_profile_vert(file, element, candidate)
|
|
if snap_kind == "EDGE" and candidate.get("profile_x_m") is not None:
|
|
return drawing_api.build_anchor_from_profile_edge(file, element, candidate)
|
|
if snap_kind in ("VERTEX", "EDGE") and candidate.get("local_m") is not None:
|
|
return drawing_api.build_anchor_from_local_point(
|
|
element, snap_kind, candidate["snap_world"], candidate["local_m"]
|
|
)
|
|
if snap_kind == "FACE":
|
|
hit_location = candidate.get("snap_world", (0.0, 0.0, 0.0))
|
|
hit_normal = candidate.get("face_normal_world")
|
|
if hit_normal:
|
|
return drawing_api.build_anchor_from_hit(file, element, hit_location, hit_normal)
|
|
pt = candidate.get("snap_world", (0.0, 0.0, 0.0))
|
|
return drawing_api.make_world_anchor(list(pt))
|
|
|
|
def _update_snap_draw_data(self) -> None:
|
|
"""Populate _snap_draw_data so _draw_snap_indicator_global draws the right indicator."""
|
|
_snap_draw_data.clear()
|
|
|
|
if self._ifc_snap_candidate:
|
|
cand = self._ifc_snap_candidate
|
|
if cand.get("method") == "LAYER_BOUNDARY":
|
|
_snap_draw_data.update({
|
|
"type": "LAYER",
|
|
"seam_corners": cand.get("seam_corners", []),
|
|
"snap_world": cand.get("snap_world"),
|
|
})
|
|
elif cand.get("snap") == "FACE":
|
|
fv = cand.get("face_verts", [])
|
|
_snap_draw_data.update({
|
|
"type": "FACE",
|
|
"face_verts": fv,
|
|
"snap_world": cand.get("snap_world"),
|
|
})
|
|
elif cand.get("snap") == "EDGE":
|
|
_snap_draw_data.update({
|
|
"type": "EDGE",
|
|
"v0": cand.get("v0"),
|
|
"v1": cand.get("v1"),
|
|
"snap_world": cand.get("snap_world"),
|
|
})
|
|
else:
|
|
_snap_draw_data.update({
|
|
"type": "VERTEX",
|
|
"snap_world": cand.get("snap_world"),
|
|
})
|
|
return
|
|
|
|
# FACE mode: outline the hovered face polygon
|
|
if not self.snapping_points:
|
|
return
|
|
snap = self.snapping_points[0]
|
|
hit_obj = snap.get("object")
|
|
if not hit_obj or not hasattr(hit_obj.data, "polygons"):
|
|
return
|
|
pt_world = snap.get("point")
|
|
face_index = snap.get("face_index")
|
|
if face_index is None or face_index >= len(hit_obj.data.polygons):
|
|
if pt_world is not None:
|
|
local_pt = hit_obj.matrix_world.inverted() @ pt_world
|
|
try:
|
|
ok, _loc, _n, face_index = hit_obj.closest_point_on_mesh(local_pt)
|
|
except RuntimeError:
|
|
return
|
|
if not ok:
|
|
return
|
|
face_index = _prefer_perp_face_index(hit_obj, pt_world, face_index)
|
|
if face_index is None or face_index >= len(hit_obj.data.polygons):
|
|
return
|
|
face = hit_obj.data.polygons[face_index]
|
|
mx = hit_obj.matrix_world
|
|
face_verts = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
|
_snap_draw_data.update({"type": "FACE", "face_verts": face_verts})
|
|
|
|
def _cleanup(self, context) -> None:
|
|
_snap_draw_data.clear()
|
|
if self._draw_handler:
|
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
|
self._draw_handler = None
|
|
context.workspace.status_text_set(None)
|
|
|
|
def _set_status(self, context) -> None:
|
|
mode_label = self._snap_mode.capitalize()
|
|
context.workspace.status_text_set(
|
|
f"[Snap: {mode_label}] TAB: cycle snap | LMB: place point"
|
|
" | 0-9: enter value | BACKSPACE: undo last | RMB/ENTER: finish | ESC: cancel"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Finalize: create IfcAnnotation + BBIM_Dimension pset
|
|
|
|
def _create_dimension_from_polyline(self, context) -> None:
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
|
|
polyline_props = tool.Model.get_polyline_props()
|
|
polyline_data = polyline_props.insertion_polyline
|
|
if not polyline_data or len(polyline_data[0].polyline_points) < 2:
|
|
self.report({"WARNING"}, "Need at least 2 points for a dimension.")
|
|
return
|
|
|
|
polyline_points = list(polyline_data[0].polyline_points)
|
|
|
|
dprops = tool.Drawing.get_document_props()
|
|
drawing = dprops.get_active_drawing()
|
|
if not drawing:
|
|
self.report({"WARNING"}, "No active drawing.")
|
|
return
|
|
|
|
props = tool.Drawing.get_annotation_props()
|
|
relating_type = (
|
|
tool.Ifc.get().by_id(int(props.relating_type_id))
|
|
if props.relating_type_id and props.relating_type_id != "0"
|
|
else None
|
|
)
|
|
|
|
obj = core.add_annotation(
|
|
tool.Ifc, tool.Collector, tool.Drawing,
|
|
drawing=drawing,
|
|
object_type="DIMENSION",
|
|
relating_type=relating_type,
|
|
enable_editing=False,
|
|
)
|
|
if not obj:
|
|
return
|
|
|
|
annotation = tool.Ifc.get_entity(obj)
|
|
if not annotation:
|
|
return
|
|
|
|
# World-space metres points straight from the polyline.
|
|
resolved_pts_m = [(pt.x, pt.y, pt.z) for pt in polyline_points]
|
|
|
|
# Update Blender curve spline AND the IFC IfcIndexedPolyCurve/IfcPolyline.
|
|
_update_blender_curve(annotation, resolved_pts_m)
|
|
|
|
# Pad anchors to match point count (e.g. if first point was keyboard-typed
|
|
# before any snap data was available).
|
|
anchors = list(self._anchors)
|
|
while len(anchors) < len(resolved_pts_m):
|
|
anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)])))
|
|
|
|
file = tool.Ifc.get()
|
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
if not pset_data:
|
|
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
pset_props = {"Anchors": json.dumps(anchors)}
|
|
if self._force_perpendicular:
|
|
pset_props["ForcePerpendicularToFace"] = True
|
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props)
|
|
|
|
# Always regenerate from anchor data so the curve reflects the true IFC
|
|
# face positions rather than the raw cursor positions from the polyline.
|
|
placement_override: dict = {}
|
|
for a in anchors:
|
|
guid = a.get("guid")
|
|
if not guid:
|
|
continue
|
|
try:
|
|
elem = file.by_guid(guid)
|
|
elem_obj = tool.Ifc.get_object(elem)
|
|
if elem_obj:
|
|
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
|
except Exception:
|
|
pass
|
|
resolved_pts = drawing_api.regenerate_dimension(
|
|
file, annotation,
|
|
shape_cache=getattr(self, "_shape_cache", None),
|
|
placement_override=placement_override,
|
|
)
|
|
if resolved_pts:
|
|
_update_blender_curve(annotation, resolved_pts)
|
|
|
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
|
_drawing_handler.invalidate_dim_index()
|
|
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj.select_set(True)
|
|
context.view_layer.objects.active = obj
|
|
|
|
# ------------------------------------------------------------------
|
|
# Modal loop — same pattern as DrawPolylineWall
|
|
|
|
def modal(self, context, event):
|
|
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
|
|
|
|
def _modal(self, context, event):
|
|
PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
|
|
tool.Blender.update_viewport()
|
|
|
|
self.handle_lock_axis(context, event)
|
|
|
|
if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
|
|
self.handle_mouse_move(context, event)
|
|
return {"PASS_THROUGH"}
|
|
|
|
self.handle_mouse_move(context, event)
|
|
self.choose_axis(event)
|
|
self.handle_snap_selection(context, event)
|
|
self._apply_perp_constraint()
|
|
|
|
# TAB: cycle snap mode when not in keyboard-input mode.
|
|
# Consume both PRESS and RELEASE so the RELEASE never reaches
|
|
# handle_keyboard_input (which would activate input mode on TAB RELEASE).
|
|
# When is_input_on is True, TAB passes through to cycle input fields as usual.
|
|
if event.type == "TAB" and not self.tool_state.is_input_on:
|
|
if event.value == "PRESS":
|
|
cur = self._SNAP_MODES.index(self._snap_mode)
|
|
self._snap_mode = self._SNAP_MODES[(cur + 1) % len(self._SNAP_MODES)]
|
|
self._ifc_snap_candidate = None
|
|
self._snap_cand_obj_ptr = -1 # invalidate cache: LAYER vs VERTEX/EDGE differ
|
|
self._snap_cand_multi_cache = {} # invalidate nearby-object cache too
|
|
self._set_status(context)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
# For LAYER / VERTEX / EDGE modes, compute an IFC-native snap candidate and
|
|
# override the polyline cursor position so the visual tracks the IFC point.
|
|
# Only recompute on mouse moves — key events don't change the hit object.
|
|
if event.type == "MOUSEMOVE":
|
|
self._ifc_snap_candidate = self._compute_ifc_snap_candidate(context, event)
|
|
if self._ifc_snap_candidate and self.snapping_points:
|
|
wp = self._ifc_snap_candidate.get("snap_world")
|
|
if wp:
|
|
self.snapping_points[0]["point"] = Vector(wp)
|
|
|
|
self._update_snap_draw_data()
|
|
|
|
if (
|
|
not self.tool_state.is_input_on
|
|
and event.value == "RELEASE"
|
|
and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}
|
|
):
|
|
self._create_dimension_from_polyline(context)
|
|
self.tool_state.plane_method = None
|
|
PolylineDecorator.uninstall()
|
|
tool.Polyline.clear_polyline()
|
|
self._cleanup(context)
|
|
tool.Blender.update_viewport()
|
|
return {"FINISHED"}
|
|
|
|
self.handle_keyboard_input(context, event)
|
|
self.handle_inserting_polyline(context, event)
|
|
|
|
cancel = self.handle_cancelation(context, event)
|
|
if cancel is not None:
|
|
self._cleanup(context)
|
|
return cancel
|
|
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def invoke(self, context, event):
|
|
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
|
|
|
|
def _init_snapping_points(self, context, event):
|
|
"""Skip the full BVH snap at startup — use a plane-intersection placeholder.
|
|
|
|
handle_mouse_move populates snapping_points properly after the first few
|
|
MOUSEMOVE events, so this placeholder only needs to survive until then.
|
|
We must also populate snap_mouse_point (a Blender prop collection) because
|
|
calculate_distance_and_angle accesses it immediately after invoke.
|
|
"""
|
|
from mathutils import Vector
|
|
plane_pt = tool.Raycast.ray_cast_to_plane(context, event, Vector((0, 0, 0)), Vector((0, 0, 1)))
|
|
snap = {"type": "Plane", "point": plane_pt, "object": None, "group": "Plane", "distance": 10}
|
|
self.snapping_points = [snap]
|
|
tool.Snap.update_snapping_point(plane_pt, "Plane")
|
|
|
|
def _invoke(self, context, event):
|
|
super().invoke(context, event)
|
|
self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face
|
|
_snap_draw_data.clear()
|
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
|
_draw_snap_indicator_global, (), "WINDOW", "POST_VIEW"
|
|
)
|
|
self._set_status(context)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
|
|
def _prefer_perp_face_index(
|
|
obj: "bpy.types.Object",
|
|
hit_world: "Vector",
|
|
current_index: "Optional[int]",
|
|
world_matrix=None,
|
|
) -> "Optional[int]":
|
|
"""Return the polygon index most perpendicular to the camera near *hit_world*.
|
|
|
|
If the camera is unavailable or the current face is already sufficiently
|
|
perpendicular (|dot| < 0.5), returns *current_index* unchanged.
|
|
*world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh
|
|
inside a collection instance whose effective transform differs from its own
|
|
``matrix_world``.
|
|
"""
|
|
camera = bpy.context.scene.camera
|
|
if not camera or not obj.data or not hasattr(obj.data, "polygons"):
|
|
return current_index
|
|
|
|
cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
|
|
mx = world_matrix if world_matrix is not None else obj.matrix_world
|
|
mx3 = mx.to_3x3()
|
|
|
|
if current_index is not None and current_index < len(obj.data.polygons):
|
|
current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized()
|
|
if abs(current_n.dot(cam_view)) < 0.5:
|
|
return current_index
|
|
|
|
best_idx = current_index
|
|
best_score = -1.0
|
|
for i, poly in enumerate(obj.data.polygons):
|
|
n_world = (mx3 @ poly.normal).normalized()
|
|
perp = 1.0 - abs(n_world.dot(cam_view))
|
|
if perp < 0.5:
|
|
continue
|
|
dist = (mx @ poly.center - hit_world).length
|
|
score = perp - dist / 4.0
|
|
if score > best_score:
|
|
best_score = score
|
|
best_idx = i
|
|
return best_idx
|
|
|
|
|
|
# Module-level draw data so the GPU callback never touches the operator RNA struct.
|
|
_snap_draw_data: dict = {}
|
|
|
|
|
|
|
|
def _draw_snap_indicator_global():
|
|
"""GPU draw callback (POST_VIEW) — draws face outline, edge, or vertex dot."""
|
|
data = _snap_draw_data
|
|
if not data or not data.get("type"):
|
|
return
|
|
import gpu
|
|
from gpu_extras.batch import batch_for_shader
|
|
try:
|
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
|
gpu.state.blend_set("ALPHA")
|
|
gpu.state.depth_test_set("ALWAYS")
|
|
snap_type = data["type"]
|
|
|
|
if snap_type == "FACE":
|
|
verts = data.get("face_verts", [])
|
|
if len(verts) >= 3:
|
|
lines = []
|
|
for i in range(len(verts)):
|
|
lines.append(verts[i])
|
|
lines.append(verts[(i + 1) % len(verts)])
|
|
shader.bind()
|
|
shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9))
|
|
gpu.state.line_width_set(4.0)
|
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
|
|
|
elif snap_type == "EDGE":
|
|
v0, v1 = data.get("v0"), data.get("v1")
|
|
if v0 and v1:
|
|
shader.bind()
|
|
shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0))
|
|
gpu.state.line_width_set(6.0)
|
|
batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader)
|
|
gpu.state.point_size_set(12.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader)
|
|
|
|
elif snap_type == "VERTEX":
|
|
pt = data.get("snap_world")
|
|
if pt:
|
|
shader.bind()
|
|
shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0))
|
|
gpu.state.point_size_set(20.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
|
|
|
elif snap_type == "LAYER":
|
|
corners = data.get("seam_corners", [])
|
|
pt = data.get("snap_world")
|
|
shader.bind()
|
|
shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0))
|
|
n = len(corners)
|
|
if n >= 2:
|
|
lines = []
|
|
for i in range(n):
|
|
lines.append(corners[i])
|
|
lines.append(corners[(i + 1) % n])
|
|
gpu.state.line_width_set(5.0)
|
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
|
gpu.state.point_size_set(10.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader)
|
|
if pt:
|
|
gpu.state.point_size_set(20.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
|
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
gpu.state.depth_test_set("LESS_EQUAL")
|
|
gpu.state.blend_set("NONE")
|
|
gpu.state.line_width_set(1.0)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Separate draw data dict and POST_PIXEL callback for SetDimensionAnchor hover —
|
|
# avoids GPU matrix state issues by working in pre-converted 2D screen coords.
|
|
_anchor_hover_draw_data: dict = {}
|
|
|
|
|
|
def _draw_anchor_hover_global():
|
|
"""POST_PIXEL callback — draws the face/edge/vertex hover indicator for SetDimensionAnchor."""
|
|
data = _anchor_hover_draw_data
|
|
if not data or not data.get("type"):
|
|
return
|
|
import gpu
|
|
from gpu_extras.batch import batch_for_shader
|
|
try:
|
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
|
gpu.state.blend_set("ALPHA")
|
|
snap_type = data["type"]
|
|
|
|
if snap_type == "FACE":
|
|
verts = data.get("face_verts_2d", [])
|
|
if len(verts) >= 3:
|
|
lines = []
|
|
for i in range(len(verts)):
|
|
lines.append(verts[i])
|
|
lines.append(verts[(i + 1) % len(verts)])
|
|
shader.bind()
|
|
shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9))
|
|
gpu.state.line_width_set(4.0)
|
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
|
|
|
elif snap_type == "EDGE":
|
|
v0, v1 = data.get("v0_2d"), data.get("v1_2d")
|
|
if v0 and v1:
|
|
shader.bind()
|
|
shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0))
|
|
gpu.state.line_width_set(6.0)
|
|
batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader)
|
|
gpu.state.point_size_set(12.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader)
|
|
|
|
elif snap_type == "LAYER":
|
|
shader.bind()
|
|
shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0))
|
|
corners = data.get("seam_corners_2d", [])
|
|
n = len(corners)
|
|
if n >= 2:
|
|
lines = []
|
|
for i in range(n):
|
|
lines.append(corners[i])
|
|
lines.append(corners[(i + 1) % n])
|
|
gpu.state.line_width_set(5.0)
|
|
batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader)
|
|
gpu.state.point_size_set(10.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader)
|
|
pt = data.get("snap_2d")
|
|
if pt:
|
|
gpu.state.point_size_set(20.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
|
|
|
elif snap_type == "VERTEX":
|
|
pt = data.get("snap_2d")
|
|
shader.bind()
|
|
shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0))
|
|
if pt:
|
|
gpu.state.point_size_set(20.0)
|
|
batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader)
|
|
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
gpu.state.blend_set("NONE")
|
|
gpu.state.line_width_set(1.0)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator):
|
|
"""Interactively anchor dimension vertices to IFC element faces.
|
|
|
|
Two-phase modal workflow (all in Object Mode):
|
|
1. Run the operator with a dimension annotation selected.
|
|
2. Click a vertex ON the dimension line to select it.
|
|
3. Hover over IFC elements — the nearest candidate is highlighted.
|
|
TAB cycles through overlapping candidates under the cursor.
|
|
Click to anchor the highlighted element face to that vertex.
|
|
ALT+Click sets a free world-point anchor instead.
|
|
4. Repeat steps 2-3 for more vertices.
|
|
5. RMB or ESC to finish.
|
|
"""
|
|
|
|
bl_idname = "bim.set_dimension_anchor"
|
|
bl_label = "Set Dimension Anchor"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
anchor_index: bpy.props.IntProperty(default=-1) # -1 = begin with vertex-pick phase
|
|
|
|
if TYPE_CHECKING:
|
|
anchor_index: int
|
|
|
|
_annotation: Optional[ifcopenshell.entity_instance] = None
|
|
_annotation_obj: Optional[bpy.types.Object] = None
|
|
_phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE"
|
|
_active_vertex_idx: int = -1
|
|
_shape_cache: dict
|
|
_region: Optional[bpy.types.Region] = None
|
|
_rv3d: Optional[bpy.types.RegionView3D] = None
|
|
|
|
# Hover-cycle state (active during PICK_FACE phase)
|
|
_hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...]
|
|
_hover_index: int # which element candidate is currently highlighted
|
|
_hover_last_px: tuple # last cursor pixel position where candidates were computed
|
|
_hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight
|
|
|
|
# Snap-mode cycle state (FACE → EDGE → VERTEX, TAB)
|
|
_snap_mode: str # "FACE" | "EDGE" | "VERTEX"
|
|
_draw_handler: object # SpaceView3D draw handler handle
|
|
|
|
_VERTEX_PICK_RADIUS_PX = 20
|
|
_HOVER_THROTTLE_PX_SQ = 144 # 12 px — enough to feel responsive without per-pixel recompute
|
|
_SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX")
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC file loaded.")
|
|
return False
|
|
obj = context.active_object
|
|
if not obj:
|
|
cls.poll_message_set("No active object.")
|
|
return False
|
|
if context.mode != "OBJECT":
|
|
cls.poll_message_set("Must be in Object Mode.")
|
|
return False
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
cls.poll_message_set("Active object must be an IfcAnnotation.")
|
|
return False
|
|
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
|
if ptype not in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"):
|
|
cls.poll_message_set("Annotation must be a dimension or elevation type.")
|
|
return False
|
|
return True
|
|
|
|
def invoke(self, context, event):
|
|
return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
|
|
|
|
def modal(self, context, event):
|
|
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
|
|
|
|
def _invoke(self, context, event):
|
|
obj = context.active_object
|
|
self._annotation = tool.Ifc.get_entity(obj)
|
|
self._annotation_obj = obj
|
|
self._shape_cache = {}
|
|
if self.anchor_index >= 0:
|
|
self._phase = "PICK_FACE"
|
|
self._active_vertex_idx = self.anchor_index
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(self.anchor_index, obj)
|
|
else:
|
|
self._phase = "PICK_VERTEX"
|
|
self._active_vertex_idx = -1
|
|
self._hover_candidates = []
|
|
self._hover_index = 0
|
|
self._hover_last_px = (-9999, -9999)
|
|
self._hover_highlighted_obj = None
|
|
self._snap_mode = "FACE"
|
|
_anchor_hover_draw_data.clear()
|
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
|
_draw_anchor_hover_global, (), "WINDOW", "POST_PIXEL"
|
|
)
|
|
|
|
# When invoked from a panel, context.region_data is None.
|
|
# Walk the screen areas to find the actual 3D viewport region.
|
|
self._region, self._rv3d = None, None
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
for region in area.regions:
|
|
if region.type == "WINDOW":
|
|
self._region = region
|
|
break
|
|
if area.spaces and area.spaces[0].type == "VIEW_3D":
|
|
self._rv3d = area.spaces[0].region_3d
|
|
break
|
|
|
|
self._set_status(context)
|
|
context.window_manager.modal_handler_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def _modal(self, context, event):
|
|
# Undo while the modal is running can free the annotation object.
|
|
try:
|
|
_ = self._annotation_obj.name
|
|
except ReferenceError:
|
|
self._cleanup(context)
|
|
return {"FINISHED"}
|
|
|
|
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
|
self._clear_hover_highlight(context)
|
|
context.workspace.status_text_set(None)
|
|
_anchor_hover_draw_data.clear()
|
|
if self._draw_handler:
|
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
|
self._draw_handler = None
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(-1)
|
|
obj = context.active_object
|
|
if obj:
|
|
obj.select_set(False)
|
|
context.view_layer.objects.active = None
|
|
return {"FINISHED"}
|
|
|
|
# Hover — recompute candidates as cursor moves (PICK_FACE phase only)
|
|
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
|
self._handle_hover(context, event)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
# Tab — cycle through candidates under cursor
|
|
if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE":
|
|
self._cycle_hover(context)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
|
# Swallow LMB RELEASE throughout the modal's lifetime.
|
|
# When ClickNearestDimensionAnchor hands off to this modal, Blender
|
|
# re-delivers that same RELEASE here; passing it through would let
|
|
# view3d.select steal the active object away from the annotation.
|
|
return {"RUNNING_MODAL"}
|
|
|
|
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
|
if self._phase == "PICK_VERTEX":
|
|
self._handle_vertex_pick(context, event)
|
|
else:
|
|
wrote = self._handle_face_pick(context, event)
|
|
if wrote:
|
|
# Finish here so this anchor write is its own undo step.
|
|
# The dimension stays selected so the user can click
|
|
# another dot immediately.
|
|
self._cleanup(context)
|
|
return {"FINISHED"}
|
|
self._set_status(context)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
return {"PASS_THROUGH"}
|
|
|
|
def _cleanup(self, context):
|
|
self._clear_hover_highlight(context)
|
|
context.workspace.status_text_set(None)
|
|
_anchor_hover_draw_data.clear()
|
|
if self._draw_handler:
|
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
|
self._draw_handler = None
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(-1)
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
break
|
|
|
|
# ------------------------------------------------------------------
|
|
# Status bar
|
|
|
|
def _set_status(self, context):
|
|
if self._phase == "PICK_VERTEX":
|
|
context.workspace.status_text_set(
|
|
"Click a dimension vertex | RMB / ESC: Finish"
|
|
)
|
|
else:
|
|
# In PICK_FACE the hover handler writes a richer status; this is the
|
|
# fallback shown when no candidates have been computed yet.
|
|
context.workspace.status_text_set(
|
|
f"Vertex {self._active_vertex_idx} — hover over element | "
|
|
"TAB: cycle candidates | Click: anchor | ALT+Click: free point | RMB/ESC: Finish"
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Phase 1: pick a vertex on the dimension curve
|
|
|
|
def _handle_vertex_pick(self, context, event):
|
|
from bpy_extras import view3d_utils
|
|
|
|
region = self._region
|
|
rv3d = self._rv3d
|
|
if not region or not rv3d:
|
|
return
|
|
|
|
# event.mouse_region_x/y is relative to the event's region (e.g. N-panel),
|
|
# not our stored 3D viewport region. Use absolute coords minus region offset.
|
|
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
|
|
obj = self._annotation_obj
|
|
|
|
best_idx = None
|
|
best_dist_sq = self._VERTEX_PICK_RADIUS_PX ** 2
|
|
|
|
if obj.data and hasattr(obj.data, "splines"):
|
|
for spline in obj.data.splines:
|
|
for i, pt in enumerate(spline.points):
|
|
world_co = obj.matrix_world @ pt.co.xyz
|
|
screen_co = view3d_utils.location_3d_to_region_2d(region, rv3d, world_co)
|
|
if screen_co is None:
|
|
continue
|
|
dist_sq = (screen_co.x - coord[0]) ** 2 + (screen_co.y - coord[1]) ** 2
|
|
if dist_sq < best_dist_sq:
|
|
best_dist_sq = dist_sq
|
|
best_idx = i
|
|
|
|
if best_idx is None:
|
|
self.report({"WARNING"}, f"Click closer to a dimension vertex (within {self._VERTEX_PICK_RADIUS_PX}px)")
|
|
return
|
|
|
|
self._active_vertex_idx = best_idx
|
|
self._phase = "PICK_FACE"
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(best_idx, obj)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Phase 2: pick a face on an IFC element
|
|
|
|
def _handle_face_pick(self, context, event):
|
|
from mathutils import Vector
|
|
|
|
region = self._region
|
|
rv3d = self._rv3d
|
|
if not region or not rv3d:
|
|
return
|
|
|
|
# ALT+click → free world-point anchor at any mesh surface.
|
|
if event.alt:
|
|
self._clear_hover_highlight(context)
|
|
origin, direction = self._unproject_coord(
|
|
(event.mouse_x - region.x, event.mouse_y - region.y)
|
|
)
|
|
best_dist = float("inf")
|
|
alt_loc = None
|
|
for obj in context.scene.objects:
|
|
if obj.type != "MESH":
|
|
continue
|
|
try:
|
|
mx_inv = obj.matrix_world.inverted()
|
|
except Exception:
|
|
continue
|
|
ok, loc_l, _, _ = obj.ray_cast(
|
|
mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized()
|
|
)
|
|
if ok:
|
|
loc_w = obj.matrix_world @ loc_l
|
|
d = (loc_w - origin).length
|
|
if d < best_dist:
|
|
best_dist = d
|
|
alt_loc = loc_w
|
|
pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0)
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
anchor = drawing_api.make_world_anchor(pt_m)
|
|
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
|
self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point")
|
|
return True
|
|
|
|
# Normal click — use whichever candidate is currently highlighted.
|
|
self._clear_hover_highlight(context)
|
|
|
|
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
|
|
dx = coord[0] - self._hover_last_px[0]
|
|
dy = coord[1] - self._hover_last_px[1]
|
|
if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates:
|
|
self._hover_candidates = self._compute_candidates(context, coord)
|
|
self._hover_index = 0
|
|
|
|
if not self._hover_candidates:
|
|
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
|
|
return
|
|
|
|
idx = min(self._hover_index, len(self._hover_candidates) - 1)
|
|
hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx]
|
|
|
|
element = tool.Ifc.get_entity(hit_obj)
|
|
if not element:
|
|
self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element")
|
|
return
|
|
|
|
file = tool.Ifc.get()
|
|
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
|
|
|
# Recompute snap geometry at the exact click position for accuracy.
|
|
snap = self._compute_snap_geom(hit_obj, face_index, coord)
|
|
snap_type = snap.get("type", "FACE")
|
|
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
try:
|
|
if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
|
|
anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
|
|
elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
|
|
anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap)
|
|
elif snap_type == "EDGE" and snap.get("profile_x_m") is not None:
|
|
anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap)
|
|
elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None:
|
|
# Tessellation fallback — element has no IfcExtrudedAreaSolid profile.
|
|
# Use element-local coordinates so the anchor follows the element
|
|
# through moves and rotations (LOCAL_POINT method).
|
|
sw = snap["snap_world"]
|
|
local_m = snap.get("local_m")
|
|
if local_m is not None:
|
|
anchor = drawing_api.build_anchor_from_local_point(element, snap_type, sw, local_m)
|
|
else:
|
|
anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])])
|
|
else:
|
|
hit_m = (float(location.x), float(location.y), float(location.z))
|
|
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
|
anchor = drawing_api.build_anchor_from_hit(
|
|
file, element, hit_m, normal_m,
|
|
shape_cache=self._shape_cache,
|
|
placement_override=placement_override,
|
|
)
|
|
except Exception as exc:
|
|
import traceback
|
|
traceback.print_exc()
|
|
self.report({"ERROR"}, f"build_anchor failed: {exc}")
|
|
return
|
|
|
|
_do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache)
|
|
|
|
# For elevation annotations the whole object should jump to the face hit
|
|
# (XY and Z), not just update Z via the object-placement path in
|
|
# _update_elevation_marker_z.
|
|
ptype = ifcopenshell.util.element.get_predefined_type(self._annotation)
|
|
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
|
|
from mathutils import Vector as _mVector
|
|
hit_pt = anchor.get("pt")
|
|
target_z = float(hit_pt[2]) if hit_pt else float(location.z)
|
|
hit_world = _mVector((float(location.x), float(location.y), target_z))
|
|
m = self._annotation_obj.matrix_world.copy()
|
|
m.translation = hit_world
|
|
ifcopenshell.api.run("geometry.edit_object_placement", file, product=self._annotation, matrix=np.array(m))
|
|
if self._annotation_obj.parent:
|
|
self._annotation_obj.location = self._annotation_obj.parent.matrix_world.inverted() @ hit_world
|
|
else:
|
|
self._annotation_obj.location = hit_world
|
|
_zero_elevation_annotation_spline_z(self._annotation, self._annotation_obj)
|
|
|
|
self.report(
|
|
{"INFO"},
|
|
f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
|
|
)
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Hover / cycle helpers
|
|
|
|
def _unproject_coord(self, coord):
|
|
"""Return (origin, direction) world-space ray for a region pixel coord."""
|
|
from mathutils import Vector
|
|
|
|
rv3d = self._rv3d
|
|
region = self._region
|
|
persinv = rv3d.perspective_matrix.inverted()
|
|
dx = (2.0 * coord[0] / region.width) - 1.0
|
|
dy = (2.0 * coord[1] / region.height) - 1.0
|
|
near_h = persinv @ Vector((dx, dy, -1.0, 1.0))
|
|
far_h = persinv @ Vector((dx, dy, 1.0, 1.0))
|
|
origin = near_h.xyz / near_h.w
|
|
far_pt = far_h.xyz / far_h.w
|
|
direction = (far_pt - origin).normalized()
|
|
if not rv3d.is_perspective:
|
|
origin = origin - direction * 1e4
|
|
return origin, direction
|
|
|
|
def _get_current_anchor_guid(self) -> "Optional[str]":
|
|
"""Return the element GUID the active anchor is currently bound to, or None."""
|
|
if self._active_vertex_idx < 0 or not self._annotation:
|
|
return None
|
|
try:
|
|
pset_data = ifcopenshell.util.element.get_pset(self._annotation, "BBIM_Dimension")
|
|
if not pset_data or not pset_data.get("Anchors"):
|
|
return None
|
|
anchors = json.loads(pset_data["Anchors"])
|
|
anchor = anchors[self._active_vertex_idx]
|
|
return anchor.get("guid") or None
|
|
except Exception:
|
|
return None
|
|
|
|
def _compute_candidates(self, context, coord):
|
|
"""Cast a ray from *coord* and return a ranked list of hit candidates.
|
|
|
|
Each entry: (ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index)
|
|
Sorted closest-first for direct hits; by proximity distance for near-misses.
|
|
"""
|
|
import math as _math
|
|
from mathutils import Vector
|
|
|
|
origin, direction = self._unproject_coord(coord)
|
|
depsgraph = context.evaluated_depsgraph_get()
|
|
view_layer = context.view_layer
|
|
|
|
def _is_hidden(obj):
|
|
h = obj.hide_get(view_layer=view_layer)
|
|
hv = obj.hide_viewport
|
|
vis = obj.visible_get()
|
|
return h or hv or not vis
|
|
|
|
# In FACE mode only snap to faces whose normal is roughly perpendicular to
|
|
# the camera view direction (i.e., wall/vertical faces in plan view, not
|
|
# floor/ceiling faces). |dot| < 0.5 ≈ within 60° of perpendicular.
|
|
_face_cam_view = None
|
|
if self._snap_mode == "FACE":
|
|
_cam = bpy.context.scene.camera
|
|
if _cam:
|
|
_face_cam_view = (_cam.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized()
|
|
|
|
def _face_perp_ok(normal_w):
|
|
"""Return True when the face is acceptably perpendicular to the camera."""
|
|
if _face_cam_view is None:
|
|
return True
|
|
return abs(normal_w.dot(_face_cam_view)) < 0.5
|
|
|
|
# Scene-BVH pierce-through: O(log N) vs the previous O(N) per-object loop.
|
|
# Each iteration steps past the last hit surface to reach the next object.
|
|
_DBG_GUIDS = {"1kGw8dvBT2zgE3OsifqnY8", "3YfgKSYh971wjlK2f3vaxy"}
|
|
|
|
direct: list = []
|
|
ray_hit_objs: set = set() # all IFC objects the ray passed through (any face)
|
|
ray_origin = Vector(origin)
|
|
_EPS = 1e-4
|
|
|
|
for _ in range(8):
|
|
result, loc_w, nrm_w, fi, hit_obj_eval, hit_mx = context.scene.ray_cast(
|
|
depsgraph, ray_origin, direction
|
|
)
|
|
if not result:
|
|
break
|
|
ray_origin = loc_w + direction * _EPS
|
|
ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval)
|
|
_dbg_guid = getattr(tool.Ifc.get_entity(ifc_obj), "GlobalId", None)
|
|
if _dbg_guid in _DBG_GUIDS:
|
|
print(f"[dbg-ray] hit {_dbg_guid} obj={ifc_obj.name}")
|
|
if ifc_obj == self._annotation_obj:
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: is annotation obj")
|
|
continue
|
|
if _is_hidden(ifc_obj):
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: hidden h={ifc_obj.hide_get(view_layer=view_layer)} hv={ifc_obj.hide_viewport} vis={ifc_obj.visible_get()}")
|
|
continue
|
|
if ifc_obj.type != "MESH":
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: type={ifc_obj.type}")
|
|
continue
|
|
if not tool.Ifc.get_entity(ifc_obj):
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: no IFC entity")
|
|
continue
|
|
ray_hit_objs.add(ifc_obj) # track even if face is non-perp
|
|
mx = ifc_obj.matrix_world
|
|
# In FACE mode use the exact hit face; _prefer_perp_face_index is only
|
|
# needed for VERTEX/EDGE profile snapping.
|
|
if self._snap_mode != "FACE":
|
|
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
|
normal = (
|
|
(mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized()
|
|
if fi is not None
|
|
else nrm_w.normalized()
|
|
)
|
|
if not _face_perp_ok(normal):
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} SKIP: face not perp normal={normal} dot={abs(normal.dot(_face_cam_view)) if _face_cam_view else 'N/A'}")
|
|
continue
|
|
dist = (loc_w - origin).length
|
|
if _dbg_guid in _DBG_GUIDS: print(f"[dbg-ray] {_dbg_guid} ACCEPTED dist={dist:.4f}")
|
|
direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
|
if direct:
|
|
direct.sort(key=lambda c: c[0])
|
|
# Prefer the element the anchor is currently bound to so that
|
|
# re-picking a gizmo dot defaults to the same element rather than
|
|
# whatever happened to be closest along the ray.
|
|
preferred_guid = self._get_current_anchor_guid()
|
|
if preferred_guid:
|
|
for _pi, _pc in enumerate(direct):
|
|
if getattr(tool.Ifc.get_entity(_pc[1]), "GlobalId", None) == preferred_guid:
|
|
if _pi > 0:
|
|
direct.insert(0, direct.pop(_pi))
|
|
break
|
|
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct]
|
|
|
|
# Proximity fallback — collect ALL candidates within TOL, sorted by perp distance.
|
|
TOL = 0.05
|
|
|
|
def _perp(v):
|
|
return v - v.dot(direction) * direction
|
|
|
|
prox: list = []
|
|
for ifc_obj in context.scene.objects:
|
|
if ifc_obj == self._annotation_obj:
|
|
continue
|
|
if _is_hidden(ifc_obj):
|
|
continue
|
|
elem = tool.Ifc.get_entity(ifc_obj)
|
|
if not elem:
|
|
continue
|
|
if ifc_obj.type != "MESH":
|
|
continue
|
|
mx = ifc_obj.matrix_world
|
|
try:
|
|
mx_inv = mx.inverted()
|
|
except Exception:
|
|
continue
|
|
bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box]
|
|
bb_proj = [_perp(v) for v in bb_world]
|
|
op = _perp(origin)
|
|
sx = max(min(v.x for v in bb_proj) - op.x, 0.0, op.x - max(v.x for v in bb_proj))
|
|
sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj))
|
|
sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj))
|
|
perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz)
|
|
if perp_dist > TOL:
|
|
continue
|
|
bb_ctr = sum((v for v in bb_world), Vector()) / 8
|
|
t = (bb_ctr - origin).dot(direction)
|
|
query_w = origin + t * direction
|
|
try:
|
|
found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0)
|
|
except RuntimeError:
|
|
continue
|
|
if not found:
|
|
continue
|
|
loc_w = mx @ loc_l
|
|
if self._snap_mode != "FACE":
|
|
fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx)
|
|
normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized()
|
|
if not _face_perp_ok(normal):
|
|
continue
|
|
prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi))
|
|
|
|
# Objects the ray directly passed through get priority over objects that
|
|
# are merely nearby — prevents adjacent windows/walls stealing the snap
|
|
# from an element the cursor is actually over.
|
|
prox.sort(key=lambda c: (0 if c[1] in ray_hit_objs else 1, c[0]))
|
|
return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox]
|
|
|
|
def _handle_hover(self, context, event):
|
|
"""Recompute candidates when cursor moves; highlight the current one."""
|
|
if not self._region:
|
|
return
|
|
coord = (event.mouse_x - self._region.x, event.mouse_y - self._region.y)
|
|
dx = coord[0] - self._hover_last_px[0]
|
|
dy = coord[1] - self._hover_last_px[1]
|
|
if dx * dx + dy * dy < self._HOVER_THROTTLE_PX_SQ:
|
|
return
|
|
self._hover_last_px = coord
|
|
self._hover_candidates = self._compute_candidates(context, coord)
|
|
self._hover_index = 0
|
|
self._apply_hover_highlight(context)
|
|
|
|
def _cycle_hover(self, context):
|
|
"""Cycle snap mode (FACE → EDGE → VERTEX); advance element on wrap-around."""
|
|
if not self._hover_candidates:
|
|
return
|
|
modes = self._SNAP_MODES
|
|
cur = modes.index(self._snap_mode)
|
|
nxt = (cur + 1) % len(modes)
|
|
self._snap_mode = modes[nxt]
|
|
if nxt == 0 and len(self._hover_candidates) > 1:
|
|
self._hover_index = (self._hover_index + 1) % len(self._hover_candidates)
|
|
self._apply_hover_highlight(context)
|
|
|
|
def _apply_hover_highlight(self, context):
|
|
"""Compute snap geometry for the current candidate and update the status bar."""
|
|
if not self._hover_candidates:
|
|
self._clear_hover_highlight(context)
|
|
return
|
|
|
|
ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index]
|
|
|
|
sg = self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px)
|
|
_anchor_hover_draw_data.clear()
|
|
if sg and self._region and self._rv3d:
|
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
|
snap_type = sg.get("type")
|
|
_anchor_hover_draw_data["type"] = snap_type
|
|
if snap_type == "FACE":
|
|
verts_2d = [
|
|
tuple(location_3d_to_region_2d(self._region, self._rv3d, v) or (0, 0))
|
|
for v in sg.get("face_verts", [])
|
|
]
|
|
_anchor_hover_draw_data["face_verts_2d"] = verts_2d
|
|
elif snap_type == "EDGE":
|
|
v0 = location_3d_to_region_2d(self._region, self._rv3d, sg["v0"])
|
|
v1 = location_3d_to_region_2d(self._region, self._rv3d, sg["v1"])
|
|
_anchor_hover_draw_data["v0_2d"] = tuple(v0) if v0 else None
|
|
_anchor_hover_draw_data["v1_2d"] = tuple(v1) if v1 else None
|
|
elif snap_type == "LAYER":
|
|
pt = sg.get("snap_world")
|
|
if pt:
|
|
sp = location_3d_to_region_2d(self._region, self._rv3d, pt)
|
|
_anchor_hover_draw_data["snap_2d"] = tuple(sp) if sp else None
|
|
corners_3d = sg.get("seam_corners", [])
|
|
_anchor_hover_draw_data["seam_corners_2d"] = [
|
|
tuple(location_3d_to_region_2d(self._region, self._rv3d, c) or (0, 0))
|
|
for c in corners_3d
|
|
]
|
|
elif snap_type == "VERTEX":
|
|
pt = sg.get("snap_world")
|
|
if pt:
|
|
sp = location_3d_to_region_2d(self._region, self._rv3d, pt)
|
|
_anchor_hover_draw_data["snap_2d"] = tuple(sp) if sp else None
|
|
entity = tool.Ifc.get_entity(ifc_obj)
|
|
label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name
|
|
n = len(self._hover_candidates)
|
|
mode_label = self._snap_mode.capitalize()
|
|
elem_hint = f" ({self._hover_index + 1}/{n})" if n > 1 else ""
|
|
context.workspace.status_text_set(
|
|
f"Dim vertex {self._active_vertex_idx} — {label} [{mode_label}{elem_hint}]"
|
|
" | TAB: cycle snap | Click: anchor | ALT+Click: free | RMB/ESC: Finish"
|
|
)
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
break
|
|
|
|
def _clear_hover_highlight(self, context):
|
|
"""Clear hover draw data and restore the annotation as the active object."""
|
|
self._hover_highlighted_obj = None
|
|
_anchor_hover_draw_data.clear()
|
|
try:
|
|
self._annotation_obj.select_set(True)
|
|
context.view_layer.objects.active = self._annotation_obj
|
|
except Exception:
|
|
pass
|
|
|
|
def cancel(self, context):
|
|
"""Called when the operator is cancelled externally — clean up GPU handler."""
|
|
_anchor_hover_draw_data.clear()
|
|
if self._draw_handler:
|
|
bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW")
|
|
self._draw_handler = None
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(-1)
|
|
obj = context.active_object
|
|
if obj:
|
|
obj.select_set(False)
|
|
context.view_layer.objects.active = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Snap geometry helpers
|
|
|
|
@staticmethod
|
|
def _coplanar_face_outline(obj, mx, seed_face):
|
|
"""Return ordered world-space vertices forming the outline of the planar region.
|
|
|
|
Finds all polygons on *obj* coplanar with *seed_face*, collects their
|
|
boundary edges (edges shared by only one polygon in the group), then
|
|
walks those edges into a single ordered loop and converts to world space.
|
|
Falls back to the seed face's own vertices if the walk fails.
|
|
"""
|
|
# Skip the full boundary-walk for highly-tessellated meshes (e.g. terrain).
|
|
# The O(N) polygon scan freezes Blender on objects with thousands of faces.
|
|
if len(obj.data.polygons) > 500:
|
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
|
|
|
target_n = seed_face.normal.copy()
|
|
target_d = seed_face.center.dot(target_n)
|
|
tol_n = 1e-3
|
|
tol_d = 1e-3
|
|
|
|
# Collect all coplanar polygon indices
|
|
coplanar = [
|
|
i for i, p in enumerate(obj.data.polygons)
|
|
if abs(p.normal.dot(target_n) - 1.0) <= tol_n
|
|
and abs(p.center.dot(target_n) - target_d) <= tol_d
|
|
]
|
|
|
|
# Count edge appearances; boundary edges appear exactly once
|
|
edge_count: dict = {}
|
|
for fi in coplanar:
|
|
poly = obj.data.polygons[fi]
|
|
verts = list(poly.vertices)
|
|
n = len(verts)
|
|
for i in range(n):
|
|
e = (min(verts[i], verts[(i + 1) % n]), max(verts[i], verts[(i + 1) % n]))
|
|
edge_count[e] = edge_count.get(e, 0) + 1
|
|
boundary = [e for e, cnt in edge_count.items() if cnt == 1]
|
|
|
|
if not boundary:
|
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
|
|
|
# Build adjacency map
|
|
adj: dict = {}
|
|
for a, b in boundary:
|
|
adj.setdefault(a, []).append(b)
|
|
adj.setdefault(b, []).append(a)
|
|
|
|
# Walk ALL disconnected loops (outer perimeter + any window/door hole loops).
|
|
# A wall with a window void has two loops: the outer wall outline and the
|
|
# inner opening perimeter. We want the largest loop (outer boundary).
|
|
unvisited = set(v for e in boundary for v in e)
|
|
loops: list = []
|
|
while unvisited:
|
|
start = next(iter(unvisited))
|
|
ring = [start]
|
|
unvisited.discard(start)
|
|
prev, cur = None, start
|
|
for _ in range(len(boundary) + 1):
|
|
nxts = [v for v in adj.get(cur, []) if v != prev]
|
|
if not nxts or nxts[0] == start:
|
|
break
|
|
prev, cur = cur, nxts[0]
|
|
if cur in unvisited:
|
|
unvisited.discard(cur)
|
|
ring.append(cur)
|
|
else:
|
|
break
|
|
if len(ring) >= 3:
|
|
loops.append(ring)
|
|
|
|
if not loops:
|
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in seed_face.vertices]
|
|
|
|
# The outer perimeter has the most vertices; window/door holes are smaller.
|
|
best_ring = max(loops, key=len)
|
|
return [tuple(mx @ obj.data.vertices[vi].co) for vi in best_ring]
|
|
|
|
def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict:
|
|
"""Return snap draw-data dict for the current snap mode and hit face.
|
|
|
|
When the hit element has an IfcExtrudedAreaSolid, VERTEX and EDGE snaps
|
|
are resolved from the IFC profile geometry (stable across mesh reloads)
|
|
rather than from Blender tessellation vertex indices.
|
|
|
|
``coord`` is a (x, y) tuple in region-local pixels. Returns an empty
|
|
dict when the face index is invalid or the region is unavailable.
|
|
"""
|
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
|
|
|
region = self._region
|
|
rv3d = self._rv3d
|
|
if not region or not rv3d or face_index is None:
|
|
return {}
|
|
try:
|
|
face = hit_obj.data.polygons[face_index]
|
|
except (IndexError, AttributeError):
|
|
return {}
|
|
|
|
mx = hit_obj.matrix_world
|
|
face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices]
|
|
|
|
if self._snap_mode == "FACE":
|
|
# For tessellated meshes a single polygon may be a tiny micro-triangle.
|
|
# Find all coplanar faces on the same plane and walk their boundary edges
|
|
# to produce the full planar face outline.
|
|
face_verts_world = self._coplanar_face_outline(hit_obj, mx, face)
|
|
return {"type": "FACE", "face_verts": face_verts_world}
|
|
|
|
# Try profile-based snap candidates first (IFC-native, index-stable).
|
|
if self._snap_mode in ("VERTEX", "EDGE"):
|
|
element = tool.Ifc.get_entity(hit_obj)
|
|
if element:
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
placement_override = {element.id(): np.array(mx)}
|
|
candidates = drawing_api.get_profile_snap_candidates(
|
|
tool.Ifc.get(), element, placement_override=placement_override
|
|
)
|
|
want_type = self._snap_mode
|
|
best_cand = None
|
|
best_d2 = float("inf")
|
|
for cand in candidates:
|
|
if cand["type"] != want_type:
|
|
continue
|
|
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
|
if sp is None:
|
|
continue
|
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < best_d2:
|
|
best_d2, best_cand = d2, cand
|
|
if best_cand is not None:
|
|
return best_cand
|
|
|
|
# Fallback: Blender tessellation snap for elements without an
|
|
# IfcExtrudedAreaSolid profile. Returns snap_world for the visual
|
|
# indicator; no pt_idx so the click handler creates a face anchor.
|
|
screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world]
|
|
n = len(face_verts_world)
|
|
|
|
if self._snap_mode == "VERTEX":
|
|
best_i, best_d2 = 0, float("inf")
|
|
for i, sp in enumerate(screen_pts):
|
|
if sp is not None:
|
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < best_d2:
|
|
best_d2, best_i = d2, i
|
|
local_co = hit_obj.data.vertices[face.vertices[best_i]].co
|
|
return {
|
|
"type": "VERTEX",
|
|
"snap_world": face_verts_world[best_i],
|
|
"local_m": (local_co.x, local_co.y, local_co.z),
|
|
}
|
|
|
|
if self._snap_mode == "EDGE":
|
|
best_e, best_d2 = 0, float("inf")
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
sp0, sp1 = screen_pts[i], screen_pts[j]
|
|
if sp0 is not None and sp1 is not None:
|
|
mid_x = (sp0.x + sp1.x) * 0.5
|
|
mid_y = (sp0.y + sp1.y) * 0.5
|
|
dx, dy = mid_x - coord[0], mid_y - coord[1]
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < best_d2:
|
|
best_d2, best_e = d2, i
|
|
i0, i1 = best_e, (best_e + 1) % n
|
|
v0_w, v1_w = face_verts_world[i0], face_verts_world[i1]
|
|
mid_w = ((v0_w[0] + v1_w[0]) * 0.5, (v0_w[1] + v1_w[1]) * 0.5, (v0_w[2] + v1_w[2]) * 0.5)
|
|
v0_l = hit_obj.data.vertices[face.vertices[i0]].co
|
|
v1_l = hit_obj.data.vertices[face.vertices[i1]].co
|
|
mid_l = ((v0_l.x + v1_l.x) * 0.5, (v0_l.y + v1_l.y) * 0.5, (v0_l.z + v1_l.z) * 0.5)
|
|
return {
|
|
"type": "EDGE",
|
|
"v0": v0_w, "v1": v1_w,
|
|
"snap_world": mid_w,
|
|
"local_m": mid_l,
|
|
}
|
|
|
|
if self._snap_mode == "LAYER":
|
|
element = tool.Ifc.get_entity(hit_obj)
|
|
if element:
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
placement_override = {element.id(): np.array(mx)}
|
|
candidates = drawing_api.get_layer_snap_candidates(
|
|
tool.Ifc.get(), element, placement_override=placement_override
|
|
)
|
|
best_cand = None
|
|
best_d2 = float("inf")
|
|
for cand in candidates:
|
|
sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"])
|
|
if sp is None:
|
|
continue
|
|
dx, dy = sp.x - coord[0], sp.y - coord[1]
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < best_d2:
|
|
best_d2, best_cand = d2, cand
|
|
if best_cand is not None:
|
|
result = dict(best_cand)
|
|
result["type"] = "LAYER"
|
|
result["method"] = "LAYER_BOUNDARY"
|
|
return result
|
|
return {"type": "FACE", "face_verts": face_verts_world}
|
|
|
|
return {}
|
|
|
|
|
|
def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None:
|
|
"""Write one anchor into the BBIM_Dimension pset and regenerate the curve."""
|
|
file = tool.Ifc.get()
|
|
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
|
|
_is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL")
|
|
|
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
|
|
if _is_elevation:
|
|
# Elevation markers use exactly one anchor (the Z reference point).
|
|
anchors: list = [new_anchor]
|
|
else:
|
|
if pset_data and pset_data.get("Anchors"):
|
|
try:
|
|
anchors = json.loads(pset_data["Anchors"])
|
|
except Exception:
|
|
anchors = []
|
|
else:
|
|
anchors = _anchors_from_spline(annotation_obj, file)
|
|
|
|
while len(anchors) <= vertex_index:
|
|
idx = len(anchors)
|
|
if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines:
|
|
pts = annotation_obj.data.splines[0].points
|
|
if idx < len(pts):
|
|
co = annotation_obj.matrix_world @ pts[idx].co.xyz
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)]))
|
|
continue
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0]))
|
|
|
|
anchors[vertex_index] = new_anchor
|
|
|
|
anchors_json = json.dumps(anchors)
|
|
|
|
if pset_data:
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
|
else:
|
|
ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension")
|
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json})
|
|
|
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
|
_drawing_handler.invalidate_dim_index()
|
|
|
|
placement_override: dict = {}
|
|
for a in anchors:
|
|
guid = a.get("guid")
|
|
if not guid:
|
|
continue
|
|
try:
|
|
elem = file.by_guid(guid)
|
|
elem_obj = tool.Ifc.get_object(elem)
|
|
if elem_obj:
|
|
placement_override[elem.id()] = np.array(elem_obj.matrix_world)
|
|
except Exception:
|
|
pass
|
|
|
|
if _is_elevation:
|
|
_update_elevation_marker_z(file, annotation, shape_cache=shape_cache, placement_override=placement_override)
|
|
else:
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
resolved_pts = drawing_api.regenerate_dimension(
|
|
file,
|
|
annotation,
|
|
shape_cache=shape_cache,
|
|
placement_override=placement_override,
|
|
)
|
|
if resolved_pts:
|
|
_update_blender_curve(annotation, resolved_pts)
|
|
|
|
|
|
|
|
class AddElevationAnnotation(SetDimensionAnchor):
|
|
"""Interactively place a SECTION_LEVEL or PLAN_LEVEL annotation.
|
|
|
|
Runs the same face / layer / edge / vertex snap UI as SetDimensionAnchor but
|
|
creates the annotation only AFTER the user clicks, placing it directly at the
|
|
picked elevation. The anchor is stored in BBIM_Dimension so the annotation
|
|
tracks the element if it moves.
|
|
"""
|
|
|
|
bl_idname = "bim.add_elevation_annotation"
|
|
bl_label = "Add Elevation Annotation"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
return False
|
|
if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera):
|
|
cls.poll_message_set("No active drawing.")
|
|
return False
|
|
if context.mode != "OBJECT":
|
|
cls.poll_message_set("Must be in Object Mode.")
|
|
return False
|
|
if not getattr(context, "space_data", None) or context.space_data.type != "VIEW_3D":
|
|
return False
|
|
ann_props = tool.Drawing.get_annotation_props()
|
|
return ann_props.object_type in ("SECTION_LEVEL", "PLAN_LEVEL")
|
|
|
|
def _invoke(self, context, event):
|
|
props = tool.Drawing.get_annotation_props()
|
|
dprops = tool.Drawing.get_document_props()
|
|
self._create_drawing = dprops.get_active_drawing()
|
|
if not self._create_drawing:
|
|
self.report({"WARNING"}, "No active drawing.")
|
|
return {"CANCELLED"}
|
|
self._create_object_type = props.object_type
|
|
self._create_relating_type = (
|
|
tool.Ifc.get().by_id(int(props.relating_type_id))
|
|
if props.relating_type_id and props.relating_type_id != "0"
|
|
else None
|
|
)
|
|
# No existing annotation — we create it on the click.
|
|
self._annotation = None
|
|
self._annotation_obj = None
|
|
self._shape_cache = {}
|
|
self._phase = "PICK_FACE"
|
|
self._active_vertex_idx = 0
|
|
self._hover_candidates = []
|
|
self._hover_index = 0
|
|
self._hover_last_px = (-9999, -9999)
|
|
self._hover_highlighted_obj = None
|
|
self._snap_mode = "FACE"
|
|
_anchor_hover_draw_data.clear()
|
|
self._draw_handler = bpy.types.SpaceView3D.draw_handler_add(
|
|
_draw_anchor_hover_global, (), "WINDOW", "POST_PIXEL"
|
|
)
|
|
self._region, self._rv3d = None, None
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
for region in area.regions:
|
|
if region.type == "WINDOW":
|
|
self._region = region
|
|
break
|
|
if area.spaces and area.spaces[0].type == "VIEW_3D":
|
|
self._rv3d = area.spaces[0].region_3d
|
|
break
|
|
context.workspace.status_text_set(
|
|
f"Hover over an element | Click: place {self._create_object_type}"
|
|
" | TAB: cycle snap | RMB/ESC: Cancel"
|
|
)
|
|
context.window_manager.modal_handler_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def _modal(self, context, event):
|
|
if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"):
|
|
self._cleanup(context)
|
|
return {"FINISHED"}
|
|
|
|
if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE":
|
|
self._handle_hover(context, event)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE":
|
|
self._cycle_hover(context)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
|
return {"RUNNING_MODAL"}
|
|
|
|
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
|
wrote = self._handle_face_pick_create(context, event)
|
|
if wrote:
|
|
self._cleanup(context)
|
|
return {"FINISHED"}
|
|
return {"RUNNING_MODAL"}
|
|
|
|
return {"PASS_THROUGH"}
|
|
|
|
def _handle_face_pick_create(self, context, event):
|
|
"""Pick a face, create the elevation annotation there, write the anchor."""
|
|
region = self._region
|
|
rv3d = self._rv3d
|
|
if not region or not rv3d:
|
|
return False
|
|
|
|
self._clear_hover_highlight(context)
|
|
|
|
coord = (event.mouse_x - region.x, event.mouse_y - region.y)
|
|
dx = coord[0] - self._hover_last_px[0]
|
|
dy = coord[1] - self._hover_last_px[1]
|
|
if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates:
|
|
self._hover_candidates = self._compute_candidates(context, coord)
|
|
self._hover_index = 0
|
|
|
|
if not self._hover_candidates:
|
|
self.report({"WARNING"}, "Nothing under cursor — click on a model element")
|
|
return False
|
|
|
|
idx = min(self._hover_index, len(self._hover_candidates) - 1)
|
|
hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx]
|
|
|
|
element = tool.Ifc.get_entity(hit_obj)
|
|
if not element:
|
|
self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element")
|
|
return False
|
|
|
|
file = tool.Ifc.get()
|
|
placement_override = {element.id(): np.array(hit_obj.matrix_world)}
|
|
snap = self._compute_snap_geom(hit_obj, face_index, coord)
|
|
snap_type = snap.get("type", "FACE")
|
|
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
try:
|
|
if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY":
|
|
anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap)
|
|
elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None:
|
|
anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap)
|
|
elif snap_type == "EDGE" and snap.get("profile_x_m") is not None:
|
|
anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap)
|
|
elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None:
|
|
sw = snap["snap_world"]
|
|
local_m = snap.get("local_m")
|
|
if local_m is not None:
|
|
anchor = drawing_api.build_anchor_from_local_point(element, snap_type, sw, local_m)
|
|
else:
|
|
anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])])
|
|
else:
|
|
hit_m = (float(location.x), float(location.y), float(location.z))
|
|
normal_m = (float(normal.x), float(normal.y), float(normal.z))
|
|
anchor = drawing_api.build_anchor_from_hit(
|
|
file, element, hit_m, normal_m,
|
|
shape_cache=self._shape_cache,
|
|
placement_override=placement_override,
|
|
)
|
|
except Exception as exc:
|
|
import traceback as _tb
|
|
_tb.print_exc()
|
|
self.report({"ERROR"}, f"build_anchor failed: {exc}")
|
|
return False
|
|
|
|
obj = core.add_annotation(
|
|
tool.Ifc, tool.Collector, tool.Drawing,
|
|
drawing=self._create_drawing,
|
|
object_type=self._create_object_type,
|
|
relating_type=self._create_relating_type,
|
|
enable_editing=False,
|
|
)
|
|
if not obj:
|
|
return False
|
|
|
|
annotation = tool.Ifc.get_entity(obj)
|
|
self._annotation = annotation
|
|
self._annotation_obj = obj
|
|
_do_write_anchor(annotation, obj, anchor, 0, self._shape_cache)
|
|
|
|
# Move annotation to the face hit position (XY and Z).
|
|
# obj.matrix_world may be stale (depsgraph not yet evaluated), so we
|
|
# override the translation column directly from the known hit point.
|
|
from mathutils import Vector as _mVector
|
|
hit_pt = anchor.get("pt")
|
|
target_z = float(hit_pt[2]) if hit_pt else float(location.z)
|
|
hit_world = _mVector((float(location.x), float(location.y), target_z))
|
|
m = obj.matrix_world.copy()
|
|
m.translation = hit_world
|
|
ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m))
|
|
if obj.parent:
|
|
obj.location = obj.parent.matrix_world.inverted() @ hit_world
|
|
else:
|
|
obj.location = hit_world
|
|
|
|
# Spline points were built from camera-space coords and may have a large
|
|
# local Z offset. Zero it so the line sits at the same world elevation
|
|
# as the object origin (where the gizmo is drawn).
|
|
_zero_elevation_annotation_spline_z(annotation, obj)
|
|
|
|
tool.Blender.select_and_activate_single_object(context, obj)
|
|
self.report(
|
|
{"INFO"},
|
|
f"Vertex 0 → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]",
|
|
)
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
pass # This operator is modal-only; always invoke with INVOKE_DEFAULT.
|
|
|
|
|
|
class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator):
|
|
"""Regenerate all parametric dimension annotations in the project.
|
|
|
|
For every IfcAnnotation that has a BBIM_Dimension pset, resolve all
|
|
anchor references from live element geometry and update the annotation's
|
|
curve vertices and linked IfcMetric values.
|
|
"""
|
|
|
|
bl_idname = "bim.regenerate_dimensions"
|
|
bl_label = "Regenerate Dimensions"
|
|
bl_description = (
|
|
"Recompute all parametric dimension annotations from current element geometry.\n"
|
|
"Updates curve vertex positions and IfcMetric segment values."
|
|
)
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
active_only: bpy.props.BoolProperty(
|
|
name="Active Only",
|
|
description="Only regenerate the currently selected dimension annotation",
|
|
default=False,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
active_only: bool
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return bool(tool.Ifc.get())
|
|
|
|
def _execute(self, context):
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
import ifcopenshell.geom
|
|
|
|
file = tool.Ifc.get()
|
|
|
|
geom_settings = ifcopenshell.geom.settings()
|
|
geom_settings.set("APPLY_DEFAULT_MATERIALS", False)
|
|
shape_cache: dict = {}
|
|
|
|
if self.active_only:
|
|
obj = context.active_object
|
|
if not obj:
|
|
self.report({"WARNING"}, "No active object.")
|
|
return
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
self.report({"WARNING"}, "Active object is not an IfcAnnotation.")
|
|
return
|
|
candidates = [element]
|
|
else:
|
|
candidates = [
|
|
a for a in file.by_type("IfcAnnotation")
|
|
if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension")
|
|
]
|
|
|
|
from bonsai.bim.module.drawing.handler import _sync_dimension_anchors_to_curve
|
|
|
|
updated = 0
|
|
for annotation in candidates:
|
|
pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
if not pset:
|
|
continue
|
|
|
|
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
|
|
_is_elevation = ptype in ("SECTION_LEVEL", "PLAN_LEVEL")
|
|
|
|
# Sync anchor count to curve vertex count for true dimensions only.
|
|
# Elevation markers always have exactly one anchor regardless of vertex count.
|
|
ann_obj = tool.Ifc.get_object(annotation)
|
|
if not _is_elevation and ann_obj and ann_obj.type == "CURVE":
|
|
_sync_dimension_anchors_to_curve(file, annotation, ann_obj)
|
|
|
|
# Build a placement override from each referenced element's current
|
|
# Blender matrix_world. Bonsai only syncs ObjectPlacement to the IFC
|
|
# file when the user explicitly clicks "Edit Object Placement" — so the
|
|
# IFC entity may be stale after a viewport G-move. Using matrix_world
|
|
# ensures we always see the current element position.
|
|
placement_override: dict[int, "np.ndarray"] = {}
|
|
try:
|
|
anchors_raw = json.loads(pset.get("Anchors") or "[]")
|
|
for anchor in anchors_raw:
|
|
guid = anchor.get("guid")
|
|
if not guid:
|
|
continue
|
|
try:
|
|
elem = file.by_guid(guid)
|
|
elem_id = elem.id()
|
|
if elem_id in placement_override:
|
|
continue
|
|
elem_obj = tool.Ifc.get_object(elem)
|
|
if elem_obj:
|
|
placement_override[elem_id] = np.array(elem_obj.matrix_world)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
if _is_elevation:
|
|
if _update_elevation_marker_z(
|
|
file, annotation,
|
|
settings=geom_settings,
|
|
shape_cache=shape_cache,
|
|
placement_override=placement_override,
|
|
):
|
|
updated += 1
|
|
else:
|
|
resolved_pts = drawing_api.regenerate_dimension(
|
|
file, annotation,
|
|
settings=geom_settings,
|
|
shape_cache=shape_cache,
|
|
placement_override=placement_override,
|
|
)
|
|
if not resolved_pts:
|
|
continue
|
|
_update_blender_curve(annotation, resolved_pts)
|
|
updated += 1
|
|
|
|
self.report({"INFO"}, f"Regenerated {updated} parametric annotation(s).")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for dimension operators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _anchors_from_spline(obj: bpy.types.Object, file: ifcopenshell.file) -> list:
|
|
"""Build a list of WORLD anchors from the current spline points of obj.
|
|
|
|
Coordinates are stored in metres (Blender world space), which matches the
|
|
output of ifcopenshell.geom.create_shape regardless of IFC project unit.
|
|
"""
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
|
|
anchors = []
|
|
if not obj or not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
|
|
return anchors
|
|
|
|
for pt in obj.data.splines[0].points:
|
|
world_co = obj.matrix_world @ pt.co.xyz
|
|
# Store in metres (Blender world space)
|
|
pt_m = [float(world_co.x), float(world_co.y), float(world_co.z)]
|
|
anchors.append(drawing_api.make_world_anchor(pt_m))
|
|
|
|
return anchors
|
|
|
|
|
|
def _update_blender_curve(
|
|
annotation: ifcopenshell.entity_instance,
|
|
resolved_pts_m: list,
|
|
) -> None:
|
|
"""Update a Blender curve object's spline points AND the backing IFC IfcPolyline.
|
|
|
|
:param resolved_pts_m: Points in metres (Blender world space).
|
|
|
|
Both the Blender curve data and the IFC representation are updated so that
|
|
entering Edit Mode (which reloads geometry from IFC via import_representation_items)
|
|
does not reset the curve back to pre-regeneration positions.
|
|
"""
|
|
obj = tool.Ifc.get_object(annotation)
|
|
if not obj or not obj.data or not hasattr(obj.data, "splines"):
|
|
return
|
|
|
|
curve_data: bpy.types.Curve = obj.data
|
|
inv_world = obj.matrix_world.inverted()
|
|
n = len(resolved_pts_m)
|
|
|
|
if not curve_data.splines:
|
|
spline = curve_data.splines.new("POLY")
|
|
spline.points.add(n - 1)
|
|
else:
|
|
spline = curve_data.splines[0]
|
|
if len(spline.points) != n:
|
|
curve_data.splines.remove(spline)
|
|
spline = curve_data.splines.new("POLY")
|
|
spline.points.add(n - 1)
|
|
|
|
is_2d = _annotation_is_2d(annotation)
|
|
for i, pt_m in enumerate(resolved_pts_m):
|
|
blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
|
|
local_pt = inv_world @ blender_world
|
|
# For 2D (plan-view) annotations, project onto the annotation plane by
|
|
# zeroing local Z — matching the Annotator.add_line_to_annotation pattern.
|
|
if is_2d:
|
|
spline.points[i].co = (local_pt.x, local_pt.y, 0.0, 1.0)
|
|
else:
|
|
spline.points[i].co = (*local_pt, 1.0)
|
|
|
|
# Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions.
|
|
try:
|
|
_update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m)
|
|
except Exception as e:
|
|
pass
|
|
|
|
|
|
def _update_elevation_marker_z(
|
|
file: "ifcopenshell.file",
|
|
annotation: "ifcopenshell.entity_instance",
|
|
settings=None,
|
|
shape_cache: Optional[dict] = None,
|
|
placement_override: Optional[dict] = None,
|
|
) -> bool:
|
|
"""For SECTION_LEVEL / PLAN_LEVEL: resolve anchor 0 elevation and slide all spline vertices to that Z.
|
|
|
|
Unlike regenerate_dimension (which rebuilds the full polyline from N anchors),
|
|
this preserves every vertex's X/Y and only updates Z, keeping the annotation's
|
|
drawn shape intact while tracking the anchored element's elevation.
|
|
Returns True if the spline was updated.
|
|
"""
|
|
from ifcopenshell.api.drawing.resolve_anchor import resolve_anchor as _resolve_anchor
|
|
import ifcopenshell.api.pset as _pset_api
|
|
import json as _json
|
|
|
|
pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension")
|
|
if not pset_data or not pset_data.get("Anchors"):
|
|
return False
|
|
|
|
try:
|
|
anchors: list = _json.loads(pset_data["Anchors"])
|
|
except Exception:
|
|
return False
|
|
|
|
if not anchors:
|
|
return False
|
|
|
|
pt = _resolve_anchor(file, anchors[0], settings, shape_cache or {}, placement_override)
|
|
if pt is None:
|
|
cached = anchors[0].get("pt")
|
|
pt = tuple(cached) if cached else None
|
|
if pt is None:
|
|
return False
|
|
|
|
target_z = pt[2]
|
|
|
|
# Persist the resolved pt so the index stays warm
|
|
anchors[0]["pt"] = list(pt)
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
_pset_api.edit_pset(file, pset=pset_entity, properties={"Anchors": _json.dumps(anchors)})
|
|
|
|
obj = tool.Ifc.get_object(annotation)
|
|
if not obj:
|
|
return False
|
|
|
|
is_2d = _annotation_is_2d(annotation)
|
|
if is_2d:
|
|
# 2D annotations encode elevation in the object placement (world Z), not curve points.
|
|
from mathutils import Vector as _Vector
|
|
current_world = obj.matrix_world.to_translation()
|
|
new_world = _Vector((current_world.x, current_world.y, target_z))
|
|
# Build updated world matrix (replace Z translation only, preserve rotation/scale)
|
|
m = obj.matrix_world.copy()
|
|
m[2][3] = target_z
|
|
ifcopenshell.api.run("geometry.edit_object_placement", file, product=annotation, matrix=np.array(m))
|
|
# Update Blender visual position
|
|
if obj.parent:
|
|
obj.location = obj.parent.matrix_world.inverted() @ new_world
|
|
else:
|
|
obj.location = new_world
|
|
return True
|
|
|
|
if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines:
|
|
return True
|
|
|
|
from mathutils import Vector as _Vector
|
|
mx = obj.matrix_world
|
|
inv_mx = mx.inverted()
|
|
|
|
for spline in obj.data.splines:
|
|
for spt in spline.points:
|
|
world_pos = mx @ spt.co.to_3d()
|
|
new_local = inv_mx @ _Vector((world_pos.x, world_pos.y, target_z))
|
|
spt.co = (*new_local, 1.0)
|
|
|
|
try:
|
|
world_pts = []
|
|
for spline in obj.data.splines:
|
|
for spt in spline.points:
|
|
wp = mx @ spt.co.to_3d()
|
|
world_pts.append((wp.x, wp.y, wp.z))
|
|
_update_ifc_polyline(file, annotation, obj, world_pts)
|
|
except Exception:
|
|
pass
|
|
|
|
return True
|
|
|
|
|
|
def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool:
|
|
"""Return True when elevation is encoded in the object placement (not spline Z values).
|
|
|
|
PLAN_LEVEL and SECTION_LEVEL always encode elevation in object placement regardless
|
|
of whether the curve representation happens to use 2D or 3D coordinates.
|
|
For other annotation types we fall back to inspecting the geometry.
|
|
"""
|
|
ptype = ifcopenshell.util.element.get_predefined_type(annotation)
|
|
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
|
|
return True
|
|
if not getattr(annotation, "Representation", None):
|
|
return False
|
|
for rep in annotation.Representation.Representations:
|
|
curve = _find_curve_item(rep)
|
|
if curve is None:
|
|
continue
|
|
if curve.is_a("IfcIndexedPolyCurve"):
|
|
return curve.Points.is_a("IfcCartesianPointList2D")
|
|
if curve.is_a("IfcPolyline") and curve.Points:
|
|
return len(curve.Points[0].Coordinates) == 2
|
|
return False
|
|
|
|
|
|
def _update_ifc_polyline(
|
|
file: ifcopenshell.file,
|
|
annotation: ifcopenshell.entity_instance,
|
|
obj: bpy.types.Object,
|
|
resolved_pts_m: list,
|
|
) -> None:
|
|
"""Update the curve coordinates in the annotation's IFC representation.
|
|
|
|
Converts world-space metres points → annotation-local IFC project units and
|
|
writes them into the existing IfcIndexedPolyCurve or IfcPolyline entities.
|
|
Handles both 2D (IfcCartesianPointList2D) and 3D representations.
|
|
"""
|
|
if not resolved_pts_m or not getattr(annotation, "Representation", None):
|
|
return
|
|
|
|
import ifcopenshell.util.unit as ifc_unit
|
|
|
|
unit_scale = ifc_unit.calculate_unit_scale(file)
|
|
inv_world = obj.matrix_world.inverted()
|
|
|
|
def _to_ifc_local(pt_m: tuple) -> tuple:
|
|
blender_local = inv_world @ Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2])))
|
|
return (
|
|
float(blender_local.x) / unit_scale,
|
|
float(blender_local.y) / unit_scale,
|
|
float(blender_local.z) / unit_scale,
|
|
)
|
|
|
|
new_coords = [_to_ifc_local(pt) for pt in resolved_pts_m]
|
|
|
|
for rep in annotation.Representation.Representations:
|
|
curve = _find_curve_item(rep)
|
|
if curve is None:
|
|
continue
|
|
|
|
if curve.is_a("IfcIndexedPolyCurve"):
|
|
pts_list = curve.Points # IfcCartesianPointList2D or 3D
|
|
n_dims = 2 if pts_list.is_a("IfcCartesianPointList2D") else 3
|
|
pts_list.CoordList = tuple(coords[:n_dims] for coords in new_coords)
|
|
# Rebuild Segments to cover all consecutive pairs. Bonsai creates
|
|
# explicit IfcLineIndex entries per segment; leaving a stale Segments
|
|
# list (e.g. [IfcLineIndex([1,2])]) after adding a 3rd point means
|
|
# the extra point is silently ignored on geometry reload.
|
|
n_pts = len(new_coords)
|
|
if n_pts >= 2:
|
|
curve.Segments = [file.createIfcLineIndex([i + 1, i + 2]) for i in range(n_pts - 1)]
|
|
else:
|
|
curve.Segments = None
|
|
return
|
|
|
|
if curve.is_a("IfcPolyline"):
|
|
existing = list(curve.Points)
|
|
if len(existing) == len(new_coords):
|
|
for ifc_pt, coords in zip(existing, new_coords):
|
|
n_dims = len(ifc_pt.Coordinates)
|
|
ifc_pt.Coordinates = coords[:n_dims]
|
|
else:
|
|
dim = len(existing[0].Coordinates) if existing else 3
|
|
curve.Points = [
|
|
file.create_entity("IfcCartesianPoint", Coordinates=coords[:dim])
|
|
for coords in new_coords
|
|
]
|
|
return
|
|
|
|
|
|
def _zero_elevation_annotation_spline_z(annotation: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
|
"""Flatten all spline-point local Z to 0 for PLAN_LEVEL / SECTION_LEVEL annotations.
|
|
|
|
For elevation annotations the elevation is encoded in the object placement (world Z).
|
|
Spline points are created from camera-space coords and may carry a large local Z
|
|
offset. Zeroing it ensures the visible line and the gizmo sit at the same elevation.
|
|
"""
|
|
if not obj.data or not hasattr(obj.data, "splines"):
|
|
return
|
|
changed = False
|
|
for spline in obj.data.splines:
|
|
for pt in spline.points:
|
|
if pt.co.z != 0.0:
|
|
pt.co = (pt.co.x, pt.co.y, 0.0, pt.co.w)
|
|
changed = True
|
|
if not changed or not getattr(annotation, "Representation", None):
|
|
return
|
|
for rep in annotation.Representation.Representations:
|
|
curve = _find_curve_item(rep)
|
|
if curve is None:
|
|
continue
|
|
if curve.is_a("IfcIndexedPolyCurve"):
|
|
pts_list = curve.Points
|
|
if not pts_list.is_a("IfcCartesianPointList2D"):
|
|
pts_list.CoordList = tuple((c[0], c[1], 0.0) for c in pts_list.CoordList)
|
|
elif curve.is_a("IfcPolyline") and curve.Points:
|
|
for ifc_pt in curve.Points:
|
|
if len(ifc_pt.Coordinates) >= 3:
|
|
ifc_pt.Coordinates = (ifc_pt.Coordinates[0], ifc_pt.Coordinates[1], 0.0)
|
|
|
|
|
|
def _find_curve_item(rep: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
|
|
"""Return the first IfcPolyline or IfcIndexedPolyCurve in a shape representation."""
|
|
for item in rep.Items:
|
|
result = _find_curve_in_item(item)
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
|
|
def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]:
|
|
if item.is_a("IfcPolyline") or item.is_a("IfcIndexedPolyCurve"):
|
|
return item
|
|
if item.is_a("IfcGeometricCurveSet"):
|
|
for element in item.Elements:
|
|
result = _find_curve_in_item(element)
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
|
|
|
|
|
|
class ClickNearestDimensionAnchor(bpy.types.Operator):
|
|
"""Click handler for dimension anchor dots.
|
|
|
|
When the active dimension's anchor dots are visible, clicking within
|
|
RADIUS_PX of a dot activates SetDimensionAnchor for that dot.
|
|
|
|
Implemented as a two-event modal (PRESS consumed in invoke, RELEASE
|
|
consumed in modal) so SetDimensionAnchor starts on a clean slate —
|
|
no stray LMB events reach it that could trigger view3d.select.
|
|
"""
|
|
|
|
bl_idname = "bim.click_nearest_dimension_anchor"
|
|
bl_label = "Click Nearest Dimension Anchor"
|
|
|
|
RADIUS_PX = 15
|
|
|
|
def invoke(self, context, event):
|
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
|
|
|
if not tool.Ifc.get():
|
|
return {"PASS_THROUGH"}
|
|
|
|
region = None
|
|
rv3d = None
|
|
for area in context.screen.areas:
|
|
if area.type != "VIEW_3D":
|
|
continue
|
|
for r in area.regions:
|
|
if r.type == "WINDOW":
|
|
region = r
|
|
break
|
|
if region:
|
|
for space in area.spaces:
|
|
if space.type == "VIEW_3D":
|
|
rv3d = space.region_3d
|
|
break
|
|
break
|
|
|
|
if not region or not rv3d:
|
|
return {"PASS_THROUGH"}
|
|
|
|
cx = event.mouse_x - region.x
|
|
cy = event.mouse_y - region.y
|
|
|
|
import ifcopenshell.util.element as _ue
|
|
|
|
r2 = self.RADIUS_PX ** 2
|
|
best_obj = None
|
|
best_idx = -1
|
|
best_dist_sq = float("inf")
|
|
|
|
for obj in context.scene.objects:
|
|
if obj.type != "CURVE":
|
|
continue
|
|
if not obj.visible_get():
|
|
continue
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
continue
|
|
pset = _ue.get_pset(element, "BBIM_Dimension")
|
|
if not pset or not pset.get("Anchors"):
|
|
continue
|
|
if not obj.data.splines:
|
|
continue
|
|
|
|
ptype = _ue.get_predefined_type(element)
|
|
if ptype in ("SECTION_LEVEL", "PLAN_LEVEL"):
|
|
# Gizmo is drawn at the object origin for elevation annotations.
|
|
world_pos = obj.matrix_world.translation.copy()
|
|
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
|
if sp:
|
|
dx, dy = cx - sp.x, cy - sp.y
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < r2 and d2 < best_dist_sq:
|
|
best_dist_sq = d2
|
|
best_idx = 0
|
|
best_obj = obj
|
|
else:
|
|
for i, pt in enumerate(obj.data.splines[0].points):
|
|
world_pos = obj.matrix_world @ pt.co.to_3d()
|
|
sp = location_3d_to_region_2d(region, rv3d, world_pos)
|
|
if not sp:
|
|
continue
|
|
dx, dy = cx - sp.x, cy - sp.y
|
|
d2 = dx * dx + dy * dy
|
|
if d2 < r2 and d2 < best_dist_sq:
|
|
best_dist_sq = d2
|
|
best_idx = i
|
|
best_obj = obj
|
|
|
|
if best_obj is None:
|
|
return {"PASS_THROUGH"}
|
|
|
|
for o in list(context.selected_objects):
|
|
o.select_set(False)
|
|
best_obj.select_set(True)
|
|
context.view_layer.objects.active = best_obj
|
|
|
|
# Store dot for the modal phase and go modal to consume the PRESS.
|
|
# The modal will also consume the RELEASE before handing off to
|
|
# SetDimensionAnchor, so view3d.select never sees either event.
|
|
self._best_obj = best_obj
|
|
self._best_idx = best_idx
|
|
context.window_manager.modal_handler_add(self)
|
|
return {"RUNNING_MODAL"}
|
|
|
|
def modal(self, context, event):
|
|
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
|
# Both PRESS and RELEASE are now consumed. Start the anchor editor.
|
|
from bonsai.bim.module.drawing.gizmos import set_active_anchor
|
|
set_active_anchor(self._best_idx, self._best_obj)
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
break
|
|
bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=self._best_idx)
|
|
return {"FINISHED"}
|
|
|
|
if event.type in ("ESC", "RIGHTMOUSE"):
|
|
return {"CANCELLED"}
|
|
|
|
# Consume intermediate LMB events; pass everything else through so
|
|
# viewport navigation (MMB, scroll) still works during the brief wait.
|
|
if event.type == "LEFTMOUSE":
|
|
return {"RUNNING_MODAL"}
|
|
return {"PASS_THROUGH"}
|
|
|
|
|
|
class MakeDimensionParametric(bpy.types.Operator, tool.Ifc.Operator):
|
|
"""Convert a dumb (static) dimension to a parametric dimension with free ends.
|
|
|
|
Creates a BBIM_Dimension pset on the active annotation with one world-point
|
|
anchor per spline vertex, matching the current curve positions exactly.
|
|
The endpoints can then be re-anchored to IFC faces via SetDimensionAnchor.
|
|
"""
|
|
|
|
bl_idname = "bim.make_dimension_parametric"
|
|
bl_label = "Make Dimension Parametric"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
_DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE"))
|
|
_ELEVATION_TYPES = frozenset(("SECTION_LEVEL", "PLAN_LEVEL"))
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC file loaded.")
|
|
return False
|
|
obj = context.active_object
|
|
if not obj or obj.type != "CURVE":
|
|
cls.poll_message_set("Active object must be an annotation curve.")
|
|
return False
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
cls.poll_message_set("Active object must be an IfcAnnotation.")
|
|
return False
|
|
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
|
if ptype not in cls._DIM_TYPES and ptype not in cls._ELEVATION_TYPES:
|
|
cls.poll_message_set("Annotation must be a dimension or elevation type.")
|
|
return False
|
|
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
|
if pset and pset.get("Anchors"):
|
|
cls.poll_message_set("Annotation is already parametric.")
|
|
return False
|
|
if ptype in cls._DIM_TYPES and (not obj.data.splines or len(obj.data.splines[0].points) < 2):
|
|
cls.poll_message_set("Curve has fewer than 2 points.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
import ifcopenshell.api.drawing as drawing_api
|
|
|
|
obj = context.active_object
|
|
element = tool.Ifc.get_entity(obj)
|
|
file = tool.Ifc.get()
|
|
ptype = ifcopenshell.util.element.get_predefined_type(element)
|
|
|
|
if ptype in self._ELEVATION_TYPES:
|
|
# One free-world anchor at the object origin (the current elevation).
|
|
world_pos = list(obj.matrix_world.translation)
|
|
anchors = [drawing_api.make_world_anchor(world_pos)]
|
|
else:
|
|
spline = obj.data.splines[0]
|
|
anchors = [
|
|
drawing_api.make_world_anchor(list(obj.matrix_world @ pt.co.to_3d()))
|
|
for pt in spline.points
|
|
]
|
|
|
|
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
|
if not pset_data:
|
|
ifcopenshell.api.run("pset.add_pset", file, product=element, name="BBIM_Dimension")
|
|
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": json.dumps(anchors)})
|
|
|
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
|
_drawing_handler.invalidate_dim_index()
|
|
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
break
|
|
|
|
self.report({"INFO"}, f"'{obj.name}' is now parametric with {len(anchors)} free-end anchor(s).")
|
|
|
|
|
|
class BakeParametricDimension(bpy.types.Operator, tool.Ifc.Operator):
|
|
"""Convert a parametric dimension to a dumb (static) dimension.
|
|
|
|
Removes the BBIM_Dimension pset from the active annotation so the dimension
|
|
curve is no longer regenerated when referenced elements move. The current
|
|
curve geometry is preserved exactly as-is.
|
|
"""
|
|
|
|
bl_idname = "bim.bake_parametric_dimension"
|
|
bl_label = "Bake Parametric Dimension"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
if not tool.Ifc.get():
|
|
cls.poll_message_set("No IFC file loaded.")
|
|
return False
|
|
obj = context.active_object
|
|
if not obj:
|
|
cls.poll_message_set("No active object.")
|
|
return False
|
|
element = tool.Ifc.get_entity(obj)
|
|
if not element or not element.is_a("IfcAnnotation"):
|
|
cls.poll_message_set("Active object must be an IfcAnnotation.")
|
|
return False
|
|
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
|
if not pset or not pset.get("Anchors"):
|
|
cls.poll_message_set("Annotation has no parametric dimension data.")
|
|
return False
|
|
return True
|
|
|
|
def _execute(self, context):
|
|
obj = context.active_object
|
|
element = tool.Ifc.get_entity(obj)
|
|
file = tool.Ifc.get()
|
|
|
|
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension")
|
|
pset_entity = file.by_id(pset_data["id"])
|
|
ifcopenshell.api.pset.remove_pset(file, product=element, pset=pset_entity)
|
|
|
|
from bonsai.bim.module.drawing import handler as _drawing_handler
|
|
_drawing_handler.invalidate_dim_index()
|
|
|
|
for area in context.screen.areas:
|
|
if area.type == "VIEW_3D":
|
|
area.tag_redraw()
|
|
break
|
|
|
|
self.report({"INFO"}, f"'{obj.name}' converted to a static dimension.")
|
|
|
|
|
|
class DebugDimensionClicks(bpy.types.Operator):
|
|
"""Debug modal: logs every LMB click in the 3D viewport vs anchor gizmo positions.
|
|
|
|
Run from the Python console:
|
|
bpy.ops.bim.debug_dimension_clicks('INVOKE_DEFAULT')
|
|
Press ESC or RMB to stop.
|
|
"""
|
|
|
|
bl_idname = "bim.debug_dimension_clicks"
|
|
bl_label = "Debug Dimension Clicks"
|
|
|
|
def modal(self, context, event):
|
|
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
|
# Use absolute window coords — mouse_region_x/y is relative to whichever
|
|
# region caught the event, which may differ from the 3D viewport region.
|
|
click_x = event.mouse_x
|
|
click_y = event.mouse_y
|
|
|
|
# Find the 3D viewport region — context.region_data is None in modal.
|
|
region = None
|
|
rv3d = None
|
|
for area in context.screen.areas:
|
|
if area.type != "VIEW_3D":
|
|
continue
|
|
for r in area.regions:
|
|
if r.type == "WINDOW":
|
|
region = r
|
|
break
|
|
if region:
|
|
for space in area.spaces:
|
|
if space.type == "VIEW_3D":
|
|
rv3d = space.region_3d
|
|
break
|
|
break
|
|
|
|
obj = context.active_object
|
|
if obj and obj.type == "CURVE" and obj.data and obj.data.splines and region and rv3d:
|
|
from bpy_extras.view3d_utils import location_3d_to_region_2d
|
|
|
|
print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) region_offset=({region.x},{region.y})")
|
|
for i, pt in enumerate(obj.data.splines[0].points):
|
|
world_pos = obj.matrix_world @ pt.co.to_3d()
|
|
screen_pos = location_3d_to_region_2d(region, rv3d, world_pos)
|
|
if screen_pos:
|
|
# Convert region-local pos to absolute window coords for comparison.
|
|
abs_x = screen_pos.x + region.x
|
|
abs_y = screen_pos.y + region.y
|
|
dist = ((click_x - abs_x) ** 2 + (click_y - abs_y) ** 2) ** 0.5
|
|
print(f" anchor[{i}] abs={abs_x:.0f},{abs_y:.0f} dist={dist:.1f}px")
|
|
else:
|
|
print(f" anchor[{i}] (off screen)")
|
|
else:
|
|
print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) — no active curve or no 3D region")
|
|
return {"PASS_THROUGH"}
|
|
|
|
if event.type in {"ESC", "RIGHTMOUSE"}:
|
|
print("[ClickDebug] Stopped.")
|
|
return {"CANCELLED"}
|
|
|
|
return {"PASS_THROUGH"}
|
|
|
|
def invoke(self, context, event):
|
|
context.window_manager.modal_handler_add(self)
|
|
print("[ClickDebug] Dimension click logger started. Click near anchor dots; press ESC to stop.")
|
|
return {"RUNNING_MODAL"}
|