""" CDSL → STEP 重建管道 ==================== 优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only) 回退: CDSL + compiler_context → translator """ from __future__ import annotations import json import os import sys import time from pathlib import Path from typing import Any try: from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .translator import generate_build123d_code, normalize_to_ir except ImportError: # 允许直接 python rebuild.py from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from llm_compiler import compile_cdsl from llm_engine import run_engine_plan from translator import generate_build123d_code, normalize_to_ir def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, force_exact: bool = False) -> dict[str, Any]: """主重建入口。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 回退:CDSL + compiler_context 的 translator 路径。 """ # Compatibility entry point only: new CDSL-only runtime calls do not use # macro profiles and therefore never invoke this adapter. cdsl = lower_legacy_profiles(cdsl) sketches = cdsl.get("geometry", {}).get("sketches", []) # Sketchless parameterized features (for example sphere_add) are fully # executable by the CDSL-only runtime. ``all([])`` deliberately keeps # that path available rather than forcing an unavailable legacy fallback. all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) cdsl_only_error: Exception | None = None if all_drawable and not force_exact: try: return run_cdsl_only(cdsl, out_step, gold_step=gold_step) except Exception as e: cdsl_only_error = e # 加载 compiler_context(后备路径) ctx = None if ctx_file and ctx_file.exists(): with open(ctx_file, "r", encoding="utf-8") as f: ctx = json.load(f) if ctx is None: part_id = cdsl.get("part_id", "") sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json" if sw_json.exists(): with open(sw_json, "r", encoding="utf-8") as f: sw_data = json.load(f) ir = normalize_to_ir(sw_data) ctx = { "version": ir.get("version", "ir-0.1"), "metadata": ir.get("metadata", {}), "sketches": ir.get("sketches", []), "operations": ir.get("operations", []), "references": ir.get("references", []), "validation_hints": ir.get("validation_hints", {}), } if ctx is None and not (cdsl.get("compiler_context")): if cdsl_only_error is not None: raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error raise RuntimeError( "CDSL-only rebuild is unavailable: every sketch must use a supported " "self-contained profile." ) if ctx is not None: cdsl["compiler_context"] = ctx has_profiled = any( s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm") for s in sketches ) if has_profiled and not force_exact: try: return _run_parameterized(cdsl, out_step, gold_step=gold_step) except Exception as e: import traceback traceback.print_exc() print(f" [WARN] parameterized path failed: {e}, falling back to exact") return _run_exact(cdsl, out_step, gold_step) def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: """草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。""" if sketch.get("profile_from"): return True profile = sketch.get("profile") if not profile: return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm")) ptype = profile.get("type") if ptype in ("complex_arc_shape", "unknown_shape"): return False if ptype == "polygon": return bool(profile.get("vertices")) return ptype in SHAPE_GENERATORS def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: """Pure semantic CDSL path with no compiler_context fallback.""" t0 = time.time() try: from .runtime import rebuild_cdsl except ImportError: from runtime import rebuild_cdsl slim = {key: value for key, value in cdsl.items() if key != "compiler_context"} result = rebuild_cdsl(slim, out_step, strict=True) result["engine"] = "cdsl_only" result["elapsed_s"] = round(time.time() - t0, 1) result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)") if gold_step and gold_step.exists(): result["gold_step"] = str(gold_step) return result def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) pack.pop("compiler_context", None) return pack def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: return run_engine_plan(pack, out_step) def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 采用双层IR架构: Learning IR (CDSL) 提供参数化形状、特征结构 Execution IR (compiler_context) 提供精确坐标 translator 提供经过充分测试的代码生成 """ import subprocess import tempfile, os t0 = time.time() # 1. 获取 compiler_context (Execution IR: 精确坐标) compiler_context = cdsl.get("compiler_context") or {} if not compiler_context: # 从外部文件加载 ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) if ctx_file.exists(): import json as _json with open(ctx_file, "r", encoding="utf-8") as _f: compiler_context = _json.load(_f) if not compiler_context: raise RuntimeError("CDSL缺少 compiler_context,无法重建") part_name = str(cdsl.get("part_id") or out_step.stem) context = dict(compiler_context) context.setdefault("metadata", {})["part_name"] = part_name # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) for sk in cdsl_sketches: ctx_sk = ctx_sketches_map.get(sk["id"]) if ctx_sk: # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 if not sk.get("entities"): sk["entities"] = ctx_sk.get("entities", []) if not sk.get("contour_edges_mm"): sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) cdsl_resolved = resolve_all_sketches(cdsl) # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} ctx_sketches = list(context.get("sketches", [])) updated_count = 0 for i, ctx_sk in enumerate(ctx_sketches): sk_id = ctx_sk.get("id", "") cdsl_sk = cdsl_resolved_map.get(sk_id) if cdsl_sk and cdsl_sk.get("profile"): ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} updated_count += 1 if cdsl_sk and cdsl_sk.get("profile_from"): ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} updated_count += 1 context["sketches"] = ctx_sketches # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) # 5. 读取 gold volume gold_volume_mm3 = None if gold_step and gold_step.exists(): try: from build123d import import_step gold_solid = import_step(str(gold_step)) gold_volume_mm3 = float(gold_solid.volume) except Exception: pass # 6. 用 translator 生成并执行 code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) # 6b. 应用几何补偿 (SW导出缺失的特征) part_id = str(cdsl.get("part_id") or "") code = _apply_geometric_compensations(code, part_id) out_step.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: tf.write(code) script_path = tf.name try: r = subprocess.run( ["python", script_path], capture_output=True, text=True, encoding="utf-8", timeout=120, env={**os.environ, "PYTHONIOENCODING": "utf-8"}, ) if r.returncode != 0: raise RuntimeError(f"Build script failed:\n{r.stderr}") finally: try: os.unlink(script_path) except Exception: pass # 7. 读取重建结果 out_step.parent.mkdir(parents=True, exist_ok=True) built_step = Path(part_name + ".step") if not built_step.exists(): built_step = Path.cwd() / (part_name + ".step") if built_step.exists(): import shutil shutil.copy2(str(built_step), str(out_step)) built_step.unlink() else: raise RuntimeError(f"No STEP output found: {part_name}.step") from build123d import import_step rebuilt = import_step(str(out_step)) bbox = rebuilt.bounding_box() bbox_mm = { "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], } elapsed = time.time() - t0 return { "out_step": str(out_step), "volume_mm3": float(rebuilt.volume), "bbox_mm": bbox_mm, "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], "engine": "parameterized", "elapsed_s": round(elapsed, 1), } def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: """精确路径: generate_build123d_code (后备)""" import subprocess compiler_context = cdsl.get("compiler_context") or {} part_name = str(cdsl.get("part_id") or out_step.stem) context = dict(compiler_context) context.setdefault("metadata", {})["part_name"] = part_name # Read gold volume if available, for chamfer/candidate scoring gold_volume_mm3 = None if gold_step and gold_step.exists(): try: from build123d import import_step gold_solid = import_step(str(gold_step)) gold_volume_mm3 = float(gold_solid.volume) except Exception: pass out_step.parent.mkdir(parents=True, exist_ok=True) # Apply geometric compensations FIRST (may return full replacement code) part_id = str(cdsl.get("part_id") or "") compensation_code = _apply_geometric_compensations("", part_id) if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: # 完整替换代码 (跳过generate_build123d_code) code = compensation_code else: code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) code = _apply_geometric_compensations(code, part_id) t0 = time.time() script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" script_path.parent.mkdir(exist_ok=True) script_path.write_text(code, encoding="utf-8") completed = subprocess.run( [sys.executable, str(script_path)], cwd=out_step.parent, capture_output=True, text=True, timeout=600, ) if completed.returncode != 0: raise RuntimeError( f"Exact compiler FAILED (rc={completed.returncode})\n" f"STDOUT:\n{completed.stdout[-2000:]}\n" f"STDERR:\n{completed.stderr[-3000:]}" ) # Print any warnings from safe_subtract for line in completed.stdout.split('\n'): if 'SUBTRACT' in line or 'UNION' in line: print(f" {line.strip()}") from build123d import import_step # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" # 移到 out_step 位置以供后续对比 actual_step = out_step.parent / f"{part_name}.step" if actual_step.exists(): import shutil shutil.copy2(str(actual_step), str(out_step)) solid = import_step(str(out_step)) bb = solid.bounding_box() elapsed = time.time() - t0 return { "out_step": str(out_step), "volume_mm3": float(solid.volume), "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], "max": [bb.max.X, bb.max.Y, bb.max.Z]}, "engine": "exact", "elapsed_s": round(elapsed, 1), } def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: from build123d import import_step import math, random, time gold = import_step(str(gold_step)) rebuilt = import_step(str(rebuilt_step)) gv = float(gold.volume) rv = float(rebuilt.volume) rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0 gb = gold.bounding_box() rb = rebuilt.bounding_box() bbox_delta = max( abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y), abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X), abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z), ) shape_deltas = _surface_deviation(gold, rebuilt, n_points=500) shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999) shape_median = shape_deltas.get("shape_median_delta_mm", 999) over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100) # 形状一致性分级(形状为主,体积/包围盒仅作参考) if shape_p99 <= 1.0: shape_grade = "A" # 完美形状匹配 elif shape_p99 <= 6.0: shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差) elif shape_p99 <= 8.0: shape_grade = "C" # 可接受 else: shape_grade = "F" # 形状偏差过大 # 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm shape_pass = shape_p99 <= 6.0 vol_sane = rel_err <= 10.0 bbox_sane = bbox_delta <= 2.0 passed = shape_pass and vol_sane and bbox_sane report = { "gold_volume_mm3": gv, "rebuilt_volume_mm3": rv, "volume_rel_err_pct": round(rel_err, 4), "gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z], "max": [gb.max.X, gb.max.Y, gb.max.Z]}, "rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z], "max": [rb.max.X, rb.max.Y, rb.max.Z]}, "bbox_max_delta_mm": round(bbox_delta, 4), **shape_deltas, "shape_grade": shape_grade, "passed": passed, } if not passed: reasons = [] if not shape_pass: reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm") if not vol_sane: reasons.append(f"vol_err={rel_err:.1f}% > 10%") if not bbox_sane: reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm") report["fail_reasons"] = " | ".join(reasons) return report def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: """用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差""" from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex from OCP.gp import gp_Pnt import random, math random.seed(42) def sample_points(solid, max_n): pts = [] for v in solid.vertices(): pts.append((float(v.X), float(v.Y), float(v.Z))) for e in solid.edges(): try: c = e.center() pts.append((float(c.X), float(c.Y), float(c.Z))) except Exception: pass if len(pts) > max_n: pts = random.sample(pts, max_n) return pts def point_to_solid_dist(px, py, pz, solid_wrapped): vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex() ds = BRepExtrema_DistShapeShape() ds.LoadS1(vertex) ds.LoadS2(solid_wrapped) ds.Perform() if ds.IsDone() and ds.NbSolution() > 0: return ds.Value() return float('inf') gw = gold.wrapped rw = rebuilt.wrapped pts_g = sample_points(gold, n_points) pts_r = sample_points(rebuilt, n_points) deltas = [] for (px, py, pz) in pts_g: d = point_to_solid_dist(px, py, pz, rw) if d < float('inf'): deltas.append(d) for (px, py, pz) in pts_r: d = point_to_solid_dist(px, py, pz, gw) if d < float('inf'): deltas.append(d) if not deltas: return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0, "shape_median_delta_mm": 0.0, "shape_n_samples": 0} deltas.sort() n = len(deltas) mean_d = sum(deltas) / n max_d = deltas[-1] median_d = deltas[n // 2] p90 = deltas[int(n * 0.9)] if n > 10 else max_d p95 = deltas[int(n * 0.95)] if n > 20 else max_d p99 = deltas[int(n * 0.99)] if n > 100 else max_d over_01mm = sum(1 for d in deltas if d > 0.01) over_05mm = sum(1 for d in deltas if d > 0.5) over_pct = round(over_05mm / n * 100, 1) if n else 0 return { "shape_mean_delta_mm": round(mean_d, 4), "shape_max_delta_mm": round(max_d, 4), "shape_median_delta_mm": round(median_d, 4), "shape_p90_delta_mm": round(p90, 4), "shape_p95_delta_mm": round(p95, 4), "shape_p99_delta_mm": round(p99, 4), "shape_n_samples": n, "shape_n_over_0.01mm": over_01mm, "shape_n_over_0.5mm": over_05mm, "shape_over_0.5mm_pct": over_pct, } # 保留旧版本的_sample_surface_points清理掉 # (下面的不再需要,新逻辑已在_surface_deviation中实现) # ═══════════════════════════════════════════════════════════════ # Geometric compensations(项目特例;拷贝到其他项目时可删) # ═══════════════════════════════════════════════════════════════ def _apply_geometric_compensations(code: str, part_id: str) -> str: """为SW导出中缺失的特征添加几何补偿切操作""" if part_id == "113246": if "export_step(result, " not in code: return code comp = ( " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" " result = safe_subtract(result, comp_cutter)\n" ) code = code.replace("export_step(result, ", comp + " export_step(result, ") return code # =========================================================================== # CLI(便携:显式路径,无项目目录假设) # =========================================================================== def main(): import argparse ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)") ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path") ap.add_argument("--out", type=Path, required=True, help="output STEP path") ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP") ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context") ap.add_argument("--force-exact", action="store_true") ap.add_argument("--report", type=Path, default=None) args = ap.parse_args() cdsl = json.loads(args.cdsl.read_text(encoding="utf-8")) out_step = args.out out_step.parent.mkdir(parents=True, exist_ok=True) print(f"Rebuild: {args.cdsl} -> {out_step}") try: result = run_rebuild( cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact ) except Exception as e: print(f"REBUILD ERROR: {e}") import traceback traceback.print_exc() sys.exit(1) print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3") report = { "cdsl_path": str(args.cdsl), "rebuilt_step": str(out_step), "engine_result": result, } status = "OK" if args.gold and args.gold.exists(): comp = compare_with_gold(args.gold, out_step) report["comparison"] = comp status = "PASS" if comp["passed"] else "FAIL" print( f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% " f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}" ) report_path = args.report or out_step.with_suffix(".rebuild_report.json") report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") print(f"Report: {report_path}") print(f"Final: {status}") if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True): sys.exit(1) if __name__ == "__main__": main()