feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
Isogrid triangulation — generates a proper isogrid rib pattern.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
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.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
2026-02-16 20:58:05 +00:00
|
|
|
from scipy.spatial import Delaunay
|
|
|
|
|
from shapely.geometry import Polygon, Point, MultiPoint
|
|
|
|
|
from shapely.ops import unary_union
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
|
|
|
|
from .density_field import evaluate_density, density_to_spacing
|
|
|
|
|
|
|
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
def _generate_hex_grid(bbox, base_spacing):
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
Generate a regular hexagonal-packed point grid.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
This creates the classic isogrid vertex layout: rows of points
|
|
|
|
|
offset by half-spacing, producing equilateral triangles when
|
|
|
|
|
Delaunay triangulated.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
Returns ndarray of shape (N, 2).
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
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 _adaptive_hex_grid(geometry, params):
|
|
|
|
|
"""
|
|
|
|
|
Generate density-adaptive hex grid.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
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.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
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
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
d_keep = params['d_keep']
|
2026-02-16 20:58:05 +00:00
|
|
|
keepout_polys = []
|
|
|
|
|
for hole in geometry.get('holes', []):
|
2026-02-16 01:11:53 +00:00
|
|
|
diameter = float(hole.get('diameter', 10.0) or 10.0)
|
|
|
|
|
if hole.get('is_circular', False) and 'center' in hole:
|
2026-02-16 20:58:05 +00:00
|
|
|
cx, cy = hole['center']
|
2026-02-16 01:11:53 +00:00
|
|
|
hole_radius = diameter / 2.0
|
2026-02-16 20:58:05 +00:00
|
|
|
boss_radius = hole_radius + d_keep * hole_radius
|
|
|
|
|
keepout = Point(cx, cy).buffer(boss_radius, resolution=16)
|
|
|
|
|
keepout_polys.append(keepout)
|
|
|
|
|
|
|
|
|
|
keepout_union = unary_union(keepout_polys) if keepout_polys else Polygon()
|
|
|
|
|
|
|
|
|
|
# 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
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
return all_pts, valid_region, keepout_union
|
2026-02-16 01:11:53 +00:00
|
|
|
|
|
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
def _add_boundary_vertices(points, geometry, params, keepout_union):
|
|
|
|
|
"""
|
|
|
|
|
Add vertices along the outer boundary and hole keepout boundaries.
|
|
|
|
|
|
|
|
|
|
This ensures triangles conform to boundaries rather than just being
|
|
|
|
|
clipped. Points are spaced at approximately s_min along boundaries.
|
2026-02-17 13:41:24 +00:00
|
|
|
|
|
|
|
|
KEY: Enforce explicit vertices at every corner of the inset boundary.
|
|
|
|
|
This guarantees no triangle can cross a corner — the Delaunay triangulation
|
|
|
|
|
is forced to use these corner points as vertices.
|
2026-02-16 20:58:05 +00:00
|
|
|
"""
|
|
|
|
|
s_min = params['s_min']
|
|
|
|
|
w_frame = params.get('w_frame', 8.0)
|
|
|
|
|
|
|
|
|
|
new_pts = list(points)
|
|
|
|
|
|
|
|
|
|
plate_poly = Polygon(geometry['outer_boundary'])
|
|
|
|
|
inner_frame = plate_poly.buffer(-w_frame)
|
2026-02-17 13:41:24 +00:00
|
|
|
if not inner_frame.is_empty:
|
|
|
|
|
# Handle MultiPolygon from buffer on complex shapes
|
|
|
|
|
if inner_frame.geom_type == 'MultiPolygon':
|
|
|
|
|
inner_polys = list(inner_frame.geoms)
|
|
|
|
|
else:
|
|
|
|
|
inner_polys = [inner_frame]
|
|
|
|
|
|
|
|
|
|
for inner_poly in inner_polys:
|
|
|
|
|
ring = inner_poly.exterior
|
|
|
|
|
|
|
|
|
|
# 1) ENFORCE corner vertices: add every vertex of the inset boundary
|
|
|
|
|
# These are the actual corner points — critical for preventing crossovers
|
|
|
|
|
coords = list(ring.coords)[:-1] # skip closing duplicate
|
|
|
|
|
for cx, cy in coords:
|
|
|
|
|
new_pts.append([cx, cy])
|
|
|
|
|
|
|
|
|
|
# 2) Add evenly spaced points along edges for density
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
# Also add inner ring vertices (for any holes in the inset boundary)
|
|
|
|
|
for interior in inner_poly.interiors:
|
|
|
|
|
for cx, cy in list(interior.coords)[:-1]:
|
|
|
|
|
new_pts.append([cx, cy])
|
2026-02-16 20:58:05 +00:00
|
|
|
|
|
|
|
|
# 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
|
2026-02-17 13:41:24 +00:00
|
|
|
# Enforce corner vertices on keepout boundaries too
|
|
|
|
|
for cx, cy in list(ring.coords)[:-1]:
|
|
|
|
|
new_pts.append([cx, cy])
|
2026-02-16 20:58:05 +00:00
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
return np.array(new_pts, dtype=np.float64)
|
2026-02-16 01:11:53 +00:00
|
|
|
|
|
|
|
|
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
def generate_triangulation(geometry, params, max_refinement_passes=3):
|
|
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
Generate isogrid triangulation: hex-packed grid + Delaunay + clip.
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
Returns dict with 'vertices' (ndarray Nx2) and 'triangles' (ndarray Mx3).
|
feat: add adaptive isogrid tool — project foundations
- Python Brain: density field, constrained Delaunay triangulation,
pocket profiles, profile assembly, validation modules
- NX Hands: skeleton scripts for geometry extraction, AFEM setup,
per-iteration solve (require NX environment to develop)
- Atomizer integration: 15-param space definition, objective function
- Technical spec, README, sample test geometry, requirements.txt
- Architecture: Python Brain + NX Hands + Atomizer Manager
2026-02-16 00:01:35 +00:00
|
|
|
"""
|
2026-02-16 20:58:05 +00:00
|
|
|
# Generate adaptive point grid
|
|
|
|
|
grid_pts, valid_region, keepout_union = _adaptive_hex_grid(geometry, params)
|
|
|
|
|
|
|
|
|
|
if len(grid_pts) < 3:
|
|
|
|
|
return {'vertices': grid_pts, 'triangles': np.empty((0, 3), dtype=int)}
|
|
|
|
|
|
|
|
|
|
# Add boundary-conforming vertices
|
|
|
|
|
all_pts = _add_boundary_vertices(grid_pts, geometry, params, keepout_union)
|
|
|
|
|
|
|
|
|
|
# Deduplicate close points
|
|
|
|
|
all_pts = np.unique(np.round(all_pts, 4), axis=0)
|
|
|
|
|
|
|
|
|
|
if len(all_pts) < 3:
|
|
|
|
|
return {'vertices': all_pts, 'triangles': np.empty((0, 3), dtype=int)}
|
|
|
|
|
|
|
|
|
|
# Delaunay triangulation of the point set
|
|
|
|
|
tri = Delaunay(all_pts)
|
|
|
|
|
triangles = tri.simplices
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-17 12:42:52 +00:00
|
|
|
# Keep if centroid is inside plate frame and outside keepouts,
|
|
|
|
|
# AND all 3 vertices are inside the plate boundary (no crossovers)
|
|
|
|
|
if not inner_plate.contains(centroid):
|
|
|
|
|
continue
|
|
|
|
|
if keepout_union.contains(centroid):
|
|
|
|
|
continue
|
|
|
|
|
all_inside = True
|
|
|
|
|
for vi in t:
|
|
|
|
|
if not plate_poly.contains(Point(all_pts[vi])):
|
|
|
|
|
# Allow small tolerance (vertex on boundary is OK)
|
|
|
|
|
if not plate_poly.buffer(0.5).contains(Point(all_pts[vi])):
|
|
|
|
|
all_inside = False
|
|
|
|
|
break
|
|
|
|
|
if all_inside:
|
2026-02-16 20:58:05 +00:00
|
|
|
keep.append(i)
|
|
|
|
|
|
|
|
|
|
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}
|