"""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 _detect_fillet_arcs(boundary: List[List[float]], arc_pts: int = 16) -> List[Dict[str, Any]]: """ Detect fillet arcs in a v1 flat polyline and return v2-style typed segments. Pattern: a filleted 90° corner produces TWO consecutive vertices at ~135° angles. These are the arc start and arc end points. The arc tangent at each point aligns with the adjacent straight edge. For fillet pair (vertices i, i+1 both at ~135°): - Arc start = vertex i, tangent = direction of edge before i - Arc end = vertex i+1, tangent = direction of edge after i+1 - Center = intersection of perpendiculars to tangents at start and end """ pts = [_as_xy(p) for p in boundary] n = len(pts) if n < 3: segments = [] for i in range(n - 1): segments.append({"type": "line", "start": pts[i], "end": pts[i + 1]}) return segments if segments else [] # Remove closing duplicate if present if n >= 2 and abs(pts[0][0] - pts[-1][0]) < 1e-6 and abs(pts[0][1] - pts[-1][1]) < 1e-6: pts = pts[:-1] n = len(pts) # Compute interior angle at each vertex angles = np.zeros(n) for i in range(n): A = np.array(pts[(i - 1) % n]) M = np.array(pts[i]) B = np.array(pts[(i + 1) % n]) vMA = A - M vMB = B - M lMA = np.linalg.norm(vMA) lMB = np.linalg.norm(vMB) if lMA < 1e-9 or lMB < 1e-9: angles[i] = 180.0 continue cos_a = np.clip(np.dot(vMA, vMB) / (lMA * lMB), -1.0, 1.0) angles[i] = math.degrees(math.acos(cos_a)) # Find fillet PAIRS: two consecutive vertices both at ~135° is_fillet_start = [False] * n # first vertex of a fillet pair for i in range(n): j = (i + 1) % n if 120 < angles[i] < 150 and 120 < angles[j] < 150: is_fillet_start[i] = True # Build typed segments segments = [] i = 0 while i < n: j = (i + 1) % n if is_fillet_start[i]: # Fillet pair at (i, j) # Arc start = point i, arc end = point j arc_start = np.array(pts[i]) arc_end = np.array(pts[j]) # Tangent at arc start = direction of incoming edge (from previous vertex) prev_i = (i - 1) % n edge_in = np.array(pts[i]) - np.array(pts[prev_i]) edge_in_len = np.linalg.norm(edge_in) if edge_in_len > 1e-9: tangent_start = edge_in / edge_in_len else: tangent_start = np.array([1.0, 0.0]) # Tangent at arc end = direction of outgoing edge (to next vertex) next_j = (j + 1) % n edge_out = np.array(pts[next_j]) - np.array(pts[j]) edge_out_len = np.linalg.norm(edge_out) if edge_out_len > 1e-9: tangent_end = edge_out / edge_out_len else: tangent_end = np.array([1.0, 0.0]) # Normal to tangent at start (perpendicular, pointing toward center) # Try both directions, pick the one that creates a valid arc n_start_a = np.array([-tangent_start[1], tangent_start[0]]) n_start_b = np.array([tangent_start[1], -tangent_start[0]]) n_end_a = np.array([-tangent_end[1], tangent_end[0]]) n_end_b = np.array([tangent_end[1], -tangent_end[0]]) # Find center: intersection of line (arc_start + t*n_start) and (arc_end + s*n_end) best_center = None best_radius = None for ns in [n_start_a, n_start_b]: for ne in [n_end_a, n_end_b]: # Solve: arc_start + t*ns = arc_end + s*ne # [ns_x, -ne_x] [t] [end_x - start_x] # [ns_y, -ne_y] [s] = [end_y - start_y] A_mat = np.array([[ns[0], -ne[0]], [ns[1], -ne[1]]]) b_vec = arc_end - arc_start det = A_mat[0, 0] * A_mat[1, 1] - A_mat[0, 1] * A_mat[1, 0] if abs(det) < 1e-12: continue t = (b_vec[0] * A_mat[1, 1] - b_vec[1] * A_mat[0, 1]) / det center = arc_start + t * ns r_start = np.linalg.norm(center - arc_start) r_end = np.linalg.norm(center - arc_end) if abs(r_start - r_end) / max(r_start, r_end, 1e-9) < 0.05 and t > 0: if best_center is None or r_start < best_radius: best_center = center best_radius = (r_start + r_end) / 2 if best_center is not None: # Determine clockwise vCA = arc_start - best_center vCB = arc_end - best_center cross = vCA[0] * vCB[1] - vCA[1] * vCB[0] segments.append({ "type": "arc", "start": pts[i], "end": pts[j], "center": [round(float(best_center[0]), 6), round(float(best_center[1]), 6)], "radius": round(float(best_radius), 6), "clockwise": bool(cross < 0), }) i = j # continue from arc end (j), next iteration creates line from j to j+1 continue # Fallback: couldn't reconstruct arc, keep as line segments.append({"type": "line", "start": pts[i], "end": pts[j]}) i += 1 else: # Regular line segment segments.append({"type": "line", "start": pts[i], "end": pts[j]}) i += 1 return segments 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", []) # Auto-detect fillet arcs in v1 polyline boundaries if 'outer_boundary' in out and 'outer_boundary_typed' not in out: typed_segs = _detect_fillet_arcs(out['outer_boundary']) n_arcs = sum(1 for s in typed_segs if s['type'] == 'arc') if n_arcs > 0: out['outer_boundary_typed'] = typed_segs # Re-generate polyline from typed segments with proper arc densification out['outer_boundary'] = _typed_segments_to_polyline_v2(typed_segs) 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) # v2: outer_boundary contains typed segment dicts, or outer_boundary_typed exists raw_outer = out.get("outer_boundary", []) typed_outer = out.get("outer_boundary_typed", []) # Detect if outer_boundary itself contains typed segments (v2 extraction output) if raw_outer and isinstance(raw_outer[0], dict) and "type" in raw_outer[0]: typed_outer = raw_outer # outer_boundary IS the typed segments if typed_outer: out["outer_boundary_typed"] = 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