fix: v1 boundary handling — inset vertices, 3-point hole keepouts, boundary-aligned triangles, smooth plotting

- Triangulation: force inset boundary corner vertices for v1 geometry (Shapely buffer)
- Hole keepouts: 3 evenly-spaced points per circular hole (not dense polyline)
- Boundary layer: seed points derived from inset polygon for proper alignment
- Triangle filtering: full polygon coverage check against inset-valid region
- Plotting: uniform polyline resampling for smooth v1 boundaries, analytic circle rendering
- Verified: 0 bad triangles on both Quicksat sandboxes
This commit is contained in:
2026-02-17 16:24:27 +00:00
parent 139a355ef3
commit 1a14f7c420
2 changed files with 138 additions and 80 deletions

View File

@@ -48,7 +48,39 @@ def _plot_boundary_polyline(geometry: Dict[str, Any], arc_pts: int = 64) -> np.n
pts = typed_segments_to_polyline(typed, arc_pts=arc_pts) pts = typed_segments_to_polyline(typed, arc_pts=arc_pts)
if len(pts) >= 3: if len(pts) >= 3:
return np.asarray(pts, dtype=float) return np.asarray(pts, dtype=float)
return np.asarray(geometry["outer_boundary"], dtype=float)
outer = np.asarray(geometry["outer_boundary"], dtype=float)
if len(outer) < 4:
return outer
# v1 fallback: dense polyline boundaries may encode fillets.
# Resample uniformly for smoother plotting while preserving the polygon path.
if np.allclose(outer[0], outer[-1]):
ring = outer
else:
ring = np.vstack([outer, outer[0]])
seg = np.diff(ring, axis=0)
seg_len = np.hypot(seg[:, 0], seg[:, 1])
nonzero = seg_len[seg_len > 1e-9]
if len(nonzero) == 0:
return outer
step = max(float(np.median(nonzero) * 0.5), 0.5)
cum = np.r_[0.0, np.cumsum(seg_len)]
total = float(cum[-1])
if total <= step:
return outer
samples = np.arange(0.0, total, step, dtype=float)
if samples[-1] < total:
samples = np.r_[samples, total]
out = []
j = 0
for s in samples:
while j < len(cum) - 2 and cum[j + 1] < s:
j += 1
den = max(cum[j + 1] - cum[j], 1e-12)
t = (s - cum[j]) / den
p = ring[j] + t * (ring[j + 1] - ring[j])
out.append([float(p[0]), float(p[1])])
return np.asarray(out, dtype=float)
def _plot_density(geometry: Dict[str, Any], params: Dict[str, Any], out_path: Path, resolution: float = 3.0) -> None: def _plot_density(geometry: Dict[str, Any], params: Dict[str, Any], out_path: Path, resolution: float = 3.0) -> None:
@@ -62,7 +94,13 @@ def _plot_density(geometry: Dict[str, Any], params: Dict[str, Any], out_path: Pa
ax.plot(np.r_[outer[:, 0], outer[0, 0]], np.r_[outer[:, 1], outer[0, 1]], "w-", lw=1.5) ax.plot(np.r_[outer[:, 0], outer[0, 0]], np.r_[outer[:, 1], outer[0, 1]], "w-", lw=1.5)
for hole in geometry.get("holes", []): for hole in geometry.get("holes", []):
hb = np.asarray(hole["boundary"]) if hole.get("is_circular", False) and "center" in hole and "diameter" in hole:
cx, cy = hole["center"]
r = float(hole["diameter"]) * 0.5
a = np.linspace(0.0, 2.0 * np.pi, 90, endpoint=True)
hb = np.column_stack([cx + r * np.cos(a), cy + r * np.sin(a)])
else:
hb = np.asarray(hole["boundary"])
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]], "k-", lw=0.9) ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]], "k-", lw=0.9)
ax.set_aspect("equal", adjustable="box") ax.set_aspect("equal", adjustable="box")
@@ -123,7 +161,13 @@ def _plot_triangulation(geometry: Dict[str, Any], triangulation: Dict[str, Any],
# Blue hole circles # Blue hole circles
for hole in geometry.get("holes", []): for hole in geometry.get("holes", []):
hb = np.asarray(hole["boundary"]) if hole.get("is_circular", False) and "center" in hole and "diameter" in hole:
cx, cy = hole["center"]
r = float(hole["diameter"]) * 0.5
a = np.linspace(0.0, 2.0 * np.pi, 90, endpoint=True)
hb = np.column_stack([cx + r * np.cos(a), cy + r * np.sin(a)])
else:
hb = np.asarray(hole["boundary"])
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]], ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]],
"b-", lw=0.8, zorder=3) "b-", lw=0.8, zorder=3)
@@ -182,7 +226,13 @@ def _plot_final_profile(geometry, pockets, ribbed_plate, out_path: Path, params:
# Blue hole circles # Blue hole circles
for hole in geometry.get("holes", []): for hole in geometry.get("holes", []):
hb = np.asarray(hole["boundary"]) if hole.get("is_circular", False) and "center" in hole and "diameter" in hole:
cx, cy = hole["center"]
r = float(hole["diameter"]) * 0.5
a = np.linspace(0.0, 2.0 * np.pi, 90, endpoint=True)
hb = np.column_stack([cx + r * np.cos(a), cy + r * np.sin(a)])
else:
hb = np.asarray(hole["boundary"])
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]], ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]],
"b-", lw=0.8, zorder=3) "b-", lw=0.8, zorder=3)

