diff --git a/tools/adaptive-isogrid/src/brain/__main__.py b/tools/adaptive-isogrid/src/brain/__main__.py index 1cf7ce6f..e3ba449b 100644 --- a/tools/adaptive-isogrid/src/brain/__main__.py +++ b/tools/adaptive-isogrid/src/brain/__main__.py @@ -107,7 +107,8 @@ def _plot_triangulation(geometry: Dict[str, Any], triangulation: Dict[str, Any], plate_poly = ShapelyPolygon(outer) w_frame = (params or {}).get("w_frame", 8.0) d_keep = (params or {}).get("d_keep", 1.5) - inner_plate = plate_poly.buffer(-w_frame) + # Use the exact inner_plate from triangulation if available (same PSLG boundary) + inner_plate = triangulation.get("inner_plate") or plate_poly.buffer(-w_frame) fig, ax = plt.subplots(figsize=(10, 8), dpi=160) diff --git a/tools/adaptive-isogrid/src/brain/triangulation.py b/tools/adaptive-isogrid/src/brain/triangulation.py index 8601b706..f2acfcb4 100644 --- a/tools/adaptive-isogrid/src/brain/triangulation.py +++ b/tools/adaptive-isogrid/src/brain/triangulation.py @@ -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}