Initial commit

This commit is contained in:
2026-08-19 19:34:30 +08:00
commit beba8744b4
285 changed files with 74293 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Local CDSL Engine
This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path:
`sketch_solver -> llm_compiler -> llm_engine -> STEP`
Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`.
The Studio only accepts self-contained profile data and requires successful
`engine=cdsl_only` output. It never uses the legacy translator fallback or
`compiler_context`.
+28
View File
@@ -0,0 +1,28 @@
"""
Local CDSL engine copied into the product repository.
The package exposes the CDSL-only rebuild API used by the backend Agent.
"""
from __future__ import annotations
from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs
from .llm_compiler import compile_cdsl
from .llm_engine import run_engine_plan
from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_engine, run_rebuild
from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
__all__ = [
"convert_sw_json_to_cdsl",
"write_cdsl_outputs",
"compile_cdsl",
"compile_cdsl_to_pack",
"run_engine_plan",
"run_engine",
"run_rebuild",
"compare_with_gold",
"resolve_all_sketches",
"SHAPE_GENERATORS",
]
__version__ = "1.0.0"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
"""将 output3 的程序化圆柱样本蒸馏为自足的短 CDSL。
这些样本的 SolidWorks 历史把每个切口存成独立草图/切除特征。本脚本按
文件名中的设计族选择一个可复用的 motif + layout 语义描述;不会复制
草图 entities、逐切口坐标、compiler_context 或任何编码后的几何。
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
SPECS: dict[str, dict[str, Any]] = {
"cylinder_001": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 16.0, "outer_radius_mm": 25.0, "half_angle_deg": 4.583662},
"layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"},
},
"cylinder_002": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 8.0, "outer_radius_mm": 12.5, "half_angle_deg": 4.583662},
"layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"},
},
"cylinder_003": {
"motif": {"type": "circle", "radius_mm": 2.4},
"layout": {"type": "ring", "radius_mm": 34.0, "count": 32},
},
"cylinder_004": {
"motif": {"type": "square", "width_mm": 3.3},
"layout": {"type": "ring", "radius_mm": 32.0, "count": 24},
},
"cylinder_005": {
"motif": {"type": "square", "width_mm": 4.242641},
"layout": {"type": "ring", "radius_mm": 32.0, "count": 24},
},
"cylinder_006": {
"motif": {"type": "circle", "radius_mm": 2.2},
"layout": {
"type": "disc_grid", "count_x": 7, "count_y": 7,
"spacing_x_mm": 10.0, "spacing_y_mm": 10.0,
"center_mm": [0.0, 0.0], "max_center_radius_mm": 36.1,
},
},
"cylinder_007": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.583662},
"layout": {
"type": "concentric_rings", "orientation": "radial",
"rings": [
{"type": "angular", "count": 12, "start_angle_deg": 15.0},
{"type": "angular", "count": 24, "start_angle_deg": 7.5},
],
},
"ring_motifs": [
{"inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.583662, "count": 12, "start_angle_deg": 15.0},
{"inner_radius_mm": 28.0, "outer_radius_mm": 40.0, "half_angle_deg": 2.864789, "count": 24, "start_angle_deg": 7.5},
],
},
"cylinder_008": {
"motif": {"type": "obround", "length_mm": 7.0, "width_mm": 2.8},
"layout": {"type": "ring", "radius_mm": 34.0, "count": 24, "orientation": "radial"},
},
"cylinder_009": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 10.0, "outer_radius_mm": 42.0, "half_angle_deg": 3.437747},
"layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"},
},
"cylinder_010": {
"motif": {"type": "circle", "radius_mm": 2.3},
"layout": {
"type": "concentric_rings",
"rings": [
{"radius_mm": 18.0, "count": 12, "start_angle_deg": 0.0},
{"radius_mm": 36.0, "count": 24, "start_angle_deg": 0.0},
],
},
},
"cylinder_011": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 10.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705},
"layout": {"type": "angular", "count": 8, "start_angle_deg": 22.5, "orientation": "radial"},
},
"cylinder_012": {
"motif": {"type": "circle", "radius_mm": 1.9},
"layout": {"type": "ring", "radius_mm": 26.0, "count": 24},
},
"cylinder_013": {
"motif": {"type": "square", "width_mm": 2.7},
"layout": {"type": "ring", "radius_mm": 25.0, "count": 16},
},
"cylinder_014": {
"motif": {"type": "square", "width_mm": 3.535534},
"layout": {"type": "ring", "radius_mm": 25.0, "count": 16},
},
"cylinder_015": {
"motif": {"type": "circle", "radius_mm": 1.8},
"layout": {
"type": "disc_grid", "count_x": 6, "count_y": 5,
"spacing_x_mm": 9.0, "spacing_y_mm": 9.0,
"center_mm": [0.0, 0.0],
},
},
"cylinder_016": {
"motif": {"type": "d_shape_polygon", "stem_length_mm": 1.9, "nose_depth_mm": 1.9, "half_height_mm": 1.3775},
"layout": {
"type": "open_arc", "radius_mm": 35.0, "count": 18,
"start_angle_deg": 18.0, "end_angle_deg": 262.8,
},
},
"cylinder_017": {
"motif": {"type": "cross", "size_mm": 4.62, "arm_width_mm": 2.9568},
"layout": {
"type": "open_arc", "radius_mm": 31.0, "count": 14,
"start_angle_deg": 189.0, "end_angle_deg": 387.0,
},
},
"cylinder_018": {
"motif": {"type": "obround", "length_mm": 5.75, "width_mm": 2.3},
"layout": {"type": "ring", "radius_mm": 27.0, "count": 16, "orientation": "radial"},
},
"cylinder_019": {
"motif": {"type": "circle", "radius_mm": 1.8},
"layout": {
"type": "concentric_rings",
"rings": [
{"radius_mm": 14.0, "count": 8, "start_angle_deg": 0.0},
{"radius_mm": 30.0, "count": 16, "start_angle_deg": 0.0},
],
},
},
"cylinder_020": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 9.0, "outer_radius_mm": 36.0, "half_angle_deg": 3.151268},
"layout": {"type": "angular", "count": 10, "start_angle_deg": 18.0, "orientation": "radial"},
},
"cylinder_021": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705},
"layout": {"type": "angular", "count": 8, "start_angle_deg": 22.5, "orientation": "radial"},
"ring_motifs": [
{"inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705, "count": 8, "start_angle_deg": 22.5},
{"inner_radius_mm": 28.0, "outer_radius_mm": 39.0, "half_angle_deg": 2.57831, "count": 16, "start_angle_deg": 11.25},
],
},
"cylinder_022": {
"motif": {"type": "skew_hexagon", "nominal_radius_mm": 1.875},
"layout": {
"type": "spiral", "count": 28, "start_radius_mm": 8.054,
"radius_step_mm": 0.851, "start_angle_deg": -0.105, "angle_step_deg": 33.2394,
"orientation": "snapped_radial", "orientation_snap_deg": 45.0,
"orientation_offset_deg": 0.0,
},
},
"cylinder_023": {
"motif": {"type": "skew_hexagon", "nominal_radius_mm": 1.9},
"layout": {
"type": "cross_lines", "count_per_axis": 6, "spacing_mm": 13.6,
"orientation_offset_deg": 0.0,
},
},
"cylinder_024": {
"motif": {"type": "triangle", "radius_mm": 2.3},
"layout": {
"type": "x_field", "levels": 5, "spacing_mm": 11.0,
"orientation": "diagonal_axes",
},
},
"cylinder_025": {
"motif": {
"type": "teardrop_polygon",
"left_width_mm": 1.116, "right_width_mm": 1.548,
"tip_height_mm": 2.79, "bottom_depth_mm": 1.476,
"shoulder_height_mm": 1.242,
},
"layout": {
"type": "twin_strips", "x_offset_mm": 15.0, "count_y": 12,
"y_start_mm": -34.0, "y_end_mm": 34.0,
},
},
"cylinder_026": {
"motif": {"type": "trapezoid", "bottom_width_mm": 4.32, "top_width_mm": 2.376, "height_mm": 3.24},
"layout": {
"type": "concentric_rings", "orientation": "radial",
"rings": [
{"radius_mm": 12.0, "count": 8, "start_angle_deg": 0.0},
{"radius_mm": 31.0, "count": 16, "start_angle_deg": 0.0},
],
},
},
"cylinder_027": {
"motif": {"type": "d_shape_polygon", "stem_length_mm": 1.8, "nose_depth_mm": 1.8, "half_height_mm": 1.305},
"layout": {"type": "corner_clusters", "levels_mm": [12.0, 18.5, 25.0]},
},
"cylinder_028": {
"motif": {"type": "square", "width_mm": 2.969848},
"layout": {"type": "diamond_field", "manhattan_radius": 3, "spacing_mm": 8.0},
},
"cylinder_029": {
"motif": {"type": "annular_sector_polygon", "inner_radius_mm": 11.0, "outer_radius_mm": 22.0, "half_angle_deg": 4.010705},
"layout": {"type": "angular", "count": 10, "start_angle_deg": 18.0, "orientation": "radial"},
},
}
def _pattern_sketches(spec: dict[str, Any]) -> list[dict[str, Any]]:
base = {
"id": "sketch_001",
"name": "草图1",
"workplane": {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": [0.0, 0.0, 1.0],
},
"profile": {"type": "circle", "center": [0.0, 0.0], "radius_mm": 50.0},
}
cut_workplane = {
"origin_mm": [0.0, 0.0, 20.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 0.0],
"normal": [0.0, 0.0, 1.0],
}
ring_motifs = spec.get("ring_motifs")
if not ring_motifs:
cut = {
"id": "sketch_002",
"name": "图案草图",
"workplane": cut_workplane,
"profile": {
"type": "patterned_cutouts",
"motif": spec["motif"],
"layout": spec["layout"],
},
}
return [base, cut]
# 两组扇区的内外半径不同,仍合并成一个切除特征;组合只包含
# 两个有名字的程序化子图案,不保存任何逐实例坐标。
cut = {
"id": "sketch_002",
"name": "双环图案草图",
"workplane": cut_workplane,
"profile": {
"type": "compound_patterned_cutouts",
"patterns": [
{
"motif": {
"type": "annular_sector_polygon",
"inner_radius_mm": item["inner_radius_mm"],
"outer_radius_mm": item["outer_radius_mm"],
"half_angle_deg": item["half_angle_deg"],
},
"layout": {
"type": "angular",
"count": item["count"],
"start_angle_deg": item["start_angle_deg"],
"orientation": "radial",
},
}
for item in ring_motifs
],
},
}
return [base, cut]
def make_cdsl(source: Path, spec: dict[str, Any]) -> dict[str, Any]:
part_id = source.name.removesuffix(".solidworks_evidence_v2.json")
return {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.0.0",
"kind": "part",
"part_id": part_id,
"features": [
{
"id": "f01",
"atomic_id": "extrude_add_blind",
"depends_on": [],
"name": "凸台-拉伸1",
"params": {"distance_mm": 20.0},
"sketch_id": "sketch_001",
},
{
"id": "f02",
"atomic_id": "extrude_cut_blind",
"depends_on": ["f01"],
"name": "程序化图案切除",
"params": {"distance_mm": 8.0, "reverse": True},
"sketch_id": "sketch_002",
},
],
"geometry": {"sketches": _pattern_sketches(spec)},
"meta": {
"source": source.name,
"parameterized_sketches": 2,
"profile_from_sketches": 0,
"notes": [
"CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout",
"No compiler_context, source entities, per-instance coordinates, or encoded geometry",
],
},
}
def distill(input_dir: Path, output_dir: Path) -> list[Path]:
sources = sorted(input_dir.glob("cylinder_*.solidworks_evidence_v2.json"))
written: list[Path] = []
missing: list[str] = []
for source in sources:
key = source.name[:12]
spec = SPECS.get(key)
if spec is None:
missing.append(source.name)
continue
part_id = source.name.removesuffix(".solidworks_evidence_v2.json")
part_dir = output_dir / part_id
part_dir.mkdir(parents=True, exist_ok=True)
out = part_dir / f"{part_id}.cdsl.json"
text = json.dumps(make_cdsl(source, spec), ensure_ascii=False, indent=2)
# 与 b005/b006 一致:短标量数组保持单行,结构数组仍按层级展开。
scalar_array = re.compile(
r"\[\n(?P<body>(?:[ \t]+(?:-?\d+(?:\.\d+)?|true|false|null|\"[^\"\\n]*\"),?\n)+)[ \t]*\]"
)
def compact(match: re.Match[str]) -> str:
values = [line.strip().rstrip(",") for line in match.group("body").splitlines()]
inline = "[" + ", ".join(values) + "]"
return inline if len(inline) <= 100 else match.group(0)
text = scalar_array.sub(compact, text)
out.write_text(
text + "\n",
encoding="utf-8",
)
written.append(out)
if missing:
raise RuntimeError("No semantic specification for: " + ", ".join(missing))
return written
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input_dir", type=Path)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
paths = distill(args.input_dir, args.out)
print(f"wrote {len(paths)} CDSL files to {args.out}")
if __name__ == "__main__":
main()
+286
View File
@@ -0,0 +1,286 @@
"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
try:
from .sketch_solver import resolve_all_sketches
except ImportError:
from sketch_solver import resolve_all_sketches
REQUIRED = {
"revolve_add": ["angle_deg", "axis"],
"revolve_cut": ["angle_deg", "axis"],
"extrude_add_blind": ["distance_mm"],
"extrude_add_two_sided": ["distance_mm"],
"extrude_cut_blind": ["distance_mm"],
"hole_blind": ["diameter_mm", "depth_mm"],
"hole_countersink": ["diameter_mm", "depth_mm"],
"hole_counterbore": ["diameter_mm", "depth_mm"],
}
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None:
if sketch is None:
return None
s = deepcopy(sketch)
wp = s.get("workplane") or {}
o = list(wp.get("origin_mm") or [0, 0, 0])
wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
s["workplane"] = wp
edges = []
for e in s.get("contour_edges_mm") or []:
ne = deepcopy(e)
for key in ("start_mm", "end_mm", "center_mm"):
if key in ne:
p = ne[key]
ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz]
edges.append(ne)
if edges:
s["contour_edges_mm"] = edges
# 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns
return s
def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]:
p = deepcopy(params)
if "positions" in p:
for pos in p["positions"]:
mm = pos.get("mm")
if mm:
pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz]
if "axis" in p and isinstance(p["axis"], dict):
o = list(p["axis"].get("origin_mm") or [0, 0, 0])
p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz]
return p
def compile_cdsl(
cdsl: dict[str, Any],
atoms_catalog: dict[str, Any] | None = None,
techniques_catalog: dict[str, Any] | None = None,
) -> dict[str, Any]:
allowed = set()
if atoms_catalog:
allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []}
techniques = {
item["technique_id"]: item
for item in (techniques_catalog or {}).get("techniques") or []
}
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
# 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm
cdsl = resolve_all_sketches(cdsl)
sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []}
steps: list[dict[str, Any]] = []
seen_ids: set[str] = set()
# feature_id -> list of emitted step dicts (for pattern source)
emitted: dict[str, list[dict[str, Any]]] = {}
def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]:
atomic = feature["atomic_id"]
if allowed and atomic not in allowed:
raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog")
for dep in feature.get("depends_on") or []:
if dep not in seen_ids and not any(dep in emitted):
# dependency may be ok if earlier
if dep not in seen_ids:
raise ValueError(f"{step_id}: depends_on {dep} not yet defined")
step = {
"step_id": step_id,
"atomic_id": atomic,
"depends_on": list(feature.get("depends_on") or []),
"params": params,
"sketch": sketch,
"source_name": feature.get("name"),
}
steps.append(step)
seen_ids.add(step_id)
return step
for feat in cdsl.get("features") or []:
fid = feat["id"]
atomic = feat.get("atomic_id")
technique_id = feat.get("technique_id")
if technique_id:
technique = techniques.get(technique_id)
if technique is None:
raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog")
groups = feat.get("params") or {}
expanded: list[dict[str, Any]] = []
previous_step_id: str | None = None
for index, internal in enumerate(technique.get("internal_steps") or [], start=1):
group_name = internal.get("params_from")
group = deepcopy(groups.get(group_name) or {})
if not isinstance(group, dict):
raise ValueError(f"{fid}: parameter group {group_name!r} must be an object")
params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group)
sketch_id = group.get("sketch_id") or params.pop("sketch_id", None)
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
internal_atomic = internal.get("atomic_id")
if not internal_atomic:
raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step")
for key in REQUIRED.get(internal_atomic, []):
if params.get(key) is None:
raise ValueError(
f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}"
)
internal_feature = {
"atomic_id": internal_atomic,
"depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []),
"name": f"{feat.get('name') or technique_id}:{group_name or index}",
}
step_id = f"{fid}.t{index}"
expanded.append(emit(internal_feature, params, sketch, step_id))
previous_step_id = step_id
if len(expanded) < 2:
raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps")
emitted[fid] = expanded
seen_ids.add(fid)
continue
if not atomic:
raise ValueError(f"{fid}: missing atomic_id")
if atomic == "pattern_linear":
params = feat.get("params") or {}
src_ids = params.get("source_feature_ids") or []
c1 = int(params.get("pattern_count_1") or 1)
c2 = int(params.get("pattern_count_2") or 1)
s1 = float(params.get("spacing_1_mm") or 0)
s2 = float(params.get("spacing_2_mm") or 0)
d1 = params.get("direction_1") or [1, 0, 0]
d2 = params.get("direction_2") or [0, 1, 0]
if params.get("direction_1_reverse"):
d1 = [-d1[0], -d1[1], -d1[2]]
if params.get("direction_2_reverse"):
d2 = [-d2[0], -d2[1], -d2[2]]
src_steps: list[dict[str, Any]] = []
for sid in src_ids:
src_steps.extend(emitted.get(sid) or [])
if not src_steps:
# 无源则跳过并记录
steps.append(
{
"step_id": fid,
"atomic_id": "noop_pattern",
"depends_on": list(feat.get("depends_on") or []),
"params": params,
"sketch": None,
"note": "pattern source steps missing",
}
)
seen_ids.add(fid)
continue
clone_steps = []
k = 0
for i in range(c1):
for j in range(c2):
if i == 0 and j == 0:
continue
dx = d1[0] * s1 * i + d2[0] * s2 * j
dy = d1[1] * s1 * i + d2[1] * s2 * j
dz = d1[2] * s1 * i + d2[2] * s2 * j
for src in src_steps:
k += 1
clone_id = f"{fid}.p{k}"
fake_feat = {
"atomic_id": src["atomic_id"],
"depends_on": [steps[-1]["step_id"]] if steps else [],
"name": f"{src.get('source_name')}_pattern",
}
st = emit(
fake_feat,
_offset_params_positions(src["params"], dx, dy, dz),
_offset_sketch(src.get("sketch"), dx, dy, dz),
clone_id,
)
clone_steps.append(st)
emitted[fid] = clone_steps
seen_ids.add(fid)
continue
params = deepcopy(feat.get("params") or {})
sketch_id = feat.get("sketch_id") or params.get("sketch_id")
sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None
if sketch_id:
params["sketch_id"] = sketch_id
# Auto-derive revolve axis origin
if "revolve" in atomic and sketch and "axis" in params:
ax = params.get("axis") or {}
# 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标
wp = sketch.get("workplane") or {}
wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0]
if ax.get("from_workplane_origin") and "origin_mm" not in ax:
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(wp_origin)
elif "origin_mm" not in ax:
ce = sketch.get("contour_edges_mm") or []
if ce:
idx = int(ax.get("from_contour_vertex", 0))
vertex = ce[idx % len(ce)]["start_mm"]
params["axis"] = deepcopy(params["axis"])
params["axis"]["origin_mm"] = list(vertex)
for key in REQUIRED.get(atomic, []):
if key == "axis" and "axis" not in params:
raise ValueError(f"{fid}: missing axis")
if key not in ("axis",) and params.get(key) is None and key != "sketch_id":
# positions can be empty temporarily
if key in params:
continue
if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None:
raise ValueError(f"{fid}: missing {key}")
st = emit(feat, params, sketch, fid)
emitted[fid] = [st]
# filter noop
steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"]
return {
"schema": "cad.engine_plan.v1",
"part_id": cdsl.get("part_id"),
"unit": "mm",
"steps": steps,
"compiler_context": deepcopy(cdsl.get("compiler_context")),
"meta": {
"from_cdsl_schema": cdsl.get("schema"),
"compiler": "cad-heard.llm_compiler.v1",
"n_steps": len(steps),
},
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--cdsl", type=Path, required=True)
ap.add_argument("--catalog", type=Path, default=None)
ap.add_argument("--techniques", type=Path, default=None)
ap.add_argument("--out", type=Path, required=True)
args = ap.parse_args()
catalog = _load(args.catalog) if args.catalog else None
techniques = _load(args.techniques) if args.techniques else None
pack = compile_cdsl(_load(args.cdsl), catalog, techniques)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"wrote {args.out} steps={len(pack['steps'])}")
if __name__ == "__main__":
main()
+500
View File
@@ -0,0 +1,500 @@
"""build123d 绘图引擎:执行 build_pack → STEP。"""
from __future__ import annotations
import builtins
import json
import math
import subprocess
import sys
from pathlib import Path
from typing import Any
# 保留内置 float,防止被 build123d 上下文 shadow
_f = builtins.float
from build123d import ( # noqa: E402
Align,
Axis,
BuildPart,
BuildSketch,
Circle,
Cone,
Cylinder,
Edge,
Face,
Location,
Locations,
Mode,
Plane,
Polygon,
Vector,
Wire,
export_step,
extrude,
import_step,
revolve,
)
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _plane_from_workplane(wp: dict[str, Any]) -> Plane:
o = wp.get("origin_mm") or [0, 0, 0]
x = wp.get("x_dir") or [1, 0, 0]
n = wp.get("normal") or [0, 0, 1]
return Plane(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])),
z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])),
)
def _axis_from_params(axis: dict[str, Any]) -> Axis:
o = axis.get("origin_mm") or [0, 0, 0]
d = axis.get("direction") or [1, 0, 0]
return Axis(
origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])),
direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])),
)
def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]:
entities = sketch.get("entities") or []
line_loop = [
i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction")
]
if not line_loop:
raise ValueError(f"sketch {sketch.get('id')}: no profile lines")
pts: list[tuple[float, float]] = []
for i in line_loop:
e = entities[i]
s = (_f(e["start"][0]), _f(e["start"][1]))
en = (_f(e["end"][0]), _f(e["end"][1]))
if not pts:
pts.append(s)
if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4:
if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4:
s, en = en, s
else:
pts.append(s)
pts.append(en)
if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4:
pts.append(pts[0])
return pts
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
b123_edges: list[Edge] = []
for e in edges_mm:
p1 = Vector(*e["start_mm"])
p2 = Vector(*e["end_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
b123_edges.append(Edge.make_line(p1, p2))
continue
n = Vector(*(e.get("normal") or [0, 0, 1]))
if n.length < 1e-9:
n = v1.cross(v2)
if n.length < 1e-9:
n = Vector(0, 0, 1)
n = n.normalized()
v1n = v1.normalized() * r
v2n = v2.normalized() * r
bis = v1n + v2n
if bis.length < 1e-9:
bis = n.cross(v1n)
mid = center + bis.normalized() * r
try:
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
b123_edges.append(Edge.make_line(p1, p2))
else:
b123_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(b123_edges))
if desired_normal is not None:
dn = Vector(*desired_normal)
if dn.length > 1e-9:
fn = face.normal_at()
if fn.dot(dn) < 0:
# 重建反转的 Wire:边顺序反转 + 每条边起止点交换
# 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed
rev_edges: list[Edge] = []
for e in reversed(edges_mm):
p1 = Vector(*e["end_mm"])
p2 = Vector(*e["start_mm"])
if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None:
center = Vector(*e["center_mm"])
r = _f(e["radius_mm"])
v1 = p1 - center
v2 = p2 - center
if v1.length < 1e-9 or v2.length < 1e-9:
rev_edges.append(Edge.make_line(p1, p2))
continue
n = Vector(*(e.get("normal") or [0, 0, 1]))
if n.length < 1e-9:
n = v1.cross(v2)
if n.length < 1e-9:
n = Vector(0, 0, 1)
n = n.normalized()
v1n = v1.normalized() * r
v2n = v2.normalized() * r
bis = v1n + v2n
if bis.length < 1e-9:
bis = n.cross(v1n)
mid = center + bis.normalized() * r
try:
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
rev_edges.append(Edge.make_line(p1, p2))
else:
rev_edges.append(Edge.make_line(p1, p2))
face = Face(Wire(rev_edges))
return face
def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float:
dist = abs(_f(params["distance_mm"]))
if prefer_sign == "plus":
return dist
if prefer_sign == "minus":
return -dist
return -dist if bool(params.get("reverse")) else dist
def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None:
"""Build circular islands and holes from containment parity.
A circle contained by one larger circle is a hole; a circle contained by
two larger circles is an island again. This preserves annular profiles
without storing the heavy tessellated sketch regions from the SW export.
"""
ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True)
tolerance = 1e-6
for index, circle in enumerate(ordered):
center = circle["center"]
radius = _f(circle["radius_mm"])
containing = 0
for outer in ordered[:index]:
outer_center = outer["center"]
outer_radius = _f(outer["radius_mm"])
distance = math.hypot(
_f(center[0]) - _f(outer_center[0]),
_f(center[1]) - _f(outer_center[1]),
)
if distance + radius <= outer_radius + tolerance:
containing += 1
mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT
with Locations((_f(center[0]), _f(center[1]))):
Circle(radius, mode=mode)
def run_engine_plan(
pack: dict[str, Any],
out_step: Path,
*,
cut_sign: str = "from_params",
) -> dict[str, Any]:
log: list[str] = []
compiler_context = pack.get("compiler_context")
if isinstance(compiler_context, dict):
# 回退路径:使用本包 translator(不依赖外部 backend.src
try:
from .translator import generate_build123d_code, get_part_name
except ImportError:
from translator import generate_build123d_code, get_part_name
context = dict(compiler_context)
context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem)
out_step.parent.mkdir(parents=True, exist_ok=True)
completed = subprocess.run(
[sys.executable, "-c", generate_build123d_code(context)],
cwd=out_step.parent,
capture_output=True,
text=True,
timeout=180,
)
if completed.returncode != 0:
raise RuntimeError(
f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
)
generated_name = get_part_name({"part_name": context["metadata"]["part_name"]})
generated = out_step.parent / f"{generated_name}.step"
if generated != out_step and generated.exists():
generated.replace(out_step)
if not out_step.exists():
raise RuntimeError(f"exact compiler did not generate {out_step}")
solid = import_step(str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(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": "translator_fallback",
}
with BuildPart() as part:
for step in pack.get("steps") or []:
atomic = step["atomic_id"]
params = step["params"]
sketch = step.get("sketch")
sid = step.get("step_id")
if atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
regions = sketch.get("contour_regions_mm") or []
sign = cut_sign if "cut" in atomic else "from_params"
circles = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "circle" and not e.get("construction")
]
lines = [
e
for e in (sketch.get("entities") or [])
if e.get("type") == "line" and not e.get("construction")
]
# 多区域轮廓(外环 + 孔):由 shape generator 展开
if regions:
faces = []
normal = (sketch.get("workplane") or {}).get("normal")
for reg in regions:
outer_edges = reg.get("outer") or []
if len(outer_edges) < 2:
continue
face = _face_from_contour_edges(outer_edges, desired_normal=normal)
for hole_edges in reg.get("holes") or []:
if len(hole_edges) < 2:
continue
hole = _face_from_contour_edges(hole_edges, desired_normal=normal)
face = face.cut(hole)
faces.append(face)
if not faces:
raise ValueError(f"{sid}: contour_regions_mm produced no faces")
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
for face in faces:
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
for face in faces:
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} regions={len(faces)}")
continue
# 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除
prefer_circles = bool(circles) and atomic.startswith("extrude_cut")
if prefer_circles:
with BuildSketch(plane):
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
elif len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} contour")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(to_extrude=face, amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} contour")
elif circles and not lines:
# 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。
with BuildSketch(plane):
_build_nested_circle_profiles(circles)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} circle-only n={len(circles)}")
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
for e in circles:
with Locations((_f(e["center"][0]), _f(e["center"][1]))):
Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT)
if atomic == "extrude_add_two_sided":
d = abs(_f(params["distance_mm"]))
extrude(amount=d, both=True, mode=Mode.ADD)
log.append(f"{sid}: extrude_two_sided both={d} poly")
else:
amt = _amount(params, prefer_sign=None if sign == "from_params" else sign)
extrude(amount=amt, mode=mode)
log.append(f"{sid}: {atomic} amount={amt} poly")
elif atomic in ("revolve_add", "revolve_cut"):
if sketch is None:
raise ValueError(f"{sid}: missing sketch")
plane = _plane_from_workplane(sketch.get("workplane") or {})
axis = _axis_from_params(params.get("axis") or {})
angle = _f(params.get("angle_deg") or 360)
mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal"))
revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode)
else:
with BuildSketch(plane):
pts = _ordered_profile_points(sketch)
poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts
Polygon(*poly)
revolve(axis=axis, revolution_arc=angle, mode=mode)
log.append(f"{sid}: {atomic} angle={angle}")
elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"):
dia = _f(params.get("diameter_mm") or 0)
depth = _f(params.get("depth_mm") or 0)
positions = params.get("positions") or []
if sketch is not None:
plane = _plane_from_workplane(sketch.get("workplane") or {})
else:
plane = Plane.XY
host_face = params.get("host_face") or {}
frame = host_face.get("frame") or {}
frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple()))
frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple()))
frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple()))
normal = plane.z_dir.normalized()
bb = part.part.bounding_box()
part_center = Vector(
(bb.min.X + bb.max.X) / 2,
(bb.min.Y + bb.max.Y) / 2,
(bb.min.Z + bb.max.Z) / 2,
)
inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal
for pos in positions:
mm = pos.get("mm") or [0, 0, 0]
start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1])
cs_dia = _f(params.get("countersink_diameter_mm") or 0)
cs_angle = _f(params.get("countersink_angle_rad") or 0)
cb_dia = _f(params.get("counterbore_diameter_mm") or 0)
cb_depth = _f(params.get("counterbore_depth_mm") or 0)
cs_depth = (
((cs_dia - dia) / 2) / math.tan(cs_angle / 2)
if cs_dia > dia and cs_angle > 0
else 0
)
base_offset = cs_depth + (cb_depth if cb_dia > dia else 0)
main_depth = max(0.001, abs(depth) - base_offset)
main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward))
tools = [
Cylinder(
radius=dia / 2,
height=main_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(main_place)
]
if cb_dia > dia and cb_depth > 0:
tools.append(
Cylinder(
radius=cb_dia / 2,
height=cb_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
if cs_depth > 0:
tools.append(
Cone(
bottom_radius=cs_dia / 2,
top_radius=dia / 2,
height=cs_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(Location(Plane(origin=start, z_dir=inward)))
)
drill_angle = _f(params.get("drill_angle_rad") or 0)
if drill_angle > 0:
tip_depth = (dia / 2) / math.tan(drill_angle / 2)
tools.append(
Cone(
bottom_radius=dia / 2,
top_radius=0,
height=tip_depth,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.PRIVATE,
).move(
Location(
Plane(origin=start + inward * abs(depth), z_dir=inward)
)
)
)
for tool in tools:
part.part = part.part.cut(tool)
log.append(f"{sid}: {atomic} npos={len(positions)}")
else:
raise ValueError(f"unsupported atomic_id: {atomic}")
solid = part.part
out_step.parent.mkdir(parents=True, exist_ok=True)
export_step(solid, str(out_step))
bb = solid.bounding_box()
return {
"out_step": str(out_step),
"volume_mm3": _f(solid.volume),
"bbox_mm": {
"min": [bb.min.X, bb.min.Y, bb.min.Z],
"max": [bb.max.X, bb.max.Y, bb.max.Z],
},
"log": log,
"cut_sign": cut_sign,
}
def main() -> None:
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--pack", type=Path, required=True)
ap.add_argument("--out-step", type=Path, required=True)
ap.add_argument("--report", type=Path, default=None)
ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"])
args = ap.parse_args()
info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign)
if args.report:
args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print(
json.dumps(
{k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info},
ensure_ascii=False,
indent=2,
)
)
for line in info.get("log") or []:
print(line)
if __name__ == "__main__":
main()
+561
View File
@@ -0,0 +1,561 @@
"""
CDSL → STEP 重建管道
====================
优先: CDSL → sketch_solver → llm_compiler → llm_engine (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 .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 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 路径。
"""
sketches = cdsl.get("geometry", {}).get("sketches", [])
all_drawable = bool(sketches) and all(
_sketch_is_cdsl_drawable(s) for s in sketches
)
if all_drawable and not force_exact:
try:
return _run_cdsl_only(cdsl, out_step, gold_step=gold_step)
except Exception as e:
import traceback
traceback.print_exc()
print(f" [WARN] CDSL-only path failed: {e}, falling back")
# 加载 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")):
raise RuntimeError("No compiler_context available and CDSL-only rebuild failed/unavailable")
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]:
"""纯 Learning-IR 路径:CDSL → sketch_solver → llm_compiler → llm_engine。"""
t0 = time.time()
slim = {k: v for k, v in cdsl.items() if k != "compiler_context"}
pack = compile_cdsl(slim)
pack.pop("compiler_context", None)
result = run_engine_plan(pack, out_step)
result["engine"] = "cdsl_only"
result["elapsed_s"] = round(time.time() - t0, 1)
result.setdefault("log", [])
result["log"].append("cdsl_only: sketch_solver + llm_compiler + llm_engine (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()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
"""用 SolidWorks evidence 的 document_truth 验收 output3 CDSL 重建结果。"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from typing import Any
from build123d import CenterOf, import_step
FORBIDDEN_CDSL_KEYS = {
"compiler_context",
"entities",
"contour_edges_mm",
"contour_regions_mm",
"_raw_entities",
"vertices",
}
def _find_forbidden(value: Any, path: str = "$") -> list[str]:
found: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
child = f"{path}.{key}"
if key in FORBIDDEN_CDSL_KEYS:
found.append(child)
found.extend(_find_forbidden(item, child))
elif isinstance(value, list):
for index, item in enumerate(value):
found.extend(_find_forbidden(item, f"{path}[{index}]"))
return found
def validate(cdsl_path: Path, evidence_dir: Path) -> dict[str, Any]:
cdsl = json.loads(cdsl_path.read_text(encoding="utf-8"))
part_id = str(cdsl["part_id"])
source_name = str(cdsl["meta"]["source"])
evidence = json.loads((evidence_dir / source_name).read_text(encoding="utf-8"))
truth = evidence["document_truth"]
mass = truth["mass_properties"]
step_path = cdsl_path.with_name(f"{part_id}_rebuilt.step")
report_path = cdsl_path.with_name(f"{part_id}.rebuild_report.json")
report = json.loads(report_path.read_text(encoding="utf-8"))
solid = import_step(str(step_path))
truth_volume = float(mass["volume"]) * 1e9
truth_area = float(mass["surface_area"]) * 1e6
truth_com = [float(value) * 1000.0 for value in mass["center_of_mass"]]
rebuilt_com_vector = solid.center(CenterOf.MASS)
rebuilt_com = [rebuilt_com_vector.X, rebuilt_com_vector.Y, rebuilt_com_vector.Z]
bbox = solid.bounding_box()
rebuilt_bbox = [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z]
truth_bbox = [float(value) * 1000.0 for value in truth["geometry"]["bounding_box"]]
volume_error_pct = abs(float(solid.volume) - truth_volume) / truth_volume * 100.0
area_error_pct = abs(float(solid.area) - truth_area) / truth_area * 100.0
com_delta_mm = math.dist(rebuilt_com, truth_com)
bbox_max_delta_mm = max(abs(a - b) for a, b in zip(rebuilt_bbox, truth_bbox))
forbidden = _find_forbidden(cdsl)
engine = report.get("engine_result", {}).get("engine")
checks = {
"engine_cdsl_only": engine == "cdsl_only",
"no_forbidden_geometry_payload": not forbidden,
"volume_error_le_1pct": volume_error_pct <= 1.0,
"surface_area_error_le_1pct": area_error_pct <= 1.0,
"center_of_mass_delta_le_0_1mm": com_delta_mm <= 0.1,
"bbox_delta_le_0_01mm": bbox_max_delta_mm <= 0.01,
}
return {
"part_id": part_id,
"cdsl_path": str(cdsl_path),
"rebuilt_step": str(step_path),
"source_evidence": str(evidence_dir / source_name),
"cdsl_lines": len(cdsl_path.read_text(encoding="utf-8").splitlines()),
"feature_count": len(cdsl.get("features") or []),
"sketch_count": len((cdsl.get("geometry") or {}).get("sketches") or []),
"engine": engine,
"forbidden_paths": forbidden,
"metrics": {
"truth_volume_mm3": truth_volume,
"rebuilt_volume_mm3": float(solid.volume),
"volume_error_pct": volume_error_pct,
"truth_surface_area_mm2": truth_area,
"rebuilt_surface_area_mm2": float(solid.area),
"surface_area_error_pct": area_error_pct,
"center_of_mass_delta_mm": com_delta_mm,
"bbox_max_delta_mm": bbox_max_delta_mm,
},
"checks": checks,
"passed": all(checks.values()),
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--evidence", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
results = [
validate(path, args.evidence)
for path in sorted(args.output.glob("cylinder_*/*.cdsl.json"))
]
summary = {
"schema": "cad.cdsl.output3.validation.v1",
"count": len(results),
"passed_count": sum(item["passed"] for item in results),
"failed_count": sum(not item["passed"] for item in results),
"max_volume_error_pct": max(item["metrics"]["volume_error_pct"] for item in results),
"max_surface_area_error_pct": max(item["metrics"]["surface_area_error_pct"] for item in results),
"max_center_of_mass_delta_mm": max(item["metrics"]["center_of_mass_delta_mm"] for item in results),
"max_bbox_delta_mm": max(item["metrics"]["bbox_max_delta_mm"] for item in results),
"results": results,
}
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"validated={summary['count']} passed={summary['passed_count']} "
f"failed={summary['failed_count']} report={args.report}"
)
if summary["failed_count"]:
raise SystemExit(1)
if __name__ == "__main__":
main()