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 SIM → FEM → Idealized Part → find bodies with ISOGRID_SANDBOX attribute
For each sandbox body, exports `geometry_<sandbox_id>.json` containing: For each sandbox body, exports `geometry_<sandbox_id>.json` containing:
- outer_boundary: 2D polyline of the sandbox outline - outer_boundary: list of typed segments (line or arc) preserving exact geometry
- inner_boundaries: 2D polylines of cutouts (reserved cylinder intersections, etc.) - inner_boundaries: same format for cutouts
- transform: 3D <-> 2D mapping for reimporting geometry - transform: 3D <-> 2D mapping for reimporting geometry
- thickness: from NX midsurface (if available) - 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, 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. 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]: @dataclass
""" class EdgeSegment:
Sample an NX edge as a polyline. """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) # NX edge analysis — extract type + arc parameters
3) IBaseCurve.Evaluate fallback (if available) # ---------------------------------------------------------------------------
4) UF arc analytic fallback for circular edges
5) Last resort -> vertices only 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: def _log(msg: str) -> None:
if lister: if lister:
lister.WriteLine(msg) lister.WriteLine(msg)
def _parse_eval_point(result: Any) -> Point3D | None: # Get vertices
"""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
try: try:
v1, v2 = edge.GetVertices() v1, v2 = edge.GetVertices()
p1 = (float(v1.X), float(v1.Y), float(v1.Z)) 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: except Exception as exc:
raise RuntimeError(f"Edge.GetVertices() failed: {exc}") raise RuntimeError(f"Edge.GetVertices() failed: {exc}")
is_linear = False # Classify edge type
is_circular = False
is_closed = (_norm(_sub(p1, p2)) < 0.001)
edge_type_str = "?" edge_type_str = "?"
try: try:
edge_type_str = str(edge.SolidEdgeType) edge_type_str = str(edge.SolidEdgeType)
is_linear = "Linear" in edge_type_str
is_circular = "Circular" in edge_type_str
except Exception: except Exception:
pass pass
# Point density driven by chord_tol_mm (tighter tol => more points) is_linear = "Linear" in edge_type_str
try: is_circular = "Circular" in edge_type_str
length = float(edge.GetLength())
except Exception:
length = _norm(_sub(p2, p1)) if not is_closed else 50.0
tol = max(float(chord_tol_mm), 0.5) # 0.5mm chord tolerance — good balance # Linear edges — simple
n_pts = max(8, int(math.ceil(length / tol))) if is_linear:
if is_circular or is_closed: return EdgeSegment(seg_type="line", start_3d=p1, end_3d=p2)
n_pts = max(24, n_pts)
# Cap to avoid absurd point counts on long straight edges
n_pts = min(n_pts, 500)
if is_linear and not is_closed: # Try UF Eval to detect arc
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: try:
import NXOpen import NXOpen
import NXOpen.UF import NXOpen.UF
uf = NXOpen.UF.UFSession.GetUFSession() uf = NXOpen.UF.UFSession.GetUFSession()
eval_obj = uf.Eval eval_obj = uf.Eval
pts: List[Point3D] = [] evaluator = None
parse_failures = 0
try: try:
evaluator = eval_obj.Initialize2(edge.Tag) 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 if eval_obj.IsArc(evaluator):
is_arc_edge = False
try:
is_arc_edge = eval_obj.IsArc(evaluator)
except Exception:
pass
if is_arc_edge:
# Get arc data and generate points analytically
try:
arc_data = eval_obj.AskArc(evaluator) arc_data = eval_obj.AskArc(evaluator)
# arc_data is UFEval.Arc struct with: center, radius, etc.
# Extract what we can # Extract center and radius from arc_data
_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 center = None
radius = None radius = None
# Try named attributes (NXOpen struct)
for attr in ('center', 'Center', 'arc_center'): for attr in ('center', 'Center', 'arc_center'):
if hasattr(arc_data, attr): if hasattr(arc_data, attr):
center = getattr(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 break
for attr in ('radius', 'Radius'): for attr in ('radius', 'Radius'):
if hasattr(arc_data, attr): if hasattr(arc_data, attr):
radius = float(getattr(arc_data, attr)) radius = float(getattr(arc_data, attr))
break 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}")
# Use EvaluateUnitVectors for parametric sampling # If named attrs didn't work, try UF Curve API
# Signature: EvaluateUnitVectors(evaluator, param) → returns point + tangent + ... if center is None or radius is None:
for i in range(n_pts + 1):
t = t0 + (t1 - t0) * (float(i) / float(n_pts))
try: try:
result = eval_obj.EvaluateUnitVectors(evaluator, t) curve_data = uf.Curve.AskArcData(edge.Tag)
# Parse result — could be tuple of (point, tangent, normal, binormal) if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'):
pt = _parse_eval_point(result) c = curve_data.arc_center
if pt is not None: center = (float(c[0]), float(c[1]), float(c[2]))
pts.append(pt) 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: else:
parse_failures += 1 _log(f"[edge] IsArc=True but could not extract center/radius. "
if parse_failures <= 2: f"arc_data attrs: {[a for a in dir(arc_data) if not a.startswith('_')]}")
_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}")
finally: finally:
if evaluator is not None: if evaluator is not None:
try: try:
eval_obj.Free(evaluator) eval_obj.Free(evaluator)
except Exception: except Exception:
pass 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: 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) # Fallback: try UF Curve.AskArcData directly (for circular edges not caught above)
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
if is_circular: if is_circular:
try: try:
import NXOpen import NXOpen
import NXOpen.UF import NXOpen.UF
uf = NXOpen.UF.UFSession.GetUFSession() uf = NXOpen.UF.UFSession.GetUFSession()
arc_data = uf.Curve.AskArcData(edge.Tag) curve_data = uf.Curve.AskArcData(edge.Tag)
# Robust extraction from varying arc_data layouts
center = None center = None
radius = None radius = None
start_angle = None if hasattr(curve_data, 'arc_center') and hasattr(curve_data, 'radius'):
end_angle = None c = curve_data.arc_center
if hasattr(arc_data, "arc_center") and hasattr(arc_data, "radius"):
c = arc_data.arc_center
center = (float(c[0]), float(c[1]), float(c[2])) center = (float(c[0]), float(c[1]), float(c[2]))
radius = float(arc_data.radius) radius = float(curve_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]
if center is not None and radius is not None and radius > 0.0: if center is not None and radius is not None and radius > 0.0:
# Build local basis in edge plane from endpoints + center _log(f"[edge] ARC (UF Curve fallback): r={radius:.3f}")
r1 = _sub(p1, center) return EdgeSegment(
r2 = _sub(p2, center) seg_type="arc", start_3d=p1, end_3d=p2,
if _norm(r1) < 1e-9: center_3d=center, radius=radius,
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)}")
except Exception as exc: 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") # Unknown curve type — warn and treat as line
return [p1, p2] _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)
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -379,13 +244,12 @@ def _chain_edges_into_loops(
edges: List[Any], edges: List[Any],
lister: Any = None, lister: Any = None,
tol: float = 0.01, tol: float = 0.01,
chord_tol_mm: float = 1.0, ) -> List[Tuple[bool, List[EdgeSegment]]]:
) -> List[Tuple[bool, List[Point3D]]]:
""" """
Chain edges into closed loops by matching vertex endpoints. Chain edges into closed loops by matching vertex endpoints.
Returns list of (is_outer, points_3d) tuples. Returns list of (is_outer, segments) tuples where segments are EdgeSegment objects.
The largest loop (by area/perimeter) is assumed to be the outer loop. The largest loop (by perimeter) is assumed to be the outer loop.
""" """
def _log(msg): def _log(msg):
if lister: if lister:
@@ -394,30 +258,27 @@ def _chain_edges_into_loops(
if not edges: if not edges:
return [] return []
# Build edge segments as (start_pt, end_pt, edge_ref) # Analyze each edge into a typed segment
segments = [] analyzed: List[Tuple[EdgeSegment, Any]] = [] # (segment, original_edge)
for edge in edges: for edge in edges:
try: try:
v1, v2 = edge.GetVertices() seg = _analyze_edge(edge, lister)
p1 = (float(v1.X), float(v1.Y), float(v1.Z)) analyzed.append((seg, edge))
p2 = (float(v2.X), float(v2.Y), float(v2.Z))
segments.append((p1, p2, edge))
except Exception as exc: except Exception as exc:
_log(f"[chain] Edge.GetVertices failed: {exc}") _log(f"[chain] Edge analysis failed: {exc}")
continue 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 # Chain into loops
used = [False] * len(segments) used = [False] * len(analyzed)
loops_points: List[List[Point3D]] = [] loops: List[List[EdgeSegment]] = []
loops_edges: List[List[Any]] = []
def pts_match(a: Point3D, b: Point3D) -> bool: def pts_match(a: Point3D, b: Point3D) -> bool:
return _norm(_sub(a, b)) < tol return _norm(_sub(a, b)) < tol
while True: while True:
# Find first unused segment
start_idx = None start_idx = None
for i, u in enumerate(used): for i, u in enumerate(used):
if not u: if not u:
@@ -426,78 +287,82 @@ def _chain_edges_into_loops(
if start_idx is None: if start_idx is None:
break break
# Start a new loop chain: List[EdgeSegment] = []
chain_pts: List[Point3D] = []
chain_edges: List[Any] = []
used[start_idx] = True used[start_idx] = True
p_start, p_end, edge = segments[start_idx] seg, _ = analyzed[start_idx]
chain.append(seg)
# Sample this edge current_end = seg.end_3d
edge_pts = _sample_edge_polyline(edge, chord_tol_mm=chord_tol_mm, lister=lister) loop_start = seg.start_3d
chain_pts.extend(edge_pts)
chain_edges.append(edge)
current_end = p_end max_iters = len(analyzed) + 1
loop_start = p_start
# Follow the chain
max_iters = len(segments) + 1
for _ in range(max_iters): for _ in range(max_iters):
if pts_match(current_end, loop_start) and len(chain_edges) > 1: if pts_match(current_end, loop_start) and len(chain) > 1:
# Loop closed
break break
# Find next segment connecting to current_end
found = False found = False
for i, (s1, s2, e) in enumerate(segments): for i, (s, _e) in enumerate(analyzed):
if used[i]: if used[i]:
continue continue
if pts_match(current_end, s1): if pts_match(current_end, s.start_3d):
used[i] = True used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister) chain.append(s)
chain_pts.extend(edge_pts[1:]) # skip duplicate junction point current_end = s.end_3d
chain_edges.append(e)
current_end = s2
found = True found = True
break break
elif pts_match(current_end, s2): elif pts_match(current_end, s.end_3d):
# Edge is reversed — traverse backward # Reversed edge — swap start/end
used[i] = True used[i] = True
edge_pts = _sample_edge_polyline(e, chord_tol_mm=chord_tol_mm, lister=lister) reversed_seg = EdgeSegment(
edge_pts.reverse() seg_type=s.seg_type,
chain_pts.extend(edge_pts[1:]) start_3d=s.end_3d,
chain_edges.append(e) end_3d=s.start_3d,
current_end = s1 center_3d=s.center_3d,
radius=s.radius,
)
chain.append(reversed_seg)
current_end = s.start_3d
found = True found = True
break break
if not found: 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 break
loops_points.append(chain_pts) loops.append(chain)
loops_edges.append(chain_edges)
_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 [] return []
# Determine which loop is outer (largest perimeter) # Determine outer loop by perimeter
def _perimeter(pts: List[Point3D]) -> float: def _loop_perimeter(segs: List[EdgeSegment]) -> float:
total = 0.0 total = 0.0
for i in range(len(pts) - 1): for s in segs:
total += _norm(_sub(pts[i + 1], pts[i])) 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 return total
perimeters = [_perimeter(pts) for pts in loops_points] perimeters = [_loop_perimeter(segs) for segs in loops]
outer_idx = perimeters.index(max(perimeters)) outer_idx = perimeters.index(max(perimeters))
result: List[Tuple[bool, List[Point3D]]] = [] result: List[Tuple[bool, List[EdgeSegment]]] = []
for i, pts in enumerate(loops_points): for i, segs in enumerate(loops):
is_outer = (i == outer_idx) is_outer = (i == outer_idx)
result.append((is_outer, pts)) n_arcs = sum(1 for s in segs if s.seg_type == "arc")
_log(f"[chain] loop {i}: {len(pts)} pts, perimeter={perimeters[i]:.1f} mm {'(OUTER)' if is_outer else '(inner)'}") 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 return result
@@ -744,44 +609,78 @@ def find_sandbox_bodies(
# Core extraction # 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( def extract_sandbox_geometry(
face: Any, face: Any,
body: Any, body: Any,
sandbox_id: str, sandbox_id: str,
lister: Any, lister: Any,
chord_tol_mm: float = 1.0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Extract a sandbox face into a JSON-serializable dict. 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. Inner loops are boundary constraints (reserved geometry edges), not holes.
""" """
frame = _face_local_frame(face, lister) frame = _face_local_frame(face, lister)
outer_2d: List[List[float]] = [] outer_segments: List[Dict[str, Any]] = []
inner_boundaries: List[Dict[str, Any]] = [] inner_boundaries: List[Dict[str, Any]] = []
# Get all edges on the face and chain them into loops # Get all edges on the face and chain them into loops
all_edges = list(face.GetEdges()) all_edges = list(face.GetEdges())
lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(all_edges)} edges on face") 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") lister.WriteLine(f"[extract_sandbox] {sandbox_id}: {len(loops)} loop(s) built")
for loop_index, (is_outer, loop_pts3d) in enumerate(loops): for loop_index, (is_outer, loop_segs) in enumerate(loops):
loop_pts3d = _close_polyline(loop_pts3d) seg_json = _segments_to_json(loop_segs, frame)
loop_pts2d = project_to_2d(loop_pts3d, 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: if is_outer:
outer_2d = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d] outer_segments = seg_json
lister.WriteLine(f"[extract_sandbox] outer loop: {len(outer_2d)} pts") lister.WriteLine(f"[extract_sandbox] outer loop: {len(seg_json)} segments "
f"({n_lines} lines, {n_arcs} arcs)")
else: else:
boundary = [[round(x, 6), round(y, 6)] for x, y in loop_pts2d]
inner_boundaries.append({ inner_boundaries.append({
"index": len(inner_boundaries), "index": len(inner_boundaries),
"boundary": boundary, "segments": seg_json,
"num_points": len(boundary), "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 # Try thickness
thickness = None thickness = None
@@ -791,10 +690,10 @@ def extract_sandbox_geometry(
pass pass
return { return {
"schema_version": "1.0", "schema_version": "2.0",
"units": "mm", "units": "mm",
"sandbox_id": sandbox_id, "sandbox_id": sandbox_id,
"outer_boundary": outer_2d, "outer_boundary": outer_segments,
"inner_boundaries": inner_boundaries, "inner_boundaries": inner_boundaries,
"num_inner_boundaries": len(inner_boundaries), "num_inner_boundaries": len(inner_boundaries),
"thickness": thickness, "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)] return [cx + r * math.cos(mid_angle), cy + r * math.sin(mid_angle)]
def _draw_outer_boundary(part, outer_2d, transform, lister): def _draw_segment(part, seg, transform, lister):
"""Draw the outer boundary as a closed polyline.""" """
outer_3d = unproject_to_3d(outer_2d, transform) 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)
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_arc_3pt(part, start_3d, mid_3d, end_3d)
return ("arc", True)
except Exception as exc:
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) n = len(outer_3d)
if n < 2: if n < 2:
return 0 return 0, 0
# Strip closing duplicate # Strip closing duplicate
if n >= 3: if n >= 3:
d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1]))) d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1])))
if d < 0.001: if d < 0.001:
n -= 1 n -= 1
count = 0 count = 0
for i in range(n): for i in range(n):
p1 = outer_3d[i]
p2 = outer_3d[(i + 1) % n]
try: try:
_draw_line(part, p1, p2) _draw_line(part, outer_3d[i], outer_3d[(i + 1) % n])
count += 1 count += 1
except Exception as exc: except Exception as exc:
if count == 0: if count == 0:
lister.WriteLine(f"[import] ERROR: First line failed: {exc}") lister.WriteLine(f"[import] ERROR: First line failed: {exc}")
return 0 return 0, 0
return count return count, 0
def _draw_structured_pocket(part, pocket, transform, lister): def _draw_structured_pocket(part, pocket, transform, lister):
@@ -812,13 +886,13 @@ def main():
if is_structured: if is_structured:
lister.WriteLine(f"[import] Structured format: {len(pockets)} pockets + outer boundary") lister.WriteLine(f"[import] Structured format: {len(pockets)} pockets + outer boundary")
# Outer boundary # Outer boundary (handles both v1.0 polyline and v2.0 typed segments)
outer_lines = _draw_outer_boundary(work_part, outer_2d, transform, lister) outer_lines, outer_arcs = _draw_outer_boundary(work_part, outer_2d, transform, lister)
lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines ({len(outer_2d)} pts)") lister.WriteLine(f"[import] Outer boundary: {outer_lines} lines + {outer_arcs} arcs")
# Pockets # Pockets
total_lines = outer_lines total_lines = outer_lines
total_arcs = 0 total_arcs = outer_arcs
for idx, pocket in enumerate(pockets): for idx, pocket in enumerate(pockets):
nl, na = _draw_structured_pocket(work_part, pocket, transform, lister) nl, na = _draw_structured_pocket(work_part, pocket, transform, lister)
total_lines += nl total_lines += nl
@@ -831,7 +905,7 @@ def main():
else: else:
# Legacy format: pockets are point lists # Legacy format: pockets are point lists
lister.WriteLine(f"[import] Legacy format: {len(pockets)} pocket polylines") 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 total_lines = outer_lines
for pocket_pts in pockets: for pocket_pts in pockets:
if len(pocket_pts) < 3: if len(pocket_pts) < 3: