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]:
|
def normalize_geometry_schema(geometry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Return geometry in legacy Brain format (outer_boundary + holes), preserving typed data."""
|
"""Return geometry in legacy Brain format (outer_boundary + holes), preserving typed data."""
|
||||||
schema_version = str(geometry.get("schema_version", "1.0"))
|
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"):
|
if schema_version.startswith("1"):
|
||||||
out = deepcopy(geometry)
|
out = deepcopy(geometry)
|
||||||
out.setdefault("holes", [])
|
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
|
return out
|
||||||
|
|
||||||
if not schema_version.startswith("2"):
|
if not schema_version.startswith("2"):
|
||||||
|
|||||||
Reference in New Issue
Block a user