fix(engine): 接受 hole_wizard 的 thread 装饰并按光滑孔降级执行
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user