Files
cdsl-cad/backend/engine/cdsl_engine/rebuild.py
T
ganjihong 95cae203f4 refactor(cdsl_engine): freeze legacy engine paths under legacy/
Phase 7 of the decoupling refactor (behavior-preserving move):
- legacy/llm_compiler.py, legacy/llm_engine.py: frozen build_pack path
  (unused by run_cdsl_only), moved with git mv for history
- legacy/exact_rebuild.py: _run_parameterized / _run_exact /
  _apply_geometric_compensations moved out of rebuild.py, including
  the part-specific compensation table
- rebuild.py keeps run_rebuild / run_cdsl_only / compare_with_gold and
  imports the moved functions
- llm_compiler.py / llm_engine.py become compatibility shims

Frozen zone: new capability belongs in executors/ + schema contracts.
The project-specific compensation no longer sits in the main pipeline.
2026-09-09 14:02:53 +08:00

344 lines
13 KiB
Python

"""
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
from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
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
from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized
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 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中实现)
# ===========================================================================
# 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()