Merge pull request 'feat: add 增加了几个较为简单的几何操作' (#8) from ganjihong into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-09-03 17:05:08 +08:00
+116 -8
View File
@@ -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:
# 沿给定平面镜像主体。