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