snap: fix possible crash

- use draw handler to move the expensive synchronous GPU readback
out of the mouse-event loop.
- restore GPU state (depth, blend, face culling) around the offscreen
This commit is contained in:
Bruno Perdigão
2026-08-31 18:56:19 -03:00
committed by Bruno Perdigão
parent 0f629d0d54
commit a6d59bf61c
4 changed files with 122 additions and 26 deletions
@@ -2874,3 +2874,54 @@ class WallSystemPathDecorator(_ConnectedNetworkPathDecorator):
else: else:
result.append(point) result.append(point)
return result return result
class GpuSnapDecorator(tool.Blender.ViewportDecorator):
"""Draw-handler that performs GPU snap detection once per viewport draw.
The detection work is injected via ``install(..., detection=...)`` so this
decorator has no knowledge of the raycast or snap subsystems. The modal
snapping code sets the current request via :meth:`set_request` and reads the
decoded result from :meth:`get_cache`.
"""
draw_method = "draw"
request: dict[str, Any] = {"mouse_x": 0, "mouse_y": 0, "objs_to_raycast": []}
cache: tuple[list[dict], bpy.types.Object | None, list[dict]] | None = None
detection: Any = None
event: Any = None
@classmethod
def install(cls, context: bpy.types.Context, event=None, detection=None) -> None:
if detection is not None:
cls.detection = detection
cls.event = event
cls.request = {"mouse_x": 0, "mouse_y": 0, "objs_to_raycast": []}
cls.cache = None
super().install(context)
@classmethod
def uninstall(cls) -> None:
cls.cache = None
cls.event = None
super().uninstall()
@classmethod
def set_request(cls, event, objs_to_raycast: list[bpy.types.Object]) -> None:
cls.event = event
cls.request["mouse_x"] = int(event.mouse_region_x)
cls.request["mouse_y"] = int(event.mouse_region_y)
cls.request["objs_to_raycast"] = objs_to_raycast
@classmethod
def get_cache(cls) -> tuple[list[dict], bpy.types.Object | None, list[dict]] | None:
return cls.cache
def draw(self, context: bpy.types.Context) -> None:
cls = type(self)
if cls.detection is None or cls.event is None or not cls.request["objs_to_raycast"]:
cls.cache = None
return
cls.cache = cls.detection(context, cls.event, cls.request)
@@ -26,7 +26,7 @@ import ifcopenshell.util.unit
from mathutils import Vector from mathutils import Vector
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.model.decorator import PolylineDecorator from bonsai.bim.module.model.decorator import GpuSnapDecorator, PolylineDecorator
class PolylineOperator: class PolylineOperator:
@@ -424,6 +424,7 @@ class PolylineOperator:
def cleanup(self, context: bpy.Types.Context): def cleanup(self, context: bpy.Types.Context):
context.workspace.status_text_set(text=None) context.workspace.status_text_set(text=None)
PolylineDecorator.uninstall() PolylineDecorator.uninstall()
GpuSnapDecorator.uninstall()
tool.Polyline.clear_polyline() tool.Polyline.clear_polyline()
tool.Raycast.clear_cache() tool.Raycast.clear_cache()
tool.Blender.update_viewport() tool.Blender.update_viewport()
@@ -460,6 +461,7 @@ class PolylineOperator:
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> None: def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
PolylineDecorator.install(context) PolylineDecorator.install(context)
GpuSnapDecorator.install(context, event, detection=tool.Raycast.detect_gpu_snaps)
tool.Snap.clear_snapping_point() tool.Snap.clear_snapping_point()
self.tool_state.use_default_container = False self.tool_state.use_default_container = False
+28 -4
View File
@@ -1108,6 +1108,13 @@ class Raycast(bonsai.core.tool.Raycast):
if _offscreen is None: if _offscreen is None:
_offscreen = GPUOffScreen(max(w, 1), max(h, 1), format="RGBA8") _offscreen = GPUOffScreen(max(w, 1), max(h, 1), format="RGBA8")
# Save GPU state so it can be restored even if readback fails.
prev_depth_mask = gpu.state.depth_mask_get()
prev_depth_test = gpu.state.depth_test_get()
prev_blend = gpu.state.blend_get()
face_culling_get = getattr(gpu.state, "face_culling_get", None)
prev_face_culling = face_culling_get() if face_culling_get is not None else None
_encoding_shader.bind() _encoding_shader.bind()
if xray_mode: if xray_mode:
@@ -1137,6 +1144,7 @@ class Raycast(bonsai.core.tool.Raycast):
buffers_list = [] buffers_list = []
last_buf = None last_buf = None
try:
with _offscreen.bind(): with _offscreen.bind():
fb = gpu.state.active_framebuffer_get() fb = gpu.state.active_framebuffer_get()
fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0) fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)
@@ -1154,10 +1162,12 @@ class Raycast(bonsai.core.tool.Raycast):
buffers_list.append(buf) buffers_list.append(buf)
if not read_per_object: if not read_per_object:
last_buf = fb.read_color(int(read_x), int(read_y), read_size, read_size, 4, 0, "UBYTE") last_buf = fb.read_color(int(read_x), int(read_y), read_size, read_size, 4, 0, "UBYTE")
finally:
# Restore state gpu.state.depth_mask_set(prev_depth_mask)
gpu.state.depth_mask_set(True) gpu.state.depth_test_set(prev_depth_test)
gpu.state.depth_test_set("LESS") gpu.state.blend_set(prev_blend)
if prev_face_culling is not None:
gpu.state.face_culling_set(prev_face_culling)
mouse_read_rect = (w, h, mx, my, read_x, read_y) mouse_read_rect = (w, h, mx, my, read_x, read_y)
if tris: if tris:
@@ -1173,6 +1183,20 @@ class Raycast(bonsai.core.tool.Raycast):
def get_gpu_wireframe_snaps(cls, context, event, objs_to_raycast): def get_gpu_wireframe_snaps(cls, context, event, objs_to_raycast):
return cls.get_gpu_detection_snaps(context, event, objs_to_raycast) return cls.get_gpu_detection_snaps(context, event, objs_to_raycast)
@classmethod
def detect_gpu_snaps(cls, context, event, request):
"""GPU snap detection callback for ``GpuSnapDecorator``."""
if bpy.app.background:
return None
objs_to_raycast = request["objs_to_raycast"]
if not objs_to_raycast:
return None
solid_snaps, closest_obj = cls.get_gpu_solid_snaps(context, event, objs_to_raycast)
wireframe_snaps, _ = cls.get_gpu_wireframe_snaps(context, event, objs_to_raycast)
return solid_snaps, closest_obj, wireframe_snaps
@classmethod @classmethod
def clear_cache(cls): def clear_cache(cls):
global _wireframe_batch_cache, _wireframe_vert_fmt, _triangle_batch_cache, _triangle_vert_fmt, _encoding_shader, _offscreen, _obj_list global _wireframe_batch_cache, _wireframe_vert_fmt, _triangle_batch_cache, _triangle_vert_fmt, _encoding_shader, _offscreen, _obj_list
+24 -5
View File
@@ -30,21 +30,21 @@ import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.drawing.data import DecoratorData from bonsai.bim.module.drawing.data import DecoratorData
from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.model.decorator import PolylineDecorator from bonsai.bim.module.model.decorator import GpuSnapDecorator, PolylineDecorator
if TYPE_CHECKING: if TYPE_CHECKING:
from bonsai.bim.prop import BIMSnapGroups, BIMSnapProperties from bonsai.bim.prop import BIMSnapGroups, BIMSnapProperties
def _get_gpu_object_snaps( def _process_gpu_object_snaps(
context: bpy.types.Context, context: bpy.types.Context,
event: bpy.types.Event, event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object], snap_faces: list[dict[str, Any]],
closest_obj: bpy.types.Object | None,
wireframe_snaps: list[dict[str, Any]],
xray_mode: bool, xray_mode: bool,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
detected_snaps: list[dict[str, Any]] = [] detected_snaps: list[dict[str, Any]] = []
snap_faces, closest_obj = tool.Raycast.get_gpu_solid_snaps(context, event, objs_to_raycast)
wireframe_snaps, _ = tool.Raycast.get_gpu_wireframe_snaps(context, event, objs_to_raycast)
if not xray_mode: if not xray_mode:
for snap in snap_faces: for snap in snap_faces:
@@ -85,6 +85,17 @@ def _get_gpu_object_snaps(
return detected_snaps return detected_snaps
def _get_gpu_object_snaps(
context: bpy.types.Context,
event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object],
xray_mode: bool,
) -> list[dict[str, Any]]:
snap_faces, closest_obj = tool.Raycast.get_gpu_solid_snaps(context, event, objs_to_raycast)
wireframe_snaps, _ = tool.Raycast.get_gpu_wireframe_snaps(context, event, objs_to_raycast)
return _process_gpu_object_snaps(context, event, snap_faces, closest_obj, wireframe_snaps, xray_mode)
def _get_cpu_object_snaps( def _get_cpu_object_snaps(
context: bpy.types.Context, context: bpy.types.Context,
event: bpy.types.Event, event: bpy.types.Event,
@@ -551,7 +562,15 @@ class Snap(bonsai.core.tool.Snap):
props = cls.get_snap_props() props = cls.get_snap_props()
if props.use_gpu_snapping: if props.use_gpu_snapping:
GpuSnapDecorator.set_request(event, objs_to_raycast)
cached = GpuSnapDecorator.get_cache()
if cached is None:
detected_snaps.extend(_get_gpu_object_snaps(context, event, objs_to_raycast, xray_mode)) detected_snaps.extend(_get_gpu_object_snaps(context, event, objs_to_raycast, xray_mode))
else:
snap_faces, closest_obj, wireframe_snaps = cached
detected_snaps.extend(
_process_gpu_object_snaps(context, event, snap_faces, closest_obj, wireframe_snaps, xray_mode)
)
else: else:
detected_snaps.extend(_get_cpu_object_snaps(context, event, objs_to_raycast, xray_mode)) detected_snaps.extend(_get_cpu_object_snaps(context, event, objs_to_raycast, xray_mode))
cut_snaps = _get_cut_object_snaps(context, event) cut_snaps = _get_cut_object_snaps(context, event)