refactor(brain): structured pocket output — 3 lines + 3 arcs per pocket

Replaced Shapely buffer-based fillet (59-pt polylines) with exact geometric
fillet computation. Each pocket now outputs:
- 3 straight edges (line start/end pairs)
- 3 fillet arcs (center, radius, tangent points, angles)

NX import updated to use SketchLineBuilder + SketchArcBuilder (3-point).
Total NX entities: ~2,600 (was ~13,000). Includes arc fallback to 2-line
segments if SketchArcBuilder fails.

Also outputs circular hole definitions for future NX circle creation.
This commit is contained in:
2026-02-16 20:17:49 +00:00
parent fdcafe96a9
commit da9b579bcf
7 changed files with 76328 additions and 389 deletions

View File

@@ -89,8 +89,10 @@ def _plot_final_profile(geometry, pockets, ribbed_plate, out_path: Path) -> None
ax.plot(np.r_[outer[:, 0], outer[0, 0]], np.r_[outer[:, 1], outer[0, 1]], "k-", lw=1.8, label="Outer boundary")
for pocket in pockets:
pv = np.asarray(pocket["vertices"])
ax.fill(pv[:, 0], pv[:, 1], color="#88ccee", alpha=0.35, lw=0.0)
polyline = pocket.get("polyline", pocket.get("vertices", []))
pv = np.asarray(polyline)
if len(pv) >= 3:
ax.fill(pv[:, 0], pv[:, 1], color="#88ccee", alpha=0.35, lw=0.0)
if ribbed_plate.geom_type == "Polygon":
geoms = [ribbed_plate]
@@ -122,7 +124,7 @@ def run_pipeline(geometry_path: Path, params_path: Path | None, output_dir: Path
ribbed_plate = assemble_profile(geometry, pockets, params)
is_valid, checks = validate_profile(ribbed_plate, params)
profile_json = profile_to_json(ribbed_plate, params)
profile_json = profile_to_json(ribbed_plate, pockets, geometry, params)
profile_json["checks"] = checks
profile_json["pipeline"] = {
"geometry_file": str(geometry_path),

View File

@@ -1,12 +1,15 @@
"""
Pocket profile generation — convert triangulation into manufacturable pocket cutouts.
Each triangle → inset by half-rib-thickness per edge → fillet corners → pocket polygon.
Each triangle → inset by half-rib-thickness per edge → fillet corners → pocket.
Output is **structured geometry**: 3 straight edges + 3 fillet arcs per pocket,
NOT dense polylines. This keeps NX sketch imports lightweight (~6 entities per
pocket instead of ~60 line segments).
"""
import numpy as np
from shapely.geometry import Polygon
from shapely.ops import unary_union
from typing import List, Dict, Optional, Tuple
from .density_field import evaluate_density, density_to_rib_thickness
@@ -21,78 +24,6 @@ def get_unique_edges(triangles):
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(
@@ -101,23 +32,258 @@ def triangle_area(p0, p1, p2):
)
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])
n1 = np.array([-dy / length, dx / length])
n2 = np.array([dy / length, -dx / length])
mid = (np.array(a) + np.array(b)) / 2.0
if np.dot(n1, np.array(opposite) - mid) > 0:
return n1
return n2
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
t = ((a2[0] - a1[0]) * d2[1] - (a2[1] - a1[1]) * d2[0]) / cross
return a1 + t * d1
def _unit(v):
"""Normalize vector."""
n = np.linalg.norm(v)
return v / n if n > 1e-12 else v
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 [v0, v1, v2] or None if collapsed.
"""
points = [np.array(p0, dtype=float), np.array(p1, dtype=float), np.array(p2, dtype=float)]
offsets = [d0, d1, d2]
edges = [(0, 1, 2), (1, 2, 0), (2, 0, 1)]
inset_lines = []
for (i, j, k), d in zip(edges, offsets):
n = _edge_normal_inward(points[i], points[j], points[k])
inset_lines.append((points[i] + n * d, points[j] + n * d))
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)
if triangle_area(new_verts[0], new_verts[1], new_verts[2]) < 0.1:
return None
return new_verts
def _fillet_corner(
v_prev: np.ndarray,
v_corner: np.ndarray,
v_next: np.ndarray,
r_f: float,
) -> Optional[Dict]:
"""
Compute a fillet arc at v_corner between edges v_prev→v_corner and
v_corner→v_next.
Returns dict with:
tangent_start: point where arc begins (on edge prev→corner)
tangent_end: point where arc ends (on edge corner→next)
center: arc center
radius: actual fillet radius used
start_angle: angle of tangent_start from center (radians)
end_angle: angle of tangent_end from center (radians)
or None if fillet can't be applied.
"""
d1 = _unit(v_prev - v_corner)
d2 = _unit(v_next - v_corner)
# Half-angle between the two edges
cos_half = np.dot(d1, d2)
cos_half = np.clip(cos_half, -1.0, 1.0)
half_angle = np.arccos(cos_half) / 2.0
if half_angle < 1e-6 or half_angle > np.pi / 2 - 1e-6:
return None
# Distance from corner to tangent point
tan_dist = r_f / np.tan(half_angle)
# Check we don't exceed half the edge length
max_dist = min(
np.linalg.norm(v_prev - v_corner) * 0.45,
np.linalg.norm(v_next - v_corner) * 0.45,
)
if tan_dist > max_dist:
# Reduce fillet radius to fit
r_f = max_dist * np.tan(half_angle)
tan_dist = max_dist
if r_f < 0.05:
return None
tangent_start = v_corner + d1 * tan_dist
tangent_end = v_corner + d2 * tan_dist
# Arc center: move from corner along bisector
bisector = _unit(d1 + d2)
center_dist = r_f / np.sin(half_angle)
center = v_corner + bisector * center_dist
# Angles
start_angle = float(np.arctan2(tangent_start[1] - center[1], tangent_start[0] - center[0]))
end_angle = float(np.arctan2(tangent_end[1] - center[1], tangent_end[0] - center[0]))
return {
'tangent_start': tangent_start.tolist(),
'tangent_end': tangent_end.tolist(),
'center': center.tolist(),
'radius': float(r_f),
'start_angle': start_angle,
'end_angle': end_angle,
}
def _compute_filleted_pocket(inset_verts: List[np.ndarray], r_f: float) -> Optional[Dict]:
"""
Compute structured pocket geometry: 3 lines + up to 3 arcs.
Returns dict with:
lines: list of [start, end] pairs (the straight rib edges)
arcs: list of arc dicts (fillet arcs at corners)
vertices: inset triangle vertices (for reference)
"""
n = len(inset_verts) # always 3 for triangles
fillets = []
for i in range(n):
v_prev = inset_verts[(i - 1) % n]
v_corner = inset_verts[i]
v_next = inset_verts[(i + 1) % n]
fillet = _fillet_corner(v_prev, v_corner, v_next, r_f)
fillets.append(fillet)
# Build lines connecting fillet tangent points
lines = []
for i in range(n):
# Line from end of fillet at vertex i to start of fillet at vertex i+1
if fillets[i] is not None:
line_start = fillets[i]['tangent_end']
else:
line_start = inset_verts[i].tolist()
next_i = (i + 1) % n
if fillets[next_i] is not None:
line_end = fillets[next_i]['tangent_start']
else:
line_end = inset_verts[next_i].tolist()
# Skip degenerate lines
d = np.sqrt((line_start[0] - line_end[0])**2 + (line_start[1] - line_end[1])**2)
if d > 0.01:
lines.append([line_start, line_end])
arcs = [f for f in fillets if f is not None]
if len(lines) == 0 and len(arcs) == 0:
return None
return {
'lines': lines,
'arcs': arcs,
'vertices': [v.tolist() for v in inset_verts],
}
def _pocket_to_polyline(pocket_geom: Dict, arc_segments: int = 8) -> List[List[float]]:
"""
Convert structured pocket geometry to a simple polyline (for Shapely
operations like area calculation and visualization).
Uses a small number of arc segments since this is only for validation.
"""
# Build ordered point list: line, arc, line, arc, ...
# The pocket geometry alternates: fillet arc at vertex i, then line to vertex i+1
pts = []
n_lines = len(pocket_geom['lines'])
n_arcs = len(pocket_geom['arcs'])
if n_arcs == 0:
# No fillets — just straight triangle
for line in pocket_geom['lines']:
pts.append(line[0])
if pocket_geom['lines']:
pts.append(pocket_geom['lines'][0][0]) # close
return pts
# Interleave: for each vertex, output arc then line
arc_idx = 0
for i in range(3):
# Find if this vertex has a fillet
# Arcs are stored in order of vertices that had fillets
if arc_idx < n_arcs:
arc = pocket_geom['arcs'][arc_idx]
# Sample arc
sa = arc['start_angle']
ea = arc['end_angle']
# Ensure we go the short way around
da = ea - sa
if da > np.pi:
da -= 2 * np.pi
elif da < -np.pi:
da += 2 * np.pi
for j in range(arc_segments + 1):
t = sa + da * j / arc_segments
px = arc['center'][0] + arc['radius'] * np.cos(t)
py = arc['center'][1] + arc['radius'] * np.sin(t)
pts.append([px, py])
arc_idx += 1
# Line to next vertex
if i < n_lines:
pts.append(pocket_geom['lines'][i][1])
# Close
if pts:
pts.append(pts[0])
return pts
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.
Each triangle becomes a pocket: inset by half-rib-thickness, filleted corners.
Output is structured geometry (lines + arcs), not dense polylines.
Returns list[dict] with keys:
triangle_index, lines, arcs, vertices, area, polyline (for viz)
"""
vertices = triangulation['vertices']
triangles = triangulation['triangles']
r_f = params['r_f']
min_pocket_radius = params.get('min_pocket_radius', 1.5)
min_triangle_area = params.get('min_triangle_area', 20.0)
# Precompute edge thicknesses
edge_thickness = {}
for tri in triangles:
@@ -127,56 +293,59 @@ def generate_pockets(triangulation, geometry, params):
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]]
# 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)
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:
# Check minimum pocket size via inscribed circle
inset_area = triangle_area(inset[0], inset[1], inset[2])
perimeter = sum(
np.linalg.norm(inset[(i+1)%3] - inset[i]) for i in range(3)
)
if perimeter < 1e-6:
continue
# Approximate inscribed radius
inscribed_r = pocket_poly.area / (pocket_poly.length / 2.0)
inscribed_r = inset_area / (perimeter / 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:
# Compute fillet amount (don't over-fillet)
fillet_amount = min(r_f, inscribed_r * 0.4)
if fillet_amount < 0.1:
fillet_amount = 0.0
pocket_geom = _compute_filleted_pocket(inset, fillet_amount)
if pocket_geom is None:
continue
coords = list(filleted.exterior.coords)
# Generate a low-res polyline for area calc and visualization
polyline = _pocket_to_polyline(pocket_geom, arc_segments=6)
pockets.append({
'triangle_index': tri_idx,
'vertices': coords,
'area': filleted.area,
'lines': pocket_geom['lines'],
'arcs': pocket_geom['arcs'],
'vertices': pocket_geom['vertices'],
'polyline': polyline,
'area': inset_area, # approximate
})
return pockets

View File

@@ -2,7 +2,7 @@
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)
Output format: structured geometry with lines + arcs (not dense polylines).
"""
import numpy as np
@@ -27,89 +27,89 @@ def hole_to_actual_polygon(hole):
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.
Create the final 2D ribbed plate profile using Shapely for
boolean operations and validation.
Returns Shapely Polygon/MultiPolygon for visualization and validation.
The actual NX import uses the structured pocket geometry directly.
"""
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(hole_to_actual_polygon(hole))
return ribbed
# Union all pocket polygons
# Build pocket polygons from low-res polylines (for Shapely ops only)
pocket_polys = []
for p in pockets:
poly = Polygon(p['vertices'])
if poly.is_valid and not poly.is_empty:
pocket_polys.append(poly)
polyline = p.get('polyline', [])
if len(polyline) >= 3:
poly = Polygon(polyline)
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 actual hole boundaries (not boss keepouts)
for hole in geometry['holes']:
ribbed = ribbed.difference(hole_to_actual_polygon(hole))
return ribbed
def profile_to_json(ribbed_plate, params):
def profile_to_json(ribbed_plate, pockets, geometry, params):
"""
Convert Shapely ribbed plate geometry to JSON-serializable dict
for NXOpen import.
Convert to JSON-serializable dict for NX import.
Output contains:
- outer_boundary: plate outline points
- pockets: list of structured pocket geometry (lines + arcs)
- holes: circular hole definitions
- parameters_used: the params dict
"""
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)
outer = geometry['outer_boundary']
if hasattr(outer, 'tolist'):
outer = outer.tolist()
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)
outer = [list(p) for p in outer]
# Structured pocket data (lines + arcs)
pocket_data = []
for p in pockets:
pocket_data.append({
'lines': p['lines'],
'arcs': p['arcs'],
})
# Hole data for NX (circles)
hole_data = []
for hole in geometry.get('holes', []):
if hole.get('is_circular', False) and 'center' in hole and 'diameter' in hole:
hole_data.append({
'center': list(hole['center']),
'radius': float(hole['diameter']) / 2.0,
'is_circular': True,
})
return {
'valid': True,
'outer_boundary': outer_coords,
'pockets': pocket_coords,
'outer_boundary': outer,
'pockets': pocket_data,
'holes': hole_data,
'parameters_used': params,
}