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