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 Strategy: lay a regular equilateral triangle grid, then adapt it:
respects plate boundary, hole keepouts, and density-driven spacing. 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 numpy as np
import triangle as tr from scipy.spatial import Delaunay
from shapely.geometry import Polygon from shapely.geometry import Polygon, Point, MultiPoint
from shapely.ops import unary_union
from .density_field import evaluate_density, density_to_spacing 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`. Generate a regular hexagonal-packed point grid.
inward=True shrinks, inward=False expands.
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) x_min, y_min, x_max, y_max = bbox
if inward: # Padding
buffered = poly.buffer(-distance) pad = base_spacing
else: x_min -= pad
buffered = poly.buffer(distance) y_min -= pad
x_max += pad
if buffered.is_empty: y_max += pad
return coords # can't offset that much, return original
row_height = base_spacing * np.sqrt(3) / 2.0
if hasattr(buffered, 'exterior'): points = []
return list(buffered.exterior.coords)[:-1] # remove closing duplicate row = 0
return coords 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): def _adaptive_hex_grid(geometry, params):
"""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):
""" """
Build Planar Straight Line Graph for Triangle library. Generate density-adaptive hex grid.
Parameters Start with a coarse grid at s_max spacing, then iteratively add
---------- points in high-density regions until local spacing matches the
geometry : dict density-driven target.
Plate geometry (outer_boundary, holes).
params : dict
Must contain d_keep (hole keepout multiplier).
Returns
-------
dict : Triangle-compatible PSLG with vertices, segments, holes.
""" """
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'] d_keep = params['d_keep']
keepout_polys = []
vertices = [] for hole in geometry.get('holes', []):
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']:
diameter = float(hole.get('diameter', 10.0) or 10.0) 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: if hole.get('is_circular', False) and 'center' in hole:
# Circular boss reservation around hole: cx, cy = hole['center']
# r_boss = r_hole + d_keep * hole_diameter / 2
hole_radius = diameter / 2.0 hole_radius = diameter / 2.0
boss_radius = hole_radius + keepout_dist boss_radius = hole_radius + d_keep * hole_radius
keepout_boundary = sample_circle(hole['center'], boss_radius, num_points=12) keepout = Point(cx, cy).buffer(boss_radius, resolution=16)
else: keepout_polys.append(keepout)
# Fallback for non-circular holes
keepout_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
# Strip closing duplicate if present keepout_union = unary_union(keepout_polys) if keepout_polys else Polygon()
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])
# Marker inside hole tells Triangle to leave this keepout region empty # Valid region = plate minus keepouts
hole_markers.append(hole['center']) 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 = { This ensures triangles conform to boundaries rather than just being
'vertices': np.array(vertices, dtype=np.float64), clipped. Points are spaced at approximately s_min along boundaries.
'segments': np.array(segments, dtype=np.int32), """
} s_min = params['s_min']
if hole_markers: w_frame = params.get('w_frame', 8.0)
result['holes'] = np.array(hole_markers, dtype=np.float64)
return result
new_pts = list(points)
def compute_triangle_areas(vertices, triangles): # Add points along inset outer boundary (frame inner edge)
"""Compute area of each triangle.""" plate_poly = Polygon(geometry['outer_boundary'])
v0 = vertices[triangles[:, 0]] inner_frame = plate_poly.buffer(-w_frame)
v1 = vertices[triangles[:, 1]] if not inner_frame.is_empty and inner_frame.geom_type == 'Polygon':
v2 = vertices[triangles[:, 2]] ring = inner_frame.exterior
# Cross product / 2 length = ring.length
areas = 0.5 * np.abs( n_pts = max(int(length / s_min), 4)
(v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - for i in range(n_pts):
(v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1]) frac = i / n_pts
) pt = ring.interpolate(frac, normalized=True)
return areas 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): return np.array(new_pts, dtype=np.float64)
"""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
def generate_triangulation(geometry, params, max_refinement_passes=3): def generate_triangulation(geometry, params, max_refinement_passes=3):
""" """
Generate density-adaptive constrained Delaunay triangulation. Generate isogrid triangulation: hex-packed grid + Delaunay + clip.
Parameters Returns dict with 'vertices' (ndarray Nx2) and 'triangles' (ndarray Mx3).
----------
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'.
""" """
pslg = build_pslg(geometry, params) # Generate adaptive point grid
grid_pts, valid_region, keepout_union = _adaptive_hex_grid(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)
# Target equilateral triangle area for the chosen spacing if len(grid_pts) < 3:
target_area = (np.sqrt(3) / 4.0) * s_target**2 return {'vertices': grid_pts, 'triangles': np.empty((0, 3), dtype=int)}
# Triangle options: p=PSLG, q30=min angle 30°, a=area constraint, D=conforming # Add boundary-conforming vertices
result = tr.triangulate(pslg, f'pq30Da{target_area:.1f}') all_pts = _add_boundary_vertices(grid_pts, geometry, params, keepout_union)
if use_adaptive: # Deduplicate close points
# Iterative density-adaptive refinement (for stress-informed passes) all_pts = np.unique(np.round(all_pts, 4), axis=0)
for iteration in range(max_refinement_passes):
verts = result['vertices']
tris = result['triangles']
areas = compute_triangle_areas(verts, tris) if len(all_pts) < 3:
centroids = compute_centroids(verts, tris) return {'vertices': all_pts, 'triangles': np.empty((0, 3), dtype=int)}
target_areas = np.array([ # Delaunay triangulation of the point set
(np.sqrt(3) / 4.0) * density_to_spacing( tri = Delaunay(all_pts)
evaluate_density(cx, cy, geometry, params), params triangles = tri.simplices
)**2
for cx, cy in centroids
])
if np.all(areas <= target_areas * 1.2): # Filter: keep only triangles whose centroid is inside valid region
break 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 keep = []
result = tr.triangulate(result, 'rpq30D') 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) triangles = triangles[keep] if keep else np.empty((0, 3), dtype=int)
result = filter_small_triangles(result, min_triangle_area)
return result # 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