fix: boundary conformance — use Shapely buffer + vertex-preserving PSLG sampling

Root cause: typed segment offsetting created self-intersecting geometry at
concave corners (notches). Triangle's PSLG boundary didn't match the plotted
inset contour, allowing vertices 7+ mm outside.

Changes:
- _build_inner_plate: always use Shapely buffer(-w_frame) (robust at concavities)
- _sample_ring: use simplified polygon vertices + interpolated points on long edges
  (preserves tight features without vertex clustering)
- Plot uses same inner_plate from triangulation (no mismatch)
- Post-process: snap any residual outside vertices to boundary
- Result: 0 vertices outside inner plate (was 10, up to 7.45mm)
This commit is contained in:
2026-02-17 20:22:54 +00:00
parent 5cf994ec4b
commit 78f56a68b0
2 changed files with 63 additions and 47 deletions

View File

@@ -49,15 +49,44 @@ def _ring_to_segments(coords: np.ndarray, start_idx: int):
def _sample_ring(ring, spacing: float) -> np.ndarray:
"""Sample points along a Shapely ring at given spacing, returning Nx2 array (not closed)."""
length = float(ring.length)
if length < 1e-9:
return np.empty((0, 2), dtype=np.float64)
n = max(int(np.ceil(length / max(spacing, 1e-3))), 8)
"""Sample points along a Shapely ring at given spacing.
Uses Shapely simplify() to reduce vertex count on curved buffer segments,
then adds vertices from the simplified ring plus interpolated points on
long edges. This preserves corners/notches while avoiding vertex clusters.
"""
# Simplify to remove closely-spaced buffer curve points, preserving shape
simplified = ring.simplify(spacing * 0.15, preserve_topology=True)
coords = np.array(simplified.coords)
if len(coords) > 1 and np.allclose(coords[0], coords[-1]):
coords = coords[:-1]
if len(coords) < 3:
# Fallback to uniform interpolation
length = float(ring.length)
if length < 1e-9:
return np.empty((0, 2), dtype=np.float64)
n = max(int(np.ceil(length / max(spacing, 1e-3))), 8)
pts = []
for i in range(n):
p = ring.interpolate(i / n, normalized=True)
pts.append([p.x, p.y])
return np.array(pts, dtype=np.float64)
pts = []
n = len(coords)
for i in range(n):
p = ring.interpolate(i / n, normalized=True)
pts.append([p.x, p.y])
p1 = coords[i]
p2 = coords[(i + 1) % n]
pts.append(p1.tolist())
# Add interpolated points on long edges
edge_len = np.linalg.norm(p2 - p1)
if edge_len > spacing * 1.5:
n_sub = int(np.ceil(edge_len / spacing))
for j in range(1, n_sub):
t = j / n_sub
pts.append((p1 + t * (p2 - p1)).tolist())
return np.array(pts, dtype=np.float64)
@@ -66,45 +95,17 @@ def _sample_ring(ring, spacing: float) -> np.ndarray:
# ---------------------------------------------------------------------------
def _build_inner_plate(geometry, params) -> Polygon:
"""Offset sandbox boundary inward by w_frame."""
"""Offset sandbox boundary inward by w_frame.
Uses Shapely buffer (robust at concave corners, handles self-intersections).
The typed segment approach was producing self-intersecting polygons at
concave corners (notches, L-junctions), causing triangle edges to extend
beyond the intended boundary.
"""
w_frame = float(params.get('w_frame', 8.0))
plate_poly = Polygon(geometry['outer_boundary'])
typed_segments = geometry.get('outer_boundary_typed')
if typed_segments:
ring = LinearRing(geometry['outer_boundary'])
is_ccw = bool(ring.is_ccw)
inset_segments = []
for seg in typed_segments:
stype = seg.get('type', 'line')
if stype == 'arc':
center_inside = plate_poly.contains(Point(seg['center']))
inset_segments.append(inset_arc({**seg, 'center_inside': center_inside}, w_frame))
else:
x1, y1 = seg['start']
x2, y2 = seg['end']
dx, dy = (x2 - x1), (y2 - y1)
ln = np.hypot(dx, dy)
if ln < 1e-12:
continue
nx_l, ny_l = (-dy / ln), (dx / ln)
nx, ny = (nx_l, ny_l) if is_ccw else (-nx_l, -ny_l)
inset_segments.append({
'type': 'line',
'start': [x1 + w_frame * nx, y1 + w_frame * ny],
'end': [x2 + w_frame * nx, y2 + w_frame * ny],
})
dense = typed_segments_to_polyline(inset_segments, arc_pts=32)
if len(dense) >= 3:
inner_plate = Polygon(dense)
if not inner_plate.is_valid:
inner_plate = inner_plate.buffer(0)
if not inner_plate.is_empty:
return inner_plate
inner_plate = plate_poly.buffer(-w_frame)
inner_plate = plate_poly.buffer(-w_frame, resolution=16)
if inner_plate.is_empty or not inner_plate.is_valid:
inner_plate = plate_poly
return inner_plate
@@ -274,8 +275,9 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
keepout_union = unary_union(keepouts) if keepouts else Polygon()
# Step 3: Build PSLG
# Boundary sampling at intermediate spacing for clean boundary conformance
boundary_spacing = max(s_min, min(s_max * 0.5, 30.0))
# _sample_ring now uses actual polygon vertices (preserving tight features)
# and only adds interpolated points on long straight edges.
boundary_spacing = max(s_min, min(s_max * 0.4, 25.0))
pslg = _build_pslg(inner_plate, keepouts, boundary_spacing)
if pslg is None or len(pslg['vertices']) < 3:
@@ -362,4 +364,17 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
)
tris = tris[areas >= min_area_filter]
return {'vertices': verts, 'triangles': tris}
# Step 7: Snap out-of-bounds vertices to nearest boundary point
# Only snap vertices that are clearly outside (> 0.1mm), not boundary vertices
snap_tol = 0.1 # mm — don't touch vertices within this distance of boundary
inner_buffered = inner_plate.buffer(snap_tol)
for i in range(len(verts)):
p = Point(verts[i, 0], verts[i, 1])
if not inner_buffered.contains(p):
nearest = inner_plate.exterior.interpolate(
inner_plate.exterior.project(p)
)
verts[i, 0] = nearest.x
verts[i, 1] = nearest.y
return {'vertices': verts, 'triangles': tris, 'inner_plate': inner_plate}