diff --git a/src/blenderbim/blenderbim/bim/module/project/__init__.py b/src/blenderbim/blenderbim/bim/module/project/__init__.py index 044f7f644e..3d2b486cc8 100644 --- a/src/blenderbim/blenderbim/bim/module/project/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/project/__init__.py @@ -17,7 +17,7 @@ # along with BlenderBIM Add-on. If not, see . import bpy -from . import ui, prop, operator, workspace +from . import ui, prop, operator, workspace, gizmo classes = ( operator.AppendEntireLibrary, @@ -57,6 +57,8 @@ classes = ( operator.QueryLinkedElement, operator.EnableCulling, operator.DisableCulling, + operator.CreateClippingPlane, + operator.RefreshClippingPlanes, prop.LibraryElement, prop.FilterCategory, prop.Link, @@ -70,6 +72,7 @@ classes = ( ui.BIM_UL_library, ui.BIM_UL_filter_categories, ui.BIM_UL_links, + gizmo.ClippingPlane, ) diff --git a/src/blenderbim/blenderbim/bim/module/project/decorator.py b/src/blenderbim/blenderbim/bim/module/project/decorator.py index 1aa80d2645..916687a8da 100644 --- a/src/blenderbim/blenderbim/bim/module/project/decorator.py +++ b/src/blenderbim/blenderbim/bim/module/project/decorator.py @@ -86,3 +86,103 @@ class ProjectDecorator: if selected_edges: self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges) self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris) + + +class ClippingPlaneDecorator: + installed = None + + @classmethod + def install(cls, context): + if cls.installed: + cls.uninstall() + handler = cls() + cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW") + + @classmethod + def uninstall(cls): + try: + SpaceView3D.draw_handler_remove(cls.installed, "WINDOW") + except ValueError: + pass + cls.installed = None + + def draw_batch(self, shader_type, content_pos, color, indices=None): + shader = self.line_shader if shader_type == "LINES" else self.shader + batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + def __call__(self, context): + self.addon_prefs = context.preferences.addons["blenderbim"].preferences + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + def transparent_color(color, alpha=0.1): + color = [i for i in color] + color[3] = alpha + return color + + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + + self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() # required to be able to change uniforms of the shader + # POLYLINE_UNIFORM_COLOR specific uniforms + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.line_shader.uniform_float("lineWidth", 2.0) + + # general shader + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + + selected_vertices = [] + selected_edges = [] + selected_tris = [] + unselected_vertices = [] + unselected_edges = [] + unselected_tris = [] + + for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: + obj = clipping_plane.obj + if not obj or not obj.data: + continue + + if obj.mode == "EDIT": + continue # A profile decorator or something else is used here. + + bm = bmesh.new() + bm.from_mesh(obj.data) + obj.data.calc_loop_triangles() + + if obj.select_get(): + offset = len(selected_vertices) + selected_vertices.extend([tuple(obj.matrix_world @ v.co) for v in bm.verts]) + selected_edges.extend([tuple([v.index + offset for v in e.verts]) for e in bm.edges]) + selected_tris.extend([tuple([i + offset for i in t.vertices]) for t in obj.data.loop_triangles]) + else: + offset = len(unselected_vertices) + unselected_vertices.extend([tuple(obj.matrix_world @ v.co) for v in bm.verts]) + unselected_edges.extend([tuple([v.index + offset for v in e.verts]) for e in bm.edges]) + unselected_tris.extend([tuple([i + offset for i in t.vertices]) for t in obj.data.loop_triangles]) + + verts = [ + tuple(obj.matrix_world @ Vector((0, 0, 0))), + tuple(obj.matrix_world @ Vector((0, 0, -0.5))), + tuple(obj.matrix_world @ Vector((-0.05, 0, -0.45))), + tuple(obj.matrix_world @ Vector((0.05, 0, -0.45))), + tuple(obj.matrix_world @ Vector((0, -0.05, -0.45))), + tuple(obj.matrix_world @ Vector((0, 0.05, -0.45))), + ] + edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)] + color = selected_elements_color if obj in context.selected_objects else special_elements_color + self.draw_batch("LINES", verts, color, edges) + + if obj.mode != "EDIT": + bm.free() + + if unselected_edges: + self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges) + self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris) + if selected_edges: + self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges) + self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris) diff --git a/src/blenderbim/blenderbim/bim/module/project/gizmo.py b/src/blenderbim/blenderbim/bim/module/project/gizmo.py new file mode 100644 index 0000000000..7e8bd77614 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/project/gizmo.py @@ -0,0 +1,67 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2024 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + + +import bpy +from bpy.types import GizmoGroup +from mathutils import Matrix + + +class ClippingPlane(GizmoGroup): + bl_idname = "OBJECT_GGT_bim_clipping_plane" + bl_label = "Clipping Plane" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + @classmethod + def poll(cls, context): + obj = context.object + return ( + context.selected_objects + and obj.name.startswith("ClippingPlane") + and obj in [sp.obj for sp in context.scene.BIMProjectProperties.clipping_planes] + ) + + def setup(self, context): + self.obj = None + self.offset = 0 + self.mw = Matrix() + self.last_mw = Matrix() + + self.gizmo = self.gizmos.new("GIZMO_GT_arrow_3d") + + def move_get_x(): + return self.offset + + def move_set_x(value): + self.obj.matrix_world.col[3] = self.mw.col[3] + (self.mw.col[2] * self.offset) + self.last_mw = self.obj.matrix_world.copy() + self.offset = value + + self.gizmo.target_set_handler("offset", get=move_get_x, set=move_set_x) + + def refresh(self, context): + if self.obj != context.object or self.last_mw != context.object.matrix_world: + self.obj = context.object + self.offset = 0 + self.mw = context.object.matrix_world.copy() + obj = context.object + mw = context.object.matrix_world.normalized() + mw.col[3] -= mw.col[2] * self.offset + self.gizmo.matrix_basis = mw diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index 0f67cbf14a..a650cf99d9 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -41,7 +41,7 @@ from pathlib import Path from mathutils import Vector, Matrix from bpy.app.handlers import persistent from blenderbim.bim.module.project.data import LinksData -from blenderbim.bim.module.project.decorator import ProjectDecorator +from blenderbim.bim.module.project.decorator import ProjectDecorator, ClippingPlaneDecorator class NewProject(bpy.types.Operator): @@ -1993,7 +1993,7 @@ class EnableCulling(bpy.types.Operator): # Check if the object is too far away from the camera object_center = obj.matrix_world.translation - distance_threshold = 900 # 30m squared + distance_threshold = 900 # 30m squared if (camera_position - object_center).length_squared > distance_threshold: # The object is too far away, so consider it not visible return False @@ -2020,3 +2020,178 @@ class DisableCulling(bpy.types.Operator): def execute(self, context): LinksData.enable_culling = False return {"FINISHED"} + + +class RefreshClippingPlanes(bpy.types.Operator): + bl_idname = "bim.refresh_clipping_planes" + bl_label = "Refresh Clipping Planes" + bl_options = {"REGISTER"} + + def __init__(self): + self.total_planes = 0 + + def invoke(self, context, event): + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def modal(self, context, event): + should_refresh = False + + self.clean_deleted_planes(context) + + for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: + if clipping_plane.obj and self.is_moved(clipping_plane.obj): + should_refresh = True + break + + total_planes = len(context.scene.BIMProjectProperties.clipping_planes) + if should_refresh or total_planes != self.total_planes: + self.refresh_clipping_planes(context) + for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: + if clipping_plane.obj: + tool.Geometry.record_object_position(clipping_plane.obj) + self.total_planes = total_planes + return {"PASS_THROUGH"} + + def clean_deleted_planes(self, context): + while True: + for i, clipping_plane in enumerate(context.scene.BIMProjectProperties.clipping_planes): + if clipping_plane.obj: + try: + clipping_plane.obj.name + except: + context.scene.BIMProjectProperties.clipping_planes.remove(i) + break + else: + context.scene.BIMProjectProperties.clipping_planes.remove(i) + break + else: + break + + def is_moved(self, obj): + if not obj.BIMObjectProperties.location_checksum: + return True # Let's be conservative + loc_check = np.frombuffer(eval(obj.BIMObjectProperties.location_checksum)) + rot_check = np.frombuffer(eval(obj.BIMObjectProperties.rotation_checksum)) + loc_real = np.array(obj.matrix_world.translation).flatten() + rot_real = np.array(obj.matrix_world.to_3x3()).flatten() + if np.allclose(loc_check, loc_real, atol=1e-4) and np.allclose(rot_check, rot_real, atol=1e-2): + return False + return True + + def refresh_clipping_planes(self, context): + import bmesh + from itertools import cycle + + area = next(a for a in bpy.context.screen.areas if a.type == "VIEW_3D") + region = next(r for r in area.regions if r.type == "WINDOW") + data = region.data + + if not len(context.scene.BIMProjectProperties.clipping_planes): + data.use_clip_planes = False + else: + with bpy.context.temp_override(area=area, region=region): + bpy.ops.view3d.clip_border() + + clip_planes = [] + for clipping_plane in bpy.context.scene.BIMProjectProperties.clipping_planes: + obj = clipping_plane.obj + if not obj: + continue + print('doing', obj) + + bm = bmesh.new() + bm.from_mesh(obj.data) + + world_matrix = obj.matrix_world + + bm.faces.ensure_lookup_table() + face = bm.faces[0] + center = world_matrix @ face.calc_center_median() + print('center', center) + normal = world_matrix.to_3x3() @ face.normal * -1 + center += normal * -0.01 + print('normal', normal) + print('new center', center) + + normal.normalize() + distance = -center.dot(normal) + clip_plane = (normal.x, normal.y, normal.z, distance) + clip_planes.append(clip_plane) + bm.free() + + clip_planes = cycle(clip_planes) + data.clip_planes = [tuple(next(clip_planes)) for i in range(0, 6)] + data.update() + region.tag_redraw() + [a.tag_redraw() for a in bpy.context.screen.areas] + return {"FINISHED"} + + +class CreateClippingPlane(bpy.types.Operator): + bl_idname = "bim.create_clipping_plane" + bl_label = "Create Clipping Plane" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.area.type == "VIEW_3D" + + def execute(self, context): + from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d + + # Clean up deleted planes + + if len(bpy.context.scene.BIMProjectProperties.clipping_planes) > 5: + self.report({"INFO"}, "Maximum of six clipping planes allowed.") + return {"FINISHED"} + + for area in bpy.context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + + region = context.region + rv3d = context.region_data + coord = (self.mouse_x, self.mouse_y) + origin = region_2d_to_origin_3d(region, rv3d, coord) + direction = region_2d_to_vector_3d(region, rv3d, coord) + hit, location, normal, face_index, obj, matrix = self.ray_cast(context, origin, direction) + if not hit: + self.report({"INFO"}, "No object found.") + return {"FINISHED"} + + print(hit, location, normal, face_index, obj, matrix) + + vertices = [(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)] + + faces = [(0, 1, 2, 3)] + + mesh = bpy.data.meshes.new(name="ClippingPlane") + mesh.from_pydata(vertices, [], faces) + mesh.update() + + plane_obj = bpy.data.objects.new("ClippingPlane", mesh) + bpy.context.collection.objects.link(plane_obj) + z_axis = Vector((0, 0, 1)) + rotation_matrix = z_axis.rotation_difference(normal).to_matrix().to_4x4() + plane_obj.matrix_world = rotation_matrix + plane_obj.matrix_world.translation = location + + bpy.context.scene.cursor.location = location + + new = bpy.context.scene.BIMProjectProperties.clipping_planes.add() + new.obj = plane_obj + + ClippingPlaneDecorator.install(bpy.context) + bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") + return {"FINISHED"} + + def ray_cast(self, context, origin, direction): + depsgraph = context.evaluated_depsgraph_get() + result = context.scene.ray_cast(depsgraph, origin, direction) + return result + + def invoke(self, context, event): + self.mouse_x = event.mouse_region_x + self.mouse_y = event.mouse_region_y + return self.execute(context) diff --git a/src/blenderbim/blenderbim/bim/module/project/prop.py b/src/blenderbim/blenderbim/bim/module/project/prop.py index cd968e8173..fd618f97d1 100644 --- a/src/blenderbim/blenderbim/bim/module/project/prop.py +++ b/src/blenderbim/blenderbim/bim/module/project/prop.py @@ -20,7 +20,7 @@ import bpy import ifcopenshell.util.placement from blenderbim.bim.module.project.data import ProjectData from blenderbim.bim.ifc import IfcStore -from blenderbim.bim.prop import StrProperty +from blenderbim.bim.prop import StrProperty, ObjProperty from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -171,6 +171,7 @@ class BIMProjectProperties(PropertyGroup): template_file: EnumProperty(items=get_template_file, name="Template File") use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) + clipping_planes: bpy.props.CollectionProperty(type=ObjProperty) def get_library_element_index(self, lib_element): return next((i for i in range(len(self.library_elements)) if self.library_elements[i] == lib_element)) diff --git a/src/blenderbim/blenderbim/bim/module/project/workspace.py b/src/blenderbim/blenderbim/bim/module/project/workspace.py index 308d494146..e6bd3b9122 100644 --- a/src/blenderbim/blenderbim/bim/module/project/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/project/workspace.py @@ -22,15 +22,15 @@ from blenderbim.bim.module.project.data import LinksData class QueryTool(bpy.types.WorkSpaceTool): - bl_space_type = 'VIEW_3D' - bl_context_mode = 'OBJECT' + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" bl_idname = "bim.query_tool" bl_label = "Query Tool" bl_description = "Fetch data about a linked IFC element" bl_icon = "ops.generic.select_circle" bl_widget = None bl_keymap = ( - ("bim.query_linked_element", {"type": 'LEFTMOUSE', "value": 'PRESS'}, None), + ("bim.query_linked_element", {"type": "LEFTMOUSE", "value": "PRESS"}, None), # ("bim.project_hotkey", {"type": "C", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_C")]}), )