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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user