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,
}

View File

@@ -4,7 +4,7 @@ NXOpen script — Import rib profile into NX as a sketch.
Reads `rib_profile_<sandbox_id>.json` (output from Python Brain) and creates
an NX sketch on the sandbox plane containing:
- Outer boundary polyline
- All pocket cutout polylines
- Structured pocket geometry (3 lines + 3 arcs per triangular pocket)
The sketch is placed in the idealized part. Antoine extrudes manually the first
time; subsequent iterations only update the sketch and the extrude regenerates.
@@ -55,6 +55,22 @@ def unproject_to_3d(
return pts3d
def unproject_point_to_3d(
pt2d: List[float],
transform: Dict[str, List[float]],
) -> Tuple[float, float, float]:
"""Convert a single 2D point to 3D."""
ox, oy, oz = transform["origin"]
xx, xy, xz = transform["x_axis"]
yx, yy, yz = transform["y_axis"]
x, y = pt2d[0], pt2d[1]
return (
ox + x * xx + y * yx,
oy + x * xy + y * yy,
oz + x * xz + y * yz,
)
# ---------------------------------------------------------------------------
# NX sketch creation
# ---------------------------------------------------------------------------
@@ -82,7 +98,6 @@ def _find_or_create_sketch(
except Exception:
continue
if fname == sketch_name:
# Get the sketch from the feature
try:
existing_sketch = feat.GetEntities()[0]
lister.WriteLine(f"[import] Found existing sketch: {sketch_name}")
@@ -93,7 +108,6 @@ def _find_or_create_sketch(
pass
if existing_sketch is not None:
# Clear existing geometry for update
try:
existing_sketch.Activate(False)
all_geom = existing_sketch.GetAllGeometry()
@@ -116,31 +130,20 @@ def _find_or_create_sketch(
normal_vec = NXOpen.Vector3d(normal[0], normal[1], normal[2])
x_vec = NXOpen.Vector3d(x_axis[0], x_axis[1], x_axis[2])
# Create a Plane (SmartObject) — NOT a DatumPlane (DisplayableObject)
# PlaneCollection.CreatePlane(method, alternate, origin, normal, expr, flip, percent, geometry)
# Create a Plane (SmartObject)
plane = part.Planes.CreatePlane(
NXOpen.PlaneTypes.MethodType.Fixed, # Fixed plane
NXOpen.PlaneTypes.MethodType.Fixed,
NXOpen.PlaneTypes.AlternateType.One,
origin_pt,
normal_vec,
"", # expression
False, # flip
False, # percent
[], # geometry refs
"",
False,
False,
[],
)
lister.WriteLine(f"[import] Created plane: {plane} (type={type(plane).__name__})")
if plane is None:
# Fallback: try Distance method
plane = part.Planes.CreatePlane(
NXOpen.PlaneTypes.MethodType.Distance,
NXOpen.PlaneTypes.AlternateType.One,
origin_pt, normal_vec, "", False, False, [],
)
lister.WriteLine(f"[import] Fallback plane: {plane}")
if plane is None:
# Fallback 2: CreateFixedTypePlane
y_axis_t = transform["y_axis"]
mtx = NXOpen.Matrix3x3()
mtx.Xx = x_axis[0]; mtx.Xy = x_axis[1]; mtx.Xz = x_axis[2]
@@ -149,94 +152,50 @@ def _find_or_create_sketch(
plane = part.Planes.CreateFixedTypePlane(
origin_pt, mtx, NXOpen.SmartObject.UpdateOption.WithinModeling,
)
lister.WriteLine(f"[import] Fallback2 plane: {plane}")
lister.WriteLine(f"[import] Fallback plane: {plane}")
# Create sketch-in-place builder
# Build sketch
sketch_builder = part.Sketches.CreateSketchInPlaceBuilder2(NXOpen.Sketch.Null)
# NXOpen Python naming collision: property getter and setter method share
# the same name. builder.PlaneReference returns None (getter), so calling
# builder.PlaneReference(plane) = None(plane) = TypeError.
# Solution: use setattr() to bypass the getter and hit the setter.
origin_point = part.Points.CreatePoint(origin_pt)
axis_dir = part.Directions.CreateDirection(
origin_pt, x_vec, NXOpen.SmartObject.UpdateOption.WithinModeling,
)
# Try multiple setter patterns
# Use setattr to bypass property/method naming collision
for attr, val, label in [
("PlaneReference", plane, "plane"),
("SketchOrigin", origin_point, "origin"),
("AxisReference", axis_dir, "axis"),
]:
# Pattern 1: setattr (property assignment)
try:
setattr(sketch_builder, attr, val)
lister.WriteLine(f"[import] Set {label} via setattr")
continue
except Exception as e1:
pass
# Pattern 2: find the setter method with Set prefix
setter_name = "Set" + attr
try:
setter = getattr(sketch_builder, setter_name, None)
if setter:
setter(val)
lister.WriteLine(f"[import] Set {label} via {setter_name}()")
continue
except Exception:
pass
lister.WriteLine(f"[import] WARNING: Could not set {label}")
# Pattern 3: direct call (in case NX version supports it)
try:
getattr(sketch_builder, attr)(val)
lister.WriteLine(f"[import] Set {label} via method call")
continue
except Exception:
pass
lister.WriteLine(f"[import] WARNING: Could not set {label} ({attr})")
# Commit — Builder.Commit() returns NXObject
sketch_obj = sketch_builder.Commit()
lister.WriteLine(f"[import] Commit returned: {sketch_obj} (type={type(sketch_obj).__name__})")
# Also get committed objects for inspection
committed = sketch_builder.GetCommittedObjects()
lister.WriteLine(f"[import] Committed objects: {len(committed)}")
for i, obj in enumerate(committed):
lister.WriteLine(f"[import] [{i}] {type(obj).__name__}: {obj}")
sketch_builder.Destroy()
# Find the Sketch object from committed objects or return value
# Get Sketch object
sketch = None
if isinstance(sketch_obj, NXOpen.Sketch):
sketch = sketch_obj
else:
# Search committed objects
for obj in committed:
if isinstance(obj, NXOpen.Sketch):
sketch = obj
break
# Try GetObject
if sketch is None:
try:
sketch = sketch_builder.GetObject()
except Exception:
pass
# Last resort: find by name in part sketches
if sketch is None:
for s in part.Sketches:
sketch = s # take the last one (just created)
lister.WriteLine(f"[import] Found sketch by iteration: {sketch}")
sketch = s
if sketch is None:
raise RuntimeError("Could not get Sketch object after commit")
# Rename
try:
sketch.Name = sketch_name
except Exception:
@@ -246,90 +205,133 @@ def _find_or_create_sketch(
return sketch
def _draw_polylines_batch(
part: Any,
sketch: Any,
polylines_3d: List[List[Tuple[float, float, float]]],
lister: Any,
close: bool = True,
) -> int:
"""
Draw multiple closed polylines directly in the active sketch using
SketchLineBuilder (creates native sketch geometry, not model curves).
The sketch must be Activate'd before calling this.
Returns total number of line segments created.
"""
import NXOpen
total_lines = 0
for points_3d in polylines_3d:
if len(points_3d) < 2:
def _resolve_enum(paths):
"""Try multiple dotted paths to resolve an NXOpen enum value."""
for path in paths:
try:
parts_p = path.split(".")
obj = __import__(parts_p[0])
for attr in parts_p[1:]:
obj = getattr(obj, attr)
return obj, path
except (AttributeError, ImportError):
continue
n = len(points_3d)
# Strip closing duplicate
if close and n >= 3:
d = math.sqrt(sum((a - b) ** 2 for a, b in zip(points_3d[0], points_3d[-1])))
if d < 0.001:
n = n - 1
segments = n if close else (n - 1)
for i in range(segments):
p1 = points_3d[i]
p2 = points_3d[(i + 1) % n]
try:
lb = part.Sketches.CreateLineBuilder()
lb.SetStartPoint(NXOpen.Point3d(p1[0], p1[1], p1[2]))
lb.SetEndPoint(NXOpen.Point3d(p2[0], p2[1], p2[2]))
lb.Commit()
lb.Destroy()
total_lines += 1
except Exception as exc:
if total_lines == 0:
lister.WriteLine(f"[import] SketchLineBuilder failed on first line: {exc}")
return 0
# Progress every 50 polylines
if (total_lines > 0) and (len(polylines_3d) > 50) and \
(polylines_3d.index(points_3d) % 50 == 49):
lister.WriteLine(f"[import] ... {total_lines} lines so far")
return total_lines
return None, None
def _draw_circle_in_sketch(
part: Any,
sketch: Any,
center_3d: Tuple[float, float, float],
radius: float,
normal: List[float],
lister: Any,
) -> bool:
"""Draw a circle in the sketch."""
# ---------------------------------------------------------------------------
# Sketch drawing: lines and arcs
# ---------------------------------------------------------------------------
def _draw_line(part, p1_3d, p2_3d):
"""Draw a single line in the active sketch. Returns True on success."""
import NXOpen
lb = part.Sketches.CreateLineBuilder()
lb.SetStartPoint(NXOpen.Point3d(p1_3d[0], p1_3d[1], p1_3d[2]))
lb.SetEndPoint(NXOpen.Point3d(p2_3d[0], p2_3d[1], p2_3d[2]))
lb.Commit()
lb.Destroy()
return True
try:
circle_builder = part.Sketches.CreateCircleBuilder()
center_pt = NXOpen.Point3d(center_3d[0], center_3d[1], center_3d[2])
circle_builder.SetCenterPoint(center_pt)
# Size point = center + radius along x
size_pt = NXOpen.Point3d(
center_3d[0] + radius,
center_3d[1],
center_3d[2],
)
circle_builder.SetSizePoint(size_pt)
circle_builder.Commit()
circle_builder.Destroy()
return True
except Exception as exc:
lister.WriteLine(f"[import] Circle creation failed: {exc}")
return False
def _draw_arc_3pt(part, start_3d, mid_3d, end_3d):
"""
Draw a 3-point arc in the active sketch.
SketchArcBuilder: SetStartPoint, SetEndPoint, SetThirdPoint, Commit.
"""
import NXOpen
ab = part.Sketches.CreateArcBuilder()
ab.SetStartPoint(NXOpen.Point3d(start_3d[0], start_3d[1], start_3d[2]))
ab.SetEndPoint(NXOpen.Point3d(end_3d[0], end_3d[1], end_3d[2]))
ab.SetThirdPoint(NXOpen.Point3d(mid_3d[0], mid_3d[1], mid_3d[2]))
ab.Commit()
ab.Destroy()
return True
def _arc_midpoint_2d(arc):
"""Compute the midpoint of a fillet arc in 2D (for 3-point arc creation)."""
cx, cy = arc['center']
r = arc['radius']
sa = arc['start_angle']
ea = arc['end_angle']
# Go the short way around
da = ea - sa
if da > math.pi:
da -= 2 * math.pi
elif da < -math.pi:
da += 2 * math.pi
mid_angle = sa + da / 2.0
return [cx + r * math.cos(mid_angle), cy + r * math.sin(mid_angle)]
def _draw_outer_boundary(part, outer_2d, transform, lister):
"""Draw the outer boundary as a closed polyline."""
outer_3d = unproject_to_3d(outer_2d, transform)
n = len(outer_3d)
if n < 2:
return 0
# Strip closing duplicate
if n >= 3:
d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1])))
if d < 0.001:
n -= 1
count = 0
for i in range(n):
p1 = outer_3d[i]
p2 = outer_3d[(i + 1) % n]
try:
_draw_line(part, p1, p2)
count += 1
except Exception as exc:
if count == 0:
lister.WriteLine(f"[import] ERROR: First line failed: {exc}")
return 0
return count
def _draw_structured_pocket(part, pocket, transform, lister):
"""
Draw a structured pocket (lines + arcs) in the active sketch.
Returns (num_lines, num_arcs) created.
"""
lines_drawn = 0
arcs_drawn = 0
# Draw straight edges
for line in pocket.get('lines', []):
start_3d = unproject_point_to_3d(line[0], transform)
end_3d = unproject_point_to_3d(line[1], transform)
try:
_draw_line(part, start_3d, end_3d)
lines_drawn += 1
except Exception:
pass
# Draw fillet arcs (3-point: start, midpoint, end)
for arc in pocket.get('arcs', []):
start_3d = unproject_point_to_3d(arc['tangent_start'], transform)
end_3d = unproject_point_to_3d(arc['tangent_end'], transform)
mid_2d = _arc_midpoint_2d(arc)
mid_3d = unproject_point_to_3d(mid_2d, transform)
try:
_draw_arc_3pt(part, start_3d, mid_3d, end_3d)
arcs_drawn += 1
except Exception as exc:
if arcs_drawn == 0:
lister.WriteLine(f"[import] Arc creation failed: {exc}")
# Fallback: draw as 2 line segments through midpoint
try:
_draw_line(part, start_3d, mid_3d)
_draw_line(part, mid_3d, end_3d)
lines_drawn += 2
lister.WriteLine("[import] Arc fallback: 2 lines")
except Exception:
pass
return lines_drawn, arcs_drawn
# ---------------------------------------------------------------------------
@@ -352,7 +354,6 @@ def main():
part_name = work_part.Name if hasattr(work_part, "Name") else ""
lister.WriteLine(f"[import] Work part: {part_name}")
# If not in idealized part, find it
if not part_name.endswith("_i"):
for part in session.Parts:
pname = part.Name if hasattr(part, "Name") else ""
@@ -362,7 +363,6 @@ def main():
lister.WriteLine(f"[import] Switched to idealized part: {pname}")
break
# Find data directory
try:
part_dir = os.path.dirname(work_part.FullPath)
except Exception:
@@ -374,22 +374,18 @@ def main():
lister.WriteLine(f"[import] ERROR: Data directory not found: {data_dir}")
return
# Find all rib profile + geometry JSON pairs
# Find rib profile JSONs
profile_files = sorted([
f for f in os.listdir(data_dir)
if f.startswith("rib_profile_") and f.endswith(".json")
])
if not profile_files:
# Also check for sandbox1_rib_profile.json pattern
profile_files = sorted([
f for f in os.listdir(data_dir)
if "rib_profile" in f and f.endswith(".json")
])
if not profile_files:
lister.WriteLine(f"[import] ERROR: No rib_profile*.json found in {data_dir}")
lister.WriteLine(f"[import] Files present: {os.listdir(data_dir)}")
lister.WriteLine(f"[import] ERROR: No rib_profile*.json in {data_dir}")
return
lister.WriteLine(f"[import] Found {len(profile_files)} profile(s) to import")
@@ -397,21 +393,17 @@ def main():
for profile_file in profile_files:
profile_path = os.path.join(data_dir, profile_file)
# Determine sandbox_id from filename
# Expected: rib_profile_sandbox_1.json or sandbox1_rib_profile.json
sandbox_id = profile_file.replace("rib_profile_", "").replace(".json", "")
if not sandbox_id.startswith("sandbox"):
sandbox_id = "sandbox_1" # fallback
sandbox_id = "sandbox_1"
# Load corresponding geometry JSON for the transform
geom_path = os.path.join(data_dir, f"geometry_{sandbox_id}.json")
if not os.path.exists(geom_path):
# Try alternate names
candidates = [f for f in os.listdir(data_dir) if f.startswith("geometry_") and f.endswith(".json")]
if candidates:
geom_path = os.path.join(data_dir, candidates[0])
else:
lister.WriteLine(f"[import] ERROR: No geometry JSON found for transform data")
lister.WriteLine("[import] ERROR: No geometry JSON for transform")
continue
lister.WriteLine(f"\n--- Importing {profile_file} ---")
@@ -429,7 +421,7 @@ def main():
transform = geometry["transform"]
sketch_name = SKETCH_NAME_PREFIX + sandbox_id
# Find or create sketch
# Create sketch
try:
sketch = _find_or_create_sketch(work_part, sketch_name, transform, lister)
except Exception as exc:
@@ -438,86 +430,81 @@ def main():
lister.WriteLine(traceback.format_exc())
continue
# Activate sketch for drawing
# Activate sketch
try:
# ViewReorient enum — try multiple access paths
view_false = None
for path in [
"NXOpen.ViewReorient.FalseValue",
view_false, path = _resolve_enum([
"NXOpen.Sketch.ViewReorient.FalseValue",
"NXOpen.SketchViewReorient.FalseValue",
]:
try:
parts_p = path.split(".")
obj = __import__(parts_p[0])
for attr in parts_p[1:]:
obj = getattr(obj, attr)
view_false = obj
lister.WriteLine(f"[import] ViewReorient resolved via: {path}")
break
except (AttributeError, ImportError):
continue
"NXOpen.ViewReorient.FalseValue",
])
if view_false is None:
# Last resort: pass False as boolean
view_false = False
lister.WriteLine("[import] ViewReorient: using False (boolean fallback)")
lister.WriteLine(f"[import] ViewReorient: {path or 'boolean fallback'}")
sketch.Activate(view_false)
except Exception as exc:
lister.WriteLine(f"[import] ERROR activating sketch: {exc}")
continue
total_lines = 0
# Collect all polylines to draw
all_polylines_3d = []
# Outer boundary
outer_2d = profile.get("outer_boundary", [])
if outer_2d:
outer_3d = unproject_to_3d(outer_2d, transform)
all_polylines_3d.append(outer_3d)
# Pocket cutouts
# --- Draw geometry ---
pockets = profile.get("pockets", [])
lister.WriteLine(f"[import] Preparing {len(pockets)} pockets + outer boundary...")
outer_2d = profile.get("outer_boundary", [])
for pocket_pts in pockets:
if len(pocket_pts) < 3:
continue
pocket_3d = unproject_to_3d(pocket_pts, transform)
all_polylines_3d.append(pocket_3d)
# Check if pockets are structured (lines+arcs) or legacy (point lists)
is_structured = (len(pockets) > 0 and isinstance(pockets[0], dict)
and 'lines' in pockets[0])
# Draw everything in one batch
lister.WriteLine(f"[import] Creating {len(all_polylines_3d)} polylines as NX curves...")
total_lines = _draw_polylines_batch(
work_part, sketch, all_polylines_3d, lister, close=True,
)
if is_structured:
lister.WriteLine(f"[import] Structured format: {len(pockets)} pockets + outer boundary")
# Outer boundary
outer_lines = _draw_outer_boundary(work_part, outer_2d, transform, lister)
lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines ({len(outer_2d)} pts)")
# Pockets
total_lines = outer_lines
total_arcs = 0
for idx, pocket in enumerate(pockets):
nl, na = _draw_structured_pocket(work_part, pocket, transform, lister)
total_lines += nl
total_arcs += na
if (idx + 1) % 50 == 0:
lister.WriteLine(f"[import] ... {idx+1}/{len(pockets)} pockets drawn")
lister.WriteLine(f"[import] Done: {total_lines} lines + {total_arcs} arcs in sketch")
lister.WriteLine(f"[import] Expected: ~{len(pockets)*3} lines + ~{len(pockets)*3} arcs")
else:
# Legacy format: pockets are point lists
lister.WriteLine(f"[import] Legacy format: {len(pockets)} pocket polylines")
outer_lines = _draw_outer_boundary(work_part, outer_2d, transform, lister)
total_lines = outer_lines
for pocket_pts in pockets:
if len(pocket_pts) < 3:
continue
pocket_3d = unproject_to_3d(pocket_pts, transform)
n = len(pocket_3d)
d = math.sqrt(sum((a - b)**2 for a, b in zip(pocket_3d[0], pocket_3d[-1])))
if d < 0.001:
n -= 1
for i in range(n):
try:
_draw_line(work_part, pocket_3d[i], pocket_3d[(i+1) % n])
total_lines += 1
except Exception:
pass
lister.WriteLine(f"[import] Done: {total_lines} lines in sketch")
# Deactivate sketch
try:
# Resolve UpdateLevel enum at runtime
update_model = None
for path in ["NXOpen.Sketch.UpdateLevel.Model", "NXOpen.UpdateLevel.Model"]:
try:
parts_p = path.split(".")
obj = __import__(parts_p[0])
for attr in parts_p[1:]:
obj = getattr(obj, attr)
update_model = obj
break
except (AttributeError, ImportError):
continue
update_model, _ = _resolve_enum([
"NXOpen.Sketch.UpdateLevel.Model",
"NXOpen.UpdateLevel.Model",
])
if update_model is None:
update_model = 1 # numeric fallback
update_model = 1
sketch.Deactivate(view_false, update_model)
except Exception as exc:
lister.WriteLine(f"[import] Warning deactivating: {exc}")
lister.WriteLine(f"[import] Done: {total_lines} total line segments in sketch '{sketch_name}'")
lister.WriteLine(f"[import] Outer boundary: {len(outer_2d)} pts")
lister.WriteLine(f"[import] Pockets: {len(pockets)}")
lister.WriteLine(f"[import] Pockets: {len(pockets)}")
lister.WriteLine("\n" + "=" * 60)
lister.WriteLine(" Import complete — extrude the sketch to rib thickness")

View File

@@ -0,0 +1,19 @@
{
"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": 3.06,
"d_keep": 1.5,
"min_pocket_radius": 3.5,
"min_triangle_area": 50.0
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff