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
y_max += pad
if buffered.is_empty: row_height = base_spacing * np.sqrt(3) / 2.0
return coords # can't offset that much, return original 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
if hasattr(buffered, 'exterior'): return np.array(points, dtype=np.float64)
return list(buffered.exterior.coords)[:-1] # remove closing duplicate
return coords
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)
result = { # Inset valid region by frame width to keep grid points inside the frame
'vertices': np.array(vertices, dtype=np.float64), w_frame = params.get('w_frame', 8.0)
'segments': np.array(segments, dtype=np.int32), inner_region = plate_poly.buffer(-w_frame)
} if inner_region.is_empty:
if hole_markers: inner_region = plate_poly
result['holes'] = np.array(hole_markers, dtype=np.float64)
return result # 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 compute_triangle_areas(vertices, triangles): def _add_boundary_vertices(points, geometry, params, keepout_union):
"""Compute area of each triangle.""" """
v0 = vertices[triangles[:, 0]] Add vertices along the outer boundary and hole keepout boundaries.
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
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)
def compute_centroids(vertices, triangles): new_pts = list(points)
"""Compute centroid of each triangle."""
v0 = vertices[triangles[:, 0]]
v1 = vertices[triangles[:, 1]]
v2 = vertices[triangles[:, 2]]
return (v0 + v1 + v2) / 3.0
# 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])
def filter_small_triangles(result, min_triangle_area): # Add points along hole keepout boundaries
"""Remove triangles smaller than the manufacturing threshold.""" if not keepout_union.is_empty:
triangles = result.get('triangles') geoms = [keepout_union] if keepout_union.geom_type == 'Polygon' else list(keepout_union.geoms)
vertices = result.get('vertices') for geom in geoms:
if triangles is None or vertices is None or len(triangles) == 0: ring = geom.exterior
return result 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])
areas = compute_triangle_areas(vertices, triangles) return np.array(new_pts, dtype=np.float64)
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. if len(grid_pts) < 3:
# Density-adaptive refinement only kicks in when stress results are return {'vertices': grid_pts, 'triangles': np.empty((0, 3), dtype=int)}
# 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 # Add boundary-conforming vertices
target_area = (np.sqrt(3) / 4.0) * s_target**2 all_pts = _add_boundary_vertices(grid_pts, geometry, params, keepout_union)
# Triangle options: p=PSLG, q30=min angle 30°, a=area constraint, D=conforming # Deduplicate close points
result = tr.triangulate(pslg, f'pq30Da{target_area:.1f}') all_pts = np.unique(np.round(all_pts, 4), axis=0)
if use_adaptive: if len(all_pts) < 3:
# Iterative density-adaptive refinement (for stress-informed passes) return {'vertices': all_pts, 'triangles': np.empty((0, 3), dtype=int)}
for iteration in range(max_refinement_passes):
verts = result['vertices']
tris = result['triangles']
areas = compute_triangle_areas(verts, tris) # Delaunay triangulation of the point set
centroids = compute_centroids(verts, tris) tri = Delaunay(all_pts)
triangles = tri.simplices
target_areas = np.array([ # Filter: keep only triangles whose centroid is inside valid region
(np.sqrt(3) / 4.0) * density_to_spacing( plate_poly = Polygon(geometry['outer_boundary'])
evaluate_density(cx, cy, geometry, params), params w_frame = params.get('w_frame', 8.0)
)**2 inner_plate = plate_poly.buffer(-w_frame)
for cx, cy in centroids if inner_plate.is_empty:
]) inner_plate = plate_poly
if np.all(areas <= target_areas * 1.2): keep = []
break 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)
result['triangle_max_area'] = target_areas triangles = triangles[keep] if keep else np.empty((0, 3), dtype=int)
result = tr.triangulate(result, 'rpq30D')
min_triangle_area = params.get('min_triangle_area', 20.0) # Filter degenerate / tiny triangles
result = filter_small_triangles(result, min_triangle_area) min_area = params.get('min_triangle_area', 20.0)
return result 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