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()