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

@@ -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")