1394 lines
73 KiB
Python
1394 lines
73 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert SolidWorks ``solidworks.cad_evidence.v2`` records to CDSL v1.1.
|
|
|
|
The converter deliberately produces semantic CDSL, not executable build123d
|
|
input. It preserves feature records which the current runtime cannot execute
|
|
yet, and records the evidence or STEP-derived references needed by a future
|
|
engine to implement them without re-reading the original SolidWorks model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ENGINE_ROOT = ROOT / "backend" / "engine"
|
|
if str(ENGINE_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ENGINE_ROOT))
|
|
|
|
from cdsl_engine.semantic_validation import validate_semantic_cdsl # noqa: E402
|
|
|
|
|
|
METERS_TO_MM = 1000.0
|
|
EPSILON_MM = 1e-4
|
|
EVIDENCE_V2_SUFFIX = ".solidworks_evidence_v2.json"
|
|
SUPPORTED_ATOMS = {
|
|
"extrude_add_blind",
|
|
"extrude_add_two_sided",
|
|
"extrude_cut_blind",
|
|
"revolve_add",
|
|
"revolve_cut",
|
|
"hole_blind",
|
|
"hole_countersink",
|
|
"hole_counterbore",
|
|
}
|
|
SKETCH_TYPES = {"ProfileFeature", "OriginProfileFeature", "3DProfileFeature"}
|
|
MODELING_TYPES = {
|
|
"Boss", "Extrusion", "Cut", "Revolution", "RevCut", "HoleWzd", "Fillet",
|
|
"Chamfer", "LPattern", "MirrorPattern", "RefPlane", "RefAxis",
|
|
}
|
|
END_CONDITIONS = {
|
|
0: "blind", 1: "through_all", 2: "through_all_both", 3: "up_to_vertex",
|
|
4: "up_to_surface", 5: "offset_from_surface", 6: "through_all_and_blind",
|
|
7: "up_to_body", 8: "mid_plane", 9: "through_next",
|
|
}
|
|
|
|
|
|
def _number(value: Any, default: float = 0.0) -> float:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return number if math.isfinite(number) else default
|
|
|
|
|
|
def _mm(value: Any) -> float:
|
|
return round(_number(value) * METERS_TO_MM, 9)
|
|
|
|
|
|
def _point2(value: Any) -> list[float] | None:
|
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
|
return None
|
|
return [_mm(value[0]), _mm(value[1])]
|
|
|
|
|
|
def _point3(value: Any) -> list[float] | None:
|
|
if not isinstance(value, (list, tuple)) or len(value) < 3:
|
|
return None
|
|
return [_mm(value[0]), _mm(value[1]), _mm(value[2])]
|
|
|
|
|
|
def _safe_id(prefix: str, number: int) -> str:
|
|
return f"{prefix}_{number:03d}"
|
|
|
|
|
|
def _identity_key(value: dict[str, Any]) -> str | None:
|
|
if not isinstance(value, dict):
|
|
return None
|
|
return str(value.get("stable_id") or value.get("persist_reference_sha256") or "") or None
|
|
|
|
|
|
def _record_values(feature: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""Return typed properties/methods regardless of direct or history capture."""
|
|
history = feature.get("history_definition") or {}
|
|
props = ((history.get("definition_properties") or feature.get("definition_properties") or {}).get("values")) or {}
|
|
methods = ((history.get("definition_methods") or feature.get("definition_methods") or {}).get("values")) or {}
|
|
return props if isinstance(props, dict) else {}, methods if isinstance(methods, dict) else {}
|
|
|
|
|
|
def _record_parents(feature: dict[str, Any]) -> list[dict[str, Any]]:
|
|
history = feature.get("history_definition") or {}
|
|
value = history.get("parents") or feature.get("parents", [])
|
|
return [item for item in (value or []) if isinstance(item, dict)]
|
|
|
|
|
|
def _record_selections(feature: dict[str, Any]) -> list[dict[str, Any]]:
|
|
history = feature.get("history_definition") or {}
|
|
value = history.get("selections") or feature.get("selections", [])
|
|
return [item for item in (value or []) if isinstance(item, dict)]
|
|
|
|
|
|
def _selector(reference: dict[str, Any], *, source: str = "solidworks", confidence: float = 1.0,
|
|
owner_feature_id: str | None = None, kind_hint: str | None = None) -> dict[str, Any] | None:
|
|
if not isinstance(reference, dict):
|
|
return None
|
|
stable_id = _identity_key(reference)
|
|
kind = kind_hint or str(reference.get("kind") or "")
|
|
if not stable_id or kind not in {"face", "edge", "axis", "plane", "feature", "vertex", "body", "sketch_segment"}:
|
|
return None
|
|
if kind == "sketch_segment":
|
|
kind = "axis"
|
|
result: dict[str, Any] = {
|
|
"kind": kind,
|
|
"stable_id": stable_id,
|
|
"source": source,
|
|
"confidence": round(confidence, 3),
|
|
}
|
|
if owner_feature_id:
|
|
result["owner_feature_id"] = owner_feature_id
|
|
geometry = reference.get("geometry")
|
|
if isinstance(geometry, dict):
|
|
result["geometry"] = _geometry_signature(geometry)
|
|
return result
|
|
|
|
|
|
def _referenced_feature_id(reference: Any, feature_id_by_source: dict[str, str],
|
|
feature_id_by_name: dict[str, str]) -> str | None:
|
|
"""Resolve SolidWorks' persisted reference, falling back to its tree name."""
|
|
if not isinstance(reference, dict):
|
|
return None
|
|
return (
|
|
feature_id_by_source.get(_identity_key(reference) or "")
|
|
or feature_id_by_name.get(str(reference.get("name") or ""))
|
|
)
|
|
|
|
|
|
def _geometry_signature(geometry: dict[str, Any]) -> dict[str, Any]:
|
|
"""Retain a small, stable geometry signature instead of COM object payloads."""
|
|
result: dict[str, Any] = {}
|
|
if isinstance(geometry.get("surface"), dict):
|
|
result["surface"] = {
|
|
key: geometry["surface"][key]
|
|
for key in ("type", "parameters") if key in geometry["surface"]
|
|
}
|
|
if isinstance(geometry.get("curve"), dict):
|
|
result["curve"] = {
|
|
key: geometry["curve"][key]
|
|
for key in ("type", "parameters") if key in geometry["curve"]
|
|
}
|
|
for key in ("start", "end", "box", "area"):
|
|
if key in geometry and geometry[key] is not None:
|
|
result[key] = geometry[key]
|
|
return result
|
|
|
|
|
|
def _normalize(vector: list[float]) -> list[float]:
|
|
length = math.sqrt(sum(component * component for component in vector))
|
|
return [round(component / length, 9) for component in vector] if length > 1e-12 else [0.0, 0.0, 1.0]
|
|
|
|
|
|
def _cross(left: list[float], right: list[float]) -> list[float]:
|
|
return [
|
|
left[1] * right[2] - left[2] * right[1],
|
|
left[2] * right[0] - left[0] * right[2],
|
|
left[0] * right[1] - left[1] * right[0],
|
|
]
|
|
|
|
|
|
def _workplane(sketch: dict[str, Any]) -> dict[str, Any]:
|
|
"""Derive a world-space workplane from SolidWorks' model-to-sketch matrix.
|
|
|
|
SolidWorks stores the inverse transform (model -> sketch). For its rigid
|
|
4x4 matrix, transpose the rotational part and apply the inverse translation.
|
|
A conservative XY fallback keeps the record valid when the exporter did
|
|
not provide a usable matrix.
|
|
"""
|
|
workplane = _workplane_from_matrix(sketch)
|
|
if workplane is not None:
|
|
return workplane
|
|
return {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
|
|
|
|
|
|
def _workplane_from_matrix(sketch: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Parse the exporter's model-to-sketch matrix into a workplane.
|
|
|
|
Returns None when no usable rigid rotation can be extracted (missing
|
|
matrix or a non-orthonormal frame). The caller then decides between a
|
|
rebuilt workplane and the conservative XY fallback.
|
|
"""
|
|
matrix = sketch.get("model_to_sketch_transform")
|
|
if not isinstance(matrix, list) or len(matrix) != 16 or not all(isinstance(item, (int, float)) for item in matrix):
|
|
return None
|
|
# The exporter serializes MathTransform in row-major form.
|
|
rotation = [matrix[0:3], matrix[4:7], matrix[8:11]]
|
|
translation = [matrix[3], matrix[7], matrix[11]]
|
|
inverse_rotation = [[rotation[column][row] for column in range(3)] for row in range(3)]
|
|
origin_m = [-sum(inverse_rotation[row][column] * translation[column] for column in range(3)) for row in range(3)]
|
|
x_dir = _normalize([inverse_rotation[row][0] for row in range(3)])
|
|
y_dir = _normalize([inverse_rotation[row][1] for row in range(3)])
|
|
normal = _normalize(_cross(x_dir, y_dir))
|
|
# A rigid model-to-sketch rotation keeps the sketch axes orthogonal. The
|
|
# exporter sometimes emits a non-orthogonal matrix for profiles placed on
|
|
# a copied reference plane; only trust a frame that is truly orthonormal.
|
|
if abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) > 1e-9:
|
|
return None
|
|
return {"origin_mm": [_mm(value) for value in origin_m], "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
|
|
|
|
|
|
def _workplane_is_orthonormal(workplane: dict[str, Any]) -> bool:
|
|
"""True when a workplane's sketch axes describe an orthonormal frame."""
|
|
x_dir = workplane.get("x_dir")
|
|
y_dir = workplane.get("y_dir")
|
|
if not x_dir or not y_dir:
|
|
return False
|
|
return abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) <= 1e-9
|
|
|
|
|
|
def _workplane_rebuilt(parent_plane: dict[str, Any] | None, axis_direction: list[float] | None) -> dict[str, Any] | None:
|
|
"""Rebuild a workplane from the parent reference plane and the revolve axis.
|
|
|
|
When the exporter's matrix is unusable, the profile still lies on its
|
|
parent reference plane and the revolve axis runs along the sketch V axis.
|
|
The captured axis direction is world space, so V maps directly onto it and
|
|
U is completed by the cross product to keep a right-handed frame.
|
|
"""
|
|
if not parent_plane or not axis_direction:
|
|
return None
|
|
normal = _normalize(list(parent_plane.get("normal") or []))
|
|
y_dir = _normalize(list(axis_direction))
|
|
if abs(normal[0] * y_dir[0] + normal[1] * y_dir[1] + normal[2] * y_dir[2]) > 0.9:
|
|
# The axis would lie parallel to the plane normal and the revolve
|
|
# would degenerate to zero volume; do not trust this rebuild.
|
|
return None
|
|
x_dir = _normalize(_cross(y_dir, normal))
|
|
origin_mm = list(parent_plane.get("origin_mm") or [0.0, 0.0, 0.0])
|
|
return {"origin_mm": origin_mm, "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
|
|
|
|
|
|
def _axis_in_world(axis: dict[str, Any], workplane: dict[str, Any]) -> dict[str, Any]:
|
|
"""Transform a revolve axis captured in sketch-local coordinates to world space."""
|
|
origin = axis.get("origin_mm")
|
|
direction = axis.get("direction")
|
|
if not origin or not direction or not workplane.get("x_dir"):
|
|
return axis
|
|
origin_mm = workplane.get("origin_mm") or [0.0, 0.0, 0.0]
|
|
x_dir = workplane["x_dir"]
|
|
y_dir = workplane.get("y_dir") or [0.0, 1.0, 0.0]
|
|
normal = workplane.get("normal") or [0.0, 0.0, 1.0]
|
|
world_origin = [
|
|
origin_mm[index] + origin[0] * x_dir[index] + origin[1] * y_dir[index] + origin[2] * normal[index]
|
|
for index in range(3)
|
|
]
|
|
world_direction = _normalize([
|
|
direction[0] * x_dir[index] + direction[1] * y_dir[index] + direction[2] * normal[index]
|
|
for index in range(3)
|
|
])
|
|
return {
|
|
**axis,
|
|
"origin_mm": [round(value, 9) for value in world_origin],
|
|
"direction": world_direction if math.sqrt(sum(c * c for c in world_direction)) > 1e-12 else list(direction),
|
|
}
|
|
|
|
|
|
def _workplane_from_plane_reference(reference: Any) -> dict[str, Any] | None:
|
|
"""Build a workplane from an exporter-captured planar face signature."""
|
|
if not isinstance(reference, dict):
|
|
return None
|
|
geometry = reference.get("geometry") if isinstance(reference.get("geometry"), dict) else {}
|
|
surface = geometry.get("surface") if isinstance(geometry.get("surface"), dict) else {}
|
|
parameters = surface.get("parameters") if isinstance(surface.get("parameters"), list) else []
|
|
if surface.get("type") != "plane" or len(parameters) < 6:
|
|
return None
|
|
raw_normal = [_number(value) for value in parameters[0:3]]
|
|
origin = _point3(parameters[3:6])
|
|
if not origin or math.sqrt(sum(value * value for value in raw_normal)) <= 1e-12:
|
|
return None
|
|
normal = _normalize(raw_normal)
|
|
seed = [1.0, 0.0, 0.0] if abs(normal[0]) < 0.9 else [0.0, 1.0, 0.0]
|
|
x_dir = _normalize(_cross(seed, normal))
|
|
return {"origin_mm": origin, "x_dir": x_dir, "normal": normal}
|
|
|
|
|
|
def _segment(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
geometry = item.get("geometry") if isinstance(item.get("geometry"), dict) else {}
|
|
segment_type = str(geometry.get("segment_type") or "")
|
|
curve = geometry.get("curve") if isinstance(geometry.get("curve"), dict) else {}
|
|
curve_type = str(curve.get("type") or "")
|
|
start = _point2(geometry.get("start"))
|
|
end = _point2(geometry.get("end"))
|
|
center = _point2(geometry.get("center"))
|
|
parameters = curve.get("parameters") if isinstance(curve.get("parameters"), list) else []
|
|
|
|
if segment_type == "swSketchLINE" or curve_type == "line":
|
|
return {"type": "line", "start": start, "end": end} if start and end else None
|
|
if segment_type == "swSketchARC" or curve_type == "circle":
|
|
if not center and len(parameters) >= 3:
|
|
center = _point2(parameters[0:3])
|
|
radius_m = geometry.get("radius")
|
|
if radius_m is None and len(parameters) >= 7:
|
|
radius_m = parameters[6]
|
|
radius_mm = _mm(radius_m)
|
|
if not center or radius_mm <= 0:
|
|
return None
|
|
if start and end and math.dist(start, end) > EPSILON_MM:
|
|
return {"type": "arc", "start": start, "end": end, "center": center, "radius_mm": radius_mm,
|
|
"clockwise": int(_number(geometry.get("direction"), 1)) < 0}
|
|
return {"type": "circle", "center": center, "radius_mm": radius_mm}
|
|
if segment_type == "swSketchSPLINE" or curve_type == "other":
|
|
spline = item.get("spline") if isinstance(item.get("spline"), dict) else {}
|
|
raw_points = spline.get("control_points") or []
|
|
dimension = int(_number(spline.get("dimension"), 3))
|
|
if dimension < 2 or len(raw_points) < dimension * 2:
|
|
return None
|
|
points = [_point2(raw_points[index:index + dimension]) for index in range(0, len(raw_points), dimension)]
|
|
points = [point for point in points if point]
|
|
if len(points) < 2:
|
|
return None
|
|
result: dict[str, Any] = {
|
|
"type": "bspline", "degree": int(_number(spline.get("degree"), 3)),
|
|
"control_points": points, "knots": [float(value) for value in spline.get("knots") or []],
|
|
"periodic": bool(spline.get("periodic")),
|
|
}
|
|
if spline.get("rational") and isinstance(spline.get("weights"), list):
|
|
result["weights"] = [float(value) for value in spline["weights"]]
|
|
return result
|
|
return None
|
|
|
|
|
|
def _segment_endpoints(segment: dict[str, Any]) -> tuple[list[float] | None, list[float] | None]:
|
|
if segment["type"] in {"line", "arc"}:
|
|
return segment.get("start"), segment.get("end")
|
|
if segment["type"] == "bspline":
|
|
points = segment.get("control_points") or []
|
|
return (points[0], points[-1]) if points else (None, None)
|
|
return None, None
|
|
|
|
|
|
def _reverse_segment(segment: dict[str, Any]) -> dict[str, Any]:
|
|
result = dict(segment)
|
|
if result["type"] in {"line", "arc"}:
|
|
result["start"], result["end"] = result["end"], result["start"]
|
|
elif result["type"] == "bspline":
|
|
result["control_points"] = list(reversed(result["control_points"]))
|
|
result["knots"] = list(reversed(result["knots"]))
|
|
return result
|
|
|
|
|
|
def _chain_segments(segments: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
|
"""Join line/arc/spline records by endpoints, preserving standalone circles."""
|
|
circles = [[segment] for segment in segments if segment["type"] == "circle"]
|
|
pending = [segment for segment in segments if segment["type"] != "circle"]
|
|
chains: list[list[dict[str, Any]]] = []
|
|
while pending:
|
|
chain = [pending.pop(0)]
|
|
while pending:
|
|
_, tail = _segment_endpoints(chain[-1])
|
|
if not tail:
|
|
break
|
|
found: tuple[int, dict[str, Any]] | None = None
|
|
for index, candidate in enumerate(pending):
|
|
start, end = _segment_endpoints(candidate)
|
|
if start and math.dist(tail, start) <= EPSILON_MM:
|
|
found = (index, candidate)
|
|
break
|
|
if end and math.dist(tail, end) <= EPSILON_MM:
|
|
found = (index, _reverse_segment(candidate))
|
|
break
|
|
if found is None:
|
|
break
|
|
index, candidate = found
|
|
pending.pop(index)
|
|
chain.append(candidate)
|
|
chains.append(chain)
|
|
return chains + circles
|
|
|
|
|
|
def _contour_area(contour: list[dict[str, Any]]) -> float:
|
|
points = [segment.get("start") for segment in contour if segment.get("start")]
|
|
if len(points) < 3:
|
|
return 0.0
|
|
return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1]
|
|
for index in range(len(points))) / 2)
|
|
|
|
|
|
def _analytic_profile(sketch: dict[str, Any]) -> dict[str, Any]:
|
|
raw_segments = sketch.get("segments") or []
|
|
drawable: list[dict[str, Any]] = []
|
|
construction: list[dict[str, Any]] = []
|
|
for item in raw_segments:
|
|
geometry = item.get("geometry") if isinstance(item, dict) else None
|
|
if not isinstance(geometry, dict):
|
|
continue
|
|
converted = _segment(item)
|
|
if converted is None:
|
|
continue
|
|
(construction if geometry.get("construction") else drawable).append(converted)
|
|
|
|
if len(drawable) == 1 and drawable[0]["type"] == "circle":
|
|
profile: dict[str, Any] = {"type": "circle", "center": drawable[0]["center"], "radius_mm": drawable[0]["radius_mm"]}
|
|
return profile
|
|
if len(drawable) == 2 and all(item["type"] == "circle" for item in drawable):
|
|
left, right = drawable
|
|
if math.dist(left["center"], right["center"]) <= EPSILON_MM:
|
|
smaller, larger = sorted(drawable, key=lambda item: item["radius_mm"])
|
|
# Annulus is emitted as analytic_contours (outer + inner) so the
|
|
# generic runtime profile contract accepts the record; ring_revolve
|
|
# can then revolve the two concentric circles into a hollow solid.
|
|
return {
|
|
"type": "analytic_contours",
|
|
"contours": [
|
|
{"role": "outer", "closed": True, "segments": [larger]},
|
|
{"role": "inner", "closed": True, "segments": [smaller]},
|
|
],
|
|
}
|
|
if drawable and all(item["type"] == "circle" for item in drawable):
|
|
# Multiple independent circles share the "extrude every closed loop" intent;
|
|
# emit them as analytic_contours (one outer contour per circle) so the
|
|
# generic runtime profile contract accepts the record.
|
|
return {
|
|
"type": "analytic_contours",
|
|
"contours": [
|
|
{"role": "outer", "closed": True, "segments": [item]}
|
|
for item in drawable
|
|
],
|
|
}
|
|
|
|
contours = _chain_segments(drawable)
|
|
areas = [_contour_area(contour) for contour in contours]
|
|
outer_index = max(range(len(contours)), key=lambda index: areas[index], default=-1)
|
|
values: list[dict[str, Any]] = []
|
|
for index, contour in enumerate(contours):
|
|
start, end = _segment_endpoints(contour[0]) if contour else (None, None)
|
|
_, tail = _segment_endpoints(contour[-1]) if contour else (None, None)
|
|
closed = bool(len(contour) == 1 and contour[0]["type"] == "circle") or bool(start and tail and math.dist(start, tail) <= EPSILON_MM)
|
|
values.append({"role": "outer" if index == outer_index else "inner", "closed": closed, "segments": contour})
|
|
profile = {"type": "analytic_contours", "contours": values}
|
|
if construction:
|
|
profile["construction"] = construction
|
|
return profile
|
|
|
|
|
|
def _feature_family(feature: dict[str, Any]) -> str | None:
|
|
feature_type = str(feature.get("effective_type") or feature.get("type_name1") or "")
|
|
return feature_type if feature_type in MODELING_TYPES else None
|
|
|
|
|
|
def _parent_feature_ids(feature: dict[str, Any], feature_id_by_source: dict[str, str], feature_id_by_name: dict[str, str],
|
|
previous_id: str | None) -> list[str]:
|
|
dependencies: list[str] = []
|
|
for parent in _record_parents(feature):
|
|
source_id = _identity_key(parent)
|
|
output_id = feature_id_by_source.get(source_id or "") or feature_id_by_name.get(str(parent.get("name") or ""))
|
|
if output_id and output_id not in dependencies:
|
|
dependencies.append(output_id)
|
|
if not dependencies and previous_id:
|
|
dependencies.append(previous_id)
|
|
return dependencies
|
|
|
|
|
|
def _parent_sketch_id(feature: dict[str, Any], sketch_id_by_source: dict[str, str], sketch_id_by_name: dict[str, str]) -> str | None:
|
|
references = [*_record_parents(feature), *[item for item in feature.get("subfeatures") or [] if isinstance(item, dict)]]
|
|
for parent in references:
|
|
sketch_id = sketch_id_by_source.get(_identity_key(parent) or "") or sketch_id_by_name.get(str(parent.get("name") or ""))
|
|
if sketch_id:
|
|
return sketch_id
|
|
return None
|
|
|
|
|
|
def _end_condition(methods: dict[str, Any], forward: bool = True) -> dict[str, Any]:
|
|
suffix = "true" if forward else "false"
|
|
code = int(_number(methods.get(f"GetEndCondition({suffix})"), 0))
|
|
result = {"type": END_CONDITIONS.get(code, f"solidworks_{code}"), "solidworks_code": code}
|
|
reference = methods.get(f"GetEndConditionReference({suffix},0)")
|
|
if isinstance(reference, dict) and isinstance(reference.get("return"), dict):
|
|
selected = _selector(reference["return"])
|
|
if selected:
|
|
result["reference"] = selected
|
|
return result
|
|
|
|
|
|
def _extrude_params(props: dict[str, Any], methods: dict[str, Any]) -> dict[str, Any]:
|
|
distance = _mm(methods.get("GetDepth(true)"))
|
|
reverse_distance = _mm(methods.get("GetDepth(false)"))
|
|
params: dict[str, Any] = {
|
|
"distance_mm": distance,
|
|
"reverse": bool(props.get("ReverseDirection")),
|
|
"end_condition": _end_condition(methods, True),
|
|
}
|
|
if bool(props.get("BothDirections")) or reverse_distance > 0:
|
|
params["reverse_distance_mm"] = reverse_distance
|
|
params["reverse_end_condition"] = _end_condition(methods, False)
|
|
return params
|
|
|
|
|
|
def _axis_from_reference(reference: Any) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
if not isinstance(reference, dict):
|
|
return None, None
|
|
selected = _selector(reference)
|
|
geometry = reference.get("geometry") if isinstance(reference.get("geometry"), dict) else {}
|
|
start = _point3(geometry.get("start"))
|
|
end = _point3(geometry.get("end"))
|
|
if start and end:
|
|
direction = _normalize([end[index] - start[index] for index in range(3)])
|
|
return {"origin_mm": start, "direction": direction}, selected
|
|
surface = geometry.get("surface") if isinstance(geometry.get("surface"), dict) else {}
|
|
parameters = surface.get("parameters") if isinstance(surface.get("parameters"), list) else []
|
|
if surface.get("type") == "cylinder" and len(parameters) >= 6:
|
|
origin = _point3(parameters[0:3])
|
|
raw_direction = [_number(value) for value in parameters[3:6]]
|
|
direction = _normalize(raw_direction)
|
|
if origin and math.sqrt(sum(component * component for component in raw_direction)) > 1e-12:
|
|
return {"origin_mm": origin, "direction": direction, "selector": selected} if selected else {"origin_mm": origin, "direction": direction}, selected
|
|
if selected:
|
|
return {"selector": selected}, selected
|
|
return None, None
|
|
|
|
|
|
def _captured_selection_values(methods: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Unwrap the exporter result for COM methods with an out-array return."""
|
|
value = methods.get("GetSelections(null)")
|
|
if isinstance(value, dict):
|
|
value = value.get("return")
|
|
return [item for item in (value or []) if isinstance(item, dict)]
|
|
|
|
|
|
def _revolve_params(props: dict[str, Any], methods: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
axis, selector = _axis_from_reference(props.get("Axis"))
|
|
unresolved: list[str] = []
|
|
if axis is None:
|
|
axis = {"unresolved": "SolidWorks revolve axis was not captured"}
|
|
unresolved.append("revolve axis was not captured")
|
|
angle = math.degrees(_number(methods.get("GetRevolutionAngle(true)"), 2 * math.pi))
|
|
params: dict[str, Any] = {
|
|
"angle_deg": round(angle, 9), "axis": axis,
|
|
"reverse": bool(props.get("ReverseDirection")),
|
|
"end_condition": _end_condition(methods, True),
|
|
}
|
|
if selector:
|
|
params["axis_selector"] = selector
|
|
return params, unresolved
|
|
|
|
|
|
def _hole_params(props: dict[str, Any], methods: dict[str, Any]) -> tuple[dict[str, Any], list[list[float]]]:
|
|
diameter = next((_mm(props.get(key)) for key in ("ThreadDiameter", "ThruHoleDiameter", "HoleDiameter", "Diameter") if _mm(props.get(key)) > 0), 0.0)
|
|
depth = next((_mm(props.get(key)) for key in ("ThreadDepth", "TapDrillDepth", "ThruHoleDepth", "HoleDepth", "Depth") if _mm(props.get(key)) > 0), 0.0)
|
|
positions: list[list[float]] = []
|
|
for point in methods.get("GetSketchPoints") or []:
|
|
if isinstance(point, dict):
|
|
value = _point3((point.get("geometry") or {}).get("point"))
|
|
if value:
|
|
positions.append(value)
|
|
params: dict[str, Any] = {
|
|
"hole_type": str(props.get("FastenerType") or props.get("Type") or "hole_wizard"),
|
|
"diameter_mm": diameter,
|
|
"depth_mm": depth,
|
|
"end_condition": {"solidworks_code": int(_number(props.get("EndCondition"), 0)), "type": END_CONDITIONS.get(int(_number(props.get("EndCondition"), 0)), "blind")},
|
|
}
|
|
if positions:
|
|
params["positions"] = [{"mm": position} for position in positions]
|
|
thread_diameter = _mm(props.get("ThreadDiameter"))
|
|
if thread_diameter > 0:
|
|
params["thread"] = {"diameter_mm": thread_diameter, "depth_mm": _mm(props.get("ThreadDepth")), "class": props.get("ThreadClass")}
|
|
if _mm(props.get("CounterSinkDiameter")) > 0:
|
|
params["countersink"] = {"diameter_mm": _mm(props.get("CounterSinkDiameter")), "angle_rad": _number(props.get("CounterSinkAngle"))}
|
|
if _mm(props.get("CounterBoreDiameter")) > 0:
|
|
params["counterbore"] = {"diameter_mm": _mm(props.get("CounterBoreDiameter")), "depth_mm": _mm(props.get("CounterBoreDepth"))}
|
|
return params, positions
|
|
|
|
|
|
def _dimension_values(feature: dict[str, Any]) -> list[float]:
|
|
result: list[float] = []
|
|
history = feature.get("history_definition") or {}
|
|
values = history.get("dimensions", feature.get("dimensions", [])) or []
|
|
for value in values:
|
|
if isinstance(value, dict):
|
|
result.append(_mm(value.get("system_value")))
|
|
return result
|
|
|
|
|
|
def _raw_dimension_values(feature: dict[str, Any]) -> list[float]:
|
|
"""Return SolidWorks system values without applying a length conversion.
|
|
|
|
A feature's dimensions may mix metres and radians; callers must choose
|
|
the appropriate unit for each semantic field.
|
|
"""
|
|
history = feature.get("history_definition") or {}
|
|
values = history.get("dimensions", feature.get("dimensions", [])) or []
|
|
return [_number(value.get("system_value")) for value in values if isinstance(value, dict)]
|
|
|
|
|
|
def _deferred_params(family: str, feature: dict[str, Any], props: dict[str, Any], methods: dict[str, Any],
|
|
source_feature_ids: list[str], feature_id_by_source: dict[str, str],
|
|
feature_id_by_name: dict[str, str]) -> tuple[dict[str, Any], list[str]]:
|
|
if family == "Fillet":
|
|
radius = _mm(props.get("Radius")) or (_dimension_values(feature) or [0.0])[0]
|
|
return {"radius_mm": radius, "tangent_propagation": bool(props.get("TangentPropagation"))}, []
|
|
if family == "Chamfer":
|
|
dimensions = _dimension_values(feature)
|
|
raw_dimensions = _raw_dimension_values(feature)
|
|
distance = _mm(props.get("Distance")) or (dimensions or [0.0])[0]
|
|
params: dict[str, Any] = {"distance_mm": distance}
|
|
angle = _number(props.get("EdgeChamferAngle"))
|
|
if angle <= 0 and len(raw_dimensions) > 1:
|
|
# 兜底:EdgeChamferAngle 缺失时,第二个 raw 值按角度(弧度)解释。
|
|
# 注意:SolidWorks 角度 system value 为弧度;Distance-Distance 倒角的
|
|
# 第二距离(米)也会落入该兜底,但真实数据中倒角均为 Distance-Angle
|
|
# (第二值为 π/4 等弧度值),runtime 通过 distance_2_mm/angle_rad
|
|
# 契约消费第二参数,不会把角度静默当等距。
|
|
angle = raw_dimensions[1]
|
|
if angle > 0:
|
|
# Distance-Angle 倒角:角度单位为弧度(build123d 侧由 runtime 换算)。
|
|
params["angle_rad"] = angle
|
|
return params, []
|
|
if family == "LPattern":
|
|
property_sources = [
|
|
_referenced_feature_id(item, feature_id_by_source, feature_id_by_name)
|
|
for item in props.get("PatternFeatureArray") or [] if isinstance(item, dict)
|
|
]
|
|
source_feature_ids = list(dict.fromkeys([*source_feature_ids, *[item for item in property_sources if item]]))
|
|
params = {
|
|
"source_feature_ids": source_feature_ids,
|
|
"direction_1": _pattern_direction(props.get("D1Axis")),
|
|
"spacing_1_mm": _mm(props.get("D1Spacing")),
|
|
"pattern_count_1": max(1, int(_number(props.get("D1TotalInstances"), 1))),
|
|
}
|
|
if int(_number(props.get("D2TotalInstances"), 1)) > 1:
|
|
params.update({"direction_2": _pattern_direction(props.get("D2Axis")), "spacing_2_mm": _mm(props.get("D2Spacing")), "pattern_count_2": int(_number(props.get("D2TotalInstances"), 1))})
|
|
missing = [] if source_feature_ids and params["spacing_1_mm"] > 0 else ["linear pattern source features or primary spacing were not captured"]
|
|
return params, missing
|
|
if family == "MirrorPattern":
|
|
property_sources = [
|
|
_referenced_feature_id(item, feature_id_by_source, feature_id_by_name)
|
|
for item in props.get("PatternFeatureArray") or [] if isinstance(item, dict)
|
|
]
|
|
source_feature_ids = list(dict.fromkeys([*source_feature_ids, *[item for item in property_sources if item]]))
|
|
plane_reference = props.get("Plane")
|
|
plane = (
|
|
_selector(
|
|
plane_reference,
|
|
kind_hint="plane",
|
|
owner_feature_id=_referenced_feature_id(plane_reference, feature_id_by_source, feature_id_by_name),
|
|
)
|
|
if isinstance(plane_reference, dict) else None
|
|
)
|
|
if plane is None:
|
|
plane = next(
|
|
(selector for selector in (_selector(item, kind_hint="plane") for item in _record_selections(feature)) if selector),
|
|
None,
|
|
)
|
|
return {"source_feature_ids": source_feature_ids, "mirror_plane": plane or {"unresolved": "mirror plane was not captured"}}, ([] if source_feature_ids and plane else ["mirror pattern source features or plane were not captured"])
|
|
if family == "RefPlane":
|
|
references: list[dict[str, Any]] = []
|
|
reference_items = [*_record_parents(feature), *[item for item in props.get("Selections") or [] if isinstance(item, dict)]]
|
|
for reference in reference_items:
|
|
selector = _selector(
|
|
reference,
|
|
kind_hint="plane" if _is_named_coordinate_plane(reference.get("name")) else None,
|
|
owner_feature_id=_referenced_feature_id(reference, feature_id_by_source, feature_id_by_name),
|
|
)
|
|
if selector and selector not in references:
|
|
references.append(selector)
|
|
base_name = next((str(item.get("name")) for item in _record_parents(feature) if _is_named_coordinate_plane(item.get("name"))), None)
|
|
if base_name:
|
|
plane = _named_plane(base_name)
|
|
elif _is_named_coordinate_plane(feature.get("name")):
|
|
plane = _named_plane(feature.get("name"))
|
|
elif (plane_reference := next((item for item in reference_items if _workplane_from_plane_reference(item)), None)):
|
|
plane = _workplane_from_plane_reference(plane_reference) or {}
|
|
else:
|
|
plane = {"unresolved": "reference plane orientation was not captured"}
|
|
if "unresolved" not in plane:
|
|
plane.pop("name", None)
|
|
offset = _mm(props.get("Distance"))
|
|
if offset:
|
|
sign = -1.0 if bool(props.get("ReverseDirection")) else 1.0
|
|
plane["origin_mm"] = [round(sign * offset * value, 9) for value in plane["normal"]]
|
|
params: dict[str, Any] = {
|
|
"plane": plane,
|
|
"offset_mm": _mm(props.get("Distance")),
|
|
"angle_rad": _number(props.get("Angle")),
|
|
"reverse": bool(props.get("ReverseDirection")),
|
|
"solidworks_type": int(_number(props.get("Type"), -1)),
|
|
}
|
|
if references:
|
|
params["references"] = references
|
|
missing = ["reference plane orientation was not captured"] if "unresolved" in plane else []
|
|
return params, missing
|
|
if family == "RefAxis":
|
|
axis, _ = _axis_from_reference(props.get("Axis"))
|
|
if axis is None:
|
|
for reference in [*_captured_selection_values(methods), *_record_selections(feature)]:
|
|
axis, _ = _axis_from_reference(reference)
|
|
if axis is not None:
|
|
break
|
|
if axis is None:
|
|
parent_planes = [
|
|
_named_plane(item.get("name"))
|
|
for item in _record_parents(feature)
|
|
if _is_named_coordinate_plane(item.get("name"))
|
|
]
|
|
if len(parent_planes) == 2:
|
|
direction = _cross(parent_planes[0]["normal"], parent_planes[1]["normal"])
|
|
if math.sqrt(sum(component * component for component in direction)) > 1e-12:
|
|
axis = {"origin_mm": [0.0, 0.0, 0.0], "direction": _normalize(direction)}
|
|
return {"axis": axis or {"unresolved": "reference axis was not captured"}}, ([] if axis else ["reference axis was not captured"])
|
|
return {}, []
|
|
|
|
|
|
def _pattern_direction(value: Any) -> list[float]:
|
|
if isinstance(value, dict):
|
|
geometry = value.get("geometry") if isinstance(value.get("geometry"), dict) else value
|
|
start = _point3(geometry.get("start"))
|
|
end = _point3(geometry.get("end"))
|
|
if start and end:
|
|
return _normalize([end[index] - start[index] for index in range(3)])
|
|
vector = geometry.get("vector")
|
|
if isinstance(vector, list) and len(vector) >= 3:
|
|
return _normalize([_number(item) for item in vector[0:3]])
|
|
return [1.0, 0.0, 0.0]
|
|
|
|
|
|
def _named_plane(name: Any) -> dict[str, Any]:
|
|
text = str(name or "").lower()
|
|
# Named coordinate planes appear in both SolidWorks' localized origin and
|
|
# as internal "ip_N XY/XZ/YZ" references. Check the pair names first so
|
|
# an axis such as "ip_1 X" is never mistaken for an arbitrary plane.
|
|
if re.search(r"(?:^|\s)xy(?:$|\s)", text):
|
|
normal, x_dir = [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]
|
|
elif re.search(r"(?:^|\s)xz(?:$|\s)", text):
|
|
normal, x_dir = [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]
|
|
elif re.search(r"(?:^|\s)yz(?:$|\s)", text):
|
|
normal, x_dir = [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]
|
|
elif "上" in text or "top" in text:
|
|
normal, x_dir = [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]
|
|
elif "右" in text or "right" in text:
|
|
normal, x_dir = [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]
|
|
elif "前" in text or "front" in text:
|
|
normal, x_dir = [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]
|
|
else:
|
|
normal, x_dir = [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]
|
|
return {"origin_mm": [0.0, 0.0, 0.0], "normal": normal, "x_dir": x_dir, "name": str(name or "reference plane")}
|
|
|
|
|
|
def _is_named_coordinate_plane(name: Any) -> bool:
|
|
text = str(name or "").lower()
|
|
return bool(
|
|
re.search(r"(?:^|\s)(?:xy|xz|yz)(?:$|\s)", text)
|
|
or any(token in text for token in ("top", "front", "right", "上", "前", "右"))
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class StepInspector:
|
|
path: Path | None
|
|
faces: list[dict[str, Any]] = field(default_factory=list)
|
|
axes: list[dict[str, Any]] = field(default_factory=list)
|
|
repeat_spacings_mm: list[dict[str, Any]] = field(default_factory=list)
|
|
metrics: dict[str, Any] = field(default_factory=dict)
|
|
error: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.path or not self.path.exists():
|
|
return
|
|
try:
|
|
from build123d import import_step
|
|
|
|
solid = import_step(str(self.path))
|
|
for index, face in enumerate(solid.faces()):
|
|
box = face.bounding_box()
|
|
center = face.center()
|
|
normal = face.normal_at()
|
|
values = [box.min.X, box.min.Y, box.min.Z, box.max.X, box.max.Y, box.max.Z]
|
|
surface_type = str(getattr(face, "geom_type", "unknown")).rsplit(".", 1)[-1].lower()
|
|
geometry: dict[str, Any] = {
|
|
"bbox_mm": [round(value, 9) for value in values],
|
|
"center_mm": [round(center.X, 9), round(center.Y, 9), round(center.Z, 9)],
|
|
"normal": [round(normal.X, 9), round(normal.Y, 9), round(normal.Z, 9)],
|
|
"surface_type": surface_type,
|
|
}
|
|
axis = getattr(face, "axis_of_rotation", None)
|
|
if surface_type in {"cylinder", "cone"} and axis is not None:
|
|
axis_record = {
|
|
"origin_mm": [round(axis.position.X, 9), round(axis.position.Y, 9), round(axis.position.Z, 9)],
|
|
"direction": [round(axis.direction.X, 9), round(axis.direction.Y, 9), round(axis.direction.Z, 9)],
|
|
"surface_type": surface_type,
|
|
}
|
|
radius = getattr(face, "radius", None)
|
|
if isinstance(radius, (int, float)) and math.isfinite(radius):
|
|
axis_record["radius_mm"] = round(radius, 9)
|
|
geometry["axis"] = axis_record
|
|
stable_payload = json.dumps({"index": index, "geometry": geometry}, sort_keys=True, separators=(",", ":"))
|
|
stable = hashlib.sha256(stable_payload.encode()).hexdigest()
|
|
record = {
|
|
"kind": "face", "stable_id": f"step-face-{stable[:24]}", "source": "inferred_from_step", "confidence": 0.0,
|
|
"geometry": geometry,
|
|
}
|
|
self.faces.append(record)
|
|
if "axis" in geometry:
|
|
self.axes.append({"stable_id": record["stable_id"], **geometry["axis"]})
|
|
self.repeat_spacings_mm = self._repeated_axis_spacings()
|
|
overall_box = solid.bounding_box()
|
|
self.metrics = {
|
|
"bounding_box_mm": [
|
|
round(value, 9)
|
|
for value in (overall_box.min.X, overall_box.min.Y, overall_box.min.Z, overall_box.max.X, overall_box.max.Y, overall_box.max.Z)
|
|
],
|
|
"volume_mm3": round(float(solid.volume), 9),
|
|
"surface_area_mm2": round(float(solid.area), 9),
|
|
"solid_count": len(solid.solids()),
|
|
"face_count": len(solid.faces()),
|
|
"edge_count": len(solid.edges()),
|
|
"vertex_count": len(solid.vertices()),
|
|
}
|
|
except Exception as error: # STEP inference is optional and must not abort conversion.
|
|
self.error = str(error)
|
|
|
|
def _repeated_axis_spacings(self) -> list[dict[str, Any]]:
|
|
"""Report only repeatable topology distances, never a guessed pattern."""
|
|
origins = list(dict.fromkeys(tuple(axis["origin_mm"]) for axis in self.axes))[:160]
|
|
distances: Counter[float] = Counter()
|
|
for index, first in enumerate(origins):
|
|
for second in origins[index + 1:]:
|
|
distance = math.dist(first, second)
|
|
if distance > EPSILON_MM:
|
|
distances[round(distance, 4)] += 1
|
|
return [
|
|
{"spacing_mm": distance, "pair_count": count}
|
|
for distance, count in distances.most_common(12) if count > 1
|
|
]
|
|
|
|
def infer_host_face(self, positions: list[list[float]]) -> dict[str, Any] | None:
|
|
if not positions or not self.faces:
|
|
return None
|
|
candidates = []
|
|
for face in self.faces:
|
|
box = face["geometry"]["bbox_mm"]
|
|
geometry = face["geometry"]
|
|
if not all(box[0] - 0.05 <= point[0] <= box[3] + 0.05 and box[1] - 0.05 <= point[1] <= box[4] + 0.05 and box[2] - 0.05 <= point[2] <= box[5] + 0.05 for point in positions):
|
|
continue
|
|
if geometry["surface_type"] == "plane":
|
|
normal = geometry["normal"]
|
|
center = geometry["center_mm"]
|
|
if not all(abs(sum((point[index] - center[index]) * normal[index] for index in range(3))) <= 0.05 for point in positions):
|
|
continue
|
|
elif geometry["surface_type"] == "cylinder" and isinstance(geometry.get("axis"), dict):
|
|
axis = geometry["axis"]
|
|
radius = axis.get("radius_mm")
|
|
if not isinstance(radius, (int, float)):
|
|
continue
|
|
origin, direction = axis["origin_mm"], axis["direction"]
|
|
if not all(abs(math.dist(point, [origin[index] + sum((point[j] - origin[j]) * direction[j] for j in range(3)) * direction[index] for index in range(3)]) - radius) <= 0.05 for point in positions):
|
|
continue
|
|
else:
|
|
continue
|
|
candidates.append(face)
|
|
if len(candidates) != 1:
|
|
return None
|
|
candidate = dict(candidates[0])
|
|
candidate["confidence"] = 0.8
|
|
return candidate
|
|
|
|
def compare_truth(self, truth: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Compare the STEP result against evidence's documented model truth.
|
|
|
|
Metrics are diagnostic evidence only. STEP topology can be split or
|
|
healed by the importer, so exact topology counts are reported but do
|
|
not turn an otherwise sound geometric match into a conversion failure.
|
|
"""
|
|
if not self.metrics or not isinstance(truth, dict):
|
|
return None
|
|
geometry = truth.get("geometry") if isinstance(truth.get("geometry"), dict) else {}
|
|
mass = truth.get("mass_properties") if isinstance(truth.get("mass_properties"), dict) else {}
|
|
expected_bbox = geometry.get("bounding_box")
|
|
expected: dict[str, Any] = {}
|
|
if isinstance(expected_bbox, list) and len(expected_bbox) == 6:
|
|
expected["bounding_box_mm"] = [_mm(value) for value in expected_bbox]
|
|
if _number(mass.get("volume")) > 0:
|
|
expected["volume_mm3"] = round(_number(mass["volume"]) * METERS_TO_MM ** 3, 9)
|
|
if _number(mass.get("surface_area")) > 0:
|
|
expected["surface_area_mm2"] = round(_number(mass["surface_area"]) * METERS_TO_MM ** 2, 9)
|
|
bodies = geometry.get("bodies") if isinstance(geometry.get("bodies"), list) else []
|
|
if bodies:
|
|
body_metrics = [item.get("geometry") for item in bodies if isinstance(item, dict) and isinstance(item.get("geometry"), dict)]
|
|
if body_metrics:
|
|
expected.update({
|
|
"solid_count": int(_number(geometry.get("solid_body_count"), len(body_metrics))),
|
|
"face_count": sum(int(_number(item.get("face_count"))) for item in body_metrics),
|
|
"edge_count": sum(int(_number(item.get("edge_count"))) for item in body_metrics),
|
|
"vertex_count": sum(int(_number(item.get("vertex_count"))) for item in body_metrics),
|
|
})
|
|
comparisons: dict[str, Any] = {}
|
|
for key in ("volume_mm3", "surface_area_mm2"):
|
|
if key in expected:
|
|
actual = self.metrics[key]
|
|
delta = abs(actual - expected[key])
|
|
comparisons[key] = {"expected": expected[key], "actual": actual, "absolute_delta": delta, "within_tolerance": delta <= max(1e-4, abs(expected[key]) * 1e-4)}
|
|
if "bounding_box_mm" in expected:
|
|
deltas = [abs(actual - target) for actual, target in zip(self.metrics["bounding_box_mm"], expected["bounding_box_mm"])]
|
|
comparisons["bounding_box_mm"] = {"expected": expected["bounding_box_mm"], "actual": self.metrics["bounding_box_mm"], "max_absolute_delta": max(deltas), "within_tolerance": max(deltas) <= 1e-3}
|
|
for key in ("solid_count", "face_count", "edge_count", "vertex_count"):
|
|
if key in expected:
|
|
comparisons[key] = {"expected": expected[key], "actual": self.metrics[key], "matches": expected[key] == self.metrics[key]}
|
|
numeric = [value["within_tolerance"] for value in comparisons.values() if "within_tolerance" in value]
|
|
return {
|
|
"expected_from_evidence": expected,
|
|
"comparisons": comparisons,
|
|
"numeric_geometry_match": all(numeric) if numeric else None,
|
|
"topology_counts_match": all(value["matches"] for value in comparisons.values() if "matches" in value),
|
|
}
|
|
|
|
def summary(self, truth: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
surface_counts = Counter(str(face["geometry"].get("surface_type") or "unknown") for face in self.faces)
|
|
result = {
|
|
"available": bool(self.path and self.path.exists()), "face_count": len(self.faces),
|
|
"surface_counts": dict(sorted(surface_counts.items())), "axis_count": len(self.axes),
|
|
"repeat_spacings_mm": self.repeat_spacings_mm, "metrics": self.metrics, "error": self.error,
|
|
}
|
|
if comparison := self.compare_truth(truth or {}):
|
|
result["truth_comparison"] = comparison
|
|
return result
|
|
|
|
|
|
def _blockers_by_feature(evidence: dict[str, Any]) -> dict[str, list[str]]:
|
|
result: dict[str, list[str]] = {}
|
|
for contract in (evidence.get("self_validation") or {}).get("feature_contracts") or []:
|
|
if isinstance(contract, dict) and contract.get("blockers"):
|
|
result[str(contract.get("feature") or "")] = [str(item) for item in contract["blockers"]]
|
|
return result
|
|
|
|
|
|
def _topologically_order_features(features: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Order source features by their explicit CDSL dependencies.
|
|
|
|
SolidWorks' tree order can place a reference object after its consumer. A
|
|
CDSL dependency must be emitted first, so normal forward references are
|
|
sorted here. A genuine cycle cannot be executed by a future engine either;
|
|
retain the feature and surface the unsupported edge as an unresolved item.
|
|
"""
|
|
remaining = list(features)
|
|
ordered: list[dict[str, Any]] = []
|
|
emitted: set[str] = set()
|
|
while remaining:
|
|
ready = [feature for feature in remaining if set(feature.get("depends_on") or []).issubset(emitted)]
|
|
if ready:
|
|
for feature in ready:
|
|
ordered.append(feature)
|
|
emitted.add(feature["id"])
|
|
remaining.remove(feature)
|
|
continue
|
|
feature = remaining.pop(0)
|
|
blocked = [dependency for dependency in feature.get("depends_on") or [] if dependency not in emitted]
|
|
feature["depends_on"] = [dependency for dependency in feature.get("depends_on") or [] if dependency in emitted]
|
|
feature.setdefault("unresolved", []).append(
|
|
"cyclic or unresolved feature dependency: " + ", ".join(blocked)
|
|
)
|
|
ordered.append(feature)
|
|
emitted.add(feature["id"])
|
|
return ordered
|
|
|
|
|
|
def _part_id_from_source_name(source_name: str) -> str:
|
|
"""Build a schema-valid ID from the entire evidence file name."""
|
|
stem = Path(source_name).name.removesuffix(EVIDENCE_V2_SUFFIX)
|
|
part_id = re.sub(r"[^A-Za-z0-9_-]+", "-", stem).strip("-")
|
|
if len(part_id) < 3:
|
|
part_id = f"part-{part_id}".rstrip("-")
|
|
return part_id[:80]
|
|
|
|
|
|
def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: Path | None = None,
|
|
part_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
if evidence.get("schema") != "solidworks.cad_evidence.v2":
|
|
raise ValueError("Expected schema solidworks.cad_evidence.v2")
|
|
features = sorted((item for item in evidence.get("features") or [] if isinstance(item, dict)), key=lambda item: int(_number(item.get("sequence"))))
|
|
source_by_stable = {_identity_key(feature): feature for feature in features if _identity_key(feature)}
|
|
source_by_name = {str(feature.get("name")): feature for feature in features if feature.get("name") is not None}
|
|
sketch_features = [feature for feature in features if str(feature.get("effective_type") or "") in SKETCH_TYPES and isinstance(feature.get("sketch"), dict)]
|
|
sketch_id_by_source = {_identity_key(feature): _safe_id("sk", index + 1) for index, feature in enumerate(sketch_features) if _identity_key(feature)}
|
|
sketch_id_by_name = {str(feature.get("name")): sketch_id_by_source[source_id] for feature in sketch_features if (source_id := _identity_key(feature)) and feature.get("name") is not None}
|
|
sketch_workplanes_by_parent_source: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
sketch_workplanes_by_parent_name: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
# sketch source id -> the parent reference-plane source id it is drawn on
|
|
sketch_parent_plane_by_source: dict[str, str] = {}
|
|
# sketch source id -> the world-space direction of its revolve axis
|
|
sketch_axis_direction_by_source: dict[str, list[float]] = {}
|
|
# sketch source ids whose exporter matrix did not form an orthonormal frame
|
|
sketch_matrix_unreliable: set[str] = set()
|
|
sketch_record_by_id: dict[str, dict[str, Any]] = {}
|
|
# Prescan the revolve features so each profile's workplane can be rebuilt
|
|
# from the axis direction captured on its own revolve feature.
|
|
for feature in features:
|
|
if _feature_family(feature) not in {"Revolution", "RevCut"}:
|
|
continue
|
|
props, _ = _record_values(feature)
|
|
axis, _ = _axis_from_reference(props.get("Axis"))
|
|
if not axis or not axis.get("direction"):
|
|
continue
|
|
for parent in _record_parents(feature):
|
|
if str(parent.get("effective_type") or "") in SKETCH_TYPES and (sketch_source_id := _identity_key(parent)):
|
|
sketch_axis_direction_by_source.setdefault(sketch_source_id, list(axis["direction"]))
|
|
sketches = []
|
|
for feature in sketch_features:
|
|
source_id = _identity_key(feature)
|
|
if not source_id:
|
|
continue
|
|
sketch = feature["sketch"]
|
|
workplane = _workplane(sketch)
|
|
record = {"id": sketch_id_by_source[source_id], "name": str(feature.get("name") or sketch_id_by_source[source_id]), "role": "profile", "workplane": workplane, "profile": _analytic_profile(sketch)}
|
|
sketches.append(record)
|
|
sketch_record_by_id[record["id"]] = record
|
|
if not _workplane_is_orthonormal(workplane):
|
|
sketch_matrix_unreliable.add(source_id)
|
|
for parent in _record_parents(feature):
|
|
parent_source_id = _identity_key(parent)
|
|
if parent_source_id:
|
|
sketch_workplanes_by_parent_source.setdefault(parent_source_id, (record["id"], workplane))
|
|
if _feature_family(parent) == "RefPlane":
|
|
sketch_parent_plane_by_source[source_id] = parent_source_id
|
|
if parent.get("name") is not None:
|
|
sketch_workplanes_by_parent_name.setdefault(str(parent["name"]), (record["id"], workplane))
|
|
|
|
model_features = [feature for feature in features if _feature_family(feature)]
|
|
feature_id_by_source = {_identity_key(feature): _safe_id("f", index + 1) for index, feature in enumerate(model_features) if _identity_key(feature)}
|
|
feature_id_by_name = {str(feature.get("name")): feature_id_by_source[source_id] for feature in model_features if (source_id := _identity_key(feature)) and feature.get("name") is not None}
|
|
source_feature_ids = {
|
|
feature_id_by_source[source_id]
|
|
for feature in model_features
|
|
if (source_id := _identity_key(feature)) and _feature_family(feature) not in {"RefPlane", "RefAxis"}
|
|
}
|
|
# Type 10 is SolidWorks' document-origin reference plane. Some localized
|
|
# exports lose the front/top/right names but retain their fixed tree order.
|
|
origin_plane_by_source: dict[str, dict[str, Any]] = {}
|
|
origin_plane_by_name: dict[str, dict[str, Any]] = {}
|
|
origin_names = ("front", "top", "right")
|
|
origin_candidates = []
|
|
for item in model_features:
|
|
if _feature_family(item) != "RefPlane" or _record_parents(item):
|
|
continue
|
|
item_props, _ = _record_values(item)
|
|
if int(_number(item_props.get("Type"), -1)) == 10:
|
|
origin_candidates.append(item)
|
|
for index, item in enumerate(origin_candidates[:3]):
|
|
plane = _named_plane(origin_names[index])
|
|
plane.pop("name", None)
|
|
if (item_id := _identity_key(item)):
|
|
origin_plane_by_source[item_id] = plane
|
|
if item.get("name") is not None:
|
|
origin_plane_by_name[str(item["name"])] = plane
|
|
blockers = _blockers_by_feature(evidence)
|
|
# Conversion can discover a missing selector even when the exporter did
|
|
# not classify it as a self-validation blocker, so inspect supplied STEP
|
|
# truth for every record. The inspector remains a no-op when no file was
|
|
# requested or found.
|
|
step = StepInspector(step_path)
|
|
cdsl_features: list[dict[str, Any]] = []
|
|
previous_id: str | None = None
|
|
diagnostics: list[dict[str, Any]] = []
|
|
# Sketch source ids whose workplane was successfully rebuilt once their
|
|
# parent reference plane was resolved. Their revolve axis is expressed in
|
|
# sketch-local coordinates and must be transformed to world space too.
|
|
sketch_fix_applied: set[str] = set()
|
|
sketch_source_by_id = {sketch_id: source_id for source_id, sketch_id in sketch_id_by_source.items()}
|
|
|
|
for feature in model_features:
|
|
source_id = _identity_key(feature)
|
|
if not source_id:
|
|
continue
|
|
feature_id = feature_id_by_source[source_id]
|
|
family = _feature_family(feature)
|
|
props, methods = _record_values(feature)
|
|
sketch_id = _parent_sketch_id(feature, sketch_id_by_source, sketch_id_by_name)
|
|
dependencies = _parent_feature_ids(feature, feature_id_by_source, feature_id_by_name, previous_id)
|
|
source_feature_ids_for_current: list[str] = []
|
|
for parent in _record_parents(feature):
|
|
source_feature_id = feature_id_by_source.get(_identity_key(parent) or "") or feature_id_by_name.get(str(parent.get("name") or ""))
|
|
if source_feature_id and source_feature_id in source_feature_ids and source_feature_id not in source_feature_ids_for_current:
|
|
source_feature_ids_for_current.append(source_feature_id)
|
|
unresolved = list(blockers.get(str(feature.get("name") or ""), []))
|
|
selectors = [item for item in (_selector(value, owner_feature_id=feature_id_by_source.get(_identity_key(value) or "")) for value in _record_selections(feature)) if item]
|
|
|
|
if family in {"Boss", "Extrusion", "Cut", "Revolution", "RevCut"} and not sketch_id:
|
|
unresolved.append("missing source sketch parent")
|
|
|
|
if family in {"Boss", "Extrusion", "Cut"}:
|
|
atomic_id = "extrude_cut_blind" if family == "Cut" else ("extrude_add_two_sided" if bool(props.get("BothDirections")) else "extrude_add_blind")
|
|
params = _extrude_params(props, methods)
|
|
# A through-all, up-to-surface, up-to-vertex, or up-to-body
|
|
# extrusion intentionally has no blind depth. Its termination is
|
|
# represented by end_condition, so reporting a zero distance as a
|
|
# missing capture would make valid SolidWorks evidence look broken.
|
|
if params["distance_mm"] <= 0 and params["end_condition"]["type"] in {"blind", "offset_from_surface", "mid_plane", "through_all_and_blind"}:
|
|
unresolved.append("extrude depth was not captured")
|
|
elif family in {"Revolution", "RevCut"}:
|
|
atomic_id = "revolve_cut" if family == "RevCut" else "revolve_add"
|
|
params, more_unresolved = _revolve_params(props, methods)
|
|
unresolved.extend(more_unresolved)
|
|
if params["angle_deg"] <= 0:
|
|
unresolved.append("revolve angle was not captured")
|
|
axis_selector = params.pop("axis_selector", None)
|
|
if axis_selector:
|
|
selectors.append(axis_selector)
|
|
if props.get("Axis") and any("no captured semantic selections" in item for item in unresolved):
|
|
unresolved = [item for item in unresolved if "no captured semantic selections" not in item]
|
|
# When the profile workplane was rebuilt, the captured revolve axis
|
|
# is still in sketch-local coordinates; map it to world space so
|
|
# the cut lands on the resolved parent reference plane.
|
|
if sketch_id and (axis_source_id := sketch_source_by_id.get(sketch_id)) in sketch_fix_applied:
|
|
sketch_workplane = sketch_record_by_id.get(sketch_id)
|
|
axis = params.get("axis")
|
|
if isinstance(axis, dict) and sketch_workplane is not None:
|
|
params["axis"] = _axis_in_world(axis, sketch_workplane["workplane"])
|
|
elif family == "HoleWzd":
|
|
atomic_id = "hole_wizard"
|
|
params, positions = _hole_params(props, methods)
|
|
if params["diameter_mm"] <= 0 or params["depth_mm"] <= 0:
|
|
unresolved.append("hole diameter or depth was not captured")
|
|
host_face = _selector(props.get("Face"))
|
|
if not host_face:
|
|
host_face = step.infer_host_face(positions)
|
|
if host_face:
|
|
params["host_face"] = host_face
|
|
selectors.append(host_face)
|
|
unresolved = [item for item in unresolved if "hole has no captured semantic selections" not in item]
|
|
elif family == "Fillet":
|
|
atomic_id = "fillet"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
elif family == "Chamfer":
|
|
atomic_id = "chamfer"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
elif family == "LPattern":
|
|
atomic_id = "pattern_linear"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
dependencies = list(dict.fromkeys([*dependencies, *params["source_feature_ids"]]))
|
|
if not more_unresolved:
|
|
unresolved = [item for item in unresolved if "pattern has no captured semantic selections" not in item]
|
|
elif family == "MirrorPattern":
|
|
atomic_id = "pattern_mirror"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
dependencies = list(dict.fromkeys([*dependencies, *params["source_feature_ids"]]))
|
|
mirror_plane = params.get("mirror_plane")
|
|
if isinstance(mirror_plane, dict) and mirror_plane.get("kind"):
|
|
selectors.append(mirror_plane)
|
|
if mirror_plane.get("owner_feature_id"):
|
|
dependencies = list(dict.fromkeys([*dependencies, mirror_plane["owner_feature_id"]]))
|
|
if not more_unresolved:
|
|
unresolved = [item for item in unresolved if "pattern has no captured semantic selections" not in item]
|
|
elif family == "RefPlane":
|
|
atomic_id = "reference_plane"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
if isinstance(params.get("plane"), dict) and "unresolved" in params["plane"]:
|
|
inferred = (
|
|
sketch_workplanes_by_parent_source.get(source_id)
|
|
or sketch_workplanes_by_parent_name.get(str(feature.get("name") or ""))
|
|
)
|
|
source = "model_to_sketch_transform"
|
|
if inferred:
|
|
sketch_source_id, plane = inferred
|
|
params["plane"] = plane
|
|
params["derived_from_sketch_id"] = sketch_source_id
|
|
else:
|
|
plane = origin_plane_by_source.get(source_id) or origin_plane_by_name.get(str(feature.get("name") or ""))
|
|
source = "solidworks_origin_plane_order"
|
|
if plane:
|
|
params["plane"] = plane
|
|
params["plane_inference"] = source
|
|
if "unresolved" in params["plane"]:
|
|
for parent in _record_parents(feature):
|
|
parent_id = _referenced_feature_id(parent, feature_id_by_source, feature_id_by_name)
|
|
parent_feature = next((item for item in cdsl_features if item["id"] == parent_id), None)
|
|
parent_plane = (parent_feature or {}).get("params", {}).get("plane")
|
|
if not isinstance(parent_plane, dict) or "origin_mm" not in parent_plane:
|
|
continue
|
|
offset = params["offset_mm"] * (-1.0 if params["reverse"] else 1.0)
|
|
params["plane"] = {
|
|
**parent_plane,
|
|
"origin_mm": [
|
|
round(parent_plane["origin_mm"][index] + offset * parent_plane["normal"][index], 9)
|
|
for index in range(3)
|
|
],
|
|
}
|
|
params["derived_from_feature_id"] = parent_id
|
|
params["plane_inference"] = "parent_reference_plane"
|
|
break
|
|
if "unresolved" not in params["plane"]:
|
|
more_unresolved = [item for item in more_unresolved if item != "reference plane orientation was not captured"]
|
|
unresolved = [item for item in unresolved if item != "reference plane orientation was not captured"]
|
|
plane = params.get("plane")
|
|
if isinstance(plane, dict) and "origin_mm" in plane and "unresolved" not in plane:
|
|
# Profiles whose exporter matrix was unusable are rebuilt from
|
|
# this resolved reference plane (normal + origin) and the axis
|
|
# direction of the profile's own revolve feature.
|
|
for sketch_source_id, ref_source_id in sketch_parent_plane_by_source.items():
|
|
if ref_source_id != source_id or sketch_source_id not in sketch_matrix_unreliable:
|
|
continue
|
|
rebuilt = _workplane_rebuilt(plane, sketch_axis_direction_by_source.get(sketch_source_id))
|
|
if rebuilt is None:
|
|
continue
|
|
sketch_record = sketch_record_by_id.get(sketch_id_by_source.get(sketch_source_id) or "")
|
|
if sketch_record is not None:
|
|
sketch_record["workplane"] = rebuilt
|
|
sketch_fix_applied.add(sketch_source_id)
|
|
for reference in params.get("references", []):
|
|
if reference not in selectors:
|
|
selectors.append(reference)
|
|
elif family == "RefAxis":
|
|
atomic_id = "reference_axis"
|
|
params, more_unresolved = _deferred_params(family, feature, props, methods, source_feature_ids_for_current, feature_id_by_source, feature_id_by_name)
|
|
unresolved.extend(more_unresolved)
|
|
if "unresolved" not in params["axis"]:
|
|
axis_selector = params["axis"].get("selector")
|
|
if isinstance(axis_selector, dict):
|
|
selectors.append(axis_selector)
|
|
for parent in _record_parents(feature):
|
|
parent_id = _referenced_feature_id(parent, feature_id_by_source, feature_id_by_name)
|
|
source_parent = source_by_stable.get(_identity_key(parent) or "") or source_by_name.get(str(parent.get("name") or ""))
|
|
is_plane = bool(source_parent and _feature_family(source_parent) == "RefPlane") or _is_named_coordinate_plane(parent.get("name"))
|
|
selector = _selector(parent, kind_hint="plane", owner_feature_id=parent_id) if is_plane else None
|
|
if selector and selector not in selectors:
|
|
selectors.append(selector)
|
|
else:
|
|
continue
|
|
|
|
execution_status = "supported" if atomic_id in SUPPORTED_ATOMS and sketch_id and not unresolved and sketches and next((item for item in sketches if item["id"] == sketch_id), {}).get("profile", {}).get("type") != "analytic_contours" else "deferred"
|
|
output: dict[str, Any] = {
|
|
"id": feature_id, "name": str(feature.get("name") or feature_id), "atomic_id": atomic_id,
|
|
"depends_on": dependencies, "params": params, "execution_status": execution_status,
|
|
}
|
|
if sketch_id:
|
|
output["sketch_id"] = sketch_id
|
|
if selectors:
|
|
output["selectors"] = selectors
|
|
if unresolved:
|
|
output["unresolved"] = sorted(set(unresolved))
|
|
cdsl_features.append(output)
|
|
diagnostics.append({"feature_id": feature_id, "source_name": output["name"], "atomic_id": atomic_id, "execution_status": execution_status, "unresolved": output.get("unresolved", []), "step_inferred_selector_count": sum(1 for selector in selectors if selector["source"] == "inferred_from_step")})
|
|
previous_id = feature_id
|
|
|
|
if not sketches:
|
|
# The v1 envelope requires one sketch. This explicit empty reference sketch lets
|
|
# reference-only evidence be represented without fabricating a profile.
|
|
default_workplane = _named_plane("default")
|
|
default_workplane.pop("name", None)
|
|
sketches.append({"id": "sk_001", "name": "missing-source-sketch", "role": "reference", "workplane": default_workplane, "profile": {"type": "analytic_contours", "contours": []}})
|
|
if not cdsl_features:
|
|
origin_plane = _named_plane("document origin")
|
|
origin_plane.pop("name", None)
|
|
cdsl_features.append({
|
|
"id": "f_001", "name": "document-origin", "atomic_id": "reference_plane", "depends_on": [],
|
|
"params": {"plane": origin_plane}, "execution_status": "deferred",
|
|
})
|
|
cdsl_features = _topologically_order_features(cdsl_features)
|
|
|
|
part_id = part_id or _part_id_from_source_name(source_name)
|
|
truth = evidence.get("document_truth") or {}
|
|
step_summary = step.summary(truth)
|
|
cdsl = {
|
|
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": part_id,
|
|
"geometry": {"sketches": sketches}, "features": cdsl_features,
|
|
"meta": {
|
|
"unit": "mm", "source": source_name, "source_schema": "solidworks.cad_evidence.v2",
|
|
"source_status": evidence.get("status"), "document_truth": {"mass_properties": truth.get("mass_properties"), "geometry": truth.get("geometry")},
|
|
"step_inference": step_summary, "source_has_modeling_features": bool(model_features),
|
|
},
|
|
}
|
|
validation = validate_semantic_cdsl(cdsl)
|
|
diagnostic = {
|
|
"source": source_name, "part_id": part_id, "source_status": evidence.get("status"),
|
|
"source_self_validation": evidence.get("self_validation"), "step_inference": step_summary,
|
|
"features": diagnostics, "semantic_validation": validation,
|
|
}
|
|
if not cdsl_features:
|
|
diagnostic["note"] = "Evidence contains no modeling features; emitted a valid empty semantic CDSL document."
|
|
return cdsl, diagnostic
|
|
|
|
|
|
def _write_json(path: Path, value: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _convert_path(source: Path, output_dir: Path, truth_dir: Path | None, overwrite: bool,
|
|
source_name: str, truth_relative_path: Path, part_id: str) -> dict[str, Any]:
|
|
cdsl_path = output_dir / f"{part_id}.cdsl.json"
|
|
diagnostic_path = output_dir / f"{part_id}.diagnostic.json"
|
|
if not overwrite and cdsl_path.exists() and diagnostic_path.exists():
|
|
return {
|
|
"part_id": part_id, "source": source_name, "status": "skipped",
|
|
"cdsl": cdsl_path.name, "diagnostic": diagnostic_path.name,
|
|
}
|
|
try:
|
|
evidence = json.loads(source.read_text(encoding="utf-8"))
|
|
step_path = (truth_dir / truth_relative_path) if truth_dir else None
|
|
cdsl, diagnostic = convert_evidence(evidence, source_name=source_name, step_path=step_path, part_id=part_id)
|
|
_write_json(cdsl_path, cdsl)
|
|
_write_json(diagnostic_path, diagnostic)
|
|
return {"part_id": part_id, "source": source_name, "status": "converted", "cdsl": cdsl_path.name, "diagnostic": diagnostic_path.name,
|
|
"future_rebuild_ready": diagnostic["semantic_validation"]["future_rebuild_ready"],
|
|
"deferred_feature_count": len(diagnostic["semantic_validation"]["deferred_feature_ids"]),
|
|
"unresolved_feature_count": len(diagnostic["semantic_validation"]["unresolved"])}
|
|
except Exception as error:
|
|
_write_json(diagnostic_path, {"source": source_name, "part_id": part_id, "error": str(error)})
|
|
return {"part_id": part_id, "source": source_name, "status": "failed", "diagnostic": diagnostic_path.name, "error": str(error)}
|
|
|
|
|
|
def batch_convert(evidence_dir: Path, output_dir: Path, *, truth_dir: Path | None = None, workers: int = 1,
|
|
fail_fast: bool = False, overwrite: bool = False) -> dict[str, Any]:
|
|
sources = sorted(evidence_dir.rglob(f"*{EVIDENCE_V2_SUFFIX}"))
|
|
if not sources:
|
|
raise ValueError(f"No *{EVIDENCE_V2_SUFFIX} files found in {evidence_dir}")
|
|
sources_by_base_id: dict[str, list[Path]] = {}
|
|
for source in sources:
|
|
base_id = _part_id_from_source_name(source.name)
|
|
sources_by_base_id.setdefault(base_id, []).append(source)
|
|
part_id_by_source: dict[Path, str] = {}
|
|
for base_id, matching_sources in sources_by_base_id.items():
|
|
if len(matching_sources) == 1:
|
|
part_id_by_source[matching_sources[0]] = base_id
|
|
continue
|
|
for source in matching_sources:
|
|
relative_name = source.relative_to(evidence_dir).as_posix()
|
|
digest = hashlib.sha256(relative_name.encode("utf-8")).hexdigest()[:10]
|
|
part_id_by_source[source] = f"{base_id[:69]}-{digest}"
|
|
if len(set(part_id_by_source.values())) != len(part_id_by_source):
|
|
raise ValueError("Unable to create unique output part IDs from evidence source paths")
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
def convert(source: Path) -> dict[str, Any]:
|
|
relative = source.relative_to(evidence_dir)
|
|
step_relative = Path(str(relative).removesuffix(EVIDENCE_V2_SUFFIX) + ".step")
|
|
return _convert_path(source, output_dir, truth_dir, overwrite, relative.as_posix(), step_relative,
|
|
part_id_by_source[source])
|
|
results: list[dict[str, Any]] = []
|
|
if workers == 1:
|
|
for source in sources:
|
|
item = convert(source)
|
|
results.append(item)
|
|
if fail_fast and item["status"] == "failed":
|
|
break
|
|
else:
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
for item in executor.map(convert, sources):
|
|
results.append(item)
|
|
if fail_fast and item["status"] == "failed":
|
|
break
|
|
manifest = {
|
|
"schema": "cdsl.evidence-v2.batch-manifest.v1", "input_directory": str(evidence_dir),
|
|
"truth_directory": str(truth_dir) if truth_dir else None, "input_count": len(sources),
|
|
"converted_count": sum(item["status"] == "converted" for item in results),
|
|
"skipped_count": sum(item["status"] == "skipped" for item in results),
|
|
"failed_count": sum(item["status"] == "failed" for item in results),
|
|
"future_rebuild_ready_count": sum(bool(item.get("future_rebuild_ready")) for item in results),
|
|
"unresolved_feature_count": sum(int(item.get("unresolved_feature_count") or 0) for item in results),
|
|
"deferred_feature_count": sum(int(item.get("deferred_feature_count") or 0) for item in results),
|
|
"results": results,
|
|
}
|
|
_write_json(output_dir / "manifest.json", manifest)
|
|
if manifest["failed_count"]:
|
|
raise RuntimeError(f"{manifest['failed_count']} conversion(s) failed; see {output_dir / 'manifest.json'}")
|
|
return manifest
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("evidence_dir", type=Path, help="directory containing *.solidworks_evidence_v2.json")
|
|
parser.add_argument("--out", type=Path, default=ROOT / "json_to_cdsl" / "output", help="output CDSL directory")
|
|
parser.add_argument("--truth-dir", type=Path, default=None, help="optional directory containing <part_id>.step truth files")
|
|
parser.add_argument("--workers", type=int, default=1, help="parallel conversion workers; STEP inspection remains conservative")
|
|
parser.add_argument("--fail-fast", action="store_true", help="stop after the first conversion failure")
|
|
parser.add_argument("--overwrite", action="store_true", help="overwrite existing per-part outputs")
|
|
args = parser.parse_args()
|
|
if args.workers < 1:
|
|
parser.error("--workers must be >= 1")
|
|
manifest = batch_convert(args.evidence_dir, args.out, truth_dir=args.truth_dir, workers=args.workers,
|
|
fail_fast=args.fail_fast, overwrite=args.overwrite)
|
|
print(json.dumps({key: manifest[key] for key in manifest if key != "results"}, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|