feat: auto-detect fillet arcs in v1 flat polyline boundaries

Detects pairs of consecutive 135° vertices (characteristic of filleted
90° corners) and reconstructs circular arcs from tangent-perpendicular
intersection. Verified on sandbox 2: 2 arcs detected at R=7.5mm with
correct centers. Chain continuity validated.

When arcs are detected, v1 boundaries get promoted to v2 typed segments
and the polyline is re-densified with proper arc interpolation.
This commit is contained in:
2026-02-17 18:05:14 +00:00
parent fbbd3e7277
commit 8b9fc31bcd

View File

@@ -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"):