解决合并冲突
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user