Files
Atomizer/tools/adaptive-isogrid/src/brain/geometry_schema.py
Antoine 9bc3b12745 fix: handle v2 typed segments in outer_boundary field directly
NX extractor outputs typed segments in 'outer_boundary' (not
'outer_boundary_typed'). Normalize now detects dict segments and
promotes them correctly.
2026-02-17 18:24:41 +00:00

290 lines
11 KiB
Python

"""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