diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index cae10b5f..4d042a49 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, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Helix, Location, Plane, ShapeList, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -146,6 +146,25 @@ class Build123dGeometryAdapter: plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) return self._faces_from_circles(sketch.get("entities") or [], plane) + @staticmethod + def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any: + """规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。""" + # build123d 的布尔方法有时返回 None(无结果)、ShapeList(多/单成员) + # 或直接返回 Solid/Compound,这里统一为单实体或 Compound。 + if result is None: + if empty_error is not None: + raise ValueError(empty_error) + return None + members = list(result) if isinstance(result, ShapeList) else [result] + if not members: + if empty_error is not None: + raise ValueError(empty_error) + return None + if len(members) == 1: + return members[0] + # build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。 + return Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue] + @staticmethod def extrude(face: Face, direction: Vector3) -> Solid: # 沿给定方向向量拉伸一个面,生成实体。 @@ -180,13 +199,9 @@ class Build123dGeometryAdapter: 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 + return Build123dGeometryAdapter._coerce_single_or_compound( + trimmed, empty_error="extent target produced an empty trimmed solid", + ) @staticmethod def body_center(body: Any) -> Vector3: @@ -288,6 +303,32 @@ class Build123dGeometryAdapter: # 以给定球心与半径生成球体实体。 return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm))) + @staticmethod + def box(length_mm: float, width_mm: float, height_mm: float, plane: PlaneSpec | None = None) -> Solid: + # 原生立方体图元。plane 缺省为世界 XY;plane 的原点是长方体最小角点, + # 长/宽/高分别沿 plane 的 x/y/z 方向生长(build123d Solid.make_box 原生语义)。 + build_plane = Build123dGeometryAdapter.plane(plane) if plane is not None else Plane.XY + return Solid.make_box(length_mm, width_mm, height_mm, build_plane) + + @staticmethod + def cylinder(radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> Solid: + # 原生圆柱图元。axis 缺省为世界 +Z;axis 的原点是底面圆心, + # 轴向由 axis 的方向决定,沿该方向生长高度。 + build_plane = ( + Plane(origin=_vector(axis.origin_mm), z_dir=_vector(axis.direction)) + if axis is not None + else Plane.XY + ) + return Solid.make_cylinder(radius_mm, height_mm, build_plane) + + @staticmethod + def intersect(left: Any, right: Any) -> Any: + # 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触), + # 此时规整 helper 会抛出明确的空交集错误。 + return Build123dGeometryAdapter._coerce_single_or_compound( + left.intersect(right), empty_error="boolean intersection produced no solid", + ) + def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid: """Build a neutral ``HoleSpec`` into one OCC cutting tool.""" # 将孔规格 HoleSpec 转成一个可直接切除的 OCC 工具体。 @@ -375,6 +416,73 @@ class Build123dGeometryAdapter: # 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。 return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face) + @staticmethod + def sweep(section: Face, spine: Edge | Wire) -> Solid: + # 沿路径线扫掠截面生成实体(build123d 原生扫掠,路径可为直线/曲线/螺旋边)。 + return Solid.sweep(section, spine) + + @staticmethod + def sweep_path(points: Iterable[Vector3]) -> Wire: + # 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。 + vertices = [_vector(point) for point in points] + if len(vertices) < 2: + raise ValueError("sweep path needs at least two points") + return Wire([Edge.make_line(vertices[index], vertices[index + 1]) for index in range(len(vertices) - 1)]) + + @staticmethod + def helix_path(radius_mm: float, pitch_mm: float, turns: float, *, lefthand: bool = False) -> Edge: + # 构造螺旋线路径(单段 Edge),供扫掠/后续螺纹、斜齿等特征使用。 + # 螺旋从 (radius, 0, 0) 处沿 +Z 方向上升(lefthand=True 时反向缠绕)。 + if pitch_mm <= 0: + raise ValueError("helix pitch must be positive") + if turns <= 0: + raise ValueError("helix turns must be positive") + helix = Helix(pitch=pitch_mm, height=turns * pitch_mm, radius=radius_mm, lefthand=lefthand) + return helix.edges()[0] + + @staticmethod + def pattern_linear(body: Any, count: int, direction: Vector3, spacing_mm: float) -> Any: + # 内核直接阵列:把 body 沿 direction 方向以 spacing 间距复制 count 份并合并。 + # 与 runtime 的“源特征重放”pattern 不同:这里直接复制实体几何本身。 + if count < 1: + raise ValueError("pattern count must be at least 1") + if count == 1 or spacing_mm == 0: + return body + vector = _vector(direction) + if vector.length <= 1e-12: + raise ValueError("pattern direction must be non-zero") + unit = vector.normalized() + result = body + for index in range(1, count): + offset = unit * (index * spacing_mm) + result = result.fuse(body.moved(Location((offset.X, offset.Y, offset.Z)))) + return result + + @staticmethod + def pattern_circular(body: Any, count: int, axis: AxisSpec, sweep_angle_deg: float) -> Any: + # 内核直接阵列:把 body 绕 axis(过 axis.origin_mm、沿 axis.direction) + # 旋转 sweep_angle_deg 均布 count 份并合并。 + if count < 1: + raise ValueError("pattern count must be at least 1") + if count == 1 or sweep_angle_deg == 0: + return body + origin = _vector(axis.origin_mm) + direction = _vector(axis.direction) + if direction.length <= 1e-12: + raise ValueError("pattern rotation axis must be non-zero") + unit = direction.normalized() + step_angle = sweep_angle_deg / count + # 注意:Location(pos, axis_vec, angle) 的语义是“绕世界原点旋转 + 平移 pos”, + # 因此绕任意轴点旋转需要分解为 T(-O) → R(绕原点) → T(+O) 三步合成。 + to_origin = Location((-float(origin.X), -float(origin.Y), -float(origin.Z))) + back = Location((float(origin.X), float(origin.Y), float(origin.Z))) + result = body + for index in range(1, count): + rotation = Location((0.0, 0.0, 0.0), (float(unit.X), float(unit.Y), float(unit.Z)), index * step_angle) + instance = body.moved(to_origin).moved(rotation).moved(back) + result = result.fuse(instance) + return result + @staticmethod def mirror(body: Any, plane: PlaneSpec) -> Any: # 沿给定平面镜像主体。