diff --git a/draw_handler.py b/draw_handler.py index 228c6fb7..be5af85f 100644 --- a/draw_handler.py +++ b/draw_handler.py @@ -1,19 +1,131 @@ -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 @@ -21,34 +133,72 @@ def draw_selection_buffer(context: Context): 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"): @@ -56,17 +206,11 @@ def update_elements(context: Context, force: bool = False): 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): @@ -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 @@ -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"} @@ -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) diff --git a/gizmos/preselection.py b/gizmos/preselection.py index b3e65671..535a03e0 100644 --- a/gizmos/preselection.py +++ b/gizmos/preselection.py @@ -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 @@ -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 @@ -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 diff --git a/global_data.py b/global_data.py index 7a4650b6..eb2bd682 100644 --- a/global_data.py +++ b/global_data.py @@ -30,6 +30,30 @@ COPY_BUFFER = {} +def cleanup_unused_batches(context): + """Clean up GPU batches for entities that no longer exist.""" + if not batches: + return + + # Get all valid entity indices + valid_indices = set() + for entity in context.scene.sketcher.entities.all: + valid_indices.add(entity.slvs_index) + + # Remove batches for entities that no longer exist + invalid_indices = [] + for index in batches.keys(): + if index not in valid_indices: + invalid_indices.append(index) + + for index in invalid_indices: + # GPU batches are automatically cleaned up by Blender when no longer referenced + del batches[index] + + if invalid_indices: + print(f"Cleaned up {len(invalid_indices)} unused GPU batches") + + class WpReq(Enum): """Workplane requirement options""" diff --git a/keymaps.py b/keymaps.py index a1d3779d..034a3c93 100644 --- a/keymaps.py +++ b/keymaps.py @@ -277,6 +277,21 @@ {"type": "V", "value": "PRESS"}, {"properties": [("use_active", True)]}, ), + ( + Operators.Select, + {"type": "LEFTMOUSE", "value": "CLICK"}, + {"properties": [("mode", "SET")]}, + ), + ( + Operators.Select, + {"type": "LEFTMOUSE", "value": "CLICK", "shift": True}, + {"properties": [("mode", "EXTEND")]}, + ), + ( + Operators.Select, + {"type": "LEFTMOUSE", "value": "CLICK", "ctrl": True}, + {"properties": [("mode", "SUBTRACT")]}, + ), ) tool_generic = ( diff --git a/model/arc.py b/model/arc.py index 8ba0f23b..b10ae702 100644 --- a/model/arc.py +++ b/model/arc.py @@ -10,10 +10,12 @@ from mathutils.geometry import intersect_line_sphere_2d, intersect_sphere_sphere_2d from bpy.utils import register_classes_factory +from ..utilities.constants import RenderingConstants +from .vulkan_compat import DashedLineRenderer from ..solver import Solver from .base_entity import SlvsGenericEntity -from .base_entity import Entity2D -from .utilities import slvs_entity_pointer, tag_update +from .base_entity import Entity2D, tag_update +from .utilities import slvs_entity_pointer from .constants import CURVE_RESOLUTION from ..utilities.constants import HALF_TURN, FULL_TURN, QUARTER_TURN from ..utilities.math import range_2pi, pol2cart @@ -24,7 +26,6 @@ create_bezier_curve, round_v, ) -from ..utilities.math import range_2pi, pol2cart logger = logging.getLogger(__name__) @@ -93,19 +94,39 @@ def update(self): offset = p1.angle_signed(Vector((1, 0))) angle = range_2pi(p2.angle_signed(p1)) - # TODO: resolution should depend on segment length?! - segments = round(CURVE_RESOLUTION * (angle / FULL_TURN)) + if self.is_dashed(): + # Create dashed arc geometry + coords = self._create_dashed_arc_coords(radius, angle, offset) + # Transform coordinates + mat_local = Matrix.Translation(self.ct.co.to_3d()) + mat = self.wp.matrix_basis @ mat_local + coords = [(mat @ Vector((*co, 0)))[:] for co in coords] + + # Use LINES for dashed arcs + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINES", kwargs) + else: + # Standard solid arc + # TODO: resolution should depend on segment length?! + segments = round(CURVE_RESOLUTION * (angle / FULL_TURN)) + coords = coords_arc_2d(0, 0, radius, segments, angle=angle, offset=offset) - coords = coords_arc_2d(0, 0, radius, segments, angle=angle, offset=offset) + mat_local = Matrix.Translation(self.ct.co.to_3d()) + mat = self.wp.matrix_basis @ mat_local + coords = [(mat @ Vector((*co, 0)))[:] for co in coords] - mat_local = Matrix.Translation(self.ct.co.to_3d()) - mat = self.wp.matrix_basis @ mat_local - coords = [(mat @ Vector((*co, 0)))[:] for co in coords] + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINE_STRIP", kwargs) - kwargs = {"pos": coords} - self._batch = batch_for_shader(self._shader, "LINE_STRIP", kwargs) self.is_dirty = False + def _create_dashed_arc_coords(self, radius, total_angle, start_offset): + """Create coordinates for a dashed arc with gaps.""" + return DashedLineRenderer.create_dashed_arc_coords( + None, radius, total_angle, start_offset, + max(3, int(CURVE_RESOLUTION * 0.1)) + ) + def create_slvs_data(self, solvesys, group=Solver.group_fixed): handle = solvesys.add_arc( group, diff --git a/model/base_constraint.py b/model/base_constraint.py index 4d95c4a6..2f2a19df 100644 --- a/model/base_constraint.py +++ b/model/base_constraint.py @@ -72,6 +72,12 @@ def dependencies(self) -> List[SlvsGenericEntity]: deps.append(s) return deps + def get_cached_dependencies(self) -> List[SlvsGenericEntity]: + """Get dependencies with caching to avoid repeated calculations.""" + if not hasattr(self, '_dependency_cache'): + self._dependency_cache = self.dependencies() + return self._dependency_cache + # TODO: avoid duplicating code def update_pointers(self, index_old, index_new): def _update(name): @@ -81,6 +87,9 @@ def _update(name): "Update reference {} of {} to {}: ".format(name, self, index_new) ) setattr(self, name, index_new) + # Invalidate dependency cache when references change + if hasattr(self, '_dependency_cache'): + del self._dependency_cache if hasattr(self, "sketch_i"): _update("sketch_i") diff --git a/model/base_entity.py b/model/base_entity.py index a198f34b..700666ec 100644 --- a/model/base_entity.py +++ b/model/base_entity.py @@ -7,6 +7,8 @@ from .. import global_data from ..utilities import preferences +from ..utilities.constants import RenderingConstants +from ..utilities.gpu_manager import ShaderManager from ..shaders import Shaders from ..declarations import Operators from ..utilities.preferences import get_prefs @@ -17,6 +19,15 @@ logger = logging.getLogger(__name__) +def tag_update(self, _context=None): + # context argument ignored + if not self.is_dirty: + self.is_dirty = True + # Invalidate dependency cache when entity changes + if hasattr(self, '_dependency_cache'): + del self._dependency_cache + + class SlvsGenericEntity: def entity_name_getter(self): return self.get("name", str(self)) @@ -34,7 +45,7 @@ def entity_name_setter(self, new_name): fixed: BoolProperty(name="Fixed", update=update_system_cb) visible: BoolProperty(name="Visible", default=True, update=update_cb) origin: BoolProperty(name="Origin") - construction: BoolProperty(name="Construction") + construction: BoolProperty(name="Construction", update=tag_update) props = () dirty: BoolProperty(name="Needs Update", default=True, options={"SKIP_SAVE"}) @@ -63,15 +74,25 @@ def is_dirty(self, value: bool): @property def _shader(self): + """ + Get the appropriate cached shader for this entity. + + Uses geometry-based rendering approach for all backends: + - Points: Cached UNIFORM_COLOR shader (for triangle-based point geometry) + - Lines: Cached POLYLINE_UNIFORM_COLOR shader (for proper line width support) + + Returns: + GPUShader: Appropriate cached shader for entity type + """ if self.is_point(): - return Shaders.uniform_color_3d() - return Shaders.uniform_color_line_3d() + return ShaderManager.get_uniform_color_shader() + # For lines, always use cached POLYLINE_UNIFORM_COLOR for proper line width + return ShaderManager.get_polyline_shader() @property def _id_shader(self): - if self.is_point(): - return Shaders.id_shader_3d() - return Shaders.id_line_3d() + """Get the appropriate cached ID shader for selection rendering.""" + return ShaderManager.get_id_shader(is_point=self.is_point()) @property def point_size(self): @@ -85,8 +106,8 @@ def point_size_select(self): def line_width(self): scale = preferences.get_scale() if self.construction: - return 1.5 * scale - return 2 * scale + return RenderingConstants.LINE_WIDTH_CONSTRUCTION * scale + return RenderingConstants.LINE_WIDTH_REGULAR * scale @property def line_width_select(self): @@ -159,6 +180,11 @@ def is_selectable(self, context: Context): if preferences.use_experimental("all_entities_selectable", False): return True + # Workplanes should always be selectable - users need to be able to select them + # to switch between workplanes even when there's an active sketch + if self.__class__.__name__ == 'SlvsWorkplane': + return True + active_sketch = context.scene.sketcher.active_sketch if active_sketch and hasattr(self, "sketch"): # Allow to select entities that share the active sketch's wp @@ -212,8 +238,17 @@ def is_dashed(self): return False def draw(self, context): + """ + Render this entity using geometry-based rendering. + + Uses POLYLINE_UNIFORM_COLOR shader for lines with lineWidth/viewportSize uniforms. + Points are rendered as triangle geometry using UNIFORM_COLOR shader. + + Args: + context: Blender context containing viewport and region information + """ if not self.is_visible(context): - return None + return batch = self._batch if not batch: @@ -221,18 +256,29 @@ def draw(self, context): shader = self._shader shader.bind() - gpu.state.blend_set("ALPHA") - gpu.state.point_size_set(self.point_size) col = self.color(context) shader.uniform_float("color", col) - if not self.is_point(): - shader.uniform_bool("dashed", (self.is_dashed(),)) - shader.uniform_float("dash_width", 0.05) - shader.uniform_float("dash_factor", 0.3) - gpu.state.line_width_set(self.line_width) + if self.is_point(): + # Points are already rendered as triangles, no additional setup needed + pass + else: + # For lines, use POLYLINE_UNIFORM_COLOR with proper uniforms + try: + shader.uniform_float("lineWidth", self.line_width) + # Try viewportSize as tuple first, then as separate components + try: + shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + except (AttributeError, ValueError) as e: + logger.debug(f"viewportSize tuple failed, trying components: {e}") + shader.uniform_float("viewportSize[0]", float(context.region.width)) + shader.uniform_float("viewportSize[1]", float(context.region.height)) + except (AttributeError, ValueError, TypeError) as e: + # Fall back to OpenGL state if uniforms fail + logger.debug(f"Line uniform setup failed, falling back to OpenGL state: {e}") + gpu.state.line_width_set(self.line_width) batch.draw(shader) gpu.shader.unbind() @@ -256,7 +302,6 @@ def draw_id(self, context): if not self.is_point(): # viewport = [context.area.width, context.area.height] # shader.uniform_float("Viewport", viewport) - shader.uniform_bool("dashed", (False,)) gpu.state.line_width_set(self.line_width_select) batch.draw(shader) @@ -296,6 +341,12 @@ def connection_points(self): def dependencies(self) -> List["SlvsGenericEntity"]: return [] + def get_cached_dependencies(self) -> List["SlvsGenericEntity"]: + """Get dependencies with caching to avoid repeated calculations.""" + if not hasattr(self, '_dependency_cache'): + self._dependency_cache = self.dependencies() + return self._dependency_cache + def draw_props(self, layout): is_experimental = preferences.is_experimental() @@ -331,11 +382,6 @@ def draw_props(self, layout): return sub - def tag_update(self, _context=None): - # context argument ignored - if not self.is_dirty: - self.is_dirty = True - def new(self, context: Context, **kwargs): """Create new entity based on this instance""" raise NotImplementedError diff --git a/model/circle.py b/model/circle.py index fccacee2..2fb500b1 100644 --- a/model/circle.py +++ b/model/circle.py @@ -10,11 +10,13 @@ from mathutils.geometry import intersect_line_sphere_2d, intersect_sphere_sphere_2d from bpy.utils import register_classes_factory +from ..utilities.constants import RenderingConstants +from .vulkan_compat import DashedLineRenderer from ..solver import Solver from ..utilities.math import range_2pi, pol2cart from .base_entity import SlvsGenericEntity -from .base_entity import Entity2D -from .utilities import slvs_entity_pointer, tag_update +from .base_entity import Entity2D, tag_update +from .utilities import slvs_entity_pointer from .constants import CURVE_RESOLUTION from ..utilities.constants import HALF_TURN, FULL_TURN from ..utilities.draw import coords_arc_2d @@ -68,18 +70,74 @@ def update(self): if bpy.app.background: return - coords = coords_arc_2d(0, 0, self.radius, CURVE_RESOLUTION) + if self.is_dashed(): + # Create dashed circle geometry + coords = self._create_dashed_circle_coords() + else: + # Standard solid circle + coords = coords_arc_2d(0, 0, self.radius, CURVE_RESOLUTION) u, v = self.ct.co - mat_local = Matrix.Translation(Vector((u, v, 0))) mat = self.wp.matrix_basis @ mat_local coords = [(mat @ Vector((*co, 0)))[:] for co in coords] - kwargs = {"pos": coords} - self._batch = batch_for_shader(self._shader, "LINE_STRIP", kwargs) + if self.is_dashed(): + # For dashed circles, use LINES instead of LINE_STRIP + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINES", kwargs) + else: + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINE_STRIP", kwargs) self.is_dirty = False + def _create_dashed_circle_coords(self): + """Create coordinates for a dashed circle with gaps.""" + radius = self.radius + if radius <= 0: + return [] + + # Calculate segments per dash based on arc length + dash_arc_length = RenderingConstants.DASH_LENGTH + gap_arc_length = RenderingConstants.GAP_LENGTH + + # Convert to angles + dash_angle = dash_arc_length / radius + gap_angle = gap_arc_length / radius + pattern_angle = dash_angle + gap_angle + + # Number of complete patterns that fit in a full circle + num_patterns = int(FULL_TURN / pattern_angle) + + coords = [] + current_angle = 0.0 + + # Use reasonable segment resolution for each dash + segments_per_dash = max(3, int(CURVE_RESOLUTION * dash_angle / FULL_TURN)) + + for i in range(num_patterns): + # Create dash segment + dash_start = current_angle + dash_end = min(current_angle + dash_angle, FULL_TURN) + + if dash_end > dash_start: + dash_coords = coords_arc_2d(0, 0, radius, segments_per_dash, + angle=(dash_end - dash_start), + offset=dash_start) + + # Convert to line segments (pairs of points) + for j in range(len(dash_coords) - 1): + coords.extend([dash_coords[j], dash_coords[j + 1]]) + + # Move to next dash (skip gap) + current_angle += pattern_angle + + # Stop if we've covered the entire circle + if current_angle >= FULL_TURN: + break + + return coords + def create_slvs_data(self, solvesys, group=Solver.group_fixed): self.param_distance = solvesys.add_distance(group, self.radius, self.wp.py_data) diff --git a/model/group_constraints.py b/model/group_constraints.py index c58d4a80..c271e1d0 100644 --- a/model/group_constraints.py +++ b/model/group_constraints.py @@ -79,7 +79,17 @@ def new_from_type(self, type: str) -> GenericConstraint: """ name = type.lower() constraint_list = getattr(self, name) - return constraint_list.add() + constraint = constraint_list.add() + + # Invalidate performance cache when constraint is created + from ..draw_handler import reset_performance_cache + reset_performance_cache() + + # Invalidate constraint existence cache + from ..operators.base_constraint import _invalidate_constraint_cache + _invalidate_constraint_cache() + + return constraint def get_lists(self): lists = [] @@ -132,6 +142,14 @@ def remove(self, constr: GenericConstraint): i = self.get_index(constr) self.get_list(constr.type).remove(i) + # Invalidate performance cache when constraint is removed + from ..draw_handler import reset_performance_cache + reset_performance_cache() + + # Invalidate constraint existence cache + from ..operators.base_constraint import _invalidate_constraint_cache + _invalidate_constraint_cache() + @property def dimensional(self): for constraint_type in self._dimensional_constraints: diff --git a/model/group_entities.py b/model/group_entities.py index c5aaeeb8..af03e136 100644 --- a/model/group_entities.py +++ b/model/group_entities.py @@ -150,6 +150,10 @@ def remove(self, index: int): entity_list, i = self._get_list_and_index(index) entity_list.remove(i) + # Invalidate performance cache when entity is removed + from ..draw_handler import reset_performance_cache + reset_performance_cache() + # Put last item to removed index and update all pointers to it last_index = len(entity_list) - 1 @@ -174,6 +178,10 @@ def _init_entity(self, entity, fixed, construction, index_reference, visible=Tru index = self._set_index(entity) + # Invalidate performance cache when entity is created + from ..draw_handler import reset_performance_cache + reset_performance_cache() + if index_reference: return index return entity diff --git a/model/line_2d.py b/model/line_2d.py index 8e02370a..6294e3d2 100644 --- a/model/line_2d.py +++ b/model/line_2d.py @@ -9,6 +9,8 @@ from mathutils import Matrix, Vector from mathutils.geometry import intersect_line_line, intersect_line_line_2d +from ..utilities.constants import RenderingConstants +from .vulkan_compat import GeometryRenderer, DashedLineRenderer from ..solver import Solver from .base_entity import SlvsGenericEntity from .base_entity import Entity2D @@ -19,7 +21,7 @@ logger = logging.getLogger(__name__) -class SlvsLine2D(Entity2D, PropertyGroup): +class SlvsLine2D(Entity2D, GeometryRenderer, PropertyGroup): """Representation of a line in 2D space. Connects p1 and p2 and lies on the sketche's workplane. @@ -51,11 +53,22 @@ def update(self): if bpy.app.background: return + # Safety check for workplane reference + if not self.wp: + logger.warning(f"Line2D {self} has no workplane reference, skipping update") + return + p1, p2 = self.p1.location, self.p2.location - coords = (p1, p2) - kwargs = {"pos": coords} - self._batch = batch_for_shader(self._shader, "LINES", kwargs) + if self.is_dashed(): + # Create dashed line geometry using utility + coords = DashedLineRenderer.create_dashed_coords(p1, p2) + self._batch = self.setup_line_rendering(coords, is_dashed=True) + else: + # Standard solid line + coords = (p1, p2) + self._batch = self.setup_line_rendering(coords, is_dashed=False) + self.is_dirty = False def create_slvs_data(self, solvesys, group=Solver.group_fixed): diff --git a/model/line_3d.py b/model/line_3d.py index 5f9fce83..26a3d183 100644 --- a/model/line_3d.py +++ b/model/line_3d.py @@ -6,6 +6,8 @@ from gpu_extras.batch import batch_for_shader from bpy.utils import register_classes_factory +from ..utilities.constants import RenderingConstants +from .vulkan_compat import DashedLineRenderer from ..solver import Solver from .base_entity import SlvsGenericEntity from .utilities import slvs_entity_pointer @@ -45,10 +47,17 @@ def update(self): return p1, p2 = self.p1.location, self.p2.location - coords = (p1, p2) - kwargs = {"pos": coords} - self._batch = batch_for_shader(self._shader, "LINES", kwargs) + if self.is_dashed(): + # Create dashed line geometry + coords = DashedLineRenderer.create_dashed_coords(p1, p2) + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINES", kwargs) + else: + # Standard solid line + coords = (p1, p2) + kwargs = {"pos": coords} + self._batch = batch_for_shader(self._shader, "LINES", kwargs) self.is_dirty = False diff --git a/model/normal_3d.py b/model/normal_3d.py index 74785dd1..e584a706 100644 --- a/model/normal_3d.py +++ b/model/normal_3d.py @@ -6,7 +6,7 @@ from mathutils import Euler from ..solver import Solver -from .base_entity import SlvsGenericEntity +from .base_entity import SlvsGenericEntity, tag_update logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ def set_orientation(self, value): description="Quaternion which describes the orientation of the normal", subtype="QUATERNION", size=4, - update=SlvsGenericEntity.tag_update, + update=tag_update, ) ui_orientation: FloatVectorProperty( @@ -60,7 +60,7 @@ def set_orientation(self, value): get=get_orientation, set=set_orientation, options={"SKIP_SAVE"}, - update=SlvsGenericEntity.tag_update, + update=tag_update, ) props = ("ui_orientation",) diff --git a/model/point_2d.py b/model/point_2d.py index 4d01a124..442a834d 100644 --- a/model/point_2d.py +++ b/model/point_2d.py @@ -1,43 +1,39 @@ import logging from typing import List -import bpy from bpy.types import PropertyGroup from bpy.props import FloatVectorProperty -from gpu_extras.batch import batch_for_shader from mathutils import Matrix, Vector from bpy.utils import register_classes_factory -from ..utilities.draw import draw_rect_2d from ..solver import Solver -from .base_entity import SlvsGenericEntity -from .base_entity import Entity2D +from .base_entity import SlvsGenericEntity, Entity2D, tag_update from .utilities import slvs_entity_pointer, make_coincident from .line_2d import SlvsLine2D +from .vulkan_compat import BillboardPointRenderer logger = logging.getLogger(__name__) -class Point2D(Entity2D): +class Point2D(Entity2D, BillboardPointRenderer): @classmethod def is_point(cls): return True - def update(self): - if bpy.app.background: - return - + def get_point_location_3d(self): + """Get the 3D location for point rendering.""" u, v = self.co mat_local = Matrix.Translation(Vector((u, v, 0))) - mat = self.wp.matrix_basis @ mat_local - size = 0.1 - coords = draw_rect_2d(0, 0, size, size) - coords = [(mat @ Vector(co))[:] for co in coords] - indices = ((0, 1, 2), (0, 2, 3)) - pos = self.location - self._batch = batch_for_shader(self._shader, "POINTS", {"pos": (pos[:],)}) - self.is_dirty = False + return mat @ Vector((0, 0, 0)) + + def update(self): + """Update screen-space point geometry.""" + return self.update_billboard_point() + + def draw(self, context): + """Draw billboard point with camera-facing geometry.""" + return self.draw_billboard_point(context) @property def location(self): @@ -79,8 +75,9 @@ class SlvsPoint2D(Point2D, PropertyGroup): subtype="XYZ", size=2, unit="LENGTH", - update=SlvsGenericEntity.tag_update, + update=tag_update, ) + props = ("co",) def dependencies(self) -> List[SlvsGenericEntity]: return [ diff --git a/model/point_3d.py b/model/point_3d.py index fac7162b..8b2e1dbd 100644 --- a/model/point_3d.py +++ b/model/point_3d.py @@ -1,33 +1,34 @@ import logging -import bpy from bpy.types import PropertyGroup from bpy.props import FloatVectorProperty -from gpu_extras.batch import batch_for_shader from bpy.utils import register_classes_factory -from ..utilities.draw import draw_cube_3d from ..solver import Solver from .base_entity import SlvsGenericEntity +from .base_entity import tag_update +from .vulkan_compat import BillboardPointRenderer logger = logging.getLogger(__name__) -class Point3D(SlvsGenericEntity): +class Point3D(SlvsGenericEntity, BillboardPointRenderer): @classmethod def is_point(cls): return True + def get_point_location_3d(self): + """Get the 3D location for point rendering.""" + return self.location + def update(self): - if bpy.app.background: - return + """Update screen-space point geometry.""" + return self.update_billboard_point() - coords, indices = draw_cube_3d(*self.location, 0.05) - self._batch = batch_for_shader( - self._shader, "POINTS", {"pos": (self.location[:],)} - ) - self.is_dirty = False + def draw(self, context): + """Draw billboard point with camera-facing geometry.""" + return self.draw_billboard_point(context) # TODO: maybe rename -> pivot_point, midpoint def placement(self): @@ -60,7 +61,7 @@ class SlvsPoint3D(Point3D, PropertyGroup): description="The location of the point", subtype="XYZ", unit="LENGTH", - update=SlvsGenericEntity.tag_update, + update=tag_update, ) props = ("location",) diff --git a/model/utilities.py b/model/utilities.py index 99234dc4..e29d57e2 100644 --- a/model/utilities.py +++ b/model/utilities.py @@ -31,8 +31,7 @@ def setter(self, entity): setattr(cls, name, setter) -def tag_update(self, context: Context): - self.tag_update() + def round_v(vec, ndigits=None): diff --git a/model/vulkan_compat.py b/model/vulkan_compat.py new file mode 100644 index 00000000..143825d3 --- /dev/null +++ b/model/vulkan_compat.py @@ -0,0 +1,221 @@ +""" +Geometry-based rendering utilities for CAD Sketcher entities. + +This module provides common functionality for geometry-based rendering +to ensure consistent visual appearance across all GPU backends. +""" + +import logging +import bpy +import gpu +from gpu_extras.batch import batch_for_shader +from mathutils import Vector + +from ..utilities.constants import RenderingConstants +from ..utilities.draw import draw_billboard_quad_3d, pixel_size_to_world_size +from ..utilities.gpu_manager import ShaderManager + +logger = logging.getLogger(__name__) + + +class GeometryRenderer: + """Mixin class providing geometry-based rendering methods for entities.""" + + def create_batch(self, coords, batch_type="LINES", indices=None): + """Create a GPU batch with appropriate parameters.""" + kwargs = {"pos": coords} + # Use cached shader instead of _shader property + shader = ShaderManager.get_polyline_shader() if batch_type in ("LINES", "LINE_STRIP") else ShaderManager.get_uniform_color_shader() + if indices is not None: + return batch_for_shader(shader, batch_type, kwargs, indices=indices) + return batch_for_shader(shader, batch_type, kwargs) + + def setup_line_rendering(self, coords, is_dashed=False): + """Setup line rendering with proper batch type.""" + if is_dashed: + # Dashed lines use LINES (individual segments) + return self.create_batch(coords, "LINES") + else: + # Solid lines can use LINE_STRIP for efficiency + return self.create_batch(coords, "LINE_STRIP") + + def setup_point_rendering(self, coords, indices): + """Setup point rendering using triangle geometry.""" + # Points as triangles for all backends + return self.create_batch(coords, "TRIS", indices) + + +class BillboardPointRenderer: + """Mixin class providing screen-space point rendering.""" + + def get_point_location_3d(self): + """Get the 3D location for point rendering. Override in subclasses.""" + raise NotImplementedError("Subclasses must implement get_point_location_3d()") + + def update_billboard_point(self): + """Update method for billboard points - creates base geometry.""" + if bpy.app.background: + return + + # Mark as needing update but don't create geometry until needed + self._needs_billboard_update = True + self.is_dirty = False + + def _ensure_billboard_geometry(self, context): + """Lazy creation of billboard geometry only when needed for drawing.""" + if not getattr(self, '_needs_billboard_update', True): + return + + location_3d = self.get_point_location_3d() + # Use fixed pixel size for point handles + pixel_size = RenderingConstants.POINT_HANDLE_PIXEL_SIZE + world_size = pixel_size_to_world_size(location_3d, pixel_size, context) + coords, indices = draw_billboard_quad_3d(*location_3d, world_size) + shader = ShaderManager.get_uniform_color_shader() + self._batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices) + self._cached_view_distance = None # Reset cache + self._needs_billboard_update = False + + def draw_billboard_point(self, context): + """Draw method for billboard points - efficient with view-change detection.""" + if not self.is_visible(context): + return + + # Ensure geometry exists (lazy initialization) + self._ensure_billboard_geometry(context) + + # Lazy initialization of cache + if not hasattr(self, '_cached_view_distance'): + self._cached_view_distance = None + + # Check if view has changed and we need to regenerate geometry + current_view_distance = None + if hasattr(context, 'region_data') and context.region_data: + current_view_distance = getattr(context.region_data, 'view_distance', 1.0) + + # Only regenerate geometry if view distance changed significantly or viewport size changed + needs_update = ( + self._cached_view_distance is None or # First time + (current_view_distance is None) != (self._cached_view_distance is None) or # None state changed + (current_view_distance is not None and self._cached_view_distance is not None and + abs(current_view_distance - self._cached_view_distance) > RenderingConstants.VIEW_CHANGE_THRESHOLD) # Significant change + ) + + if needs_update: + location_3d = self.get_point_location_3d() + pixel_size = RenderingConstants.POINT_HANDLE_PIXEL_SIZE + world_size = pixel_size_to_world_size(location_3d, pixel_size, context) + coords, indices = draw_billboard_quad_3d(*location_3d, world_size) + shader = ShaderManager.get_uniform_color_shader() + self._batch = batch_for_shader(shader, "TRIS", {"pos": coords}, indices=indices) + self._cached_view_distance = current_view_distance + + # Efficient rendering of cached geometry using cached shader + shader = ShaderManager.get_uniform_color_shader() + shader.bind() + gpu.state.blend_set("ALPHA") + + col = self.color(context) + shader.uniform_float("color", col) + + batch = self._batch + if batch: + batch.draw(shader) + + gpu.shader.unbind() + self.restore_opengl_defaults() + + +class DashedLineRenderer: + """Utility class for creating dashed line geometry.""" + + @staticmethod + def create_dashed_coords(start_point, end_point): + """Create dashed line coordinates between two points.""" + line_vec = end_point - start_point + line_length = line_vec.length + + if line_length == 0: + return [start_point, end_point] + + # Use centralized constants + dash_length = RenderingConstants.DASH_LENGTH + pattern_length = RenderingConstants.dash_pattern_length() + + coords = [] + direction = line_vec.normalized() + + current_pos = 0.0 + while current_pos < line_length: + # Start of dash + dash_start = start_point + direction * current_pos + dash_end_pos = min(current_pos + dash_length, line_length) + dash_end = start_point + direction * dash_end_pos + + # Add dash segment + coords.extend([dash_start, dash_end]) + + # Move to next dash (skip gap) + current_pos += pattern_length + + return coords + + @staticmethod + def create_dashed_arc_coords(center, radius, total_angle, start_offset, segments_per_dash): + """Create dashed arc coordinates.""" + from ..utilities.draw import coords_arc_2d + import math + + if radius <= 0 or total_angle <= 0: + return [] + + # Convert world units to angular measurements + dash_length_world = RenderingConstants.DASH_LENGTH + gap_length_world = RenderingConstants.GAP_LENGTH + + dash_angle = dash_length_world / radius + gap_angle = gap_length_world / radius + pattern_angle = dash_angle + gap_angle + + # Calculate number of complete patterns that fit in the arc + num_patterns = int(total_angle / pattern_angle) + + coords = [] + current_angle = 0.0 + + for i in range(num_patterns): + # Create dash segment within the arc + dash_start = current_angle + dash_end = min(current_angle + dash_angle, total_angle) + + if dash_end > dash_start: + # Generate points for this dash + dash_coords = coords_arc_2d(0, 0, radius, segments_per_dash, + angle=(dash_end - dash_start), + offset=(start_offset + dash_start)) + + # Convert to line segments (pairs of points) + for j in range(len(dash_coords) - 1): + coords.extend([dash_coords[j], dash_coords[j + 1]]) + + # Move to next dash (skip gap) + current_angle += pattern_angle + + # Stop if we've covered the entire arc + if current_angle >= total_angle: + break + + # Add final partial dash if there's remaining arc length + if current_angle < total_angle: + dash_start = current_angle + dash_end = total_angle + + if dash_end > dash_start: + dash_coords = coords_arc_2d(0, 0, radius, segments_per_dash, + angle=(dash_end - dash_start), + offset=(start_offset + dash_start)) + + for j in range(len(dash_coords) - 1): + coords.extend([dash_coords[j], dash_coords[j + 1]]) + + return coords \ No newline at end of file diff --git a/model/workplane.py b/model/workplane.py index 98ace021..98a0498b 100644 --- a/model/workplane.py +++ b/model/workplane.py @@ -11,6 +11,7 @@ from ..declarations import Operators from .. import global_data from ..utilities.draw import draw_rect_2d +from ..utilities.gpu_manager import ShaderManager from ..shaders import Shaders from ..utilities import preferences from ..solver import Solver @@ -72,7 +73,7 @@ def draw(self, context): # Additionally draw a face col_surface = col[:-1] + (0.2,) - shader = Shaders.uniform_color_3d() + shader = ShaderManager.get_uniform_color_shader() shader.bind() gpu.state.blend_set("ALPHA") @@ -91,8 +92,35 @@ def draw_id(self, context): scale = context.region_data.view_distance gpu.matrix.multiply_matrix(self.matrix_basis) gpu.matrix.scale(Vector((scale, scale, scale))) + + # Draw both the outline (lines) and the surface (triangles) for selection + # This makes the entire plane selectable, not just the edges super().draw_id(context) + # Draw workplane surface both behind and slightly in front of outline + # This creates maximum selectability while preserving outline visibility + shader = self._id_shader + shader.bind() + + from ..utilities.index import index_to_rgb + shader.uniform_float("color", (*index_to_rgb(self.slvs_index), 1.0)) + + coords = draw_rect_2d(0, 0, self.size, self.size) + + # Draw surface behind outline (for areas not covered by outline) + coords_behind = [(co[0], co[1], co[2] - 0.0001) for co in coords] + indices = ((0, 1, 2), (0, 2, 3)) + batch_behind = batch_for_shader(shader, "TRIS", {"pos": coords_behind}, indices=indices) + batch_behind.draw(shader) + + # Draw surface slightly in front of outline (for better selection area) + coords_front = [(co[0], co[1], co[2] + 0.0001) for co in coords] + batch_front = batch_for_shader(shader, "TRIS", {"pos": coords_front}, indices=indices) + batch_front.draw(shader) + + gpu.shader.unbind() + self.restore_opengl_defaults() + def create_slvs_data(self, solvesys, group=Solver.group_fixed): handle = solvesys.add_workplane(group, self.p1.py_data, self.nm.py_data) self.py_data = handle diff --git a/operators/base_constraint.py b/operators/base_constraint.py index ee0fc27c..d4febe75 100644 --- a/operators/base_constraint.py +++ b/operators/base_constraint.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import List, Dict, Set, Tuple from bpy.types import Context from bpy.props import BoolProperty @@ -16,6 +16,35 @@ state_docstr = "Pick entity to constrain." +# Global cache for constraint existence checks +_constraint_existence_cache: Dict[Tuple, Set[Tuple]] = {} +_cache_invalidated = True + +def _invalidate_constraint_cache(): + """Invalidate the constraint existence cache when constraints are added/removed.""" + global _cache_invalidated + _cache_invalidated = True + +def _get_constraint_existence_cache(context): + """Get or build the constraint existence cache.""" + global _constraint_existence_cache, _cache_invalidated + + if _cache_invalidated: + _constraint_existence_cache.clear() + + # Build cache: constraint_type -> set of dependency tuples + for c in context.scene.sketcher.constraints.all: + constraint_type = type(c) + dependencies = tuple(sorted(e.slvs_index for e in c.dependencies() if e)) + + if constraint_type not in _constraint_existence_cache: + _constraint_existence_cache[constraint_type] = set() + _constraint_existence_cache[constraint_type].add(dependencies) + + _cache_invalidated = False + + return _constraint_existence_cache + class GenericConstraintOp(Operator2d): initialized: BoolProperty(default=False, options={"SKIP_SAVE", "HIDDEN"}) @@ -132,12 +161,14 @@ def exists(self, context, constraint_type=None, max_constraints=1) -> bool: else: new_dependencies = [i for i in [self.entity1, self.sketch] if i is not None] - constraint_counter = 0 - for c in context.scene.sketcher.constraints.all: - if isinstance(c, constraint_type): - if set(c.dependencies()) == set(new_dependencies): - constraint_counter += 1 - if constraint_counter >= max_constraints: - return True + # Convert to sorted tuple of indices for cache lookup + new_deps_tuple = tuple(sorted(e.slvs_index for e in new_dependencies if e)) + + # Use cached constraint existence data + cache = _get_constraint_existence_cache(context) + existing_deps = cache.get(constraint_type, set()) + + # Count how many constraints match our dependencies + constraint_counter = sum(1 for deps in existing_deps if deps == new_deps_tuple) - return False + return constraint_counter >= max_constraints diff --git a/operators/delete_constraint.py b/operators/delete_constraint.py index bf1fa676..b75e8f65 100644 --- a/operators/delete_constraint.py +++ b/operators/delete_constraint.py @@ -8,6 +8,7 @@ from ..solver import solve_system from ..declarations import Operators from ..utilities.highlighting import HighlightElement +from ..draw_handler import reset_performance_cache logger = logging.getLogger(__name__) @@ -42,6 +43,9 @@ def execute(self, context: Context): constraints.remove(constr) + # Invalidate performance cache when constraint is deleted + reset_performance_cache() + sketch = context.scene.sketcher.active_sketch solve_system(context, sketch=sketch) refresh(context) diff --git a/operators/select.py b/operators/select.py index 5e02118d..78ef6069 100644 --- a/operators/select.py +++ b/operators/select.py @@ -34,19 +34,27 @@ def execute(self, context: Context): hit = index != -1 mode = self.mode + # Debug logging to help troubleshoot selection issues + import logging + logger = logging.getLogger(__name__) + logger.debug(f"Selection operator: index={index}, hover={global_data.hover}, hit={hit}, mode={mode}") + if mode == "SET" or not hit: deselect_all(context) if hit: entity = context.scene.sketcher.entities.get(index) - - value = True - if mode == "SUBTRACT": - value = False - if mode == "TOGGLE": - value = not entity.selected - - entity.selected = value + if entity: + value = True + if mode == "SUBTRACT": + value = False + if mode == "TOGGLE": + value = not entity.selected + + entity.selected = value + logger.debug(f"Selected entity {entity} with value {value}") + else: + logger.warning(f"Could not find entity with index {index}") context.area.tag_redraw() return {"FINISHED"} diff --git a/operators/select_box.py b/operators/select_box.py index e3cfe31e..6279d443 100644 --- a/operators/select_box.py +++ b/operators/select_box.py @@ -9,6 +9,7 @@ from ..utilities.index import rgb_to_index from ..utilities.view import refresh from ..utilities.select import mode_property, deselect_all +from ..shaders import Shaders def get_start_dist(value1, value2, invert: bool = False): @@ -19,9 +20,12 @@ def get_start_dist(value1, value2, invert: bool = False): def draw_callback_px(self, context): - shader = gpu.shader.from_builtin("UNIFORM_COLOR") + """Draw selection box using cached POLYLINE_UNIFORM_COLOR shader.""" + # Use cached POLYLINE_UNIFORM_COLOR shader for consistent rendering + from ..utilities.gpu_manager import ShaderManager + shader = ShaderManager.get_polyline_shader() + gpu.state.blend_set("ALPHA") - gpu.state.line_width_set(2.0) start = self.start_coords end = self.mouse_pos @@ -30,9 +34,18 @@ def draw_callback_px(self, context): batch = batch_for_shader(shader, "LINE_STRIP", {"pos": box_path}) shader.bind() shader.uniform_float("color", (0.0, 0.0, 0.0, 0.5)) + + # Set line width uniforms for POLYLINE_UNIFORM_COLOR + try: + shader.uniform_float("lineWidth", 2.0) + shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + except (AttributeError, ValueError): + # Fall back to OpenGL state if uniforms fail + gpu.state.line_width_set(2.0) + batch.draw(shader) - # restore opengl defaults + # Restore OpenGL defaults gpu.state.line_width_set(1.0) gpu.state.blend_set("NONE") diff --git a/operators/update.py b/operators/update.py index c873a940..1932272b 100644 --- a/operators/update.py +++ b/operators/update.py @@ -1,23 +1,20 @@ -from bpy.types import Operator, Context +from bpy.types import Operator from bpy.utils import register_classes_factory -from ..declarations import Operators -from ..solver import Solver +from ..solver import solve_system from ..converters import update_convertor_geometry -class View3D_OT_update(Operator): - """Solve all sketches and update converted geometry""" - - bl_idname = Operators.Update - bl_label = "Force Update" - - def execute(self, context: Context): - solver = Solver(context, None, all=True) - solver.solve() +class VIEW3D_OT_update(Operator): + bl_idname = "view3d.slvs_update" + bl_label = "Update" + def execute(self, context): update_convertor_geometry(context.scene) - return {"FINISHED"} + solve_system(context) + return {'FINISHED'} -register, unregister = register_classes_factory((View3D_OT_update,)) +register, unregister = register_classes_factory(( + VIEW3D_OT_update, +)) diff --git a/shaders.py b/shaders.py index 46d776fb..f5dc78b5 100644 --- a/shaders.py +++ b/shaders.py @@ -1,7 +1,6 @@ import gpu from gpu.types import GPUShader, GPUShaderCreateInfo, GPUStageInterfaceInfo from gpu.shader import create_from_info -from bpy import app import sys @@ -16,79 +15,6 @@ class Shaders: - base_vertex_shader_3d = """ - void main() { - gl_Position = ModelViewProjectionMatrix * vec4(pos.xyz, 1.0f); - - vec2 ssPos = vec2(gl_Position.xy / gl_Position.w); - segment_start = stipple_pos = ssPos; - } - """ - base_fragment_shader_3d = """ - void main() { - - vec2 delta = stipple_pos - segment_start; - vec2 stipple_start; - if (abs(delta.x) > abs(delta.y)) { - stipple_start.x = 0; - float t = -segment_start.x / delta.x; - stipple_start.y = segment_start.y + t * delta.y; - } - else { - stipple_start.y = 0; - float t = -segment_start.y / delta.y; - stipple_start.x = segment_start.x + t * delta.x; - } - float distance_along_line = distance(stipple_pos, stipple_start); - float normalized_distance = fract(distance_along_line / dash_width); - - if (dashed == true) { - if (normalized_distance <= dash_factor) { - discard; - } - else { - fragColor = color; - } - } - else { - fragColor = color; - } - - } - """ - - @classmethod - def get_base_shader_3d_info(cls): - - vert_out = GPUStageInterfaceInfo("stipple_pos_interface") - vert_out.no_perspective("VEC2", "stipple_pos") - vert_out.flat("VEC2", "segment_start") - - # NOTE: How to set default values? - - shader_info = GPUShaderCreateInfo() - shader_info.push_constant("MAT4", "ModelViewProjectionMatrix") - shader_info.push_constant("VEC4", "color") - shader_info.push_constant("FLOAT", "dash_width") - shader_info.push_constant("FLOAT", "dash_factor") - # shader_info.push_constant("VEC2", "Viewport") - shader_info.push_constant("BOOL", "dashed") - shader_info.vertex_in(0, "VEC3", "pos") - shader_info.vertex_out(vert_out) - shader_info.fragment_out(0, "VEC4", "fragColor") - - shader_info.vertex_source(cls.base_vertex_shader_3d) - shader_info.fragment_source(cls.base_fragment_shader_3d) - - return shader_info - - @staticmethod - @cache - def uniform_color_3d(): - if app.version < (3, 5): - return gpu.shader.from_builtin("3D_UNIFORM_COLOR") - return gpu.shader.from_builtin("UNIFORM_COLOR") - @classmethod @cache def uniform_color_image_2d(cls): @@ -132,24 +58,10 @@ def uniform_color_image_2d(cls): del shader_info return shader - @classmethod - @cache - def id_line_3d(cls): - shader = cls.uniform_color_line_3d() - return shader - - @classmethod - @cache - def uniform_color_line_3d(cls): - - shader_info = cls.get_base_shader_3d_info() - shader = create_from_info(shader_info) - del shader_info - return shader - @staticmethod @cache def id_shader_3d(): + """Simple ID shader for selection rendering (both points and lines).""" shader_info = GPUShaderCreateInfo() shader_info.push_constant("MAT4", "ModelViewProjectionMatrix") shader_info.push_constant("VEC4", "color") @@ -173,40 +85,7 @@ def id_shader_3d(): } """ ) - + shader = create_from_info(shader_info) del shader_info return shader - - @staticmethod - @cache - def dashed_uniform_color_3d(): - vertex_shader = """ - uniform mat4 ModelViewProjectionMatrix; - in vec3 pos; - in float arcLength; - - out float v_ArcLength; - vec4 project = ModelViewProjectionMatrix * vec4(pos, 1.0f); - vec4 offset = vec4(0,0,-0.001,0); - void main() - { - v_ArcLength = arcLength; - gl_Position = project + offset; - } - """ - - fragment_shader = """ - uniform float u_Scale; - uniform vec4 color; - - in float v_ArcLength; - out vec4 fragColor; - - void main() - { - if (step(sin(v_ArcLength * u_Scale), 0.7) == 0) discard; - fragColor = color; - } - """ - return GPUShader(vertex_shader, fragment_shader) diff --git a/solver.py b/solver.py index 0fa99eb1..10e34749 100644 --- a/solver.py +++ b/solver.py @@ -254,6 +254,10 @@ def _get_msg_failed(): e.update_from_slvs(self.solvesys) + # Invalidate performance cache after solver updates entity positions + from .draw_handler import reset_performance_cache + reset_performance_cache() + def _get_msg_update(): msg = "Update entities from solver:" for e in self.entities: diff --git a/ui/panels/tools.py b/ui/panels/tools.py index a9f9e063..1da81968 100644 --- a/ui/panels/tools.py +++ b/ui/panels/tools.py @@ -32,8 +32,9 @@ def draw(self, context: Context): layout.prop(context.scene.sketcher, "use_construction") # Node modifier operators - layout.label(text="Node Tools:") - col = layout.column(align=True) - # col.operator(declarations.Operators.NodeFill) - col.operator(declarations.Operators.NodeExtrude) - col.operator(declarations.Operators.NodeArrayLinear) + if is_experimental(): + layout.label(text="Node Tools:") + col = layout.column(align=True) + #col.operator(declarations.Operators.NodeFill) + col.operator(declarations.Operators.NodeExtrude) + col.operator(declarations.Operators.NodeArrayLinear) diff --git a/utilities/constants.py b/utilities/constants.py index 18e05f4c..2f1bb26f 100644 --- a/utilities/constants.py +++ b/utilities/constants.py @@ -1,5 +1,53 @@ -from math import tau +import math +import logging -FULL_TURN = tau -HALF_TURN = tau / 2 -QUARTER_TURN = tau / 4 +logger = logging.getLogger(__name__) + +# Mathematical constants +PI = math.pi +HALF_TURN = PI +QUARTER_TURN = PI / 2 +FULL_TURN = 2 * PI + +# Rendering constants for geometry-based rendering +class RenderingConstants: + """ + Centralized rendering constants for consistent visual appearance across all GPU backends. + + These constants ensure consistent rendering using geometry-based approaches: + - Point sizes are used for triangle geometry (rectangles/cubes) + - Line widths apply to POLYLINE_UNIFORM_COLOR shader uniforms + - Dash patterns create actual geometry gaps rather than shader effects + """ + + # Point size for screen-space billboard rendering + POINT_HANDLE_PIXEL_SIZE = 5 # Fixed pixel size for point handles + + # Line widths (in pixels for POLYLINE_UNIFORM_COLOR shader) + LINE_WIDTH_REGULAR = 2.0 # Regular line thickness + LINE_WIDTH_CONSTRUCTION = 1.5 # Construction line thickness + + # Construction line dash patterns + DASH_LENGTH = 0.08 # Length of each dash segment + GAP_LENGTH = 0.03 # Length of each gap between dashes + + # Selection and depth sorting constants + WORKPLANE_SELECTION_PRIORITY = 0.1 # Multiplier to give workplanes selection priority + VIEW_CHANGE_THRESHOLD = 0.001 # Minimum view distance change to trigger geometry update + + # Performance constants + CLEANUP_FRAME_INTERVAL = 1000 # Frames between GPU batch cleanup cycles + CLEANUP_INTERVAL_SECONDS = 10.0 # Seconds between time-based cleanup cycles + + # UI interaction constants + UI_BORDER_MARGIN = 5 # Pixel margin to avoid UI boundary issues + + @classmethod + def dash_pattern_length(cls): + """ + Calculate total length of one complete dash pattern. + + Returns: + float: Combined length of dash + gap for pattern calculations + """ + return cls.DASH_LENGTH + cls.GAP_LENGTH diff --git a/utilities/draw.py b/utilities/draw.py index 65915e68..92b97bbb 100644 --- a/utilities/draw.py +++ b/utilities/draw.py @@ -2,36 +2,13 @@ from math import sin, cos from typing import List +import bpy from mathutils import Vector, Matrix from .. import global_data from .constants import FULL_TURN -# def draw_circle_2d(cx: float, cy: float, r: float, num_segments: int): -# """NOTE: Not used?""" -# # circle outline -# # NOTE: also see gpu_extras.presets.draw_circle_2d -# theta = FULL_TURN / num_segments - -# # precalculate the sine and cosine -# c = math.cos(theta) -# s = math.sin(theta) - -# # start at angle = 0 -# x = r -# y = 0 -# coords = [] -# for _ in range(num_segments): -# coords.append((x + cx, y + cy)) -# # apply the rotation matrix -# t = x -# x = c * x - s * y -# y = s * t + c * y -# coords.append(coords[0]) -# return coords - - def draw_rect_2d(cx: float, cy: float, width: float, height: float): # NOTE: this currently returns xyz coordinates, might make sense to return 2d coords ox = cx - (width / 2) @@ -65,6 +42,46 @@ def draw_quad_3d(cx: float, cy: float, cz: float, width: float): return coords, indices +def draw_billboard_quad_3d(cx: float, cy: float, cz: float, width: float): + """Create a screen-facing quad that always appears as a square regardless of view angle.""" + half_width = width / 2 + center = Vector((cx, cy, cz)) + + # Get current view matrix to determine camera orientation + context = bpy.context + if hasattr(context, 'region_data') and context.region_data: + # Get the view matrix to determine camera orientation + view_matrix = context.region_data.view_matrix + + # Extract camera right and up vectors from the view matrix + # The view matrix transforms from world to view space + # So we need the inverse to get world space vectors + view_matrix_inv = view_matrix.inverted() + + # Camera right vector (X axis in view space) + right = Vector((view_matrix_inv[0][0], view_matrix_inv[1][0], view_matrix_inv[2][0])).normalized() + # Camera up vector (Y axis in view space) + up = Vector((view_matrix_inv[0][1], view_matrix_inv[1][1], view_matrix_inv[2][1])).normalized() + else: + # Fallback to XY plane if no context + right = Vector((1, 0, 0)) + up = Vector((0, 1, 0)) + + # Create quad vertices using camera-relative vectors + coords = ( + center - right * half_width - up * half_width, # Bottom-left + center + right * half_width - up * half_width, # Bottom-right + center + right * half_width + up * half_width, # Top-right + center - right * half_width + up * half_width, # Top-left + ) + + # Convert to tuples for GPU batch + coords = [co[:] for co in coords] + indices = ((0, 1, 2), (2, 3, 0)) + + return coords, indices + + def tris_from_quad_ids(id0: int, id1: int, id2: int, id3: int): return (id0, id1, id2), (id1, id2, id3) @@ -126,3 +143,38 @@ def coords_arc_2d( else: coords.append((co_x, co_y)) return coords + + +def pixel_size_to_world_size(location_3d, pixel_size, context): + import bpy_extras + from mathutils import Vector + + region = context.region + region_data = context.region_data + if not region or not region_data: + print(f"[DEBUG] Fallback: No region or region_data. Returning {pixel_size * 0.001}") + return pixel_size * 0.001 # fallback + + # Project the 3D location to 2D screen space + co_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(region, region_data, location_3d) + if co_2d is None: + print(f"[DEBUG] Fallback: location_3d_to_region_2d failed for {location_3d}. Returning {pixel_size * 0.001}") + return pixel_size * 0.001 # fallback + + # Offset by pixel_size in X direction in screen space + co_2d_offset = co_2d + Vector((pixel_size, 0)) + + # Unproject both points back to 3D at the same depth as the original point + view_vector = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, region_data, co_2d) + origin_3d = bpy_extras.view3d_utils.region_2d_to_origin_3d(region, region_data, co_2d) + depth = (location_3d - origin_3d).dot(view_vector) + location_3d_offset = origin_3d + view_vector * depth + + # Now for the offset point + view_vector_offset = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, region_data, co_2d_offset) + origin_3d_offset = bpy_extras.view3d_utils.region_2d_to_origin_3d(region, region_data, co_2d_offset) + location_3d_offset2 = origin_3d_offset + view_vector_offset * depth + + world_size = (location_3d_offset2 - location_3d_offset).length + print(f"[DEBUG] Computed world_size: {world_size} for pixel_size: {pixel_size} at location: {location_3d}") + return world_size diff --git a/utilities/gpu_manager.py b/utilities/gpu_manager.py new file mode 100644 index 00000000..83d0f8d4 --- /dev/null +++ b/utilities/gpu_manager.py @@ -0,0 +1,180 @@ +""" +GPU Resource Management for CAD Sketcher + +This module provides centralized management of GPU resources including +shader caching, batch management, and memory cleanup to improve performance +and prevent resource leaks. +""" + +import logging +import time +import gpu +from typing import Dict, Optional + +from .constants import RenderingConstants + +logger = logging.getLogger(__name__) + + +class ShaderManager: + """Centralized shader management with caching and lifecycle control.""" + + _cached_shaders: Dict[str, gpu.types.GPUShader] = {} + _last_cleanup_time: float = 0.0 + + @classmethod + def get_uniform_color_shader(cls) -> gpu.types.GPUShader: + """Get cached uniform color shader for points and solid rendering.""" + shader_key = 'uniform_color' + if shader_key not in cls._cached_shaders: + try: + cls._cached_shaders[shader_key] = gpu.shader.from_builtin("UNIFORM_COLOR") + logger.debug(f"Created shader: {shader_key}") + except Exception as e: + logger.error(f"Failed to create uniform color shader: {e}") + # Return a fallback or re-raise depending on criticality + raise + return cls._cached_shaders[shader_key] + + @classmethod + def get_polyline_shader(cls) -> gpu.types.GPUShader: + """Get cached polyline shader for thick lines.""" + shader_key = 'polyline_uniform_color' + if shader_key not in cls._cached_shaders: + try: + cls._cached_shaders[shader_key] = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + logger.debug(f"Created shader: {shader_key}") + except Exception as e: + logger.error(f"Failed to create polyline shader: {e}") + raise + return cls._cached_shaders[shader_key] + + @classmethod + def get_id_shader(cls, is_point: bool = False) -> gpu.types.GPUShader: + """Get cached ID shader for selection rendering.""" + from ..shaders import Shaders + + # Use single consolidated shader for both points and lines + shader_key = 'id_shader' + if shader_key not in cls._cached_shaders: + try: + cls._cached_shaders[shader_key] = Shaders.id_shader_3d() + logger.debug(f"Created shader: {shader_key}") + except Exception as e: + logger.error(f"Failed to create ID shader: {e}") + raise + return cls._cached_shaders[shader_key] + + @classmethod + def cleanup_unused_shaders(cls, force: bool = False) -> int: + """ + Clean up shader cache periodically or on demand. + + Args: + force: If True, clear all cached shaders immediately + + Returns: + Number of shaders cleaned up + """ + current_time = time.time() + cleanup_interval = RenderingConstants.CLEANUP_INTERVAL_SECONDS + + if not force and (current_time - cls._last_cleanup_time) < cleanup_interval: + return 0 + + if force: + # Force cleanup - clear all cached shaders + count = len(cls._cached_shaders) + cls._cached_shaders.clear() + cls._last_cleanup_time = current_time + logger.debug(f"Force cleaned up {count} cached shaders") + return count + else: + # Periodic cleanup - shaders are lightweight, just update timestamp + cls._last_cleanup_time = current_time + return 0 + + @classmethod + def get_cache_stats(cls) -> Dict[str, int]: + """Get statistics about cached shaders for debugging.""" + return { + 'cached_shaders': len(cls._cached_shaders), + 'shader_types': list(cls._cached_shaders.keys()) + } + + +class GPUResourceManager: + """Manages GPU resources beyond shaders - batches, textures, etc.""" + + _last_memory_check: float = 0.0 + _memory_check_interval: float = 30.0 # Check every 30 seconds + + @classmethod + def periodic_cleanup(cls, context) -> Dict[str, int]: + """ + Perform periodic cleanup of GPU resources. + + Returns: + Dictionary with cleanup statistics + """ + current_time = time.time() + + if (current_time - cls._last_memory_check) < cls._memory_check_interval: + return {'cleaned_shaders': 0, 'cleaned_batches': 0} + + cls._last_memory_check = current_time + + stats = { + 'cleaned_shaders': ShaderManager.cleanup_unused_shaders(), + 'cleaned_batches': 0 + } + + # Clean up unused batches + try: + from .. import global_data + if hasattr(global_data, 'cleanup_unused_batches'): + global_data.cleanup_unused_batches(context) + # We don't get a count back, but we attempted cleanup + stats['cleaned_batches'] = -1 # Indicate cleanup was attempted + except Exception as e: + logger.debug(f"Batch cleanup failed: {e}") + + if any(stats.values()): + logger.debug(f"GPU cleanup stats: {stats}") + + return stats + + @classmethod + def force_cleanup_all(cls, context) -> Dict[str, int]: + """Force cleanup of all GPU resources.""" + stats = { + 'cleaned_shaders': ShaderManager.cleanup_unused_shaders(force=True), + 'cleaned_batches': 0 + } + + try: + from .. import global_data + if hasattr(global_data, 'cleanup_unused_batches'): + global_data.cleanup_unused_batches(context) + stats['cleaned_batches'] = -1 + except Exception as e: + logger.debug(f"Force batch cleanup failed: {e}") + + logger.info(f"Force GPU cleanup completed: {stats}") + return stats + + +# Convenience functions for backward compatibility +def get_uniform_color_shader() -> gpu.types.GPUShader: + """Convenience function to get uniform color shader.""" + return ShaderManager.get_uniform_color_shader() + + +def get_polyline_shader() -> gpu.types.GPUShader: + """Convenience function to get polyline shader.""" + return ShaderManager.get_polyline_shader() + + +def get_id_shader(is_point: bool = False) -> gpu.types.GPUShader: + """Convenience function to get ID shader.""" + return ShaderManager.get_id_shader(is_point) \ No newline at end of file