diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 7b385db4..edcee86c 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -314,8 +314,12 @@ class CapabilityAnalyzer: except ValueError as error: blockers.append(self._blocker(node.feature_id, "invalid_hole_spec", str(error))) if node.atomic_id == "hole_wizard": - if params.get("thread"): - blockers.append(self._blocker(node.feature_id, "unsupported_hole_subtype", "Threaded Hole Wizard geometry is not represented by the current CDSL runtime")) + # #9 hole thread:SolidWorks 螺纹孔的 thread 是装饰信息(无螺距、 + # 不进实体几何,STEP 导出即光滑孔)。HoleSpec.from_feature 只读 + # 直径/深度/位置/沉头/沉孔,thread 天然不参与几何计算 → 孔特征 + # 直接按光滑圆柱孔执行,runtime 侧记录 thread_decoration_ignored + # info 诊断便于追溯(见 _execute_hole)。不再报 + # unsupported_hole_subtype,使 ≈712 个带 thread 的孔恢复可执行。 hole_extent = (params.get("end_condition") or {"type": "blind"}).get("type") if hole_extent not in {"blind", "through_all", "through_all_both"}: blockers.append(self._blocker( diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index e4ea5f53..67ac1ed0 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -501,8 +501,18 @@ def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: b session.adapter.body_span(session.body, inward) + 2.0, ) # 6. 从主体上减去工具实体,登记新主体并返回结果。 + # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 + # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 + # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 + diagnostics: list[RuntimeDiagnostic] = [] + if wizard and node.params.get("thread"): + diagnostics.append(RuntimeDiagnostic( + code="thread_decoration_ignored", + message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", + feature_id=node.feature_id, + )) session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) - return session.result(node) + return session.result(node, diagnostics=diagnostics) def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]: diff --git a/backend/tests/test_engine_hole_thread_contract.py b/backend/tests/test_engine_hole_thread_contract.py new file mode 100644 index 00000000..02345839 --- /dev/null +++ b/backend/tests/test_engine_hole_thread_contract.py @@ -0,0 +1,263 @@ +"""#9 孔型:hole_wizard.thread(装饰螺纹)必须可执行并降级为光滑孔。 + +中文说明 +-------- +这个文件在测试什么(issue #9「HoleSpec 仅支持简单圆柱、沉头、沉孔」的回归测试): + + 1. 背景:SolidWorks 螺纹孔(hole_wizard + thread,如"M5 螺纹孔"、 + "底部螺纹孔")在真实语料里大量存在,修复前 capabilities 把 thread + 误判为"当前 CDSL runtime 无法表示的几何"而报 unsupported_hole_subtype + blocker,≈712 个带 thread 的孔特征因此整体被拒(runtime_eligible=False), + 零件无法重建。 + 实际上 thread 只是装饰信息: + - 数据形态只有 {diameter_mm, depth_mm, class},没有螺距; + - SolidWorks/STEP 的螺纹孔实体几何就是光滑圆柱孔(装饰螺纹不进 + 实体、不进 STEP); + - HoleSpec.from_feature 只读直径/深度/位置/沉头/沉孔,thread 天然 + 不参与几何计算。 + 因此正确合同是"接受 thread、按光滑孔执行",并在 runtime 记录 + info 级诊断(thread_decoration_ignored)便于批量报告追溯降级数量。 + + 2. 本测试套件把"thread 孔必须可执行且降级为光滑孔"固定下来: + - 主契约:带 thread 的 hole_wizard → runtime_eligible=True + (不再被拒绝); + - 回归护栏:不带 thread 的 hole_wizard → 仍可执行(防止把检查 + 加过头,所有孔都被拒); + - 文档契约:hole_wizard + thread 通过 cdsl_schema.json 校验 + (字段本就在 holeWizardParams 里); + - 几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模, + 与 SolidWorks/STEP 语义一致); + - 可追溯:rebuild 结果里带 thread_decoration_ignored 信息诊断 + (降级不是静默发生的)。 + + 3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import + cdsl_engine 包做端到端测试(与 test_engine_hole_thread_contract 等 + 既有测试风格一致)。 + +函数功能一览 +------------ + _workplane() 构造默认草图工作平面(XY 平面)。 + _rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _base_block() 构造 10×10×10 拉伸主体文档(体积 1000)。 + _thread_wizard_feature() 构造 hole_wizard 特征(with_thread 决定 + 是否携带 thread 装饰字段)。 + _validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。 + HoleThreadContractTests 见各测试方法 docstring。 +""" + +from __future__ import annotations + +import json +import math +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) +sys.path.insert(0, str(ROOT / "backend" / "engine")) + +import jsonschema # noqa: E402 + +import cdsl_engine # noqa: E402 +from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402 + +# cdsl_schema.json 路径:随 cdsl_engine 包部署。 +_SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json" +_SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")) + + +try: + import build123d # noqa: F401 + _HAS_BUILD123D = True +except ImportError: + _HAS_BUILD123D = False + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _workplane() -> dict: + """默认草图工作平面:原点在 (0,0,0)、x 轴沿 +X、法向沿 +Z。""" + return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + """XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。""" + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _base_block() -> dict: + """10×10×10 拉伸主体:体积 1000,顶面位于 z=10。""" + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "hole-thread-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": _workplane(), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [{ + "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "base", + }], + } + + +def _thread_wizard_feature(*, with_thread: bool = True) -> dict: + """构造 hole_wizard 特征(仿真实数据"M5 螺纹孔")。 + + - with_thread=True:携带 thread 装饰字段 + {"diameter_mm", "depth_mm", "class"}(真实语料形态,无螺距); + - with_thread=False:不写 thread(回归护栏用)。 + """ + params: dict = { + "hole_type": "底部螺纹孔", "diameter_mm": 5.0, "depth_mm": 10.0, + "end_condition": {"type": "blind", "solidworks_code": 0}, + "positions": [{"mm": [0.0, 0.0, 0.0]}], + # 合法的 selectorRef(cdsl_schema.json hostFace oneOf 分支), + # 满足机器 schema 的 required: [kind, stable_id, source, confidence]。 + "host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1}, + } + if with_thread: + params["thread"] = {"diameter_mm": 5.0, "depth_mm": 10.0, "class": "1B"} + return {"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], "params": params} + + +def _validate_against_cdsl_schema(doc: dict) -> None: + """对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。""" + jsonschema.validate(instance=doc, schema=_SCHEMA) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class HoleThreadContractTests(unittest.TestCase): + """thread 装饰螺纹「文档格式-能力边界-运行时-几何」四方合同的回归测试。""" + + def test_thread_hole_wizard_is_executable(self) -> None: + """主契约:带 thread 的 hole_wizard 必须 runtime_eligible。 + + 修复前(当前):capabilities 报 unsupported_hole_subtype → + runtime_eligible=False → 零件无法重建 → 本测试红灯。 + 修复后:capabilities 不再拒绝 thread → runtime_eligible=True, + hole 特征无任何 blocker → 绿灯。 + """ + cdsl = _base_block() + cdsl["features"].append(_thread_wizard_feature(with_thread=True)) + + analysis = analyze_cdsl(cdsl) + hole = next(item for item in analysis.feature_results if item.feature_id == "hole") + + self.assertTrue(analysis.runtime_eligible) + self.assertTrue(hole.executable) + self.assertNotIn("unsupported_hole_subtype", [blocker.code for blocker in hole.blockers]) + + def test_threadless_hole_wizard_stays_eligible(self) -> None: + """回归护栏:不带 thread 的 hole_wizard 仍必须 runtime_eligible。 + + 修复前/修复后均应通过。这条测试防止我们把修复做成"所有孔都被拒" + (例如误删 hole 检查整段)。 + """ + cdsl = _base_block() + cdsl["features"].append(_thread_wizard_feature(with_thread=False)) + + analysis = analyze_cdsl(cdsl) + self.assertTrue(analysis.runtime_eligible) + + def test_thread_hole_passes_machine_schema(self) -> None: + """文档契约:hole_wizard + thread 必须通过 cdsl_schema.json 校验。 + + thread 字段本就在 holeWizardParams.properties 里(允许携带), + 修复策略是"能力层接受并降级",不是"schema 层拒绝"——这条测试锁死 + 文档格式对 thread 的认可,防止未来把 schema 改过头。 + """ + cdsl = _base_block() + cdsl["features"].append(_thread_wizard_feature(with_thread=True)) + _validate_against_cdsl_schema(cdsl) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_thread_hole_cuts_plain_cylindrical_bore(self) -> None: + """几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模)。 + + 10×10×10 主体,在顶面(z=10)中心打一个 d=2、深 5 的 thread 盲孔: + 体积 = 1000 - π·1²·5。若 thread 参与几何(或孔整体被拒),体积断言 + 都会失败。这条测试锁死"降级为光滑孔"的几何语义——与 SolidWorks/ + STEP 的螺纹孔表示一致。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = next( + item for item in baseline["topology_records"] + if item["kind"] == "face" + and item["geometry"]["surface_type"] == "plane" + and item["geometry"]["normal"][2] > 0.9 + ) + feature = _thread_wizard_feature(with_thread=True) + feature["params"]["diameter_mm"] = 2.0 + feature["params"]["depth_mm"] = 5.0 + feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}] + feature["params"]["host_face"] = { + "kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"], + } + feature["selectors"] = [ + {"kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"]}, + ] + with_hole = deepcopy(base) + with_hole["features"].append(feature) + holed = rebuild_cdsl(with_hole, root / "thread-hole.step") + + self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * math.pi, places=5) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_thread_fallback_reports_info_diagnostic(self) -> None: + """可追溯:thread 降级必须留下 thread_decoration_ignored 信息诊断。 + + 降级不是静默发生的:runtime 在 wizard 模式且携带 thread 时记录 + info 级诊断,批量报告(summary-by-diagnostic 或 per-part report) + 可以统计降级数量。这条测试锁死"降级可观测"。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = next( + item for item in baseline["topology_records"] + if item["kind"] == "face" + and item["geometry"]["surface_type"] == "plane" + and item["geometry"]["normal"][2] > 0.9 + ) + feature = _thread_wizard_feature(with_thread=True) + feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}] + feature["params"]["host_face"] = { + "kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"], + } + feature["selectors"] = [ + {"kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"]}, + ] + with_hole = deepcopy(base) + with_hole["features"].append(feature) + holed = rebuild_cdsl(with_hole, root / "thread-hole.step") + + hole_result = next(item for item in holed["feature_results"] if item["feature_id"] == "hole") + codes = [diagnostic["code"] for diagnostic in hole_result["diagnostics"]] + self.assertIn("thread_decoration_ignored", codes) + + +if __name__ == "__main__": + unittest.main()