添加cadfs_to_cdsl代码,优化cad生成
This commit is contained in:
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import argparse, json
|
||||
from pathlib import Path
|
||||
from .pipeline import load_samples, run_stage, scan, select_samples
|
||||
from .reports import generate_reports, read_json
|
||||
from .reports import generate_markdown_report, generate_reports, read_json
|
||||
|
||||
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
|
||||
DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
|
||||
@@ -25,6 +25,8 @@ def _parser() -> argparse.ArgumentParser:
|
||||
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
|
||||
command.add_argument("--force", action="store_true")
|
||||
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
|
||||
if name == "report":
|
||||
command.add_argument("--markdown", action="store_true", help="also write full_run_report.md")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -35,6 +37,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.command == "report":
|
||||
manifest = args.output / "manifest.jsonl"; records = [json.loads(line) for line in manifest.read_text().splitlines() if line.strip()]
|
||||
result = generate_reports(args.output, records)
|
||||
if args.markdown:
|
||||
command_text = "python -m cadfs_to_cdsl report --markdown"
|
||||
result = {**result, "markdown_report": str(generate_markdown_report(args.output, records, input_root=args.input, command=command_text))}
|
||||
else:
|
||||
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
|
||||
samples = select_samples(load_samples(args.input, args.output), sample_ids=args.sample_id, offset=args.offset, limit=args.limit, seed=args.seed)
|
||||
|
||||
+112
-26
@@ -24,6 +24,11 @@ class LoweringResult:
|
||||
history: list[dict[str, Any]]
|
||||
|
||||
|
||||
class UnsupportedCapability(ValueError):
|
||||
def __init__(self, capability: str, message: str):
|
||||
super().__init__(message); self.capability = capability
|
||||
|
||||
|
||||
def plain(value: Any) -> Any:
|
||||
if isinstance(value, Call): return {"call": value.name, "args": [plain(arg) for arg in value.args], "line": value.line}
|
||||
if isinstance(value, list): return [plain(item) for item in value]
|
||||
@@ -38,7 +43,7 @@ def _bool(value: Any) -> bool:
|
||||
def _number(value: Any, units: bool = False) -> float:
|
||||
if isinstance(value, (float, int)): return float(value)
|
||||
if isinstance(value, str):
|
||||
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8}
|
||||
constants = {"mm": 1., "millimeter": 1., "cm": 10., "m": 1000., "inch": 25.4, "in": 25.4, "ft": 304.8, "degree": 1.}
|
||||
if value in constants: return constants[value]
|
||||
return float(value)
|
||||
if isinstance(value, Call) and value.name == "__binary__":
|
||||
@@ -71,19 +76,40 @@ def _shift_plane(plane: dict[str, Any], distance: float) -> dict[str, Any]:
|
||||
return {**plane, "origin_mm": [plane["origin_mm"][i] + plane["normal"][i]*distance for i in range(3)]}
|
||||
|
||||
|
||||
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
||||
def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float = 0.0) -> dict[str, Any]:
|
||||
shifted = _shift_plane(plane, distance)
|
||||
return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]}
|
||||
|
||||
|
||||
def _plane_from_query(value: Any, feature_frames: dict[str, dict[str, Any]], sketch_by_source: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
for call in walk_calls(value):
|
||||
if call.name in {"makeId", "qCreatedBy"}:
|
||||
text = " ".join(symbolic_string(arg) for arg in call.args)
|
||||
for name, plane in PLANES.items():
|
||||
if f"{name}.planeOp" in text: return dict(plane)
|
||||
query = parse_query(value)
|
||||
if query.topology_type == "IMPRINT" and sketch_by_source and query.source_sketch in sketch_by_source:
|
||||
return dict(sketch_by_source[query.source_sketch]["workplane"])
|
||||
frame = feature_frames.get(query.owner_feature or "")
|
||||
if frame:
|
||||
if frame and query.topology_type == "CAP_FACE":
|
||||
return dict(frame["start" if query.is_start is not False else "end"])
|
||||
if frame and frame.get("start") == frame.get("end") and "qCreatedBy" in query.calls:
|
||||
return dict(frame["start"])
|
||||
raise ValueError("unsupported or unresolved sketch workplane")
|
||||
|
||||
|
||||
def _bound_name(value: Any) -> str:
|
||||
return str(value or "BLIND").split(".")[-1].upper()
|
||||
|
||||
|
||||
def _end_condition(value: Any) -> dict[str, Any]:
|
||||
name = _bound_name(value)
|
||||
if name == "BLIND": return {"type": "blind", "solidworks_code": 0}
|
||||
if name == "SYMMETRIC": return {"type": "mid_plane", "solidworks_code": 8}
|
||||
if name == "THROUGH_ALL": return {"type": "through_all", "solidworks_code": 1}
|
||||
raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}")
|
||||
|
||||
|
||||
def _arc(start: list[float], mid: list[float], end: list[float]) -> dict[str, Any]:
|
||||
ax, ay = start; bx, by = mid; cx, cy = end
|
||||
d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
|
||||
@@ -134,7 +160,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An
|
||||
if len(segments) == 1 and segments[0]["type"] == "circle" and not explicit_construction:
|
||||
profile = {"type": "circle", "center": segments[0]["center"], "radius_mm": segments[0]["radius_mm"]}
|
||||
else:
|
||||
contours, construction = _contours(segments); construction.extend(explicit_construction)
|
||||
contours, open_segments = _contours(segments)
|
||||
if open_segments:
|
||||
raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)")
|
||||
construction = list(explicit_construction)
|
||||
if not contours:
|
||||
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
|
||||
@@ -164,6 +193,13 @@ def _source_sketch(params: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile_query_kind(params: dict[str, Any]) -> str | None:
|
||||
for key in ("entities", "sheetProfilesArray"):
|
||||
if key in params:
|
||||
return parse_query(params[key]).topology_type
|
||||
return None
|
||||
|
||||
|
||||
def _profile_executable(sketch: dict[str, Any]) -> bool:
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "circle": return True
|
||||
@@ -188,7 +224,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
if isinstance(step, SketchIR):
|
||||
history.append({"feature_id": step.feature_id, "operation": "newSketch", "parameters": {"sketchPlane": plain(step.workplane)}, "entities": [{"entity_id": e.feature_id, "operation": e.operation, "parameters": plain(e.params)} for e in step.entities]})
|
||||
try:
|
||||
plane = _plane_from_query(step.workplane, feature_frames)
|
||||
plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source)
|
||||
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
|
||||
@@ -200,20 +236,52 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
try:
|
||||
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
|
||||
if item.operation == "cPlane":
|
||||
base = _plane_from_query(p.get("entities"), feature_frames); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
|
||||
plane_type = str(p.get("cplaneType") or "OFFSET").split(".")[-1].upper()
|
||||
if plane_type != "OFFSET":
|
||||
raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter only supports exact OFFSET reference planes, not {plane_type}")
|
||||
base = _plane_from_query(p.get("entities"), feature_frames, sketch_by_source); offset = _number(p.get("offset", 0), True); plane = _shift_plane(base, offset)
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane, "offset_mm": offset}, "execution_status": "supported"}
|
||||
feature_frames[item.feature_id] = {"start": plane, "end": plane}
|
||||
elif item.operation == "extrude":
|
||||
if p.get("surfaceOperationType") is not None:
|
||||
raise UnsupportedCapability("extrude_surface_or_mixed", "current CDSL engine has no exact surface or mixed solid/surface extrusion operation")
|
||||
profile_kind = _profile_query_kind(p)
|
||||
if profile_kind and profile_kind not in {"IMPRINT"}:
|
||||
raise UnsupportedCapability(f"extrude_profile_topology:{profile_kind.lower()}", f"current CDSL engine cannot exactly replay an extrude profile selected from {profile_kind}")
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("extrude sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("extrude sketch has no closed profile")
|
||||
depth = _number(p.get("depth"), True); operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection"))
|
||||
atomic = "extrude_cut_blind" if any(x in operation for x in ("REMOVE", "CUT")) else "extrude_add_two_sided" if _bool(p.get("hasSecondDirection")) else "extrude_add_blind"
|
||||
params = {"distance_mm": depth, "reverse": reverse}
|
||||
if atomic == "extrude_add_two_sided": params["reverse_distance_mm"] = _number(p.get("secondDirectionDepth", depth), True)
|
||||
end = _end_condition("SYMMETRIC" if _bool(p.get("symmetric")) else p.get("endBound"))
|
||||
depth_value = p.get("depth"); depth = _number(depth_value, True) if depth_value is not None else 1.0
|
||||
operation = str(p.get("operationType") or "NEW").upper(); reverse = _bool(p.get("oppositeDirection")); cutting = any(x in operation for x in ("REMOVE", "CUT"))
|
||||
second = _bool(p.get("hasSecondDirection"))
|
||||
if cutting and (second or end["type"] != "blind"):
|
||||
capability = "extrude_cut_two_sided" if second or end["type"] == "mid_plane" else f"extrude_cut_{end['type']}"
|
||||
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
|
||||
if not cutting and not second and end["type"] not in {"blind", "mid_plane"}:
|
||||
capability = f"extrude_add_{end['type']}"
|
||||
raise UnsupportedCapability(capability, f"current CDSL atomic set has no exact {capability} operation")
|
||||
if second:
|
||||
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
|
||||
reverse_depth = _number(p.get("secondDirectionDepth", depth), True)
|
||||
params.update({"reverse_distance_mm": reverse_depth, "reverse_end_condition": _end_condition(p.get("secondDirectionBound"))})
|
||||
elif end["type"] == "mid_plane" and not cutting:
|
||||
blind = _end_condition("BLIND")
|
||||
atomic = "extrude_add_two_sided"; params = {"distance_mm": depth / 2, "reverse_distance_mm": depth / 2, "reverse": reverse, "end_condition": blind, "reverse_end_condition": dict(blind)}
|
||||
else:
|
||||
atomic = "extrude_cut_blind" if cutting else "extrude_add_blind"
|
||||
params = {"distance_mm": depth, "reverse": reverse, "end_condition": end}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": params, "execution_status": "supported"}
|
||||
plane = sketch_by_source[source]["workplane"]; feature_frames[item.feature_id] = {"start": plane, "end": _shift_plane(plane, -depth if reverse else depth)}
|
||||
plane = sketch_by_source[source]["workplane"]
|
||||
if end["type"] == "blind" and not second:
|
||||
direction = -1 if reverse else 1
|
||||
feature_frames[item.feature_id] = {"start": dict(plane), "end": _shift_plane(plane, direction * depth)}
|
||||
elif end["type"] == "mid_plane":
|
||||
direction = -1 if reverse else 1
|
||||
feature_frames[item.feature_id] = {"start": _shift_plane(plane, -direction * depth / 2), "end": _shift_plane(plane, direction * depth / 2)}
|
||||
elif item.operation == "revolve":
|
||||
if p.get("surfaceOperationType") is not None and p.get("operationType") is None:
|
||||
raise UnsupportedCapability("revolve_surface", "current CDSL engine has no exact surface-revolve operation")
|
||||
source = _source_sketch(p)
|
||||
if not source or source not in sketch_by_source: raise ValueError("revolve sketch query is unresolved")
|
||||
if not _profile_executable(sketch_by_source[source]): raise ValueError("revolve sketch has no closed profile")
|
||||
@@ -223,17 +291,24 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
direction = [end[i]-start[i] for i in range(3)]; norm = math.sqrt(sum(x*x for x in direction)); direction = [x/norm for x in direction]
|
||||
operation = str(p.get("operationType") or p.get("surfaceOperationType") or "NEW").upper(); atomic = "revolve_cut" if "REMOVE" in operation else "revolve_add"
|
||||
full = "FULL" in str(p.get("revolveType") or "FULL").upper(); angle = 360.0 if full else _number(p.get("angle", 360.0))
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": atomic, "depends_on": depends, "sketch_id": sketch_by_source[source]["id"], "params": {"angle_deg": angle, "reverse": _bool(p.get("oppositeDirection")), "axis": {"origin_mm": start, "direction": direction}}, "execution_status": "supported"}
|
||||
elif item.operation in {"fillet", "chamfer"}:
|
||||
key = "radius" if item.operation == "fillet" else "width"; amount = _number(p.get(key), True); selectors = []
|
||||
key = "radius" if item.operation == "fillet" else "width"
|
||||
chamfer_type = str(p.get("chamferType") or "EQUAL_OFFSETS").split(".")[-1].upper()
|
||||
amount_value = p.get(key)
|
||||
if item.operation == "chamfer" and chamfer_type == "TWO_OFFSETS": amount_value = p.get("width1")
|
||||
amount = _number(amount_value, True); selectors = []
|
||||
for index, query_value in enumerate(_queries(p.get("entities"))):
|
||||
query = parse_query(query_value); owner = query.owner_feature
|
||||
if not owner: raise ValueError("selector owner is unresolved")
|
||||
selector_kind = "face" if query.kind in {"face", "entitytype.face"} or query.topology_type in {"CAP_FACE", "SWEPT_FACE"} else "edge"
|
||||
refs = _source_refs(query_value)
|
||||
source_entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
|
||||
geometry: dict[str, Any] = {}
|
||||
frame = feature_frames.get(owner); cap = frame and frame["start" if query.is_start else "end"]
|
||||
if source_entity and cap:
|
||||
if selector_kind == "face" and query.topology_type == "CAP_FACE" and cap:
|
||||
geometry = {"normal": cap["normal"], "plane_offset_mm": sum(cap["normal"][i] * cap["origin_mm"][i] for i in range(3))}
|
||||
if selector_kind == "edge" and source_entity and cap:
|
||||
if query.topology_type == "SWEPT_EDGE" and frame:
|
||||
local_point = source_entity.get("point") if source_entity["type"] == "point" else None
|
||||
if len(refs) >= 2:
|
||||
@@ -244,20 +319,28 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
start, end = _global(frame["start"], local_point), _global(frame["end"], local_point)
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
elif source_entity["type"] == "circle":
|
||||
# OCC/build123d commonly splits a closed circular edge into four
|
||||
# quarter-circle records. Bind all four deterministic arc centres.
|
||||
radius = source_entity["radius_mm"]; center = source_entity["center"]
|
||||
for quadrant, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))):
|
||||
local = [center[0] + sx*radius/math.sqrt(2), center[1] + sy*radius/math.sqrt(2)]
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}_{quadrant}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": {"curve_type": "circle", "center_mm": _global(cap, local)}})
|
||||
# Keep the source circle signature until the prefix has been
|
||||
# rebuilt. OCC may expose it as one edge or several arcs.
|
||||
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry or {"curve_type": "circle", "source_circle_center_mm": _global(cap, source_entity["center"]), "source_circle_radius_mm": source_entity["radius_mm"], "source_plane_normal": cap["normal"]}})
|
||||
continue
|
||||
elif source_entity["type"] == "line":
|
||||
start, end = _global(cap, source_entity["start"]), _global(cap, source_entity["end"])
|
||||
geometry = {"curve_type": "line", "bbox_mm": [min(start[i], end[i]) for i in range(3)] + [max(start[i], end[i]) for i in range(3)]}
|
||||
if not geometry: raise ValueError("selector geometry is unresolved")
|
||||
selectors.append({"kind": "edge", "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
|
||||
if selector_kind == "face" and not geometry:
|
||||
raise ValueError(f"{query.topology_type or 'face'} selector geometry is unresolved")
|
||||
selectors.append({"kind": selector_kind, "owner_feature_id": f"f_{owner}", "stable_id": f"cadfs_{fid}_{index}", "source": "runtime_snapshot", "confidence": 1.0, "geometry": geometry})
|
||||
params = {"radius_mm" if item.operation == "fillet" else "distance_mm": amount}
|
||||
if item.operation == "fillet": params["tangent_propagation"] = _bool(p.get("tangentPropagation"))
|
||||
elif chamfer_type == "TWO_OFFSETS":
|
||||
second = _number(p.get("width2"), True)
|
||||
if _bool(p.get("oppositeDirection")): params["distance_mm"], second = second, params["distance_mm"]
|
||||
params["distance_2_mm"] = second
|
||||
elif chamfer_type == "OFFSET_ANGLE":
|
||||
angle = math.radians(_number(p.get("angle")))
|
||||
if _bool(p.get("oppositeDirection")):
|
||||
second = amount * math.tan(angle); params["distance_mm"] = second; params["distance_2_mm"] = amount
|
||||
else: params["angle_rad"] = angle
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": item.operation, "depends_on": depends, "params": params, "selectors": selectors, "execution_status": "supported"}
|
||||
elif item.operation == "hole":
|
||||
locations = _queries(p.get("locations")); positions = []; host_plane = None
|
||||
@@ -274,11 +357,12 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
depth_value = p.get("holeDepth") or p.get("tappedDepth")
|
||||
if condition == "blind" and depth_value is None: raise ValueError("blind hole depth is unresolved")
|
||||
depth = _number(depth_value, True) if depth_value is not None else 1.0
|
||||
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": 1}, "positions": positions, "host_face": {"frame": frame}}
|
||||
if "COUNTERSINK" in style.upper():
|
||||
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or 90.0))}
|
||||
if "COUNTERBORE" in style.upper():
|
||||
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth"), True)}
|
||||
condition_code = {"blind": 0, "through_all": 1, "through_all_both": 2}[condition]
|
||||
hole_params: dict[str, Any] = {"hole_type": style, "diameter_mm": _number(p.get("holeDiameter"), True), "depth_mm": depth, "end_condition": {"type": condition, "solidworks_code": condition_code}, "positions": positions, "host_face": {"frame": frame}}
|
||||
if style.upper() in {"COUNTERSINK", "C_SINK"}:
|
||||
hole_params["countersink"] = {"diameter_mm": _number(p.get("countersinkDiameter") or p.get("cSinkDiameter") or p.get("majorDiameter"), True), "angle_rad": math.radians(_number(p.get("countersinkAngle") or p.get("cSinkAngle") or 90.0))}
|
||||
if style.upper() in {"COUNTERBORE", "C_BORE"}:
|
||||
hole_params["counterbore"] = {"diameter_mm": _number(p.get("counterboreDiameter") or p.get("cBoreDiameter") or p.get("majorDiameter"), True), "depth_mm": _number(p.get("counterboreDepth") or p.get("cBoreDepth"), True)}
|
||||
if _bool(p.get("isTappedThrough")) or p.get("tapSize") is not None: hole_params["thread"] = {"source": "CADFS", "decorative": True}
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "hole_wizard", "depends_on": depends, "params": hole_params, "execution_status": "supported"}
|
||||
elif item.operation == "mirror":
|
||||
@@ -304,6 +388,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
else:
|
||||
raise ValueError(f"operation mapping not implemented: {item.operation}")
|
||||
features.append(feature); previous.append(fid)
|
||||
except UnsupportedCapability as exc:
|
||||
diagnostics.append({"code": "unsupported_engine_capability", "capability": exc.capability, "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
|
||||
except Exception as exc:
|
||||
diagnostics.append({"code": "feature_deferred", "feature_id": item.feature_id, "operation": item.operation, "message": str(exc)}); complete = False
|
||||
if not features: return LoweringResult(None, "deferred_no_executable_feature", diagnostics, history)
|
||||
|
||||
+21
-10
@@ -11,7 +11,7 @@ from .lowering import lower_model
|
||||
from .rebuild import rebuild_candidate
|
||||
from .reports import generate_reports, read_json, write_json, write_manifest
|
||||
|
||||
PIPELINE_VERSION = "cadfs_to_cdsl.v1"
|
||||
PIPELINE_VERSION = "cadfs_to_cdsl.v2"
|
||||
|
||||
|
||||
def _fingerprint(sample: Sample) -> str:
|
||||
@@ -55,6 +55,9 @@ def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[st
|
||||
cached = read_json(status_path)
|
||||
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
|
||||
return cached
|
||||
if force:
|
||||
for name in ("candidate.cdsl.json", "bound.cdsl.json", "rebuild.step", "rebuild.json", "rebuild.worker.json", "comparison.json", "comparison.worker.json"):
|
||||
(directory / name).unlink(missing_ok=True)
|
||||
diagnostics = list(sample.diagnostics)
|
||||
try:
|
||||
feature_path = Path(sample.files["featurescript"])
|
||||
@@ -85,7 +88,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
|
||||
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
|
||||
|
||||
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> bool:
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
result_path.unlink(missing_ok=True)
|
||||
context = multiprocessing.get_context("spawn")
|
||||
process = context.Process(target=target, args=args)
|
||||
@@ -93,8 +96,8 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec
|
||||
if process.is_alive():
|
||||
process.terminate(); process.join(5)
|
||||
if process.is_alive(): process.kill(); process.join()
|
||||
return False
|
||||
return process.exitcode == 0 and result_path.exists()
|
||||
return "timeout"
|
||||
return "completed" if process.exitcode == 0 and result_path.exists() else "failed"
|
||||
|
||||
|
||||
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
|
||||
@@ -105,11 +108,16 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
|
||||
if not force and rebuild_path.exists() and status.get("rebuild_status"):
|
||||
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
|
||||
worker_result = directory / "rebuild.worker.json"
|
||||
completed = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: result = read_json(worker_result); worker_result.unlink(missing_ok=True)
|
||||
outcome = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
|
||||
if outcome == "completed":
|
||||
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
|
||||
bound_cdsl = result.pop("bound_cdsl", None)
|
||||
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
|
||||
else:
|
||||
step_path.unlink(missing_ok=True)
|
||||
result = {"status": "rebuild_timeout", "error": {"type": "TimeoutError", "message": f"rebuild exceeded {timeout_seconds:g} seconds"}}
|
||||
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
|
||||
message = f"rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "rebuild worker exited without a result"
|
||||
result = {"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", "error": {"type": error_type, "message": message}}
|
||||
write_json(rebuild_path, result)
|
||||
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
|
||||
write_json(status_path, status); return status
|
||||
@@ -123,10 +131,13 @@ def compare_one(sample: Sample, output: Path, *, force: bool = False, compare_mo
|
||||
if not force and status.get("status") in {"comparison_failed", "comparison_timeout"}: return status
|
||||
if force or not comparison_path.exists():
|
||||
worker_result = directory / "comparison.worker.json"
|
||||
completed = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
|
||||
if completed: comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
|
||||
outcome = _isolated(_compare_worker, (sample.files["step"], str(directory / "rebuild.step"), str(worker_result)), worker_result, timeout_seconds)
|
||||
if outcome == "completed": comparison = read_json(worker_result); worker_result.unlink(missing_ok=True); write_json(comparison_path, comparison)
|
||||
else:
|
||||
status["status"] = "comparison_timeout"; status["comparison_error"] = {"type": "TimeoutError", "message": f"comparison exceeded {timeout_seconds:g} seconds"}; write_json(status_path, status); return status
|
||||
status["status"] = "comparison_timeout" if outcome == "timeout" else "comparison_failed"
|
||||
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
|
||||
message = f"comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "comparison worker exited without a result"
|
||||
status["comparison_error"] = {"type": error_type, "message": message}; write_json(status_path, status); return status
|
||||
else: comparison = read_json(comparison_path)
|
||||
status["comparison_decision"] = comparison["decision"]
|
||||
accepted = comparison[compare_mode]["passed"]
|
||||
|
||||
@@ -6,13 +6,15 @@ from typing import Any
|
||||
|
||||
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
|
||||
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
from .selector_binding import bind_candidate_selectors
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
|
||||
if not analysis.runtime_eligible:
|
||||
return {"status": "runtime_ineligible", "analysis": analysis_dict}
|
||||
try:
|
||||
result = rebuild_cdsl(cdsl, output, strict=True)
|
||||
return {"status": "rebuilt", "analysis": analysis_dict, "result": result}
|
||||
bound, binding = bind_candidate_selectors(cdsl)
|
||||
result = rebuild_cdsl(bound, output, strict=True)
|
||||
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
|
||||
except Exception as exc:
|
||||
detail = {"type": type(exc).__name__, "message": str(exc)}
|
||||
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
|
||||
|
||||
+314
-2
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv, json
|
||||
import csv, json, re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +32,8 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
|
||||
if diagnostics_path.exists():
|
||||
for diagnostic in read_json(diagnostics_path):
|
||||
reasons[str(diagnostic.get("code") or "unknown")] += 1
|
||||
if diagnostic.get("operation"): gaps[str(diagnostic["operation"])].append(str(item["sample_id"]))
|
||||
gap = diagnostic.get("capability") or diagnostic.get("operation")
|
||||
if gap: gaps[str(gap)].append(str(item["sample_id"]))
|
||||
history_path = sample_dir / "history.json"
|
||||
if history_path.exists():
|
||||
for step in read_json(history_path): operations[str(step.get("operation") or "unknown")] += 1
|
||||
@@ -48,3 +50,313 @@ def generate_reports(output: Path, records: list[dict[str, Any]]) -> dict[str, A
|
||||
with (output / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=["sample_id", "decision", "bbox_max_delta_mm", "volume_relative_error", "surface_area_relative_error"]); writer.writeheader(); writer.writerows(rows)
|
||||
return summary
|
||||
|
||||
|
||||
def _pct(count: int, total: int) -> str:
|
||||
return "0.00%" if total <= 0 else f"{count / total * 100:.2f}%"
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[Any]]) -> list[str]:
|
||||
lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
|
||||
for row in rows:
|
||||
lines.append("| " + " | ".join(str(value).replace("\n", " ") for value in row) + " |")
|
||||
return lines
|
||||
|
||||
|
||||
def _read_optional_json(path: Path) -> Any | None:
|
||||
return read_json(path) if path.exists() else None
|
||||
|
||||
|
||||
def _normalize_error(message: str) -> str:
|
||||
message = re.sub(r"/Users/[^ ]+", "<path>", message)
|
||||
message = re.sub(r"0x[0-9a-fA-F]+", "0x...", message)
|
||||
message = re.sub(r"\d+\.\d{4,}", "<float>", message)
|
||||
return message[:180] if len(message) > 180 else message
|
||||
|
||||
|
||||
def generate_markdown_report(
|
||||
output: Path,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
input_root: Path | None = None,
|
||||
command: str | None = None,
|
||||
report_name: str = "full_run_report.md",
|
||||
) -> Path:
|
||||
summary = generate_reports(output, records)
|
||||
total = len(records)
|
||||
sample_root = output / "samples"
|
||||
status_records = []
|
||||
modality_missing: Counter[str] = Counter()
|
||||
alignment_fallbacks = 0
|
||||
dataset_index = _read_optional_json(output / "dataset_index.json") or {}
|
||||
indexed_records = {str(item.get("sample_id")): item for item in dataset_index.get("records") or []}
|
||||
for record in records:
|
||||
indexed = indexed_records.get(str(record["sample_id"]), {})
|
||||
for diagnostic in indexed.get("diagnostics") or record.get("diagnostics") or []:
|
||||
if diagnostic.get("code") == "missing_modality":
|
||||
modality_missing[str(diagnostic.get("modality") or "unknown")] += 1
|
||||
if diagnostic.get("code") == "alignment_fallback":
|
||||
alignment_fallbacks += 1
|
||||
status = _read_optional_json(sample_root / str(record["sample_id"]) / "status.json") or record
|
||||
status_records.append(status)
|
||||
|
||||
statuses = Counter(str(item.get("status") or "unknown") for item in status_records)
|
||||
conversion_statuses = Counter(str(item.get("conversion_status") or "missing") for item in status_records)
|
||||
file_counts = {
|
||||
"candidate.cdsl.json": sum(1 for _ in sample_root.glob("*/candidate.cdsl.json")),
|
||||
"bound.cdsl.json": sum(1 for _ in sample_root.glob("*/bound.cdsl.json")),
|
||||
"rebuild.step": sum(1 for _ in sample_root.glob("*/rebuild.step")),
|
||||
"comparison.json": sum(1 for _ in sample_root.glob("*/comparison.json")),
|
||||
"status.json": sum(1 for _ in sample_root.glob("*/status.json")),
|
||||
}
|
||||
|
||||
comparison_decisions: Counter[str] = Counter()
|
||||
strict_pass = 0
|
||||
rp_pass = 0
|
||||
comparison_failures: Counter[str] = Counter()
|
||||
comparison_errors: Counter[str] = Counter()
|
||||
comparison_error_examples: dict[str, list[str]] = defaultdict(list)
|
||||
metric_rows = []
|
||||
for status in status_records:
|
||||
error = status.get("comparison_error") or {}
|
||||
if error:
|
||||
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
|
||||
comparison_errors[key] += 1
|
||||
if len(comparison_error_examples[key]) < 5:
|
||||
comparison_error_examples[key].append(str(status.get("sample_id") or "unknown"))
|
||||
for path in sample_root.glob("*/comparison.json"):
|
||||
comparison = _read_optional_json(path)
|
||||
if not comparison:
|
||||
continue
|
||||
sample_id = path.parent.name
|
||||
comparison_decisions[str(comparison.get("decision") or "unknown")] += 1
|
||||
strict_pass += 1 if ((comparison.get("strict") or {}).get("passed")) else 0
|
||||
rp_pass += 1 if ((comparison.get("rp") or {}).get("passed")) else 0
|
||||
for reason in ((comparison.get("raw") or {}).get("failure_reasons") or []):
|
||||
comparison_failures[str(reason)] += 1
|
||||
metrics = ((comparison.get("raw") or {}).get("metrics") or {})
|
||||
metric_rows.append((
|
||||
sample_id,
|
||||
comparison.get("decision"),
|
||||
metrics.get("bbox_max_delta_mm"),
|
||||
metrics.get("volume_relative_error"),
|
||||
metrics.get("surface_area_relative_error"),
|
||||
))
|
||||
|
||||
rebuild_statuses: Counter[str] = Counter()
|
||||
rebuild_errors: Counter[str] = Counter()
|
||||
rebuild_error_examples: dict[str, list[str]] = defaultdict(list)
|
||||
for path in sample_root.glob("*/rebuild.json"):
|
||||
rebuild = _read_optional_json(path)
|
||||
if not rebuild:
|
||||
continue
|
||||
rebuild_statuses[str(rebuild.get("status") or "unknown")] += 1
|
||||
error = rebuild.get("error") or {}
|
||||
if error:
|
||||
key = f"{error.get('type') or 'Error'}: {_normalize_error(str(error.get('message') or ''))}"
|
||||
rebuild_errors[key] += 1
|
||||
if len(rebuild_error_examples[key]) < 5:
|
||||
rebuild_error_examples[key].append(path.parent.name)
|
||||
|
||||
diagnostic_counts: Counter[str] = Counter()
|
||||
diagnostic_examples: dict[str, list[str]] = defaultdict(list)
|
||||
capability_examples: dict[str, list[str]] = defaultdict(list)
|
||||
capability_source: dict[str, str] = {}
|
||||
for record in records:
|
||||
sample_id = str(record["sample_id"])
|
||||
diagnostics = _read_optional_json(sample_root / sample_id / "diagnostics.json") or []
|
||||
for diagnostic in diagnostics:
|
||||
code = str(diagnostic.get("code") or "unknown")
|
||||
diagnostic_counts[code] += 1
|
||||
if len(diagnostic_examples[code]) < 8:
|
||||
diagnostic_examples[code].append(sample_id)
|
||||
capability = diagnostic.get("capability") or diagnostic.get("operation")
|
||||
if capability:
|
||||
capability = str(capability)
|
||||
if len(capability_examples[capability]) < 10:
|
||||
capability_examples[capability].append(sample_id)
|
||||
capability_source.setdefault(capability, str(diagnostic.get("operation") or capability))
|
||||
|
||||
gap_payload = _read_optional_json(output / "capability_gaps.json") or {}
|
||||
top_gaps = sorted(
|
||||
((name, int(value.get("sample_count") or 0), ", ".join((value.get("sample_ids") or [])[:8])) for name, value in gap_payload.items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
|
||||
unsupported_ops = {
|
||||
"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
|
||||
"moveFace", "replaceFace", "deleteFace", "import", "derive",
|
||||
}
|
||||
exact_mappings = {
|
||||
"extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind",
|
||||
"revolve": "revolve_add / revolve_cut",
|
||||
"fillet": "fillet",
|
||||
"chamfer": "chamfer",
|
||||
"hole": "hole_wizard",
|
||||
"mirror": "pattern_mirror",
|
||||
"cPlane": "reference_plane (OFFSET only)",
|
||||
}
|
||||
try:
|
||||
from engine.cdsl_engine.runtime import EXECUTORS
|
||||
engine_atomic_ids = sorted(EXECUTORS)
|
||||
except Exception:
|
||||
engine_atomic_ids = []
|
||||
|
||||
operation_rows = sorted(
|
||||
((name, count) for name, count in (summary.get("operation_counts") or {}).items()),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)
|
||||
status_rows = [[name, count, _pct(count, total)] for name, count in sorted(statuses.items(), key=lambda item: (-item[1], item[0]))]
|
||||
conversion_rows = [[name, count, _pct(count, total)] for name, count in sorted(conversion_statuses.items(), key=lambda item: (-item[1], item[0]))]
|
||||
|
||||
lines: list[str] = [
|
||||
"# CADFS full conversion report",
|
||||
"",
|
||||
f"- Generated at: {datetime.now().isoformat(timespec='seconds')}",
|
||||
f"- Input: `{input_root}`" if input_root else "- Input: not recorded",
|
||||
f"- Output: `{output}`",
|
||||
f"- Command: `{command}`" if command else "- Command: not recorded",
|
||||
f"- Total samples: {total}",
|
||||
f"- Manifest rows: {sum(1 for _ in (output / 'manifest.jsonl').open(encoding='utf-8')) if (output / 'manifest.jsonl').exists() else 'missing'}",
|
||||
"",
|
||||
"## Acceptance summary",
|
||||
"",
|
||||
f"- RP accepted samples: {rp_pass} ({_pct(rp_pass, total)})",
|
||||
f"- Strict accepted samples: {strict_pass} ({_pct(strict_pass, total)})",
|
||||
f"- Rebuilt STEP files present: {file_counts['rebuild.step']}",
|
||||
f"- Comparison reports present: {file_counts['comparison.json']}",
|
||||
f"- Candidate CDSL files present: {file_counts['candidate.cdsl.json']}",
|
||||
f"- Bound CDSL files present: {file_counts['bound.cdsl.json']}",
|
||||
"",
|
||||
"## Final statuses",
|
||||
"",
|
||||
*_table(["Status", "Count", "Share"], status_rows),
|
||||
"",
|
||||
"## Conversion statuses",
|
||||
"",
|
||||
*_table(["Conversion status", "Count", "Share"], conversion_rows),
|
||||
"",
|
||||
"## Modality and alignment",
|
||||
"",
|
||||
]
|
||||
if modality_missing:
|
||||
lines.extend(_table(["Missing modality", "Count"], sorted(modality_missing.items())))
|
||||
else:
|
||||
lines.append("- No missing local modalities were recorded in the manifest.")
|
||||
lines.extend(["", f"- JSONL content alignment fallbacks: {alignment_fallbacks}", ""])
|
||||
|
||||
lines.extend([
|
||||
"## Comparison results",
|
||||
"",
|
||||
*_table(["Decision", "Count"], sorted(comparison_decisions.items(), key=lambda item: (-item[1], item[0]))),
|
||||
"",
|
||||
"Top strict/RP comparison failure checks:",
|
||||
"",
|
||||
])
|
||||
if comparison_failures:
|
||||
lines.extend(_table(["Failure check", "Count"], sorted(comparison_failures.items(), key=lambda item: (-item[1], item[0]))[:12]))
|
||||
else:
|
||||
lines.append("- No comparison failure checks were recorded.")
|
||||
lines.extend(["", "Comparison worker errors:", ""])
|
||||
if comparison_errors:
|
||||
rows = [[name, count, ", ".join(comparison_error_examples[name])] for name, count in sorted(comparison_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
|
||||
lines.extend(_table(["Error", "Count", "Examples"], rows))
|
||||
else:
|
||||
lines.append("- No comparison worker errors were recorded.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Rebuild outcomes",
|
||||
"",
|
||||
*_table(["Rebuild status", "Count"], sorted(rebuild_statuses.items(), key=lambda item: (-item[1], item[0]))),
|
||||
"",
|
||||
"Top rebuild/runtime errors:",
|
||||
"",
|
||||
])
|
||||
if rebuild_errors:
|
||||
rows = [[name, count, ", ".join(rebuild_error_examples[name])] for name, count in sorted(rebuild_errors.items(), key=lambda item: (-item[1], item[0]))[:12]]
|
||||
lines.extend(_table(["Error", "Count", "Examples"], rows))
|
||||
else:
|
||||
lines.append("- No rebuild errors were recorded.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Diagnostics",
|
||||
"",
|
||||
*_table(
|
||||
["Diagnostic code", "Count", "Example samples"],
|
||||
[[name, count, ", ".join(diagnostic_examples[name])] for name, count in sorted(diagnostic_counts.items(), key=lambda item: (-item[1], item[0]))],
|
||||
),
|
||||
"",
|
||||
"## Capability gaps",
|
||||
"",
|
||||
*_table(["Capability", "Affected samples", "Example samples"], top_gaps[:25]),
|
||||
"",
|
||||
"## FeatureScript operation counts",
|
||||
"",
|
||||
*_table(["Operation", "Occurrences"], operation_rows),
|
||||
"",
|
||||
"## Exact mapping policy",
|
||||
"",
|
||||
"- The converter keeps FeatureScript operation identity in `history.json` and diagnostics.",
|
||||
"- Unsupported operations are not rewritten as substitute atomics.",
|
||||
"- Parameters are statically evaluated from FeatureScript only; STEP geometry is not used to infer or tune CDSL parameters.",
|
||||
"",
|
||||
*_table(["FeatureScript operation", "CDSL atomic policy"], sorted(exact_mappings.items())),
|
||||
"",
|
||||
"Known unsupported FeatureScript operations recorded as capability gaps:",
|
||||
"",
|
||||
", ".join(sorted(unsupported_ops)),
|
||||
"",
|
||||
"Engine executor atomic IDs:",
|
||||
"",
|
||||
", ".join(engine_atomic_ids) if engine_atomic_ids else "Unable to import engine executor registry while generating this report.",
|
||||
"",
|
||||
"## Regression check",
|
||||
"",
|
||||
])
|
||||
regression = _read_optional_json(sample_root / "00000173" / "comparison.json")
|
||||
if regression:
|
||||
metrics = ((regression.get("raw") or {}).get("metrics") or {})
|
||||
lines.extend([
|
||||
"- Sample `00000173` decision: `" + str(regression.get("decision")) + "`",
|
||||
"- RP passed: `" + str((regression.get("rp") or {}).get("passed")) + "`, strict passed: `" + str((regression.get("strict") or {}).get("passed")) + "`",
|
||||
f"- BBox max delta: `{metrics.get('bbox_max_delta_mm')}` mm",
|
||||
f"- Volume relative error: `{metrics.get('volume_relative_error')}`",
|
||||
f"- Surface area relative error: `{metrics.get('surface_area_relative_error')}`",
|
||||
"- This is consistent with the known CADFS RP radius quantization case: FeatureScript uses 9.53 mm while the source STEP is about 9.525 mm.",
|
||||
])
|
||||
else:
|
||||
lines.append("- Sample `00000173` has no comparison report.")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Output locations",
|
||||
"",
|
||||
f"- Per-sample artifacts: `{sample_root}/<sample_id>/`",
|
||||
f"- Manifest: `{output / 'manifest.jsonl'}`",
|
||||
f"- Summary JSON: `{output / 'summary.json'}`",
|
||||
f"- Capability gaps JSON: `{output / 'capability_gaps.json'}`",
|
||||
f"- Unsupported capabilities Markdown: `{output / 'unsupported_capabilities.md'}`",
|
||||
f"- Comparison CSV: `{output / 'comparison_summary.csv'}`",
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
"- The source CADFS directory was treated as read-only by the pipeline.",
|
||||
"- `workers=1` was used for OCC stability and reproducibility.",
|
||||
"- Accepted samples require a generated CDSL candidate, rebuilt STEP, and comparison report.",
|
||||
"- Deferred or rejected samples retain evidence in `diagnostics.json`, `history.json`, `rebuild.json`, or `comparison.json`.",
|
||||
"",
|
||||
])
|
||||
if metric_rows:
|
||||
worst_bbox = sorted(metric_rows, key=lambda item: (item[2] is None, item[2] or 0), reverse=True)[:5]
|
||||
worst_volume = sorted(metric_rows, key=lambda item: (item[3] is None, item[3] or 0), reverse=True)[:5]
|
||||
lines.extend(["## Largest observed comparison deltas", "", "BBox delta:", ""])
|
||||
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_bbox))
|
||||
lines.extend(["", "Volume relative error:", ""])
|
||||
lines.extend(_table(["Sample", "Decision", "BBox max delta mm", "Volume rel err", "Area rel err"], worst_volume))
|
||||
lines.append("")
|
||||
|
||||
report_path = output / report_name
|
||||
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -17,6 +20,11 @@ def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
|
||||
try: delta = abs(float(expected[key]) - float(actual[key]))
|
||||
except Exception: return None
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
if "bbox_mm" in expected:
|
||||
actual_box = actual.get("bbox_mm")
|
||||
if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None
|
||||
delta = max(abs(float(a) - float(b)) for a, b in zip(expected["bbox_mm"], actual_box))
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
return sum(scores) / len(scores) if scores else 0.0
|
||||
|
||||
|
||||
@@ -31,4 +39,69 @@ def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], re
|
||||
if not candidates: raise ValueError("selector_not_found")
|
||||
if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous")
|
||||
score, record = candidates[0]
|
||||
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "cadfs_featurescript", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
||||
return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry}
|
||||
|
||||
|
||||
def _dot(left: list[float], right: list[float]) -> float: return sum(float(a)*float(b) for a, b in zip(left, right))
|
||||
|
||||
|
||||
def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
center = expected.get("source_circle_center_mm"); normal = expected.get("source_plane_normal"); radius = float(expected.get("source_circle_radius_mm") or 0)
|
||||
if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: return []
|
||||
plane_offset = _dot(center, normal); matches = []
|
||||
for record in records:
|
||||
geometry = record.get("geometry") or {}
|
||||
if record.get("kind") != "edge" or geometry.get("curve_type") != "circle": continue
|
||||
points = [geometry.get("start_mm"), geometry.get("end_mm")]
|
||||
if not all(isinstance(point, list) and len(point) == 3 for point in points): continue
|
||||
if any(abs(_dot(point, normal) - plane_offset) > 0.05 for point in points): continue
|
||||
radial = []
|
||||
for point in points:
|
||||
delta = [float(point[i])-float(center[i]) for i in range(3)]; axial = _dot(delta, normal)
|
||||
radial.append(math.sqrt(max(0.0, sum(value*value for value in delta)-axial*axial)))
|
||||
if all(abs(value-radius) <= max(0.05, radius*1e-4) for value in radial): matches.append(record)
|
||||
return matches
|
||||
|
||||
|
||||
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Rebuild every selector-bearing prefix and bind against its active body."""
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
bound = deepcopy(cdsl); evidence = []
|
||||
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
|
||||
for index, feature in enumerate(bound.get("features") or []):
|
||||
placeholders = list(feature.get("selectors") or [])
|
||||
if not placeholders: continue
|
||||
prefix = deepcopy(bound); prefix["features"] = bound["features"][:index]
|
||||
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
||||
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}.step", strict=True)
|
||||
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
|
||||
records = [item for item in report.get("topology_records") or [] if not body_id or item.get("body_id") == body_id or str(item.get("body_id") or "").startswith(f"{body_id}:")]
|
||||
resolved = []
|
||||
for placeholder in placeholders:
|
||||
geometry = placeholder.get("geometry") or {}
|
||||
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
|
||||
if not candidates:
|
||||
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
|
||||
owner = placeholder.get("owner_feature_id")
|
||||
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
|
||||
pool = owner_matches or same_kind
|
||||
if not geometry and len(pool) != 1:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
|
||||
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
|
||||
if scored:
|
||||
if len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
candidates = [scored[0][1]]
|
||||
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
|
||||
for record in candidates:
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id")]
|
||||
resolved.append({"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}})
|
||||
unique = {selector["stable_id"]: selector for selector in resolved}; feature["selectors"] = list(unique.values())
|
||||
if feature.get("atomic_id") == "pattern_mirror":
|
||||
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
|
||||
if len(planes) != 1:
|
||||
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
|
||||
feature.setdefault("params", {})["mirror_plane"] = planes[0]
|
||||
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"]})
|
||||
return bound, evidence
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from cadfs_to_cdsl.compare import compare_steps
|
||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||
from cadfs_to_cdsl.lowering import lower_model
|
||||
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
||||
|
||||
|
||||
class IntegrationTests(unittest.TestCase):
|
||||
@@ -13,13 +14,31 @@ class IntegrationTests(unittest.TestCase):
|
||||
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
|
||||
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
|
||||
cdsl = lower_model(parse_featurescript(feature.read_text(), "00000173"), {}).cdsl
|
||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rebuilt = Path(tmp) / "rebuild.step"; rebuild_cdsl(cdsl, rebuilt, strict=True)
|
||||
rebuilt = Path(tmp) / "rebuild.step"
|
||||
result = rebuild_candidate(cdsl, rebuilt)
|
||||
self.assertEqual(result["status"], "rebuilt")
|
||||
comparison = compare_steps(gold, rebuilt)
|
||||
self.assertFalse(comparison["strict"]["passed"])
|
||||
self.assertTrue(comparison["rp"]["passed"])
|
||||
self.assertEqual(comparison["decision"], "approximate_pass")
|
||||
|
||||
def test_counterbore_abbreviation_00002243(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0000/00002243.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00002243"), {})
|
||||
first = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
|
||||
self.assertEqual(first["atomic_id"], "extrude_add_two_sided")
|
||||
self.assertEqual(first["params"]["distance_mm"], 125.0)
|
||||
self.assertEqual(first["params"]["reverse_distance_mm"], 125.0)
|
||||
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F3")
|
||||
self.assertEqual(gap["code"], "unsupported_engine_capability")
|
||||
self.assertEqual(gap["capability"], "extrude_cut_two_sided")
|
||||
hole = next(item for item in result.cdsl["features"] if item["atomic_id"] == "hole_wizard")
|
||||
self.assertEqual(hole["params"]["hole_type"], "c_bore")
|
||||
self.assertEqual(hole["params"]["counterbore"]["diameter_mm"], 17.25)
|
||||
self.assertEqual(hole["params"]["counterbore"]["depth_mm"], 10.0)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
||||
@@ -30,6 +30,36 @@ class LoweringTests(unittest.TestCase):
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_operation")
|
||||
|
||||
def test_symmetric_cut_is_not_disguised_as_blind_cut(self):
|
||||
source = SOURCE.replace('"depth":120 * mm', '"operationType":NewBodyOperationType.REMOVE, "depth":120 * mm, "symmetric":true')
|
||||
result = lower_model(parse_featurescript(source, "symmetric-cut"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
|
||||
self.assertEqual(result.diagnostics[0]["capability"], "extrude_cut_two_sided")
|
||||
|
||||
def test_open_nonconstruction_geometry_is_not_silently_dropped(self):
|
||||
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
|
||||
result = lower_model(parse_featurescript(source, "open-profile"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "sketch_deferred")
|
||||
self.assertIn("open non-construction", result.diagnostics[0]["message"])
|
||||
|
||||
def test_surface_revolve_is_not_disguised_as_solid_revolve(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0095/00957738.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00957738"), {})
|
||||
gap = next(item for item in result.diagnostics if item.get("feature_id") == "F2")
|
||||
self.assertEqual(gap["capability"], "revolve_surface")
|
||||
self.assertIsNone(result.cdsl)
|
||||
|
||||
def test_feature_face_profile_is_not_reused_as_original_sketch(self):
|
||||
source = SOURCE.replace('qSketchRegion(id + "F0", true)', 'makeQuery(id+"F1.opExtrude","CAP_FACE",FACE,{"isStart":false})')
|
||||
result = lower_model(parse_featurescript(source, "face-profile"), {})
|
||||
self.assertIsNone(result.cdsl)
|
||||
self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability")
|
||||
self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"])
|
||||
|
||||
def test_conversion_writes_status_and_sidecars(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
|
||||
|
||||
Reference in New Issue
Block a user