Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
75f487e
Fix line width for Vulkan backend
ra100 Jun 18, 2025
be11f82
Fix Vulkan/Metal line width and point rendering - Add backend detecti…
ra100 Jun 18, 2025
d1b5d90
Refactor backend detection to target Vulkan only, excluding Metal - U…
ra100 Jun 18, 2025
d2086e0
Fix workplane selection - enable full surface selection instead of ed…
ra100 Jun 18, 2025
d2f3c9d
Fix construction line thickness on Vulkan - use POLYLINE_UNIFORM_COLO…
ra100 Jun 18, 2025
acc41ce
Add geometry-based dashed lines for Vulkan construction lines - creat…
ra100 Jun 18, 2025
5086be2
Add dashed construction geometry for circles and arcs on Vulkan - com…
ra100 Jun 18, 2025
24d89e4
Reduce point sizes for cleaner visual appearance - smaller OpenGL poi…
ra100 Jun 18, 2025
5c4c80f
Performance: Add BackendCache to avoid repeated GPU backend detection…
ra100 Jun 18, 2025
8303346
Code Organization: Centralize magic numbers in RenderingConstants cla…
ra100 Jun 18, 2025
4cccb0e
Error Handling: Improve exception handling with specific exceptions a…
ra100 Jun 18, 2025
7adf52c
Code Deduplication: Add VulkanCompatibleEntity mixin and DashedLineRe…
ra100 Jun 18, 2025
6f468b8
Documentation: Add comprehensive docstrings for Vulkan compatibility …
ra100 Jun 18, 2025
18ba3f7
Refactor: Remove Vulkan-specific checks and centralize rendering logi…
ra100 Jun 19, 2025
1a66bc1
Fix screen-space consistent sizing for 2D and 3D points
ra100 Jun 19, 2025
0d0e0a8
Fix depth-aware sorting for offscreen entity rendering
ra100 Jun 19, 2025
1f6fd5f
Fix tab boundary freezing in viewport selection
ra100 Jun 20, 2025
4966124
Simplify point rendering and fix screen-space scaling
ra100 Jun 20, 2025
0aa0e1a
Fix workplane selection and optimize selection buffer performance
ra100 Jun 21, 2025
f614df6
Performance optimizations: constraint validation caching, entity rela…
ra100 Jun 21, 2025
e2838f5
Fix critical Vulkan compatibility issues
ra100 Jun 22, 2025
3af6c19
Add GPU resource management infrastructure
ra100 Jun 22, 2025
9bb0ca8
Update entity rendering to use cached shaders
ra100 Jun 22, 2025
51ebe54
Integrate shader caching into draw systems
ra100 Jun 22, 2025
051dd32
Fix Update action in sketch
ra100 Jun 22, 2025
d2babb4
Fix tag_update AttributeError and infinite recursion
ra100 Jun 25, 2025
4ed69d8
🧹 Remove dead shader code and consolidate duplicates
ra100 Jul 11, 2025
3943f70
FIx point handle pixel size
ra100 Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 260 additions & 19 deletions draw_handler.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,216 @@
import logging

import bpy
import gpu
from bpy.types import Context, Operator
from bpy.utils import register_class, unregister_class
from bpy.app.handlers import persistent
from mathutils import Vector

from . import global_data
from .utilities.preferences import use_experimental
from .utilities.constants import RenderingConstants
from .utilities.gpu_manager import GPUResourceManager
from .declarations import Operators

logger = logging.getLogger(__name__)

# Performance cache for expensive calculations
class PerformanceCache:
"""Cache expensive calculations to avoid redundant work."""

def __init__(self):
self.reset()

def reset(self):
"""Reset all cached values."""
self._camera_location = None
self._camera_view_matrix_hash = None
self._last_entity_count = 0
self._last_viewport_size = (0, 0)
self._entities_dirty = True

def get_camera_location(self, context):
"""Get cached camera location, recalculating only when view matrix changes."""
if not hasattr(context, 'region_data') or not context.region_data:
return Vector((0, 0, 0))

