diff --git a/tools/adaptive-isogrid/src/brain/geometry_schema.py b/tools/adaptive-isogrid/src/brain/geometry_schema.py index 4461e962..908370d7 100644 --- a/tools/adaptive-isogrid/src/brain/geometry_schema.py +++ b/tools/adaptive-isogrid/src/brain/geometry_schema.py @@ -106,6 +106,144 @@ def _inner_boundary_to_hole(inner: Dict[str, Any], default_weight: float = 0.5) } +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")) @@ -113,6 +251,16 @@ def normalize_geometry_schema(geometry: Dict[str, Any]) -> Dict[str, Any]: 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"):