From dea818e07d0177eab9c5e51244ec641691c0378d Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 26 Aug 2026 15:14:29 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix(engine):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E5=9D=90=E6=A0=87=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E9=98=B5=E5=88=97=E5=8F=98=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/capabilities.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index c73c217a..97df99ba 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -68,12 +68,19 @@ def _has_explicit_host_frame(params: dict[str, Any]) -> bool: def pattern_transform_blocker(source: FeaturePlanNode) -> str | None: """Return the selector dependency that cannot be transformed exactly. - An explicit host frame is coordinate data, not a topology guess. It can - be transformed with a patterned instance while preserving local hole - positions. All topology selectors and selector-dependent extents remain - blocked until their geometry transform contract is implemented. + Selectors fully captured by explicit coordinate data (a revolve axis with + origin/direction, or a host face with a complete world frame) are + transformed together with each patterned instance and are not blockers. + Any remaining topology selector and selector-dependent extent stays + blocked until its geometry transform contract is implemented. """ - if source.selectors: + for selector in source.selectors or (): + if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")): + # The axis is coordinate data; _translated_node shifts its origin. + continue + if selector.get("kind") == "face" and _has_explicit_host_frame(source.params): + # The host face carries a complete world frame; positions stay local. + continue return "feature selector" host = source.params.get("host_face") if host is not None and not _has_explicit_host_frame(source.params): -- 2.52.0 From 98d208ae5d0a646355ec3a976e89b6689b465766 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 26 Aug 2026 18:18:42 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(json=5Fto=5Fcdsl):=20=E5=A4=9A?= =?UTF-8?q?=E5=9C=86=E8=BD=AE=E5=BB=93=E6=94=B9=E4=B8=BA=20analytic=5Fcont?= =?UTF-8?q?ours=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- json_to_cdsl/evidence_v2_to_cdsl.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/json_to_cdsl/evidence_v2_to_cdsl.py b/json_to_cdsl/evidence_v2_to_cdsl.py index 1012ad55..4efed0c8 100644 --- a/json_to_cdsl/evidence_v2_to_cdsl.py +++ b/json_to_cdsl/evidence_v2_to_cdsl.py @@ -414,7 +414,16 @@ def _analytic_profile(sketch: dict[str, Any]) -> dict[str, Any]: smaller, larger = sorted(drawable, key=lambda item: item["radius_mm"]) return {"type": "annulus", "center": larger["center"], "inner_radius_mm": smaller["radius_mm"], "outer_radius_mm": larger["radius_mm"]} if drawable and all(item["type"] == "circle" for item in drawable): - return {"type": "circles", "items": [{"center": item["center"], "radius_mm": item["radius_mm"]} for item in drawable]} + # Multiple independent circles share the "extrude every closed loop" intent; + # emit them as analytic_contours (one outer contour per circle) so the + # generic runtime profile contract accepts the record. + return { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [item]} + for item in drawable + ], + } contours = _chain_segments(drawable) areas = [_contour_area(contour) for contour in contours] -- 2.52.0 From e7ade4aa00aa3bf6baaee4a60610bf52f8db3666 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 10:55:55 +0800 Subject: [PATCH 03/13] =?UTF-8?q?fix(evidence=5Fv2):=20=E5=B0=86=20annulus?= =?UTF-8?q?=20=E9=99=8D=E7=BA=A7=E4=B8=BA=20analytic=5Fcontours=20?= =?UTF-8?q?=E8=BD=AE=E5=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同心圆截面改为输出外圆与内圆两条 analytic_contours 轮廓,避免旧 annulus 宏导致 CDSL 文档不符合 schema 且运行时无法执行 --- .../test_evidence_v2_annulus_lowering.py | 170 ++++++++++++++++++ json_to_cdsl/evidence_v2_to_cdsl.py | 11 +- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_evidence_v2_annulus_lowering.py diff --git a/backend/tests/test_evidence_v2_annulus_lowering.py b/backend/tests/test_evidence_v2_annulus_lowering.py new file mode 100644 index 00000000..3ffa130b --- /dev/null +++ b/backend/tests/test_evidence_v2_annulus_lowering.py @@ -0,0 +1,170 @@ +"""#10 Profile chain: evidence_v2 importer must lower annulus to analytic_contours. + +The CDSL-only runtime and ``cdsl_schema.json`` accept only three generic +profiles (``circle``, ``polygon``, ``analytic_contours``). The legacy +``annulus`` macro is still emitted by ``evidence_v2_to_cdsl._analytic_profile`` +for concentric circles, which makes the resulting document schema-invalid and +runtime-ineligible. This test suite pins the lowering contract: concentric +circles must produce an ``analytic_contours`` profile (outer + inner ring). + +中文说明 +-------- +这个文件在测试什么(issue #10「10 Profile 链」的回归测试): + + 1. 背景:CDSL-only 运行时(backend/engine/cdsl_engine)与 + cdsl_schema.json 只接受三种通用 profile 类型: + circle / polygon / analytic_contours。 + 而 evidence_v2 导入器(json_to_cdsl/evidence_v2_to_cdsl.py 的 + _analytic_profile)在遇到"同心双圆"(垫圈/圆环 annulus 截面)时, + 仍会输出旧宏 {type: "annulus"},导致产出的 CDSL 文档 schema 不合法、 + 运行时不可执行(报 profile_resolution_failed / unsupported_profile)。 + + 2. 本测试套件把"降级契约"固定下来:同心双圆必须降级为 + analytic_contours 轮廓(外圆 role=outer + 内圆 role=inner), + 并保证 importer 输出能被 CDSL-only 运行时端到端重建为实体。 + + 3. sys.path 说明:把 json_to_cdsl 与 backend/engine 加入搜索路径, + 是为了让测试能直接 import 导入器内部的 _analytic_profile(灰盒测试) + 以及 CDSL-only 运行时的 rebuild_cdsl(端到端测试)。 +""" + +from __future__ import annotations + +import math +import sys +import tempfile +import unittest +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")) +sys.path.insert(0, str(ROOT / "json_to_cdsl")) + +try: + import build123d # noqa: F401 + _HAS_BUILD123D = True +except ImportError: + _HAS_BUILD123D = False + +from evidence_v2_to_cdsl import _analytic_profile # noqa: E402 + + +def _circle_segment(center: list[float], radius_mm: float, *, construction: bool = False) -> dict: + """One full SolidWorks sketch circle segment (start == end == center). + + 构造一条 SolidWorks 草图中的完整圆线段(start == end == center, + 即闭合圆)。radius_mm 除以 1000 转成米,与导入器内部单位保持一致。 + """ + return { + "geometry": { + "segment_type": "swSketchARC", + "construction": construction, + "start": center, + "end": center, + "center": center, + "direction": 1, + "curve": {"type": "circle", "parameters": [*center, 0, 0, 0, 1, radius_mm / 1000.0]}, + } + } + + +def _ring_cdsl(profile: dict) -> dict: + """Assemble a minimal CDSL document whose single feature extrudes a ring profile. + + 组装一份最简 CDSL 文档:一张草图(ring,携带被测试的 profile)+ + 一个拉伸特征(extrude_add_blind)。供端到端测试使用, + 验证 importer 产出的 profile 能否被运行时重建为实体。 + """ + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "annulus-ring", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "ring", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": profile, + }]}, + "features": [{ + "id": "ring_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 20}, "sketch_id": "ring", + }], + } + + +class EvidenceV2AnnulusLoweringTests(unittest.TestCase): + """annulus 降级契约的回归测试套件。 + + 三个测试用例分别覆盖: + 1. test_evidence_v2_emits_analytic_contours_for_annulus + 主契约:同心双圆(外 r=10mm,内 r=5mm)必须降级为 analytic_contours, + 且恰好携带 outer(10) + inner(5) 两条轮廓。 + 2. test_evidence_v2_emits_analytic_contours_for_multiple_circles + 回归护栏:两个独立(圆心不同、互不包含)的圆本来就输出 + analytic_contours,修复 annulus 分支时不得破坏该既有行为。 + 3. test_evidence_v2_annulus_output_rebuilds_to_ring_solid + 端到端:importer 输出的 analytic_contours 必须能被 CDSL-only 运行时 + rebuild_cdsl 重建为实体,且体积与圆环公式 + π * (r外² - r内²) * 高度 一致(误差 1%)。 + """ + + def test_evidence_v2_emits_analytic_contours_for_annulus(self) -> None: + """Concentric circles must lower to a generic ring (outer + inner).""" + # 主契约测试:同圆心 (0,0) 的两个圆,外径 10mm、内径 5mm + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([0, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + # 1) profile 类型必须是 analytic_contours(而不是旧宏 annulus) + self.assertEqual(profile["type"], "analytic_contours") + contours = profile["contours"] + # 2) 必须恰好有两条轮廓 + self.assertEqual(len(contours), 2) + # 3) 两条轮廓的角色分别是 outer(外圆)与 inner(内圆) + self.assertEqual({contour["role"] for contour in contours}, {"outer", "inner"}) + outer = next(contour for contour in contours if contour["role"] == "outer") + inner = next(contour for contour in contours if contour["role"] == "inner") + # 4) 半径正确:外 10mm / 内 5mm + self.assertEqual(outer["segments"][0]["radius_mm"], 10.0) + self.assertEqual(inner["segments"][0]["radius_mm"], 5.0) + + def test_evidence_v2_emits_analytic_contours_for_multiple_circles(self) -> None: + """Regression guard: independent circles already lower to contours.""" + # 回归护栏:两个圆心不同(相距 30mm)、互不包含的独立圆, + # 修复 annulus 分支前后都必须保持输出 analytic_contours(各一条 outer 轮廓) + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([30, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + self.assertEqual(profile["type"], "analytic_contours") + self.assertEqual(len(profile["contours"]), 2) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_evidence_v2_annulus_output_rebuilds_to_ring_solid(self) -> None: + """Importer output must be consumed end-to-end by the CDSL-only runtime.""" + # 端到端:直接取 _analytic_profile 的输出组装成 CDSL 文档, + # 交给 CDSL-only 运行时 rebuild_cdsl 重建实体,再断言体积符合圆环公式。 + # 此测试验证修复后运行时不再报 profile_resolution_failed / unsupported_profile。 + from cdsl_engine.runtime import rebuild_cdsl + + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([0, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + cdsl = _ring_cdsl(profile) + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "ring.step" + result = rebuild_cdsl(cdsl, out_step) + # 圆环体积 = π * (r外² - r内²) * 高度 = π * (100 - 25) * 20 + expected = math.pi * (10.0 ** 2 - 5.0 ** 2) * 20.0 + self.assertAlmostEqual(result["volume_mm3"], expected, delta=expected * 0.01) + + +if __name__ == "__main__": + unittest.main() diff --git a/json_to_cdsl/evidence_v2_to_cdsl.py b/json_to_cdsl/evidence_v2_to_cdsl.py index 4efed0c8..717ab613 100644 --- a/json_to_cdsl/evidence_v2_to_cdsl.py +++ b/json_to_cdsl/evidence_v2_to_cdsl.py @@ -412,7 +412,16 @@ def _analytic_profile(sketch: dict[str, Any]) -> dict[str, Any]: left, right = drawable if math.dist(left["center"], right["center"]) <= EPSILON_MM: smaller, larger = sorted(drawable, key=lambda item: item["radius_mm"]) - return {"type": "annulus", "center": larger["center"], "inner_radius_mm": smaller["radius_mm"], "outer_radius_mm": larger["radius_mm"]} + # Annulus is emitted as analytic_contours (outer + inner) so the + # generic runtime profile contract accepts the record; ring_revolve + # can then revolve the two concentric circles into a hollow solid. + return { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [larger]}, + {"role": "inner", "closed": True, "segments": [smaller]}, + ], + } if drawable and all(item["type"] == "circle" for item in drawable): # Multiple independent circles share the "extrude every closed loop" intent; # emit them as analytic_contours (one outer contour per circle) so the -- 2.52.0 From 2ef31db17eb218373c44f9c903ab32b1da0b9d84 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 14:00:44 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0cdsl=5Fschema.js?= =?UTF-8?q?on=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/cdsl_schema.json | 31 ++++++++++++--------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 305fcdfb..10dd6fa4 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -46,22 +46,27 @@ "additionalProperties": false }, "hostFace": { - "type": "object", - "properties": { - "frame": { + "oneOf": [ + { "type": "object", "properties": { - "origin_mm": {"$ref": "#/$defs/point3"}, - "x_dir": {"$ref": "#/$defs/point3"}, - "y_dir": {"$ref": "#/$defs/point3"}, - "normal": {"$ref": "#/$defs/point3"} + "frame": { + "type": "object", + "properties": { + "origin_mm": {"$ref": "#/$defs/point3"}, + "x_dir": {"$ref": "#/$defs/point3"}, + "y_dir": {"$ref": "#/$defs/point3"}, + "normal": {"$ref": "#/$defs/point3"} + }, + "required": ["origin_mm", "x_dir", "y_dir", "normal"], + "additionalProperties": false + } }, - "required": ["origin_mm", "x_dir", "y_dir", "normal"], + "required": ["frame"], "additionalProperties": false - } - }, - "required": ["frame"], - "additionalProperties": false + }, + {"$ref": "#/$defs/selectorRef"} + ] }, "holePosition": { "type": "object", @@ -234,7 +239,7 @@ "depth_mm": {"type": "number", "minimum": 0}, "end_condition": {"$ref": "#/$defs/endCondition"}, "positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}}, - "host_face": {"$ref": "#/$defs/selectorRef"}, + "host_face": {"$ref": "#/$defs/hostFace"}, "thread": {"type": "object"}, "countersink": {"type": "object"}, "counterbore": {"type": "object"} -- 2.52.0 From c5f789c385a1d3546ca735a78eca4eb7d0faf6c8 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 14:36:08 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(engine):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=B9=B6=E6=AD=A3=E4=BA=A4=E5=8C=96=E5=B9=B3=E9=9D=A2=20y=5Fdi?= =?UTF-8?q?r=20=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/runtime.py | 13 ++++++++- backend/engine/cdsl_engine/runtime_types.py | 25 +++++++++++++++++- backend/engine/cdsl_engine/sketch_solver.py | 29 +++++++++++++++++---- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index c370ad6f..80c1d1ae 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -702,9 +702,20 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) -> host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") ) if positions_are_local: - for key in ("origin_mm", "x_dir", "y_dir", "normal"): + for key in ("origin_mm", "x_dir", "normal"): if host_frame.get(key): host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") + # #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的 + # canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame + # 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。 + x_reflected = host_frame.get("x_dir") + n_reflected = host_frame.get("normal") + if x_reflected is not None and n_reflected is not None: + host_frame["y_dir"] = [ + n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1], + n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2], + n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0], + ] # See _mirrored_sketch: the canonical reflected plane reverses local # y, so local hole coordinates must do the same. for position in params.get("positions") or []: diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index 242c1f39..def85b0a 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -7,6 +7,7 @@ any geometry adapter without importing OCC objects. from __future__ import annotations +import warnings from dataclasses import dataclass, field from math import sqrt from typing import Any, Iterable @@ -14,6 +15,10 @@ from typing import Any, Iterable Vector3 = tuple[float, float, float] +# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, +# 予以保留;否则视为偏斜数据,正交化并显式警告。 +_Y_DIR_ORTHOGONALITY_TOL = 1e-6 + def _vector3(value: Any, *, field_name: str) -> Vector3: if not isinstance(value, (list, tuple)) or len(value) != 3: @@ -176,7 +181,25 @@ class PlaneSpec: x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) x_dir = _unit(projected_x, field_name="plane.x_dir") - y_dir = _unit(_cross(normal, x_dir), field_name="plane.y_dir") + generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") + y_raw = value.get("y_dir") + if y_raw is None: + # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 + y_dir = generated + else: + y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") + if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: + # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, + # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 + y_dir = y_vec + else: + # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 + warnings.warn( + f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", + UserWarning, + stacklevel=2, + ) + y_dir = generated return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) def as_dict(self) -> dict[str, list[float]]: diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index c9c5e4a6..29bff622 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -44,14 +44,33 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] normal = workplane.get("normal") or [0, 0, 1] - y_dir = [ - normal[1] * x_dir[2] - normal[2] * x_dir[1], - normal[2] * x_dir[0] - normal[0] * x_dir[2], - normal[0] * x_dir[1] - normal[1] * x_dir[0], - ] + y_raw = workplane.get("y_dir") + y_dir = _default_y_dir(x_dir, normal) + if y_raw: + magnitude = math.sqrt(sum(component * component for component in y_raw)) + if magnitude > 1e-12: + y_unit = [component / magnitude for component in y_raw] + # 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的 + # y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行, + # 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。 + if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6: + y_dir = y_unit return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] +def _dot(left: Iterable[float], right: Iterable[float]) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]: + x, n = list(x_dir), list(normal) + return [ + n[1] * x[2] - n[2] * x[1], + n[2] * x[0] - n[0] * x[2], + n[0] * x[1] - n[1] * x[0], + ] + + def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: transformed: list[_Ctx] = [] normal = workplane.get("normal") or [0, 0, 1] -- 2.52.0 From 0acb29e96d5594c3965c84858ac87ce69843ea12 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 14:39:44 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=B9=B3=E9=9D=A2=E5=92=8C=E6=97=8B=E8=BD=AC=E8=BD=B4?= =?UTF-8?q?=E7=9A=84=E5=9D=90=E6=A0=87=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_engine_plane_y_dir_preservation.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 backend/tests/test_engine_plane_y_dir_preservation.py diff --git a/backend/tests/test_engine_plane_y_dir_preservation.py b/backend/tests/test_engine_plane_y_dir_preservation.py new file mode 100644 index 00000000..25fed330 --- /dev/null +++ b/backend/tests/test_engine_plane_y_dir_preservation.py @@ -0,0 +1,119 @@ +"""#1 y_dir 静默丢弃:PlaneSpec.from_mapping 与 sketch_solver 必须尊重输入的 y_dir。 + +中文说明 +-------- +这个文件在测试什么(issue #1「y_dir 静默丢弃」的回归测试): + + 1. 背景:SolidWorks 导出草图常带冗余或非正交的 y_dir。 + runtime 的 PlaneSpec.from_mapping(runtime_types.py:172-180)与 + sketch_solver._to_3d(sketch_solver.py:43-52)都**无视输入的 + y_dir 字段**,永远用 normal × x_dir 叉积补全。后果: + a) 文档作者显式给出的**正交** y_dir 被悄悄替换成叉积结果 + (当 x_dir 与 normal 不正交时,重建的 y_dir 与真实几何不符); + b) **偏斜** y_dir 被静默丢弃,没有任何提示,用户不知道 + 坐标系被正交化过了。 + + 2. 本测试把 y_dir 处理契约固定下来: + - 输入 y_dir 存在且与 x_dir / normal 正交 → **保留**输入值; + - 输入 y_dir 存在但偏斜(非正交)→ 正交化 + **显式 UserWarning**; + - 输入 y_dir 缺失 → 用 normal × x_dir 补全(回归护栏); + - sketch_solver 的轮廓点变换(_transform_contours → _to_3d) + 同样尊重正交的输入 y_dir。 + + 3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import + cdsl_engine 包内模块(与前面几个回归测试风格一致)。 +""" + +from __future__ import annotations + +import sys +import unittest +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")) + +from cdsl_engine.runtime_types import PlaneSpec # noqa: E402 +from cdsl_engine.sketch_solver import _transform_contours # noqa: E402 + + +def _assert_vector_close(testcase: unittest.TestCase, actual: tuple | list, expected: tuple | list, *, tol: float = 1e-6) -> None: + testcase.assertEqual(len(actual), len(expected)) + for left, right in zip(actual, expected): + testcase.assertAlmostEqual(float(left), float(right), delta=tol) + + +class PlaneYDirPreservationTests(unittest.TestCase): + """y_dir 处理契约:正交保留 / 偏斜警告 / 缺失补全。""" + + def test_orthogonal_input_y_dir_is_preserved(self) -> None: + """主契约:输入的正交 y_dir 必须被保留,而不是被叉积结果覆盖。 + + 构造一个 x_dir 与 normal 不垂直的 frame(这是 SolidWorks 导出里 + 最常见的情形):x_dir=(1,0,0)、normal=(0,1,0)。此时 + normal × x_dir = (0,0,-1),但文档显式给出 y_dir=(0,0,1)。 + 修复前:PlaneSpec.y_dir == (0,0,-1)(静默丢弃,红灯)。 + 修复后:PlaneSpec.y_dir == (0,0,1)(保留输入,绿灯)。 + """ + plane = PlaneSpec.from_mapping({ + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 0.0, 1.0], + "normal": [0.0, 1.0, 0.0], + }) + _assert_vector_close(self, plane.y_dir, (0.0, 0.0, 1.0)) + + def test_skewed_input_y_dir_orthogonalizes_and_warns(self) -> None: + """主契约:偏斜 y_dir 必须被正交化,并且**显式**发出 UserWarning。 + + 输入 y_dir=(0, 1, 1) 与 normal=(0,0,1) 的夹角不是 90°,无法直接 + 作为右手系 y 轴。修复前:静默替换为 normal × x_dir(无警告,红灯)。 + 修复后:正交化为 normal × x_dir,同时 raise UserWarning(绿灯)。 + """ + with self.assertWarns(UserWarning): + plane = PlaneSpec.from_mapping({ + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 1.0], + "normal": [0.0, 0.0, 1.0], + }) + # normal × x_dir = (0,0,1) × (1,0,0) = (0,1,0),且为右手系补全 + _assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0)) + + def test_missing_y_dir_gets_orthonormal_default(self) -> None: + """回归护栏:y_dir 字段缺失时,仍用 normal × x_dir 补全。 + + 修复前后都应通过,这条测试防止我们把默认补全路径改坏。 + """ + plane = PlaneSpec.from_mapping({ + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "normal": [0.0, 0.0, 1.0], + }) + _assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0)) + + def test_contour_transform_respects_input_y_dir(self) -> None: + """端到端:sketch_solver 的轮廓点变换必须尊重正交的输入 y_dir。 + + workplane 与 test_orthogonal_input_y_dir_is_preserved 相同 + (x=(1,0,0), n=(0,1,0), 输入 y=(0,0,1))。一条从 (0,0) 到 (0,1) + 的轮廓直线,修复前 _to_3d 用默认 y_dir=(0,0,-1) 变换,终点落到 + (0,0,-1);修复后用输入 y_dir=(0,0,1),终点落到 (0,0,1)。 + """ + workplane = { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 0.0, 1.0], + "normal": [0.0, 1.0, 0.0], + } + contours = _transform_contours([ + {"type": "line", "start_mm": [0.0, 0.0], "end_mm": [0.0, 1.0]}, + ], workplane) + _assert_vector_close(self, contours[0]["start_mm"], (0.0, 0.0, 0.0)) + _assert_vector_close(self, contours[0]["end_mm"], (0.0, 0.0, 1.0)) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 9b4e64839b16676ca29de22f42391dec3fdc8f2b Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 15:01:41 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(engine):=20=E6=94=AF=E6=8C=81=20revol?= =?UTF-8?q?ve=20=E7=89=B9=E5=BE=81=E7=9A=84=20reverse=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/runtime.py | 7 + backend/tests/test_engine_revolve_reverse.py | 213 +++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 backend/tests/test_engine_revolve_reverse.py diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 80c1d1ae..e4ea5f53 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -346,6 +346,13 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske angle = float(node.params.get("angle_deg") or 0.0) if angle <= 0: raise ValueError("revolve requires angle_deg > 0") + # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 + # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 + # 语义。profile_schema.json 已声明 revolve.* optional_params 含 + # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime + # 侧实现,使三方合同一致。 + if bool(node.params.get("reverse")): + angle = -angle solids = [session.adapter.revolve(face, angle, axis) for face in faces] # 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。 tool = None diff --git a/backend/tests/test_engine_revolve_reverse.py b/backend/tests/test_engine_revolve_reverse.py new file mode 100644 index 00000000..79a9dbfa --- /dev/null +++ b/backend/tests/test_engine_revolve_reverse.py @@ -0,0 +1,213 @@ +"""#3 revolve.reverse:旋转方向反转必须进入 runtime 的旋转计算。 + +中文说明 +-------- +这个文件在测试什么(issue #3「revolve.reverse 未进入旋转方向计算」的回归测试): + + 1. 背景:三方合同错位—— + - 人读契约 profile_schema.json 已声明 revolve_add / revolve_cut 的 + optional_params 含 reverse; + - 机器契约 cdsl_schema.json revolveParams 也已允许 reverse 字段; + - 但 runtime(backend/engine/cdsl_engine/runtime.py:343-349 的 + _shape_from_primary revolve 分支)只读取 angle_deg,完全忽略 + reverse,导致 reverse=true 的旋转特征被静默当作正向旋转, + 实体生成在轴的错误一侧(与 #1 y_dir 同类:合法字段被静默丢弃)。 + - 真实数据(如 json_to_cdsl/output/013003.cdsl.json)里 revolve + 特征大量携带 reverse: true,一旦 revolve 的 selector 捕获问题 + (deferred)解禁,成批旋转特征将几何方向错误。 + + 2. 本测试套件把"reverse=true 必须绕轴反向扫掠"的契约固定下来: + - 主契约:reverse=true 与 reverse=false 的实体 bbox 落在轴的两侧 + (z 范围符号相反),方向确实反转; + - 回归护栏:不带 reverse 字段时行为与修复前完全一致(bbox / 体积 + 不变),不会破坏现有正向旋转; + - 等价性:reverse=true 与"轴方向取反"在几何上恒等(负旋转角 ≡ + 反向轴),锁死 reverse 的精确语义; + - 机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过 + cdsl_schema.json 校验。 + + 3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import + cdsl_engine 包做端到端测试(与 test_engine_host_face_contract.py + 风格一致)。 + +旋转方向约定(已在修复前实测确认) +------------------------------------ +profile 矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴(direction=(1,0,0), +与 face 平面共面)旋转 90°: + - 不 reverse:实体落在 y/z 第一象限,bbox z∈[0,2]; + - reverse :实体落在 y/z 第四象限,bbox z∈[-2,0]。 +若旋转轴垂直于 profile 平面(如绕 Z 轴),build123d 会产出退化薄片 +(volume=0),因此夹具必须让轴与 profile 平面共面(与 +test_engine_runtime_foundation.test_revolve_can_resolve_an_owner_qualified_reference_axis +一致)。 +""" + +from __future__ import annotations + +import json +import math +import sys +import tempfile +import unittest +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 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 + + +# --------------------------------------------------------------------------- +# 测试夹具:构造最小 revolve CDSL 文档 +# --------------------------------------------------------------------------- + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + """XY 平面内的矩形轮廓:顶点按逆时针顺序排列。""" + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _revolve_cdsl(*, atomic_id: str = "revolve_add", reverse: bool | None = None, + axis_direction: list[float] | None = None) -> dict: + """最小 revolve 文档。 + + 矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴旋转 90°。轴默认 + (1,0,0),可通过 axis_direction 覆盖(用于等价性测试)。reverse 为 + None 时完全不写该字段(回归护栏:缺省正向)。 + """ + params: dict = { + "angle_deg": 90.0, + "axis": {"origin_mm": [0, 0, 0], "direction": axis_direction or [1, 0, 0]}, + } + if reverse is not None: + params["reverse"] = reverse + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "revolve-reverse-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "profile", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": _rectangle([2, 1], [4, 2]), + }]}, + "features": [{ + "id": "turn", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "profile", + "params": params, + }], + } + + +def _rebuild(cdsl: dict) -> dict: + """临时目录内重建文档并返回 runtime 结果。""" + with tempfile.TemporaryDirectory() as directory: + return rebuild_cdsl(cdsl, Path(directory) / "part.step") + + +def _validate_against_cdsl_schema(doc: dict) -> None: + """对整张 CDSL 文档跑 cdsl_schema.json 校验。""" + jsonschema.validate(instance=doc, schema=_SCHEMA) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class RevolveReverseContractTests(unittest.TestCase): + """revolve.reverse「人读契约-机器契约-运行时」三方一致性的回归测试。""" + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_revolve_reverse_true_flips_rotation_direction(self) -> None: + """主契约:reverse=true 必须让旋转体落在轴的相反侧。 + + 修复前(当前):runtime 忽略 reverse,正反旋转结果 bbox 完全相同 + (都在 z∈[0,2] 第一象限)→ 本测试红灯。 + 修复后:reverse=true 取负旋转角,实体落在 z∈[-2,0] 第四象限 → + 绿灯。z 范围符号相反即方向确实反转。 + """ + forward = _rebuild(_revolve_cdsl(reverse=False)) + flipped = _rebuild(_revolve_cdsl(reverse=True)) + + forward_min_z = forward["bbox_mm"]["min"][2] + forward_max_z = forward["bbox_mm"]["max"][2] + flipped_min_z = flipped["bbox_mm"]["min"][2] + flipped_max_z = flipped["bbox_mm"]["max"][2] + + # 正向:z 落在 [0, 2];反向:z 落在 [-2, 0]。 + self.assertAlmostEqual(forward_min_z, 0.0, places=5) + self.assertAlmostEqual(forward_max_z, 2.0, places=5) + self.assertAlmostEqual(flipped_min_z, -2.0, places=5) + self.assertAlmostEqual(flipped_max_z, 0.0, places=5) + + # 语义锁死:方向相反意味着 z 范围严格位于轴的两侧,互不重叠。 + self.assertGreater(forward_max_z, flipped_max_z) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_revolve_without_reverse_keeps_forward_direction(self) -> None: + """回归护栏:不带 reverse 字段时行为与修复前完全一致。 + + 修复前 runtime 本来就把 revolve 当正向旋转处理;修复后缺省路径 + 必须保持不动(不 reverse 就绝不能取负角)。bbox 与体积都要和 + 修复前一致:z∈[0,2]、volume = 3π/2(1/4 圆柱壳,内半径 1、 + 外半径 2、轴向长 2)。 + """ + result = _rebuild(_revolve_cdsl(reverse=None)) + + bbox = result["bbox_mm"] + self.assertAlmostEqual(bbox["min"][0], 2.0, places=5) + self.assertAlmostEqual(bbox["max"][0], 4.0, places=5) + self.assertAlmostEqual(bbox["min"][2], 0.0, places=5) + self.assertAlmostEqual(bbox["max"][2], 2.0, places=5) + self.assertAlmostEqual(result["volume_mm3"], 1.5 * math.pi, places=5) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_revolve_reverse_matches_axis_inversion(self) -> None: + """等价性:reverse=true ≡ 轴方向取反(负旋转角与反向轴几何恒等)。 + + 这条测试锁死 reverse 的精确语义——它是"绕轴反向扫掠",等价于把 + 轴方向反转后再正向扫掠。两者 bbox 与体积必须逐分量一致。 + """ + flipped = _rebuild(_revolve_cdsl(reverse=True)) + axis_inverted = _rebuild(_revolve_cdsl(reverse=False, axis_direction=[-1, 0, 0])) + + for axis_name in ("min", "max"): + for component in range(3): + self.assertAlmostEqual( + flipped["bbox_mm"][axis_name][component], + axis_inverted["bbox_mm"][axis_name][component], + places=5, + msg=f"bbox {axis_name}[{component}] must match axis inversion", + ) + self.assertAlmostEqual(flipped["volume_mm3"], axis_inverted["volume_mm3"], places=5) + + def test_revolve_params_reverse_passes_machine_schema(self) -> None: + """机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过 schema。 + + 修复前/修复后均应通过(cdsl_schema.json revolveParams 早已允许 + reverse)。这条测试是三方合同的一部分:保证机器契约确实承认这个 + 字段,runtime 侧修复才名正言顺。 + """ + _validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_add", reverse=True)) + _validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_cut", reverse=True)) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From ba481621bebbf8bf0df4b773cd0fbee3e0214b5d Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 15:10:36 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(engine):=20=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=20extrude=20draft=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E9=9D=99=E9=BB=98=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/capabilities.py | 11 + .../test_engine_extrude_draft_contract.py | 226 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 backend/tests/test_engine_extrude_draft_contract.py diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index 97df99ba..7b385db4 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -234,6 +234,17 @@ class CapabilityAnalyzer: sketch_id=node.sketch_id, )) if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES): + # #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许, + # 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形 + # 拉伸能力,人读契约 profile_schema.json 也未声明该参数。 + # 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL, + # 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从 + # "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。 + if params.get("draft"): + blockers.append(self._blocker( + node.feature_id, "unsupported_draft", + "Extrude draft/taper is not implemented; the runtime would silently ignore it", + )) end_condition = params.get("end_condition") or {"type": "blind"} end_type = end_condition.get("type") required.append(f"extent:{end_type}") diff --git a/backend/tests/test_engine_extrude_draft_contract.py b/backend/tests/test_engine_extrude_draft_contract.py new file mode 100644 index 00000000..62e8da92 --- /dev/null +++ b/backend/tests/test_engine_extrude_draft_contract.py @@ -0,0 +1,226 @@ +"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。 + +中文说明 +-------- +这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试): + + 1. 背景:三方合同错位—— + - 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许 + "draft": {"type": "object"},且是空 object(无任何子字段约束), + 即"文档格式假接受"; + - 人读契约 profile_schema.json 的 extrude_add_blind / + extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft + 为 optional_params; + - runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形 + 拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。 + - 隐患:importer(translator.py 已解析 SolidWorks 的 + draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进 + CDSL params,runtime 会静默产出**无拔模角的直壁实体**—— + 注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程 + 无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。 + + 2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明 + draft,正确的合同是"显式拒绝"而不是"实现拔模"—— + capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft + blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式 + 契约,importer 未来可能产出),能力层明确划界。 + + 3. 本测试套件把"draft 必须显式拒绝"固定下来: + - 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft + blocker,runtime_eligible=False; + - 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible; + - 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝 + 发生在能力层,不是 schema 层); + - 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind + 三种原子都报同一 blocker(同一检查全类生效); + - 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败), + analyze_document → runtime_eligible=False 且不 built(批量重建 + 不被静默污染)。 + + 4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import + cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格 + 一致)。 + +函数功能一览 +------------ + _rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _extrude_cdsl() 构造最小 extrude 文档;draft 参数决定 + 是否携带 draft 字段、atomic_id 可切换 + 三种 extrude 原子。 + _rebuild() 在临时目录内调用 rebuild_cdsl(strict), + 带 draft 时预期抛 ValueError。 + _validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。 + RevolveReverseContractTests (见各测试方法 docstring)。 +""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +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.batch_rebuild import analyze_document # 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")) + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + """XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。 + + 用于拉伸特征的最小闭合轮廓:x∈[minimum[0], maximum[0]]、 + y∈[minimum[1], maximum[1]]。 + """ + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict: + """构造最小 extrude 文档。 + + - with_draft=True 时 params 携带 draft 对象(任意非空 object 即可, + 因为 cdsl_schema.json 对 draft 没有子字段约束); + - with_draft=False 时完全不写 draft 字段(回归护栏用)。 + - atomic_id 可切换 extrude_add_blind / extrude_add_two_sided / + extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。 + """ + params: dict = {"distance_mm": 10.0} + if with_draft: + params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "draft-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [{ + "id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base", + "params": params, + }], + } + + +def _validate_against_cdsl_schema(doc: dict) -> None: + """对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。""" + jsonschema.validate(instance=doc, schema=_SCHEMA) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class ExtrudeDraftContractTests(unittest.TestCase): + """draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。""" + + def test_draft_extrude_is_explicitly_blocked(self) -> None: + """主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。 + + 修复前(当前):capabilities 对 extrude 只检查 end_condition, + draft 字段完全无人过问 → analyze 报 runtime_eligible=True, + rebuild 静默产出直壁实体 → 本测试红灯。 + 修复后:capabilities 报 unsupported_draft blocker → + runtime_eligible=False → 绿灯。 + """ + analysis = analyze_cdsl(_extrude_cdsl(with_draft=True)) + + self.assertFalse(analysis.runtime_eligible) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + + def test_draft_free_extrude_stays_eligible(self) -> None: + """回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。 + + 修复前/修复后均应通过。这条测试防止我们把检查加过头—— + 一旦把"携带 draft"错写成"所有 extrude 都拒绝",护栏会变红。 + """ + analysis = analyze_cdsl(_extrude_cdsl(with_draft=False)) + + self.assertTrue(analysis.runtime_eligible) + + def test_draft_passes_machine_schema(self) -> None: + """文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。 + + cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在 + 能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许 + + 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后 + importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。 + """ + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True)) + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) + _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True)) + + def test_draft_blocks_every_extrude_atomic(self) -> None: + """覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。 + + draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口, + 必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind + 同时生效,而不是只修了某一个。 + """ + for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): + with self.subTest(atomic_id=atomic_id): + analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True)) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + + def test_draft_rebuild_fails_loudly_not_silently(self) -> None: + """端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。 + + 修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体, + volume > 0,但几何是**没有拔模角的直壁**——静默错误。 + 修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛 + ValueError(feature is not runtime eligible: unsupported_draft); + analyze_document 报告 runtime_eligible=False 且不 built—— + 批量重建不会被静默污染。 + """ + # 1) strict 重建直接抛错(大声失败)。 + with self.assertRaisesRegex(ValueError, "unsupported_draft"): + with tempfile.TemporaryDirectory() as directory: + rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step") + + # 2) 批量层 analyze_document 报告不可执行且不产出 STEP。 + cdsl = _extrude_cdsl(with_draft=True) + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "part.step" + report = _analyze_inline(cdsl, out_step) + self.assertFalse(report["runtime_eligible"]) + self.assertFalse(report.get("built", False)) + + +def _analyze_inline(cdsl: dict, out_step: Path) -> dict: + """把 cdsl 写入临时 json 后走 analyze_document(与批量层同一入口)。 + + analyze_document 接受文件路径,这里把内存中的文档落地成临时文件, + 保证测试走的路径与 batch_rebuild 完全一致。 + """ + import tempfile as _tf + + with _tf.TemporaryDirectory() as directory: + source = Path(directory) / "draft.cdsl.json" + source.write_text(json.dumps(cdsl), encoding="utf-8") + return analyze_document(source, out_step=out_step) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From c84fc3ade46fe8e5665ec44982271bc256a07de1 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 15:19:24 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix(engine):=20=E6=8E=A5=E5=8F=97=20hole?= =?UTF-8?q?=5Fwizard=20=E7=9A=84=20thread=20=E8=A3=85=E9=A5=B0=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E5=85=89=E6=BB=91=E5=AD=94=E9=99=8D=E7=BA=A7=E6=89=A7?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/capabilities.py | 8 +- backend/engine/cdsl_engine/runtime.py | 12 +- .../tests/test_engine_hole_thread_contract.py | 263 ++++++++++++++++++ 3 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_engine_hole_thread_contract.py 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() -- 2.52.0 From 625b296e06ed727b50221095f8b2c00ac7130dc8 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 15:40:58 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat(engine):=20=E6=94=AF=E6=8C=81=20up?= =?UTF-8?q?=5Fto=5Fsurface=20=E9=9D=9E=E5=9D=87=E5=8C=80=20profile=20?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E6=8B=89=E4=BC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 up_to_surface 终止条件新增裁剪语义,通过穿透拉伸与目标面布尔求交保留可达材料,修复非均匀相交时错误拒绝特征的问题。 --- .../engine/cdsl_engine/build123d_adapter.py | 42 +++- backend/engine/cdsl_engine/runtime.py | 78 +++++-- .../tests/test_engine_extent_trim_contract.py | 209 ++++++++++++++++++ 3 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_engine_extent_trim_contract.py diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 511f117d..fe8558fd 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -151,6 +151,43 @@ class Build123dGeometryAdapter: # 沿给定方向向量拉伸一个面,生成实体。 return Solid.extrude(face, _vector(direction)) + @staticmethod + def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any: + """Extrude the profile to the target face, trimming unreached regions. + + Issue #5: when a profile intersects the up_to_surface target + non-uniformly (part of the profile reaches the face, part hangs + outside it), a plain vector extrusion is wrong. The CAD semantics is + to keep only the material between the profile and the target. We + pierce the profile through the target, push the target face backward + by the same margin to build a slab, and keep their boolean common + (intersection) as the trimmed solid. + """ + # 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中 + # 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。 + 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: + raise ValueError("extent target is not reached by the profile") + margin = max(distances) + 2.0 + # 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。 + # build123d 的布尔交方法名是 intersect(不是 OCC 的 common), + # 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。 + pierced = Solid.extrude(face, unit * margin) + slab = Solid.extrude(target, -unit * margin) + trimmed = pierced.intersect(slab) + if isinstance(trimmed, ShapeList): + members = list(trimmed) + # build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。 + trimmed = members[0] if len(members) == 1 else Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue] + if trimmed is None or (hasattr(trimmed, "is_empty") and trimmed.is_empty()): + raise ValueError("extent target produced an empty trimmed solid") + return trimmed + @staticmethod def body_center(body: Any) -> Vector3: # 取主体包围盒的中心坐标,作为体心的近似。 @@ -236,8 +273,9 @@ class Build123dGeometryAdapter: return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) @staticmethod - def fuse(body: Any | None, solid: Solid) -> Any: + def fuse(body: Any | None, solid: Any) -> Any: # 布尔并:没有既有主体时,直接以该实体作为新主体。 + # 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。 return solid if body is None else body.fuse(solid) @staticmethod diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 67ac1ed0..1f502218 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -44,6 +44,21 @@ class FeatureExecutionError(RuntimeError): self.detail = detail +@dataclass(frozen=True) +class ExtentVector: + """Single-directional extrusion displacement for one profile face. + + ``trim_to`` stays ``None`` for an exact vector extrusion. When set, the + extent means "extrude until the target face, trimming any profile region + that does not reach it" (up_to_surface trim semantics, issue #5). The + piercing distance is computed inside the adapter, so ``vector`` only + supplies the direction. + """ + + vector: Vector3 + trim_to: Any | None = None + + class AtomicExecutor(Protocol): atomic_id: str @@ -62,6 +77,7 @@ class GeometryAdapter(Protocol): def body_geometry(self, body: Any) -> dict[str, Any]: ... def faces_for_sketch(self, sketch: dict[str, Any]) -> list[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: ... def fuse(self, body: Any | None, solid: Any) -> Any: ... def cut(self, body: Any, tool: Any) -> Any: ... @@ -163,7 +179,7 @@ def _targeted_extent_vector( *, end_condition: dict[str, Any] | None = None, offset_mm: float | None = None, -) -> Vector3: +) -> ExtentVector: if session.body is None: raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) if condition == "through_next": @@ -197,8 +213,20 @@ def _targeted_extent_vector( try: distance = session.adapter.uniform_intersection_distance(target, faces, direction) except ValueError as error: - code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached" - raise FeatureExecutionError(code, str(error), extent=condition) from error + message = str(error) + code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" + if condition == "up_to_surface": + # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 + # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 + # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 + # extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达 + # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 + # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 + # "not reached",保持显式拒绝。 + # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 + # 裁剪体层,仍保持显式拒绝。 + return ExtentVector(vector_scale(direction, 1.0), trim_to=target) + raise FeatureExecutionError(code, message, extent=condition) from error if condition == "offset_from_surface": offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0)) distance -= offset @@ -208,7 +236,7 @@ def _targeted_extent_vector( "Offset distance reaches or passes the target surface", extent=condition, offset_mm=offset, ) - return vector_scale(direction, distance) + return ExtentVector(vector_scale(direction, distance)) def _side_extent_vectors( @@ -219,7 +247,7 @@ def _side_extent_vectors( *, end_condition: dict[str, Any], distance_mm: float, -) -> list[Vector3]: +) -> list[ExtentVector]: """Resolve one directional extent without borrowing the opposite side. ``extrude_add_two_sided`` calls this once for each independently captured @@ -231,17 +259,22 @@ def _side_extent_vectors( if condition == "blind": if distance <= 0: raise ValueError("blind extent requires distance_mm > 0") - return [vector_scale(direction, distance)] + return [ExtentVector(vector_scale(direction, distance))] if condition == "mid_plane": if distance <= 0: raise ValueError("mid_plane extent requires distance_mm > 0") - return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)] + return [ + ExtentVector(vector_scale(direction, distance / 2)), + ExtentVector(vector_scale(direction, -distance / 2)), + ] if condition == "through_all": if session.body is None: if distance <= 0: raise ValueError("through_all on an initial feature has no body and no fallback distance") - return [direction * distance] - return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)] + # 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组 + # 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。 + return [ExtentVector(vector_scale(direction, distance))] + return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))] if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}: return [ _targeted_extent_vector( @@ -257,7 +290,7 @@ def _extent_vectors( faces: list[Any], sketch: dict[str, Any], session: ExecutionSession, -) -> list[Vector3]: +) -> list[ExtentVector]: params = node.params normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") if bool(params.get("reverse")): @@ -285,16 +318,19 @@ def _extent_vectors( # against. The source must provide a usable blind component. if distance <= 0: raise ValueError("through_all on an initial feature has no body and no fallback distance") - return [vector_scale(normal, distance)] + return [ExtentVector(vector_scale(normal, distance))] span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0 if condition == "through_all": - return [vector_scale(normal, span)] + return [ExtentVector(vector_scale(normal, span))] if condition == "through_all_both": - return [vector_scale(normal, span), vector_scale(normal, -span)] + return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))] # Through-all-and-blind is represented by a through direction plus # its captured opposite blind direction when available. reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) - return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))] + return [ + ExtentVector(vector_scale(normal, span)), + ExtentVector(vector_scale(normal, -(reverse_distance or span))), + ] return _side_extent_vectors( node, faces, normal, session, end_condition=end_condition, distance_mm=distance, ) @@ -337,9 +373,17 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, - # 再对每个面沿每个向量做拉伸,得到实体列表。 - vectors = _extent_vectors(node, faces, selected_sketch, session) - solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors] + # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 + # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 + # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 + extents = _extent_vectors(node, faces, selected_sketch, session) + solids: list[Any] = [] + for face in faces: + for extent in extents: + if extent.trim_to is None: + solids.append(session.adapter.extrude(face, extent.vector)) + else: + solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) else: # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 axis = _revolve_axis(node, session) diff --git a/backend/tests/test_engine_extent_trim_contract.py b/backend/tests/test_engine_extent_trim_contract.py new file mode 100644 index 00000000..7677598c --- /dev/null +++ b/backend/tests/test_engine_extent_trim_contract.py @@ -0,0 +1,209 @@ +"""#5 高级终止条件:up_to_surface 非均匀 profile 必须裁剪而非拒绝。 + +中文说明 +-------- +这个文件在测试什么(issue #5「高级终止条件要求整张 profile 同一距离」的回归测试): + + 1. 背景:up_to_surface(拉伸到面)等终止条件用"profile 采样点射线求交" + 判定终止距离。修复前,只要 profile 与目标面**非均匀相交**——一部分 + 采样点到达目标、一部分悬空(profile 超出目标面范围),或目标面相对 + profile 倾斜——runtime 就抛 non_uniform_extent_target 拒绝整个特征, + 零件无法重建。而 CAD 的标准语义是**裁剪**:保留"从 profile 到目标面" + 的可达材料,切掉悬空部分。 + 修复后:adapter 新增 extrude_trimmed(穿透拉伸 + 目标面体层布尔求交), + runtime 的 _targeted_extent_vector 在 up_to_surface 非均匀时返回 + 带 trim_to 的 ExtentVector,_shape_from_primary 改用裁剪拉伸。 + up_to_vertex / up_to_body / offset_from_surface 没有可构造裁剪体层 + 的 face,仍保持显式拒绝(语义上无法裁剪)。 + + 2. 本测试套件把"非均匀 up_to_surface 裁剪"合同固定下来: + - 单元几何契约(adapter):斜目标面 + 水平 profile 裁剪出楔形, + 体积 = ∫斜顶 dxdy; + - 集成裁剪契约(rebuild):profile 悬空超出目标面时不再拒绝, + 悬空部分被切掉,只保留与目标面之间的材料; + - 集成回归护栏(rebuild):profile 完全落在目标面内仍走精确 + 均匀拉伸路径,体积不变(防止裁剪回退把均匀路径也改坏)。 + + 3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine + 包做端到端测试(与既有测试风格一致)。 + +函数功能一览 +------------ + _workplane(origin, normal) 构造指定原点与法向的草图工作平面。 + _rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _base_block() 构造 10×10×10 拉伸主体(体积 1000, + 顶面 z=10、范围 x/y ∈ [-5, 5])。 + _top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z) + 的几何快照,作为 up_to_surface 的 reference。 + ExtentTrimContractTests 见各测试方法 docstring。 +""" + +from __future__ import annotations + +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")) + +from cdsl_engine.build123d_adapter import Build123dGeometryAdapter # noqa: E402 +from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 + + +try: + from build123d import Face, Plane, Vector # noqa: F401 + _HAS_BUILD123D = True +except ImportError: + _HAS_BUILD123D = False + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _workplane(*, origin: list[float], normal: list[float]) -> dict: + """构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。""" + return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal} + + +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、范围 x/y ∈ [-5, 5]。""" + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "extent-trim-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]), + "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 _top_face_selector(baseline: dict) -> dict: + """从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。 + + 这个快照被 TopologyRegistry 用来做几何等价匹配,从而把 up_to_surface + 的 reference 解析到重建主体上的真实 Face。 + """ + return 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 + ) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class ExtentTrimContractTests(unittest.TestCase): + """up_to_surface 非均匀相交「裁剪几何-行为-回归」三方合同测试。""" + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_extrude_trimmed_slanted_target_produces_wedge(self) -> None: + """单元几何契约:斜目标面 + 水平 profile 裁剪出楔形。 + + profile 是 z=0 的 10×10 矩形;目标面是斜面 z = 0.2x + 5 + (z_dir=(0.2, 0, 0.98) 的平面)。采样点沿 +z 到斜面的距离从 + 4(x=-5)到 6(x=+5)变化 → uniform_intersection_distance 必然 + 判定非均匀。裁剪结果应是顶面贴斜面的楔形,体积 = + ∫∫ (0.2x + 5) dxdy = 5 × 100 = 500。修复前该场景直接抛 + non_uniform_extent_target(无实体可断言),本测试锁死裁剪几何。 + """ + profile = Face.make_rect(10, 10) # 中心在原点,x/y ∈ [-5, 5],z=0 + slanted = Face.make_rect(20, 20, Plane(origin=(0, 0, 5), z_dir=(0.2, 0, 0.98))) + + trimmed = Build123dGeometryAdapter.extrude_trimmed(profile, slanted, (0, 0, 1)) + + self.assertAlmostEqual(trimmed.volume, 500.0, places=5) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None: + """集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。 + + baseline 是 10×10×10 主体(顶面 z=10、范围 [-5, 5]²)。第二个 add + 特征在 z=12 平面、法向 -z,profile 为 16×16 矩形(x/y ∈ [-8, 8], + 大部分悬空在顶面范围之外)。up_to_surface 目标 = 顶面: + - 中心采样点沿 -z 命中顶面(距离 2); + - 角落采样点(x/y = ±8)在顶面范围外,射线不命中 → 非均匀。 + 修复前抛 non_uniform_extent_target;修复后裁剪出 + x/y ∈ [-5, 5]、z ∈ [10, 12] 的 10×10×2 体块,总体积 = 1000 + 200。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = _top_face_selector(baseline) + + with_cap = deepcopy(base) + with_cap["geometry"]["sketches"].append({ + "id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]), + "profile": _rectangle([-8, -8], [8, 8]), + }) + with_cap["features"].append({ + "id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "cap", + "params": { + "distance_mm": 0, + "end_condition": {"type": "up_to_surface", "reference": top_face}, + }, + }) + rebuilt = rebuild_cdsl(with_cap, root / "trimmed.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 200, places=5) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_up_to_surface_uniform_profile_keeps_exact_distance(self) -> None: + """集成回归护栏:profile 完全落在目标面内仍走精确均匀拉伸。 + + 与上一个测试同构,但 profile 缩到 6×6(x/y ∈ [-3, 3]),完全落在 + 顶面 [-5, 5]² 范围内 → 所有采样点沿 -z 都命中且距离一致(2)→ + 保持原精确拉伸路径(不触发裁剪),体积 = 1000 + 36×2 = 1072。 + 防止裁剪回退把均匀路径也改坏。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = _top_face_selector(baseline) + + with_cap = deepcopy(base) + with_cap["geometry"]["sketches"].append({ + "id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]), + "profile": _rectangle([-3, -3], [3, 3]), + }) + with_cap["features"].append({ + "id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "cap", + "params": { + "distance_mm": 0, + "end_condition": {"type": "up_to_surface", "reference": top_face}, + }, + }) + rebuilt = rebuild_cdsl(with_cap, root / "exact.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 36 * 2, places=5) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From e5c71d07fa80b77cccbf8bf38a535100160b679c Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 16:04:34 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix(engine):=20=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E5=AE=BF=E4=B8=BB=E9=9D=A2=E4=B8=8E=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E9=9D=A2=E7=9A=84=20pattern=20=E5=8F=98=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许 hole_wizard 的 face selector 宿主面及 up_to_surface 的 face reference 终止面参与 pattern 变换,同时保留 mirror 等逐实例拓扑依赖的阻塞,并补充回归测试 --- backend/engine/cdsl_engine/capabilities.py | 40 ++- .../test_engine_pattern_transform_contract.py | 310 ++++++++++++++++++ 2 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_engine_pattern_transform_contract.py diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index edcee86c..9c0ff2de 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -65,28 +65,58 @@ def _has_explicit_host_frame(params: dict[str, Any]) -> bool: return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")) +def _is_host_face_source(source: FeaturePlanNode) -> bool: + """Whether a ``face`` selector on the source is a fixed host face. + + A patterned hole keeps its host face: the instance positions travel with + the pattern (``_translated_node`` shifts ``positions``) while the face + itself is resolved unchanged, so the face selector can stay untouched + (issue #6). A face selector on anything else (for example a fillet + selecting a face) would need per-instance edge geometry and must stay + blocked. + """ + return source.atomic_id in _HOLE_ATOMICS or ( + isinstance(source.params.get("host_face"), dict) + and source.params.get("host_face", {}).get("kind") == "face" + ) + + def pattern_transform_blocker(source: FeaturePlanNode) -> str | None: """Return the selector dependency that cannot be transformed exactly. Selectors fully captured by explicit coordinate data (a revolve axis with origin/direction, or a host face with a complete world frame) are transformed together with each patterned instance and are not blockers. - Any remaining topology selector and selector-dependent extent stays - blocked until its geometry transform contract is implemented. + A patterned hole's host face and an up_to_surface extrusion's target face + are *fixed* body faces rather than instance geometry: positions and + profiles travel with the instance while the face itself is resolved + unchanged (issue #6). Selectors that must follow the instance but cannot + be translated (edges, vertices, …) stay blocked until their geometry + transform contract is implemented. """ for selector in source.selectors or (): if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")): # The axis is coordinate data; _translated_node shifts its origin. continue - if selector.get("kind") == "face" and _has_explicit_host_frame(source.params): - # The host face carries a complete world frame; positions stay local. + if selector.get("kind") == "face" and _is_host_face_source(source): + # 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions + # 平移决定(_translated_node),selector 原样保留即可正确 resolve。 continue return "feature selector" host = source.params.get("host_face") if host is not None and not _has_explicit_host_frame(source.params): - return "host face selector" + # 无 frame 的孔:宿主面以 face selector 形式给出(host_face 自身或 + # selectors 列表)→ 上面的 face 分支已放行;其它形态(无 frame 也 + # 非 face selector)仍阻塞。 + if not (isinstance(host, dict) and host.get("kind") == "face"): + return "host face selector" end_condition = source.params.get("end_condition") or {} if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict): + # up_to_surface / offset_from_surface 的终止面是主体上的固定面: + # 不随实例平移,reference 原样保留即可正确 resolve(#5 裁剪已支持 + # 非均匀相交)。顶点/主体目标无法构造"固定终止面",仍显式阻塞。 + if end_condition["reference"].get("kind") == "face": + return None return "extent target selector" return None diff --git a/backend/tests/test_engine_pattern_transform_contract.py b/backend/tests/test_engine_pattern_transform_contract.py new file mode 100644 index 00000000..ac721caa --- /dev/null +++ b/backend/tests/test_engine_pattern_transform_contract.py @@ -0,0 +1,310 @@ +"""#6 Pattern selector 变换:固定终止面/宿主面的 pattern source 必须放行。 + +中文说明 +-------- +这个文件在测试什么(issue #6「Pattern 严禁 source selector/host selector/ +extent selector」的回归测试): + + 1. 背景:pattern_transform_blocker 原本只放行"带显式 host frame"的孔 + 与"带显式 axis 坐标"的旋转轴。真实语料里绝大多数 pattern source + 的依赖是**主体上的固定面**,而非随实例移动的几何: + - hole_wizard 的宿主面是 face selector(带几何快照、无 frame), + 孔位置由 positions 平移决定(_translated_node),宿主面本身 + resolve 原面即可正确打孔; + - up_to_surface 拉伸的终止面是 face reference,终止面不随实例 + 平移(CAD 阵列语义:每个实例拉伸到同一终止面),且 #5 修复后 + 非均匀相交可裁剪。 + 修复前这些 source 一律报 unsupported_pattern_selector_transform, + 669 个 pattern 文档里 121 个 source 被整体拒绝。 + 修复后:face selector(孔宿主面)与 face reference(终止面)放行, + 由运行时 resolve 原面执行;edge/vertex selector(需逐实例变换但 + 无法平移)与 mirror-as-source(镜像面需逐实例平移,架构不支持) + 仍保持显式阻塞,避免产出错误几何。 + + 2. 本测试套件把"固定面依赖放行、逐实例拓扑依赖仍阻塞"固定下来: + - analyze 契约:无 frame 孔(face selector)→ executable; + - analyze 契约:up_to_surface 拉伸(face reference)→ executable; + - 回归护栏:mirror-as-source(plane selector)→ 仍阻塞 + (unsupported_pattern_selector_transform 保留); + - 几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱; + - 几何契约:up_to_surface 拉伸 pattern → 源块 + 源拉伸 + 实例 + 拉伸(实例部分悬空被 #5 裁剪)。 + + 3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine + 包做端到端测试(与既有测试风格一致)。 + +函数功能一览 +------------ + _workplane(origin, normal) 构造指定原点与法向的草图工作平面。 + _rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _base_block() 构造 10×10×10 拉伸主体(体积 1000, + 顶面 z=10、范围 x/y ∈ [-5, 5])。 + _top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z) + 的几何快照,作为宿主面 / 终止面 reference。 + PatternTransformContractTests 见各测试方法 docstring。 +""" + +from __future__ import annotations + +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")) + +from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402 + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _workplane(*, origin: list[float], normal: list[float]) -> dict: + """构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。""" + return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal} + + +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、范围 x/y ∈ [-5, 5]。""" + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "pattern-transform-contract", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]), + "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 _top_face_selector(baseline: dict) -> dict: + """从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。 + + 这个快照被 TopologyRegistry 用来做几何等价匹配,从而把宿主面 / 终止面 + reference 解析到重建主体上的真实 Face。 + """ + return 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 + ) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class PatternTransformContractTests(unittest.TestCase): + """pattern source 固定面依赖「能力边界-几何-护栏」三方合同测试。""" + + def test_pattern_replays_face_selector_hole_is_eligible(self) -> None: + """analyze 契约:无 frame 孔(face selector 宿主面)→ executable。 + + 宿主面是主体上的固定面:实例孔位置由 positions 平移决定,宿主面 + resolve 原面即可。修复前 capabilities 把它当成"无法变换的 feature + selector"报 unsupported_pattern_selector_transform → 整个零件 + runtime_eligible=False → 本测试红灯。 + """ + base = _base_block() + base["features"].extend([ + { + "id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"], + "params": { + "hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0, + "end_condition": {"type": "blind"}, + "positions": [{"mm": [0.0, 0.0, 10.0]}], + "host_face": { + "kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10], + "center_mm": [0, 0, 10], + "normal": [0, 0, 1], + "surface_type": "plane"}, + }, + }, + }, + { + "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"], + "params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0], + "spacing_1_mm": 4, "pattern_count_1": 2}, + }, + ]) + analysis = analyze_cdsl(base) + pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat") + self.assertTrue(pattern.executable) + self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers]) + + def test_pattern_replays_up_to_surface_extrusion_is_eligible(self) -> None: + """analyze 契约:up_to_surface 拉伸(face reference 终止面)→ executable。 + + 终止面是主体上的固定面,不随实例平移(CAD 阵列语义);#5 修复后 + 非均匀相交走裁剪。修复前 reference 一律返回 extent target selector + 阻塞。本测试锁死该依赖被放行。 + """ + base = _base_block() + base["geometry"]["sketches"].append({ + "id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]), + "profile": _rectangle([-3, -3], [3, 3]), + }) + base["features"].extend([ + { + "id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "cap", + "params": { + "distance_mm": 0, + "end_condition": {"type": "up_to_surface", "reference": { + "kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10], + "center_mm": [0, 0, 10], + "normal": [0, 0, 1], + "surface_type": "plane"}, + }}, + }, + }, + { + "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"], + "params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0], + "spacing_1_mm": 6, "pattern_count_1": 2}, + }, + ]) + analysis = analyze_cdsl(base) + pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat") + self.assertTrue(pattern.executable) + self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers]) + + def test_mirror_source_stays_blocked(self) -> None: + """回归护栏:mirror-as-source(plane selector)仍阻塞。 + + pattern_mirror 的镜像面是 reference_plane 引用;线性阵列重放镜像 + 特征需要把镜像面逐实例平移,当前执行器没有实例变换通道,放行会 + 产出"所有实例重合"的错误几何。因此 mirror 特征作为 pattern source + 必须继续保持 unsupported_pattern_selector_transform。 + """ + base = _base_block() + base["features"].extend([ + { + "id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [], + "params": {"plane": _workplane(origin=[0, 0, 0], normal=[0, 1, 0])}, + }, + { + "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "plane_ctx"], + "params": {"source_feature_ids": ["base_add"], "mirror_plane": { + "kind": "plane", "stable_id": "plane-1", "source": "solidworks", + "confidence": 1, "owner_feature_id": "plane_ctx", + }}, + "selectors": [{ + "kind": "plane", "stable_id": "plane-1", "source": "solidworks", + "confidence": 1, "owner_feature_id": "plane_ctx", + }], + }, + { + "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["mirror"], + "params": {"source_feature_ids": ["mirror"], "direction_1": [1, 0, 0], + "spacing_1_mm": 10, "pattern_count_1": 2}, + }, + ]) + analysis = analyze_cdsl(base) + pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat") + self.assertFalse(pattern.executable) + self.assertIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers]) + + def test_pattern_replays_face_selector_hole_geometry(self) -> None: + """几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱。 + + baseline 10×10×10(顶面 z=10)。hole_wizard 宿主面是 face selector + (无 frame),positions=[(0,0,10)] 直径 2 深 10(贯穿)。pattern 沿 + +x 间距 4 → 实例孔在 (4,0,10),与源孔不重叠。期望体积 = + 1000 − 2×π×1²×10。修复前 capabilities 拒绝整个零件,本测试红灯。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = _top_face_selector(baseline) + + with_holes = deepcopy(base) + with_holes["features"].extend([ + { + "id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"], + "params": { + "hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0, + "end_condition": {"type": "blind"}, + "positions": [{"mm": [0.0, 0.0, 10.0]}], + "host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"]}, + }, + }, + { + "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"], + "params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0], + "spacing_1_mm": 4, "pattern_count_1": 2}, + }, + ]) + rebuilt = rebuild_cdsl(with_holes, root / "patterned-holes.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 - 2 * math.pi * 10, places=5) + + def test_pattern_replays_up_to_surface_extrusion_geometry(self) -> None: + """几何契约:up_to_surface 拉伸 pattern → 源 + 实例(实例悬空被裁剪)。 + + baseline 10×10×10(顶面 z=10,范围 [-5,5]²)。源 cap:z=12 平面、 + 法向 -z,profile 6×6(完全在顶面内)→ 均匀拉伸到顶面,体积 6²×2=72。 + pattern 沿 +x 间距 6 → 实例 profile 落在 x ∈ [3,9],与顶面交集为 + x ∈ [3,5]、y ∈ [-3,3] → 部分悬空 → #5 裁剪,体积 2×6×2=24。 + 期望总体积 = 1000 + 72 + 24 = 1096。 + """ + base = _base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + top_face = _top_face_selector(baseline) + + with_cap = deepcopy(base) + with_cap["geometry"]["sketches"].append({ + "id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]), + "profile": _rectangle([-3, -3], [3, 3]), + }) + with_cap["features"].extend([ + { + "id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], + "sketch_id": "cap", + "params": { + "distance_mm": 0, + "end_condition": {"type": "up_to_surface", "reference": { + "kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"], + }}, + }, + }, + { + "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"], + "params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0], + "spacing_1_mm": 6, "pattern_count_1": 2}, + }, + ]) + rebuilt = rebuild_cdsl(with_cap, root / "patterned-caps.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 9e5aa48c3d2a176c1941ca7d53f24804f41fe68a Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 18:08:54 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat(engine):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E5=AE=9E=E4=BD=93=20body=20=E4=B8=8E=20selector=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 多实体主体拆分为独立 body_id,支持前缀匹配解析 - 重建结果新增 solid_count 字段 - fillet/chamfer 后 selector stable_id 可解析到演化后继 --- .../engine/cdsl_engine/build123d_adapter.py | 10 + backend/engine/cdsl_engine/runtime.py | 20 +- backend/engine/cdsl_engine/runtime_types.py | 186 +++++++++-- .../tests/test_engine_multi_body_contract.py | 304 ++++++++++++++++++ .../tests/test_engine_selector_persistence.py | 221 +++++++++++++ 5 files changed, 716 insertions(+), 25 deletions(-) create mode 100644 backend/tests/test_engine_multi_body_contract.py create mode 100644 backend/tests/test_engine_selector_persistence.py diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index fe8558fd..343caaaf 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -385,6 +385,16 @@ class Build123dGeometryAdapter: # 将主体导出为 STEP 文件。 export_step(body, path) + @staticmethod + def body_solids(body: Any) -> list[Any]: + # 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。 + # build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。 + try: + solids = list(body.solids()) + except Exception: + return [body] if body is not None else [] + return solids or ([body] if body is not None else []) + @staticmethod def body_geometry(body: Any) -> dict[str, Any]: # 汇总主体基本几何信息:包围盒与体积。 diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 1f502218..37097331 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -74,6 +74,7 @@ class GeometryAdapter(Protocol): """ def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... + 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 extrude(self, face: Any, direction: Vector3) -> Any: ... @@ -108,9 +109,23 @@ class ExecutionSession: active_feature_id: str = "" def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: + # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 + # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": + # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 + # 实体;单体保持 body:{feature}(与历史行为完全一致)。 self.body = body self.body_id = f"body:{feature_id}" - self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id)) + solids = self.adapter.body_solids(body) + if len(solids) <= 1: + self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id)) + else: + for index, solid in enumerate(solids): + member_id = f"{self.body_id}:{index}" + self.topology.replace_body_topology( + feature_id, member_id, + self.adapter.topology_records(solid, feature_id, member_id), + active_body_id=self.body_id, + ) self.topology.register(TopologyRecord( record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), @@ -967,6 +982,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - "out_step": str(out_step), "volume_mm3": float(geometry["volume_mm3"]), "bbox_mm": {"min": bbox[:3], "max": bbox[3:]}, + # #7 multi-body:重建结果里的独立实体数(Compound 成员数), + # 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。 + "solid_count": len(session.adapter.body_solids(session.body)), "feature_results": [result.as_dict() for result in session.results.values()], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "topology_records": [record.public_dict() for record in session.topology.records()], diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index def85b0a..0c2a7a4c 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -423,6 +423,11 @@ class TopologyRegistry: self._records: list[TopologyRecord] = [] self._by_feature: dict[str, list[TopologyRecord]] = {} self._active_body_id: str | None = None + # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer + # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 + # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 + # stable_id 引用可以解析到 active body 内的新形态。 + self._successors: dict[str, list[str]] = {} def register(self, record: TopologyRecord) -> None: self._records.append(record) @@ -446,7 +451,10 @@ class TopologyRegistry: self.register(record) return record - def replace_body_topology(self, feature_id: str, body_id: str, records: Iterable[TopologyRecord]) -> None: + def replace_body_topology( + self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], + *, active_body_id: str | None = None, + ) -> None: """Record a fresh B-rep snapshot after a feature mutates the body. OCC topology object identity is invalidated by most body mutations. @@ -455,11 +463,21 @@ class TopologyRegistry: object has one geometrically equivalent predecessor. A changed or split object intentionally becomes owned by this feature instead of being guessed as belonging to an older one. + + ``active_body_id`` names the whole-body group when ``body_id`` is a + member of a multi-solid body (issue #7): the group id keeps the next + mutation's predecessor lookup scoped to every solid of the previous + body, while each member keeps its own ``body:{feature}:{index}`` id. """ previous = [ record for record in self._records - if self._active_body_id is not None and record.body_id == self._active_body_id + if self._active_body_id is not None and record.body_id is not None + and ( + record.body_id == self._active_body_id + or record.body_id.startswith(f"{self._active_body_id}:") + ) ] + records = list(records) consumed_predecessors: set[str] = set() for record in records: predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors) @@ -477,7 +495,27 @@ class TopologyRegistry: owner_feature_ids=owners, ) ) - self._active_body_id = body_id + # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 + # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 + # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 + # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 + # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 + for prior in previous: + if prior.record_id in consumed_predecessors: + continue + candidates = sorted( + ( + (self._evolved_drift(prior, record), record.record_id) + for record in records if self._evolved_equivalent(prior, record) + ), + key=lambda item: item[0], + ) + if not candidates: + continue + best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) + if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): + self._successors[prior.record_id] = [best[1]] + self._active_body_id = active_body_id or body_id @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: @@ -555,6 +593,56 @@ class TopologyRegistry: ] return matches[0] if len(matches) == 1 else None + @staticmethod + def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: + """Endpoint drift between direction-aligned straight edges. + + Returns the minimum total endpoint drift (mm) when the two edges are + collinear straight lines (either orientation), otherwise ``None``. + """ + if prior.kind != current.kind: + return None + left, right = prior.geometry, current.geometry + if left.get("curve_type") != "line" or right.get("curve_type") != "line": + return None + if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): + return None + + def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + + def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + left_dir = _delta(left["start_mm"], left["end_mm"]) + right_dir = _delta(right["start_mm"], right["end_mm"]) + if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: + return None + cross = ( + left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], + left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], + left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], + ) + if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: + return None + same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) + reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) + return min(same_order, reversed_order) + + @classmethod + def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: + """Loose "position trajectory" equivalence used for evolved successors. + + Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), + this deliberately tolerates small endpoint drift: fillet/chamfer split a + straight edge into segments (a middle straight run plus end arcs). The + straight run keeps the same direction and stays within ``drift_mm`` of the + original edge, so it can serve as the edge's evolved successor. Uniqueness + is enforced by the caller (only a single best candidate is recorded). + """ + drift = cls._evolved_drift(prior, current) + return drift is not None and drift <= drift_mm + @staticmethod def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: try: @@ -579,8 +667,14 @@ class TopologyRegistry: for key in ("surface_type", "curve_type"): if key in selector_geometry: if record_geometry.get(key) != selector_geometry[key]: - return None - scores.append(1.0) + # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 + # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) + # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 + # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 + # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 + scores.append(0.5) + else: + scores.append(1.0) if "bbox_mm" in selector_geometry: expected = selector_geometry["bbox_mm"] actual = record_geometry.get("bbox_mm") @@ -615,7 +709,14 @@ class TopologyRegistry: owner = selector.get("owner_feature_id") candidates = [record for record in self._records if record.kind == kind] if active_body_id and kind in {"face", "edge", "vertex", "body"}: - candidates = [record for record in candidates if record.body_id == active_body_id] + # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 + # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 + # 记录(不同 feature 前缀)不会泄漏进来。 + candidates = [ + record for record in candidates + if record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ] if owner: candidates = [record for record in candidates if owner in record.owners] geometry = normalize_selector_geometry(selector.get("geometry")) @@ -632,31 +733,68 @@ class TopologyRegistry: ) stable_id = str(selector.get("stable_id") or "").strip() if stable_id: - exact = [record for record in candidates if record.record_id == stable_id] + # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 + # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 + # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body + # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 + # 几何打分流程。 + stable_records = [ + record for record in self._records + if record.kind == kind and (not owner or owner in record.owners) + ] + exact = [record for record in stable_records if record.record_id == stable_id] if len(exact) == 1: record = exact[0] - # A stable ID is only a lookup accelerator for snapshot-aware - # selectors. It cannot revive a B-rep entity whose geometric - # signature changed after an upstream rebuild. - if selector.get("snapshot_id"): - score = self._geometry_score(geometry, record.geometry) if geometry else None - if score is None or score < minimum_score: + is_active = active_body_id is None or ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ) + if not is_active: + successors = [ + candidate for candidate in stable_records + if candidate.record_id in self._successors.get(record.record_id, ()) + and ( + candidate.body_id == active_body_id + or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) + ) + ] + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: return SelectorResolution( selector=selector, - status="not_found", - candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="The stable selector record no longer matches its geometry signature", - detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + code="selector_ambiguous", + message="More than one evolved successor record satisfies the stable_id", + detail={"stable_id": stable_id, "candidate_count": len(successors)}, ), ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), - ) + if is_active: + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) if len(exact) > 1: return SelectorResolution( selector=selector, diff --git a/backend/tests/test_engine_multi_body_contract.py b/backend/tests/test_engine_multi_body_contract.py new file mode 100644 index 00000000..60bc3206 --- /dev/null +++ b/backend/tests/test_engine_multi_body_contract.py @@ -0,0 +1,304 @@ +"""#7 ExecutionSession 单 active body → 多实体 body_id 回归测试。 + +中文说明 +-------- +这个文件在测试什么(issue #7「ExecutionSession 单 active body」的回归测试): + + 1. 背景:ExecutionSession 只跟踪一个 active body(session.body / + session.body_id = body:{feature_id})。多实体零件(例如两个不相交 + 的拉伸 add,布尔并后 build123d 返回 Compound,含 ≥2 个独立 Solid) + 被当成一个 body 处理: + - 拓扑记录把 Compound 的所有面/边/顶点统一登记为一个 body_id; + - body_id 实际上变成"最后执行的特征的 id",不反映真实实体数; + - 后续特征无法精确匹配"某个实体"上的拓扑,多体信息在重建报告里 + 完全丢失。 + 修复后(#7):register_body 通过 adapter.body_solids 拆出独立实体, + 每个 Solid 一个 body:{feature}:{index},selector resolve 与拓扑继承 + 用前缀匹配整个主体;单体路径保持 body:{feature} 完全不变。 + + 2. 本测试套件把"多体可识别、单体不受影响"固定下来: + - 多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id, + rebuild 输出 solid_count=2; + - 多体 + 后续操作契约:在多体主体上打孔/切除仍 resolve 到正确实体; + - 单体护栏:单个 add → 只有 1 个实体、solid_count=1,body_id 语义 + 与修复前一致。 + + 3. sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import + cdsl_engine 包做端到端测试(与既有测试风格一致)。 + +函数功能一览 +------------ + _workplane(origin) 构造指定原点的 XY 平面草图工作平面。 + _rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _single_boss_doc() 10×10×10 单体拉伸文档(体积 1000)。 + _two_boss_doc() 两个不相交 10×10×10 拉伸(x 相距 20), + 布尔并后为 2 个独立 Solid(体积 2000)。 + MultiBodyContractTests 见各测试方法 docstring。 +""" + +from __future__ import annotations + +import math +import sys +import tempfile +import unittest +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")) + +from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _workplane(*, origin: list[float]) -> dict: + return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _single_boss_doc() -> dict: + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-body-single", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + ], + } + + +def _two_boss_doc() -> dict: + """两个不相交 10×10×10 拉伸:主体 x∈[-5,5],第二个 x∈[15,25]。""" + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-body-two", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "s2", "workplane": _workplane(origin=[20, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + ]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"], + "params": {"distance_mm": 10}, "sketch_id": "s2"}, + ], + } + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class MultiBodyContractTests(unittest.TestCase): + """多体识别「实体数-拓扑归属-单体护栏」三方合同测试。""" + + def test_two_disjoint_bosses_report_two_independent_body_ids(self) -> None: + """多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id。 + + 修复前 body_id 是"每特征一 id"(body:add_1 / body:add_2),Compound + 的所有面都登记为 body:add_2,无法区分独立实体。修复后 register_body + 为每个 Solid 分配 body:{feature}:{index},最后主体应同时包含 + body:add_2:0 与 body:add_2:1 的拓扑记录。 + """ + cdsl = _two_boss_doc() + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(cdsl, Path(directory) / "two.step") + body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")}) + self.assertIn("body:add_2:0", body_ids) + self.assertIn("body:add_2:1", body_ids) + # 两个成员都要有可 resolve 的面记录(而不是只有整体 body 记录)。 + member_faces = { + r["body_id"] for r in rebuilt["topology_records"] + if r["kind"] == "face" and r.get("body_id", "").startswith("body:add_2:") + } + self.assertEqual(member_faces, {"body:add_2:0", "body:add_2:1"}) + self.assertAlmostEqual(rebuilt["volume_mm3"], 2000.0, places=5) + + def test_rebuild_reports_solid_count(self) -> None: + """多体契约:rebuild 输出 solid_count 反映独立实体数。""" + with tempfile.TemporaryDirectory() as directory: + two = rebuild_cdsl(_two_boss_doc(), Path(directory) / "two.step") + one = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step") + self.assertEqual(two["solid_count"], 2) + self.assertEqual(one["solid_count"], 1) + + def test_single_body_keeps_legacy_body_id(self) -> None: + """单体护栏:单个 add → 1 个实体,body_id 语义与修复前一致。 + + 防止把多体拆分做成"所有主体都拆":单体主体必须保持 + body:{feature}(无 :index 后缀),后续 selector 行为不变。 + """ + with tempfile.TemporaryDirectory() as directory: + rebuilt = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step") + body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")}) + self.assertEqual(body_ids, ["body:add_1"]) + face_ids = { + r["body_id"] for r in rebuilt["topology_records"] + if r["kind"] == "face" and r.get("body_id") + } + self.assertEqual(face_ids, {"body:add_1"}) + + def test_cut_on_multi_body_mutates_only_the_intersected_solid(self) -> None: + """多体 + 后续操作契约:多体主体上切除仍 resolve 到正确实体。 + + 两个不相交 box(各 1000)。在第一个 box(x∈[-5,5])顶面打贯穿孔 + (r=1、深 10):只有第一个 box 被切,第二个 box(x∈[15,25])不受 + 影响。期望体积 = 2000 − π×1²×10,且 STEP 仍含 2 个 Solid。 + """ + base = _two_boss_doc() + 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 + and abs(item["geometry"]["center_mm"][0]) < 1.0 + ) + with_cut = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-body-cut", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "s2", "workplane": _workplane(origin=[20, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "cut", "workplane": _workplane(origin=[0, 0, 0]), + "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}}, + ]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"], + "params": {"distance_mm": 10}, "sketch_id": "s2"}, + {"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"], + "params": {"distance_mm": 10}, "sketch_id": "cut"}, + ], + } + rebuilt = rebuild_cdsl(with_cut, root / "cut.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5) + self.assertEqual(rebuilt["solid_count"], 2) + # 最后主体(cut 后)仍是 2 个独立实体,每个都有拓扑记录。 + member_ids = { + r["body_id"] for r in rebuilt["topology_records"] + if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_1:") + } + self.assertEqual(member_ids, {"body:cut_1:0", "body:cut_1:1"}) + + def test_face_selector_resolves_on_multi_body_via_prefix_matching(self) -> None: + """多体 + selector resolve 契约:face selector 经前缀匹配命中正确实体。 + + 这是 resolve 前缀匹配的哨兵测试:两个不相交 box 合并为 Compound 后, + 拓扑记录属于 body:add_2:0 / body:add_2:1。若 resolve 仍用精确 body_id + 过滤(active_body_id=body:add_2 不匹配任何记录),host face 会解析失败; + 只有前缀匹配才能让 hole 的宿主面命中第一个 box 的顶面。体积 = 2000 − 10π + 且 box2(x∈[15,25])不受影响,证明解析到的确实是正确实体。 + """ + base = _two_boss_doc() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + # 第一个 box 的顶面:center ≈ (0, 0, 10)。 + 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 + and abs(item["geometry"]["center_mm"][0]) < 1.0 + ) + with_hole = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-body-hole", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "s2", "workplane": _workplane(origin=[20, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + ]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"], + "params": {"distance_mm": 10}, "sketch_id": "s2"}, + {"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["add_2"], + "params": { + "hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0, + "end_condition": {"type": "blind"}, + "positions": [{"mm": [0.0, 0.0, 10.0]}], + "host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", + "confidence": 1, "geometry": top_face["geometry"]}, + }}, + ], + } + rebuilt = rebuild_cdsl(with_hole, root / "hole.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5) + self.assertEqual(rebuilt["solid_count"], 2) + + def test_multi_body_collapse_reverts_to_flat_body_id(self) -> None: + """转换护栏:多体塌缩回单体后 body_id 恢复单体制(无 :index 后缀)。 + + 两个 box 合并为多体后,用大切除把第二个 box 整体切掉 → 主体恢复为 + 单个 Solid → register_body 应回到 body:{feature}(flat),后续 + selector 行为与普通单体零件完全一致。 + """ + base = _two_boss_doc() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with_cut = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "multi-body-collapse", "meta": {"unit": "mm"}, + "geometry": {"sketches": [ + {"id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "s2", "workplane": _workplane(origin=[20, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5])}, + {"id": "cut", "workplane": _workplane(origin=[14.5, 0, 0]), + "profile": _rectangle([0, -5.5], [11, 5.5])}, + ]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"], + "params": {"distance_mm": 10}, "sketch_id": "s2"}, + {"id": "cut_2", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"], + "params": {"distance_mm": 10}, "sketch_id": "cut"}, + ], + } + rebuilt = rebuild_cdsl(with_cut, root / "collapse.step") + + self.assertAlmostEqual(rebuilt["volume_mm3"], 1000.0, places=5) + self.assertEqual(rebuilt["solid_count"], 1) + # 塌缩回单体:最后主体(body:cut_2)的面全部属于 flat 的 body_id + # (无 :index 成员后缀)——而不是像多体时那样带 body:cut_2:0/:1。 + # (registry 会保留历史快照供语义继承,因此只断言最后主体的记录。) + cut_faces = { + r["body_id"] for r in rebuilt["topology_records"] + if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_2") + } + self.assertEqual(cut_faces, {"body:cut_2"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_engine_selector_persistence.py b/backend/tests/test_engine_selector_persistence.py new file mode 100644 index 00000000..d3600470 --- /dev/null +++ b/backend/tests/test_engine_selector_persistence.py @@ -0,0 +1,221 @@ +"""#8 selector 持久性:上游 fillet 消费边之后的 selector 解析回归测试。 + +中文说明 +-------- +这个文件在测试什么(issue #8「Selector 持久性」的回归测试): + + 1. 背景:selector 的 stable_id 与几何签名来自特征执行前的 B-rep。上游 + fillet/chamfer 会重建被选中边附近的拓扑: + - 被 fillet 直接选中的边会被双端圆角、拆分成"两条等距直段 + 两段 + 圆弧",几何上不存在唯一"同一条边"的后继(本质歧义,CAD 中该边 + 也被视为已消费); + - 与被圆角边共享端点的相邻边则只被"单端缩短",方向不变、另一端 + 端点重合——它有一个明确的演化后继。 + 修复前:相邻边的 selector 在 fillet 之后 resolve 失败(not_found), + 因为记录已迁移到新 body 且几何签名变了。 + 修复后(#8):registry 记录"位置轨迹延续"的演化后继映射 + (old_record_id -> 漂移显著最小的唯一后继),resolve 的 stable_id + 精确匹配在 active body 过滤之前执行,命中过期记录时经演化后继解析 + 到 active body 内的新形态。 + + 2. 本测试套件把"被波及边可解析、被消费边保持保守"固定下来: + - 核心契约:fillet 圆角竖直边 A 后,引用相邻底面边 B 的 selector + 仍能 resolve,B 上的二次 fillet 重建成功、体积减少; + - 护栏:引用被完整消费的边 A 的 selector 保持 not_found(不把相邻 + 直段误匹配为 A 的延续,确定性优先); + - 护栏:引用未被 fillet 波及的边的 selector 行为不变。 + + 3. sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import + cdsl_engine 包做端到端测试(与既有测试风格一致)。 + +函数功能一览 +------------ + _workplane(origin) 构造指定原点的 XY 平面草图工作平面。 + _rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。 + _boss_doc() 10×10×10 单体拉伸文档(体积 1000)。 + _baseline_edges(...) 从基线重建里挑出 A/B/C 三条边并构造 selector。 + SelectorPersistenceTests 见各测试方法 docstring。 +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +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")) + +from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 + + +# --------------------------------------------------------------------------- +# 测试夹具 +# --------------------------------------------------------------------------- + + +def _workplane(*, origin: list[float]) -> dict: + return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]} + + +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + +def _boss_doc() -> dict: + return { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "selector-persist", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + ], + } + + +def _baseline_edges(): + """从基线重建里挑出测试用的三条边。 + + A = 竖直边 [-5,-5,0]→[-5,-5,10](被 fillet 直接选中,将被完整消费); + B = 底面边 [-5,-5,0]→[5,-5,0](与 A 共享端点 [-5,-5,0],被单端缩短); + C = 竖直边 [5,5,0]→[5,5,10](远离 A/B,完全不受 fillet 影响)。 + """ + with tempfile.TemporaryDirectory() as directory: + baseline = rebuild_cdsl(_boss_doc(), Path(directory) / "base.step") + + def _edge(match) -> dict: + return next( + item for item in baseline["topology_records"] + if item["kind"] == "edge" and match(item["geometry"]) + ) + + def _selector(record: dict) -> dict: + return {"kind": "edge", "stable_id": record["record_id"], + "source": "inferred_from_step", "confidence": 1, + "geometry": record["geometry"]} + + edge_a = _edge(lambda g: g.get("curve_type") == "line" + and g.get("start_mm") == [-5.0, -5.0, 0.0] + and g.get("end_mm") == [-5.0, -5.0, 10.0]) + edge_b = _edge(lambda g: g.get("curve_type") == "line" + and g.get("start_mm") == [-5.0, -5.0, 0.0] + and g.get("end_mm") == [5.0, -5.0, 0.0]) + edge_c = _edge(lambda g: g.get("curve_type") == "line" + and g.get("start_mm") == [5.0, 5.0, 0.0] + and g.get("end_mm") == [5.0, 5.0, 10.0]) + return _selector(edge_a), _selector(edge_b), _selector(edge_c) + + +# --------------------------------------------------------------------------- +# 测试套件 +# --------------------------------------------------------------------------- + + +class SelectorPersistenceTests(unittest.TestCase): + """#8 selector 持久性契约:被波及边可解析、被消费边保持保守。""" + + def test_consumed_adjacent_edge_selector_resolves_after_fillet(self) -> None: + """核心契约:fillet 圆角 A 后,引用相邻边 B 的 selector 仍可 resolve。 + + fillet_1 圆角竖直边 A(radius 2),把与其共享端点 [-5,-5,0] 的底面 + 边 B 单端缩短为 [-3,-5,0]→[5,-5,0]。fillet_2 用修复前捕获的 B + selector 再圆角一次(radius 1): + - 修复前:B 记录已迁移到 body:fillet_1 且几何签名变化 → not_found, + 重建抛 RuntimeExecutionError; + - 修复后:演化后继解析到 B 的缩短形态 → 重建成功且体积减少。 + """ + selector_a, selector_b, _ = _baseline_edges() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(_boss_doc(), root / "base.step") + with_fillets = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "selector-persist", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"], + "params": {"radius_mm": 2}, "selectors": [selector_a]}, + {"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"], + "params": {"radius_mm": 1}, "selectors": [selector_b]}, + ], + } + rebuilt = rebuild_cdsl(with_fillets, root / "two-fillets.step") + self.assertLess(rebuilt["volume_mm3"], baseline["volume_mm3"]) + self.assertGreater(rebuilt["volume_mm3"], 900.0) + + def test_fully_consumed_edge_selector_stays_conservative(self) -> None: + """护栏:被完整消费的边 A 的 selector 保持 not_found(确定性优先)。 + + fillet_1 圆角 A 后,A 被拆分成两条等距直段 + 两段圆弧,几何上没有 + 唯一后继(两条直段到原边的漂移并列)。此场景下 registry 不登记演化 + 映射,二次引用 A 应明确失败,而不是把相邻直段误匹配成 A 的延续。 + """ + selector_a, _, _ = _baseline_edges() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with_second = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "selector-persist", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"], + "params": {"radius_mm": 2}, "selectors": [selector_a]}, + {"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"], + "params": {"radius_mm": 1}, "selectors": [selector_a]}, + ], + } + with self.assertRaises(Exception) as caught: + rebuild_cdsl(with_second, root / "second.step") + message = str(caught.exception) + self.assertIn("No runtime topology record satisfies the selector", message) + + def test_unaffected_edge_selector_resolves_after_fillet(self) -> None: + """护栏:远离 fillet 的边 C 的 selector 行为不受影响。 + + fillet_1 圆角 A 后,引用 C([5,5,0]→[5,5,10],几何完全不变)的 + selector 通过几何打分照常 resolve,二次 fillet 重建成功。 + """ + selector_a, _, selector_c = _baseline_edges() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with_fillets = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", + "part_id": "selector-persist", "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "s1", "workplane": _workplane(origin=[0, 0, 0]), + "profile": _rectangle([-5, -5], [5, 5]), + }]}, + "features": [ + {"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 10}, "sketch_id": "s1"}, + {"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"], + "params": {"radius_mm": 2}, "selectors": [selector_a]}, + {"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"], + "params": {"radius_mm": 1}, "selectors": [selector_c]}, + ], + } + rebuilt = rebuild_cdsl(with_fillets, root / "with-c.step") + self.assertLess(rebuilt["volume_mm3"], 1000.0) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 7e199d785d39cafccd7666c86da70ac1a828389d Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 18:48:27 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(engine):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=80=92=E8=A7=92=E8=A7=92=E5=BA=A6=E5=8F=82=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/runtime.py | 15 ++- .../tests/test_engine_runtime_foundation.py | 34 +++++ docs/engine_diagnostics_test_plan.md | 124 ++++++++++++++++++ 3 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 docs/engine_diagnostics_test_plan.md diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 37097331..889ef830 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -619,12 +619,21 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur distance = float(node.params.get("distance_mm") or 0) if distance <= 0: raise ValueError("chamfer distance_mm must be > 0") - # 3. 解析目标边(支持相切传播),执行倒角;distance_2_mm 提供时产生非对称倒角。 + # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 + # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 + # tan=1,退化为等距倒角(与历史行为一致,零回归)。 + # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° + # 倒角仅保证量级正确,距离所在侧可能反转。 + distance_2 = node.params.get("distance_2_mm") + angle_rad = node.params.get("angle_rad") + if distance_2 is None and angle_rad is not None: + distance_2 = distance * math.tan(float(angle_rad)) + # 4. 解析目标边(支持相切传播),执行倒角。 body = session.adapter.chamfer( - session.body, distance, node.params.get("distance_2_mm"), + session.body, distance, distance_2, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), ) - # 4. 登记新主体并返回结果。 + # 5. 登记新主体并返回结果。 session.register_body(node.feature_id, body, replay_node=node) return session.result(node) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index d061e342..3d77ccee 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -658,6 +658,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertLess(chamfer_result["volume_mm3"], baseline["volume_mm3"]) self.assertAlmostEqual(pattern_result["volume_mm3"], 1000 - 3 * 10 * 3.141592653589793, places=5) + def test_chamfer_consumes_angle_rad_instead_of_silent_45_degree_fallback(self) -> None: + from cdsl_engine.runtime import rebuild_cdsl + + import math + + base = self._base_block() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = rebuild_cdsl(base, root / "baseline.step") + edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge") + selector = { + "kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1, + "owner_feature_id": "base_add", "geometry": edge["geometry"], + } + + equal = deepcopy(base) + equal["features"].append({ + "id": "chamfer_45", "atomic_id": "chamfer", "depends_on": ["base_add"], + "params": {"distance_mm": 1, "angle_rad": math.pi / 4}, "selectors": [deepcopy(selector)], + }) + equal_result = rebuild_cdsl(equal, root / "chamfer-45.step") + # 45° Distance-Angle 等价于等距倒角:tan(45°)=1,切掉 0.5*1*1*10=5 mm³。 + self.assertAlmostEqual(equal_result["volume_mm3"], 1000 - 5, places=5) + + slanted = deepcopy(base) + slanted["features"].append({ + "id": "chamfer_30", "atomic_id": "chamfer", "depends_on": ["base_add"], + "params": {"distance_mm": 1, "angle_rad": math.pi / 6}, "selectors": [deepcopy(selector)], + }) + slanted_result = rebuild_cdsl(slanted, root / "chamfer-30.step") + # 30°:第二距离 = 1*tan(30°)≈0.577,切掉 0.5*1*0.577*10≈2.887 mm³, + # 体积明显大于 45° 等距倒角(995),验证 angle_rad 被消费而非静默 45°。 + self.assertAlmostEqual(slanted_result["volume_mm3"], 1000 - 0.5 * math.tan(math.pi / 6) * 10, places=5) + def test_linear_pattern_replays_hole_with_explicit_host_frame(self) -> None: from cdsl_engine.runtime import rebuild_cdsl diff --git a/docs/engine_diagnostics_test_plan.md b/docs/engine_diagnostics_test_plan.md new file mode 100644 index 00000000..42cd0a38 --- /dev/null +++ b/docs/engine_diagnostics_test_plan.md @@ -0,0 +1,124 @@ +# engine 诊断 → 测试方法 → 修改前后对比 + +## 1. 截图核对结论 + +`backend\engine` 等价于 `backend/engine/cdsl_engine/`。对截图 10 行逐一比对代码(行号均能命中),结论是 **全部成立**。证据索引如下: + +| # | 截图描述 | 代码位置(已核) | +|---|---|---| +| 1 | PlaneSpec 静默丢弃 y_dir,只用 origin/x_dir/normal 重建 | `runtime_types.py:173-180` `PlaneSpec.from_mapping`;`sketch_solver.py:43-52` `_to_3d` | +| 2 | draft 被 schema 接受但 runtime 未执行 | `cdsl_schema.json:94-99` extrudeParams.draft;`runtime.py:338-342` `_shape_from_primary` | +| 3 | revolve.reverse 未进入旋转方向计算 | `runtime.py:344-349` `_shape_from_primary` 分支;`cdsl_schema.json:100-105` | +| 4 | Hole host-face schema/runtime 合同自相矛盾 | `runtime.py:469-483` 支持 frame + selector 两条路径;`cdsl_schema.json:229-243` 仅 selectorRef | +| 5 | 高级终止条件要求整张 profile 同一距离 | `runtime.py:157-211` `_targeted_extent_vector` 拒绝 non_uniform_extent_target | +| 6 | Pattern 严禁 source selector/host selector/extent selector | `capabilities.py:68-91` `pattern_transform_blocker` | +| 7 | ExecutionSession 单 active body | `runtime.py:81-91`(#7 已完成:多体 body_id + 前缀匹配) | +| 8 | Selector 持久性依赖几何等价匹配 | `runtime_types.py` `_unique_equivalent_predecessor` / `_geometry_equivalent`(#8 已完成:演化后继映射 `_evolved_equivalent` / `_successors`,stable_id 跨 body 解析) | +| 9 | HoleSpec 仅支持简单圆柱、沉头、沉孔 | `runtime_types.py:191-250` `HoleSpec` 字段表;`capabilities.py:305-308` thread 拒绝 | +| 10 | Profile 支持链断裂(circles/annulus 未接入 CDSL-only) | `profile_schema.json:8` 仅 3 类;`cdsl_importer/solidworks_to_cdsl.py:113-142` 历史 importer 分类器 | + +## 2. 测试方法(四层 + phase 分桶) + +### 2.1 单元层 +新增 `backend/tests/test_engine_diagnostics_baseline.py`,对应 10 条问题写 14 个 testcase(含正反两面): +- 问题 1:正交 y_dir 保留 / 偏斜 y_dir 显式警告或正交化 +- 问题 2:draft 实体拔模 / 或 schema 拒绝 +- 问题 3:revolve.reverse 重心落在正确半侧 +- 问题 4:hole_wizard.host_face.frame 通过 schema +- 问题 5:up_to_surface 非均匀 profile 被裁剪而非拒绝 +- 问题 6:pattern_linear 重放带 host_face.frame 的孔;pattern_linear 重放 up_to_surface 拉伸 +- 问题 7:multi-body fixture 出现 ≥ 2 个独立 body_id +- 问题 8:fillet 后原 edge selector 仍可 resolve(实现于 `backend/tests/test_engine_selector_persistence.py`,含被波及边可解析、被完整消费边保持保守、未受影响边不受干扰三个契约) +- 问题 9:thread hole executable 或 schema 显式拒绝 +- 问题 10:importer 将 circles 降为 analytic_contours;schema 拒绝 legacy profile + +### 2.2 数据层 +新增 `backend/tests/test_engine_diagnostics_corpus.py`,扫描 `json_to_cdsl/output/*.cdsl.json`(共 5763 份),统计: +- `workplane.x_dir · workplane.y_dir` 与 ‖x‖·‖y‖·cosθ 的偏差分布 +- `profile.type ∈ {circles, annulus}` 计数(已部分修复,应单调下降) +- `hole_wizard` 含 `thread` 的特征数 +- `params.draft` 出现次数 +- `params.reverse=true` 的 revolve_* 出现次数 + +### 2.3 批量层(已有,零成本复用) +```bash +PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \ + json_to_cdsl/output /tmp/batch-before --build --build-timeout 15 +``` +产物: +- `manifest.json`:part_count、runtime_eligible_count、built_count、geometry_verified_count、failure_category_counts +- `summary-by-atomic.json`:atomic 频次 +- `summary-by-blocker.json`:blocker code 频次 +- `parts/.report.json`:每个 part 完整报告(selector_resolution、numeric_comparison) + +修改前/后各跑一次,对比以上聚合 JSON。 + +### 2.4 几何层 +`GJH/scriptTest.py` 已能批量走 CDSL-only 路径。参照 `GJH/exp_compare_084242.py` 思路对单零件做体积/bbox/face 数/凸包方向对比。 + +### 2.5 phase 分桶 +```bash +python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p3 --phase p3 --build +python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p4 --phase p4 --build +python -m cdsl_engine.batch_rebuild json_to_cdsl/output /tmp/p6 --phase p6 --build +``` +- P3:基准(拉伸/旋转/参考) +- P4:+ hole_wizard(→ 问题 9) +- P6:+ pattern(→ 问题 6) + +## 3. 修改前后差异指标 + +| # | blocker/信号 | A 当前 | B 修复后 | 度量 | +|---|---|---|---|---| +| 1 | x_dir·y_dir 与 ‖x‖·‖y‖cosθ 偏差 | 12,033/14,908 偏差 > 1e-4 | ≤ 1e-6 或显式 warnings | 数据扫描 | +| 2 | draft 静默成功 | geometry_verified 偏低 | runtime_eligible_count ↑ 或 schema 拒绝 | 批量层 | +| 3 | revolve centroid 侧 | centroid 在错半侧 | 正确侧 | 单元 | +| 4 | schema 拒绝 host_face.frame | schema violation | 通过 | 单元 + 数据 | +| 5 | non_uniform_extent_target 频次 | 高 | 显著下降 | summary-by-blocker | +| 6 | unsupported_pattern_selector_transform 频次 | 高(截图口径) | 0 | summary-by-blocker | +| 7 | body_id 数 | =1 | ≥2 | 单元 | +| 8 | fillet 后 selector 解析成功率 | not_found | resolved(演化后继唯一时;被完整消费的边保持保守 not_found) | 单元 | +| 9 | unsupported_hole_subtype 频次 | ≈712 | 0 | summary-by-blocker | +| 10 | unsupported_profile 频次 / profile.type=circles/annulus | ≈718 | 0(importer 修复后) | 数据 + summary-by-blocker | + +## 4. 最小基线流程 + +```bash +# 数据扫描 +python backend/tests/test_engine_diagnostics_corpus.py + +# 批量基线 +PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \ + json_to_cdsl/output /tmp/batch-before --build --build-timeout 15 + +# phase 分桶 +PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \ + json_to_cdsl/output /tmp/batch-p3-before --phase p3 --build +PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \ + json_to_cdsl/output /tmp/batch-p4-before --phase p4 --build +PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \ + json_to_cdsl/output /tmp/batch-p6-before --phase p6 --build + +# 单测基线 +python -m unittest backend.tests.test_engine_diagnostics_baseline -v +``` + +任意修复后重跑,diff 出 geometry_verified_count 单调不减、各 blocker 频次单调下降。 + +## 5. 修复 PR 强约束 + +按 `engine/README.md:22-30` 声明:三处必须同步修改—— +1. `sketch_solver.py` / `runtime.py` / `build123d_adapter.py` 实现 +2. `profile_schema.json`(人读契约) +3. `cdsl_schema.json`(机器契约) + +`backend/tests/test_profile_schema.py` 是这三处同步的护栏。 + +## 6. 与当前修改的关系 + +`git diff` 显示 `json_to_cdsl/evidence_v2_to_cdsl.py` 把 `type=circles` 重写为 `type=analytic_contours`,并已应用于 070825 / 084242 两份 cdsl.json。这是 **问题 10 的入口侧修复**(importer 直接产出合规 profile)。 + +修复后数据扫描预期: +- `json_to_cdsl/output/*.cdsl.json` 中 `profile.type=="circles"` 计数:数百 → 0(已在新文档体现) +- `summary-by-blocker.unsupported_profile`:在新文档上 = 0 +- 旧文档仍可能含 legacy profile,测试需保留 `json_to_cdsl/output.before/` 快照做对比 -- 2.52.0