Files
Atomizer/tools/adaptive-isogrid/src/brain/triangulation.py

366 lines
13 KiB
Python
Raw Normal View History

"""
Isogrid triangulation generates a proper isogrid rib pattern.
Uses Shewchuk's Triangle library (constrained Delaunay + quality refinement)
for boundary-conforming, clean triangulations.
Strategy:
1. Build PSLG (Planar Straight Line Graph):
- Outer contour = inset boundary (w_frame offset inward)
- Holes = keepout circles (d_keep offset outward from holes)
2. Triangulate with quality constraints:
- Minimum angle ~25° (no slivers)
- Maximum area varies by density heatmap (smaller near holes, larger away)
3. Result: clean mesh that fills 100% of valid area, with density-graded
triangle sizes and edges parallel to boundary/hole contours.
"""
import numpy as np
import triangle as tr
from shapely.geometry import Polygon, Point, LinearRing, MultiPolygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from src.shared.arc_utils import inset_arc, typed_segments_to_polyline
from .density_field import evaluate_density, density_to_spacing
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _geometry_to_list(geom: BaseGeometry) -> list:
if geom.is_empty:
return []
if geom.geom_type == 'Polygon':
return [geom]
if geom.geom_type == 'MultiPolygon':
return list(geom.geoms)
return list(getattr(geom, 'geoms', []))
def _ring_to_segments(coords: np.ndarray, start_idx: int):
"""Convert a ring (Nx2 array, NOT closed) to vertex array + segment index pairs."""
n = len(coords)
segments = []
for i in range(n):
segments.append([start_idx + i, start_idx + (i + 1) % n])
return segments
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)
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)
# ---------------------------------------------------------------------------
# Step 1 — Build the inset plate polygon (frame inner edge)
# ---------------------------------------------------------------------------
def _build_inner_plate(geometry, params) -> Polygon:
"""Offset sandbox boundary inward by w_frame."""
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)
if inner_plate.is_empty or not inner_plate.is_valid:
inner_plate = plate_poly
return inner_plate
# ---------------------------------------------------------------------------
# Step 2 — Build hole keepout polygons
# ---------------------------------------------------------------------------
def _build_keepouts(geometry, params) -> list:
"""Build list of keepout Polygons (one per hole)."""
d_keep = float(params['d_keep'])
keepouts = []
for hole in geometry.get('holes', []):
diameter = float(hole.get('diameter', 10.0) or 10.0)
if hole.get('is_circular', False) and 'center' in hole:
cx, cy = hole['center']
hole_radius = diameter / 2.0
keepout_radius = hole_radius + d_keep * hole_radius
keepout = Point(cx, cy).buffer(keepout_radius, resolution=32)
keepouts.append(keepout)
else:
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 not keepout.is_empty:
keepouts.append(keepout)
return keepouts
# ---------------------------------------------------------------------------
# Step 3 — Build PSLG for Triangle library
# ---------------------------------------------------------------------------
def _build_pslg(inner_plate: Polygon, keepouts: list, boundary_spacing: float):
"""
Build a Planar Straight Line Graph (PSLG) for the Triangle library.
Returns dict with:
'vertices': Nx2 array
'segments': Mx2 array of vertex index pairs
'holes': Hx2 array of hole marker points
"""
vertices = []
segments = []
hole_markers = []
# Outer boundary of the inner plate
# Handle MultiPolygon (take largest piece)
polys = _geometry_to_list(inner_plate)
if not polys:
return None
main_poly = max(polys, key=lambda p: p.area)
# Sample the outer ring
outer_pts = _sample_ring(main_poly.exterior, boundary_spacing)
if len(outer_pts) < 3:
return None
start_idx = 0
vertices.extend(outer_pts.tolist())
segs = _ring_to_segments(outer_pts, start_idx)
segments.extend(segs)
start_idx += len(outer_pts)
# Handle any interior rings of the inner plate (shouldn't happen normally)
for interior_ring in main_poly.interiors:
ring_pts = _sample_ring(interior_ring, boundary_spacing)
if len(ring_pts) < 3:
continue
vertices.extend(ring_pts.tolist())
segs = _ring_to_segments(ring_pts, start_idx)
segments.extend(segs)
# Hole marker inside this interior ring
centroid = Polygon(interior_ring).centroid
hole_markers.append([centroid.x, centroid.y])
start_idx += len(ring_pts)
# Keepout holes — clip to inner plate
for keepout in keepouts:
clipped = inner_plate.intersection(keepout)
if clipped.is_empty:
continue
for kpoly in _geometry_to_list(clipped):
if kpoly.area < 1.0:
continue
ring_pts = _sample_ring(kpoly.exterior, boundary_spacing)
if len(ring_pts) < 3:
continue
vertices.extend(ring_pts.tolist())
segs = _ring_to_segments(ring_pts, start_idx)
segments.extend(segs)
# Hole marker inside the keepout
centroid = kpoly.centroid
hole_markers.append([centroid.x, centroid.y])
start_idx += len(ring_pts)
return {
'vertices': np.array(vertices, dtype=np.float64),
'segments': np.array(segments, dtype=np.int32),
'holes': np.array(hole_markers, dtype=np.float64) if hole_markers else np.empty((0, 2)),
}
# ---------------------------------------------------------------------------
# Step 4 — Compute area constraint from density field
# ---------------------------------------------------------------------------
def _max_area_for_spacing(spacing: float) -> float:
"""Area of an equilateral triangle with given edge length."""
return (np.sqrt(3) / 4.0) * spacing ** 2
def _refine_with_density(tri_result, geometry, params):
"""
Iterative refinement: check each triangle's area against the density-based
max area at its centroid. If too large, re-triangulate with tighter constraint.
Uses Triangle's region-based area constraints via the 'a' switch.
"""
s_min = float(params['s_min'])
s_max = float(params['s_max'])
verts = tri_result['vertices']
tris = tri_result['triangles']
# Compute max allowed area for each triangle based on density at centroid
max_areas = np.empty(len(tris))
for i, t in enumerate(tris):
cx = np.mean(verts[t, 0])
cy = np.mean(verts[t, 1])
eta = evaluate_density(cx, cy, geometry, params)
target_spacing = density_to_spacing(eta, params)
max_areas[i] = _max_area_for_spacing(target_spacing)
# Use the global maximum area as a single constraint
# (Triangle library doesn't support per-triangle area easily in basic mode)
# Instead, use a moderate constraint and let the quality refinement handle it
global_max_area = _max_area_for_spacing(s_max)
return global_max_area
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def generate_triangulation(geometry, params, max_refinement_passes=3):
"""
Generate isogrid triangulation using Triangle library.
Returns dict with 'vertices' (Nx2) and 'triangles' (Mx3).
"""
s_min = float(params['s_min'])
s_max = float(params['s_max'])
# Step 1: Build inner plate
inner_plate = _build_inner_plate(geometry, params)
if inner_plate.is_empty:
return {'vertices': np.empty((0, 2)), 'triangles': np.empty((0, 3), dtype=int)}
# Step 2: Build keepouts
keepouts = _build_keepouts(geometry, params)
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))
pslg = _build_pslg(inner_plate, keepouts, boundary_spacing)
if pslg is None or len(pslg['vertices']) < 3:
return {'vertices': np.empty((0, 2)), 'triangles': np.empty((0, 3), dtype=int)}
# Step 4: Initial triangulation with coarse area constraint
# 'p' = use PSLG (segments + holes)
# 'q25' = minimum angle 25° (no slivers)
# 'a{area}' = max triangle area (coarse — s_max)
max_area = _max_area_for_spacing(s_max)
tri_switches = f'pq25a{max_area:.1f}'
tri_input = {
'vertices': pslg['vertices'],
'segments': pslg['segments'],
}
if len(pslg['holes']) > 0:
tri_input['holes'] = pslg['holes']
try:
result = tr.triangulate(tri_input, tri_switches)
except Exception:
return {'vertices': np.empty((0, 2)), 'triangles': np.empty((0, 3), dtype=int)}
verts = result.get('vertices', np.empty((0, 2)))
tris = result.get('triangles', np.empty((0, 3), dtype=int))
if len(tris) == 0:
return {'vertices': verts, 'triangles': tris}
# Step 5: Density-based refinement using per-triangle area constraints
# Triangle's 'r' switch refines an existing triangulation.
# We set 'triangle_max_area' per triangle based on density at centroid.
for pass_num in range(max_refinement_passes):
# Compute per-triangle max area from density field
per_tri_max = np.empty(len(tris))
needs_refinement = False
for i, t in enumerate(tris):
cx = np.mean(verts[t, 0])
cy = np.mean(verts[t, 1])
eta = evaluate_density(cx, cy, geometry, params)
target_spacing = density_to_spacing(eta, params)
per_tri_max[i] = _max_area_for_spacing(target_spacing)
# Check if this triangle actually needs refinement
v0, v1, v2 = verts[t[0]], verts[t[1]], verts[t[2]]
actual_area = 0.5 * abs(
(v1[0] - v0[0]) * (v2[1] - v0[1]) -
(v2[0] - v0[0]) * (v1[1] - v0[1])
)
if actual_area > per_tri_max[i] * 1.3:
needs_refinement = True
if not needs_refinement:
break
# Build refinement input with per-triangle area constraints
refine_input = {
'vertices': verts,
'triangles': tris,
'segments': pslg['segments'] if len(pslg['segments']) <= len(verts) else np.empty((0, 2), dtype=np.int32),
'triangle_max_area': per_tri_max,
}
if len(pslg['holes']) > 0:
refine_input['holes'] = pslg['holes']
try:
result = tr.triangulate(refine_input, 'rpq25')
verts = result.get('vertices', verts)
tris = result.get('triangles', tris)
except Exception:
break
# Step 6: Filter degenerate / tiny triangles
min_area_filter = float(params.get('min_triangle_area', 20.0))
if len(tris) > 0:
v0 = verts[tris[:, 0]]
v1 = verts[tris[:, 1]]
v2 = verts[tris[:, 2]]
areas = 0.5 * np.abs(
(v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) -
(v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1])
)
tris = tris[areas >= min_area_filter]
return {'vertices': verts, 'triangles': tris}