解决合并冲突

This commit is contained in:
2026-09-08 13:58:58 +08:00
28 changed files with 5529 additions and 336 deletions
+587 -67
View File
@@ -5,7 +5,21 @@ from __future__ import annotations
import math
from typing import Any, Iterable
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Solid, Vector, Wire, export_step
from build123d import Axis, Compound, Edge, Face, Location, Plane, ShapeList, Shell, Solid, Vector, Wire, export_step
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_SHELL
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Vec
from .parametric_bend import build_bend_solid
from .parametric_gears import build_gear_solid, build_rack_solid
@@ -67,14 +81,34 @@ class Build123dGeometryAdapter:
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
# 将边字典列表(直线/圆弧/插值 B 样条)组装成 build123d 的 Wire 线框。
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
if edge.get("type") == "bspline":
points = [_vector(point) for point in edge.get("points_mm") or []]
if len(points) < 3:
raise ValueError("bspline contour edge needs at least 3 points")
built.append(Edge.make_spline(points, periodic=False))
parameters = edge.get("parameters")
start_tangent = edge.get("start_tangent_mm")
end_tangent = edge.get("end_tangent_mm")
if (start_tangent is None) != (end_tangent is None):
raise ValueError("bspline contour edge requires both endpoint tangents")
built.append(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
periodic=bool(edge.get("periodic")),
parameters=[float(value) for value in parameters] if parameters is not None else None,
scale=False,
))
continue
if edge.get("type") == "ellipse":
center = _vector(edge["center_mm"])
major_axis = _vector(edge["major_axis_mm"])
normal = _vector(edge.get("normal") or [0, 0, 1])
if major_axis.length <= 1e-9 or normal.length <= 1e-9:
raise ValueError("ellipse contour edge has a degenerate frame")
plane = Plane(origin=center, x_dir=major_axis, z_dir=normal)
built.append(Edge.make_ellipse(float(edge["major_radius_mm"]), float(edge["minor_radius_mm"]), plane=plane))
continue
start = _vector(edge["start_mm"])
end = _vector(edge["end_mm"])
@@ -135,6 +169,30 @@ class Build123dGeometryAdapter:
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
# 1. 单圆 contour 不应先被 sketch_solver 展开成四段圆弧。圆弧分段会
# 改变拉伸后的圆柱面拓扑:同一圆柱侧面被拆成四块,后续来自
# FeatureScript 的 SWEPT_FACE 无法再以圆心/半径唯一定位。对于
# 只含闭合整圆的轮廓,保留每个圆一条原生 circle edge;同心圆仍由
# _faces_from_circles 的包含关系生成带孔面。
profile = sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles(circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}))
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
regions = sketch.get("contour_regions_mm") or []
if regions:
@@ -156,6 +214,20 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
if outer.inner_wires():
raise ValueError("profile outer face must not already contain holes")
wires = []
for hole in holes:
if hole.inner_wires():
raise ValueError("profile hole face must have exactly one outer wire")
wires.append(hole.outer_wire())
if not wires:
raise ValueError("profile hole feature requires at least one cap face")
return Face(outer.outer_wire()).make_holes(wires)
def loft(self, sketches: list[dict[str, Any]]) -> Solid:
"""由多条简单闭合草图轮廓生成实体放样。"""
wires: list[Wire] = []
@@ -173,6 +245,20 @@ class Build123dGeometryAdapter:
raise ValueError("loft requires at least two profile sketches")
return Solid.make_loft(wires)
def loft_with_cap_face(self, cap_face: Face, sketches: list[dict[str, Any]]) -> Solid:
"""Loft from one cap face's outer wire to one closed sketch profile."""
if not sketches:
raise ValueError("cap-face loft requires at least one profile sketch")
wires = [cap_face.outer_wire()]
for index, sketch in enumerate(sketches):
faces = self.faces_for_sketch(sketch)
if len(faces) != 1:
raise ValueError(f"loft profile {index} must resolve to exactly one closed region")
if faces[0].inner_wires():
raise ValueError(f"loft profile {index} must not contain inner loops")
wires.append(faces[0].outer_wire())
return Solid.make_loft(wires)
@staticmethod
def _coerce_single_or_compound(result: Any, *, empty_error: str | None = None) -> Any:
"""规整一次布尔结果:None/空视为失败(可选报错),多成员合并为 Compound。"""
@@ -189,17 +275,44 @@ class Build123dGeometryAdapter:
return None
if len(members) == 1:
return members[0]
# build123d 0.11 的有效运行时合并 API 是 Compound.make_composite(多体域
# 合并为 Compound/Part);Compound.make_compound 在类型桩与运行时均不存在,
# 此前的调用在多成员 ShapeList 下会 AttributeError。类型桩未声明
# make_composite,保留宽泛类型桩噪音抑制。
return Compound.make_composite(members)
# ``Compound`` constructor accepts an iterable on both supported
# Build123d runtimes. ``make_composite`` is not available in every
# deployed version, so using it here breaks valid multi-solid cuts.
return Compound(members)
@staticmethod
def extrude(face: Face, direction: Vector3) -> Solid:
# 沿给定方向向量拉伸一个面,生成实体。
return Solid.extrude(face, _vector(direction))
@staticmethod
def extrude_taper(face: Face, direction: Vector3, taper_deg: float) -> Solid:
# 沿给定方向以锥角拉伸一个面。build123d 正角收缩外轮廓,负角扩张;
# CADFS 的 draftPullDirection 已由 lowering 映射到该符号。
# build123d 对负锥角回退为 offset wire loft;椭圆等解析曲线在该
# 路径会产生仅能留在内存、STEP round-trip 后退化为 Shell 的 B-rep。
# LocOpe_DPrism 同时支持正负拔模角,且保留一张解析侧面,因此在
# 无内环、拉伸方向与 face normal 同向时始终优先使用它。
vector = _vector(direction)
normal = face.normal_at()
if (
vector.length > 1e-9
and normal.length > 1e-9
and vector.normalized().dot(normal.normalized()) >= 1.0 - 1e-9
and not face.inner_wires()
):
angle_rad = math.radians(taper_deg)
prism = LocOpe_DPrism(
face.wrapped,
vector.length / math.cos(angle_rad),
angle_rad,
)
if prism.IsDone():
result = Solid(TopoDS.Solid_s(prism.Shape()))
if result.is_valid:
return result
return Solid.extrude_taper(face, _vector(direction), taper_deg)
@staticmethod
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
"""Extrude the profile to the target face, trimming unreached regions.
@@ -217,6 +330,7 @@ class Build123dGeometryAdapter:
# through_next 的 target 是当前主体,必须先从其面中选出实际命中的
# 下一终止面,不能把整个 body 当作待拉伸的 Face。
unit = _vector(direction).normalized()
target_body = target if not isinstance(target, Face) else None
points = Build123dGeometryAdapter.profile_sample_points(face)
targets = [target] if isinstance(target, Face) else list(target.faces())
candidates = []
@@ -231,15 +345,28 @@ class Build123dGeometryAdapter:
# 时取最近的正向交点,确保相邻面交界处的选择稳定。
_, _, target, distances = max(candidates, key=lambda item: (item[0], -item[1]))
margin = max(distances) + 2.0
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
if target_body is not None and len(distances) == len(points):
# through_next 的 target 是当前实体,不是孤立的终止面。先让工具体
# 轻微穿过首个命中面,再切掉既有实体,留下从 profile 到该面的一侧。
# 将圆柱面拉成 slab 会保留实体内部的短段,丢失外部新增材料。
pierced = Solid.extrude(face, unit * margin)
return Build123dGeometryAdapter._coerce_single_or_compound(
pierced.cut(target_body), empty_error="extent target leaves no leading material",
)
# 2. 穿透拉伸 profile,再从目标面两侧各构造一个体层。曲面 Face 的
# OCC 朝向不保证与拉伸方向一致(特别是 cut 后的内圆柱面),不能
# 固定假定 -unit 一定朝向 profile;选择与 profile 相接的交集侧。
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
pierced = Solid.extrude(face, unit * margin)
slab = Solid.extrude(target, -unit * margin)
trimmed = pierced.intersect(slab)
return Build123dGeometryAdapter._coerce_single_or_compound(
trimmed, empty_error="extent target produced an empty trimmed solid",
)
candidates = []
for slab_direction in (-unit * margin, unit * margin):
trimmed = Build123dGeometryAdapter._coerce_single_or_compound(pierced.intersect(Solid.extrude(target, slab_direction)))
if trimmed is not None:
candidates.append((trimmed.distance_to(face), trimmed))
if not candidates:
raise ValueError("extent target produced an empty trimmed solid")
return min(candidates, key=lambda item: item[0])[1]
@staticmethod
def body_center(body: Any) -> Vector3:
@@ -265,6 +392,20 @@ class Build123dGeometryAdapter:
# 提取顶点的三维坐标元组。
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
@staticmethod
def intersection_vertex(body: Any, face_sets: list[list[Any]]) -> Any:
"""Resolve one current-body vertex shared by every selected face set."""
if not face_sets or any(not faces for faces in face_sets):
raise ValueError("intersection selector has an empty face set")
matched = []
for vertex in body.vertices():
if all(any(face.distance_to(vertex) <= 1e-6 for face in faces) for faces in face_sets):
point = (round(float(vertex.X), 6), round(float(vertex.Y), 6), round(float(vertex.Z), 6))
if not any(point == existing[0] for existing in matched): matched.append((point, vertex))
if len(matched) != 1:
raise ValueError(f"intersection selector resolved {len(matched)} current-body vertices")
return matched[0][1]
@staticmethod
def profile_sample_points(face: Face) -> list[Vector]:
"""Sample a profile face before a selector-dependent termination.
@@ -287,6 +428,51 @@ class Build123dGeometryAdapter:
unique.append(point)
return unique
@staticmethod
def profile_touches_target(target: Any, faces: Iterable[Face]) -> bool:
"""Return whether a profile starts on the selected extent target."""
# `up_to_surface` 允许 profile 从 selected face 出发。这时 selected
# face 不是拉伸终止,而是起始边界;交给 next_body_face_after 在当前
# 主体中寻找真实的下一面。distance_to 是 OCC 的最短形体距离,适用于
# 平面、圆柱和其他可用作 selector 的 B-rep face。
for face in faces:
try:
if face.distance_to(target) <= 1e-6:
return True
except Exception as error:
raise ValueError("extent target does not support profile distance") from error
return False
@staticmethod
def next_body_face_after(body: Any, faces: Iterable[Face], direction: Vector3, *, excluded_face: Face) -> Face:
"""Find the next current-body face reached after an extent start face."""
# 对每个候选面统计其能截获的 profile 射线。完整覆盖优先,随后选取
# 最近正向交点,避免把同一外圆柱的远侧交点误当成穿过实体后的终止面。
unit = _vector(direction).normalized()
samples = [point for face in faces for point in Build123dGeometryAdapter.profile_sample_points(face)]
if not samples:
raise ValueError("extent feature has no profile samples")
candidates = []
for candidate in body.faces():
# Selector resolution can retain the preceding B-rep snapshot,
# whose wrapper is no longer `is_same` after a later boolean.
# Geometrically coincident current faces are still the selected
# start boundary and must not win by their near-zero ray hits.
if candidate.is_same(excluded_face) or candidate.distance_to(excluded_face) <= 1e-6:
continue
distances = [
value for point in samples
if (value := Build123dGeometryAdapter._forward_intersection_distance(candidate, point, unit)) is not None
]
if distances:
candidates.append((len(distances), min(distances), candidate))
if not candidates:
raise ValueError("extent target has no following body face")
coverage, _distance, target = max(candidates, key=lambda item: (item[0], -item[1]))
if coverage != len(samples):
raise ValueError("extent target does not reach every profile ray after the start face")
return target
@staticmethod
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
@@ -325,16 +511,120 @@ class Build123dGeometryAdapter:
# 绕给定轴将面旋转指定角度,生成回转实体。
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
@staticmethod
def revolve_surface(wire: Wire, angle_deg: float, axis: AxisSpec) -> Shell:
# 表面回转必须以 profile wire 而非 Face 输入。Face 回转会由 OCC 封闭为
# Solid,错误改变 CADFS NewSurfaceOperation 的实体结果和体积。
operation = BRepPrimAPI_MakeRevol(
wire.wrapped,
gp_Ax1(gp_Pnt(*axis.origin_mm), gp_Dir(*axis.direction)),
math.radians(angle_deg),
True,
)
operation.Build()
if not operation.IsDone():
raise ValueError("surface revolve did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface revolve did not produce one shell")
return Shell(shape)
@staticmethod
def surface_wires_for_sketch(sketch: dict[str, Any]) -> list[Wire]:
# 曲面拉伸只消费 CADFS 显式选择的闭合曲线,不把同心圆转换成带孔 Face。
# 后者适用于实体拉伸,但会丢失每条 source edge 对应的一张独立曲面。
profile = sketch.get("profile") or {}
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
if profile.get("type") == "circle":
return [Build123dGeometryAdapter()._circle_wire(profile.get("center") or [0, 0], float(profile["radius_mm"]), plane)]
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if not isinstance(contours, list) or not contours:
raise ValueError("surface extrude requires closed contour wires")
wires = []
for contour in contours:
segments = contour.get("segments") or []
if not contour.get("closed") or len(segments) != 1 or segments[0].get("type") != "circle":
raise ValueError("surface extrude currently supports selected circular wires only")
segment = segments[0]
wires.append(Build123dGeometryAdapter()._circle_wire(
segment.get("center") or [0, 0], float(segment["radius_mm"]), plane,
))
return wires
@staticmethod
def extrude_surface(wires: Iterable[Wire], direction: Vector3) -> Any:
# 将每条闭合 wire 沿给定方向扫成独立 shell。曲面不参与实体布尔,后续
# selector 通过 register_surface 的独立 topology snapshot 追溯其来源。
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("surface extrude direction must be non-zero")
surfaces = []
for wire in wires:
operation = BRepPrimAPI_MakePrism(wire.wrapped, gp_Vec(vector.X, vector.Y, vector.Z), True, True)
operation.Build()
if not operation.IsDone():
raise ValueError("surface extrude did not complete")
shape = operation.Shape()
if shape.IsNull() or shape.ShapeType() != TopAbs_SHELL:
raise ValueError("surface extrude did not produce one shell")
surfaces.append(Shell(shape))
if not surfaces:
raise ValueError("surface extrude requires at least one wire")
return surfaces[0] if len(surfaces) == 1 else Compound(surfaces)
@staticmethod
def combine_surfaces(*surfaces: Any) -> Any:
# 不能复用 combine:它按实体 body 生命周期调用 body_solids,会丢弃
# Shell。曲面组合只用于导出和拓扑登记,不执行实体 boolean。
members = []
for surface in surfaces:
if isinstance(surface, Compound):
members.extend(surface.faces())
else:
members.append(surface)
if not members:
raise ValueError("surface combination requires at least one shell")
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def fuse(body: Any | None, solid: Any) -> Any:
# 布尔并:没有既有主体时,直接以该实体作为新主体。
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
return solid if body is None else body.fuse(solid)
if body is None:
return solid
# build123d.Shape.fuse 未启用 OBB 加速器;镜像后的重叠实体在该路径
# 会偶发返回反向、无效的 B-rep。直接采用 OCC 的稳定布尔配置,保留
# 一般 add/replay 的同一 union 语义。
arguments = TopTools_ListOfShape(); arguments.Append(body.wrapped)
tools = TopTools_ListOfShape(); tools.Append(solid.wrapped)
operation = BRepAlgoAPI_Fuse()
operation.SetRunParallel(True); operation.SetUseOBB(True)
operation.SetArguments(arguments); operation.SetTools(tools); operation.Build()
if not operation.IsDone():
raise ValueError("OCC union operation did not complete")
result = Solid(operation.Shape())
if result.is_valid:
return result
# 保留 build123d 的既有调用作为内核版本差异下的兼容回退;无效结果
# 不能悄然进入后续 feature history。
fallback = Build123dGeometryAdapter._coerce_single_or_compound(body.fuse(solid))
if fallback is not None and fallback.is_valid:
return fallback
raise ValueError("OCC union operation produced an invalid shape")
@staticmethod
def combine(body: Any | None, solid: Any) -> Any:
# 保留独立 result body:不得调用 fuse,否则相交实体会被内核合并。
members = ([] if body is None else Build123dGeometryAdapter.body_solids(body))
members.extend(Build123dGeometryAdapter.body_solids(solid))
return members[0] if len(members) == 1 else Compound(members)
@staticmethod
def cut(body: Any, tool: Any) -> Any:
# 从主体上减去工具实体。
return body.cut(tool)
return Build123dGeometryAdapter._coerce_single_or_compound(
body.cut(tool), empty_error="OCC cut operation produced no shape",
)
@staticmethod
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
@@ -533,7 +823,179 @@ class Build123dGeometryAdapter:
@staticmethod
def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
# 对指定边做倒角;distance_2_mm 提供时形成非对称倒角。
return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
# OCC 的单距离 Add 重载会按内核的等距倒角语义处理两侧相邻面。build123d
# 的通用实现会先任选一张邻接面再调用双距离重载,复杂实体上该选择会改变
# 倒角结果,因此仅等距倒角优先走原生重载。
selected = list(edges)
if distance_2_mm is None and face is None and len(Build123dGeometryAdapter.body_solids(body)) == 1:
builder = BRepFilletAPI_MakeChamfer(body.wrapped)
for edge in selected:
builder.Add(distance_mm, edge.wrapped)
builder.Build()
if builder.IsDone():
result = Solid(builder.Shape())
if result.is_valid:
return result
return body.chamfer(distance_mm, distance_2_mm, selected, face=face)
@staticmethod
def _surface_limited_chamfer_tool(body: Any, edge: Edge, distance_mm: float, surfaces: Iterable[Any]) -> Solid:
"""Build the removable material for one surface-supported circular chamfer.
A regular equal-offset chamfer is first attempted by ``chamfer``. This
helper only handles the narrow CADFS case where that operation reaches
a concentric surface split before the requested second offset. The
explicit shell must contain the limiting circle at the selected plane;
without that evidence this method deliberately rejects the fallback.
"""
if str(edge.geom_type).split(".")[-1].lower() != "circle":
raise ValueError("surface-limited chamfer requires circular edges")
try:
radius = float(edge.radius)
center = edge.arc_center
except (TypeError, ValueError) as error:
raise ValueError("surface-limited chamfer edge has no circle radius") from error
if radius <= 0:
raise ValueError("surface-limited chamfer edge radius must be positive")
adjacent = [
face for face in body.faces()
if any(face_edge.is_same(edge) for face_edge in face.edges())
]
planes = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "plane"]
cylinders = [face for face in adjacent if str(face.geom_type).split(".")[-1].lower() == "cylinder"]
if len(planes) != 1 or len(cylinders) != 1:
raise ValueError("surface-limited chamfer requires one planar and one cylindrical adjacent face")
plane_face, cylinder_face = planes[0], cylinders[0]
axis = cylinder_face.axis_of_rotation
if axis is None:
raise ValueError("surface-limited chamfer cylinder has no axis")
axis_direction = axis.direction.normalized()
if abs(plane_face.normal_at().normalized().dot(axis_direction)) < 1.0 - 1e-6:
raise ValueError("surface-limited chamfer faces are not perpendicular")
boundaries = [
candidate for candidate in plane_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and (candidate.arc_center - center).length <= 1e-6
]
if len(boundaries) != 1:
raise ValueError("surface-limited chamfer plane has no unique concentric support")
support_edge = boundaries[0]
support_radius = float(support_edge.radius)
radial_span = radius - support_radius
if radial_span <= 1e-6 or distance_mm <= radial_span + 1e-6:
raise ValueError("surface-limited chamfer does not need a constrained outer transition")
cylinder_ends = [
candidate for candidate in cylinder_face.edges()
if str(candidate.geom_type).split(".")[-1].lower() == "circle"
and not candidate.is_same(edge)
and abs(float(candidate.radius) - radius) <= 1e-6
and (candidate.arc_center - center).length > 1e-6
]
if len(cylinder_ends) != 1:
raise ValueError("surface-limited chamfer cylinder has no unique opposite cap")
axial_span = cylinder_ends[0].arc_center - center
if axial_span.length <= distance_mm + 1e-6:
raise ValueError("surface-limited chamfer exceeds the selected cylindrical face")
direction = axial_span.normalized()
supported = False
for surface in surfaces:
for face in surface.faces():
if str(face.geom_type).split(".")[-1].lower() != "cylinder":
continue
surface_axis = face.axis_of_rotation
if surface_axis is None or abs(surface_axis.direction.normalized().dot(axis_direction)) < 1.0 - 1e-6:
continue
if abs(float(face.radius) - support_radius) > 1e-6:
continue
if any(
str(boundary.geom_type).split(".")[-1].lower() == "circle"
and abs(float(boundary.radius) - support_radius) <= 1e-6
and (boundary.arc_center - center).length <= 1e-6
for boundary in face.edges()
):
supported = True
break
if supported:
break
if not supported:
raise ValueError("surface-limited chamfer has no explicit surface support")
frame_x = edge.tangent_at(0.0).normalized()
first_length = distance_mm - radial_span
outer_radius = radius + max(distance_mm, 1.0)
start_plane = Plane(origin=center, x_dir=frame_x, z_dir=direction)
cone_plane = Plane(origin=center + direction * first_length, x_dir=frame_x, z_dir=direction)
outer = Solid.make_cylinder(outer_radius, distance_mm, start_plane)
core = Solid.make_cylinder(support_radius, first_length, start_plane).fuse(
Solid.make_cone(support_radius, radius, radial_span, cone_plane),
)
return outer.cut(core)
@staticmethod
def surface_limited_chamfer(body: Any, distance_mm: float, edges: Iterable[Edge], surfaces: Iterable[Any]) -> Any:
# 显式 surface shell 只在内核正常倒角失败后作为截断证据使用。每条边
# 都先从同一原 body 推导工具体,随后依序切除,避免已变形拓扑反向影响
# 另一条 source selector。
if len(Build123dGeometryAdapter.body_solids(body)) != 1:
raise ValueError("surface-limited chamfer requires one solid body")
selected = list(edges)
if not selected:
raise ValueError("surface-limited chamfer requires at least one edge")
surface_members = list(surfaces)
tools = [
Build123dGeometryAdapter._surface_limited_chamfer_tool(body, edge, distance_mm, surface_members)
for edge in selected
]
result = body
for tool in tools:
result = result.cut(tool)
if not isinstance(result, Solid) or not result.is_valid:
raise ValueError("surface-limited chamfer produced an invalid shape")
if result.volume >= body.volume - 1e-6:
raise ValueError("surface-limited chamfer removed no material")
return result
@staticmethod
def shell(body: Any, faces: Iterable[Face], thickness_mm: float, *, inward: bool = True) -> Any:
# 对单个实体移除指定面并偏置其余面,生成薄壁实体。多 body 的目标
# 选择与结果合并由 runtime 处理;OCC 的 MakeThickSolidByJoin 只接受
# 一个 Solid,不能把 Compound 直接交给内核并猜测其 body 生命周期。
selected = list(faces)
if not selected:
raise ValueError("shell requires at least one face to remove")
thickness = float(thickness_mm)
if thickness <= 0:
raise ValueError("shell thickness_mm must be > 0")
solids = Build123dGeometryAdapter.body_solids(body)
if len(solids) != 1:
raise ValueError("shell adapter requires exactly one target solid")
closing_faces = TopTools_ListOfShape()
for face in selected:
closing_faces.Append(face.wrapped)
builder = BRepOffsetAPI_MakeThickSolid()
builder.MakeThickSolidByJoin(
solids[0].wrapped,
closing_faces,
-thickness if inward else thickness,
1e-6,
BRepOffset_Skin,
False,
False,
GeomAbs_Arc,
False,
)
builder.Build()
if not builder.IsDone():
raise ValueError("OCC shell operation did not complete")
result = Solid(builder.Shape())
if not result.is_valid:
raise ValueError("OCC shell operation produced an invalid shape")
return result
@staticmethod
def sweep(
@@ -556,15 +1018,36 @@ class Build123dGeometryAdapter:
}
if transition is not None:
sweep_options["transition"] = transition
return Solid.sweep(section, spine, **sweep_options)
result = Solid.sweep(section, spine, **sweep_options)
if make_solid and (not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9):
# OCC 在截面与路径不构成有效实体 sweep 时可能返回零体积形状,
# 而不报告 Build() 失败。该结果不能作为 CADFS 的 solid body 继续传播。
raise ValueError("OCC sweep operation did not produce a solid")
return result
@staticmethod
def sweep_path(points: Iterable[Vector3]) -> Wire:
# 把三维点列连成折线 Wire,作为扫掠路径的通用构造入口。
def sweep_path(
points: Iterable[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
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)])
if len(vertices) == 2:
if start_tangent is not None or end_tangent is not None or parameters is not None:
raise ValueError("line sweep path does not accept B-spline tangents")
return Edge.make_line(vertices[0], vertices[1])
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
return Edge.make_spline(vertices, tangents=tangents, parameters=parameters, scale=False)
@staticmethod
def helix_path(
@@ -644,8 +1127,30 @@ class Build123dGeometryAdapter:
@staticmethod
def export(body: Any, path: str) -> None:
# 将主体导出为 STEP 文件。
export_step(body, path)
# OCCT 对大于 90 度的 SURFACE_OF_REVOLUTION 在 STEP round-trip 时会
# 丢失部分参数域,导入后该侧面退化为一条母线。只对包含这类曲面的
# 独立实体按 45 度分段,保留原始解析曲面与实体几何,避免影响其余
# B-rep 的拓扑和导出体积。
segments = []
for solid in Build123dGeometryAdapter.body_solids(body):
if not isinstance(solid, Solid):
# Shell 或只含曲面的 Compound 没有实体体积分割语义。直接交给
# STEP exporter,才能保留 pure-surface feature history 的面。
segments.append(solid); continue
has_revolution = any(
BRep_Tool.Surface_s(face.wrapped).IsKind(Geom_SurfaceOfRevolution.get_type_descriptor_s())
for face in solid.faces()
)
if not has_revolution:
segments.append(solid); continue
divider = ShapeUpgrade_ShapeDivideAngle(math.radians(45.0), solid.wrapped)
divider.SetPrecision(1e-7); divider.SetMaxTolerance(1e-5)
if not divider.Perform(): raise ValueError("STEP revolution surface segmentation failed")
segmented = Solid(divider.Result())
if not segmented.is_valid:
raise ValueError("STEP revolution surface segmentation produced an invalid solid")
segments.append(segmented)
export_step(segments[0] if len(segments) == 1 else Compound(segments), path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
@@ -671,6 +1176,16 @@ class Build123dGeometryAdapter:
"solid_count": len(solids),
}
@staticmethod
def surface_geometry(surface: Any) -> dict[str, Any]:
# 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。
bbox = surface.bounding_box()
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
"area_mm2": float(surface.area),
"face_count": len(surface.faces()),
}
@staticmethod
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
@@ -757,51 +1272,56 @@ class Build123dGeometryAdapter:
elif geometry["surface_type"] in {"cylinder", "cone"}:
axis = face.axis_of_rotation
if axis is None:
raise ValueError("rotational face is missing an axis")
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
# Build123d can omit this optional OCC property for valid
# swept rotational faces. Keep their generic B-rep record
# so a selector-free workflow remains executable; do not
# invent axis/radius evidence for an axis-based selector.
pass
else:
geometry["cylinder_role"] = "unknown"
direction = axis.direction
origin = axis.position
geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z]
geometry["axis_direction"] = [direction.X, direction.Y, direction.Z]
raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None
if geometry["surface_type"] == "cone":
boundary_radii: list[float] = []
for edge in face.edges():
if str(edge.geom_type).split(".")[-1].lower() != "circle":
continue
try:
boundary_radii.append(float(edge.radius))
except ValueError:
continue
geometry["boundary_radii_mm"] = sorted(boundary_radii)
geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None
# ``through`` alone describes a cylinder spanning two opposed
# planar faces. That applies to both a through bore and the
# outside wall of a cylindrical extrusion. Classify the B-rep
# face by its oriented normal so downstream acceptance claims
# can prove holes without mistaking an exterior wall for one.
unit_axis = (direction.X, direction.Y, direction.Z)
radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z)
axial_projection = sum(radial[component] * unit_axis[component] for component in range(3))
radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3))
radial_length = sum(component * component for component in radial) ** 0.5
if geometry["surface_type"] == "cylinder":
# OCC can report a cylinder surface with ``radius=None``
# after a non-planar-side Boolean cut. The face centre is
# still on that cylinder, so its perpendicular distance to
# the rotation axis is an equivalent measured radius. Do
# not fail an otherwise valid build merely because that
# optional OCC convenience property is absent.
if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)):
geometry["radius_mm"] = float(raw_cylinder_radius)
elif radial_length > 1e-9:
geometry["radius_mm"] = radial_length
if radial_length > 1e-9:
normal_components = (normal.X, normal.Y, normal.Z)
alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length
geometry["radial_normal_alignment"] = alignment
geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown"
else:
geometry["cylinder_role"] = "unknown"
face_edge_indexes.append(set(boundary_edge_indexes))
face_geometries.append(geometry)
records.append(TopologyRecord(