diff --git a/backend/agent/skills/cad-engine/SKILL.md b/backend/agent/skills/cad-engine/SKILL.md index 9427d021..234460ee 100644 --- a/backend/agent/skills/cad-engine/SKILL.md +++ b/backend/agent/skills/cad-engine/SKILL.md @@ -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. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json index 74d07eff..f82f8911 100644 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ b/backend/agent/skills/cdsl-author-guidance/manifest.json @@ -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"] } } diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md index 960bca12..f88a19b2 100644 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md +++ b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md @@ -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`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-loft.md b/backend/agent/skills/cdsl-author-guidance/op-loft.md new file mode 100644 index 00000000..99deb7c9 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-loft.md @@ -0,0 +1 @@ +`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。不要以挤出替代放样,也不要把 selector 选中的实体面当作放样截面,除非 operation contract 明确支持。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-primitives.md b/backend/agent/skills/cdsl-author-guidance/op-primitives.md new file mode 100644 index 00000000..09e10d85 --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-primitives.md @@ -0,0 +1 @@ +`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-thread.md b/backend/agent/skills/cdsl-author-guidance/op-thread.md new file mode 100644 index 00000000..38e932da --- /dev/null +++ b/backend/agent/skills/cdsl-author-guidance/op-thread.md @@ -0,0 +1 @@ +`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。 diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 105ba1b3..e555705e 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -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), diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index dd023cc7..6ad669aa 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -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 draft:extrudeParams.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 一致:无宿主时由 diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 6f251c8e..c8774d56 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -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"}}}}, diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py index 7e274f06..83a5dd5c 100644 --- a/backend/engine/cdsl_engine/phase_pools.py +++ b/backend/engine/cdsl_engine/phase_pools.py @@ -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 () diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 4a38cba6..6afb7565 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -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." } } } diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index d91d6a04..3118a5a6 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -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, diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index 8f1d3093..b9429afc 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -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"] diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py index 3ba6d541..54af4727 100644 --- a/backend/tests/test_author_guidance.py +++ b/backend/tests/test_author_guidance.py @@ -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) diff --git a/backend/tests/test_engine_loft_geometry.py b/backend/tests/test_engine_loft_geometry.py new file mode 100644 index 00000000..b40b92e6 --- /dev/null +++ b/backend/tests/test_engine_loft_geometry.py @@ -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() diff --git a/backend/tests/test_engine_pattern_transform_contract.py b/backend/tests/test_engine_pattern_transform_contract.py index 2aba2e95..0c21b28f 100644 --- a/backend/tests/test_engine_pattern_transform_contract.py +++ b/backend/tests/test_engine_pattern_transform_contract.py @@ -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() diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 43565df2..d31ae60f 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -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 diff --git a/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md b/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md new file mode 100644 index 00000000..5b651567 --- /dev/null +++ b/cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_BASELINE.md @@ -0,0 +1,103 @@ +# CADFS 基线 Engine 能力缺口与补齐清单 + +基于原始 `cadfs_to_cdsl/output` 全量报告。受影响样本数可重叠,不可相加。 + +## P0:主体、草图与核心特征 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| B 样条草图 / `skFitSpline` | 未单独统计 | 控制点、闭合 wire、B-spline edge,以及供 extrude、loft、sweep 使用的 profile | +| 复杂草图轮廓 | 未单独统计 | 直线、圆弧、圆、椭圆、样条、多 wire 和孔洞的稳定闭合与排序 | +| `shell` | 696 | 移除面、壁厚、内/外方向、多实体和失败诊断 | +| `sweep` | 326 | 截面、路径、导轨、实体/曲面模式、扭转和过渡策略 | +| `loft` | 309 | 多 profile、闭合/开口 profile、导轨、实体/曲面模式和 profile 对齐 | +| `booleanBodies` | 187 | union、subtract、intersect、目标/工具 body 和保留工具体策略 | +| `circularPattern` | 232 | source feature 重放、旋转轴、数量、角度范围和嵌套 pattern | + +## P0:拉伸语义 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude_cut_through_all` | 303 | 按目标 body 实际交段执行穿透全部切除 | +| `extrude_cut_two_sided` | 262 | 正反独立距离和终止条件的双向切除 | +| `extrude_extent:up_to_surface` | 95 | 到面终止与非平面 profile 裁剪 | +| `extrude_extent:up_to_next` | 49 | 到下一实体终止 | +| `extrude_add_through_all` | 4 | 穿透全部加料拉伸 | +| `extrude_extent:up_to_vertex` | 4 | 到顶点终止 | +| `extrude_surface_or_mixed` | 4 | 曲面拉伸和 surface/solid 混合拓扑 | +| `extrude_extent:up_to_body` | 2 | 到指定 body 终止 | + +## P1:selector 与基准面 + +| 能力缺口 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude_profile_topology:intersect` | 382 | 面/边交集 selector 与持久化 | +| `extrude_profile_topology:cap_face` | 200 | 拉伸端盖面 selector | +| `extrude_profile_topology:cap_edge` | 133 | 拉伸端盖边 selector | +| `reference_plane:line_angle` | 123 | 线-角度基准面 | +| `extrude_profile_topology:swept_face` | 99 | 侧壁面 selector | +| `reference_plane:plane_point` | 47 | 平面-点基准面 | +| `reference_plane:three_point` | 35 | 三点基准面 | +| `reference_plane:mid_plane` | 25 | 两平面中面 | +| `reference_plane:line_point` | 23 | 线-点基准面 | +| `extrude_profile_topology:offset_face` | 18 | 偏移面 selector | +| `extrude_profile_topology:swept_edge` | 16 | 侧壁边 selector | +| `reference_plane:curve_point` | 13 | 曲线-点基准面 | +| `extrude_profile_topology:mid_cap_edge` | 2 | 中性面端盖边 selector | + +## P1:已有特征的未覆盖语义 + +| 特征 | 受影响样本 | 需要补足的能力 | +| --- | ---: | --- | +| `extrude` | 3,863 | 复杂 profile、拓扑引用和曲面/混合实体语义 | +| `fillet` | 1,913 | 稳定边选择、切线传播、半径可行性 | +| `revolve` | 742 | 轴选择、方向/角度、曲面模式和 profile 表达 | +| `hole` | 623 | 孔型、螺纹、沉头/沉孔、终止条件和宿主面引用 | +| `chamfer` | 588 | 距离-角度、双距离等参数及稳定边选择 | +| `revolve_surface` | 367 | 曲面旋转及其与实体布尔的混合策略 | +| `mirror` | 263 | source replay、镜像面引用和嵌套 pattern | +| `cPlane` | 145 | 除 OFFSET 外的基准面构造和可重用 frame | + +## P2:未覆盖的 FeatureScript 操作 + +| 能力缺口 | 需要补足的能力 | +| --- | --- | +| `draft` | 拔模面与中性面定义 | +| `thicken` | 曲面加厚和方向控制 | +| `split` | 面/实体分割与结果 body 管理 | +| `moveFace` | 偏移、旋转、平移现有面 | +| `deleteFace` | 删除面及修补策略 | +| `replaceFace` | 面替换与拓扑更新 | +| `derive` | 受控外部派生模型协议 | +| `import` | 受控外部几何导入协议 | + +## 补齐清单 + +### 第一阶段 + +- [ ] 支持 B 样条草图和闭合 profile。 +- [ ] 新增 `loft_add`。 +- [ ] 新增 `shell`。 +- [ ] 新增 `sweep_add`。 +- [ ] 新增多 body boolean:union、subtract、intersect。 +- [ ] 为以上 FeatureScript 操作完成 lowering、schema、executor、adapter、selector 和端到端回归。 + +### 第二阶段 + +- [ ] 补齐 extrude 的 through-all、two-sided cut、up-to-surface、up-to-next、up-to-body、up-to-vertex。 +- [ ] 支持 `revolve_surface` 和 surface/solid 混合策略。 +- [ ] 补齐 `hole_wizard` 的 CADFS 孔型、螺纹和终止条件。 +- [ ] 补齐 `fillet`/`chamfer` 的参数变体、可行性预检查和稳定失败诊断。 +- [ ] 补齐 `circularPattern` 和 `mirror` 的 source replay、基准引用和嵌套 pattern。 + +### 第三阶段 + +- [ ] 补齐 CAP、SWEPT、OFFSET、INTERSECT、MID_CAP 等面/边 selector。 +- [ ] 补齐 line-angle、plane-point、three-point、mid-plane、line-point、curve-point 基准面。 +- [ ] 补齐 selector 的 owner、几何签名和歧义解析。 + +### 第四阶段 + +- [ ] 支持 `draft`、`thicken`、`split`。 +- [ ] 支持 `moveFace`、`deleteFace`、`replaceFace`。 +- [ ] 定义并实现 `derive`、`import` 的受控资产协议。 diff --git a/cadfs_to_cdsl/README.md b/cadfs_to_cdsl/README.md index 7cdb1019..28f2a28f 100644 --- a/cadfs_to_cdsl/README.md +++ b/cadfs_to_cdsl/README.md @@ -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//`. + +## 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. diff --git a/cadfs_to_cdsl/cli.py b/cadfs_to_cdsl/cli.py index 9bcc1777..378449a1 100644 --- a/cadfs_to_cdsl/cli.py +++ b/cadfs_to_cdsl/cli.py @@ -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, diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index 3a360bd2..7dde2219 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -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) diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 0d7fd0d8..ce37a358 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -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: diff --git a/cadfs_to_cdsl/pipeline.py b/cadfs_to_cdsl/pipeline.py index 5e7655f6..0cee6328 100644 --- a/cadfs_to_cdsl/pipeline.py +++ b/cadfs_to_cdsl/pipeline.py @@ -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 diff --git a/cadfs_to_cdsl/regression.py b/cadfs_to_cdsl/regression.py new file mode 100644 index 00000000..8ca6e393 --- /dev/null +++ b/cadfs_to_cdsl/regression.py @@ -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()))} diff --git a/cadfs_to_cdsl/regression/manifest.json b/cadfs_to_cdsl/regression/manifest.json new file mode 100644 index 00000000..f866186c --- /dev/null +++ b/cadfs_to_cdsl/regression/manifest.json @@ -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 +} diff --git a/cadfs_to_cdsl/reports.py b/cadfs_to_cdsl/reports.py index 53543bb9..c72ad908 100644 --- a/cadfs_to_cdsl/reports.py +++ b/cadfs_to_cdsl/reports.py @@ -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", diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 93ab6ef0..7baafa3c 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -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")] diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 6f958a82..6ca5cc11 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -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"), {}) diff --git a/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index a420fd82..127197ad 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -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) diff --git a/cadfs_to_cdsl/tests/test_regression.py b/cadfs_to_cdsl/tests/test_regression.py new file mode 100644 index 00000000..6986efa3 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_regression.py @@ -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"] + ) diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py new file mode 100644 index 00000000..33fc7ca2 --- /dev/null +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -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) diff --git a/json_to_cdsl/output/corpus-manifest.json b/json_to_cdsl/output/corpus-manifest.json index 8db24cc1..27917825 100644 --- a/json_to_cdsl/output/corpus-manifest.json +++ b/json_to_cdsl/output/corpus-manifest.json @@ -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" } } }