添加cadfs_to_cdsl代码,优化cad生成

This commit is contained in:
2026-09-03 10:40:51 +08:00
parent f3eb5dff54
commit c25b73c71e
34 changed files with 3586 additions and 147 deletions
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+4 -2
View File
@@ -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
View File
@@ -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
+74 -1
View File
@@ -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
+21 -2
View File
@@ -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
View File
@@ -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)