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
|
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
|
arc_data = eval_obj.AskArc(evaluator)
|
||||||
try:
|
|
||||||
is_arc_edge = eval_obj.IsArc(evaluator)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if is_arc_edge:
|
# Extract center and radius from arc_data
|
||||||
# Get arc data and generate points analytically
|
center = None
|
||||||
try:
|
radius = None
|
||||||
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}")
|
|
||||||
|
|
||||||
# Use EvaluateUnitVectors for parametric sampling
|
# Try named attributes (NXOpen struct)
|
||||||
# Signature: EvaluateUnitVectors(evaluator, param) → returns point + tangent + ...
|
for attr in ('center', 'Center', 'arc_center'):
|
||||||
for i in range(n_pts + 1):
|
if hasattr(arc_data, attr):
|
||||||
t = t0 + (t1 - t0) * (float(i) / float(n_pts))
|
c = getattr(arc_data, attr)
|
||||||
try:
|
if hasattr(c, 'X'):
|
||||||
result = eval_obj.EvaluateUnitVectors(evaluator, t)
|
center = (float(c.X), float(c.Y), float(c.Z))
|
||||||
# Parse result — could be tuple of (point, tangent, normal, binormal)
|
elif isinstance(c, (list, tuple)) and len(c) >= 3:
|
||||||
pt = _parse_eval_point(result)
|
center = (float(c[0]), float(c[1]), float(c[2]))
|
||||||
if pt is not None:
|
break
|
||||||
pts.append(pt)
|
for attr in ('radius', 'Radius'):
|
||||||
else:
|
if hasattr(arc_data, attr):
|
||||||
parse_failures += 1
|
radius = float(getattr(arc_data, attr))
|
||||||
if parse_failures <= 2:
|
break
|
||||||
_log(f"[edge] Parse failed at t={t:.4f}, type={type(result).__name__}, repr={repr(result)[:300]}")
|
|
||||||
except Exception as exc:
|
# If named attrs didn't work, try UF Curve API
|
||||||
parse_failures += 1
|
if center is None or radius is None:
|
||||||
if parse_failures <= 2:
|
try:
|
||||||
_log(f"[edge] EvaluateUnitVectors failed at t={t:.4f}: {exc}")
|
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:
|
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,
|
||||||
|
|||||||
@@ -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.
|
||||||
n = len(outer_3d)
|
Supports schema v2.0 segment dicts with "type", "start", "end", "center", "radius".
|
||||||
if n < 2:
|
Returns ("line"|"arc", success_bool).
|
||||||
return 0
|
"""
|
||||||
|
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 seg_type == "arc" and "center" in seg:
|
||||||
if n >= 3:
|
center_3d = unproject_point_to_3d(seg["center"], transform)
|
||||||
d = math.sqrt(sum((a - b) ** 2 for a, b in zip(outer_3d[0], outer_3d[-1])))
|
radius = seg["radius"]
|
||||||
if d < 0.001:
|
# Compute midpoint of arc for 3-point arc creation
|
||||||
n -= 1
|
cx, cy = seg["center"]
|
||||||
|
sx, sy = seg["start"]
|
||||||
count = 0
|
ex, ey = seg["end"]
|
||||||
for i in range(n):
|
# Angles from center
|
||||||
p1 = outer_3d[i]
|
sa = math.atan2(sy - cy, sx - cx)
|
||||||
p2 = outer_3d[(i + 1) % n]
|
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:
|
try:
|
||||||
_draw_line(part, p1, p2)
|
_draw_arc_3pt(part, start_3d, mid_3d, end_3d)
|
||||||
count += 1
|
return ("arc", True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if count == 0:
|
lister.WriteLine(f"[import] Arc failed, fallback to line: {exc}")
|
||||||
lister.WriteLine(f"[import] ERROR: First line failed: {exc}")
|
# Fallback: draw as line
|
||||||
return 0
|
try:
|
||||||
return count
|
_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):
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user