Adaptive isogrid: min triangle area filtering and circular hole bosses
This commit is contained in:
@@ -25,6 +25,7 @@ PARAM_SPACE = {
|
|||||||
'w_frame': {'type': 'float', 'low': 3.0, 'high': 20.0, 'desc': 'Perimeter frame width (mm)'},
|
'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)'},
|
'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)'},
|
'd_keep': {'type': 'float', 'low': 1.0, 'high': 3.0, 'desc': 'Hole keepout multiplier (× diameter)'},
|
||||||
|
'min_triangle_area': {'type': 'float', 'low': 5.0, 'high': 80.0, 'desc': 'Minimum pocketable triangle area (mm²)'},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default parameters for standalone brain testing
|
# Default parameters for standalone brain testing
|
||||||
@@ -45,4 +46,5 @@ DEFAULT_PARAMS = {
|
|||||||
'r_f': 1.5,
|
'r_f': 1.5,
|
||||||
'd_keep': 1.5,
|
'd_keep': 1.5,
|
||||||
'min_pocket_radius': 1.5,
|
'min_pocket_radius': 1.5,
|
||||||
|
'min_triangle_area': 20.0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ def generate_pockets(triangulation, geometry, params):
|
|||||||
triangles = triangulation['triangles']
|
triangles = triangulation['triangles']
|
||||||
r_f = params['r_f']
|
r_f = params['r_f']
|
||||||
min_pocket_radius = params.get('min_pocket_radius', 1.5)
|
min_pocket_radius = params.get('min_pocket_radius', 1.5)
|
||||||
|
min_triangle_area = params.get('min_triangle_area', 20.0)
|
||||||
|
|
||||||
# Precompute edge thicknesses
|
# Precompute edge thicknesses
|
||||||
edge_thickness = {}
|
edge_thickness = {}
|
||||||
@@ -132,7 +133,12 @@ def generate_pockets(triangulation, geometry, params):
|
|||||||
p0 = vertices[tri[0]]
|
p0 = vertices[tri[0]]
|
||||||
p1 = vertices[tri[1]]
|
p1 = vertices[tri[1]]
|
||||||
p2 = vertices[tri[2]]
|
p2 = vertices[tri[2]]
|
||||||
|
|
||||||
|
# Leave tiny triangles solid to avoid unpocketable slivers
|
||||||
|
tri_area = triangle_area(p0, p1, p2)
|
||||||
|
if tri_area < min_triangle_area:
|
||||||
|
continue
|
||||||
|
|
||||||
# Get edge thicknesses (half for inset)
|
# Get edge thicknesses (half for inset)
|
||||||
e01 = tuple(sorted([tri[0], tri[1]]))
|
e01 = tuple(sorted([tri[0], tri[1]]))
|
||||||
e12 = tuple(sorted([tri[1], tri[2]]))
|
e12 = tuple(sorted([tri[1], tri[2]]))
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ into the final 2D ribbed plate profile for NX import.
|
|||||||
Output: plate boundary - pockets - holes = ribbed plate (Shapely geometry)
|
Output: plate boundary - pockets - holes = ribbed plate (Shapely geometry)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from shapely.ops import unary_union
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_circle(center, radius, num_points=64):
|
||||||
|
cx, cy = center
|
||||||
|
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
|
||||||
|
return [(cx + radius * np.cos(a), cy + radius * np.sin(a)) for a in angles]
|
||||||
|
|
||||||
|
|
||||||
|
def hole_to_actual_polygon(hole):
|
||||||
|
"""Return the actual hole boundary polygon (not boss/keepout)."""
|
||||||
|
if hole.get('boundary'):
|
||||||
|
return Polygon(hole['boundary'])
|
||||||
|
if hole.get('is_circular', False) and 'center' in hole and 'diameter' in hole:
|
||||||
|
return Polygon(_sample_circle(hole['center'], float(hole['diameter']) / 2.0))
|
||||||
|
return Polygon()
|
||||||
|
|
||||||
|
|
||||||
def assemble_profile(geometry, pockets, params):
|
def assemble_profile(geometry, pockets, params):
|
||||||
"""
|
"""
|
||||||
Create the final 2D ribbed plate profile.
|
Create the final 2D ribbed plate profile.
|
||||||
@@ -41,7 +57,7 @@ def assemble_profile(geometry, pockets, params):
|
|||||||
# Frame too wide for plate — return solid plate minus holes
|
# Frame too wide for plate — return solid plate minus holes
|
||||||
ribbed = plate
|
ribbed = plate
|
||||||
for hole in geometry['holes']:
|
for hole in geometry['holes']:
|
||||||
ribbed = ribbed.difference(Polygon(hole['boundary']))
|
ribbed = ribbed.difference(hole_to_actual_polygon(hole))
|
||||||
return ribbed
|
return ribbed
|
||||||
|
|
||||||
# Union all pocket polygons
|
# Union all pocket polygons
|
||||||
@@ -60,10 +76,9 @@ def assemble_profile(geometry, pockets, params):
|
|||||||
else:
|
else:
|
||||||
ribbed = plate
|
ribbed = plate
|
||||||
|
|
||||||
# Subtract original hole boundaries
|
# Subtract actual hole boundaries (not boss keepouts)
|
||||||
for hole in geometry['holes']:
|
for hole in geometry['holes']:
|
||||||
hole_poly = Polygon(hole['boundary'])
|
ribbed = ribbed.difference(hole_to_actual_polygon(hole))
|
||||||
ribbed = ribbed.difference(hole_poly)
|
|
||||||
|
|
||||||
return ribbed
|
return ribbed
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ respects plate boundary, hole keepouts, and density-driven spacing.
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import triangle as tr
|
import triangle as tr
|
||||||
from shapely.geometry import Polygon, LinearRing
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
from .density_field import evaluate_density, density_to_spacing
|
from .density_field import evaluate_density, density_to_spacing
|
||||||
|
|
||||||
@@ -33,6 +33,13 @@ def offset_polygon(coords, distance, inward=True):
|
|||||||
return coords
|
return coords
|
||||||
|
|
||||||
|
|
||||||
|
def sample_circle(center, radius, num_points=32):
|
||||||
|
"""Sample a circle as a polygon with `num_points` vertices."""
|
||||||
|
cx, cy = center
|
||||||
|
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
|
||||||
|
return [[cx + radius * np.cos(a), cy + radius * np.sin(a)] for a in angles]
|
||||||
|
|
||||||
|
|
||||||
def build_pslg(geometry, params):
|
def build_pslg(geometry, params):
|
||||||
"""
|
"""
|
||||||
Build Planar Straight Line Graph for Triangle library.
|
Build Planar Straight Line Graph for Triangle library.
|
||||||
@@ -62,18 +69,28 @@ def build_pslg(geometry, params):
|
|||||||
for i in range(n):
|
for i in range(n):
|
||||||
segments.append([v_start + i, v_start + (i + 1) % n])
|
segments.append([v_start + i, v_start + (i + 1) % n])
|
||||||
|
|
||||||
# Each hole with keepout offset
|
# Each hole with boss keepout reservation
|
||||||
for hole in geometry['holes']:
|
for hole in geometry['holes']:
|
||||||
keepout_dist = d_keep * (hole.get('diameter', 10.0) or 10.0) / 2.0
|
diameter = float(hole.get('diameter', 10.0) or 10.0)
|
||||||
hole_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
|
keepout_dist = d_keep * diameter / 2.0
|
||||||
|
|
||||||
|
if hole.get('is_circular', False) and 'center' in hole:
|
||||||
|
# Circular boss reservation around hole:
|
||||||
|
# r_boss = r_hole + d_keep * hole_diameter / 2
|
||||||
|
hole_radius = diameter / 2.0
|
||||||
|
boss_radius = hole_radius + keepout_dist
|
||||||
|
keepout_boundary = sample_circle(hole['center'], boss_radius, num_points=32)
|
||||||
|
else:
|
||||||
|
# Fallback for non-circular holes
|
||||||
|
keepout_boundary = offset_polygon(hole['boundary'], keepout_dist, inward=False)
|
||||||
|
|
||||||
v_start = len(vertices)
|
v_start = len(vertices)
|
||||||
vertices.extend(hole_boundary)
|
vertices.extend(keepout_boundary)
|
||||||
n_h = len(hole_boundary)
|
n_h = len(keepout_boundary)
|
||||||
for i in range(n_h):
|
for i in range(n_h):
|
||||||
segments.append([v_start + i, v_start + (i + 1) % n_h])
|
segments.append([v_start + i, v_start + (i + 1) % n_h])
|
||||||
|
|
||||||
# Marker inside hole tells Triangle to leave it empty
|
# Marker inside hole tells Triangle to leave this keepout region empty
|
||||||
hole_markers.append(hole['center'])
|
hole_markers.append(hole['center'])
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -107,6 +124,22 @@ def compute_centroids(vertices, triangles):
|
|||||||
return (v0 + v1 + v2) / 3.0
|
return (v0 + v1 + v2) / 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def filter_small_triangles(result, min_triangle_area):
|
||||||
|
"""Remove triangles smaller than the manufacturing threshold."""
|
||||||
|
triangles = result.get('triangles')
|
||||||
|
vertices = result.get('vertices')
|
||||||
|
if triangles is None or vertices is None or len(triangles) == 0:
|
||||||
|
return result
|
||||||
|
|
||||||
|
areas = compute_triangle_areas(vertices, triangles)
|
||||||
|
keep_mask = areas >= float(min_triangle_area)
|
||||||
|
|
||||||
|
result['triangle_areas'] = areas
|
||||||
|
result['small_triangle_mask'] = ~keep_mask
|
||||||
|
result['triangles'] = triangles[keep_mask]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def generate_triangulation(geometry, params, max_refinement_passes=3):
|
def generate_triangulation(geometry, params, max_refinement_passes=3):
|
||||||
"""
|
"""
|
||||||
Generate density-adaptive constrained Delaunay triangulation.
|
Generate density-adaptive constrained Delaunay triangulation.
|
||||||
@@ -137,10 +170,10 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
|
|||||||
for iteration in range(max_refinement_passes):
|
for iteration in range(max_refinement_passes):
|
||||||
verts = result['vertices']
|
verts = result['vertices']
|
||||||
tris = result['triangles']
|
tris = result['triangles']
|
||||||
|
|
||||||
areas = compute_triangle_areas(verts, tris)
|
areas = compute_triangle_areas(verts, tris)
|
||||||
centroids = compute_centroids(verts, tris)
|
centroids = compute_centroids(verts, tris)
|
||||||
|
|
||||||
# Compute target area for each triangle based on density at centroid
|
# Compute target area for each triangle based on density at centroid
|
||||||
target_areas = np.array([
|
target_areas = np.array([
|
||||||
(np.sqrt(3) / 4.0) * density_to_spacing(
|
(np.sqrt(3) / 4.0) * density_to_spacing(
|
||||||
@@ -148,13 +181,15 @@ def generate_triangulation(geometry, params, max_refinement_passes=3):
|
|||||||
)**2
|
)**2
|
||||||
for cx, cy in centroids
|
for cx, cy in centroids
|
||||||
])
|
])
|
||||||
|
|
||||||
# Check if all triangles satisfy constraints (20% tolerance)
|
# Check if all triangles satisfy constraints (20% tolerance)
|
||||||
if np.all(areas <= target_areas * 1.2):
|
if np.all(areas <= target_areas * 1.2):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Set per-triangle max area and refine
|
# Set per-triangle max area and refine
|
||||||
result['triangle_max_area'] = target_areas
|
result['triangle_max_area'] = target_areas
|
||||||
result = tr.triangulate(result, 'rpq30D')
|
result = tr.triangulate(result, 'rpq30D')
|
||||||
|
|
||||||
|
min_triangle_area = params.get('min_triangle_area', 20.0)
|
||||||
|
result = filter_small_triangles(result, min_triangle_area)
|
||||||
return result
|
return result
|
||||||
|
|||||||
120041
tools/adaptive-isogrid/tests/output/complex_bracket_rib_profile.json
Normal file
120041
tools/adaptive-isogrid/tests/output/complex_bracket_rib_profile.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 484 KiB |
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 520 KiB After Width: | Height: | Size: 449 KiB |
1282
tools/adaptive-isogrid/tests/test_geometries/complex_bracket.json
Normal file
1282
tools/adaptive-isogrid/tests/test_geometries/complex_bracket.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user