feat(adaptive-isogrid): preserve arcs as typed segments instead of polyline discretization

Schema v2.0: outer_boundary is now a list of typed segments:
  - {type: 'line', start: [x,y], end: [x,y]}
  - {type: 'arc', start: [x,y], end: [x,y], center: [x,y], radius: R, clockwise: bool}

Extract: detect arcs via UF Eval.IsArc/AskArc, output exact geometry.
Import: create NX sketch arcs (3-point) for arc segments, backward-compatible with v1.0 polylines.
This commit is contained in:
2026-02-17 01:47:36 +00:00
parent abc7d5f013
commit 612a21f561
2 changed files with 299 additions and 326 deletions

View File

@@ -5,11 +5,15 @@ Runs from the .sim file context. Navigates:
SIM → FEM → Idealized Part → find bodies with ISOGRID_SANDBOX attribute
For each sandbox body, exports `geometry_<sandbox_id>.json` containing:
- outer_boundary: 2D polyline of the sandbox outline
- inner_boundaries: 2D polylines of cutouts (reserved cylinder intersections, etc.)
- outer_boundary: list of typed segments (line or arc) preserving exact geometry
- inner_boundaries: same format for cutouts
- transform: 3D <-> 2D mapping for reimporting geometry
- thickness: from NX midsurface (if available)
Schema v2.0: segments are typed objects, not flat polylines.
Line: {"type": "line", "start": [x,y], "end": [x,y]}
Arc: {"type": "arc", "start": [x,y], "end": [x,y], "center": [x,y], "radius": R, "clockwise": bool}
Inner loops are treated as boundary constraints (edges), NOT as holes to rib around,
because hole reservations are handled by separate solid cylinders in the fixed geometry.
@@ -93,44 +97,36 @@ def unproject_to_3d(points2d: Sequence[Point2D], frame: LocalFrame) -> List[Poin
# ---------------------------------------------------------------------------
# NX edge sampling
# Edge segment types
# ---------------------------------------------------------------------------
def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) -> List[Point3D]:
"""
Sample an NX edge as a polyline.
@dataclass
class EdgeSegment:
"""A typed geometry segment — either a line or an arc."""
seg_type: str # "line" or "arc"
start_3d: Point3D
end_3d: Point3D
# Arc-specific (None for lines)
center_3d: Point3D | None = None
radius: float | None = None
Strategy (in order):
1) Linear edges -> vertices only
2) UF_EVAL sampling (robust parsing for NX Python return variants)
3) IBaseCurve.Evaluate fallback (if available)
4) UF arc analytic fallback for circular edges
5) Last resort -> vertices only
# ---------------------------------------------------------------------------
# NX edge analysis — extract type + arc parameters
# ---------------------------------------------------------------------------
def _analyze_edge(edge: Any, lister: Any = None) -> EdgeSegment:
"""
Analyze an NX edge and return a typed EdgeSegment.
For arcs: extracts center + radius from UF.
For lines: just start/end vertices.
For unknown curves: falls back to line (vertices only) with a warning.
"""
def _log(msg: str) -> None:
if lister:
lister.WriteLine(msg)
def _parse_eval_point(result: Any) -> Point3D | None:
"""Parse NX UF_EVAL/IBaseCurve return variants into a 3D point."""
# Direct NXOpen.Point3d-like object
if hasattr(result, "X") and hasattr(result, "Y") and hasattr(result, "Z"):
return (float(result.X), float(result.Y), float(result.Z))
# Flat numeric array [x,y,z,...]
if isinstance(result, (list, tuple)):
if len(result) >= 3 and all(isinstance(v, (int, float)) for v in result[:3]):
return (float(result[0]), float(result[1]), float(result[2]))
# Nested tuple patterns, e.g. (point,), (point, deriv), etc.
for item in result:
p = _parse_eval_point(item)
if p is not None:
return p
return None
# Get start and end vertices
# Get vertices
try:
v1, v2 = edge.GetVertices()
p1 = (float(v1.X), float(v1.Y), float(v1.Z))
@@ -138,237 +134,106 @@ def _sample_edge_polyline(edge: Any, chord_tol_mm: float, lister: Any = None) ->
except Exception as exc:
raise RuntimeError(f"Edge.GetVertices() failed: {exc}")
is_linear = False
is_circular = False
is_closed = (_norm(_sub(p1, p2)) < 0.001)
# Classify edge type
edge_type_str = "?"
try:
edge_type_str = str(edge.SolidEdgeType)
is_linear = "Linear" in edge_type_str
is_circular = "Circular" in edge_type_str
except Exception:
pass
# Point density driven by chord_tol_mm (tighter tol => more points)
try:
length = float(edge.GetLength())
except Exception:
length = _norm(_sub(p2, p1)) if not is_closed else 50.0
is_linear = "Linear" in edge_type_str
is_circular = "Circular" in edge_type_str
tol = max(float(chord_tol_mm), 0.5) # 0.5mm chord tolerance — good balance
n_pts = max(8, int(math.ceil(length / tol)))
if is_circular or is_closed:
n_pts = max(24, n_pts)
# Cap to avoid absurd point counts on long straight edges
n_pts = min(n_pts, 500)
# Linear edges — simple
if is_linear:
return EdgeSegment(seg_type="line", start_3d=p1, end_3d=p2)
if is_linear and not is_closed:
return [p1, p2]
_log(
f"[edge] type={edge_type_str} closed={is_closed} circular={is_circular} "
f"len={length:.3f} tol={tol:.3f} n_pts={n_pts}"
)
# 1) Primary: UF curve evaluation — try multiple API patterns
# Try UF Eval to detect arc
try:
import NXOpen
import NXOpen.UF
uf = NXOpen.UF.UFSession.GetUFSession()
eval_obj = uf.Eval
pts: List[Point3D] = []
parse_failures = 0
evaluator = None
try:
evaluator = eval_obj.Initialize2(edge.Tag)
limits = eval_obj.AskLimits(evaluator)
t0, t1 = float(limits[0]), float(limits[1])
# Try arc-specific analytical approach first
is_arc_edge = False
try:
is_arc_edge = eval_obj.IsArc(evaluator)
except Exception:
pass
if eval_obj.IsArc(evaluator):
arc_data = eval_obj.AskArc(evaluator)
if is_arc_edge:
# Get arc data and generate points analytically
try:
arc_data = eval_obj.AskArc(evaluator)
# arc_data is UFEval.Arc struct with: center, radius, etc.
# Extract what we can
_log(f"[edge] Arc data type: {type(arc_data).__name__}, attrs: {[a for a in dir(arc_data) if not a.startswith('_')]}")
# Try to access fields
center = None
radius = None
for attr in ('center', 'Center', 'arc_center'):
if hasattr(arc_data, attr):
center = getattr(arc_data, attr)
break
for attr in ('radius', 'Radius'):
if hasattr(arc_data, attr):
radius = float(getattr(arc_data, attr))
break
if center is not None and radius is not None:
_log(f"[edge] Arc: center={center}, radius={radius}, t0={t0}, t1={t1}")
except Exception as exc:
_log(f"[edge] AskArc failed: {exc}")
# Extract center and radius from arc_data
center = None
radius = None
# Use EvaluateUnitVectors for parametric sampling
# Signature: EvaluateUnitVectors(evaluator, param) → returns point + tangent + ...
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (float(i) / float(n_pts))
try:
result = eval_obj.EvaluateUnitVectors(evaluator, t)
# Parse result — could be tuple of (point, tangent, normal, binormal)
pt = _parse_eval_point(result)
if pt is not None:
pts.append(pt)
else:
parse_failures += 1
if parse_failures <= 2:
_log(f"[edge] Parse failed at t={t:.4f}, type={type(result).__name__}, repr={repr(result)[:300]}")
except Exception as exc:
parse_failures += 1
if parse_failures <= 2:
_log(f"[edge] EvaluateUnitVectors failed at t={t:.4f}: {exc}")
# Try named attributes (NXOpen struct)
for attr in ('center', 'Center', 'arc_center'):
if hasattr(arc_data, attr):
c = getattr(arc_data, attr)
if hasattr(c, 'X'):
center = (float(c.X), float(c.Y), float(c.Z))
elif isinstance(c, (list, tuple)) and len(c) >= 3:
center = (float(c[0]), float(c[1]), float(c[2]))
break
for attr in ('radius', 'Radius'):
if hasattr(arc_data, attr):
radius = float(getattr(arc_data, attr))
break
# If named attrs didn't work, try UF Curve API
if center is None or radius is None:
try:
curve_data = uf.Curve.AskArcData(edge.Tag)
if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'):
c = curve_data.arc_center
center = (float(c[0]), float(c[1]), float(c[2]))
radius = float(curve_data.radius)
except Exception:
pass
if center is not None and radius is not None and radius > 0.0:
_log(f"[edge] ARC: center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f}) "
f"r={radius:.3f}")
return EdgeSegment(
seg_type="arc", start_3d=p1, end_3d=p2,
center_3d=center, radius=radius,
)
else:
_log(f"[edge] IsArc=True but could not extract center/radius. "
f"arc_data attrs: {[a for a in dir(arc_data) if not a.startswith('_')]}")
finally:
if evaluator is not None:
try:
eval_obj.Free(evaluator)
except Exception:
pass
if len(pts) >= 2:
_log(f"[edge] sampled via EvaluateUnitVectors ({len(pts)} pts, {parse_failures} failures)")
return pts
_log(f"[edge] EvaluateUnitVectors insufficient points ({len(pts)}), falling back")
except Exception as exc:
_log(f"[edge] UF Eval failed: {exc}")
_log(f"[edge] UF arc detection failed: {exc}")
# 2) Fallback: IBaseCurve.Evaluate (signature differs by NX versions)
try:
pts: List[Point3D] = []
# Some NX APIs expose parameter limits directly on curve objects.
t0 = 0.0
t1 = 1.0
for lim_name in ("GetLimits", "AskLimits"):
lim_fn = getattr(edge, lim_name, None)
if callable(lim_fn):
try:
lim = lim_fn()
if isinstance(lim, (list, tuple)) and len(lim) >= 2:
t0, t1 = float(lim[0]), float(lim[1])
break
except Exception:
pass
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (i / n_pts)
result = edge.Evaluate(t)
p = _parse_eval_point(result)
if p is not None:
pts.append(p)
if len(pts) >= 2:
_log(f"[edge] sampled via IBaseCurve.Evaluate ({len(pts)} pts)")
return pts
_log(f"[edge] IBaseCurve.Evaluate insufficient points ({len(pts)})")
except Exception as exc:
_log(f"[edge] IBaseCurve.Evaluate failed: {exc}")
# 3) Circular analytic fallback using UF arc data
# Fallback: try UF Curve.AskArcData directly (for circular edges not caught above)
if is_circular:
try:
import NXOpen
import NXOpen.UF
uf = NXOpen.UF.UFSession.GetUFSession()
arc_data = uf.Curve.AskArcData(edge.Tag)
# Robust extraction from varying arc_data layouts
curve_data = uf.Curve.AskArcData(edge.Tag)
center = None
radius = None
start_angle = None
end_angle = None
if hasattr(arc_data, "arc_center") and hasattr(arc_data, "radius"):
c = arc_data.arc_center
if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'):
c = curve_data.arc_center
center = (float(c[0]), float(c[1]), float(c[2]))
radius = float(arc_data.radius)
start_angle = float(getattr(arc_data, "start_angle", 0.0))
end_angle = float(getattr(arc_data, "end_angle", 2.0 * math.pi))
elif isinstance(arc_data, (list, tuple)):
# Look for center candidate [x,y,z] and a scalar radius
for item in arc_data:
if center is None and isinstance(item, (list, tuple)) and len(item) >= 3:
if all(isinstance(v, (int, float)) for v in item[:3]):
center = (float(item[0]), float(item[1]), float(item[2]))
if radius is None and isinstance(item, (int, float)) and abs(float(item)) > 1e-9:
# Keep first non-zero scalar as probable radius only if still missing
radius = float(item) if radius is None else radius
nums = [float(x) for x in arc_data if isinstance(x, (int, float))]
if len(nums) >= 2:
start_angle = nums[-2]
end_angle = nums[-1]
radius = float(curve_data.radius)
if center is not None and radius is not None and radius > 0.0:
# Build local basis in edge plane from endpoints + center
r1 = _sub(p1, center)
r2 = _sub(p2, center)
if _norm(r1) < 1e-9:
r1 = (radius, 0.0, 0.0)
x_axis = _normalize(r1)
normal = _normalize(_cross(r1, r2)) if _norm(_cross(r1, r2)) > 1e-9 else (0.0, 0.0, 1.0)
y_axis = _normalize(_cross(normal, x_axis))
a0 = 0.0
a1 = math.atan2(_dot(r2, y_axis), _dot(r2, x_axis))
if is_closed and abs(a1) < 1e-9:
a1 = 2.0 * math.pi
elif a1 <= 0.0:
a1 += 2.0 * math.pi
# If UF supplied angles, prefer them when they look valid
if start_angle is not None and end_angle is not None:
da = end_angle - start_angle
if abs(da) > 1e-9:
a0, a1 = start_angle, end_angle
pts = []
for i in range(n_pts + 1):
a = a0 + (a1 - a0) * (i / n_pts)
ca, sa = math.cos(a), math.sin(a)
px = center[0] + radius * (ca * x_axis[0] + sa * y_axis[0])
py = center[1] + radius * (ca * x_axis[1] + sa * y_axis[1])
pz = center[2] + radius * (ca * x_axis[2] + sa * y_axis[2])
pts.append((px, py, pz))
if len(pts) >= 2:
_log(f"[edge] sampled via UF arc analytic ({len(pts)} pts)")
return pts
_log(f"[edge] UF arc fallback could not decode arc_data: {repr(arc_data)}")
_log(f"[edge] ARC (UF Curve fallback): r={radius:.3f}")
return EdgeSegment(
seg_type="arc", start_3d=p1, end_3d=p2,
center_3d=center, radius=radius,
)
except Exception as exc:
_log(f"[edge] UF arc fallback failed: {exc}")
_log(f"[edge] UF Curve.AskArcData fallback failed: {exc}")
_log("[edge] fallback to vertices only")
return [p1, p2]
def _close_polyline(points: List[Point3D]) -> List[Point3D]:
if not points:
return points
if _norm(_sub(points[0], points[-1])) > 1e-6:
points.append(points[0])
return points
# Unknown curve type — warn and treat as line
_log(f"[edge] WARNING: Non-line/arc edge type={edge_type_str}, treating as line (vertices only)")
return EdgeSegment(seg_type="line", start_3d=p1, end_3d=p2)
# ---------------------------------------------------------------------------
@@ -379,13 +244,12 @@ def _chain_edges_into_loops(
edges: List[Any],
lister: Any = None,
tol: float = 0.01,
chord_tol_mm: float = 1.0,
) -> List[Tuple[bool, List[Point3D]]]:
) -> List[Tuple[bool, List[EdgeSegment]]]:
"""
Chain edges into closed loops by matching vertex endpoints.
Returns list of (is_outer, points_3d) tuples.
The largest loop (by area/perimeter) is assumed to be the outer loop.
Returns list of (is_outer, segments) tuples where segments are EdgeSegment objects.
The largest loop (by perimeter) is assumed to be the outer loop.
"""
def _log(msg):
if lister:
@@ -394,30 +258,27 @@ def _chain_edges_into_loops(
if not edges:
return []
# Build edge segments as (start_pt, end_pt, edge_ref)
segments = []
# Analyze each edge into a typed segment
analyzed: List[Tuple[EdgeSegment, Any]] = [] # (segment, original_edge)
for edge in edges:
try:
v1, v2 = edge.GetVertices()
p1 = (float(v1.X), float(v1.Y), float(v1.Z))
p2 = (float(v2.X), float(v2.Y), float(v2.Z))
segments.append((p1, p2, edge))
seg = _analyze_edge(edge, lister)
analyzed.append((seg, edge))
except Exception as exc:
_log(f"[chain] Edge.GetVertices failed: {exc}")
_log(f"[chain] Edge analysis failed: {exc}")
continue
_log(f"[chain] {len(segments)} edge segments to chain")
_log(f"[chain] {len(analyzed)} edges analyzed ({sum(1 for s,_ in analyzed if s.seg_type == 'arc')} arcs, "
f"{sum(1 for s,_ in analyzed if s.seg_type == 'line')} lines)")
# Chain into loops
used = [False] * len(segments)
loops_points: List[List[Point3D]] = []
loops_edges: List[List[Any]] = []
used = [False] * len(analyzed)
loops: List[List[EdgeSegment]] = []
def pts_match(a: Point3D, b: Point3D) -> bool:
return _norm(_sub(a, b)) < tol
while True:
# Find first unused segment
start_idx = None
for i, u in enumerate(used):
if not u:
@@ -426,78 +287,82 @@ def _chain_edges_into_loops(
if start_idx is None:
break
# Start a new loop
chain_pts: List[Point3D] = []
chain_edges: List[Any] = []
chain: List[EdgeSegment] = []
used[start_idx] = True
p_start, p_end, edge = segments[start_idx]
seg, _ = analyzed[start_idx]
chain.append(seg)
# Sample this edge
edge_pts = _sample_edge_polyline(edge, chord_tol_mm=chord_tol_mm, lister=lister)
chain_pts.extend(edge_pts)
chain_edges.append(edge)
current_end = seg.end_3d
loop_start = seg.start_3d
current_end = p_end
loop_start = p_start
# Follow the chain
max_iters = len(segments) + 1
max_iters = len(analyzed) + 1
for _ in range(max_iters):
if pts_match(current_end, loop_start) and len(chain_edges) > 1:
# Loop closed
if pts_match(current_end, loop_start) and len(chain) > 1:
break
# Find next segment connecting to current_end
found = False
for i, (s1, s2, e) in enumerate(segments):
for i, (s, _e) in enumerate(analyzed):
if used[i]:
continue
if pts_match(current_end, s1):
if pts_match(current_end, s.start_3d):
used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister)
chain_pts.extend(edge_pts[1:]) # skip duplicate junction point
chain_edges.append(e)
current_end = s2
chain.append(s)
current_end = s.end_3d
found = True
break
elif pts_match(current_end, s2):
# Edge is reversed — traverse backward
elif pts_match(current_end, s.end_3d):
# Reversed edge — swap start/end
used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister)
edge_pts.reverse()
chain_pts.extend(edge_pts[1:])
chain_edges.append(e)
current_end = s1
reversed_seg = EdgeSegment(
seg_type=s.seg_type,
start_3d=s.end_3d,
end_3d=s.start_3d,
center_3d=s.center_3d,
radius=s.radius,
)
chain.append(reversed_seg)
current_end = s.start_3d
found = True
break
if not found:
_log(f"[chain] Warning: could not continue chain at {current_end}")
_log(f"[chain] Warning: could not continue chain at "
f"({current_end[0]:.3f}, {current_end[1]:.3f}, {current_end[2]:.3f})")
break
loops_points.append(chain_pts)
loops_edges.append(chain_edges)
loops.append(chain)
_log(f"[chain] Built {len(loops_points)} loop(s)")
_log(f"[chain] Built {len(loops)} loop(s)")
if not loops_points:
if not loops:
return []
# Determine which loop is outer (largest perimeter)
def _perimeter(pts: List[Point3D]) -> float:
# Determine outer loop by perimeter
def _loop_perimeter(segs: List[EdgeSegment]) -> float:
total = 0.0
for i in range(len(pts) - 1):
total += _norm(_sub(pts[i + 1], pts[i]))
for s in segs:
if s.seg_type == "arc" and s.center_3d is not None and s.radius is not None:
# Arc length = radius * angle
r1 = _sub(s.start_3d, s.center_3d)
r2 = _sub(s.end_3d, s.center_3d)
cos_a = max(-1.0, min(1.0, _dot(_normalize(r1), _normalize(r2))))
angle = math.acos(cos_a)
total += s.radius * angle
else:
total += _norm(_sub(s.end_3d, s.start_3d))
return total
perimeters = [_perimeter(pts) for pts in loops_points]
perimeters = [_loop_perimeter(segs) for segs in loops]
outer_idx = perimeters.index(max(perimeters))
result: List[Tuple[bool, List[Point3D]]] = []
for i, pts in enumerate(loops_points):
result: List[Tuple[bool, List[EdgeSegment]]] = []
for i, segs in enumerate(loops):
is_outer = (i == outer_idx)
result.append((is_outer, pts))
_log(f"[chain] loop {i}: {len(pts)} pts, perimeter={perimeters[i]:.1f} mm {'(OUTER)' if is_outer else '(inner)'}")
n_arcs = sum(1 for s in segs if s.seg_type == "arc")
n_lines = sum(1 for s in segs if s.seg_type == "line")
result.append((is_outer, segs))
_log(f"[chain] loop {i}: {len(segs)} segments ({n_lines} lines, {n_arcs} arcs), "
f"perimeter={perimeters[i]:.1f} mm {'(OUTER)' if is_outer else '(inner)'}")
return result
@@ -744,44 +609,78 @@ def find_sandbox_bodies(
# Core extraction
# ---------------------------------------------------------------------------
def _project_point_2d(pt3d: Point3D, frame: LocalFrame) -> Point2D:
"""Project a single 3D point to local 2D."""
v = _sub(pt3d, frame.origin)
return (_dot(v, frame.x_axis), _dot(v, frame.y_axis))
def _segments_to_json(segments: List[EdgeSegment], frame: LocalFrame) -> List[Dict[str, Any]]:
"""Convert a list of EdgeSegments to JSON-serializable dicts in 2D."""
result = []
for seg in segments:
start_2d = _project_point_2d(seg.start_3d, frame)
end_2d = _project_point_2d(seg.end_3d, frame)
entry: Dict[str, Any] = {
"type": seg.seg_type,
"start": [round(start_2d[0], 6), round(start_2d[1], 6)],
"end": [round(end_2d[0], 6), round(end_2d[1], 6)],
}
if seg.seg_type == "arc" and seg.center_3d is not None:
center_2d = _project_point_2d(seg.center_3d, frame)
entry["center"] = [round(center_2d[0], 6), round(center_2d[1], 6)]
entry["radius"] = round(seg.radius, 6)
# Determine clockwise/ccw: cross product of (start-center) × (end-center)
# projected onto the face normal
r1 = _sub(seg.start_3d, seg.center_3d)
r2 = _sub(seg.end_3d, seg.center_3d)
cross = _cross(r1, r2)
dot_normal = _dot(cross, frame.normal)
entry["clockwise"] = (dot_normal < 0)
result.append(entry)
return result
def extract_sandbox_geometry(
face: Any,
body: Any,
sandbox_id: str,
lister: Any,
chord_tol_mm: float = 1.0,
) -> Dict[str, Any]:
"""
Extract a sandbox face into a JSON-serializable dict.
Schema v2.0: typed segments (line/arc) instead of polylines.
Inner loops are boundary constraints (reserved geometry edges), not holes.
"""
frame = _face_local_frame(face, lister)
outer_2d: List[List[float]] = []
outer_segments: List[Dict[str, Any]] = []
inner_boundaries: List[Dict[str, Any]] = []
# Get all edges on the face and chain them into loops
all_edges = list(face.GetEdges())
lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(all_edges)} edges on face")
loops = _chain_edges_into_loops(all_edges, lister, chord_tol_mm=chord_tol_mm)
loops = _chain_edges_into_loops(all_edges, lister)
lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) built")
for loop_index, (is_outer, loop_pts3d) in enumerate(loops):
loop_pts3d = _close_polyline(loop_pts3d)
loop_pts2d = project_to_2d(loop_pts3d, frame)
for loop_index, (is_outer, loop_segs) in enumerate(loops):
seg_json = _segments_to_json(loop_segs, frame)
n_arcs = sum(1 for s in seg_json if s["type"] == "arc")
n_lines = sum(1 for s in seg_json if s["type"] == "line")
if is_outer:
outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d]
lister.WriteLine(f"[extract_sandbox] outer loop: {len(outer_2d)} pts")
outer_segments = seg_json
lister.WriteLine(f"[extract_sandbox] outer loop: {len(seg_json)} segments "
f"({n_lines} lines, {n_arcs} arcs)")
else:
boundary = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d]
inner_boundaries.append({
"index": len(inner_boundaries),
"boundary": boundary,
"num_points": len(boundary),
"segments": seg_json,
"num_segments": len(seg_json),
})
lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: {len(boundary)} pts")
lister.WriteLine(f"[extract_sandbox] inner loop {len(inner_boundaries)}: "
f"{len(seg_json)} segments ({n_lines} lines, {n_arcs} arcs)")
# Try thickness
thickness = None
@@ -791,10 +690,10 @@ def extract_sandbox_geometry(
pass
return {
"schema_version": "1.0",
"schema_version": "2.0",
"units": "mm",
"sandbox_id": sandbox_id,
"outer_boundary": outer_2d,
"outer_boundary": outer_segments,
"inner_boundaries": inner_boundaries,
"num_inner_boundaries": len(inner_boundaries),
"thickness": thickness,

View File

@@ -351,31 +351,105 @@ def _arc_midpoint_2d(arc):
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
def _draw_segment(part, seg, transform, lister):
"""
Draw a single typed segment (line or arc) in the active sketch.
Supports schema v2.0 segment dicts with "type", "start", "end", "center", "radius".
Returns ("line"|"arc", success_bool).
"""
seg_type = seg.get("type", "line")
start_3d = unproject_point_to_3d(seg["start"], transform)
end_3d = unproject_point_to_3d(seg["end"], transform)
# 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]
if seg_type == "arc" and "center" in seg:
center_3d = unproject_point_to_3d(seg["center"], transform)
radius = seg["radius"]
# Compute midpoint of arc for 3-point arc creation
cx, cy = seg["center"]
sx, sy = seg["start"]
ex, ey = seg["end"]
# Angles from center
sa = math.atan2(sy - cy, sx - cx)
ea = math.atan2(ey - cy, ex - cx)
clockwise = seg.get("clockwise", False)
# Compute mid-angle
if clockwise:
# CW: sa → ea going clockwise (decreasing angle)
da = sa - ea
if da <= 0:
da += 2 * math.pi
mid_angle = sa - da / 2.0
else:
# CCW: sa → ea going counter-clockwise (increasing angle)
da = ea - sa
if da <= 0:
da += 2 * math.pi
mid_angle = sa + da / 2.0
mid_2d = [cx + radius * math.cos(mid_angle), cy + radius * math.sin(mid_angle)]
mid_3d = unproject_point_to_3d(mid_2d, transform)
try:
_draw_line(part, p1, p2)
count += 1
_draw_arc_3pt(part, start_3d, mid_3d, end_3d)
return ("arc", True)
except Exception as exc:
if count == 0:
lister.WriteLine(f"[import] ERROR: First line failed: {exc}")
return 0
return count
lister.WriteLine(f"[import] Arc failed, fallback to line: {exc}")
# Fallback: draw as line
try:
_draw_line(part, start_3d, end_3d)
return ("line", True)
except Exception:
return ("line", False)
else:
try:
_draw_line(part, start_3d, end_3d)
return ("line", True)
except Exception:
return ("line", False)
def _draw_outer_boundary(part, outer_boundary, transform, lister):
"""
Draw the outer boundary. Handles both:
- Schema v2.0: list of typed segment dicts
- Schema v1.0: list of [x,y] points (polyline)
Returns (num_lines, num_arcs).
"""
if not outer_boundary:
return 0, 0
# Detect schema version: v2.0 segments are dicts with "type" key
if isinstance(outer_boundary[0], dict) and "type" in outer_boundary[0]:
# Schema v2.0 — typed segments
n_lines = 0
n_arcs = 0
for seg in outer_boundary:
kind, ok = _draw_segment(part, seg, transform, lister)
if ok:
if kind == "arc":
n_arcs += 1
else:
n_lines += 1
return n_lines, n_arcs
else:
# Schema v1.0 — flat polyline points
outer_3d = unproject_to_3d(outer_boundary, transform)
n = len(outer_3d)
if n < 2:
return 0, 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):
try:
_draw_line(part, outer_3d[i], outer_3d[(i + 1) % n])
count += 1
except Exception as exc:
if count == 0:
lister.WriteLine(f"[import] ERROR: First line failed: {exc}")
return 0, 0
return count, 0
def _draw_structured_pocket(part, pocket, transform, lister):
@@ -812,13 +886,13 @@ def main():
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)")
# Outer boundary (handles both v1.0 polyline and v2.0 typed segments)
outer_lines, outer_arcs = _draw_outer_boundary(work_part, outer_2d, transform, lister)
lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines + {outer_arcs} arcs")
# Pockets
total_lines = outer_lines
total_arcs = 0
total_arcs = outer_arcs
for idx, pocket in enumerate(pockets):
nl, na = _draw_structured_pocket(work_part, pocket, transform, lister)
total_lines += nl
@@ -831,7 +905,7 @@ def main():
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)
outer_lines, outer_arcs = _draw_outer_boundary(work_part, outer_2d, transform, lister)
total_lines = outer_lines
for pocket_pts in pockets:
if len(pocket_pts) < 3: