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:
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user