Skip to content

Commit 31b5713

Browse files
committed
Snap: improve handling with objects that are partially behind the camera.
1 parent cef5d41 commit 31b5713

2 files changed

Lines changed: 154 additions & 25 deletions

File tree

src/bonsai/bonsai/tool/raycast.py

Lines changed: 152 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,14 @@ def get_on_screen_2d_bounding_boxes(
7373
rv3d = context.region_data
7474
assert rv3d
7575
view_location = rv3d.view_matrix.inverted().translation
76+
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
7677
obj_matrix = obj.matrix_world.copy()
7778
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
79+
bbox_edges = [
80+
(0,1),(1,2),(2,3),(3,0),
81+
(4,5),(5,6),(6,7),(7,4),
82+
(0,4),(1,5),(2,6),(3,7)
83+
]
7884

7985
transposed_bbox: list[Vector] = []
8086
bbox_2d: list[float] = []
@@ -98,8 +104,23 @@ def get_on_screen_2d_bounding_boxes(
98104

99105
for v in bbox:
100106
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
101-
if coord_2d is not None:
102-
transposed_bbox.append(coord_2d)
107+
transposed_bbox.append(coord_2d)
108+
109+
if not any(transposed_bbox):
110+
transposed_bbox = []
111+
# If there are None values in transposed_bbox it means that there are vertices behind the camera
112+
# so we get the intersection of the edge with the region border
113+
# new_bbox = []
114+
if any(transposed_bbox) and not all(transposed_bbox):
115+
new_bbox = transposed_bbox.copy()
116+
new_bbox = [x for x in new_bbox if x is not None]
117+
for edge in bbox_edges:
118+
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
119+
point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
120+
if point:
121+
new_bbox.append(point)
122+
if new_bbox:
123+
transposed_bbox = new_bbox
103124

104125
region = context.region
105126
borders = (0, region.width, 0, region.height)
@@ -121,6 +142,99 @@ def get_on_screen_2d_bounding_boxes(
121142
return (obj, bbox_2d)
122143
return None
123144

145+
def intersect_edge_region_border(region, space, rv3d, v1, v2):
146+
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
147+
a_view = view_matrix @ p_world_a
148+
b_view = view_matrix @ p_world_b
149+
z_near = -clip_start
150+
za = a_view.z
151+
zb = b_view.z
152+
denom = (zb - za)
153+
if denom == 0.0:
154+
return None, None
155+
t = (z_near - za) / denom
156+
if t < 0.0 or t > 1.0:
157+
return None, None
158+
p_view = a_view.lerp(b_view, t)
159+
cam_world = view_matrix.inverted()
160+
p_world = cam_world @ p_view
161+
return p_world, t
162+
163+
def is_inside_region(pt2d, region):
164+
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
165+
166+
def clamp_to_region_border(point2d, region):
167+
x, y = point2d
168+
x_clamped = max(0.0, min(region.width, x))
169+
y_clamped = max(0.0, min(region.height, y))
170+
return Vector((x_clamped, y_clamped))
171+
172+
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
173+
"""
174+
Use iterative approach: move t toward 0. Returns the first point that is inside region border
175+
"""
176+
t = initial_t_on_segment
177+
for i in range(max_iters):
178+
test_3d = p1.lerp(p2, t)
179+
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
180+
if test_2d is not None and is_inside_region(test_2d, region):
181+
return test_3d, test_2d, t
182+
# move t toward 0 by reducing it by a fraction of its current value
183+
t -= step
184+
# if t is already very small, break
185+
if t <= 1e-6:
186+
break
187+
188+
return None, None, None
189+
190+
# Ensures that all the calculation uses the same direction based on which point is on the screen
191+
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
192+
onscreen_vert = v1
193+
offscreen_vert = v2
194+
else:
195+
onscreen_vert = v2
196+
offscreen_vert = v1
197+
# v2, v1 = v1, v2
198+
199+
clip_start = space.clip_start
200+
view_mat = rv3d.view_matrix
201+
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
202+
203+
if inter_world is None:
204+
print("No intersection with viewport near plane found for the segment.")
205+
return
206+
207+
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
208+
209+
if init_2d is not None and is_inside_region(init_2d, region):
210+
final_world = inter_world
211+
final_2d = init_2d
212+
final_t = initial_t
213+
else:
214+
found_world, found_2d, found_t = find_nearby_onscreen_point(
215+
region, rv3d,
216+
onscreen_vert, offscreen_vert,
217+
t_on_ab,
218+
max_iters=600, step=0.01
219+
)
220+
if found_world is None:
221+
if init_2d is None:
222+
print("Initial projection invalid and iterative search failed.")
223+
return
224+
# fallback: clamp projected point to border via manual mapping
225+
final_2d = clamp_to_region_border(init_2d, region)
226+
final_world = None
227+
final_t = None
228+
# print("Iterative search failed; using clamped 2D:", final_2d)
229+
else:
230+
final_world = found_world
231+
final_2d = found_2d
232+
final_t = found_t
233+
# print(f"Found onscreen point at t={final_t:.4f}")
234+
235+
# print("Final 2D:", final_2d)
236+
return final_2d, v2
237+
124238
@classmethod
125239
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
126240
x, y = mouse_pos
@@ -241,7 +355,7 @@ def ray_cast_by_proximity_2d(
241355
cls,
242356
context: bpy.types.Context,
243357
event: bpy.types.Event,
244-
snap_obj,
358+
snap_obj: SnapObj,
245359
):
246360

247361
def divide_vector(start, end, n):
@@ -258,22 +372,45 @@ def divide_vector(start, end, n):
258372
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
259373
points = []
260374

261-
verts_2d = [
262-
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
263-
] # Numpy version is worst in performance
264-
verts_2d = [
265-
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
266-
] # Numpy version is worst in performance
267-
snap_threshold = 10.0
268-
269375
try:
270376
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
271377
except:
272378
loc = Vector((0, 0, 0))
273379

380+
verts_2d = [
381+
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
382+
] # Numpy version is worst in performance
383+
384+
intersected = snap_obj.raycast_boxes(
385+
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
386+
)
387+
edges = []
388+
for it in intersected:
389+
edges.extend(it.edges)
390+
edges = set(edges)
391+
392+
edge_verts = {}
393+
for e in edges:
394+
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
395+
verts = snap_obj.obj.data.vertices
396+
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
397+
v1_2d = verts_2d[verts_idx[0]]
398+
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
399+
v2_2d = verts_2d[verts_idx[1]]
400+
if (v1_2d is None) ^ (v2_2d is None):
401+
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
402+
if v1_2d is None:
403+
edge_verts[e] = (point, v2_2d)
404+
else:
405+
edge_verts[e] = (v1_2d, point)
406+
else:
407+
edge_verts[e] = (v1_2d, v2_2d)
408+
409+
snap_threshold = 10.0
410+
274411
for i, point in enumerate(verts_2d):
275412
if not point:
276-
break
413+
continue
277414
distance = (Vector(mouse_pos) - point).length
278415
if distance <= snap_threshold:
279416
snap_point = {
@@ -284,20 +421,12 @@ def divide_vector(start, end, n):
284421
}
285422
points.append(snap_point)
286423

287-
intersected = snap_obj.raycast_boxes(
288-
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
289-
)
290-
edges = []
291-
for it in intersected:
292-
edges.extend(it.edges)
293-
edges = set(edges)
294424
count = 0
295425
selected_edges = {}
296426
for e in edges:
297-
idx = snap_obj.obj.data.edges[e].vertices
298-
299-
p0x, p0y = verts_2d[idx[0]][0], verts_2d[idx[0]][1]
300-
p1x, p1y = verts_2d[idx[1]][0], verts_2d[idx[1]][1]
427+
p0, p1 = edge_verts[e]
428+
p0x, p0y = p0
429+
p1x, p1y = p1
301430
px, py = mouse_pos
302431

303432
# segment vector = p1 - p0

src/bonsai/bonsai/tool/snap.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ def select_plane_method():
398398
for snap in closest_snaps:
399399
if snap_obj.obj == snap["object"]:
400400
if xray_mode:
401-
if "face_index" in snap and snap["face_index"]:
401+
if "face_index" in snap and snap["face_index"] is not None:
402402
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
403403
for point in snap_points:
404404
point["group"] = "Object"
@@ -407,7 +407,7 @@ def select_plane_method():
407407
# If it is a solid object that is closest to camera it ignores all the rest
408408
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
409409
closest_snap = [snap] # discards objects that aren't the closest
410-
if "face_index" in snap and snap["face_index"]:
410+
if "face_index" in snap and snap["face_index"] is not None:
411411
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
412412
for point in snap_points:
413413
point["group"] = "Object"

0 commit comments

Comments
 (0)