refactor(triangulation): hex grid isogrid layout replaces constrained Delaunay

Complete rewrite of triangulation engine:
- Regular hexagonal-packed vertex grid (equilateral triangles)
- Density-adaptive refinement: denser near holes, coarser in open areas
- Boundary-conforming vertices along frame edge and hole keepouts
- Delaunay on point set + clip to valid region (inside frame, outside keepouts)
- Result: proper isogrid layout, 87 pockets from 234 triangles
- 553 NX entities, min fillet 4.89mm, mass 2770g
- No more dependency on Shewchuk Triangle library (scipy.spatial.Delaunay)
This commit is contained in:
2026-02-16 20:58:05 +00:00
parent 239e2f01a9
commit 4f051aa7e1
2 changed files with 6607 additions and 5526 deletions

View File

@@ -1,208 +1,245 @@
"""
Constrained Delaunay triangulation with density-adaptive refinement.
Isogrid triangulation — generates a proper isogrid rib pattern.
Uses Shewchuk's Triangle library to generate adaptive mesh that
respects plate boundary, hole keepouts, and density-driven spacing.
Strategy: lay a regular equilateral triangle grid, then adapt it:
1. Generate hex-packed vertex grid at base spacing
2. Scale spacing locally based on density field (growing away from features)
3. Delaunay triangulate the point set
4. Clip to plate boundary, exclude hole keepouts
5. Result: well-proportioned triangles everywhere, denser near holes/edges
This is NOT a mesh — it's an isogrid layout. Every triangle should be
a viable machining pocket.
"""
import numpy as np
import triangle as tr
from shapely.geometry import Polygon
from scipy.spatial import Delaunay
from shapely.geometry import Polygon, Point, MultiPoint
from shapely.ops import unary_union
from .density_field import evaluate_density, density_to_spacing
def offset_polygon(coords, distance, inward=True):
def _generate_hex_grid(bbox, base_spacing):
"""
Offset a polygon boundary by `distance`.
inward=True shrinks, inward=False expands.
Generate a regular hexagonal-packed point grid.
Uses Shapely buffer (negative = inward for exterior ring).
This creates the classic isogrid vertex layout: rows of points
offset by half-spacing, producing equilateral triangles when
Delaunay triangulated.
Returns ndarray of shape (N, 2).
"""
poly = Polygon(coords)
if inward:
buffered = poly.buffer(-distance)
else:
buffered = poly.buffer(distance)
if buffered.is_empty:
return coords # can't offset that much, return original
if hasattr(buffered, 'exterior'):
return list(buffered.exterior.coords)[:-1] # remove closing duplicate
return coords
x_min, y_min, x_max, y_max = bbox
# Padding
pad = base_spacing
x_min -= pad
y_min -= pad
x_max += pad
y_max += pad
row_height = base_spacing * np.sqrt(3) / 2.0
points = []
row = 0
y = y_min
while y <= y_max:
# Alternate rows offset by half spacing
x_offset = (base_spacing / 2.0) if (row % 2) else 0.0
x = x_min + x_offset
while x <= x_max:
points.append([x, y])
x += base_spacing
y += row_height
row += 1
return np.array(points, dtype=np.float64)
def sample_circle(center, radius, num_points=32):
"""Sample a circle as a polygon with `num_points` vertices."""
cx, cy = center
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
return [[cx + radius * np.cos(a), cy + radius * np.sin(a)] for a in angles]
def build_pslg(geometry, params):
def _adaptive_hex_grid(geometry, params):
"""
Build Planar Straight Line Graph for Triangle library.
Generate density-adaptive hex grid.
Parameters
----------
geometry : dict
Plate geometry (outer_boundary, holes).
params : dict
Must contain d_keep (hole keepout multiplier).
Returns
-------
dict : Triangle-compatible PSLG with vertices, segments, holes.
Start with a coarse grid at s_max spacing, then iteratively add
points in high-density regions until local spacing matches the
density-driven target.
"""
outer = np.array(geometry['outer_boundary'])
x_min, y_min = outer.min(axis=0)
x_max, y_max = outer.max(axis=0)
bbox = (x_min, y_min, x_max, y_max)
s_min = params['s_min']
s_max = params['s_max']
plate_poly = Polygon(geometry['outer_boundary'])
# Build hole keepout polygons
d_keep = params['d_keep']
vertices = []
segments = []
hole_markers = []
# Outer boundary (no offset needed — frame is handled in profile assembly)
outer = list(geometry['outer_boundary'])
# Strip closing duplicate if last == first
if len(outer) > 2:
d = np.linalg.norm(np.array(outer[0]) - np.array(outer[-1]))
if d < 0.01:
outer = outer[:-1]
v_start = len(vertices)
vertices.extend(outer)
n = len(outer)
for i in range(n):
segments.append([v_start + i, v_start + (i + 1) % n])
# Each hole with boss keepout reservation
for hole in geometry['holes']:
keepout_polys = []
for hole in geometry.get('holes', []):
diameter = float(hole.get('diameter', 10.0) or 10.0)
keepout_dist = d_keep * diameter / 2.0
if hole.get('is_circular', False) and 'center' in hole:
# Circular boss reservation around hole:
# r_boss = r_hole + d_keep * hole_diameter / 2
cx, cy = hole['center']
hole_radius = diameter / 2.0
boss_radius = hole_radius + keepout_dist
keepout_boundary = sample_circle(hole['center'], boss_radius, num_points=12)
else:
# Fallback for non-circular holes
keepout_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
boss_radius = hole_radius + d_keep * hole_radius
keepout = Point(cx, cy).buffer(boss_radius, resolution=16)
keepout_polys.append(keepout)
# Strip closing duplicate if present
if len(keepout_boundary) > 2:
d = np.linalg.norm(np.array(keepout_boundary[0]) - np.array(keepout_boundary[-1]))
if d < 0.01:
keepout_boundary = keepout_boundary[:-1]
v_start = len(vertices)
vertices.extend(keepout_boundary)
n_h = len(keepout_boundary)
for i in range(n_h):
segments.append([v_start + i, v_start + (i + 1) % n_h])
keepout_union = unary_union(keepout_polys) if keepout_polys else Polygon()
# Marker inside hole tells Triangle to leave this keepout region empty
hole_markers.append(hole['center'])
# Valid region = plate minus keepouts
valid_region = plate_poly.difference(keepout_union)
# Inset valid region by frame width to keep grid points inside the frame
w_frame = params.get('w_frame', 8.0)
inner_region = plate_poly.buffer(-w_frame)
if inner_region.is_empty:
inner_region = plate_poly
# Generate base grid at s_max (coarsest)
base_pts = _generate_hex_grid(bbox, s_max)
# Filter to plate interior (outside keepouts, inside frame)
valid_pts = []
for pt in base_pts:
p = Point(pt[0], pt[1])
if inner_region.contains(p) and not keepout_union.contains(p):
valid_pts.append(pt)
valid_pts = np.array(valid_pts, dtype=np.float64) if valid_pts else np.empty((0, 2))
if s_min >= s_max * 0.95:
# Uniform mode — no refinement needed
return valid_pts, valid_region, keepout_union
# Adaptive refinement: add denser points near high-density areas
# Generate a fine grid at s_min
fine_pts = _generate_hex_grid(bbox, s_min)
# For each fine point, check if local density warrants adding it
extra_pts = []
for pt in fine_pts:
p = Point(pt[0], pt[1])
if not inner_region.contains(p) or keepout_union.contains(p):
continue
# What spacing does the density field want here?
eta = evaluate_density(pt[0], pt[1], geometry, params)
target_spacing = density_to_spacing(eta, params)
# If target spacing < s_max * 0.8, this is a refinement zone
if target_spacing < s_max * 0.8:
# Check distance to nearest existing point
if len(valid_pts) > 0:
dists = np.sqrt(np.sum((valid_pts - pt)**2, axis=1))
min_dist = np.min(dists)
# Add point if nearest neighbor is farther than target spacing
if min_dist > target_spacing * 0.85:
extra_pts.append(pt)
# Also add to valid_pts for future distance checks
valid_pts = np.vstack([valid_pts, pt])
if extra_pts:
all_pts = np.vstack([valid_pts, np.array(extra_pts)])
# Deduplicate (shouldn't be needed but safe)
all_pts = np.unique(np.round(all_pts, 6), axis=0)
else:
all_pts = valid_pts
return all_pts, valid_region, keepout_union
def _add_boundary_vertices(points, geometry, params, keepout_union):
"""
Add vertices along the outer boundary and hole keepout boundaries.
result = {
'vertices': np.array(vertices, dtype=np.float64),
'segments': np.array(segments, dtype=np.int32),
}
if hole_markers:
result['holes'] = np.array(hole_markers, dtype=np.float64)
return result
This ensures triangles conform to boundaries rather than just being
clipped. Points are spaced at approximately s_min along boundaries.
"""
s_min = params['s_min']
w_frame = params.get('w_frame', 8.0)
new_pts = list(points)
def compute_triangle_areas(vertices, triangles):
"""Compute area of each triangle."""
v0 = vertices[triangles[:, 0]]
v1 = vertices[triangles[:, 1]]
v2 = vertices[triangles[:, 2]]
# Cross product / 2
areas = 0.5 * np.abs(
(v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) -
(v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1])
)
return areas
# Add points along inset outer boundary (frame inner edge)
plate_poly = Polygon(geometry['outer_boundary'])
inner_frame = plate_poly.buffer(-w_frame)
if not inner_frame.is_empty and inner_frame.geom_type == 'Polygon':
ring = inner_frame.exterior
length = ring.length
n_pts = max(int(length / s_min), 4)
for i in range(n_pts):
frac = i / n_pts
pt = ring.interpolate(frac, normalized=True)
new_pts.append([pt.x, pt.y])
# Add points along hole keepout boundaries
if not keepout_union.is_empty:
geoms = [keepout_union] if keepout_union.geom_type == 'Polygon' else list(keepout_union.geoms)
for geom in geoms:
ring = geom.exterior
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])
def compute_centroids(vertices, triangles):
"""Compute centroid of each triangle."""
v0 = vertices[triangles[:, 0]]
v1 = vertices[triangles[:, 1]]
v2 = vertices[triangles[:, 2]]
return (v0 + v1 + v2) / 3.0
def filter_small_triangles(result, min_triangle_area):
"""Remove triangles smaller than the manufacturing threshold."""
triangles = result.get('triangles')
vertices = result.get('vertices')
if triangles is None or vertices is None or len(triangles) == 0:
return result
areas = compute_triangle_areas(vertices, triangles)
keep_mask = areas >= float(min_triangle_area)
result['triangle_areas'] = areas
result['small_triangle_mask'] = ~keep_mask
result['triangles'] = triangles[keep_mask]
return result
return np.array(new_pts, dtype=np.float64)
def generate_triangulation(geometry, params, max_refinement_passes=3):
"""
Generate density-adaptive constrained Delaunay triangulation.
Generate isogrid triangulation: hex-packed grid + Delaunay + clip.
Parameters
----------
geometry : dict
Plate geometry.
params : dict
Full parameter set (density field + spacing + manufacturing).
max_refinement_passes : int
Number of iterative refinement passes.
Returns
-------
dict : Triangle result with 'vertices' and 'triangles'.
Returns dict with 'vertices' (ndarray Nx2) and 'triangles' (ndarray Mx3).
"""
pslg = build_pslg(geometry, params)
# Use s_min as the uniform target spacing for initial/non-adaptive pass.
# Density-adaptive refinement only kicks in when stress results are
# available (future iterations). For now, uniform triangles everywhere.
s_target = params['s_min']
use_adaptive = params.get('adaptive_density', False)
# Generate adaptive point grid
grid_pts, valid_region, keepout_union = _adaptive_hex_grid(geometry, params)
# Target equilateral triangle area for the chosen spacing
target_area = (np.sqrt(3) / 4.0) * s_target**2
if len(grid_pts) < 3:
return {'vertices': grid_pts, 'triangles': np.empty((0, 3), dtype=int)}
# Triangle options: p=PSLG, q30=min angle 30°, a=area constraint, D=conforming
result = tr.triangulate(pslg, f'pq30Da{target_area:.1f}')
# Add boundary-conforming vertices
all_pts = _add_boundary_vertices(grid_pts, geometry, params, keepout_union)
if use_adaptive:
# Iterative density-adaptive refinement (for stress-informed passes)
for iteration in range(max_refinement_passes):
verts = result['vertices']
tris = result['triangles']
# Deduplicate close points
all_pts = np.unique(np.round(all_pts, 4), axis=0)
areas = compute_triangle_areas(verts, tris)
centroids = compute_centroids(verts, tris)
if len(all_pts) < 3:
return {'vertices': all_pts, 'triangles': np.empty((0, 3), dtype=int)}
target_areas = np.array([
(np.sqrt(3) / 4.0) * density_to_spacing(
evaluate_density(cx, cy, geometry, params), params
)**2
for cx, cy in centroids
])
# Delaunay triangulation of the point set
tri = Delaunay(all_pts)
triangles = tri.simplices
if np.all(areas <= target_areas * 1.2):
break
# Filter: keep only triangles whose centroid is inside valid region
plate_poly = Polygon(geometry['outer_boundary'])
w_frame = params.get('w_frame', 8.0)
inner_plate = plate_poly.buffer(-w_frame)
if inner_plate.is_empty:
inner_plate = plate_poly
result['triangle_max_area'] = target_areas
result = tr.triangulate(result, 'rpq30D')
keep = []
for i, t in enumerate(triangles):
cx = np.mean(all_pts[t, 0])
cy = np.mean(all_pts[t, 1])
centroid = Point(cx, cy)
# Keep if centroid is inside plate frame and outside keepouts
if inner_plate.contains(centroid) and not keepout_union.contains(centroid):
keep.append(i)
min_triangle_area = params.get('min_triangle_area', 20.0)
result = filter_small_triangles(result, min_triangle_area)
return result
triangles = triangles[keep] if keep else np.empty((0, 3), dtype=int)
# Filter degenerate / tiny triangles
min_area = params.get('min_triangle_area', 20.0)
if len(triangles) > 0:
v0 = all_pts[triangles[:, 0]]
v1 = all_pts[triangles[:, 1]]
v2 = all_pts[triangles[:, 2]]
areas = 0.5 * np.abs(
(v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) -
(v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1])
)
triangles = triangles[areas >= min_area]
return {'vertices': all_pts, 'triangles': triangles}

File diff suppressed because it is too large Load Diff