264 lines
12 KiB
Python
264 lines
12 KiB
Python
"""#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()
|