view_matrix = context.region_data.view_matrix
# Use matrix hash to detect changes efficiently - revert to original working method
current_hash = hash(tuple(view_matrix.col[0]) + tuple(view_matrix.col[1]) +
tuple(view_matrix.col[2]) + tuple(view_matrix.col[3]))

if self._camera_view_matrix_hash != current_hash:
self._camera_location = view_matrix.inverted().translation
self._camera_view_matrix_hash = current_hash

return self._camera_location

def should_redraw_selection_buffer(self, context):
"""Determine if selection buffer needs redrawing based on scene changes."""
current_entity_count = len(context.scene.sketcher.entities.all)
current_viewport_size = (context.region.width, context.region.height)

# Check if view matrix changed (camera moved/rotated/zoomed)
view_matrix_changed = False
if hasattr(context, 'region_data') and context.region_data:
view_matrix = context.region_data.view_matrix
current_hash = hash(tuple(view_matrix.col[0]) + tuple(view_matrix.col[1]) +
tuple(view_matrix.col[2]) + tuple(view_matrix.col[3]))

# If we haven't stored a hash yet, or if it changed, mark as changed
if self._camera_view_matrix_hash is None or self._camera_view_matrix_hash != current_hash:
view_matrix_changed = True
# Update stored hash for next comparison
self._camera_view_matrix_hash = current_hash

# Check if we need to redraw
needs_redraw = (
self._entities_dirty or # Entities were modified/moved
current_entity_count != self._last_entity_count or # Entity count changed
current_viewport_size != self._last_viewport_size or # Viewport resized
view_matrix_changed or # Camera moved/rotated/zoomed
not global_data.offscreen # No offscreen buffer exists
)

if needs_redraw:
self._last_entity_count = current_entity_count
self._last_viewport_size = current_viewport_size
self._entities_dirty = False

return needs_redraw

def mark_entities_dirty(self):
"""Mark entities as dirty to force next selection buffer redraw."""
self._entities_dirty = True

# Global performance cache instance
_perf_cache = PerformanceCache()

# Frame counter for periodic cleanup
_cleanup_frame_counter = 0


def get_entity_distance_from_camera(entity, context, camera_location=None):
"""Calculate the distance from the entity to the camera for depth sorting."""
try:
# Use cached camera location if provided
if camera_location is None:
camera_location = _perf_cache.get_camera_location(context)

# Get entity location (use placement method if available, otherwise try common location attributes)
if hasattr(entity, 'placement'):
entity_location = entity.placement()
elif hasattr(entity, 'location'):
entity_location = entity.location
elif hasattr(entity, 'p1') and hasattr(entity.p1, 'location'):
# For entities like workplanes that have a p1 point
entity_location = entity.p1.location
else:
# Fallback: return a large distance so entity is drawn first (behind others)
return float('inf')

# Calculate distance
if hasattr(entity_location, '__len__') and len(entity_location) >= 3:
entity_pos = Vector(entity_location[:3])
else:
entity_pos = Vector(entity_location)

return (camera_location - entity_pos).length

except (AttributeError, ValueError, TypeError) as e:
# Log specific errors for debugging while gracefully handling them
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Distance calculation failed for entity {getattr(entity, '__class__', type(entity)).__name__}: {e}")
# Return large distance on error so entity is drawn first
return float('inf')


def draw_selection_buffer(context: Context):
"""Draw elements offscreen"""
"""Draw elements offscreen with depth-aware sorting and performance optimizations."""
region = context.region

# create offscreen
width, height = region.width, region.height
offscreen = global_data.offscreen = gpu.types.GPUOffScreen(width, height)

with offscreen.bind():
# Enable depth testing for proper z-buffer behavior
gpu.state.depth_test_set('LESS')
gpu.state.depth_mask_set(True)

fb = gpu.state.active_framebuffer_get()
fb.clear(color=(0.0, 0.0, 0.0, 0.0))
fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)

# Get all selectable entities
entities = []

