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
|
2026-02-17 16:24:27 +00:00
|
|
|
from shapely.geometry import Polygon, Point, LinearRing
|
|
|
|
|
from shapely.geometry.base import BaseGeometry
|
2026-02-16 20:58:05 +00:00
|
|
|
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
|
|
|
|
2026-02-17 14:05:28 +00:00
|
|
|
from src.shared.arc_utils import inset_arc, typed_segments_to_polyline, typed_segments_to_sparse
|
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-17 16:24:27 +00:00
|
|
|
def _geometry_to_list(geom: BaseGeometry) -> list[BaseGeometry]:
|
|
|
|
|
if geom.is_empty:
|
|
|
|
|
return []
|
|
|
|
|
return [geom] if geom.geom_type == 'Polygon' else list(getattr(geom, 'geoms', []))
|
2026-02-17 14:37:13 +00:00
|
|
|
|
|
|
|
|
|
2026-02-17 16:24:27 +00:00
|
|
|
def _add_boundary_layer_seed_points(points, geometry, params, inner_plate, keepout_union):
|
|
|
|
|
"""Add a row of points just inside the inset boundary to align boundary triangles."""
|
|
|
|
|
offset = float(params.get('boundary_layer_offset', 0.0) or 0.0)
|
|
|
|
|
if offset <= 0.0:
|
|
|
|
|
offset = max(params.get('s_min', 10.0) * 0.6, 0.5)
|
|
|
|
|
|
|
|
|
|
layer_geom = inner_plate.buffer(-offset)
|
|
|
|
|
if layer_geom.is_empty:
|
|
|
|
|
return points
|
|
|
|
|
|
2026-02-17 14:37:13 +00:00
|
|
|
boundary_pts = []
|
2026-02-17 16:24:27 +00:00
|
|
|
for poly in _geometry_to_list(layer_geom):
|
|
|
|
|
ring = poly.exterior
|
|
|
|
|
length = float(ring.length)
|
|
|
|
|
if length <= 1e-9:
|
2026-02-17 14:37:13 +00:00
|
|
|
continue
|
2026-02-17 16:24:27 +00:00
|
|
|
n_pts = max(int(np.ceil(length / max(params.get('s_min', 10.0), 1e-3))), 8)
|
|
|
|
|
for i in range(n_pts):
|
|
|
|
|
frac = i / n_pts
|
|
|
|
|
p = ring.interpolate(frac, normalized=True)
|
|
|
|
|
if not inner_plate.buffer(1e-6).covers(p):
|
2026-02-17 14:37:13 +00:00
|
|
|
continue
|
2026-02-17 16:24:27 +00:00
|
|
|
if not keepout_union.is_empty and keepout_union.buffer(1e-6).covers(p):
|
2026-02-17 14:37:13 +00:00
|
|
|
continue
|
2026-02-17 16:24:27 +00:00
|
|
|
boundary_pts.append([p.x, p.y])
|
2026-02-17 14:37:13 +00:00
|
|
|
|
|
|
|
|
if boundary_pts:
|
|
|
|
|
return np.vstack([points, np.asarray(boundary_pts, dtype=np.float64)])
|
|
|
|
|
return points
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _np(pt):
|
|
|
|
|
return np.asarray([float(pt[0]), float(pt[1])], dtype=float)
|
|
|
|
|
|
|
|
|
|
|
2026-02-17 16:24:27 +00:00
|
|
|
def _hole_keepout_seed_points(geometry, params):
|
|
|
|
|
"""Return sparse keepout seed points: 3 for circular holes, coarse ring for others."""
|
|
|
|
|
d_keep = float(params['d_keep'])
|
|
|
|
|
pts = []
|
|
|
|
|
for hole in geometry.get('holes', []):
|
|
|
|
|
if hole.get('is_circular', False) and 'center' in hole:
|
|
|
|
|
cx, cy = hole['center']
|
|
|
|
|
diameter = float(hole.get('diameter', 10.0) or 10.0)
|
|
|
|
|
hole_radius = diameter / 2.0
|
|
|
|
|
keepout_radius = hole_radius * (1.0 + d_keep)
|
|
|
|
|
for k in range(3):
|
|
|
|
|
a = (2.0 * np.pi * k) / 3.0
|
|
|
|
|
pts.append([cx + keepout_radius * np.cos(a), cy + keepout_radius * np.sin(a)])
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
hb = np.asarray(hole.get('boundary', []), dtype=float)
|
|
|
|
|
if len(hb) < 3:
|
|
|
|
|
continue
|
|
|
|
|
ring = LinearRing(hb)
|
|
|
|
|
keepout = Polygon(ring).buffer(max(d_keep, 0.0))
|
|
|
|
|
if keepout.is_empty:
|
|
|
|
|
continue
|
|
|
|
|
for geom in _geometry_to_list(keepout):
|
|
|
|
|
ext = geom.exterior
|
|
|
|
|
n = max(int(np.ceil(ext.length / max(params.get('s_min', 10.0), 1e-3))), 6)
|
|
|
|
|
for i in range(n):
|
|
|
|
|
p = ext.interpolate(i / n, normalized=True)
|
|
|
|
|
pts.append([p.x, p.y])
|
|
|
|
|
|
|
|
|
|
if not pts:
|
|
|
|
|
return np.empty((0, 2), dtype=np.float64)
|
|
|
|
|
return np.asarray(pts, dtype=np.float64)
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-02-17 14:05:28 +00:00
|
|
|
"""Add inset boundary vertices (arc-aware) and keepout boundary points."""
|
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'])
|
2026-02-17 13:41:24 +00:00
|
|
|
|
2026-02-17 14:05:28 +00:00
|
|
|
typed_segments = geometry.get('outer_boundary_typed')
|
|
|
|
|
if typed_segments:
|
|
|
|
|
ring = LinearRing(geometry['outer_boundary'])
|
|
|
|
|
is_ccw = bool(ring.is_ccw)
|
|
|
|
|
inset_segments = []
|
|
|
|
|
|
|
|
|
|
for seg in typed_segments:
|
|
|
|
|
stype = seg.get('type', 'line')
|
|
|
|
|
if stype == 'arc':
|
|
|
|
|
center_inside = plate_poly.contains(Point(seg['center']))
|
|
|
|
|
inset_segments.append(inset_arc({**seg, 'center_inside': center_inside}, w_frame))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
x1, y1 = seg['start']
|
|
|
|
|
x2, y2 = seg['end']
|
|
|
|
|
dx, dy = (x2 - x1), (y2 - y1)
|
|
|
|
|
ln = np.hypot(dx, dy)
|
|
|
|
|
if ln < 1e-12:
|
|
|
|
|
continue
|
|
|
|
|
nx_l, ny_l = (-dy / ln), (dx / ln)
|
|
|
|
|
nx, ny = (nx_l, ny_l) if is_ccw else (-nx_l, -ny_l)
|
|
|
|
|
inset_segments.append({
|
|
|
|
|
'type': 'line',
|
|
|
|
|
'start': [x1 + w_frame * nx, y1 + w_frame * ny],
|
|
|
|
|
'end': [x2 + w_frame * nx, y2 + w_frame * ny],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Sparse points forced into Delaunay (3/arc, 2/line)
|
|
|
|
|
sparse_pts = typed_segments_to_sparse(inset_segments)
|
|
|
|
|
new_pts.extend(sparse_pts)
|
|
|
|
|
|
|
|
|
|
# Dense inset polygon for containment checks
|
|
|
|
|
dense = typed_segments_to_polyline(inset_segments, arc_pts=16)
|
|
|
|
|
if len(dense) >= 3:
|
|
|
|
|
inner_plate = Polygon(dense)
|
|
|
|
|
if not inner_plate.is_valid:
|
|
|
|
|
inner_plate = inner_plate.buffer(0)
|
|
|
|
|
if inner_plate.is_empty:
|
|
|
|
|
inner_plate = plate_poly.buffer(-w_frame)
|
|
|
|
|
else:
|
|
|
|
|
inner_plate = plate_poly.buffer(-w_frame)
|
|
|
|
|
|
|
|
|
|
# Even spacing along inset boundary (as before)
|
|
|
|
|
if not inner_plate.is_empty and inner_plate.is_valid:
|
2026-02-17 16:24:27 +00:00
|
|
|
for inner_poly in _geometry_to_list(inner_plate):
|
2026-02-17 14:05:28 +00:00
|
|
|
ext = inner_poly.exterior
|
|
|
|
|
length = ext.length
|
|
|
|
|
n_pts = max(int(length / s_min), 4)
|
|
|
|
|
for i in range(n_pts):
|
|
|
|
|
frac = i / n_pts
|
|
|
|
|
pt = ext.interpolate(frac, normalized=True)
|
|
|
|
|
new_pts.append([pt.x, pt.y])
|
|
|
|
|
else:
|
2026-02-17 16:24:27 +00:00
|
|
|
# v1 geometry fallback: inset from polygon and force all inset ring vertices.
|
2026-02-17 14:05:28 +00:00
|
|
|
inner_plate = plate_poly.buffer(-w_frame)
|
|
|
|
|
if not inner_plate.is_empty:
|
2026-02-17 16:24:27 +00:00
|
|
|
for inner_poly in _geometry_to_list(inner_plate):
|
2026-02-17 14:05:28 +00:00
|
|
|
ext = inner_poly.exterior
|
2026-02-17 16:24:27 +00:00
|
|
|
# Always keep true inset corners/vertices from the offset polygon.
|
2026-02-17 14:05:28 +00:00
|
|
|
coords = list(ext.coords)[:-1]
|
|
|
|
|
for cx, cy in coords:
|
2026-02-17 13:41:24 +00:00
|
|
|
new_pts.append([cx, cy])
|
2026-02-17 16:24:27 +00:00
|
|
|
# Plus evenly spaced boundary points for perimeter alignment.
|
2026-02-17 14:05:28 +00:00
|
|
|
length = ext.length
|
|
|
|
|
n_pts = max(int(length / s_min), 4)
|
|
|
|
|
for i in range(n_pts):
|
|
|
|
|
frac = i / n_pts
|
|
|
|
|
pt = ext.interpolate(frac, normalized=True)
|
|
|
|
|
new_pts.append([pt.x, pt.y])
|
2026-02-16 20:58:05 +00:00
|
|
|
|
2026-02-17 16:24:27 +00:00
|
|
|
# Add sparse points for hole keepouts (3 points per circular keepout).
|
|
|
|
|
keepout_seeds = _hole_keepout_seed_points(geometry, params)
|
|
|
|
|
if len(keepout_seeds) > 0:
|
|
|
|
|
new_pts.extend(keepout_seeds.tolist())
|
2026-02-16 20:58:05 +00:00
|
|
|
|
2026-02-17 14:05:28 +00:00
|
|
|
if inner_plate.is_empty or not inner_plate.is_valid:
|
|
|
|
|
inner_plate = plate_poly
|
|
|
|
|
|
|
|
|
|
return np.array(new_pts, dtype=np.float64), inner_plate
|
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)}
|
|
|
|
|
|
2026-02-17 14:05:28 +00:00
|
|
|
# Add boundary-conforming vertices and get inset plate polygon for clipping
|
|
|
|
|
all_pts, inner_plate = _add_boundary_vertices(grid_pts, geometry, params, keepout_union)
|
2026-02-16 20:58:05 +00:00
|
|
|
|
2026-02-17 14:37:13 +00:00
|
|
|
# Add structured boundary-layer seed row along straight edges
|
|
|
|
|
plate_poly = Polygon(geometry['outer_boundary'])
|
2026-02-17 16:24:27 +00:00
|
|
|
all_pts = _add_boundary_layer_seed_points(all_pts, geometry, params, inner_plate, keepout_union)
|
2026-02-17 14:37:13 +00:00
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
# 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'])
|
|
|
|
|
if inner_plate.is_empty:
|
|
|
|
|
inner_plate = plate_poly
|
|
|
|
|
|
2026-02-17 16:24:27 +00:00
|
|
|
inner_valid_region = inner_plate
|
|
|
|
|
if not keepout_union.is_empty:
|
|
|
|
|
inner_valid_region = inner_plate.difference(keepout_union)
|
|
|
|
|
|
2026-02-16 20:58:05 +00:00
|
|
|
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
|
2026-02-17 16:24:27 +00:00
|
|
|
if not all_inside:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
tri_poly = Polygon(all_pts[t])
|
|
|
|
|
if tri_poly.is_empty or not tri_poly.is_valid:
|
|
|
|
|
continue
|
|
|
|
|
if not inner_valid_region.buffer(1e-6).covers(tri_poly):
|
|
|
|
|
continue
|
|
|
|
|
keep.append(i)
|
2026-02-16 20:58:05 +00:00
|
|
|
|
|
|
|
|
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}
|