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
This commit is contained in:
75
tools/adaptive-isogrid/README.md
Normal file
75
tools/adaptive-isogrid/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Adaptive Isogrid — Plate Lightweighting Tool
|
||||
|
||||
**Status:** Foundation / Pre-Implementation
|
||||
**Architecture:** Python Brain + NX Hands + Atomizer Manager
|
||||
|
||||
## What It Does
|
||||
|
||||
Takes a plate with holes → generates an optimally lightweighted isogrid pattern → produces manufacturing-ready geometry. Isogrid density varies across the plate based on hole importance, edge proximity, and optimization-driven meta-parameters.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Component | Role | Runtime |
|
||||
|-----------|------|---------|
|
||||
| **Python Brain** | Density field → Constrained Delaunay → rib profile | ~1-3 sec |
|
||||
| **NX Hands** | Import profile → mesh → AFEM merge → Nastran solve → extract results | ~60-90 sec |
|
||||
| **Atomizer Manager** | Optuna TPE sampling → objective evaluation → convergence | 500-2000 trials |
|
||||
|
||||
### Key Insight: Assembly FEM with Superposed Models
|
||||
|
||||
- **Model A** (permanent): Spider elements at holes + edge BC nodes. All loads/BCs applied here.
|
||||
- **Model B** (variable): 2D shell mesh of ribbed plate. Rebuilt each iteration.
|
||||
- **Node merge** at fixed interface locations connects them reliably every time.
|
||||
|
||||
Loads and BCs never need re-association. Only the rib pattern changes.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
adaptive-isogrid/
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
├── docs/
|
||||
│ └── technical-spec.md # Full architecture spec
|
||||
├── src/
|
||||
│ ├── brain/ # Python geometry generator
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── density_field.py # η(x) evaluation
|
||||
│ │ ├── triangulation.py # Constrained Delaunay + refinement
|
||||
│ │ ├── pocket_profiles.py # Pocket inset + filleting
|
||||
│ │ ├── profile_assembly.py # Final plate - pockets - holes
|
||||
│ │ └── validation.py # Manufacturing constraint checks
|
||||
│ ├── nx/ # NXOpen journal scripts
|
||||
│ │ ├── extract_geometry.py # One-time: face → geometry.json
|
||||
│ │ ├── build_interface_model.py # One-time: Model A + spiders
|
||||
│ │ └── iteration_solve.py # Per-trial: rebuild Model B + solve
|
||||
│ └── atomizer_study.py # Atomizer/Optuna integration
|
||||
└── tests/
|
||||
└── test_geometries/ # Sample geometry.json files
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
1. **Python Brain standalone** (1-2 weeks) — geometry generator with matplotlib viz
|
||||
2. **NX extraction + AFEM setup** (1-2 weeks) — one-time project setup scripts
|
||||
3. **NX iteration script** (1-2 weeks) — per-trial mesh/solve/extract loop
|
||||
4. **Atomizer integration** (1 week) — wire objective function + study management
|
||||
5. **Validation + first real project** (1-2 weeks) — production run on client plate
|
||||
|
||||
## Quick Start (Phase 1)
|
||||
|
||||
```bash
|
||||
cd tools/adaptive-isogrid
|
||||
pip install -r requirements.txt
|
||||
python -m src.brain --geometry tests/test_geometries/sample_bracket.json --params default
|
||||
```
|
||||
|
||||
## Parameter Space
|
||||
|
||||
15 continuous parameters optimized by Atomizer (Optuna TPE):
|
||||
- Density field: η₀, α, R₀, κ, p, β, R_edge
|
||||
- Spacing: s_min, s_max
|
||||
- Rib thickness: t_min, t₀, γ
|
||||
- Manufacturing: w_frame, r_f, d_keep
|
||||
|
||||
See `docs/technical-spec.md` for full formulation.
|
||||
1128
tools/adaptive-isogrid/docs/technical-spec.md
Normal file
1128
tools/adaptive-isogrid/docs/technical-spec.md
Normal file
File diff suppressed because it is too large
Load Diff
5
tools/adaptive-isogrid/requirements.txt
Normal file
5
tools/adaptive-isogrid/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
numpy>=1.24
|
||||
scipy>=1.10
|
||||
shapely>=2.0
|
||||
triangle>=20230923
|
||||
matplotlib>=3.7
|
||||
0
tools/adaptive-isogrid/src/__init__.py
Normal file
0
tools/adaptive-isogrid/src/__init__.py
Normal file
48
tools/adaptive-isogrid/src/atomizer_study.py
Normal file
48
tools/adaptive-isogrid/src/atomizer_study.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Atomizer/Optuna integration for adaptive isogrid optimization.
|
||||
|
||||
Wires: parameter sampling → Python Brain → NX Hands → result extraction.
|
||||
"""
|
||||
|
||||
# Parameter space definition
|
||||
PARAM_SPACE = {
|
||||
# Density field
|
||||
'eta_0': {'type': 'float', 'low': 0.0, 'high': 0.4, 'desc': 'Baseline density offset'},
|
||||
'alpha': {'type': 'float', 'low': 0.3, 'high': 2.0, 'desc': 'Hole influence scale'},
|
||||
'R_0': {'type': 'float', 'low': 10.0, 'high': 100.0, 'desc': 'Base influence radius (mm)'},
|
||||
'kappa': {'type': 'float', 'low': 0.0, 'high': 3.0, 'desc': 'Weight-to-radius coupling'},
|
||||
'p': {'type': 'float', 'low': 1.0, 'high': 4.0, 'desc': 'Decay exponent'},
|
||||
'beta': {'type': 'float', 'low': 0.0, 'high': 1.0, 'desc': 'Edge influence scale'},
|
||||
'R_edge': {'type': 'float', 'low': 5.0, 'high': 40.0, 'desc': 'Edge influence radius (mm)'},
|
||||
# Spacing
|
||||
's_min': {'type': 'float', 'low': 8.0, 'high': 20.0, 'desc': 'Min cell size (mm)'},
|
||||
's_max': {'type': 'float', 'low': 25.0, 'high': 60.0, 'desc': 'Max cell size (mm)'},
|
||||
# Rib thickness
|
||||
't_min': {'type': 'float', 'low': 2.0, 'high': 4.0, 'desc': 'Min rib thickness (mm)'},
|
||||
't_0': {'type': 'float', 'low': 2.0, 'high': 6.0, 'desc': 'Nominal rib thickness (mm)'},
|
||||
'gamma': {'type': 'float', 'low': 0.0, 'high': 3.0, 'desc': 'Density-thickness coupling'},
|
||||
# Manufacturing
|
||||
'w_frame': {'type': 'float', 'low': 3.0, 'high': 20.0, 'desc': 'Perimeter frame width (mm)'},
|
||||
'r_f': {'type': 'float', 'low': 0.5, 'high': 3.0, 'desc': 'Pocket fillet radius (mm)'},
|
||||
'd_keep': {'type': 'float', 'low': 1.0, 'high': 3.0, 'desc': 'Hole keepout multiplier (× diameter)'},
|
||||
}
|
||||
|
||||
# Default parameters for standalone brain testing
|
||||
DEFAULT_PARAMS = {
|
||||
'eta_0': 0.1,
|
||||
'alpha': 1.0,
|
||||
'R_0': 30.0,
|
||||
'kappa': 1.0,
|
||||
'p': 2.0,
|
||||
'beta': 0.3,
|
||||
'R_edge': 15.0,
|
||||
's_min': 12.0,
|
||||
's_max': 35.0,
|
||||
't_min': 2.5,
|
||||
't_0': 3.0,
|
||||
'gamma': 1.0,
|
||||
'w_frame': 8.0,
|
||||
'r_f': 1.5,
|
||||
'd_keep': 1.5,
|
||||
'min_pocket_radius': 1.5,
|
||||
}
|
||||
8
tools/adaptive-isogrid/src/brain/__init__.py
Normal file
8
tools/adaptive-isogrid/src/brain/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Adaptive Isogrid — Python Brain
|
||||
|
||||
Density field evaluation → Constrained Delaunay triangulation →
|
||||
Rib thickness computation → Pocket profile generation → Validation.
|
||||
|
||||
Takes geometry.json + parameters → outputs rib_profile.json
|
||||
"""
|
||||
146
tools/adaptive-isogrid/src/brain/density_field.py
Normal file
146
tools/adaptive-isogrid/src/brain/density_field.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Density field η(x) — maps every point on the plate to [0, 1].
|
||||
|
||||
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
|
||||
|
||||
Where:
|
||||
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x)/Rᵢ)^p) — hole influence
|
||||
E(x) = exp(-(d_edge(x)/R_edge)^p_edge) — edge reinforcement
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import Polygon, Point, LinearRing
|
||||
|
||||
|
||||
def compute_hole_influence(x, y, holes, params):
|
||||
"""
|
||||
Compute hole influence term I(x) at point (x, y).
|
||||
|
||||
I(x) = Σᵢ wᵢ · exp(-(dᵢ(x) / Rᵢ)^p)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x, y : float
|
||||
Query point coordinates.
|
||||
holes : list[dict]
|
||||
Hole definitions from geometry.json.
|
||||
params : dict
|
||||
Must contain: R_0, kappa, p
|
||||
"""
|
||||
R_0 = params['R_0']
|
||||
kappa = params['kappa']
|
||||
p = params['p']
|
||||
|
||||
influence = 0.0
|
||||
for hole in holes:
|
||||
w = hole['weight']
|
||||
if w <= 0:
|
||||
continue
|
||||
|
||||
# Distance to hole
|
||||
if hole.get('is_circular', True):
|
||||
cx, cy = hole['center']
|
||||
d = np.sqrt((x - cx)**2 + (y - cy)**2)
|
||||
else:
|
||||
# Non-circular: distance to boundary polygon
|
||||
ring = LinearRing(hole['boundary'])
|
||||
d = Point(x, y).distance(ring)
|
||||
|
||||
# Influence radius scales with hole weight
|
||||
R_i = R_0 * (1.0 + kappa * w)
|
||||
|
||||
# Exponential decay
|
||||
influence += w * np.exp(-(d / R_i)**p)
|
||||
|
||||
return influence
|
||||
|
||||
|
||||
def compute_edge_influence(x, y, outer_boundary, params):
|
||||
"""
|
||||
Compute edge reinforcement term E(x) at point (x, y).
|
||||
|
||||
E(x) = exp(-(d_edge(x) / R_edge)^p)
|
||||
"""
|
||||
R_edge = params['R_edge']
|
||||
p = params['p'] # reuse same decay exponent (could be separate in v2)
|
||||
|
||||
ring = LinearRing(outer_boundary)
|
||||
d_edge = Point(x, y).distance(ring)
|
||||
|
||||
return np.exp(-(d_edge / R_edge)**p)
|
||||
|
||||
|
||||
def evaluate_density(x, y, geometry, params):
|
||||
"""
|
||||
Evaluate the combined density field η(x, y).
|
||||
|
||||
η(x) = clamp(0, 1, η₀ + α·I(x) + β·E(x))
|
||||
|
||||
Returns
|
||||
-------
|
||||
float : density value in [0, 1]
|
||||
"""
|
||||
eta_0 = params['eta_0']
|
||||
alpha = params['alpha']
|
||||
beta = params['beta']
|
||||
|
||||
I = compute_hole_influence(x, y, geometry['holes'], params)
|
||||
E = compute_edge_influence(x, y, geometry['outer_boundary'], params)
|
||||
|
||||
eta = eta_0 + alpha * I + beta * E
|
||||
return np.clip(eta, 0.0, 1.0)
|
||||
|
||||
|
||||
def evaluate_density_grid(geometry, params, resolution=2.0):
|
||||
"""
|
||||
Evaluate density field on a regular grid covering the plate.
|
||||
Useful for visualization (heatmap).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
geometry : dict
|
||||
Plate geometry with outer_boundary and holes.
|
||||
params : dict
|
||||
Density field parameters.
|
||||
resolution : float
|
||||
Grid spacing in mm.
|
||||
|
||||
Returns
|
||||
-------
|
||||
X, Y, eta : ndarray
|
||||
Meshgrid coordinates and density values.
|
||||
"""
|
||||
boundary = np.array(geometry['outer_boundary'])
|
||||
x_min, y_min = boundary.min(axis=0)
|
||||
x_max, y_max = boundary.max(axis=0)
|
||||
|
||||
xs = np.arange(x_min, x_max + resolution, resolution)
|
||||
ys = np.arange(y_min, y_max + resolution, resolution)
|
||||
X, Y = np.meshgrid(xs, ys)
|
||||
|
||||
plate_poly = Polygon(geometry['outer_boundary'])
|
||||
eta = np.full_like(X, np.nan)
|
||||
|
||||
for i in range(X.shape[0]):
|
||||
for j in range(X.shape[1]):
|
||||
pt = Point(X[i, j], Y[i, j])
|
||||
if plate_poly.contains(pt):
|
||||
eta[i, j] = evaluate_density(X[i, j], Y[i, j], geometry, params)
|
||||
|
||||
return X, Y, eta
|
||||
|
||||
|
||||
def density_to_spacing(eta, params):
|
||||
"""Convert density η to local target triangle edge length s(x)."""
|
||||
s_min = params['s_min']
|
||||
s_max = params['s_max']
|
||||
return s_max - (s_max - s_min) * eta
|
||||
|
||||
|
||||
def density_to_rib_thickness(eta, params):
|
||||
"""Convert density η to local rib thickness t(x)."""
|
||||
t_min = params['t_min']
|
||||
t_0 = params['t_0']
|
||||
gamma = params['gamma']
|
||||
t_max = t_0 * (1.0 + gamma) # max possible thickness
|
||||
return np.clip(t_0 * (1.0 + gamma * eta), t_min, t_max)
|
||||
176
tools/adaptive-isogrid/src/brain/pocket_profiles.py
Normal file
176
tools/adaptive-isogrid/src/brain/pocket_profiles.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Pocket profile generation — convert triangulation into manufacturable pocket cutouts.
|
||||
|
||||
Each triangle → inset by half-rib-thickness per edge → fillet corners → pocket polygon.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from .density_field import evaluate_density, density_to_rib_thickness
|
||||
|
||||
|
||||
def get_unique_edges(triangles):
|
||||
"""Extract unique edges from triangle connectivity."""
|
||||
edges = set()
|
||||
for tri in triangles:
|
||||
for i in range(3):
|
||||
edge = tuple(sorted([tri[i], tri[(i + 1) % 3]]))
|
||||
edges.add(edge)
|
||||
return edges
|
||||
|
||||
|
||||
def get_triangle_edge_thicknesses(tri_vertices, tri_edges_thickness):
|
||||
"""Get thickness for each edge of a triangle."""
|
||||
return [tri_edges_thickness.get(e, 2.0) for e in tri_vertices]
|
||||
|
||||
|
||||
def inset_triangle(p0, p1, p2, d0, d1, d2):
|
||||
"""
|
||||
Inset a triangle by distances d0, d1, d2 on each edge.
|
||||
|
||||
Edge 0: p0→p1, inset by d0
|
||||
Edge 1: p1→p2, inset by d1
|
||||
Edge 2: p2→p0, inset by d2
|
||||
|
||||
Returns inset triangle vertices or None if triangle collapses.
|
||||
"""
|
||||
def edge_normal_inward(a, b, opposite):
|
||||
"""Unit inward normal of edge a→b (toward opposite vertex)."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = np.sqrt(dx**2 + dy**2)
|
||||
if length < 1e-10:
|
||||
return np.array([0.0, 0.0])
|
||||
# Two possible normals
|
||||
n1 = np.array([-dy / length, dx / length])
|
||||
n2 = np.array([dy / length, -dx / length])
|
||||
# Pick the one pointing toward the opposite vertex
|
||||
mid = (np.array(a) + np.array(b)) / 2.0
|
||||
if np.dot(n1, np.array(opposite) - mid) > 0:
|
||||
return n1
|
||||
return n2
|
||||
|
||||
points = [np.array(p0), np.array(p1), np.array(p2)]
|
||||
offsets = [d0, d1, d2]
|
||||
|
||||
# Compute inset lines for each edge
|
||||
inset_lines = []
|
||||
edges = [(0, 1, 2), (1, 2, 0), (2, 0, 1)]
|
||||
for (i, j, k), d in zip(edges, offsets):
|
||||
n = edge_normal_inward(points[i], points[j], points[k])
|
||||
# Offset edge inward
|
||||
a_off = points[i] + n * d
|
||||
b_off = points[j] + n * d
|
||||
inset_lines.append((a_off, b_off))
|
||||
|
||||
# Intersect consecutive inset lines to get new vertices
|
||||
new_verts = []
|
||||
for idx in range(3):
|
||||
a1, b1 = inset_lines[idx]
|
||||
a2, b2 = inset_lines[(idx + 1) % 3]
|
||||
pt = line_intersection(a1, b1, a2, b2)
|
||||
if pt is None:
|
||||
return None
|
||||
new_verts.append(pt)
|
||||
|
||||
# Check if triangle is valid (positive area)
|
||||
area = triangle_area(new_verts[0], new_verts[1], new_verts[2])
|
||||
if area < 0.1: # mm² — too small
|
||||
return None
|
||||
|
||||
return new_verts
|
||||
|
||||
|
||||
def line_intersection(a1, b1, a2, b2):
|
||||
"""Find intersection of two lines (a1→b1) and (a2→b2)."""
|
||||
d1 = b1 - a1
|
||||
d2 = b2 - a2
|
||||
cross = d1[0] * d2[1] - d1[1] * d2[0]
|
||||
if abs(cross) < 1e-12:
|
||||
return None # parallel
|
||||
t = ((a2[0] - a1[0]) * d2[1] - (a2[1] - a1[1]) * d2[0]) / cross
|
||||
return a1 + t * d1
|
||||
|
||||
|
||||
def triangle_area(p0, p1, p2):
|
||||
"""Signed area of triangle."""
|
||||
return 0.5 * abs(
|
||||
(p1[0] - p0[0]) * (p2[1] - p0[1]) -
|
||||
(p2[0] - p0[0]) * (p1[1] - p0[1])
|
||||
)
|
||||
|
||||
|
||||
def generate_pockets(triangulation, geometry, params):
|
||||
"""
|
||||
Generate pocket profiles from triangulation.
|
||||
|
||||
Each triangle becomes a pocket (inset + filleted).
|
||||
Small triangles are left solid (no pocket).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict] : pocket definitions with vertices and metadata.
|
||||
"""
|
||||
vertices = triangulation['vertices']
|
||||
triangles = triangulation['triangles']
|
||||
r_f = params['r_f']
|
||||
min_pocket_radius = params.get('min_pocket_radius', 1.5)
|
||||
|
||||
# Precompute edge thicknesses
|
||||
edge_thickness = {}
|
||||
for tri in triangles:
|
||||
for i in range(3):
|
||||
edge = tuple(sorted([tri[i], tri[(i + 1) % 3]]))
|
||||
if edge not in edge_thickness:
|
||||
mid = (vertices[edge[0]] + vertices[edge[1]]) / 2.0
|
||||
eta = evaluate_density(mid[0], mid[1], geometry, params)
|
||||
edge_thickness[edge] = density_to_rib_thickness(eta, params)
|
||||
|
||||
pockets = []
|
||||
for tri_idx, tri in enumerate(triangles):
|
||||
p0 = vertices[tri[0]]
|
||||
p1 = vertices[tri[1]]
|
||||
p2 = vertices[tri[2]]
|
||||
|
||||
# Get edge thicknesses (half for inset)
|
||||
e01 = tuple(sorted([tri[0], tri[1]]))
|
||||
e12 = tuple(sorted([tri[1], tri[2]]))
|
||||
e20 = tuple(sorted([tri[2], tri[0]]))
|
||||
|
||||
d0 = edge_thickness[e01] / 2.0
|
||||
d1 = edge_thickness[e12] / 2.0
|
||||
d2 = edge_thickness[e20] / 2.0
|
||||
|
||||
inset = inset_triangle(p0, p1, p2, d0, d1, d2)
|
||||
if inset is None:
|
||||
continue
|
||||
|
||||
# Check minimum pocket size via inscribed circle approximation
|
||||
pocket_poly = Polygon(inset)
|
||||
if not pocket_poly.is_valid or pocket_poly.is_empty:
|
||||
continue
|
||||
|
||||
# Approximate inscribed radius
|
||||
inscribed_r = pocket_poly.area / (pocket_poly.length / 2.0)
|
||||
if inscribed_r < min_pocket_radius:
|
||||
continue
|
||||
|
||||
# Apply fillet (buffer negative then positive = round inward corners)
|
||||
fillet_amount = min(r_f, inscribed_r * 0.4) # don't over-fillet
|
||||
if fillet_amount > 0.1:
|
||||
filleted = pocket_poly.buffer(-fillet_amount).buffer(fillet_amount)
|
||||
else:
|
||||
filleted = pocket_poly
|
||||
|
||||
if filleted.is_empty or not filleted.is_valid:
|
||||
continue
|
||||
|
||||
coords = list(filleted.exterior.coords)
|
||||
pockets.append({
|
||||
'triangle_index': tri_idx,
|
||||
'vertices': coords,
|
||||
'area': filleted.area,
|
||||
})
|
||||
|
||||
return pockets
|
||||
100
tools/adaptive-isogrid/src/brain/profile_assembly.py
Normal file
100
tools/adaptive-isogrid/src/brain/profile_assembly.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Profile assembly — combine plate boundary, pockets, and holes
|
||||
into the final 2D ribbed plate profile for NX import.
|
||||
|
||||
Output: plate boundary - pockets - holes = ribbed plate (Shapely geometry)
|
||||
"""
|
||||
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
||||
|
||||
def assemble_profile(geometry, pockets, params):
|
||||
"""
|
||||
Create the final 2D ribbed plate profile.
|
||||
|
||||
Plate boundary - pockets - holes = ribbed plate
|
||||
|
||||
Parameters
|
||||
----------
|
||||
geometry : dict
|
||||
Plate geometry with outer_boundary and holes.
|
||||
pockets : list[dict]
|
||||
Pocket definitions from pocket_profiles.generate_pockets().
|
||||
params : dict
|
||||
Must contain w_frame (perimeter frame width).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Shapely Polygon/MultiPolygon : the ribbed plate profile.
|
||||
"""
|
||||
plate = Polygon(geometry['outer_boundary'])
|
||||
w_frame = params['w_frame']
|
||||
|
||||
# Inner boundary (frame inset)
|
||||
if w_frame > 0:
|
||||
inner_plate = plate.buffer(-w_frame)
|
||||
else:
|
||||
inner_plate = plate
|
||||
|
||||
if inner_plate.is_empty:
|
||||
# Frame too wide for plate — return solid plate minus holes
|
||||
ribbed = plate
|
||||
for hole in geometry['holes']:
|
||||
ribbed = ribbed.difference(Polygon(hole['boundary']))
|
||||
return ribbed
|
||||
|
||||
# Union all pocket polygons
|
||||
pocket_polys = []
|
||||
for p in pockets:
|
||||
poly = Polygon(p['vertices'])
|
||||
if poly.is_valid and not poly.is_empty:
|
||||
pocket_polys.append(poly)
|
||||
|
||||
if pocket_polys:
|
||||
all_pockets = unary_union(pocket_polys)
|
||||
# Clip pockets to inner plate (don't cut into frame)
|
||||
clipped_pockets = all_pockets.intersection(inner_plate)
|
||||
# Subtract pockets from plate
|
||||
ribbed = plate.difference(clipped_pockets)
|
||||
else:
|
||||
ribbed = plate
|
||||
|
||||
# Subtract original hole boundaries
|
||||
for hole in geometry['holes']:
|
||||
hole_poly = Polygon(hole['boundary'])
|
||||
ribbed = ribbed.difference(hole_poly)
|
||||
|
||||
return ribbed
|
||||
|
||||
|
||||
def profile_to_json(ribbed_plate, params):
|
||||
"""
|
||||
Convert Shapely ribbed plate geometry to JSON-serializable dict
|
||||
for NXOpen import.
|
||||
"""
|
||||
if ribbed_plate.is_empty:
|
||||
return {'valid': False, 'reason': 'empty_geometry'}
|
||||
|
||||
# Separate exterior and interiors
|
||||
if ribbed_plate.geom_type == 'MultiPolygon':
|
||||
# Take the largest polygon (should be the plate)
|
||||
largest = max(ribbed_plate.geoms, key=lambda g: g.area)
|
||||
else:
|
||||
largest = ribbed_plate
|
||||
|
||||
# Classify interiors as pockets or holes
|
||||
outer_coords = list(largest.exterior.coords)
|
||||
pocket_coords = []
|
||||
hole_coords = []
|
||||
|
||||
for interior in largest.interiors:
|
||||
coords = list(interior.coords)
|
||||
pocket_coords.append(coords)
|
||||
|
||||
return {
|
||||
'valid': True,
|
||||
'outer_boundary': outer_coords,
|
||||
'pockets': pocket_coords,
|
||||
'parameters_used': params,
|
||||
}
|
||||
160
tools/adaptive-isogrid/src/brain/triangulation.py
Normal file
160
tools/adaptive-isogrid/src/brain/triangulation.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Constrained Delaunay triangulation with density-adaptive refinement.
|
||||
|
||||
Uses Shewchuk's Triangle library to generate adaptive mesh that
|
||||
respects plate boundary, hole keepouts, and density-driven spacing.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import triangle as tr
|
||||
from shapely.geometry import Polygon, LinearRing
|
||||
|
||||
from .density_field import evaluate_density, density_to_spacing
|
||||
|
||||
|
||||
def offset_polygon(coords, distance, inward=True):
|
||||
"""
|
||||
Offset a polygon boundary by `distance`.
|
||||
inward=True shrinks, inward=False expands.
|
||||
|
||||
Uses Shapely buffer (negative = inward for exterior ring).
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def build_pslg(geometry, params):
|
||||
"""
|
||||
Build Planar Straight Line Graph for Triangle library.
|
||||
|
||||
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.
|
||||
"""
|
||||
d_keep = params['d_keep']
|
||||
|
||||
vertices = []
|
||||
segments = []
|
||||
hole_markers = []
|
||||
|
||||
# Outer boundary (no offset needed — frame is handled in profile assembly)
|
||||
outer = geometry['outer_boundary']
|
||||
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 keepout offset
|
||||
for hole in geometry['holes']:
|
||||
keepout_dist = d_keep * (hole.get('diameter', 10.0) or 10.0) / 2.0
|
||||
hole_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
|
||||
|
||||
v_start = len(vertices)
|
||||
vertices.extend(hole_boundary)
|
||||
n_h = len(hole_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 it empty
|
||||
hole_markers.append(hole['center'])
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 generate_triangulation(geometry, params, max_refinement_passes=3):
|
||||
"""
|
||||
Generate density-adaptive constrained Delaunay triangulation.
|
||||
|
||||
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'.
|
||||
"""
|
||||
pslg = build_pslg(geometry, params)
|
||||
|
||||
# Initial triangulation with global max area
|
||||
s_max = params['s_max']
|
||||
global_max_area = (np.sqrt(3) / 4.0) * s_max**2
|
||||
|
||||
# Triangle options: p=PSLG, q30=min angle 30°, a=area constraint, D=conforming Delaunay
|
||||
result = tr.triangulate(pslg, f'pq30Da{global_max_area:.1f}')
|
||||
|
||||
# Iterative refinement based on density field
|
||||
for iteration in range(max_refinement_passes):
|
||||
verts = result['vertices']
|
||||
tris = result['triangles']
|
||||
|
||||
areas = compute_triangle_areas(verts, tris)
|
||||
centroids = compute_centroids(verts, tris)
|
||||
|
||||
# Compute target area for each triangle based on density at centroid
|
||||
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
|
||||
])
|
||||
|
||||
# Check if all triangles satisfy constraints (20% tolerance)
|
||||
if np.all(areas <= target_areas * 1.2):
|
||||
break
|
||||
|
||||
# Set per-triangle max area and refine
|
||||
result['triangle_max_area'] = target_areas
|
||||
result = tr.triangulate(result, 'rpq30D')
|
||||
|
||||
return result
|
||||
106
tools/adaptive-isogrid/src/brain/validation.py
Normal file
106
tools/adaptive-isogrid/src/brain/validation.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Manufacturing constraint validation for ribbed plate profiles.
|
||||
|
||||
Checks run after profile assembly to catch issues before NX import.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.validation import make_valid
|
||||
|
||||
|
||||
def check_minimum_web(ribbed_plate, t_min):
|
||||
"""
|
||||
Check that no rib section is thinner than t_min.
|
||||
|
||||
Uses negative buffer: if buffering inward by t_min/2 leaves
|
||||
the geometry connected, minimum web width is satisfied.
|
||||
|
||||
Returns True if minimum web width is OK.
|
||||
"""
|
||||
eroded = ribbed_plate.buffer(-t_min / 2.0)
|
||||
if eroded.is_empty:
|
||||
return False
|
||||
# Check connectivity — should still be one piece
|
||||
if eroded.geom_type == 'MultiPolygon':
|
||||
# Some thin sections broke off — might be OK if they're tiny
|
||||
main_area = max(g.area for g in eroded.geoms)
|
||||
total_area = sum(g.area for g in eroded.geoms)
|
||||
# If >95% of area is in one piece, it's fine
|
||||
return main_area / total_area > 0.95
|
||||
return True
|
||||
|
||||
|
||||
def check_no_islands(ribbed_plate):
|
||||
"""
|
||||
Check that the ribbed plate has no floating islands
|
||||
(disconnected solid regions).
|
||||
|
||||
Returns True if no islands found.
|
||||
"""
|
||||
if ribbed_plate.geom_type == 'Polygon':
|
||||
return True
|
||||
elif ribbed_plate.geom_type == 'MultiPolygon':
|
||||
# Multiple polygons = islands. Only OK if one is dominant.
|
||||
areas = [g.area for g in ribbed_plate.geoms]
|
||||
return max(areas) / sum(areas) > 0.99
|
||||
return False
|
||||
|
||||
|
||||
def estimate_mass(ribbed_plate, params, density_kg_m3=2700.0):
|
||||
"""
|
||||
Estimate mass of the ribbed plate.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ribbed_plate : Shapely geometry
|
||||
The ribbed plate profile.
|
||||
params : dict
|
||||
Must contain thickness info (from geometry).
|
||||
density_kg_m3 : float
|
||||
Material density (default: aluminum 6061).
|
||||
|
||||
Returns
|
||||
-------
|
||||
float : estimated mass in grams.
|
||||
"""
|
||||
area_mm2 = ribbed_plate.area
|
||||
thickness_mm = params.get('thickness', 10.0)
|
||||
volume_mm3 = area_mm2 * thickness_mm
|
||||
volume_m3 = volume_mm3 * 1e-9
|
||||
mass_kg = volume_m3 * density_kg_m3
|
||||
return mass_kg * 1000.0 # grams
|
||||
|
||||
|
||||
def validate_profile(ribbed_plate, params):
|
||||
"""
|
||||
Run all validation checks on the ribbed plate profile.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple : (is_valid: bool, checks: dict)
|
||||
"""
|
||||
t_min = params['t_min']
|
||||
|
||||
# Fix any geometry issues
|
||||
if not ribbed_plate.is_valid:
|
||||
ribbed_plate = make_valid(ribbed_plate)
|
||||
|
||||
checks = {
|
||||
'is_valid_geometry': ribbed_plate.is_valid,
|
||||
'min_web_width': check_minimum_web(ribbed_plate, t_min),
|
||||
'no_islands': check_no_islands(ribbed_plate),
|
||||
'no_self_intersections': ribbed_plate.is_valid,
|
||||
'mass_estimate_g': estimate_mass(ribbed_plate, params),
|
||||
'area_mm2': ribbed_plate.area,
|
||||
'num_interiors': len(list(ribbed_plate.interiors)) if ribbed_plate.geom_type == 'Polygon' else 0,
|
||||
}
|
||||
|
||||
is_valid = all([
|
||||
checks['is_valid_geometry'],
|
||||
checks['min_web_width'],
|
||||
checks['no_islands'],
|
||||
checks['no_self_intersections'],
|
||||
])
|
||||
|
||||
return is_valid, checks
|
||||
6
tools/adaptive-isogrid/src/nx/__init__.py
Normal file
6
tools/adaptive-isogrid/src/nx/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Adaptive Isogrid — NX Hands
|
||||
|
||||
NXOpen journal scripts for geometry extraction, AFEM setup, and per-iteration solve.
|
||||
These scripts run inside NX Simcenter via the NXOpen Python API.
|
||||
"""
|
||||
28
tools/adaptive-isogrid/src/nx/build_interface_model.py
Normal file
28
tools/adaptive-isogrid/src/nx/build_interface_model.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
NXOpen script — Build Interface Model (Model A) for Assembly FEM.
|
||||
|
||||
ONE-TIME: Creates spider elements (RBE2/RBE3) at each hole + edge BC nodes.
|
||||
Exports interface_nodes.json for the iteration script.
|
||||
|
||||
NOTE: Skeleton — requires NX environment for development.
|
||||
See docs/technical-spec.md Section 4.2 for full pseudocode.
|
||||
"""
|
||||
|
||||
|
||||
def build_interface_model(geometry_json_path, fem_part):
|
||||
"""
|
||||
Build Model A: spider elements at each hole + edge BC nodes.
|
||||
|
||||
For each hole:
|
||||
- Center node (master)
|
||||
- N circumference nodes (~1 per 2mm of circumference)
|
||||
- RBE2 (rigid) or RBE3 (distributing) spider
|
||||
|
||||
For plate boundary:
|
||||
- Edge nodes at ~3mm spacing
|
||||
|
||||
Exports interface_nodes.json with all node IDs and coordinates.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.2."
|
||||
)
|
||||
54
tools/adaptive-isogrid/src/nx/extract_geometry.py
Normal file
54
tools/adaptive-isogrid/src/nx/extract_geometry.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
NXOpen script — Extract plate geometry from selected face.
|
||||
|
||||
ONE-TIME: Run inside NX. User selects plate face, assigns hole weights.
|
||||
Exports geometry.json for the Python brain.
|
||||
|
||||
NOTE: This is pseudocode / skeleton. Actual NXOpen API calls need
|
||||
NX environment to develop and test.
|
||||
"""
|
||||
|
||||
# This script runs inside NX — NXOpen is available at runtime
|
||||
# import NXOpen
|
||||
# import json
|
||||
# import math
|
||||
|
||||
|
||||
def extract_plate_geometry(face, hole_weights):
|
||||
"""
|
||||
Extract plate geometry from an NX face.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
face : NXOpen.Face
|
||||
The selected plate face.
|
||||
hole_weights : dict
|
||||
{loop_index: weight} from user input.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict : geometry definition for export.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"This script must be developed and tested inside NX Simcenter. "
|
||||
"See docs/technical-spec.md Section 2 for full pseudocode."
|
||||
)
|
||||
|
||||
|
||||
def sample_edge(edge, tolerance=0.1):
|
||||
"""Sample edge curve as polyline with given chord tolerance."""
|
||||
# NXOpen: edge.GetCurve(), evaluate at intervals
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def fit_circle(points):
|
||||
"""Fit a circle to boundary points. Returns (center, diameter)."""
|
||||
# Least-squares circle fit
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def export_geometry(geometry, filepath='geometry.json'):
|
||||
"""Export geometry dict to JSON."""
|
||||
import json
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(geometry, f, indent=2)
|
||||
35
tools/adaptive-isogrid/src/nx/iteration_solve.py
Normal file
35
tools/adaptive-isogrid/src/nx/iteration_solve.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
NXOpen script — Per-iteration Model B rebuild + solve + extract.
|
||||
|
||||
LOOP: Called by Atomizer for each trial.
|
||||
1. Delete old Model B geometry + mesh
|
||||
2. Import new 2D ribbed profile from rib_profile.json
|
||||
3. Mesh with hard-point seeds at interface node locations
|
||||
4. Merge nodes in Assembly FEM
|
||||
5. Solve (Nastran)
|
||||
6. Extract results → results.json
|
||||
|
||||
NOTE: Skeleton — requires NX environment for development.
|
||||
See docs/technical-spec.md Section 4.3 for full pseudocode.
|
||||
"""
|
||||
|
||||
|
||||
def iteration_solve(profile_path, interface_nodes_path, afem_part):
|
||||
"""
|
||||
Single optimization iteration.
|
||||
|
||||
Returns dict with status, mass, stress/displacement fields.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.3."
|
||||
)
|
||||
|
||||
|
||||
def extract_results(afem, solution):
|
||||
"""
|
||||
Extract field results from solved assembly FEM.
|
||||
Only from Model B elements (plate mesh), ignoring spiders.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Develop inside NX Simcenter. See docs/technical-spec.md Section 4.3."
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"plate_id": "sample_bracket",
|
||||
"units": "mm",
|
||||
"thickness": 10.0,
|
||||
"material": "AL6061-T6",
|
||||
"outer_boundary": [[0,0], [400,0], [400,300], [0,300]],
|
||||
"holes": [
|
||||
{
|
||||
"index": 0,
|
||||
"center": [50, 50],
|
||||
"diameter": 12.0,
|
||||
"is_circular": true,
|
||||
"boundary": [[44,50], [44.2,51.9], [44.8,53.7], [45.8,55.2], [47.1,56.3], [48.6,57.0], [50.2,57.0], [51.7,56.5], [53.0,55.4], [53.9,53.9], [54.4,52.1], [54.4,50.1], [54.0,48.3], [53.1,46.7], [51.8,45.6], [50.3,45.0], [48.6,45.0], [47.1,45.5], [45.8,46.6], [44.9,48.1]],
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"center": [50, 250],
|
||||
"diameter": 12.0,
|
||||
"is_circular": true,
|
||||
"boundary": [[44,250], [44.2,251.9], [44.8,253.7], [45.8,255.2], [47.1,256.3], [48.6,257.0], [50.2,257.0], [51.7,256.5], [53.0,255.4], [53.9,253.9], [54.4,252.1], [54.4,250.1], [54.0,248.3], [53.1,246.7], [51.8,245.6], [50.3,245.0], [48.6,245.0], [47.1,245.5], [45.8,246.6], [44.9,248.1]],
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"center": [200, 150],
|
||||
"diameter": 8.0,
|
||||
"is_circular": true,
|
||||
"boundary": [[196,150], [196.1,151.3], [196.5,152.5], [197.2,153.5], [198.1,154.2], [199.2,154.6], [200.4,154.6], [201.5,154.2], [202.4,153.4], [203.0,152.3], [203.3,151.0], [203.3,149.7], [203.0,148.5], [202.3,147.4], [201.4,146.6], [200.3,146.2], [199.1,146.2], [198.0,146.7], [197.2,147.5], [196.5,148.7]],
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"center": [350, 50],
|
||||
"diameter": 10.0,
|
||||
"is_circular": true,
|
||||
"boundary": [[345,50], [345.1,51.6], [345.6,53.1], [346.5,54.3], [347.6,55.2], [349.0,55.7], [350.5,55.7], [351.9,55.1], [353.0,54.1], [353.8,52.8], [354.2,51.3], [354.2,49.7], [353.8,48.2], [352.9,46.9], [351.7,46.0], [350.3,45.5], [348.8,45.5], [347.5,46.1], [346.5,47.1], [345.6,48.4]],
|
||||
"weight": 0.7
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"center": [350, 250],
|
||||
"diameter": 10.0,
|
||||
"is_circular": true,
|
||||
"boundary": [[345,250], [345.1,251.6], [345.6,253.1], [346.5,254.3], [347.6,255.2], [349.0,255.7], [350.5,255.7], [351.9,255.1], [353.0,254.1], [353.8,252.8], [354.2,251.3], [354.2,249.7], [353.8,248.2], [352.9,246.9], [351.7,246.0], [350.3,245.5], [348.8,245.5], [347.5,246.1], [346.5,247.1], [345.6,248.4]],
|
||||
"weight": 0.7
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user