NX extractor outputs typed segments in 'outer_boundary' (not 'outer_boundary_typed'). Normalize now detects dict segments and promotes them correctly.
290 lines
11 KiB
Python
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
|