View File

@@ -14,68 +14,45 @@ a viable machining pocket.
import numpy as np import numpy as np
from scipy.spatial import Delaunay from scipy.spatial import Delaunay
from shapely.geometry import Polygon, Point, MultiPoint, LinearRing from shapely.geometry import Polygon, Point, LinearRing
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union from shapely.ops import unary_union
from src.shared.arc_utils import inset_arc, typed_segments_to_polyline, typed_segments_to_sparse from src.shared.arc_utils import inset_arc, typed_segments_to_polyline, typed_segments_to_sparse
from .density_field import evaluate_density, density_to_spacing from .density_field import evaluate_density, density_to_spacing
def _boundary_layer_offset_for_segment(mid_pt, geometry, params): def _geometry_to_list(geom: BaseGeometry) -> list[BaseGeometry]:
"""Choose inward offset for boundary seed row.""" if geom.is_empty:
explicit = params.get('boundary_layer_offset', None) return []
if explicit is not None: return [geom] if geom.geom_type == 'Polygon' else list(getattr(geom, 'geoms', []))
return max(float(explicit), 0.0)
eta = evaluate_density(mid_pt[0], mid_pt[1], geometry, params)
return max(float(density_to_spacing(eta, params)), 1e-3)
def _add_boundary_layer_seed_points(points, geometry, params, plate_poly, keepout_union): def _add_boundary_layer_seed_points(points, geometry, params, inner_plate, keepout_union):
"""Add a structured point row offset inward from each straight outer edge.""" """Add a row of points just inside the inset boundary to align boundary triangles."""
offset = float(params.get('boundary_layer_offset', 0.0) or 0.0)
if offset <= 0.0:
offset = max(params.get('s_min', 10.0) * 0.6, 0.5)
layer_geom = inner_plate.buffer(-offset)
if layer_geom.is_empty:
return points
boundary_pts = [] boundary_pts = []
ring = LinearRing(geometry['outer_boundary']) for poly in _geometry_to_list(layer_geom):
is_ccw = bool(ring.is_ccw) ring = poly.exterior
length = float(ring.length)
# Prefer typed segments to avoid treating discretized arcs as straight edges if length <= 1e-9:
typed = geometry.get('outer_boundary_typed')
if typed:
segments = [seg for seg in typed if seg.get('type', 'line') == 'line']
edge_pairs = [(_np(seg['start']), _np(seg['end'])) for seg in segments]
else:
coords = np.asarray(geometry['outer_boundary'], dtype=float)
if len(coords) >= 2 and np.allclose(coords[0], coords[-1]):
coords = coords[:-1]
edge_pairs = []
for i in range(len(coords)):
edge_pairs.append((coords[i], coords[(i + 1) % len(coords)]))
for a, b in edge_pairs:
dx, dy = b[0] - a[0], b[1] - a[1]
edge_len = float(np.hypot(dx, dy))
if edge_len < 1e-9:
continue continue
n_pts = max(int(np.ceil(length / max(params.get('s_min', 10.0), 1e-3))), 8)
mid = np.array([(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5], dtype=float) for i in range(n_pts):
spacing = float(density_to_spacing(evaluate_density(mid[0], mid[1], geometry, params), params)) frac = i / n_pts
spacing = max(spacing, 1e-3) p = ring.interpolate(frac, normalized=True)
offset = _boundary_layer_offset_for_segment(mid, geometry, params) if not inner_plate.buffer(1e-6).covers(p):
nx_l, ny_l = (-dy / edge_len), (dx / edge_len)
nx, ny = (nx_l, ny_l) if is_ccw else (-nx_l, -ny_l)
n_pts = max(int(np.floor(edge_len / spacing)), 1)
for k in range(1, n_pts + 1):
t = k / (n_pts + 1)
bx = a[0] + t * dx
by = a[1] + t * dy
px = bx + offset * nx
py = by + offset * ny
p = Point(px, py)
if not plate_poly.buffer(1e-6).contains(p):
continue continue
if not keepout_union.is_empty and keepout_union.contains(p): if not keepout_union.is_empty and keepout_union.buffer(1e-6).covers(p):
continue continue
boundary_pts.append([px, py]) boundary_pts.append([p.x, p.y])
if boundary_pts: if boundary_pts:
return np.vstack([points, np.asarray(boundary_pts, dtype=np.float64)]) return np.vstack([points, np.asarray(boundary_pts, dtype=np.float64)])
@@ -86,6 +63,40 @@ def _np(pt):
return np.asarray([float(pt[0]), float(pt[1])], dtype=float) return np.asarray([float(pt[0]), float(pt[1])], dtype=float)
def _hole_keepout_seed_points(geometry, params):
"""Return sparse keepout seed points: 3 for circular holes, coarse ring for others."""
d_keep = float(params['d_keep'])
pts = []
for hole in geometry.get('holes', []):
if hole.get('is_circular', False) and 'center' in hole:
cx, cy = hole['center']
diameter = float(hole.get('diameter', 10.0) or 10.0)
hole_radius = diameter / 2.0
keepout_radius = hole_radius * (1.0 + d_keep)
for k in range(3):
a = (2.0 * np.pi * k) / 3.0
pts.append([cx + keepout_radius * np.cos(a), cy + keepout_radius * np.sin(a)])
continue
hb = np.asarray(hole.get('boundary', []), dtype=float)
if len(hb) < 3:
continue
ring = LinearRing(hb)
keepout = Polygon(ring).buffer(max(d_keep, 0.0))
if keepout.is_empty:
continue
for geom in _geometry_to_list(keepout):
ext = geom.exterior
n = max(int(np.ceil(ext.length / max(params.get('s_min', 10.0), 1e-3))), 6)
for i in range(n):
p = ext.interpolate(i / n, normalized=True)
pts.append([p.x, p.y])
if not pts:
return np.empty((0, 2), dtype=np.float64)
return np.asarray(pts, dtype=np.float64)
def _generate_hex_grid(bbox, base_spacing): def _generate_hex_grid(bbox, base_spacing):
""" """
Generate a regular hexagonal-packed point grid. Generate a regular hexagonal-packed point grid.
@@ -266,11 +277,7 @@ def _add_boundary_vertices(points, geometry, params, keepout_union):
# Even spacing along inset boundary (as before) # Even spacing along inset boundary (as before)
if not inner_plate.is_empty and inner_plate.is_valid: if not inner_plate.is_empty and inner_plate.is_valid:
if inner_plate.geom_type == 'MultiPolygon': for inner_poly in _geometry_to_list(inner_plate):
inner_polys = list(inner_plate.geoms)
else:
inner_polys = [inner_plate]
for inner_poly in inner_polys:
ext = inner_poly.exterior ext = inner_poly.exterior
length = ext.length length = ext.length
n_pts = max(int(length / s_min), 4) n_pts = max(int(length / s_min), 4)
@@ -279,17 +286,16 @@ def _add_boundary_vertices(points, geometry, params, keepout_union):
pt = ext.interpolate(frac, normalized=True) pt = ext.interpolate(frac, normalized=True)
new_pts.append([pt.x, pt.y]) new_pts.append([pt.x, pt.y])
else: else:
# v1 geometry fallback: inset from polygon and force all inset ring vertices.
inner_plate = plate_poly.buffer(-w_frame) inner_plate = plate_poly.buffer(-w_frame)
if not inner_plate.is_empty: if not inner_plate.is_empty:
if inner_plate.geom_type == 'MultiPolygon': for inner_poly in _geometry_to_list(inner_plate):
inner_polys = list(inner_plate.geoms)
else:
inner_polys = [inner_plate]
for inner_poly in inner_polys:
ext = inner_poly.exterior ext = inner_poly.exterior
# Always keep true inset corners/vertices from the offset polygon.
coords = list(ext.coords)[:-1] coords = list(ext.coords)[:-1]
for cx, cy in coords: for cx, cy in coords:
new_pts.append([cx, cy]) new_pts.append([cx, cy])
# Plus evenly spaced boundary points for perimeter alignment.
length = ext.length length = ext.length
n_pts = max(int(length / s_min), 4) n_pts = max(int(length / s_min), 4)
for i in range(n_pts): for i in range(n_pts):
@@ -297,19 +303,10 @@ def _add_boundary_vertices(points, geometry, params, keepout_union):
pt = ext.interpolate(frac, normalized=True) pt = ext.interpolate(frac, normalized=True)
new_pts.append([pt.x, pt.y]) new_pts.append([pt.x, pt.y])
# Add points along hole keepout boundaries # Add sparse points for hole keepouts (3 points per circular keepout).
if not keepout_union.is_empty: keepout_seeds = _hole_keepout_seed_points(geometry, params)
geoms = [keepout_union] if keepout_union.geom_type == 'Polygon' else list(keepout_union.geoms) if len(keepout_seeds) > 0:
for geom in geoms: new_pts.extend(keepout_seeds.tolist())
ring = geom.exterior
for cx, cy in list(ring.coords)[:-1]:
new_pts.append([cx, cy])
length = ring.length
n_pts = max(int(length / (s_min * 0.7)), 6)
for i in range(n_pts):
frac = i / n_pts
pt = ring.interpolate(frac, normalized=True)
new_pts.append([pt.x, pt.y])
if inner_plate.is_empty or not inner_plate.is_valid: if inner_plate.is_empty or not inner_plate.is_valid:
inner_plate = plate_poly inner_plate = plate_poly
@@ -334,7 +331,7 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
# Add structured boundary-layer seed row along straight edges # Add structured boundary-layer seed row along straight edges
plate_poly = Polygon(geometry['outer_boundary']) plate_poly = Polygon(geometry['outer_boundary'])
all_pts = _add_boundary_layer_seed_points(all_pts, geometry, params, plate_poly, keepout_union) all_pts = _add_boundary_layer_seed_points(all_pts, geometry, params, inner_plate, keepout_union)
# Deduplicate close points # Deduplicate close points
all_pts = np.unique(np.round(all_pts, 4), axis=0) all_pts = np.unique(np.round(all_pts, 4), axis=0)
@@ -351,6 +348,10 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
if inner_plate.is_empty: if inner_plate.is_empty:
inner_plate = plate_poly inner_plate = plate_poly
inner_valid_region = inner_plate
if not keepout_union.is_empty:
inner_valid_region = inner_plate.difference(keepout_union)
keep = [] keep = []
for i, t in enumerate(triangles): for i, t in enumerate(triangles):
cx = np.mean(all_pts[t, 0]) cx = np.mean(all_pts[t, 0])
@@ -369,8 +370,15 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
if not plate_poly.buffer(0.5).contains(Point(all_pts[vi])): if not plate_poly.buffer(0.5).contains(Point(all_pts[vi])):
all_inside = False all_inside = False
break break
if all_inside: if not all_inside:
keep.append(i) continue
tri_poly = Polygon(all_pts[t])
if tri_poly.is_empty or not tri_poly.is_valid:
continue
if not inner_valid_region.buffer(1e-6).covers(tri_poly):
continue
keep.append(i)
triangles = triangles[keep] if keep else np.empty((0, 3), dtype=int) triangles = triangles[keep] if keep else np.empty((0, 3), dtype=int)