entities = list(context.scene.sketcher.entities.all)
for e in reversed(entities):
for e in context.scene.sketcher.entities.all:
if e.slvs_index in global_data.ignore_list:
continue
if not hasattr(e, "draw_id"):
continue
if not e.is_selectable(context):
continue
entities.append(e)

# Cache camera location for all distance calculations
camera_location = _perf_cache.get_camera_location(context)

# Custom sorting for better workplane selection:
# 1. Sort by distance from camera (farthest first)
# 2. BUT give workplanes selection priority by treating them as closer
def get_sorting_key(entity):
distance = get_entity_distance_from_camera(entity, context, camera_location)

# Give workplanes selection priority by reducing their effective distance
is_workplane = entity.__class__.__name__ == 'SlvsWorkplane'

if is_workplane:
# Reduce workplane distance significantly to give them selection priority
distance *= RenderingConstants.WORKPLANE_SELECTION_PRIORITY

return distance

# Sort entities by modified distance (farthest first, but workplanes get priority)
entities.sort(key=get_sorting_key, reverse=True)

# Draw entities in distance-sorted order
for e in entities:
e.draw_id(context)

# Restore default depth state
gpu.state.depth_test_set('NONE')
gpu.state.depth_mask_set(False)


def ensure_selection_texture(context: Context):
"""Ensure selection texture is up to date - restored original simple logic."""
if not global_data.redraw_selection_buffer:
return

# Always redraw when flag is set (original behavior)
# This ensures selection works reliably
draw_selection_buffer(context)
global_data.redraw_selection_buffer = False


def update_elements(context: Context, force: bool = False):
"""
TODO: Avoid to always update batches and selection texture
Update entity geometry batches when needed.
"""
entities = list(context.scene.sketcher.entities.all)
entities_updated = False

for e in entities:
if not hasattr(e, "update"):
continue
if not force and not e.is_dirty:
continue
e.update()
entities_updated = True

def _get_msg():
msg = "Update geometry batches:"
for e in entities:
if not e.is_dirty:
continue
msg += "\n - " + str(e)
return msg

if logger.isEnabledFor(logging.DEBUG):
logger.debug(_get_msg())
# Mark selection buffer as needing redraw if entities were updated
if entities_updated:
_perf_cache.mark_entities_dirty()


def draw_elements(context: Context):
Expand All @@ -78,12 +222,102 @@ def draw_elements(context: Context):
def draw_cb():
context = bpy.context

# Ensure performance cache is initialized for loaded files
global _perf_cache
if not hasattr(_perf_cache, '_initialized_for_scene'):
_perf_cache.reset()
_perf_cache.mark_entities_dirty()
_perf_cache._initialized_for_scene = True

force = use_experimental("force_redraw", True)
update_elements(context, force=force)
draw_elements(context)

# Restore original behavior: mark for redraw every frame
# This ensures selection works correctly
global_data.redraw_selection_buffer = True

# Periodic cleanup of GPU resources (time-based instead of frame-based)
cleanup_stats = GPUResourceManager.periodic_cleanup(context)

# Keep the old frame-based cleanup as a backup for very long sessions
global _cleanup_frame_counter
_cleanup_frame_counter += 1
if _cleanup_frame_counter >= (RenderingConstants.CLEANUP_FRAME_INTERVAL * 10): # Much less frequent
# Force cleanup every 10,000 frames as a safety net
GPUResourceManager.force_cleanup_all(context)
_cleanup_frame_counter = 0


def reset_performance_cache():
"""Reset performance cache - call when scene changes significantly."""
global _perf_cache
_perf_cache.reset()


def force_selection_buffer_refresh(context):
"""Force an immediate selection buffer refresh - useful for debugging selection issues."""
global _perf_cache
_perf_cache.mark_entities_dirty()
draw_selection_buffer(context)


def get_cache_status():
"""Get current cache status for debugging."""
global _perf_cache
return {
"entities_dirty": _perf_cache._entities_dirty,
"last_entity_count": _perf_cache._last_entity_count,
"last_viewport_size": _perf_cache._last_viewport_size,
"has_camera_location": _perf_cache._camera_location is not None,
"has_offscreen_buffer": global_data.offscreen is not None
}


