"""Geometry schema normalization (v1.0 and v2.0 typed-segment support).""" from __future__ import annotations import math from copy import deepcopy from typing import Any, Dict, List import numpy as np from shapely.geometry import Polygon from src.shared.arc_utils import arc_to_polyline def _as_xy(pt: Any) -> List[float]: return [float(pt[0]), float(pt[1])] def _arc_span(seg: Dict[str, Any]) -> float: cx, cy = _as_xy(seg["center"]) sx, sy = _as_xy(seg["start"]) ex, ey = _as_xy(seg["end"]) a0 = math.atan2(sy - cy, sx - cx) a1 = math.atan2(ey - cy, ex - cx) cw = bool(seg.get("clockwise", False)) if abs(sx - ex) < 1e-9 and abs(sy - ey) < 1e-9: return 2.0 * math.pi if cw: if a1 > a0: a1 -= 2.0 * math.pi else: if a1 < a0: a1 += 2.0 * math.pi return abs(a1 - a0) def _typed_segments_to_polyline_v2(segments: List[Dict[str, Any]], full_circle_segments: int = 32) -> List[List[float]]: out: List[List[float]] = [] for seg in segments or []: stype = seg.get("type", "line") if stype == "arc": span = _arc_span(seg) n_seg = max(2, int(round(full_circle_segments * span / (2.0 * math.pi)))) pts = arc_to_polyline(seg, n_pts=n_seg + 1) else: pts = [_as_xy(seg["start"]), _as_xy(seg["end"])] if out and pts: if abs(out[-1][0] - pts[0][0]) < 1e-9 and abs(out[-1][1] - pts[0][1]) < 1e-9: out.extend(pts[1:]) else: out.extend(pts) else: out.extend(pts) if len(out) >= 2 and abs(out[0][0] - out[-1][0]) < 1e-9 and abs(out[0][1] - out[-1][1]) < 1e-9: out = out[:-1] return out def _inner_boundary_to_hole(inner: Dict[str, Any], default_weight: float = 0.5) -> Dict[str, Any]: segments = inner.get("segments", []) boundary = _typed_segments_to_polyline_v2(segments) # Circular hole detection: single full-circle arc is_circular = False center = None diameter = None if len(segments) == 1 and segments[0].get("type") == "arc": seg = segments[0] s = _as_xy(seg["start"]) e = _as_xy(seg["end"]) if abs(s[0] - e[0]) < 1e-8 and abs(s[1] - e[1]) < 1e-8: is_circular = True center = _as_xy(seg["center"]) diameter = 2.0 * float(seg["radius"]) if center is None or diameter is None: if len(boundary) >= 3: poly = Polygon(boundary) if poly.is_valid and not poly.is_empty: c = poly.centroid center = [float(c.x), float(c.y)] minx, miny, maxx, maxy = poly.bounds diameter = float(max(maxx - minx, maxy - miny)) else: arr = np.asarray(boundary, dtype=float) center = [float(np.mean(arr[:, 0])), float(np.mean(arr[:, 1]))] diameter = float(max(np.ptp(arr[:, 0]), np.ptp(arr[:, 1]))) else: center = [0.0, 0.0] diameter = 1.0 return { "index": int(inner.get("index", 0)), "center": center, "diameter": float(diameter), "boundary": boundary, "is_circular": bool(is_circular), "weight": float(inner.get("weight", default_weight)), } def normalize_geometry_schema(geometry: Dict[str, Any]) -> Dict[str, Any]: """Return geometry in legacy Brain format (outer_boundary + holes), preserving typed data.""" schema_version = str(geometry.get("schema_version", "1.0")) if schema_version.startswith("1"): out = deepcopy(geometry) out.setdefault("holes", []) return out if not schema_version.startswith("2"): # Unknown schema: best effort fallback (assume legacy fields are present) out = deepcopy(geometry) out.setdefault("holes", []) return out out = deepcopy(geometry) typed_outer = out.get("outer_boundary_typed", []) if typed_outer: out["outer_boundary"] = _typed_segments_to_polyline_v2(typed_outer) inner_boundaries = out.get("inner_boundaries", []) out["holes"] = [_inner_boundary_to_hole(inner) for inner in inner_boundaries] return out