Spaces:
Sleeping
Sleeping
| """Polygon operations — conversions and validation for polygon geometry. | |
| Polygons are represented as list[tuple[float, float]] — ordered (x, y) vertices. | |
| """ | |
| from __future__ import annotations | |
| from typing import TYPE_CHECKING | |
| if TYPE_CHECKING: | |
| from src.app.geometry.bbox import BBoxTuple | |
| PolygonPoints = list[tuple[float, float]] | |
| def polygon_to_bbox(polygon: PolygonPoints) -> BBoxTuple: | |
| """Compute the axis-aligned bounding box enclosing a polygon. | |
| Returns (x, y, width, height) in canonical convention. | |
| Raises ValueError if the polygon has fewer than 3 points. | |
| """ | |
| if len(polygon) < 3: | |
| raise ValueError(f"A polygon needs at least 3 points, got {len(polygon)}") | |
| xs = [p[0] for p in polygon] | |
| ys = [p[1] for p in polygon] | |
| min_x = min(xs) | |
| min_y = min(ys) | |
| max_x = max(xs) | |
| max_y = max(ys) | |
| return (min_x, min_y, max_x - min_x, max_y - min_y) | |
| def bbox_to_polygon(bbox: BBoxTuple) -> PolygonPoints: | |
| """Convert a bbox to a 4-point polygon (clockwise from top-left).""" | |
| x, y, w, h = bbox | |
| return [ | |
| (x, y), | |
| (x + w, y), | |
| (x + w, y + h), | |
| (x, y + h), | |
| ] | |
| def polygon_area(polygon: PolygonPoints) -> float: | |
| """Compute area using the shoelace formula. Always returns a positive value.""" | |
| n = len(polygon) | |
| if n < 3: | |
| return 0.0 | |
| total = 0.0 | |
| for i in range(n): | |
| j = (i + 1) % n | |
| total += polygon[i][0] * polygon[j][1] | |
| total -= polygon[j][0] * polygon[i][1] | |
| return abs(total) / 2.0 | |
| def polygon_centroid(polygon: PolygonPoints) -> tuple[float, float]: | |
| """Compute the centroid of a polygon. | |
| Raises ValueError if the polygon has fewer than 3 points. | |
| """ | |
| if len(polygon) < 3: | |
| raise ValueError(f"A polygon needs at least 3 points, got {len(polygon)}") | |
| n = len(polygon) | |
| cx = sum(p[0] for p in polygon) / n | |
| cy = sum(p[1] for p in polygon) / n | |
| return (cx, cy) | |
| def validate_polygon(polygon: PolygonPoints) -> list[str]: | |
| """Return a list of warnings about the polygon. Empty list = valid.""" | |
| warnings: list[str] = [] | |
| if len(polygon) < 3: | |
| warnings.append(f"Polygon has fewer than 3 points ({len(polygon)})") | |
| return warnings | |
| # Check for duplicate consecutive points | |
| for i in range(len(polygon)): | |
| j = (i + 1) % len(polygon) | |
| if polygon[i] == polygon[j]: | |
| warnings.append(f"Duplicate consecutive points at index {i} and {j}") | |
| # Check for zero area | |
| if polygon_area(polygon) == 0.0: | |
| warnings.append("Polygon has zero area (degenerate)") | |
| # Check for negative coordinates | |
| for i, (x, y) in enumerate(polygon): | |
| if x < 0 or y < 0: | |
| warnings.append(f"Negative coordinate at point {i}: ({x}, {y})") | |
| return warnings | |
| def is_clockwise(polygon: PolygonPoints) -> bool: | |
| """Check if polygon vertices are ordered clockwise.""" | |
| n = len(polygon) | |
| if n < 3: | |
| return False | |
| total = 0.0 | |
| for i in range(n): | |
| j = (i + 1) % n | |
| total += (polygon[j][0] - polygon[i][0]) * (polygon[j][1] + polygon[i][1]) | |
| return total > 0 | |
| def ensure_clockwise(polygon: PolygonPoints) -> PolygonPoints: | |
| """Return the polygon in clockwise order.""" | |
| if not is_clockwise(polygon): | |
| return list(reversed(polygon)) | |
| return polygon | |