738934416e
- 扩展 CDSL engine 的 shell、sweep、loft、reference plane、pattern 等运行时能力, 支持新的实体结果模式、双向拉伸、曲线扫掠、镜像/圆周阵列及相关 selector 解析。 - 完善 Build123d 适配层的拓扑快照、Compound/ShapeList 兼容处理和旋转曲面识别, 兼容 Python 3.12 / 当前 Build123d 缺少 axis_of_rotation 的合法曲面场景。 - 扩展 CDSL schema、profile schema、capability analysis、semantic validation 和 sketch solver,使新增建模操作能够被校验、执行并保留可诊断的部分结果。 - 完善 CADFS FeatureScript lowering: 支持 shell、sweep、surface/实体 loft、圆周阵列副本、镜像副本、删除阵列实例、 新 body 操作、更多拉伸终止条件和 reference plane 变体。 - 补齐椭圆、B-spline、环形区域、imprint、SWEPT_FACE、CAP_FACE、OFFSET_FACE 等 草图和拓扑引用的转换逻辑,改善后续特征的工作平面、轴线和 profile 定位精度。 - 改进 selector binding:支持 pattern 前缀复合 B-rep 快照、交集顶点引用、 多面 match_mode=all、圆柱轴线/半径和面积下限等稳定匹配条件。 - 修复 MID_PLANE 法向统一后交线方向未同步的问题,恢复 00287955 基准面的正确位置; 修复 00542223 sweep 路径反转后的切线契约和 00423838 的拓扑面数不稳定测试假设。 - 修正 CADFS 比较模块 import 路径,补充重建报告、批量重建脚本、目标文档和 README。 - 新增并扩展 engine、lowering、parser、selector binding、reports、integration 和 Onshape pipeline 回归测试,覆盖代表性 CADFS 特征链及运行时兼容性。
152 lines
8.5 KiB
Python
152 lines
8.5 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
|
|
from onshape_to_cdsl.compare import _bbox, _distances, _surface_points, strict_compare
|
|
from onshape_to_cdsl.convert import convert_one
|
|
from onshape_to_cdsl.issues import write_issue_register
|
|
from onshape_to_cdsl.merge_scans import merge_scans
|
|
from onshape_to_cdsl.select import select
|
|
from onshape_to_cdsl.sketches import sketch_to_cdsl, workplane_from_matrix
|
|
from onshape_to_cdsl.source_urls import read_url_file
|
|
from onshape_to_cdsl.units import length_mm
|
|
|
|
|
|
class PipelineTests(unittest.TestCase):
|
|
def test_url_file_and_units(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
mapping = Path(temporary) / "objects.yml"
|
|
mapping.write_text("{'00000352': 'https://cad.onshape.com/documents/doc/w/work/e/element'}\n", encoding="utf-8")
|
|
reference = read_url_file(mapping)[0]
|
|
self.assertEqual(reference.sample_id, "00000352")
|
|
self.assertEqual(reference.did, "doc")
|
|
self.assertAlmostEqual(length_mm(".063 in"), 1.6002)
|
|
self.assertAlmostEqual(length_mm("1.2*cm"), 12.0)
|
|
with self.assertRaises(ValueError):
|
|
length_mm("width")
|
|
|
|
def test_solved_rectangle_becomes_closed_analytic_contour(self) -> None:
|
|
matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, .002, 0, 0, 0, 1]
|
|
self.assertEqual(workplane_from_matrix(matrix)["origin_mm"], [0.0, 0.0, 2.0])
|
|
point = lambda x, y: {"x": x / 1000, "y": y / 1000, "z": .002}
|
|
entities = []
|
|
for start, end in [((0, 0), (10, 0)), ((10, 0), (10, 5)), ((10, 5), (0, 5)), ((0, 5), (0, 0))]:
|
|
entities.append({"sketchEntityType": "skLineSegment", "isConstruction": False, "startPosition3d": point(*start), "endPosition3d": point(*end)})
|
|
cdsl = sketch_to_cdsl({"featureId": "sketch_one", "sketchMatrix": matrix, "entities": entities})
|
|
contour = cdsl["profile"]["contours"][0]
|
|
self.assertTrue(contour["closed"])
|
|
self.assertEqual(len(contour["segments"]), 4)
|
|
self.assertEqual(contour["segments"][0]["type"], "line")
|
|
|
|
def test_conversion_rejects_unsupported_history(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary) / "raw" / "sample_01"
|
|
root.mkdir(parents=True)
|
|
(root / "features.json").write_text(json.dumps({"features": [{"featureId": "x", "featureType": "loft", "parameters": []}]}), encoding="utf-8")
|
|
(root / "sketches.json").write_text(json.dumps({"sketches": []}), encoding="utf-8")
|
|
result = convert_one(root, Path(temporary) / "converted")
|
|
self.assertEqual(result["status"], "rejected")
|
|
self.assertEqual(result["diagnostics"][0]["status"], "deferred")
|
|
|
|
def test_issue_register_summarizes_engine_capability_gaps(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
converted = Path(temporary) / "converted"
|
|
sample = converted / "sample_01"
|
|
sample.mkdir(parents=True)
|
|
(sample / "conversion.json").write_text(json.dumps({"sample_id": "sample_01", "status": "rejected", "diagnostics": [{"feature_id": "loft_1", "feature_type": "loft", "status": "deferred", "reason": "loft executor is not implemented by the engine"}]}), encoding="utf-8")
|
|
register = write_issue_register(converted, [{"sample_id": "sample_01", "status": "rejected", "failure_reason": "conversion_not_executable"}])
|
|
self.assertEqual(register["summary"], {"engine_capability_gap": 1})
|
|
document = (Path(temporary) / "reconstruction_issues.md").read_text(encoding="utf-8")
|
|
self.assertIn("Engine Work Queue", document)
|
|
self.assertIn("loft executor", document)
|
|
|
|
def test_deterministic_stratified_selection(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
scan = Path(temporary) / "scan.json"
|
|
records = [{"sample_id": str(index), "status": "scanned", "feature_count": index, "feature_types": {"extrude": 1}, "curve_types": {"line": 4}} for index in range(8)]
|
|
scan.write_text(json.dumps({"records": records}), encoding="utf-8")
|
|
one = select(scan, Path(temporary) / "one.json", count=4, seed=7)
|
|
two = select(scan, Path(temporary) / "two.json", count=4, seed=7)
|
|
self.assertEqual([item["sample_id"] for item in one["records"]], [item["sample_id"] for item in two["records"]])
|
|
|
|
def test_merge_scan_batches_rejects_duplicate_ids(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
first, second = root / "first.json", root / "second.json"
|
|
first.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "scanned"}]}), encoding="utf-8")
|
|
second.write_text(json.dumps({"records": [{"sample_id": "0002", "status": "failed"}]}), encoding="utf-8")
|
|
merged = merge_scans([first, second], root / "merged.json")
|
|
self.assertEqual([item["sample_id"] for item in merged["records"]], ["0001", "0002"])
|
|
second.write_text(json.dumps({"records": [{"sample_id": "0001", "status": "failed"}]}), encoding="utf-8")
|
|
with self.assertRaises(ValueError):
|
|
merge_scans([first, second], root / "bad.json")
|
|
|
|
def test_strict_comparator_accepts_identical_and_rejects_translation(self) -> None:
|
|
from build123d import Box, export_step
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
source, same, shifted = root / "source.step", root / "same.step", root / "shifted.step"
|
|
box = Box(10, 10, 10)
|
|
export_step(box, source)
|
|
export_step(box, same)
|
|
moved = Box(10, 10, 10).translate((0.02, 0, 0))
|
|
export_step(moved, shifted)
|
|
self.assertTrue(strict_compare(source, same)["passed"])
|
|
rejection = strict_compare(source, shifted)
|
|
self.assertFalse(rejection["passed"])
|
|
self.assertIn("bbox_exceeds_0.01mm", rejection["failure_reasons"])
|
|
|
|
def test_strict_comparator_can_fast_reject_an_exact_mismatch(self) -> None:
|
|
from build123d import Box, export_step
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
source, changed = root / "source.step", root / "changed.step"
|
|
export_step(Box(10, 10, 10), source)
|
|
export_step(Box(20, 10, 10), changed)
|
|
rejection = strict_compare(source, changed, fast_reject_relative_error=.005)
|
|
self.assertFalse(rejection["passed"])
|
|
self.assertEqual(rejection["surface"]["status"], "skipped_after_exact_mismatch")
|
|
self.assertIsNone(rejection["metrics"]["bbox_max_delta_mm"])
|
|
|
|
def test_strict_comparator_samples_points_on_the_source_brep(self) -> None:
|
|
from build123d import Cylinder
|
|
|
|
cylinder = Cylinder(21.3, 50)
|
|
samples = _surface_points(cylinder, 0.05)
|
|
self.assertGreater(len(samples), 0)
|
|
self.assertLess(max(_distances(samples, cylinder)), 1e-6)
|
|
|
|
def test_strict_comparator_bounds_use_the_sampled_brep_surface(self) -> None:
|
|
points = [(-1.0, -2.0, -3.0), (4.0, 5.0, 6.0), (0.0, 1.0, 2.0)]
|
|
|
|
self.assertEqual(_bbox(points), [-1.0, -2.0, -3.0, 4.0, 5.0, 6.0])
|
|
|
|
def test_00000352_offline_end_to_end_when_fixture_is_available(self) -> None:
|
|
fixture = Path.cwd() / "json_to_cdsl/input/onshape_complete/00000352"
|
|
if not (fixture / "model.step").exists():
|
|
self.skipTest("optional local 00000352 raw fixture is not installed")
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
raw = root / "raw" / "00000352"
|
|
raw.parent.mkdir()
|
|
shutil.copytree(fixture, raw)
|
|
result = convert_one(raw, root / "converted")
|
|
self.assertEqual(result["status"], "converted")
|
|
from cdsl_engine.runtime import rebuild_cdsl
|
|
from cdsl_engine.semantic_validation import validate_semantic_cdsl
|
|
cdsl_path = root / "converted/00000352/candidate.cdsl.json"
|
|
cdsl = json.loads(cdsl_path.read_text(encoding="utf-8"))
|
|
validate_semantic_cdsl(cdsl)
|
|
rebuilt = root / "rebuild.step"
|
|
rebuild_cdsl(cdsl, rebuilt, strict=True)
|
|
comparison = strict_compare(raw / "model.step", rebuilt)
|
|
self.assertTrue(comparison["passed"], comparison["failure_reasons"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|