New clipping planes feature.

This commit is contained in:
Dion Moult
2024-02-29 16:49:23 +11:00
parent d333df8b73
commit 3ef4927e55
6 changed files with 514 additions and 2 deletions
@@ -17,7 +17,7 @@
# along with BlenderBIM Add-on. If not, see <http://www.gnu.org/licenses/>. # along with BlenderBIM Add-on. If not, see <http://www.gnu.org/licenses/>.
import bpy import bpy
from . import ui, prop, operator from . import ui, prop, operator, workspace, gizmo
classes = ( classes = (
operator.AppendEntireLibrary, operator.AppendEntireLibrary,
@@ -25,6 +25,7 @@ classes = (
operator.AppendLibraryElementByQuery, operator.AppendLibraryElementByQuery,
operator.AssignLibraryDeclaration, operator.AssignLibraryDeclaration,
operator.ChangeLibraryElement, operator.ChangeLibraryElement,
operator.CreateClippingPlane,
operator.CreateProject, operator.CreateProject,
operator.DisableCulling, operator.DisableCulling,
operator.DisableEditingHeader, operator.DisableEditingHeader,
@@ -40,6 +41,7 @@ classes = (
operator.LoadProjectElements, operator.LoadProjectElements,
operator.NewProject, operator.NewProject,
operator.QueryLinkedElement, operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
operator.RefreshLibrary, operator.RefreshLibrary,
operator.ReloadLink, operator.ReloadLink,
operator.RevertProject, operator.RevertProject,
@@ -53,6 +55,7 @@ classes = (
operator.UnlinkIfc, operator.UnlinkIfc,
operator.UnloadLink, operator.UnloadLink,
operator.UnloadProject, operator.UnloadProject,
workspace.QueryHotkey,
prop.LibraryElement, prop.LibraryElement,
prop.FilterCategory, prop.FilterCategory,
prop.Link, prop.Link,
@@ -66,6 +69,7 @@ classes = (
ui.BIM_UL_library, ui.BIM_UL_library,
ui.BIM_UL_filter_categories, ui.BIM_UL_filter_categories,
ui.BIM_UL_links, ui.BIM_UL_links,
gizmo.ClippingPlane,
) )
@@ -73,6 +77,8 @@ addon_keymaps = []
def register(): def register():
if not bpy.app.background:
bpy.utils.register_tool(workspace.QueryTool, after={"builtin.select"}, separator=True, group=False)
bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties) bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties)
bpy.types.TOPBAR_MT_file.prepend(ui.file_menu) bpy.types.TOPBAR_MT_file.prepend(ui.file_menu)
bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu) bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu)
@@ -92,6 +98,8 @@ def register():
def unregister(): def unregister():
if not bpy.app.background:
bpy.utils.unregister_tool(workspace.QueryTool)
bpy.types.TOPBAR_MT_file.remove(ui.file_menu) bpy.types.TOPBAR_MT_file.remove(ui.file_menu)
bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu) bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu)
del bpy.types.Scene.BIMProjectProperties del bpy.types.Scene.BIMProjectProperties
@@ -0,0 +1,188 @@
# BlenderBIM Add-on - OpenBIM Blender Add-on
# Copyright (C) 2024 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
import gpu
import bmesh
import blenderbim.tool as tool
from bpy.types import SpaceView3D
from mathutils import Vector
from gpu_extras.batch import batch_for_shader
class ProjectDecorator:
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 = []
try:
obj = context.scene.BIMProjectProperties.queried_obj
selected_vertices = obj["selected_vertices"]
selected_edges = obj["selected_edges"]
selected_tris = obj["selected_tris"]
except:
return
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)
@@ -0,0 +1,67 @@
# BlenderBIM Add-on - OpenBIM Blender Add-on
# Copyright (C) 2024 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
@@ -41,6 +41,7 @@ from pathlib import Path
from mathutils import Vector, Matrix from mathutils import Vector, Matrix
from bpy.app.handlers import persistent from bpy.app.handlers import persistent
from blenderbim.bim.module.project.data import LinksData from blenderbim.bim.module.project.data import LinksData
from blenderbim.bim.module.project.decorator import ProjectDecorator, ClippingPlaneDecorator
class NewProject(bpy.types.Operator): class NewProject(bpy.types.Operator):
@@ -1700,3 +1701,178 @@ class DisableCulling(bpy.types.Operator):
def execute(self, context): def execute(self, context):
LinksData.enable_culling = False LinksData.enable_culling = False
return {"FINISHED"} 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"}
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
tool.Blender.set_active_object(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)
@@ -20,7 +20,7 @@ import bpy
import ifcopenshell.util.placement import ifcopenshell.util.placement
from blenderbim.bim.module.project.data import ProjectData from blenderbim.bim.module.project.data import ProjectData
from blenderbim.bim.ifc import IfcStore 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.types import PropertyGroup
from bpy.props import ( from bpy.props import (
PointerProperty, PointerProperty,
@@ -171,6 +171,7 @@ class BIMProjectProperties(PropertyGroup):
template_file: EnumProperty(items=get_template_file, name="Template File") template_file: EnumProperty(items=get_template_file, name="Template File")
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False) use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
queried_obj: bpy.props.PointerProperty(type=bpy.types.Object) queried_obj: bpy.props.PointerProperty(type=bpy.types.Object)
clipping_planes: bpy.props.CollectionProperty(type=ObjProperty)
def get_library_element_index(self, lib_element): 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)) return next((i for i in range(len(self.library_elements)) if self.library_elements[i] == lib_element))
@@ -0,0 +1,72 @@
# BlenderBIM Add-on - OpenBIM Blender Add-on
# Copyright (C) 2024 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
import bpy
import blenderbim.tool as tool
from bpy.types import WorkSpaceTool
from blenderbim.bim.module.project.data import LinksData
class QueryTool(bpy.types.WorkSpaceTool):
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": "RIGHTMOUSE", "value": "PRESS"}, None),
("bim.query_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}),
("bim.query_hotkey", {"type": "C", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_C")]}),
)
def draw_settings(context, layout, ws_tool):
row = layout.row(align=True)
row.label(text="Query Object", icon="MOUSE_RMB")
row = layout.row(align=True)
row.label(text="", icon="EVENT_SHIFT")
row.label(text="Add Clipping Plane", icon="EVENT_C")
row = layout.row(align=True)
row.label(text="", icon="EVENT_ALT")
row.label(text="Disable Culling" if LinksData.enable_culling else "Enable Culling", icon="EVENT_C")
class QueryHotkey(bpy.types.Operator):
bl_idname = "bim.query_hotkey"
bl_label = "Query Hotkey"
bl_options = {"REGISTER", "UNDO"}
hotkey: bpy.props.StringProperty()
description: bpy.props.StringProperty()
@classmethod
def description(cls, context, operator):
return operator.description or ""
def execute(self, context):
getattr(self, f"hotkey_{self.hotkey}")()
return {"FINISHED"}
def hotkey_S_C(self):
bpy.ops.bim.create_clipping_plane("INVOKE_DEFAULT")
def hotkey_A_C(self):
if LinksData.enable_culling:
bpy.ops.bim.disable_culling()
else:
bpy.ops.bim.enable_culling("INVOKE_DEFAULT")