From cc73143ac111d38ef769cf6b96369e72050015a4 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 21 May 2025 14:36:32 +0500 Subject: [PATCH] clash - typing fixes and some refactor --- .../bonsai/bim/module/clash/operator.py | 49 ++++++++++++++----- src/bonsai/bonsai/bim/module/clash/prop.py | 26 ++++++++-- src/bonsai/bonsai/bim/module/clash/ui.py | 42 ++++++++++++++-- src/bonsai/bonsai/tool/clash.py | 22 ++++++--- src/ifcclash/ifcclash/ifcclash.py | 7 +-- 5 files changed, 113 insertions(+), 33 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py index bac9dc22c3..4d8d9f43a6 100644 --- a/src/bonsai/bonsai/bim/module/clash/operator.py +++ b/src/bonsai/bonsai/bim/module/clash/operator.py @@ -29,6 +29,7 @@ from math import radians from mathutils import Matrix, Vector from bonsai.bim.ifc import IfcStore from bonsai.bim.module.clash.decorator import ClashDecorator +from typing import TYPE_CHECKING class ExportClashSets(bpy.types.Operator, ExportHelper): @@ -127,7 +128,8 @@ class AddClashSource(bpy.types.Operator): def execute(self, context): props = tool.Clash.get_clash_props() clash_set = props.active_clash_set - source = getattr(clash_set, self.group).add() + assert clash_set + clash_set.get_clash_sources_group(self.group).add() return {"FINISHED"} @@ -142,7 +144,8 @@ class RemoveClashSource(bpy.types.Operator): def execute(self, context): props = tool.Clash.get_clash_props() clash_set = props.active_clash_set - getattr(clash_set, self.group).remove(self.index) + assert clash_set + clash_set.get_clash_sources_group(self.group).remove(self.index) return {"FINISHED"} @@ -159,7 +162,9 @@ class SelectClashSource(bpy.types.Operator, ImportHelper): def execute(self, context): props = tool.Clash.get_clash_props() clash_set = props.active_clash_set - getattr(clash_set, self.group)[self.index].name = self.filepath + assert clash_set + clash_source = clash_set.get_clash_sources_group(self.group)[self.index] + clash_source.name = self.filepath return {"FINISHED"} @@ -191,9 +196,21 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): bl_idname = "bim.execute_ifc_clash" bl_label = "Execute IFC Clash" bl_description = "Execute clash detection and save the information to a .bcf or .json file" - filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"}) - format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")]) - filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) + + filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + default="*.bcf;*.json", options={"HIDDEN"} + ) + format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name="Format", items=[(i, i, "") for i in ("bcf", "json")] + ) + filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + subtype="FILE_PATH", options={"SKIP_SAVE"} + ) + + if TYPE_CHECKING: + filter_glob: str + format: str + filepath: str @property def filename_ext(self) -> str: @@ -225,11 +242,14 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): if self.props.should_create_clash_snapshots: - def get_viewpoint_snapshot(viewpoint): + def get_viewpoint_snapshot(viewpoint) -> tuple[str, bytes]: + assert context.scene + camera = bpy.data.objects.get("IFC Clash Camera") if not camera: camera = bpy.data.objects.new("IFC Clash Camera", bpy.data.cameras.new("IFC Clash Camera")) context.scene.collection.objects.link(camera) + assert isinstance(camera.data, bpy.types.Camera) bcf_camera = viewpoint.visualization_info.perspective_camera p = bcf_camera.camera_view_point @@ -238,6 +258,7 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): y = bcf_camera.camera_up_vector y = Vector([y.x, y.y, y.z]) x = y.cross(z) + assert isinstance(x, Vector) mat = Matrix( [ @@ -251,9 +272,9 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): camera.matrix_world = mat context.scene.camera = camera camera.data.angle = radians(60) - area = next(area for area in context.screen.areas if area.type == "VIEW_3D") - area.spaces[0].region_3d.view_perspective = "CAMERA" - area.spaces[0].shading.show_xray = True + assert (space := tool.Blender.get_view3d_space()) and space.region_3d + space.region_3d.view_perspective = "CAMERA" + space.shading.show_xray = True context.scene.render.resolution_x = 480 context.scene.render.resolution_y = 270 context.scene.render.image_settings.file_format = "PNG" @@ -271,7 +292,7 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): if extension == ".json": tool.Clash.load_clash_sets(self.filepath) tool.Clash.import_active_clashes() - self.report({"INFO"}, "Finished IFC clash.") + self.report({"INFO"}, f"IFC Clash results are saved to '{Path(self.filepath).name}'.") return {"FINISHED"} @@ -348,8 +369,10 @@ class SelectClash(bpy.types.Operator): def execute(self, context): self.props = tool.Clash.get_clash_props() - clash_set = tool.Clash.get_clash_set(self.props.active_clash_set.name) - active_clash = self.props.active_clash + assert (active_clash := self.props.active_clash) + assert (active_clash_set := self.props.active_clash_set) + clash_set = tool.Clash.get_clash_set(active_clash_set.name) + assert clash_set clash = tool.Clash.get_clash(clash_set, active_clash.a_global_id, active_clash.b_global_id) if not clash: diff --git a/src/bonsai/bonsai/bim/module/clash/prop.py b/src/bonsai/bonsai/bim/module/clash/prop.py index 88b13a0e25..1eed5e5117 100644 --- a/src/bonsai/bonsai/bim/module/clash/prop.py +++ b/src/bonsai/bonsai/bim/module/clash/prop.py @@ -35,9 +35,12 @@ from typing import TYPE_CHECKING, Literal, Union class ClashSource(PropertyGroup): - name: StringProperty(name="File") - filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") - mode: EnumProperty( + name: StringProperty( # pyright: ignore[reportRedeclaration] + name="File", + description="Absolute filepath to existing .ifc file to use as a clash source.", + ) + filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration] + mode: EnumProperty( # pyright: ignore[reportRedeclaration] items=[ ("a", "All Elements", "All elements will be used for clashing"), ("i", "Include", "Only the selected elements are included for clashing"), @@ -47,6 +50,7 @@ class ClashSource(PropertyGroup): ) if TYPE_CHECKING: + name: str filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] mode: Literal["a", "i", "e"] @@ -56,7 +60,11 @@ class Clash(PropertyGroup): b_global_id: StringProperty(name="B") a_name: StringProperty(name="A Name") b_name: StringProperty(name="B Name") - status: BoolProperty(name="Status", default=False) + status: BoolProperty( + name="Status", + description="Clash status, not stored anywhere - currently just displayed in UI for convenience.", + default=False, + ) if TYPE_CHECKING: a_global_id: str @@ -99,6 +107,16 @@ class ClashSet(PropertyGroup): b: bpy.types.bpy_prop_collection_idprop[ClashSource] clashes: bpy.types.bpy_prop_collection_idprop[Clash] + def get_clash_sources_group( + self, group: tool.Clash.ClashSourceGroup + ) -> "bpy.types.bpy_prop_collection_idprop[ClashSource]": + return getattr(self, group) + + def get_clash_sources( + self, + ) -> "dict[tool.Clash.ClashSourceGroup, bpy.types.bpy_prop_collection_idprop[ClashSource]]": + return {g: self.get_clash_sources_group(g) for g in tool.Clash.CLASH_SOURCE_GROUP_LITERALS} + class SmartClashGroup(PropertyGroup): number: StringProperty(name="Number") diff --git a/src/bonsai/bonsai/bim/module/clash/ui.py b/src/bonsai/bonsai/bim/module/clash/ui.py index 3943f21f04..a13be4230e 100644 --- a/src/bonsai/bonsai/bim/module/clash/ui.py +++ b/src/bonsai/bonsai/bim/module/clash/ui.py @@ -16,11 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel from bonsai.bim.module.clash.data import ClashData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.clash.prop import BIMClashProperties, ClashSet, SmartClashGroup, Clash class BIM_PT_ifcclash(Panel): @@ -36,6 +41,7 @@ class BIM_PT_ifcclash(Panel): if not ClashData.is_loaded: ClashData.load() + assert self.layout layout = self.layout props = tool.Clash.get_clash_props() @@ -155,6 +161,7 @@ class BIM_PT_smart_clash_manager(Panel): bl_parent_id = "BIM_PT_clash_manager" def draw(self, context): + assert self.layout layout = self.layout props = tool.Clash.get_clash_props() @@ -188,8 +195,16 @@ class BIM_PT_smart_clash_manager(Panel): class BIM_UL_clash_sets(bpy.types.UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - ob = data + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMClashProperties, + item: ClashSet, + icon, + active_data, + active_propname, + ) -> None: if item: layout.prop(item, "name", text="", emboss=False) else: @@ -197,8 +212,16 @@ class BIM_UL_clash_sets(bpy.types.UIList): class BIM_UL_smart_groups(bpy.types.UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - ob = data + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMClashProperties, + item: SmartClashGroup, + icon, + active_data, + active_propname, + ) -> None: if item: layout.label(text=str(item.number), translate=False, icon="NONE", icon_value=0) else: @@ -206,7 +229,16 @@ class BIM_UL_smart_groups(bpy.types.UIList): class BIM_UL_clashes(bpy.types.UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMClashProperties, + item: Clash, + icon, + active_data, + active_propname, + ) -> None: if item: row = layout.row(align=True) row.label(text=str(item.a_name), translate=False, icon="NONE", icon_value=0) diff --git a/src/bonsai/bonsai/tool/clash.py b/src/bonsai/bonsai/tool/clash.py index 6a93d67322..a5ac49c3e4 100644 --- a/src/bonsai/bonsai/tool/clash.py +++ b/src/bonsai/bonsai/tool/clash.py @@ -26,7 +26,8 @@ import bonsai.tool as tool from contextlib import contextmanager from mathutils import Vector from ifcclash import ifcclash -from typing import TYPE_CHECKING, Union +from ifcclash.ifcclash import ClashSource +from typing import TYPE_CHECKING, Union, Literal, get_args if TYPE_CHECKING: from bonsai.bim.module.clash.prop import BIMClashProperties @@ -38,16 +39,19 @@ class Clash(bonsai.core.tool.Clash): def get_clash_props(cls) -> BIMClashProperties: return bpy.context.scene.BIMClashProperties + ClashSourceGroup = Literal["a", "b"] + CLASH_SOURCE_GROUP_LITERALS = ("a", "b") + @classmethod def export_clash_sets(cls) -> list[ifcclash.ClashSet]: clash_sets: list[ifcclash.ClashSet] = [] props = cls.get_clash_props() for clash_set in props.clash_sets: - a = [] - b = [] - for ab in ["a", "b"]: - for data in getattr(clash_set, ab): - clash_source = {"file": data.name} + a: list[ClashSource] = [] + b: list[ClashSource] = [] + for ab, ab_data in clash_set.get_clash_sources().items(): + for data in ab_data: + clash_source: ClashSource = {"file": data.name} query = tool.Search.export_filter_query(data.filter_groups) if query and data.mode != "a": clash_source["selector"] = query @@ -96,13 +100,15 @@ class Clash(bonsai.core.tool.Clash): clash_set.clashes.clear() result = tool.Clash.get_clash_set(clash_set.name) assert result is not None - for clash in sorted(result.get("clashes", {}).values(), key=lambda x: x["distance"]): + if "clashes" not in result: + return + for clash in sorted(result["clashes"].values(), key=lambda x: x["distance"]): blender_clash = clash_set.clashes.add() blender_clash.a_global_id = clash["a_global_id"] blender_clash.b_global_id = clash["b_global_id"] blender_clash.a_name = "{}/{}".format(clash["a_ifc_class"], clash["a_name"]) blender_clash.b_name = "{}/{}".format(clash["b_ifc_class"], clash["b_name"]) - blender_clash.status = False if not "status" in clash.keys() else clash["status"] + blender_clash.status = False if not "status" in clash else clash["status"] @classmethod def load_clash_sets(cls, fn: str) -> None: diff --git a/src/ifcclash/ifcclash/ifcclash.py b/src/ifcclash/ifcclash/ifcclash.py index e2e1a67d7f..04605a2806 100644 --- a/src/ifcclash/ifcclash/ifcclash.py +++ b/src/ifcclash/ifcclash/ifcclash.py @@ -28,7 +28,7 @@ import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.selector from logging import Logger -from typing import Literal, TypedDict +from typing import Literal, TypedDict, Union from typing_extensions import NotRequired @@ -206,6 +206,7 @@ class Clasher: start = time.time() def export(self) -> None: + """Save clash results to ``settings.output``.""" if len(self.settings.output) > 4 and self.settings.output[-4:] == ".bcf": return self.export_bcfxml() self.export_json() @@ -230,12 +231,12 @@ class Clasher: suffix = f".{i}" if i else "" bcfxml.save(f"{self.settings.output}{suffix}") - def get_viewpoint_snapshot(self, viewpoint) -> None: + def get_viewpoint_snapshot(self, viewpoint) -> Union[None, tuple[str, bytes]]: # Possible to overload this function in a GUI application if used as a library. - # Should return a tuple of (filename, bytes). return None def export_json(self) -> None: + """Saved clash results as ``list[ClashSet]``.""" clash_sets = self.clash_sets.copy() for clash_set in clash_sets: for source in clash_set["a"]: