Lk dev #14

Merged
likang merged 2 commits from lk_dev into main 2026-09-07 18:40:20 +08:00
32 changed files with 2504 additions and 91 deletions
+13
View File
@@ -51,3 +51,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine.
value shapes in `cdsl_schema.json`. In particular, hole positions must be
objects such as `{"mm": [u_mm, v_mm, w_mm]}`, never bare coordinate arrays.
- Preserve the original CDSL and write revisions as separate artifacts.
## CADFS Engine Maintenance
When changing CADFS lowering, the CDSL schema, runtime, geometry adapter,
selector binding, or their tests, update
`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md` in the same change.
- Record the end-to-end capability state, remaining semantic boundary, and
regression evidence. A successful rebuild is not geometric similarity.
- Update a capability to completed only when lowering, contract, execution,
geometry, and regression coverage all exist.
- The progress ledger is local development state and is intentionally ignored
by Git. Do not add it to a commit.
@@ -15,12 +15,15 @@
{"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false},
{"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true},
{"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true},
{"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true},
{"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true},
{"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true},
{"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true},
{"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true},
{"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true},
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true}
{"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true},
{"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true},
{"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true}
],
"phase_sections": {
"DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"],
@@ -39,6 +42,9 @@
"extrude_add_blind": ["op-extrude-add"],
"extrude_add_two_sided": ["op-extrude-add"],
"extrude_cut_blind": ["op-extrude-cut"],
"extrude_cut_two_sided": ["op-extrude-cut"],
"extrude_cut_through": ["op-extrude-cut"],
"loft_add": ["op-loft"],
"revolve_add": ["op-revolve"],
"revolve_cut": ["op-revolve"],
"hole_blind": ["op-hole"],
@@ -49,8 +55,13 @@
"reference_axis": ["op-reference"],
"pattern_linear": ["op-pattern"],
"pattern_mirror": ["op-pattern"],
"pattern_circular": ["op-pattern"],
"fillet": ["op-finish"],
"chamfer": ["op-finish"],
"sphere_add": ["op-sphere"]
"sphere_add": ["op-sphere"],
"box_add": ["op-primitives"],
"cylinder_add": ["op-primitives"],
"thread_add": ["op-thread"],
"thread_cut": ["op-thread"]
}
}
@@ -1 +1 @@
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。
@@ -0,0 +1 @@
`loft_add``params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。
@@ -0,0 +1 @@
`box_add``cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。
@@ -0,0 +1 @@
`thread_add``thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。
+42 -10
View File
@@ -65,9 +65,15 @@ class Build123dGeometryAdapter:
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
built.append(Edge.make_spline(points, periodic=False))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
@@ -133,20 +139,38 @@ class Build123dGeometryAdapter:
result: list[Face] = []
for region in regions:
outer = region.get("outer") or []
if len(outer) < 2:
# 闭合插值样条仅有一条边;直线/圆弧轮廓通常由多条边组成。
if len(outer) < 1:
continue
face = Face(self._wire(outer))
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 1]
result.append(face.make_holes(holes) if holes else face)
return result
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
edges = sketch.get("contour_edges_mm") or []
if len(edges) >= 2:
if len(edges) >= 1:
return [Face(self._wire(edges))]
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = []
for index, sketch in enumerate(sketches):
# Solid.make_loft 接收 Wire;复用 faces_for_sketch 保持放样、
# 拉伸和回转的 profile resolver 一致。多区域/内环的截面对应关系
# 尚未由 CDSL 表达,必须显式拒绝而非猜测。
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
if len(wires) < 2:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -188,14 +212,22 @@ class Build123dGeometryAdapter:
"""
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
hits = [
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
for point in Build123dGeometryAdapter.profile_sample_points(face)
]
distances = [value for value in hits if value is not None]
if not distances:
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
for candidate in targets:
hits = [Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit) for point in points]
distances = [value for value in hits if value is not None]
if distances:
candidates.append((len(distances), min(distances), candidate, distances))
if not candidates:
raise ValueError("extent target is not reached by the profile")
# 覆盖最多 profile 采样点的面就是本次实体的下一终止面;覆盖数相同
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
+83 -16
View File
@@ -22,21 +22,22 @@ _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_through", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_cut_through",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
})
@@ -148,27 +149,32 @@ def _schema_contract() -> dict[str, dict[str, Any]]:
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
"""Return only sketches consumed by a feature with a sketch contract.
"""Return only sketches consumed by executable feature contracts.
CAD documents commonly preserve construction or abandoned sketches whose
contours are incomplete. They are semantic data, but must not make an
otherwise independent feature history ineligible for execution.
otherwise independent feature history ineligible for execution. Most
operations declare ``sketch_id``; loft declares its ordered section set
in ``params.profile_sketch_ids``.
"""
contracts = _schema_contract()
return frozenset(
str(feature["sketch_id"])
for feature in cdsl.get("features") or ()
if feature.get("sketch_id") is not None
and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch")
)
required: set[str] = set()
for feature in cdsl.get("features") or ():
atomic_id = str(feature.get("atomic_id") or "")
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
required.add(str(feature["sketch_id"]))
if atomic_id == "loft_add":
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
required.add(str(sketch_id))
return frozenset(required)
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)):
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
if len(sketch.get("contour_edges_mm") or []) >= 2:
if len(sketch.get("contour_edges_mm") or []) >= 1:
return True
return any(
entity.get("type") == "circle" and not entity.get("construction")
@@ -178,6 +184,18 @@ def _has_closed_region(sketch: dict[str, Any]) -> bool:
)
def _has_single_loft_region(sketch: dict[str, Any]) -> bool:
"""Whether a resolved profile maps exactly to one solid loft section."""
regions = sketch.get("contour_regions_mm") or []
if regions:
return len(regions) == 1 and not (regions[0].get("holes") or [])
circles = [
entity for entity in sketch.get("entities") or []
if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction")
]
return len(circles) == 1
@dataclass(frozen=True)
class CapabilityAnalysis:
plan: tuple[FeaturePlanNode, ...]
@@ -295,6 +313,54 @@ class CapabilityAnalyzer:
sketch_id=node.sketch_id,
atomic_id=node.atomic_id,
))
if node.atomic_id == "loft_add":
profile_ids = params.get("profile_sketch_ids")
if not isinstance(profile_ids, list) or len(profile_ids) < 2:
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft requires at least two profile sketch ids",
))
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
blockers.append(self._blocker(
node.feature_id, "invalid_loft_profiles",
"Loft profile sketch ids must be distinct",
))
else:
for sketch_id in profile_ids:
sketch_id = str(sketch_id)
profile = sketches.get(sketch_id)
if profile is None:
blockers.append(self._blocker(
node.feature_id, "missing_loft_profile",
"Loft profile sketch does not exist", sketch_id=sketch_id,
))
continue
profile_type = str((profile.get("profile") or {}).get("type") or "")
required.append(f"loft_profile:{profile_type}")
resolution_error = sketch_errors.get(sketch_id)
if resolution_error:
blockers.append(self._blocker(
node.feature_id, "profile_resolution_failed",
"A loft profile sketch could not be resolved into executable regions",
sketch_id=sketch_id, reason=resolution_error,
))
elif profile_type not in self.profile_types:
blockers.append(self._blocker(
node.feature_id, "unsupported_profile",
"The current runtime cannot resolve a loft profile",
sketch_id=sketch_id, profile_type=profile_type,
))
elif not _has_closed_region(profile):
blockers.append(self._blocker(
node.feature_id, "profile_no_closed_region",
"A loft profile contains no closed region", sketch_id=sketch_id,
))
elif not _has_single_loft_region(profile):
blockers.append(self._blocker(
node.feature_id, "unsupported_loft_profile_regions",
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
# #2 draftextrudeParams.draft 在 cdsl_schema.json 中被允许,
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
@@ -332,7 +398,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id == "extrude_add_two_sided":
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_type = reverse_condition.get("type")
required.append(f"extent:reverse:{reverse_type}")
@@ -463,7 +529,8 @@ class CapabilityAnalyzer:
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_producers = {
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add",
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
+21 -3
View File
@@ -108,6 +108,20 @@
"required": ["end_condition"],
"additionalProperties": false
},
"loftParams": {
"type": "object",
"properties": {
"profile_sketch_ids": {
"type": "array",
"minItems": 2,
"maxItems": 16,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
}
},
"required": ["profile_sketch_ids"],
"additionalProperties": false
},
"revolveParams": {
"type": "object",
"properties": {"angle_deg": {"type": "number", "minimum": 0, "maximum": 360}, "axis": {"$ref": "#/$defs/axis"}, "reverse": {"type": "boolean"}, "end_condition": {"$ref": "#/$defs/endCondition"}},
@@ -335,18 +349,20 @@
"analyticSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "circle"]},
"type": {"enum": ["line", "arc", "circle", "bspline"]},
"start": {"$ref": "#/$defs/point2"},
"end": {"$ref": "#/$defs/point2"},
"center": {"$ref": "#/$defs/point2"},
"radius_mm": {"$ref": "#/$defs/positive"},
"points": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}},
"clockwise": {"type": "boolean"}
},
"required": ["type"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}
{"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["start", "end", "points"]}}
],
"additionalProperties": false
},
@@ -389,7 +405,7 @@
"required": ["type", "contours"],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_through", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "loft_add", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "fillet", "chamfer", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -410,6 +426,8 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_add_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_blind"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_through"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeCutThroughParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "extrude_cut_two_sided"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sphere_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sphereParams"}}}},
+4 -4
View File
@@ -18,7 +18,7 @@ from .sketch_solver import resolve_required_sketches
P3_ATOMIC_IDS = frozenset({
"reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided",
"extrude_cut_blind", "revolve_add", "revolve_cut",
"extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
})
P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
@@ -27,7 +27,7 @@ P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"})
# revolve history. Whether a first cut has a preceding active body remains a
# runtime preflight question, not a reason to erase it from the input pool.
_P3_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "revolve_add", "revolve_cut",
})
@@ -47,8 +47,8 @@ def _p3_profile_ready(cdsl: dict[str, Any]) -> bool:
if profile.get("type") not in P3_PROFILE_TYPES:
return False
if not (
any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
or len(sketch.get("contour_edges_mm") or ()) >= 2
any(len(region.get("outer") or []) >= 1 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
or len(sketch.get("contour_edges_mm") or ()) >= 1
or any(
entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0
for entity in sketch.get("entities") or ()
@@ -1,15 +1,17 @@
{
"schema": "cdsl.engine.schema.v1",
"schema_version": "1.3.1",
"schema_version": "1.3.2",
"cdsl_json_schema_file": "cdsl_schema.json",
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours"],
"operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]},
"revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]},
"hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]},
@@ -71,7 +73,7 @@
"constraints": ["vertices contains at least three [u, v] points"]
},
"analytic_contours": {
"summary": "Closed executable line, arc and circle contours. B-splines are unsupported in contours; imported construction B-splines are retained as non-executable audit geometry."
"summary": "Closed executable line, arc, circle and interpolation B-spline contours. Executable B-splines preserve ordered interpolation points and must be closed; imported construction B-splines remain non-executable audit geometry."
}
}
}
+83 -6
View File
@@ -20,8 +20,8 @@ from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"extrude_cut_through",
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_cut_through", "loft_add",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
@@ -82,6 +82,7 @@ class GeometryAdapter(Protocol):
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
@@ -236,7 +237,7 @@ def _targeted_extent_vector(
except ValueError as error:
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition == "up_to_surface":
if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
@@ -244,6 +245,7 @@ def _targeted_extent_vector(
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
# "not reached",保持显式拒绝。
# through_next 从当前主体中选取实际命中的下一张面;
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
# 裁剪体层,仍保持显式拒绝。
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
@@ -271,7 +273,7 @@ def _side_extent_vectors(
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` calls this once for each independently captured
``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured
termination. The regular one-sided executor also uses it for all simple
termination modes, keeping the geometry adapter interface uniform.
"""
@@ -319,7 +321,7 @@ def _extent_vectors(
end_condition = params.get("end_condition") or {"type": "blind"}
condition = end_condition.get("type", "blind")
distance = abs(float(params.get("distance_mm") or 0.0))
if node.atomic_id == "extrude_add_two_sided":
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
if reverse_distance <= 0:
@@ -457,6 +459,21 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
return session.result(node)
def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析
# 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
sketch = session.sketches.get(str(sketch_id))
if sketch is None:
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
profiles.append(sketch)
solid = session.adapter.loft(profiles)
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(node)
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
@@ -763,6 +780,11 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)]
if "points_mm" in value:
value["points_mm"] = [
[float(point[index]) + components[index] for index in range(3)]
for point in value["points_mm"]
]
for child in value.values():
translate(child)
elif isinstance(value, list):
@@ -772,6 +794,30 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
return output
def _transformed_loft_profiles(
node: FeaturePlanNode,
params: dict[str, Any],
instance_id: str,
session: ExecutionSession,
transform: Callable[[dict[str, Any]], dict[str, Any]],
) -> None:
"""为 pattern replay 创建放样截面的变换副本。"""
if node.atomic_id != "loft_add":
return
profile_ids = params.get("profile_sketch_ids") or []
transformed_ids: list[str] = []
for index, sketch_id in enumerate(profile_ids):
source = session.sketches.get(str(sketch_id))
if source is None:
raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition")
transformed_id = f"{instance_id}.profile.{index}"
# 不复用原 profile:pattern 中的每个截面都必须与 source feature
# 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。
session.sketches[transformed_id] = transform(source)
transformed_ids.append(transformed_id)
params["profile_sketch_ids"] = transformed_ids
def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None:
"""解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。
@@ -812,6 +858,10 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, s
# box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心,
# 否则阵列副本会静默重合在原位置。
params["center_mm"] = [float(center[index]) + components[index] for index in range(3)]
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _translated_sketch(sketch, offset),
)
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移
@@ -919,6 +969,12 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
point = value.get(point_key)
if isinstance(point, list) and len(point) == 2:
value[point_key] = [float(point[0]), -float(point[1])]
if isinstance(value.get("points"), list):
value["points"] = [
[float(point[0]), -float(point[1])]
for point in value["points"]
if isinstance(point, list) and len(point) == 2
]
for child in value.values():
mirror_local_coordinates(child)
elif isinstance(value, list):
@@ -935,6 +991,8 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = _reflect_point(value[point_key], plane)
if "points_mm" in value:
value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]]
if value.get("normal"):
value["normal"] = _reflect_point(value["normal"], plane, vector=True)
for child in value.values():
@@ -994,6 +1052,10 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, se
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern
# 中已被显式拒绝)。
params["center_mm"] = _reflect_point(center, plane)
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _mirrored_sketch(sketch, plane),
)
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用
@@ -1102,7 +1164,7 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
# 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转;
# 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与
# _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center
# 世界坐标点绕轴旋转。
# 世界坐标点和圆弧法向绕轴旋转。
output = deepcopy(sketch)
workplane = output.get("workplane") or {}
if workplane.get("origin_mm"):
@@ -1117,6 +1179,10 @@ def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) ->
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = _rotated_point(value[point_key], axis, angle_rad)
if "points_mm" in value:
value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]]
if "normal" in value:
value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad))
for child in value.values():
rotate(child)
elif isinstance(value, list):
@@ -1168,6 +1234,10 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
center = params.get("center_mm")
if isinstance(center, list) and len(center) == 3:
params["center_mm"] = _rotated_point(center, axis, angle_rad)
_transformed_loft_profiles(
node, params, instance_id, session,
lambda sketch: _rotated_sketch(sketch, axis, angle_rad),
)
if node.atomic_id in {"pattern_mirror", "pattern_circular"}:
# pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套
# pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。
@@ -1281,6 +1351,11 @@ def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch:
return _shape_from_primary(node, session, sketch=sketch)
def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_add(node, session)
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_reference_plane(node, session)
@@ -1347,7 +1422,9 @@ EXECUTORS: dict[str, ExecutorFunction] = {
"extrude_add_blind": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
"extrude_cut_two_sided": _primary_executor,
"extrude_cut_through": _primary_executor,
"loft_add": _loft_executor,
"revolve_add": _primary_executor,
"revolve_cut": _primary_executor,
"hole_blind": _hole_executor,
+23 -4
View File
@@ -1,9 +1,9 @@
"""Core CDSL sketch resolver.
The runtime accepts only direct geometric descriptions: circles, straight-edge
polygons, and closed analytic line/arc/circle contours. Semantic shapes and
historical profile macros belong to the importer compatibility layer and must
be lowered before this module is invoked.
polygons, and closed analytic line/arc/circle/B-spline contours. Semantic
shapes and historical profile macros belong to the importer compatibility
layer and must be lowered before this module is invoked.
"""
from __future__ import annotations
@@ -81,6 +81,11 @@ def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
if edge["type"] == "arc":
output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1])
output["normal"] = list(normal)
elif edge["type"] == "bspline":
output["points_mm"] = [
_to_3d(workplane, point[0], point[1])
for point in edge["points_mm"]
]
transformed.append(output)
return transformed
@@ -118,6 +123,8 @@ def _reverse(edge: _Ctx) -> _Ctx:
output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"]
if output.get("type") == "arc" and "clockwise" in output:
output["clockwise"] = not bool(output["clockwise"])
if output.get("type") == "bspline":
output["points_mm"] = list(reversed(output["points_mm"]))
return output
@@ -176,7 +183,16 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
if kind == "circle":
return _circle_edges(segment)
if kind == "bspline":
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
points = segment.get("points") or []
if len(points) < 3:
raise ValueError("analytic_contours: bspline needs at least 3 interpolation points")
converted = [_point(point) for point in points]
return [{
"type": "bspline",
"start_mm": converted[0],
"end_mm": converted[-1],
"points_mm": converted,
}]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
@@ -185,6 +201,9 @@ def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") == "bspline":
points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1])
continue
if edge.get("type") != "arc":
continue
center, end = edge["center_mm"], edge["end_mm"]
+1 -1
View File
@@ -73,7 +73,7 @@ class AuthorGuidanceTests(unittest.TestCase):
self.assertLessEqual(len(selection.content), 1_200)
self.assertIn("世界坐标", selection.content)
covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-"))
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere"})
self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread"})
def test_phase_repair_and_budget_selection_are_stable(self) -> None:
guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600)
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from engine.cdsl_engine.runtime import rebuild_cdsl
def _spline_profile(sketch_id: str, z: float) -> dict:
return {
"id": sketch_id,
"workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {
"type": "analytic_contours",
"contours": [{
"role": "outer",
"closed": True,
"segments": [{
"type": "bspline",
"start": [0, 0],
"end": [0, 0],
"points": [[0, 0], [20, 0], [25, 10], [15, 18], [0, 10], [0, 0]],
}],
}],
},
}
class LoftGeometryTests(unittest.TestCase):
def test_open_bspline_segment_can_close_with_other_contour_edges(self):
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "open-bspline-contour",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [
{"type": "line", "start": [0, 0], "end": [0, 10]},
{
"type": "bspline", "start": [0, 10], "end": [0, 0],
"points": [[0, 10], [4, 10], [4, 0], [0, 0]],
},
],
}]},
}]},
"features": [{
"id": "add", "atomic_id": "extrude_add_blind", "depends_on": [],
"sketch_id": "profile", "params": {"distance_mm": 5},
}],
}
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "open-bspline-contour.step")
self.assertGreater(rebuilt["volume_mm3"], 1.0)
def test_bspline_loft_and_mirror_replay_produce_step(self):
cdsl = {
"schema": "cad.cdsl.llm.v1",
"schema_version": "1.1.0",
"kind": "part",
"part_id": "bspline-loft-mirror",
"meta": {"unit": "mm"},
"geometry": {"sketches": [_spline_profile("lower", 0), _spline_profile("upper", 20)]},
"features": [
{
"id": "mirror_plane",
"atomic_id": "reference_plane",
"depends_on": [],
"params": {"plane": {"origin_mm": [35, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}},
"execution_status": "supported",
},
{
"id": "loft",
"atomic_id": "loft_add",
"depends_on": [],
"params": {"profile_sketch_ids": ["lower", "upper"]},
"execution_status": "supported",
},
{
"id": "mirror",
"atomic_id": "pattern_mirror",
"depends_on": ["mirror_plane", "loft"],
"params": {
"source_feature_ids": ["loft"],
"mirror_plane": {
"kind": "plane",
"owner_feature_id": "mirror_plane",
"stable_id": "mirror-plane",
"source": "runtime_snapshot",
"confidence": 1.0,
},
},
"selectors": [{
"kind": "plane",
"owner_feature_id": "mirror_plane",
"stable_id": "mirror-plane",
"source": "runtime_snapshot",
"confidence": 1.0,
}],
"execution_status": "supported",
},
],
}
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(cdsl, Path(tmp) / "loft.step")
self.assertGreater(rebuilt["volume_mm3"], 1.0)
self.assertEqual(rebuilt["solid_count"], 2)
self.assertEqual([item["feature_id"] for item in rebuilt["feature_results"]], ["mirror_plane", "loft", "mirror.m.loft", "mirror"])
if __name__ == "__main__":
unittest.main()
@@ -316,6 +316,55 @@ class PatternTransformContractTests(unittest.TestCase):
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5)
def test_circular_pattern_rotates_analytic_arc_normals_with_the_sketch(self) -> None:
"""倾斜圆草图的环形阵列必须保持每条圆弧处于旋转后的工作平面。
``analytic_contours`` 在运行时会预先展开为世界坐标的圆弧边环形阵列
不能只旋转边端点和圆心Build123d 依据 edge.normal 计算三点圆弧中点
法向遗留在源草图平面会让中点偏离 wire 平面最终报
``Cannot build face(s): wires not planar`` fixture 使用绕 Z 轴的
垂直圆草图保证每个实例都实际变换该法向
"""
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"part_id": "circular-arc-normal", "meta": {"unit": "mm"},
"geometry": {"sketches": [
{
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 10},
},
{
"id": "boss", "workplane": _workplane(origin=[0, -10, 0], normal=[0, 1, 0]),
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [{"type": "circle", "center": [0, -5], "radius_mm": 1}],
}]},
},
]},
"features": [
{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"sketch_id": "base", "params": {"distance_mm": 10},
},
{
"id": "boss_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "boss", "params": {"distance_mm": 2},
},
{
"id": "repeat", "atomic_id": "pattern_circular", "depends_on": ["boss_add"],
"params": {
"source_feature_ids": ["boss_add"],
"axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]},
"pattern_count": 4, "sweep_angle_deg": 360,
},
},
],
}
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "circular-arc-normal.step")
self.assertGreater(rebuilt["volume_mm3"], math.pi * 1000)
self.assertEqual(rebuilt["solid_count"], 1)
if __name__ == "__main__":
unittest.main()
@@ -918,6 +918,25 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
analysis = analyze_cdsl(cdsl)
self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers])
def test_two_sided_cut_uses_independent_forward_and_reverse_distances(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "cut", "workplane": {**_workplane(), "origin_mm": [0, 0, 5]},
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
cdsl["features"].append({
"id": "cut_1", "atomic_id": "extrude_cut_two_sided", "depends_on": ["base_add"],
"params": {
"distance_mm": 3, "reverse_distance_mm": 5,
"end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"},
}, "sketch_id": "cut",
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "two-sided-cut.step")
self.assertAlmostEqual(result["volume_mm3"], 1000.0 - 8.0 * 3.141592653589793, places=5)
def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
@@ -1038,6 +1057,22 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(cdsl, root / f"{name}.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
def test_through_next_trims_a_partially_overlapping_profile_to_the_next_body_face(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "partial", "workplane": {"origin_mm": [0, 0, 12], "x_dir": [1, 0, 0], "normal": [0, 0, -1]},
"profile": {"type": "polygon", "vertices": [[3, -3], [9, -3], [9, 3], [3, 3]]},
})
base["features"].append({
"id": "partial_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
"sketch_id": "partial", "params": {"distance_mm": 0, "end_condition": {"type": "through_next"}},
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(base, Path(directory) / "partial-through-next.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 + 24, places=5)
def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
@@ -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 终止 |
## P1selector 与基准面
| 能力缺口 | 受影响样本 | 需要补足的能力 |
| --- | ---: | --- |
| `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 booleanunion、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` 的受控资产协议。
+25
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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:
+6 -1
View File
@@ -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
+216
View File
@@ -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()))}
+942
View File
@@ -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
}
+2 -1
View File
@@ -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",
+30 -8
View File
@@ -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")]
+131 -5
View File
@@ -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"), {})
+14
View File
@@ -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)
+73
View File
@@ -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)
+6 -6
View File
@@ -5,16 +5,16 @@
"document_stems_sha256": "ba5b905191817cd0f7b9ad07d4002ad27730e200a395f71f9c4e31ebb1867cd6",
"phase_pools": {
"p3": {
"selector_count": 770,
"selectors_sha256": "7b0e2142effc71bf2e3011654f020062665103cad5a6e219a323d1ee17887c89"
"selector_count": 771,
"selectors_sha256": "72093701357cd1858f2dbcc0f1ab9e6c7baea7db26357154e0586e5e1be6f801"
},
"p4": {
"selector_count": 825,
"selectors_sha256": "fa9bce14dc72170bd92ea8cb274490e1df733a85a8647de5f13cc39d8b1e945d"
"selector_count": 826,
"selectors_sha256": "ab9ea6edd8532ce7a0110d0725b5303c63a53617a0af502101b12f657285d371"
},
"p6": {
"selector_count": 892,
"selectors_sha256": "4f925d00636f471f517ccfbd1c030bc6fda8fa172332399964d80618f698ce89"
"selector_count": 893,
"selectors_sha256": "ac9f91b690bc533b1fdd1772d65ee77b248d108f39a429615d909b78332d9a08"
}
}
}