feat(cadfs): 补齐核心建模能力并建立代表性回归
- 新增 loft、双向切除、through-all/up-to-next 等 CADFS lowering 与 engine 支持 - 支持多种 reference plane、B-spline profile 和 circular pattern replay - 保留 transform 历史,并烘焙安全的单源平移/旋转变换 - 改进 selector 绑定、拓扑快照和 pattern 变换处理 - 建立 17 个代表样本的转换、重建与比较回归工具链 - 补充 schema、author guidance、运行时和几何回归测试
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# CADFS 基线 Engine 能力缺口与补齐清单
|
||||
|
||||
基于原始 `cadfs_to_cdsl/output` 全量报告。受影响样本数可重叠,不可相加。
|
||||
|
||||
## P0:主体、草图与核心特征
|
||||
|
||||
| 能力缺口 | 受影响样本 | 需要补足的能力 |
|
||||
| --- | ---: | --- |
|
||||
| B 样条草图 / `skFitSpline` | 未单独统计 | 控制点、闭合 wire、B-spline edge,以及供 extrude、loft、sweep 使用的 profile |
|
||||
| 复杂草图轮廓 | 未单独统计 | 直线、圆弧、圆、椭圆、样条、多 wire 和孔洞的稳定闭合与排序 |
|
||||
| `shell` | 696 | 移除面、壁厚、内/外方向、多实体和失败诊断 |
|
||||
| `sweep` | 326 | 截面、路径、导轨、实体/曲面模式、扭转和过渡策略 |
|
||||
| `loft` | 309 | 多 profile、闭合/开口 profile、导轨、实体/曲面模式和 profile 对齐 |
|
||||
| `booleanBodies` | 187 | union、subtract、intersect、目标/工具 body 和保留工具体策略 |
|
||||
| `circularPattern` | 232 | source feature 重放、旋转轴、数量、角度范围和嵌套 pattern |
|
||||
|
||||
## P0:拉伸语义
|
||||
|
||||
| 能力缺口 | 受影响样本 | 需要补足的能力 |
|
||||
| --- | ---: | --- |
|
||||
| `extrude_cut_through_all` | 303 | 按目标 body 实际交段执行穿透全部切除 |
|
||||
| `extrude_cut_two_sided` | 262 | 正反独立距离和终止条件的双向切除 |
|
||||
| `extrude_extent:up_to_surface` | 95 | 到面终止与非平面 profile 裁剪 |
|
||||
| `extrude_extent:up_to_next` | 49 | 到下一实体终止 |
|
||||
| `extrude_add_through_all` | 4 | 穿透全部加料拉伸 |
|
||||
| `extrude_extent:up_to_vertex` | 4 | 到顶点终止 |
|
||||
| `extrude_surface_or_mixed` | 4 | 曲面拉伸和 surface/solid 混合拓扑 |
|
||||
| `extrude_extent:up_to_body` | 2 | 到指定 body 终止 |
|
||||
|
||||
## P1:selector 与基准面
|
||||
|
||||
| 能力缺口 | 受影响样本 | 需要补足的能力 |
|
||||
| --- | ---: | --- |
|
||||
| `extrude_profile_topology:intersect` | 382 | 面/边交集 selector 与持久化 |
|
||||
| `extrude_profile_topology:cap_face` | 200 | 拉伸端盖面 selector |
|
||||
| `extrude_profile_topology:cap_edge` | 133 | 拉伸端盖边 selector |
|
||||
| `reference_plane:line_angle` | 123 | 线-角度基准面 |
|
||||
| `extrude_profile_topology:swept_face` | 99 | 侧壁面 selector |
|
||||
| `reference_plane:plane_point` | 47 | 平面-点基准面 |
|
||||
| `reference_plane:three_point` | 35 | 三点基准面 |
|
||||
| `reference_plane:mid_plane` | 25 | 两平面中面 |
|
||||
| `reference_plane:line_point` | 23 | 线-点基准面 |
|
||||
| `extrude_profile_topology:offset_face` | 18 | 偏移面 selector |
|
||||
| `extrude_profile_topology:swept_edge` | 16 | 侧壁边 selector |
|
||||
| `reference_plane:curve_point` | 13 | 曲线-点基准面 |
|
||||
| `extrude_profile_topology:mid_cap_edge` | 2 | 中性面端盖边 selector |
|
||||
|
||||
## P1:已有特征的未覆盖语义
|
||||
|
||||
| 特征 | 受影响样本 | 需要补足的能力 |
|
||||
| --- | ---: | --- |
|
||||
| `extrude` | 3,863 | 复杂 profile、拓扑引用和曲面/混合实体语义 |
|
||||
| `fillet` | 1,913 | 稳定边选择、切线传播、半径可行性 |
|
||||
| `revolve` | 742 | 轴选择、方向/角度、曲面模式和 profile 表达 |
|
||||
| `hole` | 623 | 孔型、螺纹、沉头/沉孔、终止条件和宿主面引用 |
|
||||
| `chamfer` | 588 | 距离-角度、双距离等参数及稳定边选择 |
|
||||
| `revolve_surface` | 367 | 曲面旋转及其与实体布尔的混合策略 |
|
||||
| `mirror` | 263 | source replay、镜像面引用和嵌套 pattern |
|
||||
| `cPlane` | 145 | 除 OFFSET 外的基准面构造和可重用 frame |
|
||||
|
||||
## P2:未覆盖的 FeatureScript 操作
|
||||
|
||||
| 能力缺口 | 需要补足的能力 |
|
||||
| --- | --- |
|
||||
| `draft` | 拔模面与中性面定义 |
|
||||
| `thicken` | 曲面加厚和方向控制 |
|
||||
| `split` | 面/实体分割与结果 body 管理 |
|
||||
| `moveFace` | 偏移、旋转、平移现有面 |
|
||||
| `deleteFace` | 删除面及修补策略 |
|
||||
| `replaceFace` | 面替换与拓扑更新 |
|
||||
| `derive` | 受控外部派生模型协议 |
|
||||
| `import` | 受控外部几何导入协议 |
|
||||
|
||||
## 补齐清单
|
||||
|
||||
### 第一阶段
|
||||
|
||||
- [ ] 支持 B 样条草图和闭合 profile。
|
||||
- [ ] 新增 `loft_add`。
|
||||
- [ ] 新增 `shell`。
|
||||
- [ ] 新增 `sweep_add`。
|
||||
- [ ] 新增多 body boolean:union、subtract、intersect。
|
||||
- [ ] 为以上 FeatureScript 操作完成 lowering、schema、executor、adapter、selector 和端到端回归。
|
||||
|
||||
### 第二阶段
|
||||
|
||||
- [ ] 补齐 extrude 的 through-all、two-sided cut、up-to-surface、up-to-next、up-to-body、up-to-vertex。
|
||||
- [ ] 支持 `revolve_surface` 和 surface/solid 混合策略。
|
||||
- [ ] 补齐 `hole_wizard` 的 CADFS 孔型、螺纹和终止条件。
|
||||
- [ ] 补齐 `fillet`/`chamfer` 的参数变体、可行性预检查和稳定失败诊断。
|
||||
- [ ] 补齐 `circularPattern` 和 `mirror` 的 source replay、基准引用和嵌套 pattern。
|
||||
|
||||
### 第三阶段
|
||||
|
||||
- [ ] 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 等面/边 selector。
|
||||
- [ ] 补齐 line-angle、plane-point、three-point、mid-plane、line-point、curve-point 基准面。
|
||||
- [ ] 补齐 selector 的 owner、几何签名和歧义解析。
|
||||
|
||||
### 第四阶段
|
||||
|
||||
- [ ] 支持 `draft`、`thicken`、`split`。
|
||||
- [ ] 支持 `moveFace`、`deleteFace`、`replaceFace`。
|
||||
- [ ] 定义并实现 `derive`、`import` 的受控资产协议。
|
||||
@@ -14,3 +14,28 @@ PYTHONPATH=backend:. python -m cadfs_to_cdsl pipeline
|
||||
All stages are resumable. Use `--force` after changing converter behavior.
|
||||
The original CADFS directory is read-only; generated evidence is written under
|
||||
`cadfs_to_cdsl/output/samples/<sample_id>/`.
|
||||
|
||||
## Representative regression pool
|
||||
|
||||
Generate the checked-in representative manifest after a full corpus conversion:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression-select
|
||||
```
|
||||
|
||||
The selector deterministically covers every observed FeatureScript modeling
|
||||
operation, sketch entity type, lowered CDSL atomic operation, unsupported
|
||||
operation, and unsupported engine-capability variant. It records which atomic
|
||||
operations have a successful engine baseline and which are diagnostic-only;
|
||||
the latter are never reported as passing builds. A normal engine change has a
|
||||
small, executable STEP regression command:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend:. python -m cadfs_to_cdsl regression --tier engine --stage rebuild
|
||||
```
|
||||
|
||||
The command reruns by default; use `--resume` only to inspect cached results.
|
||||
Use `--tier conversion --stage convert` to verify feature lowering and
|
||||
diagnostics, or `--tier all --stage pipeline` for the complete representative
|
||||
conversion/rebuild/compare pass. Unsupported CADFS features remain diagnostic
|
||||
coverage, not expected successful engine builds.
|
||||
|
||||
+23
-2
@@ -4,6 +4,7 @@ import argparse, json
|
||||
from pathlib import Path
|
||||
from .describe import describe_samples
|
||||
from .pipeline import load_samples, run_stage, scan, select_samples
|
||||
from .regression import regression_sample_ids, summarize_regression, write_regression_manifest
|
||||
from .reports import generate_markdown_report, generate_reports, read_json
|
||||
|
||||
DEFAULT_INPUT = Path("data/cadfs-sample/CADFS_test")
|
||||
@@ -13,11 +14,21 @@ DEFAULT_OUTPUT = Path("cadfs_to_cdsl/output")
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Convert CADFS FeatureScript to CDSL and validate against STEP")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe"):
|
||||
for name in ("scan", "convert", "rebuild", "compare", "report", "pipeline", "describe", "regression-select", "regression"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--input", type=Path, default=DEFAULT_INPUT)
|
||||
command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
if name == "describe":
|
||||
if name == "regression-select":
|
||||
command.add_argument("--manifest", type=Path, default=Path("cadfs_to_cdsl/regression/manifest.json"))
|
||||
elif name == "regression":
|
||||
command.add_argument("--manifest", type=Path, default=Path("cadfs_to_cdsl/regression/manifest.json"))
|
||||
command.add_argument("--tier", choices=("engine", "conversion", "all"), default="engine")
|
||||
command.add_argument("--stage", choices=("convert", "rebuild", "compare", "pipeline"), default="rebuild")
|
||||
command.add_argument("--workers", type=int, default=1)
|
||||
command.add_argument("--compare-mode", choices=("rp", "strict"), default="rp")
|
||||
command.add_argument("--timeout-seconds", type=float, default=30.0, help="per-model OCC timeout (default: 30)")
|
||||
command.add_argument("--resume", action="store_true", help="reuse cached per-sample stage results instead of rerunning them")
|
||||
elif name == "describe":
|
||||
command.add_argument("--shard", help="input shard directory to describe, for example 0005")
|
||||
command.add_argument("--sample-id", action="append")
|
||||
command.add_argument("--offset", type=int, default=0)
|
||||
@@ -46,6 +57,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
if args.command == "scan":
|
||||
records = scan(args.input, args.output); result = {"sample_count": len(records), "output": str(args.output / "dataset_index.json")}
|
||||
elif args.command == "regression-select":
|
||||
manifest = write_regression_manifest(args.output, args.manifest)
|
||||
result = {"manifest": str(args.manifest), "selected_sample_count": manifest["selected_sample_count"], "engine_sample_count": manifest["engine_sample_count"]}
|
||||
elif args.command == "regression":
|
||||
if not 1 <= args.workers <= 8: raise ValueError("--workers must be between 1 and 8")
|
||||
if args.timeout_seconds <= 0: raise ValueError("--timeout-seconds must be positive")
|
||||
sample_ids = regression_sample_ids(args.manifest, args.tier)
|
||||
samples = select_samples(load_samples(args.input, args.output), sample_ids=sample_ids)
|
||||
records = run_stage(args.stage, samples, args.output, force=not args.resume, compare_mode=args.compare_mode, timeout_seconds=args.timeout_seconds, workers=args.workers)
|
||||
result = {"tier": args.tier, "stage": args.stage, "manifest": str(args.manifest), **summarize_regression(records)}
|
||||
elif args.command == "describe":
|
||||
records, result = describe_samples(
|
||||
args.input,
|
||||
|
||||
@@ -119,7 +119,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
|
||||
if model.sketches:
|
||||
args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}"
|
||||
model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name))
|
||||
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies"}:
|
||||
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "transform"}:
|
||||
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}"
|
||||
feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name)
|
||||
model.features.append(feature_ir); model.steps.append(feature_ir)
|
||||
|
||||
+416
-18
@@ -8,7 +8,7 @@ from .ir import Call, FeatureIR, ModelIR, SketchIR
|
||||
from .query_parser import parse_query, walk_calls
|
||||
|
||||
|
||||
UNSUPPORTED = {"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
|
||||
UNSUPPORTED = {"shell", "sweep", "draft", "thicken", "split", "booleanBodies", "moveFace", "replaceFace", "deleteFace", "import", "derive"}
|
||||
PLANES = {
|
||||
"Top": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., 0., 1.]},
|
||||
"Front": {"origin_mm": [0., 0., 0.], "x_dir": [1., 0., 0.], "normal": [0., -1., 0.]},
|
||||
@@ -65,6 +65,43 @@ def _cross(a: list[float], b: list[float]) -> list[float]:
|
||||
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
|
||||
|
||||
|
||||
def _dot(a: list[float], b: list[float]) -> float: return sum(left * right for left, right in zip(a, b))
|
||||
|
||||
|
||||
def _unit(value: list[float], message: str) -> list[float]:
|
||||
length = math.sqrt(_dot(value, value))
|
||||
if length <= 1e-9: raise ValueError(message)
|
||||
return [component / length for component in value]
|
||||
|
||||
|
||||
def _sub(a: list[float], b: list[float]) -> list[float]: return [a[index] - b[index] for index in range(3)]
|
||||
|
||||
|
||||
def _rotate(value: list[float], axis: list[float], angle_rad: float) -> list[float]:
|
||||
axis = _unit(axis, "rotation axis is degenerate")
|
||||
cosine, sine = math.cos(angle_rad), math.sin(angle_rad)
|
||||
cross = _cross(axis, value); projection = _dot(axis, value) * (1.0 - cosine)
|
||||
return [value[index] * cosine + cross[index] * sine + axis[index] * projection for index in range(3)]
|
||||
|
||||
|
||||
def _translate_frame(frame: dict[str, Any], offset: list[float]) -> dict[str, Any]:
|
||||
return {**frame, "origin_mm": [frame["origin_mm"][index] + offset[index] for index in range(3)]}
|
||||
|
||||
|
||||
def _rotate_point(point: list[float], axis: dict[str, list[float]], angle_rad: float) -> list[float]:
|
||||
relative = _rotate(_sub(point, axis["origin_mm"]), axis["direction"], angle_rad)
|
||||
return [axis["origin_mm"][index] + relative[index] for index in range(3)]
|
||||
|
||||
|
||||
def _rotate_frame(frame: dict[str, Any], axis: dict[str, list[float]], angle_rad: float) -> dict[str, Any]:
|
||||
return {
|
||||
**frame,
|
||||
"origin_mm": _rotate_point(frame["origin_mm"], axis, angle_rad),
|
||||
"x_dir": _rotate(frame["x_dir"], axis["direction"], angle_rad),
|
||||
"normal": _rotate(frame["normal"], axis["direction"], angle_rad),
|
||||
}
|
||||
|
||||
|
||||
def _y_dir(plane: dict[str, Any]) -> list[float]: return _cross(plane["normal"], plane["x_dir"])
|
||||
|
||||
|
||||
@@ -81,6 +118,12 @@ def _oriented_plane(plane: dict[str, Any], normal_sign: float, distance: float =
|
||||
return {**shifted, "normal": [normal_sign * value for value in plane["normal"]]}
|
||||
|
||||
|
||||
def _frame(origin: list[float], x_dir: list[float], normal: list[float]) -> dict[str, list[float]]:
|
||||
normal = _unit(normal, "reference plane normal is degenerate")
|
||||
x_dir = _sub(x_dir, [normal[index] * _dot(x_dir, normal) for index in range(3)])
|
||||
return {"origin_mm": origin, "x_dir": _unit(x_dir, "reference plane x direction is degenerate"), "normal": 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"}:
|
||||
@@ -107,6 +150,7 @@ def _end_condition(value: Any) -> dict[str, Any]:
|
||||
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}
|
||||
if name == "UP_TO_NEXT": return {"type": "through_next", "solidworks_code": 4}
|
||||
raise UnsupportedCapability(f"extrude_extent:{name.lower()}", f"current CDSL atomic set has no exact extrusion operation for {name}")
|
||||
|
||||
|
||||
@@ -137,12 +181,13 @@ def _contours(segments: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], lis
|
||||
if _endpoint(item, True) == tail:
|
||||
item["start"], item["end"] = item["end"], item["start"]
|
||||
if item["type"] == "arc": item["clockwise"] = not item["clockwise"]
|
||||
elif item["type"] == "bspline": item["points"] = list(reversed(item["points"]))
|
||||
contour.append(item); tail = _endpoint(item, True)
|
||||
if tail == first: contours.append({"role": "unknown", "closed": True, "segments": contour})
|
||||
return contours + circles, construction
|
||||
|
||||
|
||||
def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
||||
def _lower_sketch(sketch: SketchIR, plane: dict[str, Any], allow_open: bool = False) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
||||
segments: list[dict[str, Any]] = []; explicit_construction: list[dict[str, Any]] = []; entities: dict[str, dict[str, Any]] = {}; unsupported = []
|
||||
for entity in sketch.entities:
|
||||
p = entity.params
|
||||
@@ -150,6 +195,10 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An
|
||||
if entity.operation == "skLineSegment": item = {"type": "line", "start": _point(p["start"]), "end": _point(p["end"])}
|
||||
elif entity.operation == "skCircle": item = {"type": "circle", "center": _point(p["center"]), "radius_mm": _number(p["radius"], True)}
|
||||
elif entity.operation == "skArc": item = _arc(_point(p["start"]), _point(p["mid"]), _point(p["end"]))
|
||||
elif entity.operation == "skFitSpline":
|
||||
points = [_point(point) for point in p.get("points") or []]
|
||||
if len(points) < 3: raise ValueError("fit spline needs at least 3 points")
|
||||
item = {"type": "bspline", "start": points[0], "end": points[-1], "points": points}
|
||||
else: unsupported.append(entity.operation); continue
|
||||
(explicit_construction if _bool(p.get("construction")) else segments).append(item); entities[entity.feature_id] = item
|
||||
if unsupported: raise ValueError("unsupported sketch entities: " + ",".join(sorted(set(unsupported))))
|
||||
@@ -162,7 +211,9 @@ def _lower_sketch(sketch: SketchIR, plane: dict[str, Any]) -> tuple[dict[str, An
|
||||
else:
|
||||
contours, open_segments = _contours(segments)
|
||||
if open_segments:
|
||||
raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)")
|
||||
if not allow_open: raise ValueError(f"sketch has {len(open_segments)} open non-construction segment(s)")
|
||||
profile = {"type": "analytic_contours", "contours": [], "construction": explicit_construction + open_segments}
|
||||
return {"id": f"sketch_{sketch.feature_id}", "name": sketch.feature_id, "workplane": plane, "profile": profile, "role": "reference"}, entities
|
||||
construction = list(explicit_construction)
|
||||
if not contours:
|
||||
profile = {"type": "analytic_contours", "contours": [], "construction": construction}
|
||||
@@ -193,6 +244,89 @@ def _source_sketch(params: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _pattern_source_features(value: Any, previous: list[str]) -> list[str]:
|
||||
sources = []
|
||||
for call in walk_calls(value):
|
||||
if call.name != "makeQuery" or not call.args:
|
||||
continue
|
||||
owner = symbolic_string(call.args[0])
|
||||
if "F" not in owner:
|
||||
continue
|
||||
source = "f_" + owner[owner.find("F"):].split(".", 1)[0]
|
||||
if source in previous and source not in sources:
|
||||
sources.append(source)
|
||||
if not sources:
|
||||
raise ValueError("pattern source features are unresolved")
|
||||
return sources
|
||||
|
||||
|
||||
def _transform_source_features(value: Any, previous: list[str]) -> list[str]:
|
||||
sources = []
|
||||
for call in walk_calls(value):
|
||||
if call.name not in {"makeQuery", "qCreatedBy"} or not call.args:
|
||||
continue
|
||||
owner = symbolic_string(call.args[0])
|
||||
if "F" not in owner:
|
||||
continue
|
||||
source = "f_" + owner[owner.find("F"):].split(".", 1)[0]
|
||||
if source in previous and source not in sources:
|
||||
sources.append(source)
|
||||
if not sources:
|
||||
raise ValueError("transform source features are unresolved")
|
||||
return sources
|
||||
|
||||
|
||||
def _circular_pattern_axis(
|
||||
value: Any,
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> dict[str, list[float]]:
|
||||
query = parse_query(value)
|
||||
entity = (entity_by_sketch.get(query.source_sketch or "") or {}).get(query.source_entity or "")
|
||||
if entity is None or query.source_sketch not in sketch_by_source:
|
||||
raise ValueError("circular pattern axis is unresolved")
|
||||
plane = sketch_by_source[query.source_sketch]["workplane"]
|
||||
if entity["type"] == "line":
|
||||
start = _global(plane, entity["start"]); end = _global(plane, entity["end"])
|
||||
direction = [end[index] - start[index] for index in range(3)]
|
||||
norm = math.sqrt(sum(component * component for component in direction))
|
||||
if norm <= 1e-9:
|
||||
raise ValueError("circular pattern axis line is degenerate")
|
||||
return {"origin_mm": start, "direction": [component / norm for component in direction]}
|
||||
if entity["type"] == "circle":
|
||||
frame = feature_frames.get(query.owner_feature or "")
|
||||
if frame and query.is_start is not None:
|
||||
plane = frame["start" if query.is_start else "end"]
|
||||
return {"origin_mm": _global(plane, entity["center"]), "direction": list(plane["normal"])}
|
||||
raise ValueError("circular pattern axis must be a sketch line or circular edge")
|
||||
|
||||
|
||||
def _loft_profile_sketches(params: dict[str, Any]) -> list[str]:
|
||||
# CADFS loft 的 profile 是草图 IMPRINT 面;几何仍来自原始闭合草图,
|
||||
# 保留草图 source,不能把前序实体的选中面近似为新的放样轮廓。
|
||||
profiles = params.get("sheetProfilesArray")
|
||||
if not isinstance(profiles, list):
|
||||
raise ValueError("loft sheetProfilesArray is unresolved")
|
||||
sources: list[str] = []
|
||||
for profile in profiles:
|
||||
query_value = profile.get("sheetProfileEntities") if isinstance(profile, dict) else profile
|
||||
query = parse_query(query_value)
|
||||
if query.topology_type and query.topology_type != "IMPRINT":
|
||||
raise UnsupportedCapability(
|
||||
f"loft_profile_topology:{query.topology_type.lower()}",
|
||||
f"current CDSL loft only supports sketch-imprint profiles, not {query.topology_type}",
|
||||
)
|
||||
if not query.source_sketch:
|
||||
raise ValueError("loft profile sketch query is unresolved")
|
||||
sources.append(query.source_sketch)
|
||||
if len(sources) < 2:
|
||||
raise ValueError("loft requires at least two profile sketches")
|
||||
if len(set(sources)) != len(sources):
|
||||
raise ValueError("loft profile sketches must be distinct")
|
||||
return sources
|
||||
|
||||
|
||||
def _profile_query_kind(params: dict[str, Any]) -> str | None:
|
||||
for key in ("entities", "sheetProfilesArray"):
|
||||
if key in params:
|
||||
@@ -215,18 +349,243 @@ def _default_plane(value: Any) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _entity_from_query(
|
||||
query: Any,
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], str]:
|
||||
info = parse_query(query); source = info.source_sketch or ""; token = info.source_entity or ""
|
||||
available = entity_by_sketch.get(source) or {}
|
||||
entity = available.get(token)
|
||||
if entity is None:
|
||||
entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="")
|
||||
entity = available.get(entity_id)
|
||||
sketch = sketch_by_source.get(source)
|
||||
if entity is None or sketch is None:
|
||||
raise ValueError("reference geometry source is unresolved")
|
||||
return entity, sketch["workplane"], token
|
||||
|
||||
|
||||
def _entity_point(entity: dict[str, Any], plane: dict[str, Any], token: str) -> list[float]:
|
||||
if entity["type"] == "point": return _global(plane, entity["point"])
|
||||
if entity["type"] == "line":
|
||||
local = entity["end"] if ".end" in token else entity["start"]
|
||||
return _global(plane, local)
|
||||
if entity["type"] == "bspline":
|
||||
index = next((int(part) - 1 for part in token.split(".") if part.isdigit()), 0)
|
||||
points = entity.get("points") or []
|
||||
if not points: raise ValueError("B-spline reference point is unresolved")
|
||||
return _global(plane, points[max(0, min(index, len(points) - 1))])
|
||||
raise ValueError("reference entity does not define a point")
|
||||
|
||||
|
||||
def _entity_line(entity: dict[str, Any], plane: dict[str, Any]) -> tuple[list[float], list[float]]:
|
||||
if entity["type"] != "line": raise ValueError("reference entity is not a line")
|
||||
return _global(plane, entity["start"]), _global(plane, entity["end"])
|
||||
|
||||
|
||||
def _query_plane(
|
||||
query: Any,
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return _plane_from_query(query, feature_frames, sketch_by_source)
|
||||
except ValueError:
|
||||
info = parse_query(query)
|
||||
if info.topology_type != "SWEPT_FACE" or not info.owner_feature:
|
||||
raise
|
||||
entity, source_plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch)
|
||||
start, end = _entity_line(entity, source_plane); frame = feature_frames.get(info.owner_feature)
|
||||
if frame is None: raise ValueError("swept face owner frame is unresolved")
|
||||
direction = _unit(_sub(end, start), "swept face source line is degenerate")
|
||||
normal = _cross(direction, frame["end"]["normal"])
|
||||
return _frame(start, direction, normal)
|
||||
|
||||
|
||||
def _query_line(
|
||||
query: Any,
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> tuple[list[float], list[float]]:
|
||||
info = parse_query(query)
|
||||
entity, plane, _ = _entity_from_query(query, sketch_by_source, entity_by_sketch)
|
||||
if info.topology_type == "CAP_EDGE" and info.owner_feature in feature_frames:
|
||||
frame = feature_frames[info.owner_feature]["start" if info.is_start else "end"]
|
||||
plane = frame
|
||||
return _entity_line(entity, plane)
|
||||
|
||||
|
||||
def _transform_axis(
|
||||
query: Any,
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> dict[str, list[float]]:
|
||||
start, end = _query_line(query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
return {"origin_mm": start, "direction": _unit(_sub(end, start), "transform axis is degenerate")}
|
||||
|
||||
|
||||
def _bake_transform(
|
||||
params: dict[str, Any],
|
||||
previous: list[str],
|
||||
feature_by_id: dict[str, dict[str, Any]],
|
||||
feature_source_by_id: dict[str, str],
|
||||
sketches_by_id: dict[str, dict[str, Any]],
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> None:
|
||||
if _bool(params.get("makeCopy")):
|
||||
raise UnsupportedCapability("transform", "current CDSL engine cannot exactly copy transformed CADFS source bodies")
|
||||
sources = _transform_source_features(params.get("entities"), previous)
|
||||
if len(sources) != 1:
|
||||
raise UnsupportedCapability("transform", "current CDSL engine cannot exactly transform multiple selected CADFS source bodies")
|
||||
source_id = sources[0]; source = feature_by_id.get(source_id)
|
||||
if source is None or source.get("atomic_id") not in {"extrude_add_blind", "extrude_add_two_sided", "revolve_add"}:
|
||||
raise UnsupportedCapability("transform", "current CDSL engine can only bake a direct additive extrusion or revolve transform")
|
||||
sketch = sketches_by_id.get(str(source.get("sketch_id") or ""))
|
||||
source_feature_id = feature_source_by_id.get(source_id)
|
||||
if sketch is None or source_feature_id is None:
|
||||
raise UnsupportedCapability("transform", "current CDSL engine cannot resolve the transformed source feature geometry")
|
||||
transform_type = str(params.get("transformType") or "").split(".")[-1].upper()
|
||||
if transform_type == "TRANSLATION_3D":
|
||||
offset = [_number(params.get(key, 0.0), True) for key in ("dx", "dy", "dz")]
|
||||
transform_frame = lambda frame: _translate_frame(frame, offset)
|
||||
transform_axis = lambda axis: {**axis, "origin_mm": [axis["origin_mm"][index] + offset[index] for index in range(3)]}
|
||||
elif transform_type == "ROTATION":
|
||||
axis = _transform_axis(params.get("transformAxis"), feature_frames, sketch_by_source, entity_by_sketch)
|
||||
angle_rad = math.radians(_number(params.get("angle"), True))
|
||||
transform_frame = lambda frame: _rotate_frame(frame, axis, angle_rad)
|
||||
transform_axis = lambda value: {
|
||||
**value,
|
||||
"origin_mm": _rotate_point(value["origin_mm"], axis, angle_rad),
|
||||
"direction": _rotate(value["direction"], axis["direction"], angle_rad),
|
||||
}
|
||||
else:
|
||||
raise UnsupportedCapability("transform", f"current CDSL engine cannot exactly bake {transform_type or 'unknown'} transform")
|
||||
sketch["workplane"] = transform_frame(sketch["workplane"])
|
||||
frame = feature_frames.get(source_feature_id)
|
||||
if frame is not None:
|
||||
feature_frames[source_feature_id] = {key: transform_frame(value) for key, value in frame.items()}
|
||||
source_axis = source.get("params", {}).get("axis")
|
||||
if isinstance(source_axis, dict) and source_axis.get("origin_mm") and source_axis.get("direction"):
|
||||
source["params"]["axis"] = transform_axis(source_axis)
|
||||
|
||||
|
||||
def _query_point(
|
||||
query: Any,
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> list[float]:
|
||||
info = parse_query(query)
|
||||
if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames:
|
||||
references = _source_refs(query)
|
||||
if len(references) >= 2:
|
||||
plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"]
|
||||
lines = []
|
||||
for source, token in references:
|
||||
available = entity_by_sketch.get(source) or {}
|
||||
entity = available.get(token)
|
||||
if entity is None:
|
||||
entity_id = max((key for key in available if token.startswith(key + ".")), key=len, default="")
|
||||
entity = available.get(entity_id)
|
||||
if entity and entity.get("type") == "line": lines.append(_entity_line(entity, plane))
|
||||
if len(lines) >= 2:
|
||||
pairs = [(math.dist(left, right), left) for left in lines[0] for right in lines[1]]
|
||||
distance, point = min(pairs, key=lambda item: item[0])
|
||||
if distance <= 1e-5: return point
|
||||
entity, plane, token = _entity_from_query(query, sketch_by_source, entity_by_sketch)
|
||||
if info.topology_type == "CAP_VERTEX" and info.owner_feature in feature_frames:
|
||||
plane = feature_frames[info.owner_feature]["start" if info.is_start else "end"]
|
||||
return _entity_point(entity, plane, token)
|
||||
|
||||
|
||||
def _cplane(
|
||||
params: dict[str, Any],
|
||||
feature_frames: dict[str, dict[str, Any]],
|
||||
sketch_by_source: dict[str, dict[str, Any]],
|
||||
entity_by_sketch: dict[str, dict[str, dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
plane_type = str(params.get("cplaneType") or "OFFSET").split(".")[-1].upper()
|
||||
entities = _queries(params.get("entities"))
|
||||
if plane_type == "OFFSET":
|
||||
return _shift_plane(_query_plane(entities[0], feature_frames, sketch_by_source, entity_by_sketch), _number(params.get("offset", 0), True))
|
||||
if plane_type == "LINE_ANGLE":
|
||||
line_query = next((item for item in entities if parse_query(item).source_entity), None)
|
||||
if line_query is None: raise ValueError("line-angle reference line is unresolved")
|
||||
base_query = next((item for item in entities if item is not line_query), line_query)
|
||||
try:
|
||||
base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
except ValueError:
|
||||
_, base, _ = _entity_from_query(line_query, sketch_by_source, entity_by_sketch)
|
||||
start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
axis = _sub(end, start); angle = _number(params.get("angle", 0.0))
|
||||
if _bool(params.get("oppositeDirection")): angle = -angle
|
||||
return _frame(start, _rotate(base["x_dir"], axis, math.radians(angle)), _rotate(base["normal"], axis, math.radians(angle)))
|
||||
if plane_type == "PLANE_POINT":
|
||||
base_query = next((item for item in entities if _default_plane(item) or "qCreatedBy" in parse_query(item).calls), None)
|
||||
point_query = next((item for item in entities if item is not base_query), None)
|
||||
if base_query is None or point_query is None: raise ValueError("plane-point references are unresolved")
|
||||
base = _query_plane(base_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
return _frame(_query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch), base["x_dir"], base["normal"])
|
||||
if plane_type == "CURVE_POINT":
|
||||
point_query = next((item for item in entities if parse_query(item).kind and "vertex" in parse_query(item).kind), None)
|
||||
curve_query = next((item for item in entities if item is not point_query), None)
|
||||
if point_query is None or curve_query is None: raise ValueError("curve-point references are unresolved")
|
||||
point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
start, end = _query_line(curve_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
_, source_plane, _ = _entity_from_query(curve_query, sketch_by_source, entity_by_sketch)
|
||||
return _frame(point, source_plane["normal"], _sub(end, start))
|
||||
if plane_type == "THREE_POINT":
|
||||
if len(entities) != 3: raise ValueError("three-point plane requires exactly three points")
|
||||
first, second, third = [_query_point(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities]
|
||||
normal = _cross(_sub(second, first), _sub(third, first))
|
||||
if _bool(params.get("oppositeDirection")): normal = [-value for value in normal]
|
||||
return _frame(first, _sub(second, first), normal)
|
||||
if plane_type == "LINE_POINT":
|
||||
line_query = next((item for item in entities if "edge" in (parse_query(item).kind or "")), None)
|
||||
point_query = next((item for item in entities if item is not line_query), None)
|
||||
if line_query is None or point_query is None: raise ValueError("line-point references are unresolved")
|
||||
start, end = _query_line(line_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
point = _query_point(point_query, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
direction = _sub(end, start); normal = _cross(direction, _sub(point, start))
|
||||
if _bool(params.get("oppositeDirection")): normal = [-value for value in normal]
|
||||
return _frame(start, direction, normal)
|
||||
if plane_type == "MID_PLANE":
|
||||
if len(entities) != 2: raise ValueError("mid-plane requires exactly two reference planes")
|
||||
first, second = [_query_plane(item, feature_frames, sketch_by_source, entity_by_sketch) for item in entities]
|
||||
alignment = 1.0 if _dot(first["normal"], second["normal"]) >= 0 else -1.0
|
||||
offset = _dot(_sub(second["origin_mm"], first["origin_mm"]), first["normal"]) * alignment
|
||||
return _shift_plane(first, offset / 2.0)
|
||||
raise UnsupportedCapability(f"reference_plane:{plane_type.lower()}", f"current converter has no exact {plane_type} reference plane")
|
||||
|
||||
|
||||
def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
diagnostics: list[dict[str, Any]] = []; history = []
|
||||
sketches: list[dict[str, Any]] = []; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
sketches: list[dict[str, Any]] = []; sketches_by_id: dict[str, dict[str, Any]] = {}; sketch_by_source: dict[str, dict[str, Any]] = {}; entity_by_sketch: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
feature_frames: dict[str, dict[str, Any]] = {}
|
||||
features: list[dict[str, Any]] = []; complete = True; previous: list[str] = []
|
||||
feature_by_id: dict[str, dict[str, Any]] = {}; feature_source_by_id: dict[str, str] = {}
|
||||
for step in model.steps:
|
||||
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, 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
|
||||
lowered, entities = _lower_sketch(step, plane); sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||||
except Exception as exc:
|
||||
# 开放草图不能作为实体 profile,但其几何仍可能是后续基准面、
|
||||
# 阵列轴或旋转轴的精确引用。保留为 reference 草图,后续实体
|
||||
# 特征仍由 _profile_executable 明确拒绝,不能静默把开放轮廓实体化。
|
||||
try:
|
||||
plane = _plane_from_query(step.workplane, feature_frames, sketch_by_source)
|
||||
lowered, entities = _lower_sketch(step, plane, allow_open=True)
|
||||
sketches.append(lowered); sketches_by_id[lowered["id"]] = lowered; sketch_by_source[step.feature_id] = lowered; entity_by_sketch[step.feature_id] = entities
|
||||
except Exception:
|
||||
pass
|
||||
diagnostics.append({"code": "sketch_deferred", "feature_id": step.feature_id, "message": str(exc)}); complete = False
|
||||
continue
|
||||
item = step
|
||||
@@ -235,12 +594,14 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
diagnostics.append({"code": "unsupported_operation", "feature_id": item.feature_id, "operation": item.operation}); complete = False; continue
|
||||
try:
|
||||
fid = f"f_{item.feature_id}"; depends = list(previous[-1:]); p = item.params; feature: dict[str, Any]
|
||||
if item.operation == "cPlane":
|
||||
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"}
|
||||
if item.operation == "transform":
|
||||
# 仅将单一、直接的原始实体变换烘焙回其输入几何。不能移动当前
|
||||
# 聚合主体:CADFS transform 可能只选择 pattern copy 或多 body。
|
||||
_bake_transform(p, previous, feature_by_id, feature_source_by_id, sketches_by_id, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
continue
|
||||
elif item.operation == "cPlane":
|
||||
plane = _cplane(p, feature_frames, sketch_by_source, entity_by_sketch)
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "reference_plane", "depends_on": depends, "params": {"plane": plane}, "execution_status": "supported"}
|
||||
feature_frames[item.feature_id] = {"start": plane, "end": plane}
|
||||
elif item.operation == "extrude":
|
||||
if p.get("surfaceOperationType") is not None:
|
||||
@@ -255,19 +616,21 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
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']}"
|
||||
if cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}:
|
||||
capability = 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"}:
|
||||
if not cutting and not second and end["type"] not in {"blind", "mid_plane", "through_all", "through_next"}:
|
||||
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}
|
||||
atomic = "extrude_cut_two_sided" if cutting else "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:
|
||||
elif end["type"] == "mid_plane":
|
||||
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)}
|
||||
atomic = "extrude_cut_two_sided" if cutting else "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}
|
||||
@@ -279,6 +642,22 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
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 == "loft":
|
||||
sources = _loft_profile_sketches(p)
|
||||
missing = [source for source in sources if source not in sketch_by_source]
|
||||
if missing:
|
||||
raise ValueError("loft profile sketches are unresolved: " + ", ".join(missing))
|
||||
non_closed = [source for source in sources if not _profile_executable(sketch_by_source[source])]
|
||||
if non_closed:
|
||||
raise ValueError("loft profile sketches have no closed profile: " + ", ".join(non_closed))
|
||||
feature = {
|
||||
"id": fid,
|
||||
"name": item.feature_id,
|
||||
"atomic_id": "loft_add",
|
||||
"depends_on": depends,
|
||||
"params": {"profile_sketch_ids": [sketch_by_source[source]["id"] for source in sources]},
|
||||
"execution_status": "supported",
|
||||
}
|
||||
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")
|
||||
@@ -365,6 +744,25 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
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 == "circularPattern":
|
||||
sources = _pattern_source_features(p.get("entities"), previous)
|
||||
axis = _circular_pattern_axis(p.get("axis"), feature_frames, sketch_by_source, entity_by_sketch)
|
||||
count = int(_number(p.get("instanceCount")))
|
||||
if count < 1:
|
||||
raise ValueError("circular pattern instanceCount must be positive")
|
||||
feature = {
|
||||
"id": fid,
|
||||
"name": item.feature_id,
|
||||
"atomic_id": "pattern_circular",
|
||||
"depends_on": list(dict.fromkeys(sources + depends)),
|
||||
"params": {
|
||||
"source_feature_ids": sources,
|
||||
"axis": axis,
|
||||
"pattern_count": count,
|
||||
"sweep_angle_deg": _number(p.get("angle", 360.0)),
|
||||
},
|
||||
"execution_status": "supported",
|
||||
}
|
||||
elif item.operation == "mirror":
|
||||
owners = []
|
||||
for call in walk_calls(p.get("entities")):
|
||||
@@ -387,7 +785,7 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult:
|
||||
feature = {"id": fid, "name": item.feature_id, "atomic_id": "pattern_mirror", "depends_on": list(dict.fromkeys(owners + [plane_owner])), "params": {"source_feature_ids": owners, "mirror_plane": mirror_plane}, "selectors": [mirror_plane], "execution_status": "supported"}
|
||||
else:
|
||||
raise ValueError(f"operation mapping not implemented: {item.operation}")
|
||||
features.append(feature); previous.append(fid)
|
||||
features.append(feature); feature_by_id[fid] = feature; feature_source_by_id[fid] = item.feature_id; 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:
|
||||
|
||||
@@ -103,7 +103,12 @@ def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_sec
|
||||
def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_seconds: float = 30.0) -> dict[str, Any]:
|
||||
directory = _sample_dir(output, sample.sample_id); status_path = directory / "status.json"
|
||||
status = read_json(status_path) if status_path.exists() else convert_one(sample, output, force=force)
|
||||
if status.get("conversion_status") != "converted_complete": return status
|
||||
# A partial conversion may still retain a self-contained, semantically
|
||||
# executable CDSL prefix. That prefix is valuable engine evidence and
|
||||
# must be rebuilt instead of being hidden behind the conversion label.
|
||||
# ``rebuild_candidate`` keeps the two outcomes distinct by reporting a
|
||||
# runtime-ineligible candidate when no executable body can be produced.
|
||||
if not (directory / "candidate.cdsl.json").exists(): return status
|
||||
rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step"
|
||||
if not force and rebuild_path.exists() and status.get("rebuild_status"):
|
||||
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Deterministic, feature-covering CADFS regression pools.
|
||||
|
||||
The converted CADFS corpus is deliberately kept outside version control. This
|
||||
module turns the artifacts already produced in ``output/samples`` into a small,
|
||||
versionable manifest that records why every selected source sample is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .reports import read_json, write_json
|
||||
|
||||
|
||||
REGRESSION_SCHEMA = "cadfs_to_cdsl.regression.v1"
|
||||
|
||||
|
||||
def _tag(kind: str, value: str) -> str:
|
||||
return f"{kind}:{value}"
|
||||
|
||||
|
||||
def _split_tag(tag: str) -> tuple[str, str]:
|
||||
kind, separator, value = tag.partition(":")
|
||||
if not separator:
|
||||
raise ValueError(f"Malformed regression coverage tag: {tag!r}")
|
||||
return kind, value
|
||||
|
||||
|
||||
def _sample_features(sample_dir: Path) -> dict[str, Any]:
|
||||
"""Read coverage signals from a converted sample without reparsing CADFS."""
|
||||
history = read_json(sample_dir / "history.json") if (sample_dir / "history.json").exists() else []
|
||||
candidate = read_json(sample_dir / "candidate.cdsl.json") if (sample_dir / "candidate.cdsl.json").exists() else {}
|
||||
diagnostics = read_json(sample_dir / "diagnostics.json") if (sample_dir / "diagnostics.json").exists() else []
|
||||
status = read_json(sample_dir / "status.json") if (sample_dir / "status.json").exists() else {}
|
||||
|
||||
source_operations: set[str] = set()
|
||||
sketch_entities: set[str] = set()
|
||||
for item in history:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
operation = item.get("operation")
|
||||
if isinstance(operation, str):
|
||||
source_operations.add(operation)
|
||||
for entity in item.get("entities") or ():
|
||||
if isinstance(entity, dict) and isinstance(entity.get("operation"), str):
|
||||
sketch_entities.add(str(entity["operation"]))
|
||||
|
||||
atomic_ids = {
|
||||
str(feature["atomic_id"])
|
||||
for feature in candidate.get("features") or ()
|
||||
if isinstance(feature, dict) and isinstance(feature.get("atomic_id"), str)
|
||||
}
|
||||
unsupported_capabilities = {
|
||||
str(item["capability"])
|
||||
for item in diagnostics
|
||||
if isinstance(item, dict)
|
||||
and item.get("code") == "unsupported_engine_capability"
|
||||
and isinstance(item.get("capability"), str)
|
||||
}
|
||||
unsupported_operations = {
|
||||
str(item["operation"])
|
||||
for item in diagnostics
|
||||
if isinstance(item, dict)
|
||||
and item.get("code") == "unsupported_operation"
|
||||
and isinstance(item.get("operation"), str)
|
||||
}
|
||||
return {
|
||||
"sample_id": sample_dir.name,
|
||||
"source_operations": sorted(source_operations),
|
||||
"sketch_entities": sorted(sketch_entities),
|
||||
"engine_atomic_ids": sorted(atomic_ids),
|
||||
"unsupported_capabilities": sorted(unsupported_capabilities),
|
||||
"unsupported_operations": sorted(unsupported_operations),
|
||||
"has_candidate": bool(candidate),
|
||||
"baseline": {
|
||||
key: status[key]
|
||||
for key in ("conversion_status", "rebuild_status", "comparison_decision", "status")
|
||||
if key in status
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tags(record: dict[str, Any]) -> set[str]:
|
||||
return {
|
||||
*(_tag("source_operation", item) for item in record["source_operations"]),
|
||||
*(_tag("sketch_entity", item) for item in record["sketch_entities"]),
|
||||
*(_tag("engine_atomic", item) for item in record["engine_atomic_ids"]),
|
||||
*(_tag("unsupported_capability", item) for item in record["unsupported_capabilities"]),
|
||||
*(_tag("unsupported_operation", item) for item in record["unsupported_operations"]),
|
||||
}
|
||||
|
||||
|
||||
def _greedy_cover(records: list[dict[str, Any]], wanted: set[str], selected: list[dict[str, Any]]) -> dict[str, list[str]]:
|
||||
"""Cover ``wanted`` with stable maximum-coverage selection.
|
||||
|
||||
The sample id breaks ties, so a corpus refresh is reviewable and never
|
||||
changes pool membership due to directory iteration order.
|
||||
"""
|
||||
coverage: dict[str, list[str]] = {}
|
||||
selected_ids = {record["sample_id"] for record in selected}
|
||||
remaining = set(wanted)
|
||||
while remaining:
|
||||
choices = [record for record in records if record["sample_id"] not in selected_ids]
|
||||
if not choices:
|
||||
break
|
||||
choice = min(
|
||||
choices,
|
||||
key=lambda record: (-len(_tags(record) & remaining), record["sample_id"]),
|
||||
)
|
||||
gained = _tags(choice) & remaining
|
||||
if not gained:
|
||||
break
|
||||
selected.append(choice)
|
||||
selected_ids.add(choice["sample_id"])
|
||||
for tag in sorted(gained):
|
||||
coverage.setdefault(tag, []).append(choice["sample_id"])
|
||||
remaining -= gained
|
||||
return coverage
|
||||
|
||||
|
||||
def build_regression_manifest(output: Path) -> dict[str, Any]:
|
||||
"""Build a compact pool covering every observed CADFS modeling signal."""
|
||||
records = [_sample_features(directory) for directory in sorted((output / "samples").glob("*")) if directory.is_dir()]
|
||||
if not records:
|
||||
raise FileNotFoundError(f"No converted CADFS samples found under {output / 'samples'}")
|
||||
|
||||
all_tags = set().union(*(_tags(record) for record in records))
|
||||
selected: list[dict[str, Any]] = []
|
||||
|
||||
# Give the engine pool a current successful baseline per atomic operation
|
||||
# whenever the corpus has one. Unsupported/failed atoms remain covered by
|
||||
# the full conversion pool and are never represented as passing builds.
|
||||
engine_records = [
|
||||
record for record in records
|
||||
if record["has_candidate"] and record["baseline"].get("rebuild_status") == "rebuilt"
|
||||
]
|
||||
engine_tags = {_tag("engine_atomic", atom) for record in engine_records for atom in record["engine_atomic_ids"]}
|
||||
_greedy_cover(engine_records, engine_tags, selected)
|
||||
# Samples chosen for the executable baseline can also cover source and
|
||||
# converter signals. Count those signals before the general pass so the
|
||||
# final fixture remains genuinely representative rather than redundant.
|
||||
coverage = {
|
||||
tag: sorted(record["sample_id"] for record in selected if tag in _tags(record))
|
||||
for tag in sorted(set().union(*(_tags(record) for record in selected)))
|
||||
}
|
||||
all_coverage = _greedy_cover(records, all_tags - set(coverage), selected)
|
||||
for tag, sample_ids in all_coverage.items():
|
||||
coverage[tag] = sorted(set(coverage.get(tag, []) + sample_ids))
|
||||
|
||||
selected_ids = {record["sample_id"] for record in selected}
|
||||
missing = sorted(all_tags - set(coverage))
|
||||
if missing:
|
||||
raise RuntimeError("Unable to cover CADFS regression features: " + ", ".join(missing))
|
||||
|
||||
entries = []
|
||||
for record in sorted(selected, key=lambda item: item["sample_id"]):
|
||||
tags = _tags(record)
|
||||
tiers = ["conversion"]
|
||||
if record["sample_id"] in {item["sample_id"] for item in engine_records}:
|
||||
tiers.append("engine")
|
||||
entries.append({
|
||||
**record,
|
||||
"tiers": tiers,
|
||||
"selection_reasons": sorted(tag for tag, sample_ids in coverage.items() if record["sample_id"] in sample_ids),
|
||||
})
|
||||
|
||||
inventory: dict[str, dict[str, int]] = {}
|
||||
for kind, _ in map(_split_tag, sorted(all_tags)):
|
||||
inventory.setdefault(kind, {})
|
||||
for tag in all_tags:
|
||||
kind, value = _split_tag(tag)
|
||||
inventory[kind][value] = sum(tag in _tags(record) for record in records)
|
||||
|
||||
return {
|
||||
"schema": REGRESSION_SCHEMA,
|
||||
"description": "Feature-covering representative CADFS regression pool. Engine samples have a prior successful rebuild; conversion samples retain unsupported-feature diagnostics.",
|
||||
"source_output": str(output),
|
||||
"source_sample_count": len(records),
|
||||
"selected_sample_count": len(entries),
|
||||
"engine_sample_count": sum("engine" in entry["tiers"] for entry in entries),
|
||||
"conversion_sample_count": len(entries),
|
||||
"engine_baseline_atomic_ids": sorted(tag.removeprefix("engine_atomic:") for tag in engine_tags),
|
||||
"engine_diagnostic_only_atomic_ids": sorted(
|
||||
tag.removeprefix("engine_atomic:") for tag in all_tags - engine_tags if tag.startswith("engine_atomic:")
|
||||
),
|
||||
"feature_inventory": {kind: dict(sorted(values.items())) for kind, values in sorted(inventory.items())},
|
||||
"coverage": coverage,
|
||||
"entries": entries,
|
||||
"selected_ids": sorted(selected_ids),
|
||||
}
|
||||
|
||||
|
||||
def write_regression_manifest(output: Path, manifest_path: Path) -> dict[str, Any]:
|
||||
manifest = build_regression_manifest(output)
|
||||
write_json(manifest_path, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def regression_sample_ids(manifest_path: Path, tier: str) -> list[str]:
|
||||
manifest = read_json(manifest_path)
|
||||
if manifest.get("schema") != REGRESSION_SCHEMA:
|
||||
raise ValueError(f"Unsupported regression manifest schema: {manifest.get('schema')!r}")
|
||||
if tier not in {"engine", "conversion", "all"}:
|
||||
raise ValueError(f"Unknown regression tier: {tier!r}")
|
||||
entries = manifest.get("entries") or []
|
||||
selected = [item["sample_id"] for item in entries if tier == "all" or tier in (item.get("tiers") or ())]
|
||||
if not selected:
|
||||
raise ValueError(f"Regression manifest has no samples for tier {tier!r}")
|
||||
return sorted(selected)
|
||||
|
||||
|
||||
def summarize_regression(records: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
statuses = Counter(str(record.get("status") or "unknown") for record in records)
|
||||
return {"sample_count": len(records), "statuses": dict(sorted(statuses.items()))}
|
||||
@@ -0,0 +1,942 @@
|
||||
{
|
||||
"conversion_sample_count": 17,
|
||||
"coverage": {
|
||||
"engine_atomic:chamfer": [
|
||||
"00002243",
|
||||
"00111611"
|
||||
],
|
||||
"engine_atomic:extrude_add_blind": [
|
||||
"00111611"
|
||||
],
|
||||
"engine_atomic:extrude_add_two_sided": [
|
||||
"00002243",
|
||||
"00111611"
|
||||
],
|
||||
"engine_atomic:extrude_cut_blind": [
|
||||
"00002243",
|
||||
"00129362"
|
||||
],
|
||||
"engine_atomic:fillet": [
|
||||
"00111611"
|
||||
],
|
||||
"engine_atomic:hole_wizard": [
|
||||
"00002243"
|
||||
],
|
||||
"engine_atomic:pattern_mirror": [
|
||||
"00925274"
|
||||
],
|
||||
"engine_atomic:reference_plane": [
|
||||
"00129362"
|
||||
],
|
||||
"engine_atomic:revolve_add": [
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"engine_atomic:revolve_cut": [
|
||||
"00129362"
|
||||
],
|
||||
"sketch_entity:skArc": [
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"sketch_entity:skCircle": [
|
||||
"00002243",
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"sketch_entity:skEllipse": [
|
||||
"00287955"
|
||||
],
|
||||
"sketch_entity:skFitSpline": [
|
||||
"00542223"
|
||||
],
|
||||
"sketch_entity:skLineSegment": [
|
||||
"00002243",
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"sketch_entity:skPoint": [
|
||||
"00002243",
|
||||
"00129362"
|
||||
],
|
||||
"source_operation:booleanBodies": [
|
||||
"00835610"
|
||||
],
|
||||
"source_operation:cPlane": [
|
||||
"00129362"
|
||||
],
|
||||
"source_operation:chamfer": [
|
||||
"00002243",
|
||||
"00111611"
|
||||
],
|
||||
"source_operation:circularPattern": [
|
||||
"00542223"
|
||||
],
|
||||
"source_operation:extrude": [
|
||||
"00002243",
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"source_operation:fillet": [
|
||||
"00111611"
|
||||
],
|
||||
"source_operation:hole": [
|
||||
"00002243"
|
||||
],
|
||||
"source_operation:loft": [
|
||||
"00287955"
|
||||
],
|
||||
"source_operation:mirror": [
|
||||
"00925274"
|
||||
],
|
||||
"source_operation:newSketch": [
|
||||
"00002243",
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"source_operation:revolve": [
|
||||
"00111611",
|
||||
"00129362"
|
||||
],
|
||||
"source_operation:shell": [
|
||||
"00542223"
|
||||
],
|
||||
"source_operation:sweep": [
|
||||
"00542223"
|
||||
],
|
||||
"unsupported_capability:extrude_add_through_all": [
|
||||
"00159804"
|
||||
],
|
||||
"unsupported_capability:extrude_cut_through_all": [
|
||||
"00542223"
|
||||
],
|
||||
"unsupported_capability:extrude_cut_two_sided": [
|
||||
"00212904"
|
||||
],
|
||||
"unsupported_capability:extrude_extent:up_to_body": [
|
||||
"00694309"
|
||||
],
|
||||
"unsupported_capability:extrude_extent:up_to_next": [
|
||||
"00192744"
|
||||
],
|
||||
"unsupported_capability:extrude_extent:up_to_surface": [
|
||||
"00925274"
|
||||
],
|
||||
"unsupported_capability:extrude_extent:up_to_vertex": [
|
||||
"00423838"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:cap_edge": [
|
||||
"00287955"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:cap_face": [
|
||||
"00835610"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:intersect": [
|
||||
"00835610"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:mid_cap_edge": [
|
||||
"00612529"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:offset_face": [
|
||||
"00789939"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:swept_edge": [
|
||||
"00054089"
|
||||
],
|
||||
"unsupported_capability:extrude_profile_topology:swept_face": [
|
||||
"00789939"
|
||||
],
|
||||
"unsupported_capability:extrude_surface_or_mixed": [
|
||||
"00710855"
|
||||
],
|
||||
"unsupported_capability:reference_plane:curve_point": [
|
||||
"00192744"
|
||||
],
|
||||
"unsupported_capability:reference_plane:line_angle": [
|
||||
"00925274"
|
||||
],
|
||||
"unsupported_capability:reference_plane:line_point": [
|
||||
"00035682"
|
||||
],
|
||||
"unsupported_capability:reference_plane:mid_plane": [
|
||||
"00287955"
|
||||
],
|
||||
"unsupported_capability:reference_plane:plane_point": [
|
||||
"00542223"
|
||||
],
|
||||
"unsupported_capability:reference_plane:three_point": [
|
||||
"00212904"
|
||||
],
|
||||
"unsupported_capability:revolve_surface": [
|
||||
"00835610"
|
||||
],
|
||||
"unsupported_operation:booleanBodies": [
|
||||
"00835610"
|
||||
],
|
||||
"unsupported_operation:circularPattern": [
|
||||
"00542223"
|
||||
],
|
||||
"unsupported_operation:loft": [
|
||||
"00287955"
|
||||
],
|
||||
"unsupported_operation:shell": [
|
||||
"00542223"
|
||||
],
|
||||
"unsupported_operation:sweep": [
|
||||
"00542223"
|
||||
]
|
||||
},
|
||||
"description": "Feature-covering representative CADFS regression pool. Engine samples have a prior successful rebuild; conversion samples retain unsupported-feature diagnostics.",
|
||||
"engine_baseline_atomic_ids": [
|
||||
"chamfer",
|
||||
"extrude_add_blind",
|
||||
"extrude_add_two_sided",
|
||||
"extrude_cut_blind",
|
||||
"fillet",
|
||||
"hole_wizard",
|
||||
"reference_plane",
|
||||
"revolve_add",
|
||||
"revolve_cut"
|
||||
],
|
||||
"engine_diagnostic_only_atomic_ids": [
|
||||
"pattern_mirror"
|
||||
],
|
||||
"engine_sample_count": 3,
|
||||
"entries": [
|
||||
{
|
||||
"baseline": {
|
||||
"comparison_decision": "rejected",
|
||||
"conversion_status": "converted_complete",
|
||||
"rebuild_status": "rebuilt",
|
||||
"status": "rebuilt"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"chamfer",
|
||||
"extrude_add_two_sided",
|
||||
"extrude_cut_blind",
|
||||
"hole_wizard"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00002243",
|
||||
"selection_reasons": [
|
||||
"engine_atomic:chamfer",
|
||||
"engine_atomic:extrude_add_two_sided",
|
||||
"engine_atomic:extrude_cut_blind",
|
||||
"engine_atomic:hole_wizard",
|
||||
"sketch_entity:skCircle",
|
||||
"sketch_entity:skLineSegment",
|
||||
"sketch_entity:skPoint",
|
||||
"source_operation:chamfer",
|
||||
"source_operation:extrude",
|
||||
"source_operation:hole",
|
||||
"source_operation:newSketch"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skLineSegment",
|
||||
"skPoint"
|
||||
],
|
||||
"source_operations": [
|
||||
"chamfer",
|
||||
"extrude",
|
||||
"hole",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion",
|
||||
"engine"
|
||||
],
|
||||
"unsupported_capabilities": [],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "deferred_no_executable_feature",
|
||||
"status": "deferred_no_executable_feature"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_cut_blind",
|
||||
"revolve_add"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00035682",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:reference_plane:line_point"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"reference_plane:line_point"
|
||||
],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "deferred_no_executable_feature",
|
||||
"status": "deferred_no_executable_feature"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind",
|
||||
"extrude_cut_blind",
|
||||
"revolve_add"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00054089",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_profile_topology:swept_edge"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_profile_topology:swept_edge"
|
||||
],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"comparison_decision": "rejected",
|
||||
"conversion_status": "converted_complete",
|
||||
"rebuild_status": "rebuilt",
|
||||
"status": "rebuilt"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"chamfer",
|
||||
"extrude_add_blind",
|
||||
"extrude_add_two_sided",
|
||||
"fillet",
|
||||
"revolve_add"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00111611",
|
||||
"selection_reasons": [
|
||||
"engine_atomic:chamfer",
|
||||
"engine_atomic:extrude_add_blind",
|
||||
"engine_atomic:extrude_add_two_sided",
|
||||
"engine_atomic:fillet",
|
||||
"engine_atomic:revolve_add",
|
||||
"sketch_entity:skArc",
|
||||
"sketch_entity:skCircle",
|
||||
"sketch_entity:skLineSegment",
|
||||
"source_operation:chamfer",
|
||||
"source_operation:extrude",
|
||||
"source_operation:fillet",
|
||||
"source_operation:newSketch",
|
||||
"source_operation:revolve"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"chamfer",
|
||||
"extrude",
|
||||
"fillet",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion",
|
||||
"engine"
|
||||
],
|
||||
"unsupported_capabilities": [],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_complete",
|
||||
"rebuild_status": "rebuilt",
|
||||
"status": "rebuilt"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_cut_blind",
|
||||
"reference_plane",
|
||||
"revolve_add",
|
||||
"revolve_cut"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00129362",
|
||||
"selection_reasons": [
|
||||
"engine_atomic:extrude_cut_blind",
|
||||
"engine_atomic:reference_plane",
|
||||
"engine_atomic:revolve_add",
|
||||
"engine_atomic:revolve_cut",
|
||||
"sketch_entity:skArc",
|
||||
"sketch_entity:skCircle",
|
||||
"sketch_entity:skLineSegment",
|
||||
"sketch_entity:skPoint",
|
||||
"source_operation:cPlane",
|
||||
"source_operation:extrude",
|
||||
"source_operation:newSketch",
|
||||
"source_operation:revolve"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skCircle",
|
||||
"skLineSegment",
|
||||
"skPoint"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion",
|
||||
"engine"
|
||||
],
|
||||
"unsupported_capabilities": [],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"reference_plane",
|
||||
"revolve_add"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00159804",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_add_through_all"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"circularPattern",
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_add_through_all",
|
||||
"reference_plane:line_angle"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"circularPattern"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind",
|
||||
"reference_plane"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00192744",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_extent:up_to_next",
|
||||
"unsupported_capability:reference_plane:curve_point"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"circularPattern",
|
||||
"extrude",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_extent:up_to_next",
|
||||
"reference_plane:curve_point"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"circularPattern"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00212904",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_cut_two_sided",
|
||||
"unsupported_capability:reference_plane:three_point"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skLineSegment",
|
||||
"skPoint"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"shell"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_cut_two_sided",
|
||||
"reference_plane:three_point"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00287955",
|
||||
"selection_reasons": [
|
||||
"sketch_entity:skEllipse",
|
||||
"source_operation:loft",
|
||||
"unsupported_capability:extrude_profile_topology:cap_edge",
|
||||
"unsupported_capability:reference_plane:mid_plane",
|
||||
"unsupported_operation:loft"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skEllipse",
|
||||
"skFitSpline",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"loft",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_profile_topology:cap_edge",
|
||||
"reference_plane:mid_plane"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"loft"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00423838",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_extent:up_to_vertex"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"circularPattern",
|
||||
"extrude",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_cut_two_sided",
|
||||
"extrude_extent:up_to_vertex"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"circularPattern"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00542223",
|
||||
"selection_reasons": [
|
||||
"sketch_entity:skFitSpline",
|
||||
"source_operation:circularPattern",
|
||||
"source_operation:shell",
|
||||
"source_operation:sweep",
|
||||
"unsupported_capability:extrude_cut_through_all",
|
||||
"unsupported_capability:reference_plane:plane_point",
|
||||
"unsupported_operation:circularPattern",
|
||||
"unsupported_operation:shell",
|
||||
"unsupported_operation:sweep"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skFitSpline"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"circularPattern",
|
||||
"extrude",
|
||||
"fillet",
|
||||
"newSketch",
|
||||
"shell",
|
||||
"sweep"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_cut_through_all",
|
||||
"reference_plane:plane_point"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"circularPattern",
|
||||
"shell",
|
||||
"sweep"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"reference_plane"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00612529",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_profile_topology:mid_cap_edge"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skFitSpline"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"loft",
|
||||
"mirror",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_profile_topology:mid_cap_edge"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"loft"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind",
|
||||
"fillet"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00694309",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_extent:up_to_body"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle"
|
||||
],
|
||||
"source_operations": [
|
||||
"extrude",
|
||||
"fillet",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_extent:up_to_body"
|
||||
],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00710855",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_surface_or_mixed"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle"
|
||||
],
|
||||
"source_operations": [
|
||||
"chamfer",
|
||||
"extrude",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_surface_or_mixed"
|
||||
],
|
||||
"unsupported_operations": []
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind",
|
||||
"fillet"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00789939",
|
||||
"selection_reasons": [
|
||||
"unsupported_capability:extrude_profile_topology:offset_face",
|
||||
"unsupported_capability:extrude_profile_topology:swept_face"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skArc",
|
||||
"skCircle",
|
||||
"skLineSegment",
|
||||
"skPoint"
|
||||
],
|
||||
"source_operations": [
|
||||
"extrude",
|
||||
"fillet",
|
||||
"newSketch",
|
||||
"shell"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_profile_topology:offset_face",
|
||||
"extrude_profile_topology:swept_face"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00835610",
|
||||
"selection_reasons": [
|
||||
"source_operation:booleanBodies",
|
||||
"unsupported_capability:extrude_profile_topology:cap_face",
|
||||
"unsupported_capability:extrude_profile_topology:intersect",
|
||||
"unsupported_capability:revolve_surface",
|
||||
"unsupported_operation:booleanBodies"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skLineSegment",
|
||||
"skPoint"
|
||||
],
|
||||
"source_operations": [
|
||||
"booleanBodies",
|
||||
"extrude",
|
||||
"newSketch",
|
||||
"revolve"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_profile_topology:cap_face",
|
||||
"extrude_profile_topology:intersect",
|
||||
"revolve_surface"
|
||||
],
|
||||
"unsupported_operations": [
|
||||
"booleanBodies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"baseline": {
|
||||
"conversion_status": "converted_partial",
|
||||
"status": "converted_partial"
|
||||
},
|
||||
"engine_atomic_ids": [
|
||||
"extrude_add_blind",
|
||||
"pattern_mirror",
|
||||
"reference_plane"
|
||||
],
|
||||
"has_candidate": true,
|
||||
"sample_id": "00925274",
|
||||
"selection_reasons": [
|
||||
"engine_atomic:pattern_mirror",
|
||||
"source_operation:mirror",
|
||||
"unsupported_capability:extrude_extent:up_to_surface",
|
||||
"unsupported_capability:reference_plane:line_angle"
|
||||
],
|
||||
"sketch_entities": [
|
||||
"skCircle",
|
||||
"skLineSegment"
|
||||
],
|
||||
"source_operations": [
|
||||
"cPlane",
|
||||
"extrude",
|
||||
"mirror",
|
||||
"newSketch"
|
||||
],
|
||||
"tiers": [
|
||||
"conversion"
|
||||
],
|
||||
"unsupported_capabilities": [
|
||||
"extrude_cut_through_all",
|
||||
"extrude_extent:up_to_surface",
|
||||
"reference_plane:line_angle"
|
||||
],
|
||||
"unsupported_operations": []
|
||||
}
|
||||
],
|
||||
"feature_inventory": {
|
||||
"engine_atomic": {
|
||||
"chamfer": 1011,
|
||||
"extrude_add_blind": 6215,
|
||||
"extrude_add_two_sided": 924,
|
||||
"extrude_cut_blind": 1302,
|
||||
"fillet": 2292,
|
||||
"hole_wizard": 343,
|
||||
"pattern_mirror": 152,
|
||||
"reference_plane": 1023,
|
||||
"revolve_add": 1062,
|
||||
"revolve_cut": 99
|
||||
},
|
||||
"sketch_entity": {
|
||||
"skArc": 2777,
|
||||
"skCircle": 5556,
|
||||
"skEllipse": 131,
|
||||
"skFitSpline": 897,
|
||||
"skLineSegment": 7962,
|
||||
"skPoint": 4419
|
||||
},
|
||||
"source_operation": {
|
||||
"booleanBodies": 187,
|
||||
"cPlane": 1188,
|
||||
"chamfer": 1432,
|
||||
"circularPattern": 232,
|
||||
"extrude": 8238,
|
||||
"fillet": 3639,
|
||||
"hole": 894,
|
||||
"loft": 309,
|
||||
"mirror": 411,
|
||||
"newSketch": 9347,
|
||||
"revolve": 1798,
|
||||
"shell": 696,
|
||||
"sweep": 326
|
||||
},
|
||||
"unsupported_capability": {
|
||||
"extrude_add_through_all": 4,
|
||||
"extrude_cut_through_all": 303,
|
||||
"extrude_cut_two_sided": 262,
|
||||
"extrude_extent:up_to_body": 2,
|
||||
"extrude_extent:up_to_next": 49,
|
||||
"extrude_extent:up_to_surface": 95,
|
||||
"extrude_extent:up_to_vertex": 4,
|
||||
"extrude_profile_topology:cap_edge": 133,
|
||||
"extrude_profile_topology:cap_face": 200,
|
||||
"extrude_profile_topology:intersect": 382,
|
||||
"extrude_profile_topology:mid_cap_edge": 2,
|
||||
"extrude_profile_topology:offset_face": 18,
|
||||
"extrude_profile_topology:swept_edge": 16,
|
||||
"extrude_profile_topology:swept_face": 99,
|
||||
"extrude_surface_or_mixed": 4,
|
||||
"reference_plane:curve_point": 13,
|
||||
"reference_plane:line_angle": 123,
|
||||
"reference_plane:line_point": 23,
|
||||
"reference_plane:mid_plane": 25,
|
||||
"reference_plane:plane_point": 47,
|
||||
"reference_plane:three_point": 35,
|
||||
"revolve_surface": 367
|
||||
},
|
||||
"unsupported_operation": {
|
||||
"booleanBodies": 187,
|
||||
"circularPattern": 232,
|
||||
"loft": 309,
|
||||
"shell": 696,
|
||||
"sweep": 326
|
||||
}
|
||||
},
|
||||
"schema": "cadfs_to_cdsl.regression.v1",
|
||||
"selected_ids": [
|
||||
"00002243",
|
||||
"00035682",
|
||||
"00054089",
|
||||
"00111611",
|
||||
"00129362",
|
||||
"00159804",
|
||||
"00192744",
|
||||
"00212904",
|
||||
"00287955",
|
||||
"00423838",
|
||||
"00542223",
|
||||
"00612529",
|
||||
"00694309",
|
||||
"00710855",
|
||||
"00789939",
|
||||
"00835610",
|
||||
"00925274"
|
||||
],
|
||||
"selected_sample_count": 17,
|
||||
"source_output": "cadfs_to_cdsl/output",
|
||||
"source_sample_count": 9347
|
||||
}
|
||||
@@ -184,11 +184,12 @@ def generate_markdown_report(
|
||||
)
|
||||
|
||||
unsupported_ops = {
|
||||
"shell", "loft", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
|
||||
"shell", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
|
||||
"moveFace", "replaceFace", "deleteFace", "import", "derive",
|
||||
}
|
||||
exact_mappings = {
|
||||
"extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind",
|
||||
"loft": "loft_add (simple closed sketch profiles only)",
|
||||
"revolve": "revolve_add / revolve_cut",
|
||||
"fillet": "fillet",
|
||||
"chamfer": "chamfer",
|
||||
|
||||
@@ -14,6 +14,12 @@ def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None:
|
||||
left, right = expected[key], actual.get(key)
|
||||
if not isinstance(right, (list, tuple)) or len(left) != len(right): return None
|
||||
delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right)))
|
||||
if key == "normal":
|
||||
# A source CAP_FACE identifies a geometric plane, not the OCC
|
||||
# orientation of the resulting face. The two kernels may
|
||||
# report the same cap with inverse normals, especially for a
|
||||
# start cap. Keep axis direction orientation-sensitive.
|
||||
delta = min(delta, math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right))))
|
||||
scores.append(max(0.0, 1.0 - delta / 0.05))
|
||||
for key in ("radius_mm", "plane_offset_mm"):
|
||||
if key in expected:
|
||||
@@ -75,7 +81,16 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
|
||||
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}:")]
|
||||
records = [
|
||||
item for item in report.get("topology_records") or []
|
||||
# Reference planes and axes are session context, not body
|
||||
# topology. They must remain available while binding a mirror
|
||||
# or extent selector against a body-bearing prefix.
|
||||
if item.get("kind") in {"plane", "axis"}
|
||||
or 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 {}
|
||||
@@ -85,14 +100,21 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list
|
||||
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:
|
||||
if not geometry:
|
||||
# Context selectors (notably a generated mirror plane)
|
||||
# may have no geometric snapshot. Their owner-qualified
|
||||
# singleton identity is sufficient and must not be scored
|
||||
# as a zero-information geometric match.
|
||||
if len(pool) != 1:
|
||||
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
|
||||
candidates = [scored[0][1]]
|
||||
candidates = [pool[0]]
|
||||
else:
|
||||
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")]
|
||||
|
||||
@@ -7,7 +7,7 @@ from cadfs_to_cdsl.lowering import lower_model
|
||||
from cadfs_to_cdsl.pipeline import convert_one
|
||||
from cadfs_to_cdsl.dataset import Sample
|
||||
from cadfs_to_cdsl.dataset import scan_dataset
|
||||
from cadfs_to_cdsl.tests.test_parser import SOURCE
|
||||
from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE
|
||||
|
||||
|
||||
class LoweringTests(unittest.TestCase):
|
||||
@@ -30,12 +30,61 @@ 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):
|
||||
def test_direct_translation_transform_is_baked_into_the_source_feature(self):
|
||||
result = lower_model(parse_featurescript(TRANSFORM_SOURCE, "transform"), {})
|
||||
self.assertEqual(result.status, "converted_complete")
|
||||
self.assertEqual([item["id"] for item in result.cdsl["features"]], ["f_F1"])
|
||||
self.assertEqual(result.history[-1]["feature_id"], "F2")
|
||||
self.assertFalse(result.diagnostics)
|
||||
self.assertEqual(result.cdsl["geometry"]["sketches"][0]["workplane"]["origin_mm"], [10.0, 0.0, 0.0])
|
||||
|
||||
def test_copy_transform_remains_an_explicit_engine_capability_gap(self):
|
||||
source = TRANSFORM_SOURCE.replace('"makeCopy":false', '"makeCopy":true')
|
||||
result = lower_model(parse_featurescript(source, "transform-copy"), {})
|
||||
self.assertEqual(result.status, "converted_partial")
|
||||
self.assertEqual(result.diagnostics[-1]["code"], "unsupported_engine_capability")
|
||||
self.assertEqual(result.diagnostics[-1]["capability"], "transform")
|
||||
|
||||
def test_symmetric_cut_lowers_to_two_sided_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")
|
||||
self.assertEqual(result.status, "converted_complete")
|
||||
feature = result.cdsl["features"][0]
|
||||
self.assertEqual(feature["atomic_id"], "extrude_cut_two_sided")
|
||||
self.assertEqual(feature["params"]["distance_mm"], 60.0)
|
||||
self.assertEqual(feature["params"]["reverse_distance_mm"], 60.0)
|
||||
|
||||
def test_through_all_extrudes_reuse_the_engine_extent_contract(self):
|
||||
for operation_type, expected_atomic in [
|
||||
("NewBodyOperationType.NEW", "extrude_add_blind"),
|
||||
("NewBodyOperationType.REMOVE", "extrude_cut_blind"),
|
||||
]:
|
||||
with self.subTest(operation_type=operation_type):
|
||||
source = SOURCE.replace(
|
||||
'"depth":120 * mm',
|
||||
f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.THROUGH_ALL',
|
||||
)
|
||||
result = lower_model(parse_featurescript(source, f"through-all-{operation_type}"), {})
|
||||
self.assertEqual(result.status, "converted_complete")
|
||||
feature = result.cdsl["features"][0]
|
||||
self.assertEqual(feature["atomic_id"], expected_atomic)
|
||||
self.assertEqual(feature["params"]["end_condition"]["type"], "through_all")
|
||||
|
||||
def test_up_to_next_extrudes_reuse_the_engine_extent_contract(self):
|
||||
for operation_type, expected_atomic in [
|
||||
("NewBodyOperationType.NEW", "extrude_add_blind"),
|
||||
("NewBodyOperationType.REMOVE", "extrude_cut_blind"),
|
||||
]:
|
||||
with self.subTest(operation_type=operation_type):
|
||||
source = SOURCE.replace(
|
||||
'"depth":120 * mm',
|
||||
f'"operationType":{operation_type}, "depth":120 * mm, "endBound":BoundingType.UP_TO_NEXT',
|
||||
)
|
||||
result = lower_model(parse_featurescript(source, f"up-to-next-{operation_type}"), {})
|
||||
self.assertEqual(result.status, "converted_complete")
|
||||
feature = result.cdsl["features"][0]
|
||||
self.assertEqual(feature["atomic_id"], expected_atomic)
|
||||
self.assertEqual(feature["params"]["end_condition"]["type"], "through_next")
|
||||
|
||||
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)
|
||||
@@ -53,6 +102,83 @@ class LoweringTests(unittest.TestCase):
|
||||
self.assertEqual(gap["capability"], "revolve_surface")
|
||||
self.assertIsNone(result.cdsl)
|
||||
|
||||
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0061/00612529.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
|
||||
self.assertIsNotNone(result.cdsl)
|
||||
features = {item["id"]: item for item in result.cdsl["features"]}
|
||||
self.assertEqual(features["f_F3"]["atomic_id"], "loft_add")
|
||||
self.assertEqual(features["f_F3"]["params"]["profile_sketch_ids"], ["sketch_F2", "sketch_F0"])
|
||||
self.assertEqual(features["f_F4"]["atomic_id"], "pattern_mirror")
|
||||
self.assertEqual(features["f_F4"]["params"]["source_feature_ids"], ["f_F3"])
|
||||
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
|
||||
self.assertEqual(sketches["sketch_F0"]["profile"]["contours"][0]["segments"][0]["type"], "bspline")
|
||||
self.assertNotIn("loft", {item.get("operation") for item in result.diagnostics})
|
||||
|
||||
def test_fit_spline_loft_candidate_rebuilds(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0061/00612529.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00612529"), {})
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
||||
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
|
||||
self.assertEqual(rebuilt["status"], "rebuilt")
|
||||
self.assertGreater(rebuilt["result"]["volume_mm3"], 0)
|
||||
|
||||
def test_direct_translation_transform_updates_the_source_revolve(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0011/00111611.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00111611"), {})
|
||||
self.assertNotIn("F10", {item.get("feature_id") for item in result.diagnostics})
|
||||
features = {item["id"]: item for item in result.cdsl["features"]}
|
||||
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
|
||||
self.assertEqual(sketches[features["f_F9"]["sketch_id"]]["workplane"]["origin_mm"], [77.16, -11.67, -63.0])
|
||||
for actual, expected in zip(features["f_F9"]["params"]["axis"]["origin_mm"], [0.13, -11.67, 56.11]):
|
||||
self.assertAlmostEqual(actual, expected)
|
||||
|
||||
def test_circular_pattern_lowers_to_existing_engine_contract(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
feature = root / "featurescript_rp/0042/00423838.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
|
||||
self.assertIsNotNone(result.cdsl)
|
||||
features = {item["id"]: item for item in result.cdsl["features"]}
|
||||
pattern = features["f_F4"]
|
||||
self.assertEqual(pattern["atomic_id"], "pattern_circular")
|
||||
self.assertEqual(pattern["params"]["source_feature_ids"], ["f_F1"])
|
||||
self.assertEqual(pattern["params"]["pattern_count"], 6)
|
||||
self.assertEqual(pattern["params"]["sweep_angle_deg"], 360.0)
|
||||
self.assertEqual(pattern["params"]["axis"]["direction"], [0.0, 0.0, -1.0])
|
||||
self.assertNotIn("F2", {item.get("feature_id") for item in result.diagnostics})
|
||||
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
|
||||
self.assertNotEqual(sketches["sketch_F0"]["workplane"]["normal"], [0.0, 0.0, 1.0])
|
||||
self.assertNotIn("circularPattern", {item.get("operation") for item in result.diagnostics})
|
||||
|
||||
def test_reference_plane_variants_lower_to_explicit_frames(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
cases = {
|
||||
"00159804": {"F3", "F5"},
|
||||
"00192744": {"F3"},
|
||||
"00212904": {"F3"},
|
||||
"00542223": {"F3"},
|
||||
}
|
||||
for sample_id, expected in cases.items():
|
||||
with self.subTest(sample_id=sample_id):
|
||||
feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
|
||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||
result = lower_model(parse_featurescript(feature.read_text(), sample_id), {})
|
||||
features = {item["name"]: item for item in result.cdsl["features"]}
|
||||
for feature_id in expected:
|
||||
plane = features[feature_id]
|
||||
self.assertEqual(plane["atomic_id"], "reference_plane")
|
||||
self.assertEqual(set(plane["params"]["plane"]), {"origin_mm", "x_dir", "normal"})
|
||||
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
|
||||
self.assertFalse(diagnostics)
|
||||
|
||||
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"), {})
|
||||
|
||||
@@ -19,6 +19,14 @@ export const f = defineFeature(function(context, id, definition) {
|
||||
});
|
||||
'''
|
||||
|
||||
TRANSFORM_SOURCE = SOURCE.replace(
|
||||
'\n});\n',
|
||||
'''
|
||||
transform(context, id + "F2", {"entities":qCreatedBy(id + "F1", BODY), "transformType":TransformType.TRANSLATION_3D, "dx":10 * mm, "dy":0 * mm, "dz":0 * mm, "makeCopy":false});
|
||||
});
|
||||
''',
|
||||
)
|
||||
|
||||
|
||||
class ParserTests(unittest.TestCase):
|
||||
def test_lexer_ignores_comments_and_preserves_lines(self):
|
||||
@@ -32,6 +40,12 @@ class ParserTests(unittest.TestCase):
|
||||
self.assertEqual(model.sketches[0].entities[0].operation, "skCircle")
|
||||
self.assertIsInstance(model.features[0].params["entities"], Call)
|
||||
|
||||
def test_transform_is_preserved_as_a_feature(self):
|
||||
model = parse_featurescript(TRANSFORM_SOURCE, "transform")
|
||||
self.assertEqual([step.feature_id for step in model.steps], ["F0", "F1", "F2"])
|
||||
self.assertEqual(model.features[-1].operation, "transform")
|
||||
self.assertEqual(model.features[-1].params["transformType"], "TransformType.TRANSLATION_3D")
|
||||
|
||||
def test_query_parser(self):
|
||||
query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}])
|
||||
value = parse_query(query)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cadfs_to_cdsl.regression import build_regression_manifest, regression_sample_ids
|
||||
|
||||
|
||||
class RegressionSelectionTests(unittest.TestCase):
|
||||
def _sample(
|
||||
self,
|
||||
root: Path,
|
||||
sample_id: str,
|
||||
*,
|
||||
operation: str,
|
||||
entity: str | None = None,
|
||||
atomic: str | None = None,
|
||||
capability: str | None = None,
|
||||
rebuilt: bool = False,
|
||||
) -> None:
|
||||
directory = root / "samples" / sample_id
|
||||
directory.mkdir(parents=True)
|
||||
history = [{"operation": operation}]
|
||||
if entity:
|
||||
history[0]["entities"] = [{"operation": entity}]
|
||||
(directory / "history.json").write_text(json.dumps(history), encoding="utf-8")
|
||||
if atomic:
|
||||
(directory / "candidate.cdsl.json").write_text(json.dumps({"features": [{"atomic_id": atomic}]}), encoding="utf-8")
|
||||
diagnostics = [] if not capability else [{"code": "unsupported_engine_capability", "capability": capability}]
|
||||
(directory / "diagnostics.json").write_text(json.dumps(diagnostics), encoding="utf-8")
|
||||
status = {"status": "rebuilt_approximate" if rebuilt else "converted_partial"}
|
||||
if rebuilt:
|
||||
status["rebuild_status"] = "rebuilt"
|
||||
(directory / "status.json").write_text(json.dumps(status), encoding="utf-8")
|
||||
|
||||
def test_manifest_covers_every_observed_signal_and_keeps_engine_pool_buildable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
self._sample(output, "00000001", operation="extrude", entity="skLineSegment", atomic="extrude_add_blind", rebuilt=True)
|
||||
self._sample(output, "00000002", operation="loft", entity="skFitSpline", capability="loft")
|
||||
self._sample(output, "00000003", operation="revolve", entity="skCircle", atomic="revolve_add", rebuilt=True)
|
||||
|
||||
manifest = build_regression_manifest(output)
|
||||
|
||||
self.assertEqual(manifest["selected_ids"], ["00000001", "00000002", "00000003"])
|
||||
self.assertEqual(set(manifest["coverage"]), {
|
||||
"engine_atomic:extrude_add_blind", "engine_atomic:revolve_add",
|
||||
"sketch_entity:skCircle", "sketch_entity:skFitSpline", "sketch_entity:skLineSegment",
|
||||
"source_operation:extrude", "source_operation:loft", "source_operation:revolve",
|
||||
"unsupported_capability:loft",
|
||||
})
|
||||
self.assertEqual(regression_sample_ids_from_manifest(manifest, "engine"), ["00000001", "00000003"])
|
||||
self.assertEqual(manifest["engine_baseline_atomic_ids"], ["extrude_add_blind", "revolve_add"])
|
||||
self.assertEqual(manifest["engine_diagnostic_only_atomic_ids"], [])
|
||||
|
||||
def test_manifest_reader_rejects_unknown_tier(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self._sample(root, "00000001", operation="extrude", atomic="extrude_add_blind", rebuilt=True)
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(build_regression_manifest(root)), encoding="utf-8")
|
||||
self.assertEqual(regression_sample_ids(manifest_path, "engine"), ["00000001"])
|
||||
with self.assertRaisesRegex(ValueError, "Unknown regression tier"):
|
||||
regression_sample_ids(manifest_path, "not-a-tier")
|
||||
|
||||
|
||||
def regression_sample_ids_from_manifest(manifest: dict, tier: str) -> list[str]:
|
||||
return sorted(
|
||||
entry["sample_id"] for entry in manifest["entries"]
|
||||
if tier == "all" or tier in entry["tiers"]
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from cadfs_to_cdsl.selector_binding import _score
|
||||
|
||||
|
||||
class SelectorBindingTests(unittest.TestCase):
|
||||
def test_face_normal_match_is_orientation_independent(self) -> None:
|
||||
score = _score(
|
||||
{"normal": [0.0, 0.0, 1.0], "plane_offset_mm": 12.0},
|
||||
{"normal": [0.0, 0.0, -1.0], "plane_offset_mm": 12.0},
|
||||
)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_axis_direction_remains_orientation_sensitive(self) -> None:
|
||||
score = _score(
|
||||
{"axis_direction": [0.0, 0.0, 1.0]},
|
||||
{"axis_direction": [0.0, 0.0, -1.0]},
|
||||
)
|
||||
self.assertEqual(score, 0.0)
|
||||
|
||||
def test_empty_snapshot_score_is_not_treated_as_a_match(self) -> None:
|
||||
self.assertEqual(_score({}, {"normal": [0.0, 0.0, 1.0]}), 0.0)
|
||||
Reference in New Issue
Block a user