@persistent
def load_handler_reset_cache(dummy):
"""Reset performance cache when file is loaded to ensure selection works with existing entities."""
global _perf_cache
_perf_cache.reset()
_perf_cache.mark_entities_dirty()
# Clear scene initialization flag so it gets properly set up
if hasattr(_perf_cache, '_initialized_for_scene'):
delattr(_perf_cache, '_initialized_for_scene')
# Force selection buffer redraw
global_data.redraw_selection_buffer = True

# Clean up GPU resources on file load to prevent accumulation
try:
import bpy
# Use a dummy context for cleanup - should be safe during file load
context = bpy.context
GPUResourceManager.force_cleanup_all(context)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.debug(f"GPU cleanup on file load failed: {e}")

# Ensure Select tool is active to enable click selection - use timer for deferred activation
def activate_select_tool():
"""Deferred function to activate select tool after file load completes."""
from .declarations import WorkSpaceTools
try:
# Check if we're in the right context (3D viewport)
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
# Activate the CAD Sketcher Select tool so click selection works
bpy.ops.wm.tool_set_by_id(name=WorkSpaceTools.Select)
break
except Exception as e:
# Log but don't fail if tool activation fails
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not activate Select tool on file load: {e}")
return None # Don't repeat the timer

# Schedule tool activation for next frame to ensure everything is loaded
bpy.app.timers.register(activate_select_tool, first_interval=0.1)


class View3D_OT_slvs_register_draw_cb(Operator):
bl_idname = Operators.RegisterDrawCB
Expand All @@ -93,7 +327,8 @@ def execute(self, context: Context):
global_data.draw_handle = bpy.types.SpaceView3D.draw_handler_add(
draw_cb, (), "WINDOW", "POST_VIEW"
)

# Reset cache when registering new draw callback
reset_performance_cache()
return {"FINISHED"}


Expand All @@ -109,8 +344,14 @@ def execute(self, context: Context):
def register():
register_class(View3D_OT_slvs_register_draw_cb)
register_class(View3D_OT_slvs_unregister_draw_cb)
# Register file load handler to reset cache when files are loaded
if load_handler_reset_cache not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(load_handler_reset_cache)


def unregister():
unregister_class(View3D_OT_slvs_unregister_draw_cb)
unregister_class(View3D_OT_slvs_register_draw_cb)
# Unregister file load handler
if load_handler_reset_cache in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(load_handler_reset_cache)
13 changes: 11 additions & 2 deletions gizmos/preselection.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from ..declarations import Gizmos, GizmoGroups
from ..draw_handler import ensure_selection_texture
from ..utilities.index import rgb_to_index
from ..utilities.constants import RenderingConstants
from .utilities import context_mode_check


Expand Down Expand Up @@ -37,6 +38,14 @@ def draw(self, context):
pass

def test_select(self, context, location):
mouse_x, mouse_y = location
region = context.region

# Fix for tab boundary freezing: GPU operations hang near viewport/tab boundary
# Only the top edge (where tabs are) causes this issue, other edges are fine
if mouse_y >= (region.height - RenderingConstants.UI_BORDER_MARGIN):
return -1

# reset gizmo highlight
if global_data.highlight_constraint:
global_data.highlight_constraint = None
Expand Down Expand Up @@ -78,11 +87,11 @@ def test_select(self, context, location):


def _spiral(N, M):
x,y = 0,0
x,y = 0,0
dx, dy = 0, -1

for dumb in range(N*M):
if abs(x) == abs(y) and [dx,dy] != [1,0] or x>0 and y == 1-x:
if abs(x) == abs(y) and [dx,dy] != [1,0] or x>0 and y == 1-x:
dx, dy = -dy, dx # corner, change direction

if abs(x)>N/2 or abs(y)>M/2: # non-square
Expand Down
Loading