Files
IfcOpenShell/src/bonsai/bonsai/tool/snap.py
T
2024-08-28 18:58:11 -03:00

647 lines
24 KiB
Python

# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2022 Cyril Waechter <cyril@biminsight.ch>
#
# 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 bpy
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.module.model.decorator import PolylineDecorator
import math
import mathutils
from mathutils import Matrix, Vector
from lark import Lark, Transformer
class Snap(bonsai.core.tool.Snap):
mouse_pos = None
snap_angle = None
use_default_container = False
snap_plane_method = None
snap_axis_method = None
@classmethod
def set_use_default_container(cls, value=True):
cls.use_default_container = value
@classmethod
def set_snap_plane_method(cls, value=True):
cls.snap_plane_method = value
@classmethod
def cycle_snap_plane_method(cls, value=True):
if cls.snap_plane_method == value:
cls.snap_plane_method = None
return
cls.snap_plane_method = value
@classmethod
def set_snap_axis_method(cls, value=True):
if cls.snap_axis_method == value:
cls.snap_axis_method = None
return
cls.snap_axis_method = value
@classmethod
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
matrix = obj.matrix_world.copy()
face = obj.data.polygons[face_index]
verts = []
for i in face.vertices:
verts.append(matrix @ obj.data.vertices[i].co)
hit, hit_type = tool.Raycast.ray_cast_by_proximity(context, event, obj, face)
snap_point = (hit, hit_type)
if hit is None:
return (None, None)
return snap_point
@classmethod
def get_snap_points_on_polyline(cls):
snap_points = {}
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
polyline_points = []
for point_data in polyline_data:
point = Vector((point_data.x, point_data.y, point_data.z))
polyline_points.append(point)
snap_points.update({tuple(point): "Polyline Point" for point in polyline_points})
return snap_points
@classmethod
def select_snap_point(cls, snap_points, hit, threshold):
shortest_distance = None
snap_point = None
for point, snap_type in snap_points.items():
point = Vector(point)
distance = (point - hit).length
if distance > threshold:
continue
if shortest_distance and distance < shortest_distance:
shortest_distance = distance
snap_point = (point, snap_type)
elif not shortest_distance:
shortest_distance = distance
snap_point = (point, snap_type)
else:
pass
return snap_point
@classmethod
def update_snapping_point(cls, snap_point, snap_type):
try:
snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point[0]
except:
snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point.add()
info = f"""Snap: {snap_type}
Axis:{cls.snap_axis_method}
Plane:{cls.snap_plane_method}
"""
PolylineDecorator.set_snap_info(info)
snap_vertex.x = snap_point[0]
snap_vertex.y = snap_point[1]
snap_vertex.z = snap_point[2]
snap_vertex.snap_type = snap_type
@classmethod
def update_snapping_ref(cls, snap_point, snap_type):
try:
snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_ref[0]
except:
snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_ref.add()
snap_vertex.x = snap_point[0]
snap_vertex.y = snap_point[1]
snap_vertex.z = snap_point[2]
snap_vertex.snap_type = snap_type
@classmethod
def clear_snapping_ref(cls):
bpy.context.scene.BIMModelProperties.snap_mouse_ref.clear()
@classmethod
def insert_polyline_point(cls, input_panel):
x = float(input_panel["X"])
y = float(input_panel["Y"])
try:
z = float(input_panel["Z"])
except:
z = Vector((0, 0, 0))
d = input_panel["D"]
a = input_panel["A"]
snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point[0]
if cls.use_default_container:
z = tool.Ifc.get_object(tool.Root.get_default_container()).location.z
if x is None and y is None:
x = snap_vertex.x
y = snap_vertex.y
z = snap_vertex.z
# Avoids creating two points at the same location
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
if polyline_data:
last_point = polyline_data[len(polyline_data) - 1]
if (x, y, z) == (round(last_point.x, 4), round(last_point.y, 4), round(last_point.z, 4)):
return
polyline_point = bpy.context.scene.BIMModelProperties.polyline_point.add()
polyline_point.x = x
polyline_point.y = y
polyline_point.z = z
polyline_measurement = bpy.context.scene.BIMModelProperties.polyline_measurement.add()
polyline_measurement.dim = d
polyline_measurement.angle = a
polyline_measurement.position = Vector((x, y, z))
@classmethod
def close_polyline(cls):
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
if len(polyline_data) > 2:
first_point = polyline_data[0]
polyline_point = bpy.context.scene.BIMModelProperties.polyline_point.add()
polyline_point.x = first_point.x
polyline_point.y = first_point.y
polyline_point.z = first_point.z
@classmethod
def clear_polyline(cls):
bpy.context.scene.BIMModelProperties.polyline_point.clear()
bpy.context.scene.BIMModelProperties.polyline_measurement.clear()
@classmethod
def remove_last_polyline_point(cls):
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
polyline_data.remove(len(polyline_data) - 1)
polyline_measurement = bpy.context.scene.BIMModelProperties.polyline_measurement
polyline_measurement.remove(len(polyline_measurement) - 1)
@classmethod
def snap_on_axis(cls, intersection, lock_axis=None):
def create_axis_line_data(rot_mat, origin):
length = 1000
direction = Vector((1, 0, 0))
if cls.snap_plane_method == "YZ" or (not cls.snap_plane_method and cls.snap_axis_method == "Z"):
direction = Vector((0, 0, 1))
rot_dir = rot_mat.inverted() @ direction
start = origin + rot_dir * length
end = origin - rot_dir * length
return start, end
def create_axis_rectangle_data(origin):
size = 0.5
direction = Vector((1, 0, 0))
if cls.snap_plane_method == "YZ":
direction = Vector((0, 0, 1))
rot_mat = Matrix.Rotation(math.radians(360), 3, pivot_axis)
rot_dir = rot_mat.inverted() @ direction
v1 = origin + rot_dir * 0
v2 = origin + rot_dir * size
if cls.snap_plane_method == "XY":
angle = 270
else:
angle = 90
rot_mat = Matrix.Rotation(math.radians(angle), 3, pivot_axis)
rot_dir = rot_mat.inverted() @ direction
v3 = origin + rot_dir * size
v4 = v2 + rot_dir * size
return (v1, v2, v3, v4)
default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
if polyline_data:
last_point_data = polyline_data[-1]
last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z))
else:
last_point = Vector((0, 0, default_container_elevation))
# Translates intersection point based on last_point
translated_intersection = intersection - last_point
snap_axis = []
if not lock_axis:
for i in range(1, 13):
angle = 30 * i
snap_axis.append(angle)
else:
snap_axis = [lock_axis]
pivot_axis = "Z"
if cls.snap_plane_method == "XZ":
pivot_axis = "Y"
if cls.snap_plane_method == "YZ":
pivot_axis = "X"
for axis in snap_axis:
rot_mat = Matrix.Rotation(math.radians(360 - axis), 3, pivot_axis)
start, end = create_axis_line_data(rot_mat, last_point)
rot_intersection = rot_mat @ translated_intersection
proximity = rot_intersection.y
if cls.snap_plane_method == "XZ":
proximity = rot_intersection.z
PolylineDecorator.set_angle_axis_line(start, end)
if lock_axis:
is_on_rot_axis = True
else:
is_on_rot_axis = abs(proximity) <= 0.15
if is_on_rot_axis:
# Snap to axis
rot_intersection = Vector((rot_intersection.x, 0, rot_intersection.z))
if cls.snap_plane_method == "XZ":
rot_intersection = Vector((rot_intersection.x, rot_intersection.y, 0))
# Convert it back
snap_intersection = rot_mat.inverted() @ rot_intersection + last_point
return snap_intersection, axis, start, end
return None, None, None, None
@classmethod
def mix_snap_and_axis(cls, snap_point, axis_start, axis_end):
# Creates a mixed snap point between the locked axis and
# the object snap
intersections = []
intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((1, 0, 0))))
intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((0, 1, 0))))
intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((0, 0, 1))))
sorted_intersections = sorted(i for i in intersections if i is not None)
if sorted_intersections[0]:
return sorted_intersections[0], "Mix"
@classmethod
def detect_snapping_points(cls, context, event, objs_2d_bbox):
region = context.region
rv3d = context.region_data
space = context.space_data
cls.mouse_pos = event.mouse_region_x, event.mouse_region_y
detected_snaps = []
snap_threshold = 0.3
offset = 10
mouse_offset = (
(-offset, offset),
(0, offset),
(offset, offset),
(-offset, 0),
(0, 0),
(offset, 0),
(-offset, -offset),
(0, -offset),
(offset, -offset),
)
def select_plane_method():
if not last_polyline_point:
plane_origin = Vector((0, 0, 0))
plane_normal = Vector((0, 0, 1))
if not cls.snap_plane_method:
camera_rotation = rv3d.view_rotation
plane_origin = Vector((0, 0, 0))
view_direction = Vector((0, 0, -1)) @ camera_rotation.to_matrix().transposed()
plane_normal = view_direction.normalized()
if cls.snap_plane_method == "XY" or (not cls.snap_plane_method and cls.snap_axis_method in {"X", "Y"}):
if cls.use_default_container:
plane_origin = Vector((0, 0, elevation))
elif not last_polyline_point:
plane_origin = Vector((0, 0, 0))
else:
plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z))
plane_normal = Vector((0, 0, 1))
elif cls.snap_plane_method == "XZ" or (not cls.snap_plane_method and cls.snap_axis_method == "Z"):
if last_polyline_point:
plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z))
plane_normal = Vector((0, 1, 0))
elif cls.snap_plane_method == "YZ":
if last_polyline_point:
plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z))
plane_normal = Vector((1, 0, 0))
return plane_origin, plane_normal
def cast_rays_and_get_best_object(objs_to_raycast):
best_length_squared = 1.0
best_obj = None
best_hit = None
best_face_index = None
for obj in objs_to_raycast:
hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj)
if hit is None:
# Tried original mouse position. Now it will try the offsets.
original_mouse_pos = cls.mouse_pos
for value in mouse_offset:
cls.mouse_pos = tuple(x + y for x, y in zip(original_mouse_pos, value))
hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj, cls.mouse_pos)
if hit:
break
cls.mouse_pos = original_mouse_pos
if hit is not None:
hit_world = obj.original.matrix_world @ hit
length_squared = (hit_world - 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_world
best_face_index = face_index
if best_obj is not None:
return best_obj, best_hit, best_face_index
else:
return None, None, None
ray_origin, ray_target, ray_direction = tool.Raycast.get_viewport_ray_data(context, event)
objs_to_raycast = []
for obj, bbox_2d in objs_2d_bbox:
if obj.type == "MESH" and bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(cls.mouse_pos, bbox_2d, offset):
if space.local_view:
if obj.local_view_get(context.space_data):
objs_to_raycast.append(obj)
else:
objs_to_raycast.append(obj)
# Obj
snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast)
if hit is not None:
detected_snaps.append({"Object": (snap_obj, hit, face_index)})
# Edge-Vertex
for obj in objs_to_raycast:
if len(obj.data.polygons) == 0:
options = tool.Raycast.ray_cast_by_proximity(context, event, obj)
snap_obj = obj
if options:
detected_snaps.append({"Edge-Vertex": (snap_obj, options)})
break
# Polyline
try:
polyline_data = bpy.context.scene.BIMModelProperties.polyline_point
last_polyline_point = polyline_data[len(polyline_data) - 1]
except:
last_polyline_point = None
snap_points = tool.Raycast.ray_cast_to_polyline(context, event)
# snap_point = cls.select_snap_point(snap_points, intersection, snap_threshold)
if snap_points:
detected_snaps.append({"Polyline": snap_points})
# Axis and Plane
elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z
plane_origin, plane_normal = select_plane_method()
PolylineDecorator.set_plane(plane_origin, plane_normal)
intersection = tool.Raycast.ray_cast_to_plane(context, event, plane_origin, plane_normal)
axis_start = None
axis_end = None
# TODO It only work for XY plane. Make it work also for None plane_method
rot_intersection = None
if not cls.snap_plane_method:
if cls.snap_axis_method == "X":
cls.snap_angle = 180
if cls.snap_axis_method == "Y":
cls.snap_angle = 90
if cls.snap_axis_method == "Z":
cls.snap_angle = 90
if cls.snap_axis_method:
rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, cls.snap_angle)
if cls.snap_plane_method:
if cls.snap_plane_method in {"XY", "XZ"} and cls.snap_axis_method == "X":
cls.snap_angle = 180
if cls.snap_plane_method in {"XY", "YZ"} and cls.snap_axis_method == "Y":
cls.snap_angle = 90
if cls.snap_plane_method in {"YZ"} and cls.snap_axis_method == "Z":
cls.snap_angle = 180
if cls.snap_plane_method in {"XZ"} and cls.snap_axis_method == "Z":
cls.snap_angle = 90
if event.shift or cls.snap_axis_method:
# Doesn't update snap_angle so that it keeps in the same axis
rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, cls.snap_angle)
else:
rot_intersection, cls.snap_angle, axis_start, axis_end = cls.snap_on_axis(intersection, None)
if rot_intersection:
detected_snaps.append({"Axis": (rot_intersection, axis_start, axis_end)})
detected_snaps.append({"Plane": intersection})
return detected_snaps
@classmethod
def select_snapping_points(cls, context, event, detected_snaps):
snapping_points = []
for origin in detected_snaps:
if "Object" in list(origin.keys()):
snap_obj, hit, face_index = origin["Object"]
matrix = snap_obj.matrix_world.copy()
face = snap_obj.data.polygons[face_index]
verts = []
for i in face.vertices:
verts.append(matrix @ snap_obj.data.vertices[i].co)
options = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj, face)
if not options:
snapping_points.append((hit, "Face"))
else:
for op in options:
snapping_points.append(op)
break
if "Edge-Vertex" in list(origin.keys()):
snap_obj, options = origin["Edge-Vertex"]
for op in options:
snapping_points.append(op)
break
if "Polyline" in list(origin.keys()):
options = origin["Polyline"]
for op in options:
snapping_points.append(op)
break
if "Plane" in list(origin.keys()):
intersection = origin["Plane"]
snapping_points.append((intersection, "Plane"))
for origin in detected_snaps:
if "Axis" in list(origin.keys()):
intersection = origin["Axis"]
axis_start = intersection[1]
axis_end = intersection[2]
snapping_points.append((intersection[0], "Axis"))
# Make Axis first priority
if event.shift or cls.snap_axis_method in {"X", "Y", "Z"}:
cls.update_snapping_ref(snapping_points[0][0], snapping_points[0][1])
for point in snapping_points:
if point[1] == "Axis":
if snapping_points[0][1] not in {"Axis", "Plane"}:
mixed_snap = cls.mix_snap_and_axis(snapping_points[0], axis_start, axis_end)
cls.update_snapping_point(mixed_snap[0], mixed_snap[1])
return snapping_points
cls.update_snapping_point(point[0], point[1])
return snapping_points
cls.update_snapping_point(snapping_points[0][0], snapping_points[0][1])
return snapping_points
@classmethod
def modify_snapping_point_selection(cls, snapping_points):
shifted_list = snapping_points[1:] + snapping_points[:1]
cls.update_snapping_point(shifted_list[0][0], shifted_list[0][1])
return shifted_list
@classmethod
def validate_input(cls, input_number, input_type):
grammar_imperial = """
start: FORMULA? dim expr?
dim: imperial
FORMULA: "="
imperial: feet? "-"? inches?
feet: NUMBER? " "? fraction? "'"
inches: NUMBER? " "? fraction? "\\""
fraction: NUMBER "/" NUMBER
expr: (ADD | SUB) dim | (MUL | DIV) NUMBER
NUMBER: /-?\\d+(?:\\.\\d+)?/
ADD: "+"
SUB: "-"
MUL: "*"
DIV: "/"
%ignore " "
"""
grammar_metric = """
start: FORMULA? dim expr?
dim: metric
FORMULA: "="
metric: NUMBER
expr: (ADD | SUB | MUL | DIV) dim
NUMBER: /-?\\d+(?:\\.\\d+)?/
ADD: "+"
SUB: "-"
MUL: "*"
DIV: "/"
%ignore " "
"""
class InputTransform(Transformer):
def NUMBER(self, n):
return float(n)
def fraction(self, numbers):
return numbers[0] / numbers[1]
def inches(self, args):
if len(args) > 1:
result = args[0] + args[1]
else:
result = args[0]
return result / 12
def feet(self, args):
return args[0]
def imperial(self, args):
if len(args) > 1:
result = args[0] + args[1]
else:
result = args[0]
return result
def metric(self, args):
return args[0]
def dim(self, args):
return args[0]
def expr(self, args):
op = args[0]
value = float(args[1])
if op == "+":
return lambda x: x + value
elif op == "-":
return lambda x: x - value
elif op == "*":
return lambda x: x * value
elif op == "/":
return lambda x: x / value
def FORMULA(cls, args):
return args[0]
def start(self, args):
i = 0
if args[0] == "=":
i += 1
else:
if len(args) > 1:
raise ValueError("Invalid input.")
dimension = args[i]
if len(args) > i + 1:
expression = args[i + 1]
return expression(dimension) * factor
else:
return dimension * factor
try:
if bpy.context.scene.unit_settings.system == "IMPERIAL":
parser = Lark(grammar_imperial)
factor = 0.3048
else:
parser = Lark(grammar_metric)
factor = 1
if bpy.context.scene.unit_settings.length_unit == "MILLIMETERS":
factor = 0.001
if input_type == "A":
parser = Lark(grammar_metric)
factor = 1
parse_tree = parser.parse(input_number)
transformer = InputTransform()
result = transformer.transform(parse_tree)
return True, str(result)
except:
return False, "0"