Ganjihong #3

Merged
likang merged 5 commits from ganjihong into main 2026-08-26 14:45:18 +08:00
2 changed files with 193 additions and 14 deletions
+67 -13
View File
@@ -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, Edge, Face, Plane, 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,28 @@ 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 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 +172,7 @@ class Build123dGeometryAdapter:
@staticmethod
def vertex_coordinates(vertex: Any) -> Vector3:
# 提取顶点的三维坐标元组。
return (float(vertex.X), float(vertex.Y), float(vertex.Z))
@staticmethod
@@ -157,10 +184,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 +199,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 +214,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 +232,44 @@ 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))
# 布尔并:没有既有主体时,直接以该实体作为新主体。
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 +277,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 +288,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 +301,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 +310,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 +323,33 @@ 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_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积。
bbox = body.bounding_box()
return {
"bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
@@ -313,6 +358,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())
@@ -320,17 +366,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():
@@ -339,6 +388,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(),
@@ -346,6 +396,8 @@ class Build123dGeometryAdapter:
str(len(edge_faces[edge_index])),
))
# 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名;
# 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。
for index, face in enumerate(faces):
bbox = face.bounding_box()
center = face.center()
@@ -371,6 +423,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()
@@ -388,6 +441,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(
+126 -1
View File
@@ -184,9 +184,22 @@ def _workplane(sketch: dict[str, Any]) -> dict[str, Any]:
A conservative XY fallback keeps the record valid when the exporter did
not provide a usable matrix.
"""
workplane = _workplane_from_matrix(sketch)
if workplane is not None:
return workplane
return {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
def _workplane_from_matrix(sketch: dict[str, Any]) -> dict[str, Any] | None:
"""Parse the exporter's model-to-sketch matrix into a workplane.
Returns None when no usable rigid rotation can be extracted (missing
matrix or a non-orthonormal frame). The caller then decides between a
rebuilt workplane and the conservative XY fallback.
"""
matrix = sketch.get("model_to_sketch_transform")
if not isinstance(matrix, list) or len(matrix) != 16 or not all(isinstance(item, (int, float)) for item in matrix):
return {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
return None
# The exporter serializes MathTransform in row-major form.
rotation = [matrix[0:3], matrix[4:7], matrix[8:11]]
translation = [matrix[3], matrix[7], matrix[11]]
@@ -195,9 +208,69 @@ def _workplane(sketch: dict[str, Any]) -> dict[str, Any]:
x_dir = _normalize([inverse_rotation[row][0] for row in range(3)])
y_dir = _normalize([inverse_rotation[row][1] for row in range(3)])
normal = _normalize(_cross(x_dir, y_dir))
# A rigid model-to-sketch rotation keeps the sketch axes orthogonal. The
# exporter sometimes emits a non-orthogonal matrix for profiles placed on
# a copied reference plane; only trust a frame that is truly orthonormal.
if abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) > 1e-9:
return None
return {"origin_mm": [_mm(value) for value in origin_m], "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
def _workplane_is_orthonormal(workplane: dict[str, Any]) -> bool:
"""True when a workplane's sketch axes describe an orthonormal frame."""
x_dir = workplane.get("x_dir")
y_dir = workplane.get("y_dir")
if not x_dir or not y_dir:
return False
return abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) <= 1e-9
def _workplane_rebuilt(parent_plane: dict[str, Any] | None, axis_direction: list[float] | None) -> dict[str, Any] | None:
"""Rebuild a workplane from the parent reference plane and the revolve axis.
When the exporter's matrix is unusable, the profile still lies on its
parent reference plane and the revolve axis runs along the sketch V axis.
The captured axis direction is world space, so V maps directly onto it and
U is completed by the cross product to keep a right-handed frame.
"""
if not parent_plane or not axis_direction:
return None
normal = _normalize(list(parent_plane.get("normal") or []))
y_dir = _normalize(list(axis_direction))
if abs(normal[0] * y_dir[0] + normal[1] * y_dir[1] + normal[2] * y_dir[2]) > 0.9:
# The axis would lie parallel to the plane normal and the revolve
# would degenerate to zero volume; do not trust this rebuild.
return None
x_dir = _normalize(_cross(y_dir, normal))
origin_mm = list(parent_plane.get("origin_mm") or [0.0, 0.0, 0.0])
return {"origin_mm": origin_mm, "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
def _axis_in_world(axis: dict[str, Any], workplane: dict[str, Any]) -> dict[str, Any]:
"""Transform a revolve axis captured in sketch-local coordinates to world space."""
origin = axis.get("origin_mm")
direction = axis.get("direction")
if not origin or not direction or not workplane.get("x_dir"):
return axis
origin_mm = workplane.get("origin_mm") or [0.0, 0.0, 0.0]
x_dir = workplane["x_dir"]
y_dir = workplane.get("y_dir") or [0.0, 1.0, 0.0]
normal = workplane.get("normal") or [0.0, 0.0, 1.0]
world_origin = [
origin_mm[index] + origin[0] * x_dir[index] + origin[1] * y_dir[index] + origin[2] * normal[index]
for index in range(3)
]
world_direction = _normalize([
direction[0] * x_dir[index] + direction[1] * y_dir[index] + direction[2] * normal[index]
for index in range(3)
])
return {
**axis,
"origin_mm": [round(value, 9) for value in world_origin],
"direction": world_direction if math.sqrt(sum(c * c for c in world_direction)) > 1e-12 else list(direction),
}
def _workplane_from_plane_reference(reference: Any) -> dict[str, Any] | None:
"""Build a workplane from an exporter-captured planar face signature."""
if not isinstance(reference, dict):
@@ -892,6 +965,25 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
sketch_id_by_name = {str(feature.get("name")): sketch_id_by_source[source_id] for feature in sketch_features if (source_id := _identity_key(feature)) and feature.get("name") is not None}
sketch_workplanes_by_parent_source: dict[str, tuple[str, dict[str, Any]]] = {}
sketch_workplanes_by_parent_name: dict[str, tuple[str, dict[str, Any]]] = {}
# sketch source id -> the parent reference-plane source id it is drawn on
sketch_parent_plane_by_source: dict[str, str] = {}
# sketch source id -> the world-space direction of its revolve axis
sketch_axis_direction_by_source: dict[str, list[float]] = {}
# sketch source ids whose exporter matrix did not form an orthonormal frame
sketch_matrix_unreliable: set[str] = set()
sketch_record_by_id: dict[str, dict[str, Any]] = {}
# Prescan the revolve features so each profile's workplane can be rebuilt
# from the axis direction captured on its own revolve feature.
for feature in features:
if _feature_family(feature) not in {"Revolution", "RevCut"}:
continue
props, _ = _record_values(feature)
axis, _ = _axis_from_reference(props.get("Axis"))
if not axis or not axis.get("direction"):
continue
for parent in _record_parents(feature):
if str(parent.get("effective_type") or "") in SKETCH_TYPES and (sketch_source_id := _identity_key(parent)):
sketch_axis_direction_by_source.setdefault(sketch_source_id, list(axis["direction"]))
sketches = []
for feature in sketch_features:
source_id = _identity_key(feature)
@@ -901,10 +993,15 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
workplane = _workplane(sketch)
record = {"id": sketch_id_by_source[source_id], "name": str(feature.get("name") or sketch_id_by_source[source_id]), "role": "profile", "workplane": workplane, "profile": _analytic_profile(sketch)}
sketches.append(record)
sketch_record_by_id[record["id"]] = record
if not _workplane_is_orthonormal(workplane):
sketch_matrix_unreliable.add(source_id)
for parent in _record_parents(feature):
parent_source_id = _identity_key(parent)
if parent_source_id:
sketch_workplanes_by_parent_source.setdefault(parent_source_id, (record["id"], workplane))
if _feature_family(parent) == "RefPlane":
sketch_parent_plane_by_source[source_id] = parent_source_id
if parent.get("name") is not None:
sketch_workplanes_by_parent_name.setdefault(str(parent["name"]), (record["id"], workplane))
@@ -944,6 +1041,11 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
cdsl_features: list[dict[str, Any]] = []
previous_id: str | None = None
diagnostics: list[dict[str, Any]] = []
# Sketch source ids whose workplane was successfully rebuilt once their
# parent reference plane was resolved. Their revolve axis is expressed in
# sketch-local coordinates and must be transformed to world space too.
sketch_fix_applied: set[str] = set()
sketch_source_by_id = {sketch_id: source_id for source_id, sketch_id in sketch_id_by_source.items()}
for feature in model_features:
source_id = _identity_key(feature)
@@ -985,6 +1087,14 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
selectors.append(axis_selector)
if props.get("Axis") and any("no captured semantic selections" in item for item in unresolved):
unresolved = [item for item in unresolved if "no captured semantic selections" not in item]
# When the profile workplane was rebuilt, the captured revolve axis
# is still in sketch-local coordinates; map it to world space so
# the cut lands on the resolved parent reference plane.
if sketch_id and (axis_source_id := sketch_source_by_id.get(sketch_id)) in sketch_fix_applied:
sketch_workplane = sketch_record_by_id.get(sketch_id)
axis = params.get("axis")
if isinstance(axis, dict) and sketch_workplane is not None:
params["axis"] = _axis_in_world(axis, sketch_workplane["workplane"])
elif family == "HoleWzd":
atomic_id = "hole_wizard"
params, positions = _hole_params(props, methods)
@@ -1065,6 +1175,21 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
if "unresolved" not in params["plane"]:
more_unresolved = [item for item in more_unresolved if item != "reference plane orientation was not captured"]
unresolved = [item for item in unresolved if item != "reference plane orientation was not captured"]
plane = params.get("plane")
if isinstance(plane, dict) and "origin_mm" in plane and "unresolved" not in plane:
# Profiles whose exporter matrix was unusable are rebuilt from
# this resolved reference plane (normal + origin) and the axis
# direction of the profile's own revolve feature.
for sketch_source_id, ref_source_id in sketch_parent_plane_by_source.items():
if ref_source_id != source_id or sketch_source_id not in sketch_matrix_unreliable:
continue
rebuilt = _workplane_rebuilt(plane, sketch_axis_direction_by_source.get(sketch_source_id))
if rebuilt is None:
continue
sketch_record = sketch_record_by_id.get(sketch_id_by_source.get(sketch_source_id) or "")
if sketch_record is not None:
sketch_record["workplane"] = rebuilt
sketch_fix_applied.add(sketch_source_id)
for reference in params.get("references", []):
if reference not in selectors:
selectors.append(reference)