merge
This commit is contained in:
@@ -5,32 +5,39 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Iterable
|
||||
|
||||
from build123d import Axis, Compound, 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
|
||||
|
||||
|
||||
def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||
# 将三元坐标(list 或 tuple)转换为 build123d 的 Vector 对象。
|
||||
return Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
|
||||
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
||||
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
||||
radius = float(edge.get("radius_mm") or (start - center).length)
|
||||
first = start - center
|
||||
second = end - center
|
||||
# 2. 起点或终点与圆心重合时,圆弧退化为线段,中点取两端中点。
|
||||
if first.length <= 1e-9 or second.length <= 1e-9:
|
||||
return (start + end) / 2
|
||||
# 3. 确定圆弧所在平面法向:优先显式 normal,其次由两半径向量叉积推得,最后回退到 +Z。
|
||||
normal = _vector(edge.get("normal") or [0, 0, 1])
|
||||
if normal.length <= 1e-9:
|
||||
normal = first.cross(second)
|
||||
if normal.length <= 1e-9:
|
||||
normal = Vector(0, 0, 1)
|
||||
normal = normal.normalized()
|
||||
# 4. 未指定旋转方向:取两条半径单位向量之和(角平分线)指向圆弧中点。
|
||||
if "clockwise" not in edge:
|
||||
bisector = first.normalized() + second.normalized()
|
||||
if bisector.length <= 1e-9:
|
||||
bisector = normal.cross(first)
|
||||
return center + bisector.normalized() * radius
|
||||
# 5. 指定了方向:按有符号扫掠角规整到 (−π, π],再沿首半径旋转半角得到中点。
|
||||
sweep = math.atan2(normal.dot(first.cross(second)), first.dot(second))
|
||||
if bool(edge["clockwise"]):
|
||||
if sweep >= 0:
|
||||
@@ -47,34 +54,43 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def plane(spec: PlaneSpec) -> Plane:
|
||||
# 将运行时平面定义 PlaneSpec 转换为 build123d 的 Plane。
|
||||
return Plane(origin=_vector(spec.origin_mm), x_dir=_vector(spec.x_dir), z_dir=_vector(spec.normal))
|
||||
|
||||
@staticmethod
|
||||
def axis(spec: AxisSpec) -> Axis:
|
||||
# 将运行时轴定义 AxisSpec 转换为 build123d 的 Axis。
|
||||
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
# 将边字典列表(直线/圆弧)组装成 build123d 的 Wire 线框。
|
||||
built: list[Edge] = []
|
||||
for edge in edges:
|
||||
start = _vector(edge["start_mm"])
|
||||
end = _vector(edge["end_mm"])
|
||||
if edge.get("type") == "arc" and edge.get("center_mm") is not None:
|
||||
# 圆弧边:由起点、中点、终点三点构造圆弧。
|
||||
center = _vector(edge["center_mm"])
|
||||
built.append(Edge.make_three_point_arc(start, _arc_midpoint(edge, start, end, center), end))
|
||||
else:
|
||||
# 直线边:直接连接首尾。
|
||||
built.append(Edge.make_line(start, end))
|
||||
return Wire(built)
|
||||
|
||||
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
|
||||
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
||||
origin = Vector(*plane_spec.origin_mm) + Vector(*plane_spec.x_dir) * float(center[0]) + Vector(*plane_spec.y_dir) * float(center[1])
|
||||
circle_plane = Plane(origin=origin, x_dir=Vector(*plane_spec.x_dir), z_dir=Vector(*plane_spec.normal))
|
||||
return Wire.make_circle(radius, circle_plane)
|
||||
|
||||
def _faces_from_circles(self, entities: list[dict[str, Any]], plane_spec: PlaneSpec) -> list[Face]:
|
||||
# 由草图中的实体圆生成面,按圆间包含关系识别孔洞并跳过落入孔洞区的圆。
|
||||
# 1. 筛选非构造圆;没有实体圆时直接返回空列表。
|
||||
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
|
||||
if not circles:
|
||||
return []
|
||||
# 2. 逐个生成整圆 Wire,非法半径(≤0)的圆跳过。
|
||||
entries = []
|
||||
for item in circles:
|
||||
radius = float(item.get("radius_mm") or 0)
|
||||
@@ -84,6 +100,7 @@ class Build123dGeometryAdapter:
|
||||
entries.append({"center": center, "radius": radius, "wire": self._circle_wire(center, radius, plane_spec)})
|
||||
faces: list[Face] = []
|
||||
for entry in entries:
|
||||
# 3. 统计当前圆被多少个更大圆完整包含;被奇数层包含说明其处于孔洞区,跳过不建面。
|
||||
containing = sum(
|
||||
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
|
||||
for other in entries
|
||||
@@ -91,6 +108,7 @@ class Build123dGeometryAdapter:
|
||||
)
|
||||
if containing % 2:
|
||||
continue
|
||||
# 4. 收集直接包在自身内部的圆作为孔洞,且它们只能被当前这一层包含。
|
||||
holes = [
|
||||
other["wire"]
|
||||
for other in entries
|
||||
@@ -101,11 +119,14 @@ class Build123dGeometryAdapter:
|
||||
if candidate is not other
|
||||
) == containing + 1
|
||||
]
|
||||
# 5. 以当前圆为外轮廓建面,必要时打孔。
|
||||
face = Face(entry["wire"])
|
||||
faces.append(face.make_holes(holes) if holes else face)
|
||||
return faces
|
||||
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
|
||||
# 从草图数据解析出可拉伸/旋转的轮廓面,按三种数据来源依次回退。
|
||||
# 1. 优先使用预计算的轮廓区域 contour_regions_mm(外轮廓 + 孔洞列表)。
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if regions:
|
||||
result: list[Face] = []
|
||||
@@ -117,23 +138,65 @@ class Build123dGeometryAdapter:
|
||||
holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
|
||||
result.append(face.make_holes(holes) if holes else face)
|
||||
return result
|
||||
# 2. 退化:仅有单组轮廓边时,直接作为外轮廓建面。
|
||||
edges = sketch.get("contour_edges_mm") or []
|
||||
if len(edges) >= 2:
|
||||
return [Face(self._wire(edges))]
|
||||
# 3. 最终回退:由工作平面与实体圆生成面(圆环/孔洞处理见 _faces_from_circles)。
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
||||
|
||||
@staticmethod
|
||||
def extrude(face: Face, direction: Vector3) -> Solid:
|
||||
# 沿给定方向向量拉伸一个面,生成实体。
|
||||
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:
|
||||
# 取主体包围盒的中心坐标,作为体心的近似。
|
||||
bbox = body.bounding_box()
|
||||
return ((bbox.min.X + bbox.max.X) / 2, (bbox.min.Y + bbox.max.Y) / 2, (bbox.min.Z + bbox.max.Z) / 2)
|
||||
|
||||
@staticmethod
|
||||
def body_span(body: Any, direction: Vector3) -> float:
|
||||
# 计算主体在指定方向上的最大跨度:8 个包围盒角点沿方向投影后取极差。
|
||||
unit = _vector(direction).normalized()
|
||||
bbox = body.bounding_box()
|
||||
values = [
|
||||
@@ -146,6 +209,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def vertex_coordinates(vertex: Any) -> Vector3:
|
||||
# 提取顶点的三维坐标元组。
|
||||
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
|
||||
|
||||
@staticmethod
|
||||
@@ -157,10 +221,13 @@ class Build123dGeometryAdapter:
|
||||
boundary samples let the runtime prove that precondition instead of
|
||||
silently constructing a wrong prismatic solid.
|
||||
"""
|
||||
# 采样轮廓面的代表性点:面心 + 每条边的 0/0.25/0.5/0.75 参数点,
|
||||
# 用于后续校验目标面到轮廓的距离是否处处一致。
|
||||
points = [face.center()]
|
||||
for edge in face.edges():
|
||||
for fraction in (0.0, 0.25, 0.5, 0.75):
|
||||
points.append(edge.position_at(fraction))
|
||||
# 去重:彼此距离在 1e-6 内的采样点只保留一个,减少重复求交。
|
||||
unique: list[Vector] = []
|
||||
for point in points:
|
||||
if not any((point - current).length <= 1e-6 for current in unique):
|
||||
@@ -169,10 +236,12 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
|
||||
# 从 point 沿 direction 发一条射线,求与目标的第一个正向交点距离。
|
||||
try:
|
||||
intersections = target.find_intersection_points(Axis(point, direction)) or []
|
||||
except Exception as error:
|
||||
raise ValueError("extent target does not support ray intersection") from error
|
||||
# 只保留方向一致(点积 > 0)的交点,返回其中最近距离;无交点则返回 None。
|
||||
distances = [
|
||||
(hit_point - point).dot(direction)
|
||||
for hit_point, _normal in intersections
|
||||
@@ -182,6 +251,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
def uniform_intersection_distance(self, target: Any, faces: Iterable[Face], direction: Vector3) -> float:
|
||||
"""Return a proven uniform positive target distance for a profile set."""
|
||||
# 对所有轮廓采样点求到目标的距离,各点距离必须一致,简单拉伸才能精确表达终止条件。
|
||||
unit_direction = _vector(direction).normalized()
|
||||
distances: list[float] = []
|
||||
for face in faces:
|
||||
@@ -199,44 +269,45 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
|
||||
# 绕给定轴将面旋转指定角度,生成回转实体。
|
||||
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
||||
|
||||
@staticmethod
|
||||
def _body_shape(value: Any) -> Any:
|
||||
"""Normalize boolean results, including disconnected ShapeList values."""
|
||||
if hasattr(value, "bounding_box"):
|
||||
return value
|
||||
shapes = list(value)
|
||||
if not shapes:
|
||||
raise ValueError("Boolean operation produced no shapes")
|
||||
return shapes[0] if len(shapes) == 1 else Compound(shapes)
|
||||
|
||||
@staticmethod
|
||||
def fuse(body: Any | None, solid: Solid) -> Any:
|
||||
return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid))
|
||||
def fuse(body: Any | None, solid: Any) -> Any:
|
||||
# 布尔并:没有既有主体时,直接以该实体作为新主体。
|
||||
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
|
||||
return solid if body is None else body.fuse(solid)
|
||||
|
||||
@staticmethod
|
||||
def cut(body: Any, tool: Any) -> Any:
|
||||
return Build123dGeometryAdapter._body_shape(body.cut(tool))
|
||||
# 从主体上减去工具实体。
|
||||
return body.cut(tool)
|
||||
|
||||
@staticmethod
|
||||
def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
|
||||
# 以给定球心与半径生成球体实体。
|
||||
return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
|
||||
|
||||
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 工具体。
|
||||
# 1. 深度:通孔取贯穿深度(保证穿透),盲孔取规格中的深度。
|
||||
depth = through_depth_mm if spec.end_condition != "blind" else spec.depth_mm
|
||||
result: Solid | None = None
|
||||
for start in starts:
|
||||
# 2. 每个孔位:以起点为原点、向内方向为轴向,先生成主孔圆柱。
|
||||
plane = Plane(origin=_vector(start), z_dir=_vector(inward))
|
||||
tool = Solid.make_cylinder(spec.diameter_mm / 2, depth, plane)
|
||||
# 3. 沉孔(counterbore):在主孔上并一个更大直径、更浅的短圆柱。
|
||||
if spec.counterbore:
|
||||
diameter, bore_depth = spec.counterbore
|
||||
tool = tool.fuse(Solid.make_cylinder(diameter / 2, bore_depth, plane))
|
||||
# 4. 锪孔(countersink):按锥角与口径差推得锥深,并一个上大下小的圆锥。
|
||||
if spec.countersink:
|
||||
diameter, angle = spec.countersink
|
||||
sink_depth = ((diameter - spec.diameter_mm) / 2) / math.tan(angle / 2)
|
||||
tool = tool.fuse(Solid.make_cone(diameter / 2, spec.diameter_mm / 2, sink_depth, plane))
|
||||
# 5. 汇总所有孔位的工具实体。
|
||||
result = self.fuse(result, tool)
|
||||
if result is None:
|
||||
raise ValueError("hole has no positions")
|
||||
@@ -244,6 +315,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
|
||||
# 对指定边以给定半径做圆角。
|
||||
return body.fillet(radius_mm, list(edges))
|
||||
|
||||
@staticmethod
|
||||
@@ -254,8 +326,10 @@ class Build123dGeometryAdapter:
|
||||
global edge set or source stable IDs, and is consequently safe after a
|
||||
body mutation invalidates earlier topology objects.
|
||||
"""
|
||||
# 从种子边出发,沿“共顶点且切线平行”的边链扩展,得到相切连续的一整组边。
|
||||
edges = list(body.edges())
|
||||
selected = [edge for edge in seeds]
|
||||
# 1. 用 is_same 把种子边映射到主体边列表的下标集合。
|
||||
selected_indexes = {
|
||||
index
|
||||
for index, edge in enumerate(edges)
|
||||
@@ -265,6 +339,7 @@ class Build123dGeometryAdapter:
|
||||
return []
|
||||
|
||||
def shared_vertex(first: Edge, second: Edge) -> tuple[float, float] | None:
|
||||
# 找两条边共用的端点,返回各自在该端点处的参数位置;无共用端点返回 None。
|
||||
first_ends = [(0.0, vertex) for vertex in first.vertices()[:1]] + [(1.0, vertex) for vertex in first.vertices()[-1:]]
|
||||
second_ends = [(0.0, vertex) for vertex in second.vertices()[:1]] + [(1.0, vertex) for vertex in second.vertices()[-1:]]
|
||||
for first_parameter, first_vertex in first_ends:
|
||||
@@ -273,8 +348,10 @@ class Build123dGeometryAdapter:
|
||||
return first_parameter, second_parameter
|
||||
return None
|
||||
|
||||
# 共顶点且端点处切线平行(方向无关)的边即构成相切连续链。
|
||||
# Edges sharing a vertex whose tangents are parallel (orientation is
|
||||
# irrelevant) are a tangent-continuous chain.
|
||||
# 2. BFS 扩展:新加入的边作为候选种子,继续寻找与其相切的下一条边。
|
||||
pending = list(selected_indexes)
|
||||
while pending:
|
||||
current_index = pending.pop()
|
||||
@@ -284,27 +361,43 @@ class Build123dGeometryAdapter:
|
||||
shared = shared_vertex(edges[current_index], candidate)
|
||||
if shared is None:
|
||||
continue
|
||||
# 比较两条边在共用端点处的切线方向(取绝对值以忽略方向)。
|
||||
first_tangent = edges[current_index].tangent_at(shared[0]).normalized()
|
||||
second_tangent = candidate.tangent_at(shared[1]).normalized()
|
||||
if abs(abs(first_tangent.dot(second_tangent)) - 1.0) <= angular_tolerance:
|
||||
selected_indexes.add(candidate_index)
|
||||
pending.append(candidate_index)
|
||||
# 3. 按下标映射回边对象列表。
|
||||
return [edge for index, edge in enumerate(edges) if index in selected_indexes]
|
||||
|
||||
@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)
|
||||
|
||||
@staticmethod
|
||||
def mirror(body: Any, plane: PlaneSpec) -> Any:
|
||||
# 沿给定平面镜像主体。
|
||||
return body.mirror(Build123dGeometryAdapter.plane(plane))
|
||||
|
||||
@staticmethod
|
||||
def export(body: Any, path: str) -> None:
|
||||
# 将主体导出为 STEP 文件。
|
||||
export_step(body, path)
|
||||
|
||||
@staticmethod
|
||||
def body_solids(body: Any) -> list[Any]:
|
||||
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
|
||||
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
|
||||
try:
|
||||
solids = list(body.solids())
|
||||
except Exception:
|
||||
return [body] if body is not None else []
|
||||
return solids or ([body] if body is not None else [])
|
||||
|
||||
@staticmethod
|
||||
def body_geometry(body: Any) -> dict[str, Any]:
|
||||
# 汇总主体基本几何信息:包围盒与体积。
|
||||
bbox = body.bounding_box()
|
||||
# A feature history can contain several body IDs while still ending in
|
||||
# one connected solid (for example, a base extrusion followed by hole
|
||||
@@ -318,6 +411,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
|
||||
# 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。
|
||||
records: list[TopologyRecord] = []
|
||||
faces = list(body.faces())
|
||||
edges = list(body.edges())
|
||||
@@ -325,17 +419,20 @@ class Build123dGeometryAdapter:
|
||||
|
||||
def index_for(shape: Any, candidates: list[Any]) -> int | None:
|
||||
"""Map a subshape returned by a face/edge back to body topology."""
|
||||
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
|
||||
for index, candidate in enumerate(candidates):
|
||||
if shape.is_same(candidate):
|
||||
return index
|
||||
return None
|
||||
|
||||
# 1. 建立邻接索引:每条边关联的面集合(edge_faces)。
|
||||
edge_faces: list[set[int]] = [set() for _edge in edges]
|
||||
for face_index, face in enumerate(faces):
|
||||
for edge in face.edges():
|
||||
edge_index = index_for(edge, edges)
|
||||
if edge_index is not None:
|
||||
edge_faces[edge_index].add(face_index)
|
||||
# 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。
|
||||
vertex_edges: list[set[int]] = [set() for _vertex in vertices]
|
||||
for edge_index, edge in enumerate(edges):
|
||||
for vertex in edge.vertices():
|
||||
@@ -344,6 +441,7 @@ class Build123dGeometryAdapter:
|
||||
vertex_edges[vertex_index].add(edge_index)
|
||||
|
||||
def edge_signature(edge_index: int) -> str:
|
||||
# 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。
|
||||
edge = edges[edge_index]
|
||||
return ":".join((
|
||||
str(edge.geom_type).split(".")[-1].lower(),
|
||||
@@ -351,6 +449,8 @@ class Build123dGeometryAdapter:
|
||||
str(len(edge_faces[edge_index])),
|
||||
))
|
||||
|
||||
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
|
||||
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
|
||||
for index, face in enumerate(faces):
|
||||
bbox = face.bounding_box()
|
||||
center = face.center()
|
||||
@@ -376,6 +476,7 @@ class Build123dGeometryAdapter:
|
||||
record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
|
||||
geometry=geometry,
|
||||
))
|
||||
# 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。
|
||||
for index, edge in enumerate(edges):
|
||||
bbox = edge.bounding_box()
|
||||
center = edge.center()
|
||||
@@ -393,6 +494,7 @@ class Build123dGeometryAdapter:
|
||||
record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge,
|
||||
geometry=geometry,
|
||||
))
|
||||
# 5. 导出顶点记录:含坐标与关联边数。
|
||||
for index, vertex in enumerate(vertices):
|
||||
point = [vertex.X, vertex.Y, vertex.Z]
|
||||
records.append(TopologyRecord(
|
||||
|
||||
@@ -65,21 +65,58 @@ def _has_explicit_host_frame(params: dict[str, Any]) -> bool:
|
||||
return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal"))
|
||||
|
||||
|
||||
def _is_host_face_source(source: FeaturePlanNode) -> bool:
|
||||
"""Whether a ``face`` selector on the source is a fixed host face.
|
||||
|
||||
A patterned hole keeps its host face: the instance positions travel with
|
||||
the pattern (``_translated_node`` shifts ``positions``) while the face
|
||||
itself is resolved unchanged, so the face selector can stay untouched
|
||||
(issue #6). A face selector on anything else (for example a fillet
|
||||
selecting a face) would need per-instance edge geometry and must stay
|
||||
blocked.
|
||||
"""
|
||||
return source.atomic_id in _HOLE_ATOMICS or (
|
||||
isinstance(source.params.get("host_face"), dict)
|
||||
and source.params.get("host_face", {}).get("kind") == "face"
|
||||
)
|
||||
|
||||
|
||||
def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
|
||||
"""Return the selector dependency that cannot be transformed exactly.
|
||||
|
||||
An explicit host frame is coordinate data, not a topology guess. It can
|
||||
be transformed with a patterned instance while preserving local hole
|
||||
positions. All topology selectors and selector-dependent extents remain
|
||||
blocked until their geometry transform contract is implemented.
|
||||
Selectors fully captured by explicit coordinate data (a revolve axis with
|
||||
origin/direction, or a host face with a complete world frame) are
|
||||
transformed together with each patterned instance and are not blockers.
|
||||
A patterned hole's host face and an up_to_surface extrusion's target face
|
||||
are *fixed* body faces rather than instance geometry: positions and
|
||||
profiles travel with the instance while the face itself is resolved
|
||||
unchanged (issue #6). Selectors that must follow the instance but cannot
|
||||
be translated (edges, vertices, …) stay blocked until their geometry
|
||||
transform contract is implemented.
|
||||
"""
|
||||
if source.selectors:
|
||||
for selector in source.selectors or ():
|
||||
if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")):
|
||||
# The axis is coordinate data; _translated_node shifts its origin.
|
||||
continue
|
||||
if selector.get("kind") == "face" and _is_host_face_source(source):
|
||||
# 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions
|
||||
# 平移决定(_translated_node),selector 原样保留即可正确 resolve。
|
||||
continue
|
||||
return "feature selector"
|
||||
host = source.params.get("host_face")
|
||||
if host is not None and not _has_explicit_host_frame(source.params):
|
||||
return "host face selector"
|
||||
# 无 frame 的孔:宿主面以 face selector 形式给出(host_face 自身或
|
||||
# selectors 列表)→ 上面的 face 分支已放行;其它形态(无 frame 也
|
||||
# 非 face selector)仍阻塞。
|
||||
if not (isinstance(host, dict) and host.get("kind") == "face"):
|
||||
return "host face selector"
|
||||
end_condition = source.params.get("end_condition") or {}
|
||||
if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict):
|
||||
# up_to_surface / offset_from_surface 的终止面是主体上的固定面:
|
||||
# 不随实例平移,reference 原样保留即可正确 resolve(#5 裁剪已支持
|
||||
# 非均匀相交)。顶点/主体目标无法构造"固定终止面",仍显式阻塞。
|
||||
if end_condition["reference"].get("kind") == "face":
|
||||
return None
|
||||
return "extent target selector"
|
||||
return None
|
||||
|
||||
@@ -227,6 +264,17 @@ class CapabilityAnalyzer:
|
||||
sketch_id=node.sketch_id,
|
||||
))
|
||||
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
|
||||
# #2 draft:extrudeParams.draft 在 cdsl_schema.json 中被允许,
|
||||
# 但 runtime 的拉伸执行器(build123d Solid.extrude)没有锥形
|
||||
# 拉伸能力,人读契约 profile_schema.json 也未声明该参数。
|
||||
# 若 importer 把 SolidWorks 的 draft_angle_rad 写进 CDSL,
|
||||
# 当前 runtime 会静默产出无拔模角的直壁实体。这里把它从
|
||||
# "静默忽略"改为"显式拒绝"(与 unsupported_extent 同模式)。
|
||||
if params.get("draft"):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_draft",
|
||||
"Extrude draft/taper is not implemented; the runtime would silently ignore it",
|
||||
))
|
||||
end_condition = params.get("end_condition") or {"type": "blind"}
|
||||
end_type = end_condition.get("type")
|
||||
required.append(f"extent:{end_type}")
|
||||
@@ -296,8 +344,12 @@ class CapabilityAnalyzer:
|
||||
except ValueError as error:
|
||||
blockers.append(self._blocker(node.feature_id, "invalid_hole_spec", str(error)))
|
||||
if node.atomic_id == "hole_wizard":
|
||||
if params.get("thread"):
|
||||
blockers.append(self._blocker(node.feature_id, "unsupported_hole_subtype", "Threaded Hole Wizard geometry is not represented by the current CDSL runtime"))
|
||||
# #9 hole thread:SolidWorks 螺纹孔的 thread 是装饰信息(无螺距、
|
||||
# 不进实体几何,STEP 导出即光滑孔)。HoleSpec.from_feature 只读
|
||||
# 直径/深度/位置/沉头/沉孔,thread 天然不参与几何计算 → 孔特征
|
||||
# 直接按光滑圆柱孔执行,runtime 侧记录 thread_decoration_ignored
|
||||
# info 诊断便于追溯(见 _execute_hole)。不再报
|
||||
# unsupported_hole_subtype,使 ≈712 个带 thread 的孔恢复可执行。
|
||||
hole_extent = (params.get("end_condition") or {"type": "blind"}).get("type")
|
||||
if hole_extent not in {"blind", "through_all", "through_all_both"}:
|
||||
blockers.append(self._blocker(
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
"depth_mm": {"type": "number", "minimum": 0},
|
||||
"end_condition": {"$ref": "#/$defs/endCondition"},
|
||||
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
||||
"host_face": {"$ref": "#/$defs/selectorRef"},
|
||||
"host_face": {"$ref": "#/$defs/hostFace"},
|
||||
"thread": {"type": "object"},
|
||||
"countersink": {"type": "object"},
|
||||
"counterbore": {"type": "object"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,9 +74,11 @@ class GeometryAdapter(Protocol):
|
||||
"""
|
||||
|
||||
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
|
||||
def body_solids(self, body: Any) -> list[Any]: ...
|
||||
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: ...
|
||||
@@ -92,9 +109,23 @@ class ExecutionSession:
|
||||
active_feature_id: str = ""
|
||||
|
||||
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
|
||||
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
||||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||||
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
|
||||
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
|
||||
self.body = body
|
||||
self.body_id = f"body:{feature_id}"
|
||||
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
|
||||
solids = self.adapter.body_solids(body)
|
||||
if len(solids) <= 1:
|
||||
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
|
||||
else:
|
||||
for index, solid in enumerate(solids):
|
||||
member_id = f"{self.body_id}:{index}"
|
||||
self.topology.replace_body_topology(
|
||||
feature_id, member_id,
|
||||
self.adapter.topology_records(solid, feature_id, member_id),
|
||||
active_body_id=self.body_id,
|
||||
)
|
||||
self.topology.register(TopologyRecord(
|
||||
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
|
||||
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
|
||||
@@ -163,7 +194,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 +228,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 +251,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 +262,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 +274,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 +305,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 +333,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,15 +388,30 @@ 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)
|
||||
angle = float(node.params.get("angle_deg") or 0.0)
|
||||
if angle <= 0:
|
||||
raise ValueError("revolve requires angle_deg > 0")
|
||||
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
|
||||
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
|
||||
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
|
||||
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
|
||||
# 侧实现,使三方合同一致。
|
||||
if bool(node.params.get("reverse")):
|
||||
angle = -angle
|
||||
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
|
||||
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
|
||||
tool = None
|
||||
@@ -498,8 +564,18 @@ def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: b
|
||||
session.adapter.body_span(session.body, inward) + 2.0,
|
||||
)
|
||||
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||||
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||||
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
|
||||
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
if wizard and node.params.get("thread"):
|
||||
diagnostics.append(RuntimeDiagnostic(
|
||||
code="thread_decoration_ignored",
|
||||
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||
feature_id=node.feature_id,
|
||||
))
|
||||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||
return session.result(node)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]:
|
||||
@@ -547,12 +623,21 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur
|
||||
distance = float(node.params.get("distance_mm") or 0)
|
||||
if distance <= 0:
|
||||
raise ValueError("chamfer distance_mm must be > 0")
|
||||
# 3. 解析目标边(支持相切传播),执行倒角;distance_2_mm 提供时产生非对称倒角。
|
||||
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
|
||||
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
|
||||
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
|
||||
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
|
||||
# 倒角仅保证量级正确,距离所在侧可能反转。
|
||||
distance_2 = node.params.get("distance_2_mm")
|
||||
angle_rad = node.params.get("angle_rad")
|
||||
if distance_2 is None and angle_rad is not None:
|
||||
distance_2 = distance * math.tan(float(angle_rad))
|
||||
# 4. 解析目标边(支持相切传播),执行倒角。
|
||||
body = session.adapter.chamfer(
|
||||
session.body, distance, node.params.get("distance_2_mm"),
|
||||
session.body, distance, distance_2,
|
||||
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
|
||||
)
|
||||
# 4. 登记新主体并返回结果。
|
||||
# 5. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node)
|
||||
return session.result(node)
|
||||
|
||||
@@ -706,9 +791,20 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) ->
|
||||
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
|
||||
)
|
||||
if positions_are_local:
|
||||
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
|
||||
for key in ("origin_mm", "x_dir", "normal"):
|
||||
if host_frame.get(key):
|
||||
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
|
||||
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
|
||||
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
|
||||
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
|
||||
x_reflected = host_frame.get("x_dir")
|
||||
n_reflected = host_frame.get("normal")
|
||||
if x_reflected is not None and n_reflected is not None:
|
||||
host_frame["y_dir"] = [
|
||||
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
|
||||
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
|
||||
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
|
||||
]
|
||||
# See _mirrored_sketch: the canonical reflected plane reverses local
|
||||
# y, so local hole coordinates must do the same.
|
||||
for position in params.get("positions") or []:
|
||||
@@ -899,7 +995,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": float(geometry["volume_mm3"]),
|
||||
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
|
||||
"solid_count": int(geometry.get("solid_count") or 0),
|
||||
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
|
||||
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
|
||||
"solid_count": len(session.adapter.body_solids(session.body)),
|
||||
"feature_results": [result.as_dict() for result in session.results.values()],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
|
||||
"topology_records": [record.public_dict() for record in session.topology.records()],
|
||||
|
||||
@@ -7,6 +7,7 @@ any geometry adapter without importing OCC objects.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from math import sqrt
|
||||
from typing import Any, Iterable
|
||||
@@ -14,6 +15,10 @@ from typing import Any, Iterable
|
||||
|
||||
Vector3 = tuple[float, float, float]
|
||||
|
||||
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
|
||||
# 予以保留;否则视为偏斜数据,正交化并显式警告。
|
||||
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
|
||||
|
||||
|
||||
def _vector3(value: Any, *, field_name: str) -> Vector3:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
@@ -176,7 +181,25 @@ class PlaneSpec:
|
||||
x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir")
|
||||
projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3))
|
||||
x_dir = _unit(projected_x, field_name="plane.x_dir")
|
||||
y_dir = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
|
||||
generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
|
||||
y_raw = value.get("y_dir")
|
||||
if y_raw is None:
|
||||
# y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。
|
||||
y_dir = generated
|
||||
else:
|
||||
y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir")
|
||||
if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL:
|
||||
# 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向,
|
||||
# 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。
|
||||
y_dir = y_vec
|
||||
else:
|
||||
# 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。
|
||||
warnings.warn(
|
||||
f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
y_dir = generated
|
||||
return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal)
|
||||
|
||||
def as_dict(self) -> dict[str, list[float]]:
|
||||
@@ -400,6 +423,11 @@ class TopologyRegistry:
|
||||
self._records: list[TopologyRecord] = []
|
||||
self._by_feature: dict[str, list[TopologyRecord]] = {}
|
||||
self._active_body_id: str | None = None
|
||||
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
|
||||
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
|
||||
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
|
||||
# stable_id 引用可以解析到 active body 内的新形态。
|
||||
self._successors: dict[str, list[str]] = {}
|
||||
|
||||
def register(self, record: TopologyRecord) -> None:
|
||||
self._records.append(record)
|
||||
@@ -423,7 +451,10 @@ class TopologyRegistry:
|
||||
self.register(record)
|
||||
return record
|
||||
|
||||
def replace_body_topology(self, feature_id: str, body_id: str, records: Iterable[TopologyRecord]) -> None:
|
||||
def replace_body_topology(
|
||||
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
|
||||
*, active_body_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record a fresh B-rep snapshot after a feature mutates the body.
|
||||
|
||||
OCC topology object identity is invalidated by most body mutations.
|
||||
@@ -432,11 +463,21 @@ class TopologyRegistry:
|
||||
object has one geometrically equivalent predecessor. A changed or
|
||||
split object intentionally becomes owned by this feature instead of
|
||||
being guessed as belonging to an older one.
|
||||
|
||||
``active_body_id`` names the whole-body group when ``body_id`` is a
|
||||
member of a multi-solid body (issue #7): the group id keeps the next
|
||||
mutation's predecessor lookup scoped to every solid of the previous
|
||||
body, while each member keeps its own ``body:{feature}:{index}`` id.
|
||||
"""
|
||||
previous = [
|
||||
record for record in self._records
|
||||
if self._active_body_id is not None and record.body_id == self._active_body_id
|
||||
if self._active_body_id is not None and record.body_id is not None
|
||||
and (
|
||||
record.body_id == self._active_body_id
|
||||
or record.body_id.startswith(f"{self._active_body_id}:")
|
||||
)
|
||||
]
|
||||
records = list(records)
|
||||
consumed_predecessors: set[str] = set()
|
||||
for record in records:
|
||||
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
|
||||
@@ -454,7 +495,27 @@ class TopologyRegistry:
|
||||
owner_feature_ids=owners,
|
||||
)
|
||||
)
|
||||
self._active_body_id = body_id
|
||||
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
|
||||
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
|
||||
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
|
||||
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
|
||||
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
|
||||
for prior in previous:
|
||||
if prior.record_id in consumed_predecessors:
|
||||
continue
|
||||
candidates = sorted(
|
||||
(
|
||||
(self._evolved_drift(prior, record), record.record_id)
|
||||
for record in records if self._evolved_equivalent(prior, record)
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
)
|
||||
if not candidates:
|
||||
continue
|
||||
best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None)
|
||||
if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]):
|
||||
self._successors[prior.record_id] = [best[1]]
|
||||
self._active_body_id = active_body_id or body_id
|
||||
|
||||
@staticmethod
|
||||
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
|
||||
@@ -532,6 +593,56 @@ class TopologyRegistry:
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
@staticmethod
|
||||
def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None:
|
||||
"""Endpoint drift between direction-aligned straight edges.
|
||||
|
||||
Returns the minimum total endpoint drift (mm) when the two edges are
|
||||
collinear straight lines (either orientation), otherwise ``None``.
|
||||
"""
|
||||
if prior.kind != current.kind:
|
||||
return None
|
||||
left, right = prior.geometry, current.geometry
|
||||
if left.get("curve_type") != "line" or right.get("curve_type") != "line":
|
||||
return None
|
||||
if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")):
|
||||
return None
|
||||
|
||||
def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||||
|
||||
def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
||||
return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
|
||||
|
||||
left_dir = _delta(left["start_mm"], left["end_mm"])
|
||||
right_dir = _delta(right["start_mm"], right["end_mm"])
|
||||
if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9:
|
||||
return None
|
||||
cross = (
|
||||
left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1],
|
||||
left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2],
|
||||
left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0],
|
||||
)
|
||||
if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3:
|
||||
return None
|
||||
same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"])
|
||||
reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"])
|
||||
return min(same_order, reversed_order)
|
||||
|
||||
@classmethod
|
||||
def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool:
|
||||
"""Loose "position trajectory" equivalence used for evolved successors.
|
||||
|
||||
Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance),
|
||||
this deliberately tolerates small endpoint drift: fillet/chamfer split a
|
||||
straight edge into segments (a middle straight run plus end arcs). The
|
||||
straight run keeps the same direction and stays within ``drift_mm`` of the
|
||||
original edge, so it can serve as the edge's evolved successor. Uniqueness
|
||||
is enforced by the caller (only a single best candidate is recorded).
|
||||
"""
|
||||
drift = cls._evolved_drift(prior, current)
|
||||
return drift is not None and drift <= drift_mm
|
||||
|
||||
@staticmethod
|
||||
def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
|
||||
try:
|
||||
@@ -556,8 +667,14 @@ class TopologyRegistry:
|
||||
for key in ("surface_type", "curve_type"):
|
||||
if key in selector_geometry:
|
||||
if record_geometry.get(key) != selector_geometry[key]:
|
||||
return None
|
||||
scores.append(1.0)
|
||||
# #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、
|
||||
# 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点)
|
||||
# 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全
|
||||
# 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的
|
||||
# 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。
|
||||
scores.append(0.5)
|
||||
else:
|
||||
scores.append(1.0)
|
||||
if "bbox_mm" in selector_geometry:
|
||||
expected = selector_geometry["bbox_mm"]
|
||||
actual = record_geometry.get("bbox_mm")
|
||||
@@ -592,7 +709,14 @@ class TopologyRegistry:
|
||||
owner = selector.get("owner_feature_id")
|
||||
candidates = [record for record in self._records if record.kind == kind]
|
||||
if active_body_id and kind in {"face", "edge", "vertex", "body"}:
|
||||
candidates = [record for record in candidates if record.body_id == active_body_id]
|
||||
# #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体
|
||||
# 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的
|
||||
# 记录(不同 feature 前缀)不会泄漏进来。
|
||||
candidates = [
|
||||
record for record in candidates
|
||||
if record.body_id == active_body_id
|
||||
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
|
||||
]
|
||||
if owner:
|
||||
candidates = [record for record in candidates if owner in record.owners]
|
||||
geometry = normalize_selector_geometry(selector.get("geometry"))
|
||||
@@ -609,31 +733,68 @@ class TopologyRegistry:
|
||||
)
|
||||
stable_id = str(selector.get("stable_id") or "").strip()
|
||||
if stable_id:
|
||||
exact = [record for record in candidates if record.record_id == stable_id]
|
||||
# #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
|
||||
# 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。
|
||||
# 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body
|
||||
# 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到
|
||||
# 几何打分流程。
|
||||
stable_records = [
|
||||
record for record in self._records
|
||||
if record.kind == kind and (not owner or owner in record.owners)
|
||||
]
|
||||
exact = [record for record in stable_records if record.record_id == stable_id]
|
||||
if len(exact) == 1:
|
||||
record = exact[0]
|
||||
# A stable ID is only a lookup accelerator for snapshot-aware
|
||||
# selectors. It cannot revive a B-rep entity whose geometric
|
||||
# signature changed after an upstream rebuild.
|
||||
if selector.get("snapshot_id"):
|
||||
score = self._geometry_score(geometry, record.geometry) if geometry else None
|
||||
if score is None or score < minimum_score:
|
||||
is_active = active_body_id is None or (
|
||||
record.body_id == active_body_id
|
||||
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
|
||||
)
|
||||
if not is_active:
|
||||
successors = [
|
||||
candidate for candidate in stable_records
|
||||
if candidate.record_id in self._successors.get(record.record_id, ())
|
||||
and (
|
||||
candidate.body_id == active_body_id
|
||||
or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:"))
|
||||
)
|
||||
]
|
||||
if len(successors) == 1:
|
||||
record = successors[0]
|
||||
is_active = True
|
||||
elif len(successors) > 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
|
||||
status="ambiguous",
|
||||
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_geometry_mismatch",
|
||||
message="The stable selector record no longer matches its geometry signature",
|
||||
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
|
||||
code="selector_ambiguous",
|
||||
message="More than one evolved successor record satisfies the stable_id",
|
||||
detail={"stable_id": stable_id, "candidate_count": len(successors)},
|
||||
),
|
||||
)
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="resolved",
|
||||
record=record,
|
||||
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
|
||||
)
|
||||
if is_active:
|
||||
# A stable ID is only a lookup accelerator for snapshot-aware
|
||||
# selectors. It cannot revive a B-rep entity whose geometric
|
||||
# signature changed after an upstream rebuild.
|
||||
if selector.get("snapshot_id"):
|
||||
score = self._geometry_score(geometry, record.geometry) if geometry else None
|
||||
if score is None or score < minimum_score:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code="selector_geometry_mismatch",
|
||||
message="The stable selector record no longer matches its geometry signature",
|
||||
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="resolved",
|
||||
record=record,
|
||||
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
|
||||
)
|
||||
if len(exact) > 1:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
|
||||
@@ -44,14 +44,33 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
|
||||
origin = workplane.get("origin_mm") or [0, 0, 0]
|
||||
x_dir = workplane.get("x_dir") or [1, 0, 0]
|
||||
normal = workplane.get("normal") or [0, 0, 1]
|
||||
y_dir = [
|
||||
normal[1] * x_dir[2] - normal[2] * x_dir[1],
|
||||
normal[2] * x_dir[0] - normal[0] * x_dir[2],
|
||||
normal[0] * x_dir[1] - normal[1] * x_dir[0],
|
||||
]
|
||||
y_raw = workplane.get("y_dir")
|
||||
y_dir = _default_y_dir(x_dir, normal)
|
||||
if y_raw:
|
||||
magnitude = math.sqrt(sum(component * component for component in y_raw))
|
||||
if magnitude > 1e-12:
|
||||
y_unit = [component / magnitude for component in y_raw]
|
||||
# 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的
|
||||
# y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行,
|
||||
# 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。
|
||||
if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6:
|
||||
y_dir = y_unit
|
||||
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
|
||||
|
||||
|
||||
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
|
||||
return sum(a * b for a, b in zip(left, right))
|
||||
|
||||
|
||||
def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]:
|
||||
x, n = list(x_dir), list(normal)
|
||||
return [
|
||||
n[1] * x[2] - n[2] * x[1],
|
||||
n[2] * x[0] - n[0] * x[2],
|
||||
n[0] * x[1] - n[1] * x[0],
|
||||
]
|
||||
|
||||
|
||||
def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
|
||||
transformed: list[_Ctx] = []
|
||||
normal = workplane.get("normal") or [0, 0, 1]
|
||||
|
||||
Reference in New Issue
Block a user