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:
@@ -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
Reference in New Issue
Block a user