refactor: rewrite triangulation using Triangle library (constrained Delaunay + quality refinement)

- Replace scipy.spatial.Delaunay with Shewchuk's Triangle (PSLG-based)
- Boundary conforming: PSLG constrains edges along inset contour + hole keepout rings
- Quality: min angle 25°, no slivers
- Per-triangle density-based area refinement (s_min=20, s_max=80)
- Clean boundary plotting (no more crooked v1 line resampling)
- Triangulation plot shows inset contour (red dashed) + keepout rings (orange dashed)
- Add sandbox2_brain_input.json geometry file
This commit is contained in:
2026-02-17 17:14:11 +00:00
parent 1a14f7c420
commit fbbd3e7277
4 changed files with 460 additions and 397 deletions

View File

@@ -43,44 +43,21 @@ def _merge_params(geometry: Dict[str, Any], params_file: Path | None) -> Dict[st
def _plot_boundary_polyline(geometry: Dict[str, Any], arc_pts: int = 64) -> np.ndarray:
"""Get the sandbox boundary as a clean polyline for plotting.
For v2 typed segments: densify arcs, keep lines exact.
For v1: just use the raw outer_boundary vertices — no resampling needed
since these are the true polygon corners from NX.
"""
typed = geometry.get("outer_boundary_typed")
if typed:
pts = typed_segments_to_polyline(typed, arc_pts=arc_pts)
if len(pts) >= 3:
return np.asarray(pts, dtype=float)
# v1 fallback: use raw boundary vertices directly — they ARE the geometry.
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)
return outer
def _plot_density(geometry: Dict[str, Any], params: Dict[str, Any], out_path: Path, resolution: float = 3.0) -> None:
@@ -114,65 +91,83 @@ def _plot_density(geometry: Dict[str, Any], params: Dict[str, Any], out_path: Pa
def _plot_triangulation(geometry: Dict[str, Any], triangulation: Dict[str, Any], out_path: Path, params: Dict[str, Any] = None) -> None:
"""
Plot the Delaunay triangulation clipped to the inner frame (w_frame inset).
Green boundary, blue rib edges, blue hole circles.
Plot the triangulation with:
- Green: original sandbox boundary
- Red dashed: inset contour (w_frame offset) — this is what the mesh fills
- Blue: triangle edges
- Blue circles: hole boundaries
- Orange dashed: hole keepout rings
"""
from shapely.geometry import Polygon as ShapelyPolygon, LineString, Point
from shapely.geometry import Polygon as ShapelyPolygon, Point as ShapelyPoint
verts = triangulation["vertices"]
tris = triangulation["triangles"]
outer = _plot_boundary_polyline(geometry)
plate_poly = ShapelyPolygon(outer)
w_frame = (params or {}).get("w_frame", 2.0)
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)
if inner_plate.is_empty or not inner_plate.is_valid:
inner_plate = plate_poly
fig, ax = plt.subplots(figsize=(8, 6), dpi=160)
fig, ax = plt.subplots(figsize=(10, 8), dpi=160)
# Draw triangle edges clipped to inner plate
# Draw all triangle edges
drawn_edges = set()
for tri in tris:
centroid = Point(verts[tri].mean(axis=0))
if not inner_plate.contains(centroid):
continue
for i in range(3):
edge = tuple(sorted([tri[i], tri[(i + 1) % 3]]))
if edge in drawn_edges:
continue
drawn_edges.add(edge)
p1, p2 = verts[edge[0]], verts[edge[1]]
line = LineString([p1, p2])
clipped = inner_plate.intersection(line)
if clipped.is_empty:
continue
if clipped.geom_type == "LineString":
cx, cy = clipped.xy
ax.plot(cx, cy, color="#1f77b4", lw=0.5, alpha=0.85)
elif clipped.geom_type == "MultiLineString":
for seg in clipped.geoms:
cx, cy = seg.xy
ax.plot(cx, cy, color="#1f77b4", lw=0.5, alpha=0.85)
ax.plot([p1[0], p2[0]], [p1[1], p2[1]],
color="#1f77b4", lw=0.5, alpha=0.85)
# Green sandbox boundary
# Green: original sandbox boundary
ax.plot(np.r_[outer[:, 0], outer[0, 0]], np.r_[outer[:, 1], outer[0, 1]],
color="#228833", lw=1.8, zorder=5)
color="#228833", lw=1.8, zorder=5, label="Sandbox boundary")
# Blue hole circles
# Red dashed: inset contour (w_frame)
if not inner_plate.is_empty:
polys = [inner_plate] if inner_plate.geom_type == 'Polygon' else list(inner_plate.geoms)
for poly in polys:
ix, iy = poly.exterior.xy
ax.plot(ix, iy, color="#cc3333", lw=1.2, ls="--", zorder=4,
label=f"Inset contour (w_frame={w_frame}mm)")
# Blue hole circles + Orange keepout rings
for hole in geometry.get("holes", []):
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)
# Hole circle
hb = np.column_stack([cx + r * np.cos(a), cy + r * np.sin(a)])
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]],
"b-", lw=0.8, zorder=3)
# Keepout ring
kr = r * (1.0 + d_keep)
kb = np.column_stack([cx + kr * np.cos(a), cy + kr * np.sin(a)])
ax.plot(np.r_[kb[:, 0], kb[0, 0]], np.r_[kb[:, 1], kb[0, 1]],
color="#ff8800", lw=0.8, ls="--", zorder=3)
else:
hb = np.asarray(hole["boundary"])
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]],
"b-", lw=0.8, zorder=3)
ax.plot(np.r_[hb[:, 0], hb[0, 0]], np.r_[hb[:, 1], hb[0, 1]],
"b-", lw=0.8, zorder=3)
# De-duplicate legend entries
handles, labels = ax.get_legend_handles_labels()
seen = set()
unique_handles, unique_labels = [], []
for h, l in zip(handles, labels):
if l not in seen:
seen.add(l)
unique_handles.append(h)
unique_labels.append(l)
ax.legend(unique_handles, unique_labels, loc="upper right", fontsize=8)
ax.set_aspect("equal", adjustable="box")
ax.set_title("Constrained Delaunay Triangulation / Rib Pattern")
ax.set_title(f"Isogrid Triangulation ({len(tris)} triangles, {len(drawn_edges)} edges)")
ax.set_xlabel("x [mm]")
ax.set_ylabel("y [mm]")
fig.tight_layout()