Source code for segnomms.svg.path_clipper

"""Path clipping utilities for frame boundaries.

This module provides utilities to clip SVG paths to frame boundaries,
ensuring that cluster paths and other generated shapes respect the
configured frame shape.
"""

from typing import List, Optional, Tuple


[docs] class PathClipper: """Clips SVG paths to frame boundaries. This class provides methods to ensure that generated paths (from clustering or other phases) don't extend beyond the configured frame shape boundaries. """
[docs] def __init__( self, frame_shape: str, width: int, height: int, border: int, corner_radius: float = 0.0, ): """Initialize the path clipper. Args: frame_shape: Frame shape type ('square', 'circle', 'rounded-rect', 'squircle') width: Total SVG width in pixels height: Total SVG height in pixels border: Border size in pixels corner_radius: Corner radius for rounded-rect (0.0-1.0) """ self.frame_shape = frame_shape self.width = width self.height = height self.border = border self.corner_radius = corner_radius # Calculate frame boundaries self.frame_left = border self.frame_top = border self.frame_right = width - border self.frame_bottom = height - border self.frame_width = width - 2 * border self.frame_height = height - 2 * border
[docs] def is_point_in_frame(self, x: float, y: float) -> bool: """Check if a point is within the frame boundaries. Args: x: X coordinate y: Y coordinate Returns: True if point is within frame boundaries """ if self.frame_shape == "square": return self.frame_left <= x <= self.frame_right and self.frame_top <= y <= self.frame_bottom elif self.frame_shape == "circle": cx = self.width / 2 cy = self.height / 2 r = min(self.width, self.height) / 2 dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 return float(dist) <= r elif self.frame_shape == "rounded-rect": # Check if in main rectangle area if ( self.frame_left + self.corner_radius * self.frame_width <= x <= self.frame_right - self.corner_radius * self.frame_width and self.frame_top <= y <= self.frame_bottom ): return True if ( self.frame_left <= x <= self.frame_right and self.frame_top + self.corner_radius * self.frame_height <= y <= self.frame_bottom - self.corner_radius * self.frame_height ): return True # Check corners corner_r = self.corner_radius * min(self.frame_width, self.frame_height) / 2 # Top-left corner if x < self.frame_left + corner_r and y < self.frame_top + corner_r: cx = self.frame_left + corner_r cy = self.frame_top + corner_r return float(((x - cx) ** 2 + (y - cy) ** 2) ** 0.5) <= corner_r # Similar checks for other corners... return True # Simplified for now elif self.frame_shape == "squircle": # Superellipse formula cx = self.width / 2 cy = self.height / 2 rx = self.frame_width / 2 ry = self.frame_height / 2 n = 4 # Squircle parameter if rx <= 0 or ry <= 0: return False return (abs(x - cx) / rx) ** n + (abs(y - cy) / ry) ** n <= 1 return True # Default to allowing the point
[docs] def get_distance_from_edge(self, x: float, y: float) -> float: """Calculate distance from point to frame edge. Args: x: X coordinate y: Y coordinate Returns: Distance in pixels from the nearest frame edge (0 = on edge, positive = inside) """ if self.frame_shape == "square": # Distance to nearest edge dist_left = x - self.frame_left dist_right = self.frame_right - x dist_top = y - self.frame_top dist_bottom = self.frame_bottom - y return min(dist_left, dist_right, dist_top, dist_bottom) elif self.frame_shape == "circle": cx = self.width / 2 cy = self.height / 2 r = min(self.width, self.height) / 2 dist_from_center = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 return float(r - dist_from_center) elif self.frame_shape in ["rounded-rect", "squircle"]: # Simplified: use rectangular distance for now # NOTE: Enhancement opportunity - accurate rounded shape distance calculation dist_left = x - self.frame_left dist_right = self.frame_right - x dist_top = y - self.frame_top dist_bottom = self.frame_bottom - y return min(dist_left, dist_right, dist_top, dist_bottom) return 0.0 # Default
[docs] def get_scale_factor(self, x: float, y: float, scale_distance: float) -> float: """Calculate scale factor for a point based on distance from frame edge. Args: x: X coordinate y: Y coordinate scale_distance: Distance in pixels where scaling begins Returns: Scale factor (1.0 = full size, 0.0 = invisible) """ distance = self.get_distance_from_edge(x, y) if distance >= scale_distance: return 1.0 # Full size elif distance <= 0: return 0.0 # At or outside edge else: # Linear interpolation return distance / scale_distance
[docs] def clip_rectangle_to_frame(self, x: float, y: float, width: float, height: float) -> Optional[str]: """Clip a rectangle to frame boundaries. Args: x: Rectangle X position y: Rectangle Y position width: Rectangle width height: Rectangle height Returns: SVG path string for clipped shape, or None if entirely outside """ # For non-square frames, check if rectangle corners are in frame if self.frame_shape == "square": # Simple clipping for square frames clipped_x = max(x, self.frame_left) clipped_y = max(y, self.frame_top) clipped_right = min(x + width, self.frame_right) clipped_bottom = min(y + height, self.frame_bottom) if clipped_right <= clipped_x or clipped_bottom <= clipped_y: return None return ( f"M {clipped_x} {clipped_y} " f"L {clipped_right} {clipped_y} " f"L {clipped_right} {clipped_bottom} " f"L {clipped_x} {clipped_bottom} Z" ) # For other frame shapes, check if any part is visible # This is a simplified check - just see if center is in frame center_x = x + width / 2 center_y = y + height / 2 if not self.is_point_in_frame(center_x, center_y): # Check corners corners = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)] if not any(self.is_point_in_frame(cx, cy) for cx, cy in corners): return None # Return the original rectangle path # In a full implementation, this would actually clip to the frame shape return f"M {x} {y} " f"L {x + width} {y} " f"L {x + width} {y + height} " f"L {x} {y + height} Z"
[docs] def adjust_cluster_path(self, path: str, scale: int) -> str: """Adjust a cluster path to respect frame boundaries. Args: path: SVG path string scale: Module scale in pixels Returns: Adjusted SVG path string """ # For now, just return the original path # In a full implementation, this would parse the path and clip it return path
[docs] def clip_path(self, path_data: str) -> str: """Clip an SVG path to frame boundaries. Args: path_data: SVG path string to clip Returns: Clipped SVG path string Note: This is a basic implementation that returns the original path for most cases. Full path parsing and clipping is a complex operation that would require substantial geometry libraries. """ # For square frames, return the original path (no complex clipping needed) if self.frame_shape == "square": return path_data # For other frame shapes, we would need sophisticated path clipping # For now, return the original path as a fallback # TODO: Implement proper SVG path parsing and clipping return path_data
[docs] def get_frame_aware_bounds( self, positions: List[Tuple[int, int]], scale: int ) -> Tuple[int, int, int, int]: """Get bounding box for positions, constrained by frame. Args: positions: List of (row, col) positions scale: Module scale in pixels Returns: Tuple of (x, y, width, height) in pixels """ if not positions: return (0, 0, 0, 0) # Get module bounds rows = [p[0] for p in positions] cols = [p[1] for p in positions] min_row, max_row = min(rows), max(rows) min_col, max_col = min(cols), max(cols) # Convert to pixel coordinates x = min_col * scale + self.border y = min_row * scale + self.border width = (max_col - min_col + 1) * scale height = (max_row - min_row + 1) * scale # For square frames, simple clipping if self.frame_shape == "square": x = max(x, self.frame_left) y = max(y, self.frame_top) right = min(x + width, self.frame_right) bottom = min(y + height, self.frame_bottom) if right > x and bottom > y: return (x, y, right - x, bottom - y) else: return (0, 0, 0, 0) # For other shapes, return original bounds # The path generation will handle the clipping return (x, y, width, height)