Files
pythonocc-step-editor/step_model.py
T
nikelaluo 1ab595f82e feat: 完善 STEP 编辑器 MVP 闭环
中文化模型结构树,补充选择交互、局部编辑、导出质量检查、历史记录、拓扑缓存和中文文档。
2026-07-24 17:52:17 +08:00

4617 lines
196 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.GeomAbs import (
GeomAbs_BSplineCurve,
GeomAbs_BSplineSurface,
GeomAbs_BezierCurve,
GeomAbs_BezierSurface,
GeomAbs_Circle,
GeomAbs_Cone,
GeomAbs_Cylinder,
GeomAbs_Ellipse,
GeomAbs_Hyperbola,
GeomAbs_Line,
GeomAbs_OffsetSurface,
GeomAbs_OtherCurve,
GeomAbs_OtherSurface,
GeomAbs_Parabola,
GeomAbs_Plane,
GeomAbs_Sphere,
GeomAbs_SurfaceOfExtrusion,
GeomAbs_SurfaceOfRevolution,
GeomAbs_Torus,
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
from OCC.Core.TDocStd import TDocStd_Document
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
SURFACE_TYPES = {
GeomAbs_Plane: "plane",
GeomAbs_Cylinder: "cylinder",
GeomAbs_Cone: "cone",
GeomAbs_Sphere: "sphere",
GeomAbs_Torus: "torus",
GeomAbs_BezierSurface: "bezier surface",
GeomAbs_BSplineSurface: "b-spline surface",
GeomAbs_SurfaceOfRevolution: "surface of revolution",
GeomAbs_SurfaceOfExtrusion: "surface of extrusion",
GeomAbs_OffsetSurface: "offset surface",
GeomAbs_OtherSurface: "other surface",
}
CURVE_TYPES = {
GeomAbs_Line: "line",
GeomAbs_Circle: "circle",
GeomAbs_Ellipse: "ellipse",
GeomAbs_Hyperbola: "hyperbola",
GeomAbs_Parabola: "parabola",
GeomAbs_BezierCurve: "bezier curve",
GeomAbs_BSplineCurve: "b-spline curve",
GeomAbs_OtherCurve: "other curve",
}
ORIENTATION_TYPES = {
TopAbs_FORWARD: "forward",
TopAbs_REVERSED: "reversed",
TopAbs_INTERNAL: "internal",
TopAbs_EXTERNAL: "external",
}
@dataclass
class PartNode:
id: int
name: str
kind: str
shape: TopoDS_Shape
parent_id: int | None = None
depth: int = 0
path: str = ""
@dataclass
class TopologyStats:
parts: int
solids: int
faces: int
edges: int
vertices: int
class StepModel:
def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape):
self.filename = filename
self.parts = parts
self.shape = shape
self.faces: list[TopoDS_Shape] = []
self.face_part_ids: list[int] = []
self.face_solid_ids: list[int] = []
self.edges: list[TopoDS_Shape] = []
self.edge_part_ids: list[int] = []
self.edge_solid_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = []
self._face_info_cache: dict[int, dict[str, object]] = {}
self._edge_info_cache: dict[int, dict[str, object]] = {}
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
self.refresh_topology()
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
product_names = _parse_product_names(path)
parts, whole_shape = _load_with_xcaf(path, product_names)
if not parts or whole_shape.IsNull():
whole_shape = _load_plain_step(path)
fallback_name = product_names[0] if product_names else path.stem
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
return leaf_parts
return [p for p in self.parts if not p.shape.IsNull()]
def stats(self) -> TopologyStats:
topo = TopologyExplorer(self.shape, ignore_orientation=True)
return TopologyStats(
parts=len(self.display_parts()),
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def part_topology_stats(self, part_id: int) -> TopologyStats:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
return TopologyStats(
parts=1,
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def geometry_stats(self) -> dict[str, object]:
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(self.shape, surface_props)
info: dict[str, object] = {
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(self.shape))
info.update(_shape_volume_info(self.shape))
return info
def refresh_topology(self) -> None:
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
self.faces.clear()
self.face_part_ids.clear()
self.face_solid_ids.clear()
self.edges.clear()
self.edge_part_ids.clear()
self.edge_solid_ids.clear()
self.solids.clear()
self._face_info_cache.clear()
self._edge_info_cache.clear()
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
if part_solids:
for solid in part_solids:
current_solid_id = solid_id
self.solids.append((part.id, solid))
part_solid_entries.append((current_solid_id, solid))
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
topexp.MapShapesAndAncestors(solid, TopAbs_EDGE, TopAbs_SOLID, edge_map)
part_solid_edge_maps.append((current_solid_id, edge_map))
solid_id += 1
part_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(part.shape, TopAbs_EDGE, part_edge_map)
part_edge_ids_by_index: dict[int, int] = {}
for local_edge_index in range(1, part_edge_map.Size() + 1):
edge = part_edge_map.FindKey(local_edge_index)
edge_id = len(self.edges)
part_edge_ids_by_index[local_edge_index] = edge_id
self.edges.append(edge)
self.edge_part_ids.append(part.id)
self.edge_solid_ids.append(_mapped_edge_solid_id(edge, part_solid_edge_maps))
self._edge_face_ids_cache[edge_id] = []
if part_solids:
for current_solid_id, solid in part_solid_entries:
for face in _explore(solid, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(current_solid_id)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
else:
for face in _explore(part.shape, TopAbs_FACE):
face_id = len(self.faces)
self.faces.append(face)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(-1)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
def _cache_face_edge_links(
self,
face_id: int,
face: TopoDS_Shape,
part_edge_map: TopTools_IndexedMapOfShape,
part_edge_ids_by_index: dict[int, int],
) -> None:
face_edge_ids: list[int] = []
face_edge_map = TopTools_IndexedMapOfShape()
topexp.MapShapes(face, TopAbs_EDGE, face_edge_map)
for local_face_edge_index in range(1, face_edge_map.Size() + 1):
local_part_edge_index = part_edge_map.FindIndex(face_edge_map.FindKey(local_face_edge_index))
edge_id = part_edge_ids_by_index.get(local_part_edge_index)
if edge_id is None:
continue
face_edge_ids.append(edge_id)
self._edge_face_ids_cache.setdefault(edge_id, []).append(face_id)
self._face_edge_ids_cache[face_id] = face_edge_ids
def part_by_id(self, part_id: int) -> PartNode | None:
return next((p for p in self.parts if p.id == part_id), None)
def snapshot(self) -> dict[int, TopoDS_Shape]:
return {part.id: part.shape for part in self.parts}
def restore_snapshot(self, snapshot: dict[int, TopoDS_Shape]) -> None:
for part in self.parts:
if part.id in snapshot:
part.shape = snapshot[part.id]
self.refresh_topology()
def face_info(self, face_id: int) -> dict[str, object]:
if face_id in self._face_info_cache:
return dict(self._face_info_cache[face_id])
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": boundary_edges,
}
info.update(_shape_bounds_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=True)
info["radius"] = cyl.Radius()
info["diameter"] = cyl.Radius() * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info.update(self._cylinder_end_opening_info(face_id, surf))
info.update(_cylinder_resize_readiness(info))
info.update(_cylinder_boss_resize_readiness(info))
info.update(_cylinder_depth_readiness(info))
info.update(_cylinder_suppress_readiness(info))
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
self._face_info_cache[face_id] = dict(info)
return dict(info)
def feature_info(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
surface = str(info.get("surface", ""))
if surface == "cylinder":
return self._cylindrical_feature_info(face_id, info)
if surface == "plane":
return self._planar_feature_info(face_id, info)
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "暂不支持的局部曲面候选",
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_highlight_face_ids": (face_id,),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_edit_actions": "当前第一版只能查看该局部曲面,暂不支持直接编辑。",
"feature_mode": "Feature 模式会把选中的 face 解释为局部几何特征候选。",
}
)
return result
def _planar_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
coplanar_face_ids = self._connected_coplanar_planar_face_ids(face_id)
boundary_edge_ids = self._region_boundary_edge_ids(coplanar_face_ids)
if len(coplanar_face_ids) > 1:
scope_note = f"已检测到 {len(coplanar_face_ids)} 个共享边且共面的 face,推拉时会作为同一片平面区域处理。"
else:
scope_note = "当前 face 没有检测到可一起推拉的共享边共面邻居。"
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": "可推拉平面候选",
"feature_source_face_id": face_id,
"feature_face_ids": tuple(coplanar_face_ids),
"feature_highlight_face_ids": tuple(coplanar_face_ids),
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_adjacent_face_ids": tuple(
sorted(set(self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)) - set(coplanar_face_ids))
),
"push_pull_scope_face_ids": tuple(coplanar_face_ids),
"push_pull_scope_face_count": len(coplanar_face_ids),
"push_pull_scope_note": scope_note,
"feature_edit_actions": "推拉平面",
"feature_mode": "这是从 B-Rep 几何推断出的平面编辑候选,不是 CAD 历史特征。",
}
)
return result
def _cylindrical_feature_info(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
boundary_edge_ids = self._face_boundary_edge_ids(face_id)
adjacent_face_ids = self._adjacent_face_ids_for_edges(boundary_edge_ids, face_id)
end_faces = self._cylindrical_end_face_groups(face_id, adjacent_face_ids, info)
end_face_ids = end_faces["end_face_ids"]
bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, info)
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0))
if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选"
else:
feature_type = "圆柱孔候选"
if info.get("cylinder_end_type") == "blind" and bottom_face_ids:
edit_actions = "调整圆柱孔径;调整盲孔深度"
else:
edit_actions = "调整圆柱孔径;孔深调整需要明确盲孔底面"
if angular_span >= math.tau * 0.92:
edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate":
feature_type = "圆角/倒圆候选"
edit_actions = "当前可识别已有圆角半径和相邻支撑面;已有圆角半径修改尚未实现。"
elif guess == "boss/outer-round candidate":
feature_type = "凸台/外圆候选"
edit_actions = "调整圆柱凸台直径。"
else:
feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。"
highlight_face_ids = tuple(
sorted(
{
face_id,
*end_face_ids,
*slot_info.get("feature_slot_boundary_face_ids", ()),
*fillet_info.get("feature_existing_fillet_support_face_ids", ()),
}
)
)
result = dict(info)
result.update(
{
"kind": "feature",
"feature_type": feature_type,
"feature_source_face_id": face_id,
"feature_face_ids": (face_id,),
"feature_side_face_ids": (face_id,),
"feature_end_face_ids": tuple(end_face_ids),
"feature_bottom_face_ids": tuple(bottom_face_ids),
"feature_opening_face_ids": tuple(opening_face_ids),
"feature_highlight_face_ids": highlight_face_ids,
"feature_boundary_edge_ids": tuple(boundary_edge_ids),
"feature_adjacent_face_ids": tuple(adjacent_face_ids),
"feature_start_end_face_ids": tuple(end_faces["start_end_face_ids"]),
"feature_end_end_face_ids": tuple(end_faces["end_end_face_ids"]),
"feature_bottom_confidence": end_faces["bottom_confidence"],
"feature_bottom_detection": end_faces["bottom_detection"],
"feature_bottom_note": end_faces["bottom_note"],
"feature_edit_actions": edit_actions,
"feature_mode": "这是从 B-Rep 圆柱面、相邻面和材料采样推断出的局部特征候选。",
**slot_info,
**fillet_info,
}
)
return result
def _cylindrical_slot_info(
self,
face_id: int,
adjacent_face_ids: list[int],
end_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = float(info.get("angular_span", 0.0))
if guess != "hole/groove candidate" or angular_span >= math.tau * 0.92:
return {}
radius = max(float(info.get("radius", 0.0)), 0.0)
span = min(max(angular_span, 0.0), math.tau)
boundary_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
chord_width = 2.0 * radius * math.sin(span / 2.0) if radius > 0 else 0.0
sagitta_depth = radius * (1.0 - math.cos(min(span, math.pi) / 2.0)) if radius > 0 else 0.0
return {
"slot_kind": "partial-cylindrical-groove",
"slot_status": "candidate",
"slot_angular_span": angular_span,
"slot_open_angle": max(math.tau - span, 0.0),
"slot_chord_width_estimate": chord_width,
"slot_arc_length_estimate": radius * span,
"slot_sagitta_depth_estimate": sagitta_depth,
"feature_slot_face_ids": (face_id,),
"feature_slot_boundary_face_ids": tuple(boundary_face_ids),
"slot_note": (
"这是由局部圆柱面推断出的槽/半孔候选;宽度和深度是几何估算,"
"不是 CAD 历史里的参数。"
),
}
def _cylindrical_existing_fillet_info(
self,
face_id: int,
adjacent_face_ids: list[int],
end_face_ids: Iterable[int],
info: dict[str, object],
) -> dict[str, object]:
if str(info.get("feature_guess", "cylindrical face")) != "round/fillet candidate":
return {}
radius = max(float(info.get("radius", 0.0)), 0.0)
angular_span = min(max(float(info.get("angular_span", 0.0)), 0.0), math.tau)
support_face_ids = sorted(set(adjacent_face_ids) - set(end_face_ids))
return {
"existing_fillet_kind": "cylindrical-round-face",
"existing_fillet_status": "candidate",
"existing_fillet_radius_estimate": radius,
"existing_fillet_angular_span": angular_span,
"existing_fillet_arc_length_estimate": radius * angular_span,
"feature_existing_fillet_face_ids": (face_id,),
"feature_existing_fillet_support_face_ids": tuple(support_face_ids),
"existing_fillet_note": (
"这是由局部小半径圆柱面推断出的已有圆角/倒圆候选;"
"第一版可尝试使用 defeature + 重新倒圆修改半径;"
"复杂 blend 或支撑面不明确时可能失败并回滚。"
),
}
def _cylindrical_end_face_groups(
self,
face_id: int,
adjacent_face_ids: list[int],
info: dict[str, object],
) -> dict[str, object]:
axis_point_values = info.get("axis_point")
axis_values = info.get("axis")
v_range = info.get("v_range")
if not isinstance(axis_point_values, tuple) or not isinstance(axis_values, tuple) or not isinstance(v_range, tuple):
return {
"end_face_ids": [],
"start_end_face_ids": [],
"end_end_face_ids": [],
"bottom_face_ids": [],
"opening_face_ids": [],
"bottom_note": "缺少圆柱轴线或参数范围,无法判断端面/底面。",
}
axis_point = gp_Pnt(*axis_point_values)
axis_dir = gp_Dir(float(axis_values[0]), float(axis_values[1]), float(axis_values[2]))
v_min = min(float(v_range[0]), float(v_range[1]))
v_max = max(float(v_range[0]), float(v_range[1]))
span = max(v_max - v_min, 1e-9)
radius = float(info.get("radius", 0.0))
tolerance = max(span * 0.08, radius * 0.2, 0.05)
start_end_face_ids: list[int] = []
end_end_face_ids: list[int] = []
for adjacent_id in adjacent_face_ids:
match = self._axis_end_match_for_planar_face(
adjacent_id,
axis_point,
axis_dir,
v_min,
v_max,
tolerance,
radial_tolerance=None,
)
if match == "start":
start_end_face_ids.append(adjacent_id)
elif match == "end":
end_end_face_ids.append(adjacent_id)
if info.get("cylinder_end_type") == "blind" and (not start_end_face_ids or not end_end_face_ids):
scanned = self._axis_cap_face_candidates(
face_id,
axis_point,
axis_dir,
v_min,
v_max,
radius,
span,
set(adjacent_face_ids),
)
if not start_end_face_ids:
start_end_face_ids.extend(scanned["start"])
if not end_end_face_ids:
end_end_face_ids.extend(scanned["end"])
bottom_face_ids: list[int] = []
opening_face_ids: list[int] = []
if info.get("start_end_open") is True:
opening_face_ids.extend(start_end_face_ids)
elif info.get("start_end_state") == "inside":
bottom_face_ids.extend(start_end_face_ids)
if info.get("end_end_open") is True:
opening_face_ids.extend(end_end_face_ids)
elif info.get("end_end_state") == "inside":
bottom_face_ids.extend(end_end_face_ids)
end_face_ids = sorted({*start_end_face_ids, *end_end_face_ids})
bottom_face_ids = sorted(set(bottom_face_ids))
opening_face_ids = sorted(set(opening_face_ids))
bottom_detection = "axis-cap-scan" if any(
face_id not in adjacent_face_ids for face_id in bottom_face_ids
) else "adjacent-end-face"
bottom_confidence = "medium" if bottom_detection == "axis-cap-scan" else "high"
if not end_face_ids:
note = "没有在圆柱边界附近找到平面端面。"
elif bottom_face_ids:
if bottom_detection == "axis-cap-scan":
note = "已通过轴线端部采样和轴线附近圆盘面扫描标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
else:
note = "已根据圆柱轴线端部 inside/outside 采样标记疑似底面;这是几何推断,不是 CAD 历史孔深。"
else:
note = "已找到端面候选,但端部采样显示这些端面更像开口附近的相邻面。"
return {
"end_face_ids": end_face_ids,
"start_end_face_ids": sorted(set(start_end_face_ids)),
"end_end_face_ids": sorted(set(end_end_face_ids)),
"bottom_face_ids": bottom_face_ids,
"opening_face_ids": opening_face_ids,
"bottom_confidence": bottom_confidence if bottom_face_ids else "none",
"bottom_detection": bottom_detection if bottom_face_ids else "none",
"bottom_note": note,
}
def _axis_end_match_for_planar_face(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
tolerance: float,
radial_tolerance: float | None,
) -> str | None:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
normal = surf.Plane().Axis().Direction()
if abs(_direction_dot(normal, axis_dir)) < 0.65:
return None
if radial_tolerance is not None:
center = _surface_center(self.faces[face_id])
if _point_axis_distance(axis_point, axis_dir, center) > radial_tolerance:
return None
parameters = _shape_axis_parameters(self.faces[face_id], axis_point, axis_dir)
if not parameters:
return None
start_distance = min(abs(parameter - v_min) for parameter in parameters)
end_distance = min(abs(parameter - v_max) for parameter in parameters)
if min(start_distance, end_distance) > tolerance:
return None
return "start" if start_distance <= end_distance else "end"
def _axis_cap_face_candidates(
self,
face_id: int,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
v_min: float,
v_max: float,
radius: float,
span: float,
adjacent_face_ids: set[int],
) -> dict[str, list[int]]:
source_solid_id = self.face_solid_ids[face_id]
tolerance = max(span * 0.12, radius * 0.35, 0.08)
radial_tolerance = max(radius * 1.2, tolerance)
start: list[int] = []
end: list[int] = []
for candidate_id in range(len(self.faces)):
if candidate_id == face_id or candidate_id in adjacent_face_ids:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
match = self._axis_end_match_for_planar_face(
candidate_id,
axis_point,
axis_dir,
v_min,
v_max,
tolerance,
radial_tolerance=radial_tolerance,
)
if match == "start":
start.append(candidate_id)
elif match == "end":
end.append(candidate_id)
return {"start": sorted(set(start)), "end": sorted(set(end))}
def _bottom_face_axis_parameter(
self,
bottom_face_ids: Iterable[int],
axis_point: gp_Pnt,
axis_dir: gp_Dir,
expected_parameter: float,
) -> float | None:
candidates: list[float] = []
for bottom_face_id in bottom_face_ids:
if bottom_face_id < 0 or bottom_face_id >= len(self.faces):
continue
parameters = _shape_axis_parameters(self.faces[bottom_face_id], axis_point, axis_dir)
if not parameters:
continue
candidates.append(sum(parameters) / len(parameters))
if not candidates:
return None
return min(candidates, key=lambda parameter: abs(parameter - expected_parameter))
def _face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id in self._face_edge_ids_cache:
return list(self._face_edge_ids_cache[face_id])
if face_id < 0 or face_id >= len(self.faces):
return []
face_edges = list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges())
edge_ids: list[int] = []
for edge_id, edge in enumerate(self.edges):
if any(_same_shape(edge, face_edge) for face_edge in face_edges):
edge_ids.append(edge_id)
self._face_edge_ids_cache[face_id] = list(edge_ids)
return edge_ids
def face_boundary_edge_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
return self._face_boundary_edge_ids(face_id)
def nearest_edge_id_to_point(
self,
edge_ids: Iterable[int],
point: tuple[float, float, float] | None,
) -> int | None:
valid_edge_ids = [int(edge_id) for edge_id in edge_ids if 0 <= int(edge_id) < len(self.edges)]
if not valid_edge_ids:
return None
if point is None:
return valid_edge_ids[0]
px, py, pz = (float(point[0]), float(point[1]), float(point[2]))
best_edge_id: int | None = None
best_distance = math.inf
for edge_id in valid_edge_ids:
try:
samples = discretize_edge(self.edges[edge_id], 0.35)
except Exception:
samples = []
if len(samples) < 2:
try:
curve = BRepAdaptor_Curve(self.edges[edge_id])
samples = [
_point_tuple(curve.Value(curve.FirstParameter())),
_point_tuple(curve.Value(curve.LastParameter())),
]
except Exception:
samples = []
if not samples:
continue
sample_points = [(float(coords[0]), float(coords[1]), float(coords[2])) for coords in samples]
if len(sample_points) == 1:
distance = _point_distance_sq((px, py, pz), sample_points[0])
else:
distance = min(
_point_segment_distance_sq((px, py, pz), start, end)
for start, end in zip(sample_points, sample_points[1:])
)
if distance < best_distance:
best_distance = distance
best_edge_id = edge_id
return best_edge_id if best_edge_id is not None else valid_edge_ids[0]
def _adjacent_face_ids_for_edges(self, edge_ids: list[int], face_id: int) -> list[int]:
if not edge_ids:
return []
target_edges = [self.edges[edge_id] for edge_id in edge_ids if 0 <= edge_id < len(self.edges)]
source_solid_id = self.face_solid_ids[face_id]
adjacent: list[int] = []
for candidate_id, candidate in enumerate(self.faces):
if candidate_id == face_id:
continue
if source_solid_id >= 0 and self.face_solid_ids[candidate_id] != source_solid_id:
continue
candidate_edges = TopologyExplorer(candidate, ignore_orientation=True).edges()
if any(_same_shape(candidate_edge, target_edge) for candidate_edge in candidate_edges for target_edge in target_edges):
adjacent.append(candidate_id)
return adjacent
def _connected_coplanar_planar_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
source_surf = BRepAdaptor_Surface(self.faces[face_id])
if source_surf.GetType() != GeomAbs_Plane:
return [face_id]
source_solid_id = self.face_solid_ids[face_id]
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
visited = {face_id}
queue = [face_id]
while queue:
current_id = queue.pop(0)
for adjacent_id in self._adjacent_face_ids_for_edges(self._face_boundary_edge_ids(current_id), current_id):
if adjacent_id in visited:
continue
if source_solid_id >= 0 and self.face_solid_ids[adjacent_id] != source_solid_id:
continue
candidate_surf = BRepAdaptor_Surface(self.faces[adjacent_id])
if _surfaces_are_coplanar(source_surf, candidate_surf, tolerance):
visited.add(adjacent_id)
queue.append(adjacent_id)
return sorted(visited)
def _region_boundary_edge_ids(self, face_ids: Iterable[int]) -> list[int]:
counts: dict[int, int] = {}
for face_id in face_ids:
for edge_id in self._face_boundary_edge_ids(face_id):
counts[edge_id] = counts.get(edge_id, 0) + 1
return sorted(edge_id for edge_id, count in counts.items() if count == 1)
def _push_pull_profile_shape(self, face_ids: Iterable[int]) -> TopoDS_Shape:
profile_faces = [self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces)]
if not profile_faces:
raise ValueError("No planar faces were found for push/pull.")
return _unify_same_domain_shape(_compound_from_shapes(profile_faces))
def edge_info(self, edge_id: int) -> dict[str, object]:
if edge_id in self._edge_info_cache:
return dict(self._edge_info_cache[edge_id])
edge = self.edges[edge_id]
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
info: dict[str, object] = {
"kind": "edge",
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": self._edge_solid_id(edge_id),
"orientation": _orientation_name(edge.Orientation()),
"curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"),
"length": props.Mass(),
"length_center": _point_tuple(props.CentreOfMass()),
"first_parameter": curve.FirstParameter(),
"last_parameter": curve.LastParameter(),
"start_point": _point_tuple(curve.Value(curve.FirstParameter())),
"end_point": _point_tuple(curve.Value(curve.LastParameter())),
}
info.update(_shape_bounds_info(edge))
if curve_type == GeomAbs_Line:
line = curve.Line()
info["line_origin"] = _point_tuple(line.Location())
info["direction"] = _dir_tuple(line.Direction())
if curve_type == GeomAbs_Circle:
circle = curve.Circle()
info["center"] = _point_tuple(circle.Location())
info["axis"] = _dir_tuple(circle.Axis().Direction())
info["radius"] = circle.Radius()
info["diameter"] = circle.Radius() * 2.0
adjacent_face_ids = self._edge_adjacent_face_ids(edge_id)
info["adjacent_face_ids"] = tuple(adjacent_face_ids)
info["adjacent_face_count"] = len(adjacent_face_ids)
self._edge_info_cache[edge_id] = dict(info)
return dict(info)
def _edge_solid_id(self, edge_id: int) -> int:
if edge_id < 0 or edge_id >= len(self.edges):
return -1
return self.edge_solid_ids[edge_id] if edge_id < len(self.edge_solid_ids) else -1
def edge_ids_for_solid(self, solid_id: int) -> list[int]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
return [edge_id for edge_id in range(len(self.edges)) if self._edge_solid_id(edge_id) == solid_id]
def _edge_adjacent_face_ids(self, edge_id: int) -> list[int]:
if edge_id < 0 or edge_id >= len(self.edges):
return []
if edge_id in self._edge_face_ids_cache:
return list(self._edge_face_ids_cache[edge_id])
edge = self.edges[edge_id]
part_id = self.edge_part_ids[edge_id]
solid_id = self._edge_solid_id(edge_id)
adjacent: list[int] = []
for face_id, face in enumerate(self.faces):
if self.face_part_ids[face_id] != part_id:
continue
if solid_id >= 0 and self.face_solid_ids[face_id] != solid_id:
continue
if any(_same_shape(candidate, edge) for candidate in TopologyExplorer(face, ignore_orientation=True).edges()):
adjacent.append(face_id)
return adjacent
def solid_info(self, solid_id: int) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
topo = TopologyExplorer(solid, ignore_orientation=True)
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(solid, surface_props)
info: dict[str, object] = {
"kind": "solid",
"solid_id": solid_id,
"part_id": part_id,
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(solid))
info.update(_shape_volume_info(solid))
return info
def part_info(self, part_id: int) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
info: dict[str, object] = {
"kind": part.kind,
"part_id": part.id,
"name": part.name,
"path": part.path,
"parent_id": part.parent_id if part.parent_id is not None else "",
"depth": part.depth,
"solids": len(list(topo.solids())),
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
}
info.update(_shape_bounds_info(part.shape))
info.update(_shape_volume_info(part.shape))
return info
def editable_feature_candidates(
self,
limit: int = 160,
detailed: bool = False,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
per_type_limit = max(1, limit // 5)
candidates: list[dict[str, object]] = []
diameter_count = 0
boss_diameter_count = 0
depth_count = 0
suppress_count = 0
existing_fillet_count = 0
depth_limit = max(2, min(per_type_limit, limit // 12))
suppress_limit = max(2, min(per_type_limit, limit // 12))
existing_fillet_limit = max(2, min(per_type_limit, limit // 12))
cylinder_scan_limit = max(per_type_limit * 4, 24)
for item in self.cylindrical_feature_candidates(
limit=cylinder_scan_limit,
include_end_info=True,
progress_callback=progress_callback,
):
feature_guess = str(item["feature_guess"])
if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate":
feature = self.feature_info(int(item["face_id"]))
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
support_note = (
f"支撑 Face: {support_face_ids}。"
if support_face_ids
else "暂未识别出稳定支撑 Face。"
)
candidates.append(
{
"operation_key": "inspect_existing_fillet",
"operation": "修改已有圆角半径",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": feature_guess,
"current_value": feature.get("existing_fillet_radius_estimate", item["radius"]),
"current_value_label": "radius",
"status": "caution",
"risk": "medium" if len(support_face_ids) >= 2 else "high",
"confidence": item["confidence"],
"note": (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径,"
"再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。"
f" {support_note}"
),
}
)
existing_fillet_count += 1
if diameter_count < per_type_limit and feature_guess != "round/fillet candidate":
candidates.append(
{
"operation_key": "resize_cylinder",
"operation": "调整圆柱孔径",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": feature_guess,
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": item["resize_status"],
"risk": item["resize_risk"],
"confidence": item["confidence"],
"note": item["resize_note"],
}
)
diameter_count += 1
boss_info = _cylinder_boss_resize_readiness(item)
if boss_diameter_count < per_type_limit and boss_info["boss_resize_status"] != "blocked":
candidates.append(
{
"operation_key": "resize_boss",
"operation": "调整圆柱凸台直径",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": boss_info["boss_resize_status"],
"risk": boss_info["boss_resize_risk"],
"confidence": item["confidence"],
"note": boss_info["boss_resize_note"],
}
)
boss_diameter_count += 1
suppress_info = _cylinder_suppress_readiness(item)
if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked":
candidates.append(
{
"operation_key": "suppress_cylinder",
"operation": "封堵圆柱孔",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": suppress_info["suppress_status"],
"risk": suppress_info["suppress_risk"],
"confidence": item["confidence"],
"note": suppress_info["suppress_note"],
}
)
suppress_count += 1
depth_info = _cylinder_depth_readiness(item)
if depth_count < depth_limit and depth_info["depth_status"] != "blocked":
feature = self.feature_info(int(item["face_id"]))
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
if not bottom_face_ids:
continue
depth_context = self._blind_cylindrical_depth_context(
int(item["face_id"]),
item,
feature,
float(item["hole_depth_estimate"]),
)
current_depth = float(depth_context.get("depth_current_depth", item["hole_depth_estimate"]))
candidates.append(
{
"operation_key": "resize_depth",
"operation": "调整盲孔深度",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": current_depth,
"current_value_label": "depth",
"status": depth_info["depth_status"],
"risk": depth_info["depth_risk"],
"confidence": item["confidence"],
"note": (
f"{depth_info['depth_note']} "
f"底面: {bottom_face_ids}; "
f"来源: {feature.get('feature_bottom_detection')}; "
f"深度来源: {depth_context.get('depth_current_depth_source', 'cylinder-v-range')}。"
),
}
)
depth_count += 1
if (
diameter_count >= per_type_limit
and boss_diameter_count >= per_type_limit
and suppress_count >= suppress_limit
and depth_count >= depth_limit
and existing_fillet_count >= existing_fillet_limit
):
break
plane_count = 0
for face_id, face in enumerate(self.faces):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if plane_count >= per_type_limit:
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
confidence = str(direction_info["confidence"])
risk = "low" if confidence == "high" else "medium"
status = "ready" if confidence == "high" else "caution"
note = str(direction_info["note"])
else:
confidence = "pending"
risk = "medium"
status = "caution"
note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。"
candidates.append(
{
"operation_key": "push_pull_plane",
"operation": "推拉平面",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "planar push/pull candidate",
"current_value": props.Mass(),
"current_value_label": "area",
"status": status,
"risk": risk,
"confidence": confidence,
"note": note,
}
)
plane_count += 1
fillet_edge_count = 0
chamfer_edge_count = 0
edge_length_count = 0
edge_type_limit = max(1, per_type_limit // 3)
for edge_id, edge in enumerate(self.edges):
if progress_callback is not None and edge_id % 80 == 0:
progress_callback()
if (
fillet_edge_count >= edge_type_limit
and chamfer_edge_count >= edge_type_limit
and edge_length_count >= edge_type_limit
):
break
curve = BRepAdaptor_Curve(edge)
if curve.GetType() != GeomAbs_Line:
continue
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
length = props.Mass()
if length <= 1e-9:
continue
solid_id = self._edge_solid_id(edge_id)
if fillet_edge_count < edge_type_limit:
candidates.append(
{
"operation_key": "fillet_edge",
"operation": "给边添加圆角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": "linear edge fillet candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:添加圆角半径会在执行前根据边长和相邻面再详细判断。",
}
)
fillet_edge_count += 1
if chamfer_edge_count < edge_type_limit:
candidates.append(
{
"operation_key": "chamfer_edge",
"operation": "给边添加倒角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": "linear edge chamfer candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:倒角距离会在执行前根据边长和相邻面再详细判断。",
}
)
chamfer_edge_count += 1
if edge_length_count < edge_type_limit:
candidates.append(
{
"operation_key": "resize_edge_length",
"operation": "调整直线边长度",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": "linear edge length candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:第一版边长调整会在执行前尝试寻找可推拉的端面,找不到端面会阻止。",
}
)
edge_length_count += 1
status_order = {"ready": 0, "caution": 1, "blocked": 2}
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
operation_order = {
"resize_cylinder": 0,
"resize_boss": 1,
"suppress_cylinder": 2,
"resize_depth": 3,
"inspect_existing_fillet": 4,
"push_pull_plane": 5,
"fillet_edge": 6,
"chamfer_edge": 7,
"resize_edge_length": 8,
}
candidates.sort(
key=lambda item: (
status_order.get(str(item["status"]), 9),
risk_order.get(str(item["risk"]), 9),
operation_order.get(str(item["operation_key"]), 9),
int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))),
)
)
return candidates[:limit]
def cylindrical_feature_candidates(
self,
limit: int = 100,
include_end_info: bool = False,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
candidates: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
v_span = abs(surf.LastVParameter() - surf.FirstVParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
height_estimate = props.Mass() / swept_area
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
classification = self._classify_cylindrical_face(face_id, surf)
candidate = {
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"radius": radius,
"diameter": radius * 2.0,
"axis": _dir_tuple(cyl.Axis().Direction()),
"area": props.Mass(),
"angular_span": u_span,
"height_estimate": height_estimate,
"param_height": v_span,
"boundary_edges": boundary_edges,
"feature_guess": classification["feature_guess"],
"material_toward_axis": classification["toward_axis"],
"material_away_axis": classification["away_axis"],
"material_vote_summary": classification["vote_summary"],
"material_sample_count": classification["sample_count"],
"confidence": classification["confidence"],
"note": classification["note"],
}
if include_end_info:
candidate.update(self._cylinder_end_opening_info(face_id, surf))
candidate.update(_cylinder_resize_readiness(candidate))
candidate.update(_cylinder_boss_resize_readiness(candidate))
candidates.append(candidate)
if len(candidates) >= limit:
break
return candidates
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。",
}
current_diameter = float(info["diameter"])
readiness = _cylinder_resize_readiness(info, new_diameter)
resize_mode = _resize_mode(current_diameter, new_diameter)
delta_diameter = new_diameter - current_diameter
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
height_estimate = float(info.get("height_estimate", 0.0))
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else ""
feature = self.feature_info(face_id)
cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature)
fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {}
return {
"status": readiness["resize_status"],
"risk": readiness["resize_risk"],
"message": readiness["resize_note"],
"warnings": readiness["resize_warnings"],
"blockers": readiness["resize_blockers"],
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"current_diameter": current_diameter,
"target_diameter": new_diameter,
"delta_diameter": delta_diameter,
"diameter_delta_ratio": diameter_delta_ratio,
"target_to_height_ratio": target_to_height_ratio,
"resize_mode": resize_mode,
"feature_type": feature.get("feature_type"),
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": info.get("height_estimate"),
"material_vote_summary": info.get("material_vote_summary"),
"material_sample_count": info.get("material_sample_count"),
**cutter_plan,
**fill_plan,
}
def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。",
}
current_diameter = float(info["diameter"])
readiness = _cylinder_boss_resize_readiness(info, new_diameter)
resize_mode = _resize_mode(current_diameter, new_diameter)
delta_diameter = new_diameter - current_diameter
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
height_estimate = float(info.get("height_estimate", 0.0))
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else ""
feature = self.feature_info(face_id)
tool_plan = self._bounded_boss_resize_tool_plan(face_id, new_diameter)
return {
"status": readiness["boss_resize_status"],
"risk": readiness["boss_resize_risk"],
"message": readiness["boss_resize_note"],
"warnings": readiness["boss_resize_warnings"],
"blockers": readiness["boss_resize_blockers"],
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"current_diameter": current_diameter,
"target_diameter": new_diameter,
"delta_diameter": delta_diameter,
"diameter_delta_ratio": diameter_delta_ratio,
"target_to_height_ratio": target_to_height_ratio,
"resize_mode": resize_mode,
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": info.get("height_estimate"),
"material_vote_summary": info.get("material_vote_summary"),
"material_sample_count": info.get("material_sample_count"),
"feature_adjacent_face_ids": feature.get("feature_adjacent_face_ids"),
"feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"),
**tool_plan,
}
def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能封堵圆柱孔。",
}
readiness = _cylinder_suppress_readiness(info)
feature = self.feature_info(face_id)
fill_plan = self._bounded_cylinder_fill_plan(face_id)
return {
"status": readiness["suppress_status"],
"risk": readiness["suppress_risk"],
"message": readiness["suppress_note"],
"warnings": readiness["suppress_warnings"],
"blockers": readiness["suppress_blockers"],
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"angular_span": info.get("angular_span"),
"height_estimate": info.get("height_estimate"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"material_vote_summary": info.get("material_vote_summary"),
"cylinder_end_type": info.get("cylinder_end_type"),
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"),
**fill_plan,
}
def cylindrical_depth_plan(self, face_id: int, target_depth: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能调整盲孔深度。",
}
feature = self.feature_info(face_id)
context = self._blind_cylindrical_depth_context(face_id, info, feature, target_depth)
depth_info = dict(info)
if context.get("context_status") == "ready" and isinstance(context.get("depth_current_depth"), (int, float)):
depth_info["hole_depth_estimate"] = float(context["depth_current_depth"])
readiness = _cylinder_depth_readiness(depth_info, target_depth)
if context.get("context_status") == "blocked":
readiness = dict(readiness)
readiness["depth_status"] = "blocked"
readiness["depth_risk"] = "blocked"
readiness["depth_blockers"] = _join_nonempty(
readiness.get("depth_blockers"),
context.get("context_message"),
)
readiness["depth_note"] = readiness["depth_blockers"]
current_depth = float(depth_info.get("hole_depth_estimate", 0.0))
delta_depth = target_depth - current_depth
depth_delta_ratio = abs(delta_depth) / max(current_depth, 1e-9)
plan = {
"status": readiness["depth_status"],
"risk": readiness["depth_risk"],
"message": readiness["depth_note"],
"warnings": readiness["depth_warnings"],
"blockers": readiness["depth_blockers"],
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"current_depth": current_depth,
"target_depth": target_depth,
"delta_depth": delta_depth,
"depth_delta_ratio": depth_delta_ratio,
"depth_mode": "deepen" if delta_depth > 0 else "shallow",
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"material_vote_summary": info.get("material_vote_summary"),
"cylinder_end_type": info.get("cylinder_end_type"),
"start_end_state": info.get("start_end_state"),
"end_end_state": info.get("end_end_state"),
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_confidence": feature.get("feature_bottom_confidence"),
"feature_bottom_detection": feature.get("feature_bottom_detection"),
"feature_bottom_note": feature.get("feature_bottom_note"),
}
plan.update(context)
return plan
def _blind_cylindrical_depth_context(
self,
face_id: int,
info: dict[str, object],
feature: dict[str, object],
target_depth: float,
) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"context_status": "blocked",
"context_message": "当前选中的 face 不是圆柱面。",
}
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
if info.get("cylinder_end_type") != "blind" or not bottom_face_ids:
return {
"context_status": "blocked",
"context_message": "第一版孔深调整只支持已经识别出疑似底面的盲孔/盲槽。",
}
start_open = info.get("start_end_open") is True
end_open = info.get("end_end_open") is True
if start_open == end_open:
return {
"context_status": "blocked",
"context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。",
}
cyl = surf.Cylinder()
radius = float(cyl.Radius())
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
v_min = min(float(surf.FirstVParameter()), float(surf.LastVParameter()))
v_max = max(float(surf.FirstVParameter()), float(surf.LastVParameter()))
if start_open:
open_parameter = v_min
nominal_bottom_parameter = v_max
direction_sign = 1.0
else:
open_parameter = v_max
nominal_bottom_parameter = v_min
direction_sign = -1.0
bottom_parameter = self._bottom_face_axis_parameter(
bottom_face_ids,
axis_point,
axis_dir,
nominal_bottom_parameter,
)
current_depth_source = "bottom-face-axis-parameter" if bottom_parameter is not None else "cylinder-v-range"
if bottom_parameter is None:
bottom_parameter = nominal_bottom_parameter
current_depth = max(abs(bottom_parameter - open_parameter), 1e-9)
target_bottom_parameter = open_parameter + direction_sign * target_depth
delta_depth = target_depth - current_depth
depth_mode = "deepen" if delta_depth > 0 else "shallow"
tool_direction = (
axis_dir.X() * direction_sign,
axis_dir.Y() * direction_sign,
axis_dir.Z() * direction_sign,
)
open_margin = min(max(radius * 0.05, abs(delta_depth) * 0.2, 0.02), max(current_depth * 0.1, 0.2))
bottom_overlap = min(max(radius * 0.02, abs(delta_depth) * 0.05, 0.01), max(current_depth * 0.03, 0.08))
if depth_mode == "deepen":
start_parameter = open_parameter - direction_sign * open_margin
end_parameter = target_bottom_parameter
tool_height = target_depth + open_margin
tool_role = "cutter"
tool_strategy = "bounded-blind-depth-cut"
tool_radius = radius
radius_overlap = 0.0
tool_note = "加深盲孔:沿识别出的开口到疑似底面方向,使用有限长度圆柱 cutter 延伸切削。"
else:
start_parameter = target_bottom_parameter
end_parameter = bottom_parameter + direction_sign * bottom_overlap
tool_height = current_depth - target_depth + bottom_overlap
tool_role = "fill"
tool_strategy = "bounded-bottom-fill"
radius_overlap = min(max(radius * 0.001, 0.001), 0.05)
tool_radius = radius + radius_overlap
tool_note = "变浅盲孔:从目标新底面到旧底面方向补料,并让补料半径略有重叠以便和原实体合并。"
return {
"context_status": "ready",
"depth_tool_strategy": tool_strategy,
"depth_tool_role": tool_role,
"depth_tool_note": tool_note,
"depth_axis_direction": tool_direction,
"depth_open_parameter": open_parameter,
"depth_bottom_parameter": bottom_parameter,
"depth_nominal_bottom_parameter": nominal_bottom_parameter,
"depth_bottom_parameter_source": current_depth_source,
"depth_current_depth": current_depth,
"depth_current_depth_source": current_depth_source,
"depth_target_bottom_parameter": target_bottom_parameter,
"depth_tool_start_parameter": start_parameter,
"depth_tool_end_parameter": end_parameter,
"depth_tool_height": max(tool_height, 1e-6),
"depth_tool_radius": tool_radius,
"depth_tool_radius_overlap": radius_overlap,
"depth_open_point": _point_tuple(_point_on_axis(axis_point, axis_dir, open_parameter)),
"depth_current_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, bottom_parameter)),
"depth_target_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, target_bottom_parameter)),
"depth_tool_start_point": _point_tuple(_point_on_axis(axis_point, axis_dir, start_parameter)),
}
def _bounded_cylinder_cutter_plan(
self,
face_id: int,
new_diameter: float,
feature: dict[str, object] | None = None,
) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"cutter_strategy": "unavailable",
"cutter_note": "selected face is not cylindrical",
}
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
span = max(v_max - v_min, 0.0)
end_info = self._cylinder_end_opening_info(face_id, surf)
base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2))
closed_margin = min(base_margin, max(span * 0.005, 0.02))
start_margin = base_margin if end_info["start_end_open"] else closed_margin
end_margin = base_margin if end_info["end_end_open"] else closed_margin
feature = feature or self.feature_info(face_id)
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
opening_face_ids = tuple(feature.get("feature_opening_face_ids", ()))
bottom_protection = bool(bottom_face_ids)
if bottom_protection:
bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。"
else:
bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。"
start_parameter = v_min - start_margin
end_parameter = v_max + end_margin
height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = gp_Pnt(
axis_point.X() + direction.X() * start_parameter,
axis_point.Y() + direction.Y() * start_parameter,
axis_point.Z() + direction.Z() * start_parameter,
)
return {
"cutter_strategy": "bounded-to-selected-cylinder-v-range",
"cutter_note": "有限长度切削:按选中圆柱面的 V 参数范围生成 cutter,减少贯穿整个零件的误切风险。",
"cutter_start_parameter": start_parameter,
"cutter_end_parameter": end_parameter,
"cutter_height": height,
"cutter_margin": base_margin,
"cutter_start_margin": start_margin,
"cutter_end_margin": end_margin,
"cutter_radius": new_radius,
"cutter_axis_point": _point_tuple(axis_point),
"cutter_axis_direction": _dir_tuple(direction),
"cutter_start_point": _point_tuple(start),
"cutter_bottom_protection": bottom_protection,
"cutter_protected_bottom_face_ids": bottom_face_ids,
"cutter_opening_face_ids": opening_face_ids,
"cutter_bottom_note": bottom_note,
**end_info,
}
def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"fill_strategy": "unavailable",
"fill_note": "selected face is not cylindrical",
}
cyl = surf.Cylinder()
radius = cyl.Radius()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
height = max(v_max - v_min, 1e-6)
overlap = min(max(radius * 0.001, 0.001), 0.05)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = _point_on_axis(axis_point, direction, v_min)
return {
"fill_strategy": "bounded-fill-then-recut",
"fill_note": "缩小孔径实验策略:先在原圆柱面范围内补料,再按目标直径重切。补料不向开口端外伸。",
"fill_start_parameter": v_min,
"fill_end_parameter": v_max,
"fill_height": height,
"fill_radius": radius + overlap,
"fill_radius_overlap": overlap,
"fill_start_point": _point_tuple(start),
}
def _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"boss_tool_strategy": "unavailable",
"boss_tool_note": "selected face is not cylindrical",
}
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
span = max(v_max - v_min, 1e-6)
delta_radius = abs(new_radius - old_radius)
base_radius = max(old_radius, new_radius)
axial_margin = min(
max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001),
max(span * 0.01, 0.02),
)
radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05)
start_parameter = v_min - axial_margin
end_parameter = v_max + axial_margin
height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = _point_on_axis(axis_point, direction, start_parameter)
resize_mode = _resize_mode(old_radius * 2.0, new_diameter)
return {
"boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "bounded-annular-cut",
"boss_tool_note": (
"扩大凸台会在选中圆柱面的 V 范围内生成目标半径圆柱并 Fuse;"
"缩小凸台会生成环形 cutter 并 Cut。第一版会给轴向两端保留少量重叠,"
"让布尔结果更容易和原实体合并。"
),
"boss_tool_start_parameter": start_parameter,
"boss_tool_end_parameter": end_parameter,
"boss_tool_height": height,
"boss_tool_axial_margin": axial_margin,
"boss_tool_radius": new_radius,
"boss_tool_old_radius": old_radius,
"boss_tool_outer_radius": old_radius + radial_overlap if resize_mode == "shrink" else new_radius,
"boss_tool_inner_radius": new_radius if resize_mode == "shrink" else "",
"boss_tool_radial_overlap": radial_overlap if resize_mode == "shrink" else "",
"boss_tool_axis_point": _point_tuple(axis_point),
"boss_tool_axis_direction": _dir_tuple(direction),
"boss_tool_start_point": _point_tuple(start),
}
def _cylinder_end_opening_info(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
fallback = {
"cylinder_end_type": "unknown",
"hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()),
"start_end_state": "unknown",
"end_end_state": "unknown",
"start_end_open": False,
"end_end_open": False,
"open_end_count": 0,
"closed_end_count": 0,
"end_sample_offset": "",
"end_sample_note": "no owning solid was found",
}
if solid_id < 0 or solid_id >= len(self.solids):
return fallback
solid = self.solids[solid_id][1]
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
direction = axis.Direction()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
span = max(v_max - v_min, 0.0)
radius = cyl.Radius()
offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2))
start_probe = _point_on_axis(axis_point, direction, v_min - offset)
end_probe = _point_on_axis(axis_point, direction, v_max + offset)
start_state = _solid_state(solid, start_probe)
end_state = _solid_state(solid, end_probe)
start_open = start_state == "outside"
end_open = end_state == "outside"
start_closed = start_state == "inside"
end_closed = end_state == "inside"
open_count = int(start_open) + int(end_open)
closed_count = int(start_closed) + int(end_closed)
if open_count == 2:
end_type = "through/open-ended"
note = "both axis-end probes are outside material"
elif open_count == 1 and closed_count == 1:
end_type = "blind"
note = "one axis-end probe is outside material and the other is inside material"
elif closed_count == 2:
end_type = "closed/internal"
note = "both axis-end probes are inside material"
else:
end_type = "unclear"
note = "axis-end probes did not produce a clear open/closed pattern"
return {
"cylinder_end_type": end_type,
"hole_depth_estimate": span,
"start_end_state": start_state,
"end_end_state": end_state,
"start_end_open": start_open,
"end_end_open": end_open,
"open_end_count": open_count,
"closed_end_count": closed_count,
"end_sample_offset": offset,
"end_sample_note": note,
}
def _classify_cylindrical_face(
self,
face_id: int,
surf: BRepAdaptor_Surface,
detailed: bool = False,
) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "no owning solid was found",
}
solid = self.solids[solid_id][1]
radius = surf.Cylinder().Radius()
angular_span = abs(surf.LastUParameter() - surf.FirstUParameter())
boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()))
solid_diagonal = _shape_diagonal(solid)
is_partial_cylinder = angular_span < math.tau * 0.92
is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04
is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12
is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05
is_fillet_like_partial = (
is_partial_cylinder
and is_quarter_roundish
and is_fillet_radius
and boundary_edges >= 4
)
samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed)
sample_count = len(samples)
if sample_count == 0:
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "could not sample cylinder material sides",
}
toward_states = [sample["toward"] for sample in samples]
away_states = [sample["away"] for sample in samples]
hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside")
boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside")
unclear_votes = sample_count - hole_votes - boss_votes
vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}"
threshold = max(1, math.ceil(sample_count * 0.6))
base = {
"toward_axis": _state_summary(toward_states),
"away_axis": _state_summary(away_states),
"vote_summary": vote_summary,
"sample_count": sample_count,
}
if hole_votes >= threshold:
confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium"
return {
"feature_guess": "hole/groove candidate",
"confidence": confidence,
"note": "axis side is mostly empty and outer side is mostly material",
**base,
}
if is_partial_cylinder and (is_small_radius or is_fillet_like_partial):
return {
"feature_guess": "round/fillet candidate",
"confidence": "medium" if boundary_edges >= 4 else "low",
"note": "partial small-radius cylinder; may be a fillet or blend",
**base,
}
if boss_votes >= threshold:
return {
"feature_guess": "boss/outer-round candidate",
"confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium",
"note": "axis side is mostly material and outer side is mostly empty",
**base,
}
return {
"feature_guess": "cylindrical face",
"confidence": "low",
"note": "material sampling did not produce a clear inside/outside pattern",
**base,
}
def _sample_cylinder_material_states(
self,
surf: BRepAdaptor_Surface,
solid: TopoDS_Shape,
detailed: bool = False,
) -> list[dict[str, str]]:
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
radius = cyl.Radius()
u_first = surf.FirstUParameter()
u_last = surf.LastUParameter()
v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0
u_span = u_last - u_first
fractions = [0.5]
if detailed and abs(u_span) > 0.2:
fractions = [0.25, 0.5, 0.75]
samples: list[dict[str, str]] = []
for fraction in fractions:
u = u_first + u_span * fraction
point = surf.Value(u, v)
axis_to_point = _vec_from_points(axis_point, point)
projection = _dot(axis_to_point, axis_dir)
center = gp_Pnt(
axis_point.X() + axis_dir.X() * projection,
axis_point.Y() + axis_dir.Y() * projection,
axis_point.Z() + axis_dir.Z() * projection,
)
radial = _vec_from_points(center, point)
radial_len = radial.Magnitude()
if radial_len <= 1e-9:
continue
unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len)
epsilon = min(max(radius * 0.03, 0.05), 1.0)
toward_point = gp_Pnt(
point.X() - unit.X() * epsilon,
point.Y() - unit.Y() * epsilon,
point.Z() - unit.Z() * epsilon,
)
away_point = gp_Pnt(
point.X() + unit.X() * epsilon,
point.Y() + unit.Y() * epsilon,
point.Z() + unit.Z() * epsilon,
)
samples.append(
{
"toward": _solid_state(solid, toward_point),
"away": _solid_state(solid, away_point),
}
)
return samples
def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
face = self.faces[face_id]
direction = surf.Plane().Axis().Direction()
axis_tuple = _dir_tuple(direction)
oriented_tuple = _oriented_dir_tuple(direction, face)
fallback = {
"outward_direction": oriented_tuple,
"inward_direction": _neg_tuple(oriented_tuple),
"plus_side_state": "unknown",
"minus_side_state": "unknown",
"confidence": "low",
"note": "falling back to topology-oriented plane normal",
}
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
fallback["note"] = "no owning solid was found; using topology-oriented plane normal"
return fallback
solid = self.solids[solid_id][1]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
sample = props.CentreOfMass()
diagonal = _shape_diagonal(solid)
epsilon = min(max(diagonal * 1e-4, 0.05), 1.0)
plus_point = gp_Pnt(
sample.X() + direction.X() * epsilon,
sample.Y() + direction.Y() * epsilon,
sample.Z() + direction.Z() * epsilon,
)
minus_point = gp_Pnt(
sample.X() - direction.X() * epsilon,
sample.Y() - direction.Y() * epsilon,
sample.Z() - direction.Z() * epsilon,
)
plus_state = _solid_state(solid, plus_point)
minus_state = _solid_state(solid, minus_point)
if plus_state == "outside" and minus_state == "inside":
return {
"outward_direction": axis_tuple,
"inward_direction": _neg_tuple(axis_tuple),
"plus_side_state": plus_state,
"minus_side_state": minus_state,
"confidence": "high",
"note": "positive plane normal side is outside material",
}
if plus_state == "inside" and minus_state == "outside":
return {
"outward_direction": _neg_tuple(axis_tuple),
"inward_direction": axis_tuple,
"plus_side_state": plus_state,
"minus_side_state": minus_state,
"confidence": "high",
"note": "negative plane normal side is outside material",
}
fallback["plus_side_state"] = plus_state
fallback["minus_side_state"] = minus_state
fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal"
return fallback
def export_all(self, filename: str | Path) -> None:
export_shape = _compound_from_shapes(
_prepare_shape_for_step_export(part.shape) for part in self.display_parts()
)
_write_step(export_shape, Path(filename))
def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]:
if scope == "all":
return _shape_quality_info("当前完整模型", self.shape, expect_solid=False)
if scope == "part":
if target_id is None:
raise ValueError("Part id is required.")
part = self.part_by_id(target_id)
if part is None:
raise ValueError(f"Unknown part id {target_id}")
info = _shape_quality_info(f"零件 {part.id}: {part.name}", part.shape, expect_solid=True)
info["part_id"] = part.id
return info
if scope == "solid":
if target_id is None or target_id < 0 or target_id >= len(self.solids):
raise ValueError(f"Unknown solid id {target_id}")
part_id, solid = self.solids[target_id]
info = _shape_quality_info(f"Solid {target_id}", solid, expect_solid=True)
info["part_id"] = part_id
info["solid_id"] = target_id
return info
if scope == "face":
if target_id is None or target_id < 0 or target_id >= len(self.faces):
raise ValueError(f"Unknown face id {target_id}")
info = _shape_quality_info(f"Face {target_id}", self.faces[target_id], expect_solid=False)
info["part_id"] = self.face_part_ids[target_id]
info["solid_id"] = self.face_solid_ids[target_id]
info["face_id"] = target_id
return info
if scope == "edge":
if target_id is None or target_id < 0 or target_id >= len(self.edges):
raise ValueError(f"Unknown edge id {target_id}")
info = _shape_quality_info(f"Edge {target_id}", self.edges[target_id], expect_solid=False)
info["part_id"] = self.edge_part_ids[target_id]
info["solid_id"] = self._edge_solid_id(target_id)
info["edge_id"] = target_id
return info
if scope == "feature":
if target_id is None or target_id < 0 or target_id >= len(self.faces):
raise ValueError(f"Unknown feature source face id {target_id}")
feature = self.feature_info(target_id)
face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [target_id]
shape = _compound_from_shapes(self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces))
info = _shape_quality_info(f"Feature from face {target_id}", shape, expect_solid=False)
info["part_id"] = self.face_part_ids[target_id]
info["solid_id"] = self.face_solid_ids[target_id]
info["face_id"] = target_id
info["feature_face_ids"] = tuple(face_ids)
return info
raise ValueError(f"Unknown export quality scope: {scope}")
def export_part(self, part_id: int, filename: str | Path) -> None:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
_write_step(_prepare_shape_for_step_export(part.shape), Path(filename))
def export_solid(self, solid_id: int, filename: str | Path) -> None:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
_write_step(_prepare_shape_for_step_export(self.solids[solid_id][1]), Path(filename))
def export_face(self, face_id: int, filename: str | Path) -> None:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
_write_step(self.faces[face_id], Path(filename))
def export_edge(self, edge_id: int, filename: str | Path) -> None:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
_write_step(self.edges[edge_id], Path(filename))
def export_feature(self, face_id: int, filename: str | Path) -> None:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown feature source face id {face_id}")
feature = self.feature_info(face_id)
face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [face_id]
shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)]
if not shapes:
raise ValueError("Feature export did not find any valid faces.")
_write_step(_compound_from_shapes(shapes), Path(filename))
def translate_part_plan(self, part_id: int, vector: tuple[float, float, float]) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
readiness = _translation_readiness(vector, part.shape)
return {
"status": readiness["translate_status"],
"risk": readiness["translate_risk"],
"message": readiness["translate_note"],
"warnings": readiness["translate_warnings"],
"blockers": readiness["translate_blockers"],
"target_kind": "part",
"part_id": part.id,
"name": part.name,
"translation_vector": vector,
"translation_distance": _vector_length(vector),
"bbox_diagonal": _shape_diagonal(part.shape),
}
def translate_solid_plan(self, solid_id: int, vector: tuple[float, float, float]) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
readiness = _translation_readiness(vector, solid)
part = self.part_by_id(part_id)
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
warnings = readiness["translate_warnings"]
risk = readiness["translate_risk"]
status = readiness["translate_status"]
if part_solid_count <= 1 and status != "blocked":
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape。")
if risk == "low":
risk = "medium"
status = "caution"
return {
"status": status,
"risk": risk,
"message": _join_nonempty(readiness["translate_note"], warnings),
"warnings": warnings,
"blockers": readiness["translate_blockers"],
"target_kind": "solid",
"part_id": part_id,
"solid_id": solid_id,
"part_solid_count": part_solid_count,
"translation_vector": vector,
"translation_distance": _vector_length(vector),
"bbox_diagonal": _shape_diagonal(solid),
}
def translate_part(self, part_id: int, vector: tuple[float, float, float]) -> str:
plan = self.translate_part_plan(part_id, vector)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part.shape = _translated_shape_by_vector(part.shape, vector)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part translated: part {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
)
def translate_solid(self, solid_id: int, vector: tuple[float, float, float]) -> str:
plan = self.translate_solid_plan(solid_id, vector)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = _translated_shape_by_vector(part.shape, vector)
else:
translated = _translated_shape_by_vector(solid, vector)
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(translated)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, "
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
)
def rotate_part_plan(self, part_id: int, axis: str, angle_degrees: float) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
readiness = _rotation_readiness(axis, angle_degrees)
return {
"status": readiness["rotate_status"],
"risk": readiness["rotate_risk"],
"message": readiness["rotate_note"],
"warnings": readiness["rotate_warnings"],
"blockers": readiness["rotate_blockers"],
"target_kind": "part",
"part_id": part.id,
"name": part.name,
"rotation_axis": axis.upper(),
"rotation_angle_degrees": angle_degrees,
"rotation_center": _shape_center(part.shape),
"bbox_diagonal": _shape_diagonal(part.shape),
}
def rotate_solid_plan(self, solid_id: int, axis: str, angle_degrees: float) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
readiness = _rotation_readiness(axis, angle_degrees)
part = self.part_by_id(part_id)
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
warnings = readiness["rotate_warnings"]
risk = readiness["rotate_risk"]
status = readiness["rotate_status"]
if part_solid_count <= 1 and status != "blocked":
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape。")
if risk == "low":
risk = "medium"
status = "caution"
return {
"status": status,
"risk": risk,
"message": _join_nonempty(readiness["rotate_note"], warnings),
"warnings": warnings,
"blockers": readiness["rotate_blockers"],
"target_kind": "solid",
"part_id": part_id,
"solid_id": solid_id,
"part_solid_count": part_solid_count,
"rotation_axis": axis.upper(),
"rotation_angle_degrees": angle_degrees,
"rotation_center": _shape_center(solid),
"bbox_diagonal": _shape_diagonal(solid),
}
def rotate_part(self, part_id: int, axis: str, angle_degrees: float) -> str:
plan = self.rotate_part_plan(part_id, axis, angle_degrees)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Part rotated: part {part_id}, axis={plan['rotation_axis']}, "
f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
f"risk={plan['risk']}."
)
def rotate_solid(self, solid_id: int, axis: str, angle_degrees: float) -> str:
plan = self.rotate_solid_plan(solid_id, axis, angle_degrees)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id, solid = self.solids[solid_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
part_solids = _explore(part.shape, TopAbs_SOLID)
if len(part_solids) <= 1:
part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
else:
rotated = _rotated_shape(solid, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
replaced = False
shapes: list[TopoDS_Shape] = []
for item in part_solids:
if not replaced and _same_shape(item, solid):
shapes.append(rotated)
replaced = True
else:
shapes.append(item)
if not replaced:
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
part.shape = _compound_from_shapes(shapes)
_ensure_valid_shape(part.shape)
self.refresh_topology()
return (
f"Solid rotated: solid {solid_id}, part {part_id}, axis={plan['rotation_axis']}, "
f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
f"risk={plan['risk']}."
)
def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_fillet_readiness(info, radius)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["fillet_status"] != "blocked":
readiness["fillet_status"] = "caution"
readiness["fillet_risk"] = _max_risk(str(readiness["fillet_risk"]), "high")
readiness["fillet_warnings"] = _join_nonempty(
readiness["fillet_warnings"],
f"当前零件包含 {part_stats.solids} 个 solid,边倒圆会作用在整个 part shape 上,请导出前检查结果。",
)
readiness["fillet_note"] = _join_nonempty(readiness["fillet_note"], readiness["fillet_warnings"])
length = float(info.get("length", 0.0))
radius_to_length_ratio = radius / max(length, 1e-9)
return {
"status": readiness["fillet_status"],
"risk": readiness["fillet_risk"],
"message": readiness["fillet_note"],
"warnings": readiness["fillet_warnings"],
"blockers": readiness["fillet_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_radius": radius,
"radius_to_length_ratio": radius_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
}
def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
readiness = _edge_chamfer_readiness(info, distance)
part_id = int(info["part_id"])
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
readiness = dict(readiness)
if readiness["chamfer_status"] != "blocked":
readiness["chamfer_status"] = "caution"
readiness["chamfer_risk"] = _max_risk(str(readiness["chamfer_risk"]), "high")
readiness["chamfer_warnings"] = _join_nonempty(
readiness["chamfer_warnings"],
f"当前零件包含 {part_stats.solids} 个 solid,边倒角会作用在整个 part shape 上,请导出前检查结果。",
)
readiness["chamfer_note"] = _join_nonempty(readiness["chamfer_note"], readiness["chamfer_warnings"])
length = float(info.get("length", 0.0))
distance_to_length_ratio = distance / max(length, 1e-9)
return {
"status": readiness["chamfer_status"],
"risk": readiness["chamfer_risk"],
"message": readiness["chamfer_note"],
"warnings": readiness["chamfer_warnings"],
"blockers": readiness["chamfer_blockers"],
"edge_id": edge_id,
"part_id": info["part_id"],
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"edge_length": length,
"target_distance": distance,
"distance_to_length_ratio": distance_to_length_ratio,
"adjacent_face_ids": info.get("adjacent_face_ids", ()),
"adjacent_face_count": info.get("adjacent_face_count", 0),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
}
def straight_edge_length_plan(self, edge_id: int, target_length: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
info = self.edge_info(edge_id)
current_length = float(info.get("length", 0.0))
delta_length = float(target_length) - current_length
base: dict[str, object] = {
"edge_id": edge_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id", -1),
"curve": info.get("curve"),
"current_length": current_length,
"target_length": target_length,
"delta_length": delta_length,
"length_change_ratio": abs(delta_length) / max(current_length, 1e-9),
"start_point": info.get("start_point"),
"end_point": info.get("end_point"),
"direction": info.get("direction"),
"resize_strategy": "move-edge-end-plane-by-push-pull",
}
warnings: list[str] = [
"第一版边长调整是受限功能:只移动直线边端点附近的平面端面,不是通用参数化边长编辑。"
]
blockers: list[str] = []
risk = "low"
status = "ready"
if info.get("curve") != "line":
blockers.append("当前 edge 不是直线,第一版不能调整长度。")
if current_length <= 1e-9:
blockers.append("当前 edge 长度无效。")
if target_length <= 1e-9:
blockers.append("目标边长必须大于 0。")
if abs(delta_length) <= max(current_length * 1e-7, 1e-7):
blockers.append("目标边长与当前边长几乎相同,不需要修改。")
if not blockers:
ratio = abs(delta_length) / max(current_length, 1e-9)
if ratio > 0.5:
risk = _max_risk(risk, "high")
warnings.append("长度变化超过当前边长的 50%,布尔运算失败或形状异常的概率较高。")
elif ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append("长度变化超过当前边长的 25%,请确认预览范围。")
candidate: dict[str, object] | None = None
if not blockers:
candidate = self._straight_edge_length_end_face_candidate(info, delta_length)
if candidate is None:
blockers.append("没有找到可用于改变这条直线边长度的平面端面。")
else:
base.update(candidate)
push_plan = self.push_pull_plan(int(candidate["end_face_id"]), float(candidate["push_pull_distance"]))
if push_plan["status"] == "blocked":
blockers.append(str(push_plan["message"]))
else:
risk = _max_risk(risk, str(push_plan["risk"]))
push_warnings = str(push_plan.get("warnings", ""))
if push_warnings:
warnings.append(push_warnings)
base.update(
{
"push_pull_status": push_plan.get("status"),
"push_pull_risk": push_plan.get("risk"),
"push_pull_message": push_plan.get("message"),
"push_pull_scope_face_ids": push_plan.get("push_pull_scope_face_ids", ()),
"push_pull_scope_face_count": push_plan.get("push_pull_scope_face_count", 1),
"push_pull_scope_note": push_plan.get("push_pull_scope_note", ""),
}
)
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers)
elif risk != "low":
status = "caution"
message = " ".join(warnings)
else:
message = "可以尝试通过端面推拉调整这条直线边长度。"
base.update(
{
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
}
)
return base
def _straight_edge_length_end_face_candidate(
self,
edge_info: dict[str, object],
delta_length: float,
) -> dict[str, object] | None:
start = _tuple_or_none(edge_info.get("start_point"))
end = _tuple_or_none(edge_info.get("end_point"))
if start is None or end is None:
return None
axis = _tuple_normalized(_tuple_sub(end, start))
if axis is None:
return None
solid_id = int(edge_info.get("solid_id", -1))
if solid_id < 0 or solid_id >= len(self.solids):
return None
solid = self.solids[solid_id][1]
tolerance = max(_shape_diagonal(solid) * 1e-5, abs(delta_length) * 1e-5, 1e-4)
candidates: list[tuple[float, dict[str, object]]] = []
endpoint_specs = [
("起点端", start, _tuple_scale(axis, -delta_length)),
("终点端", end, _tuple_scale(axis, delta_length)),
]
for endpoint_label, endpoint, desired_vector in endpoint_specs:
desired_unit = _tuple_normalized(desired_vector)
if desired_unit is None:
continue
for face_id, face in enumerate(self.faces):
if self.face_solid_ids[face_id] != solid_id:
continue
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
plane = surf.Plane()
plane_origin = _point_tuple(plane.Location())
plane_normal = _tuple_normalized(_dir_tuple(plane.Axis().Direction()))
if plane_normal is None:
continue
plane_distance = abs(_tuple_dot(_tuple_sub(endpoint, plane_origin), plane_normal))
if plane_distance > tolerance:
continue
axis_alignment = abs(_tuple_dot(plane_normal, axis))
if axis_alignment < 0.82:
continue
face_info = self.face_info(face_id)
outward = _tuple_normalized(_tuple_or_none(face_info.get("push_pull_outward_direction")))
if outward is None:
continue
movement_alignment = abs(_tuple_dot(outward, desired_unit))
if movement_alignment < 0.82:
continue
push_pull_distance = _tuple_dot(desired_vector, outward)
if abs(push_pull_distance) <= 1e-9:
continue
confidence_bonus = 0.0 if face_info.get("push_pull_confidence") == "high" else 0.2
score = plane_distance / max(tolerance, 1e-9) + (1.0 - movement_alignment) + confidence_bonus
candidates.append(
(
score,
{
"end_face_id": face_id,
"end_face_label": endpoint_label,
"end_face_plane_distance": plane_distance,
"end_face_axis_alignment": axis_alignment,
"end_face_movement_alignment": movement_alignment,
"end_face_outward_direction": outward,
"end_face_push_pull_confidence": face_info.get("push_pull_confidence", ""),
"push_pull_distance": push_pull_distance,
"desired_movement_vector": desired_vector,
},
)
)
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
def existing_fillet_resize_plan(self, face_id: int, target_radius: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "radius" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱圆角面,不能修改已有圆角半径。",
"blockers": "当前选中的 face 不是圆柱圆角面。",
"warnings": "",
"face_id": face_id,
}
feature = self.feature_info(face_id)
feature_guess = str(info.get("feature_guess", ""))
current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"]))
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
warnings: list[str] = []
blockers: list[str] = []
risk = "medium"
status = "caution"
if feature_guess != "round/fillet candidate":
blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。")
if target_radius <= 0:
blockers.append("目标圆角半径必须大于 0。")
if current_radius <= 0:
blockers.append("当前圆角半径估算无效。")
if current_radius > 0 and abs(target_radius - current_radius) <= max(current_radius * 1e-5, 1e-6):
blockers.append("目标圆角半径与当前估算半径几乎相同,不需要修改。")
if len(support_face_ids) < 2:
blockers.append("第一版只对识别到至少两个支撑 face 的已有圆角候选开放。")
part_id = int(info.get("part_id", -1))
part_stats = None
try:
part_stats = self.part_topology_stats(part_id)
except Exception:
part_stats = None
if part_stats is not None and part_stats.solids != 1:
blockers.append(
f"当前零件包含 {part_stats.solids} 个 solid;已有圆角半径修改第一版只对单 solid 零件开放。"
)
height_estimate = float(info.get("height_estimate", 0.0))
angular_span = float(info.get("angular_span", 0.0))
radius_delta = target_radius - current_radius
radius_delta_ratio = abs(radius_delta) / max(current_radius, 1e-9)
if not blockers:
if radius_delta_ratio > 1.0:
risk = "high"
warnings.append("目标半径变化超过当前半径的 100%defeature/refillet 很可能失败。")
elif radius_delta_ratio > 0.35:
risk = _max_risk(risk, "high")
warnings.append("目标半径变化超过当前半径的 35%,请谨慎检查结果。")
if height_estimate > 0 and target_radius > height_estimate * 0.5:
risk = _max_risk(risk, "high")
warnings.append("目标半径超过圆角长度估算的一半,几何比例异常。")
if angular_span > math.pi * 1.25:
risk = _max_risk(risk, "high")
warnings.append("当前圆角圆弧跨度较大,可能不是普通边圆角。")
if str(info.get("confidence", "low")) != "high":
warnings.append("已有圆角识别置信度不是 high,执行结果需要重点检查。")
if blockers:
status = "blocked"
risk = "blocked"
message = " ".join(blockers + warnings)
else:
message = "将尝试先移除已有圆角面,再在恢复出的锐边上按目标半径重新倒圆。"
if warnings:
message += " " + " ".join(warnings)
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"feature_type": feature.get("feature_type"),
"feature_guess": feature_guess,
"confidence": info.get("confidence"),
"current_radius": current_radius,
"target_radius": target_radius,
"delta_radius": radius_delta,
"radius_delta_ratio": radius_delta_ratio,
"height_estimate": info.get("height_estimate"),
"angular_span": info.get("angular_span"),
"axis_point": info.get("axis_point"),
"axis": info.get("axis"),
"feature_existing_fillet_support_face_ids": support_face_ids,
"feature_boundary_edge_ids": feature.get("feature_boundary_edge_ids"),
"resize_strategy": "defeature-existing-fillet-face-then-refillet-axis-edge",
"resize_note": (
"第一版已有圆角半径修改只支持由圆柱面表示的直线边圆角。"
"执行后 face/edge ID 会重建,请重新选择对象确认结果。"
),
}
def push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是平面,不能执行推拉平面。",
"blockers": "当前选中的 face 不是平面。",
"warnings": "",
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"distance": distance,
}
info = self.face_info(face_id)
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id)
if len(scope_face_ids) > 1:
scope_note = f"将一起推拉 {len(scope_face_ids)} 个共享边且共面的 face,减少 STEP 碎面导致的贴块缝。"
else:
scope_note = "只推拉当前 face。"
direction_confidence = str(info.get("push_pull_confidence", "low"))
bbox_diagonal = float(info.get("bbox_diagonal", 0.0))
distance_abs = abs(distance)
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
if distance_abs <= 1e-9:
status = "blocked"
risk = "blocked"
blockers.append("推拉距离为 0,不需要修改。")
if direction_confidence != "high":
risk = _max_risk(risk, "medium")
warnings.append("推拉方向判断置信度较低,可能不是期望的内外方向。")
if bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.2:
risk = _max_risk(risk, "high")
warnings.append("推拉距离超过当前 face 包围盒对角线的 20%,容易导致布尔失败或大范围变形。")
elif bbox_diagonal > 0 and distance_abs > bbox_diagonal * 0.08:
risk = _max_risk(risk, "medium")
warnings.append("推拉距离相对当前 face 尺寸偏大,请确认预览范围。")
if risk in {"medium", "high"} and status != "blocked":
status = "caution"
if blockers:
message = " ".join(blockers + warnings)
elif warnings:
message = " ".join(warnings)
else:
message = "可以尝试推拉该平面。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"distance": distance,
"surface": info.get("surface"),
"area": info.get("area"),
"bbox_diagonal": info.get("bbox_diagonal"),
"outward_direction": info.get("push_pull_outward_direction"),
"direction_confidence": direction_confidence,
"direction_note": info.get("push_pull_note"),
"push_pull_scope_face_ids": tuple(scope_face_ids),
"push_pull_scope_face_count": len(scope_face_ids),
"push_pull_scope_note": scope_note,
}
def cylindrical_resize_preview_polydata(
self,
face_id: int,
new_diameter: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder resize preview currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
previews: list[dict[str, object]] = []
if plan["resize_mode"] == "shrink" and "fill_start_point" in plan:
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
BRepMesh_IncrementalMesh(filler, deflection)
previews.append(
{
"role": "fill",
"label": "补料预览",
"polydata": _shape_faces_polydata(filler),
}
)
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(
cutter_axis,
float(plan["cutter_radius"]),
float(plan["cutter_height"]),
).Shape()
BRepMesh_IncrementalMesh(cutter, deflection)
previews.append(
{
"role": "cutter",
"label": "切削预览",
"polydata": _shape_faces_polydata(cutter),
}
)
return previews
def cylindrical_boss_resize_preview_polydata(
self,
face_id: int,
new_diameter: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
start = gp_Pnt(*plan["boss_tool_start_point"])
direction = gp_Dir(*plan["boss_tool_axis_direction"])
axis = gp_Ax2(start, direction)
height = float(plan["boss_tool_height"])
if plan["resize_mode"] == "enlarge":
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
BRepMesh_IncrementalMesh(tool, deflection)
return [
{
"role": "fill",
"label": "凸台扩大补料预览",
"polydata": _shape_faces_polydata(tool),
}
]
outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
ring_cut = BRepAlgoAPI_Cut(outer, inner)
ring = _finalize_boolean_result(ring_cut, "boss annular cutter preview")
BRepMesh_IncrementalMesh(ring, deflection)
return [
{
"role": "cutter",
"label": "凸台缩小环形切削预览",
"polydata": _shape_faces_polydata(ring),
}
]
def cylindrical_suppress_preview_polydata(
self,
face_id: int,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder suppress preview currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
BRepMesh_IncrementalMesh(filler, deflection)
return [
{
"role": "fill",
"label": "封堵补料预览",
"polydata": _shape_faces_polydata(filler),
}
]
def cylindrical_depth_preview_polydata(
self,
face_id: int,
target_depth: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.cylindrical_depth_plan(face_id, target_depth)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
start = gp_Pnt(*plan["depth_tool_start_point"])
direction = gp_Dir(*plan["depth_axis_direction"])
axis = gp_Ax2(start, direction)
tool = BRepPrimAPI_MakeCylinder(
axis,
float(plan["depth_tool_radius"]),
float(plan["depth_tool_height"]),
).Shape()
BRepMesh_IncrementalMesh(tool, deflection)
role = str(plan["depth_tool_role"])
return [
{
"role": role,
"label": "切削预览" if role == "cutter" else "补料预览",
"polydata": _shape_faces_polydata(tool),
}
]
def existing_fillet_resize_preview_polydata(
self,
face_id: int,
target_radius: float,
deflection: float = 0.8,
) -> list[dict[str, object]]:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return [
{
"role": "remove",
"label": "将移除并重建的已有圆角面",
"polydata": self.build_face_polydata(face_ids=[face_id], deflection=deflection),
}
]
def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8):
plan = self.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
profile_shape = self._push_pull_profile_shape(scope_face_ids)
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull preview currently supports planar faces only.")
outward = plan["outward_direction"]
vec = gp_Vec(
float(outward[0]) * distance,
float(outward[1]) * distance,
float(outward[2]) * distance,
)
preview_shape = BRepPrimAPI_MakePrism(profile_shape, vec).Shape()
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def straight_edge_length_preview_polydata(self, edge_id: int, target_length: float, deflection: float = 0.8):
plan = self.straight_edge_length_plan(edge_id, target_length)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
return self.push_pull_preview_polydata(int(plan["end_face_id"]), float(plan["push_pull_distance"]), deflection)
def resize_straight_edge_length(self, edge_id: int, target_length: float) -> str:
plan = self.straight_edge_length_plan(edge_id, target_length)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"]))
return (
"Straight edge length resize completed: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"end_face={int(plan['end_face_id'])}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"risk={plan['risk']}. {push_result}"
)
def push_pull_face(self, face_id: int, distance: float) -> str:
plan = self.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull currently supports planar faces only.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
outward = plan["outward_direction"]
scope_face_ids = _int_values(plan.get("push_pull_scope_face_ids")) or [face_id]
profile_shape = self._push_pull_profile_shape(scope_face_ids)
overlap = _boolean_overlap_distance(part.shape, distance)
start_offset = -overlap if distance >= 0 else overlap
tool_distance = distance + overlap if distance >= 0 else distance - overlap
tool_face = _translated_shape(profile_shape, outward, start_offset)
vec = gp_Vec(
float(outward[0]) * tool_distance,
float(outward[1]) * tool_distance,
float(outward[2]) * tool_distance,
)
tool_shape = BRepPrimAPI_MakePrism(tool_face, vec).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape)
result = _finalize_boolean_result(op, "push/pull")
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
part.shape = result
self.refresh_topology()
action = "fused outward prism" if distance >= 0 else "cut inward prism"
return (
"Planar face push/pull completed: "
f"{action}, semantic_distance={distance:g}, "
f"tool_overlap={overlap:g}, "
f"scope_faces={len(scope_face_ids)}, "
f"outward_direction={_format_tuple(outward)}, "
f"direction_confidence={plan['direction_confidence']}, "
f"risk={plan['risk']}."
)
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
plan = self.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = int(plan["part_id"])
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
source_face = topods.Face(self.faces[face_id])
defeatured = _defeature_faces(part.shape, [source_face])
axis_point = gp_Pnt(*plan["axis_point"])
axis_dir = gp_Dir(*plan["axis"])
root_edges = _axis_aligned_edge_candidates(
defeatured,
axis_point,
axis_dir,
expected_length=float(plan.get("height_estimate") or 0.0),
reference_radius=float(plan["current_radius"]),
)
if not root_edges:
raise RuntimeError(
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
"该圆角可能不是简单直线边圆角。"
)
result = None
failures: list[str] = []
for index, root_edge in enumerate(root_edges[:16], start=1):
try:
maker = BRepFilletAPI_MakeFillet(defeatured)
maker.Add(float(target_radius), topods.Edge(root_edge))
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
break
except Exception as exc:
failures.append(str(exc))
if result is None:
detail = failures[-1] if failures else "没有可用的候选边。"
raise RuntimeError(
"已移除已有圆角面,但所有候选锐边都无法重新倒圆;"
f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}"
)
part.shape = result
self.refresh_topology()
return (
"Existing fillet radius resize completed: "
f"face {face_id}, current_radius={float(plan['current_radius']):g}, "
f"target_radius={target_radius:g}, "
f"delta_radius={float(plan['delta_radius']):g}, "
f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, "
f"risk={plan['risk']}."
)
def fillet_edge(self, edge_id: int, radius: float) -> str:
plan = self.edge_fillet_plan(edge_id, radius)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeFillet(part.shape)
maker.Add(radius, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge fillet")
part.shape = result
self.refresh_topology()
return (
f"Edge fillet completed: edge {edge_id}, radius={radius:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def chamfer_edge(self, edge_id: int, distance: float) -> str:
plan = self.edge_chamfer_plan(edge_id, distance)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.edge_part_ids[edge_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
maker = BRepFilletAPI_MakeChamfer(part.shape)
maker.Add(distance, topods.Edge(self.edges[edge_id]))
result = _finalize_builder_result(maker, "edge chamfer")
part.shape = result
self.refresh_topology()
return (
f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, "
f"edge_length={float(plan['edge_length']):g}, "
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
f"risk={plan['risk']}."
)
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
return self.resize_cylindrical_hole(face_id, new_diameter)
def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Hole resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
if new_radius <= 0:
raise ValueError("Target diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction()
source_shape = part.shape
if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse")
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape()
op = BRepAlgoAPI_Cut(source_shape, cutter)
result = _finalize_boolean_result(op, "cylinder cut")
part.shape = result
self.refresh_topology()
action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}."
)
def resize_cylindrical_boss(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_boss_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
start = gp_Pnt(*plan["boss_tool_start_point"])
direction = gp_Dir(*plan["boss_tool_axis_direction"])
axis = gp_Ax2(start, direction)
height = float(plan["boss_tool_height"])
if plan["resize_mode"] == "enlarge":
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "cylindrical boss fuse")
action = "enlarged by bounded fuse"
else:
outer = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
inner = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
ring_cut = BRepAlgoAPI_Cut(outer, inner)
ring = _finalize_boolean_result(ring_cut, "cylindrical boss annular cutter")
op = BRepAlgoAPI_Cut(part.shape, ring)
result = _finalize_boolean_result(op, "cylindrical boss cut")
action = "shrunk by bounded annular cut"
part.shape = result
self.refresh_topology()
return (
f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, "
f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, "
f"height={float(plan['boss_tool_height']):g}."
)
def suppress_cylindrical_hole(self, face_id: int) -> str:
plan = self.cylindrical_suppress_plan(face_id)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Cylinder suppress currently supports cylindrical faces only.")
direction = surf.Cylinder().Axis().Direction()
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
result = _finalize_boolean_result(fuse, "cylinder suppress/fill")
part.shape = result
self.refresh_topology()
return (
f"Cylindrical hole suppress completed: face {face_id}, "
f"diameter={float(plan['diameter']):g}, "
f"height={float(plan['fill_height']):g}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}."
)
def resize_cylindrical_depth(self, face_id: int, target_depth: float) -> str:
plan = self.cylindrical_depth_plan(face_id, target_depth)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
start = gp_Pnt(*plan["depth_tool_start_point"])
direction = gp_Dir(*plan["depth_axis_direction"])
axis = gp_Ax2(start, direction)
tool = BRepPrimAPI_MakeCylinder(
axis,
float(plan["depth_tool_radius"]),
float(plan["depth_tool_height"]),
).Shape()
if plan["depth_mode"] == "deepen":
op = BRepAlgoAPI_Cut(part.shape, tool)
result = _finalize_boolean_result(op, "blind depth cut")
action = "deepened by bounded cut"
else:
op = BRepAlgoAPI_Fuse(part.shape, tool)
result = _finalize_boolean_result(op, "blind depth fill/fuse")
action = "made shallower by bounded fill"
part.shape = result
self.refresh_topology()
return (
f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, "
f"mode={plan['depth_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}."
)
def build_face_polydata(
self,
face_ids: Iterable[int] | None = None,
part_ids: Iterable[int] | None = None,
deflection: float = 0.8,
):
import vtk
selected_faces = set(face_ids) if face_ids is not None else None
selected_parts = set(part_ids) if part_ids is not None else None
BRepMesh_IncrementalMesh(self.shape, deflection)
points = vtk.vtkPoints()
polys = vtk.vtkCellArray()
face_arr = vtk.vtkIntArray()
face_arr.SetName("face_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
solid_arr = vtk.vtkIntArray()
solid_arr.SetName("solid_id")
for face_id, face in enumerate(self.faces):
part_id = self.face_part_ids[face_id]
if selected_faces is not None and face_id not in selected_faces:
continue
if selected_parts is not None and part_id not in selected_parts:
continue
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
if tri is None:
continue
transform = loc.Transformation()
node_offset = points.GetNumberOfPoints()
for node_index in range(1, tri.NbNodes() + 1):
pnt = tri.Node(node_index).Transformed(transform)
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
reversed_face = face.Orientation() == TopAbs_REVERSED
for tri_index in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tri.Triangle(tri_index).Get()
if reversed_face:
n2, n3 = n3, n2
vtk_tri = vtk.vtkTriangle()
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
polys.InsertNextCell(vtk_tri)
face_arr.InsertNextValue(face_id)
part_arr.InsertNextValue(part_id)
solid_arr.InsertNextValue(self.face_solid_ids[face_id])
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetPolys(polys)
poly.GetCellData().AddArray(face_arr)
poly.GetCellData().AddArray(part_arr)
poly.GetCellData().AddArray(solid_arr)
return poly
def build_snapshot_polydata(self, snapshot: dict[int, TopoDS_Shape], deflection: float = 0.8):
shape = _compound_from_shapes(snapshot.values())
BRepMesh_IncrementalMesh(shape, deflection)
return _shape_faces_polydata(shape)
def build_edge_polydata(
self,
edge_ids: Iterable[int] | None = None,
part_ids: Iterable[int] | None = None,
deflection: float = 0.8,
):
import vtk
selected_edges = set(edge_ids) if edge_ids is not None else None
selected_parts = set(part_ids) if part_ids is not None else None
points = vtk.vtkPoints()
lines = vtk.vtkCellArray()
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
for edge_id, edge in enumerate(self.edges):
part_id = self.edge_part_ids[edge_id]
if selected_edges is not None and edge_id not in selected_edges:
continue
if selected_parts is not None and part_id not in selected_parts:
continue
samples = discretize_edge(edge, deflection)
if len(samples) < 2:
continue
polyline = vtk.vtkPolyLine()
polyline.GetPointIds().SetNumberOfIds(len(samples))
for i, coords in enumerate(samples):
point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2]))
polyline.GetPointIds().SetId(i, point_id)
lines.InsertNextCell(polyline)
edge_arr.InsertNextValue(edge_id)
part_arr.InsertNextValue(part_id)
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetLines(lines)
poly.GetCellData().AddArray(edge_arr)
poly.GetCellData().AddArray(part_arr)
return poly
def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]:
doc = TDocStd_Document("pythonocc-step-document")
shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main())
reader = STEPCAFControl_Reader()
reader.SetColorMode(True)
reader.SetLayerMode(True)
reader.SetNameMode(True)
reader.SetMatMode(True)
reader.SetGDTMode(True)
status = reader.ReadFile(str(path))
if status != IFSelect_RetDone:
raise ValueError(f"Could not read STEP file: {path}")
if not reader.Transfer(doc):
raise ValueError(f"Could not transfer STEP document: {path}")
parts: list[PartNode] = []
free_shapes = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_shapes)
def next_name(label: TDF_Label, index: int) -> str:
label_name = str(label.GetLabelName()).strip()
if label_name:
return label_name
if index - 1 < len(product_names):
return product_names[index - 1]
return f"Part {index}"
def add_node(
name: str,
kind: str,
shape: TopoDS_Shape,
parent_id: int | None,
depth: int,
path_text: str,
) -> PartNode:
node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text)
parts.append(node)
return node
def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape:
shape = shape_tool.GetShape(label)
if shape.IsNull() or not locations:
return shape
location = TopLoc_Location()
for loc in locations:
location = location.Multiplied(loc)
return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape()
def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]):
name = next_name(label, len(parts) + 1)
label_path = " / ".join(path_names + [name])
if shape_tool.IsAssembly(label):
node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path)
components = TDF_LabelSequence()
shape_tool.GetComponents(label, components)
for i in range(1, components.Length() + 1):
component = components.Value(i)
if shape_tool.IsReference(component):
referred = TDF_Label()
shape_tool.GetReferredShape(component, referred)
loc = shape_tool.GetLocation(component)
walk(referred, node.id, depth + 1, locations + [loc], path_names + [name])
else:
walk(component, node.id, depth + 1, locations, path_names + [name])
return
if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label):
add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path)
for i in range(1, free_shapes.Length() + 1):
walk(free_shapes.Value(i), None, 0, [], [])
display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()]
if not display_shapes:
display_shapes = [p.shape for p in parts if not p.shape.IsNull()]
return parts, _compound_from_shapes(display_shapes)
def _load_plain_step(path: Path) -> TopoDS_Shape:
reader = STEPControl_Reader()
status = reader.ReadFile(str(path))
if status != IFSelect_RetDone:
raise ValueError(f"Could not read STEP file: {path}")
if not reader.TransferRoots():
raise ValueError(f"Could not transfer STEP roots: {path}")
return reader.Shape()
def _parse_product_names(path: Path) -> list[str]:
text = path.read_text(errors="ignore")
names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text)
return [name.replace("''", "'") for name in names if name.strip()]
def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
if shape.IsNull():
raise ValueError("Cannot export a null shape.")
filename.parent.mkdir(parents=True, exist_ok=True)
Interface_Static.SetCVal("write.step.schema", "AP214IS")
writer = STEPControl_Writer()
writer.Transfer(shape, STEPControl_AsIs)
status = writer.Write(str(filename))
if status != IFSelect_RetDone:
raise IOError(f"Could not write STEP file: {filename}")
def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape:
if shape.IsNull():
return shape
try:
repaired = _repair_shape(shape)
unified = _unify_same_domain_shape(repaired)
repaired_unified = _repair_shape(unified)
if repaired_unified.IsNull():
return shape
return repaired_unified
except Exception:
return shape
def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape:
from OCC.Core.BRep import BRep_Builder
valid_shapes = [shape for shape in shapes if not shape.IsNull()]
if len(valid_shapes) == 1:
return valid_shapes[0]
compound = TopoDS_Compound()
builder = BRep_Builder()
builder.MakeCompound(compound)
for shape in valid_shapes:
builder.Add(compound, shape)
return compound
def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]:
items: list[TopoDS_Shape] = []
explorer = TopExp_Explorer(shape, shape_type)
while explorer.More():
current = explorer.Current()
if shape_type == TopAbs_FACE:
items.append(topods.Face(current))
elif shape_type == TopAbs_EDGE:
items.append(topods.Edge(current))
elif shape_type == TopAbs_SOLID:
items.append(topods.Solid(current))
else:
items.append(current)
explorer.Next()
return items
def _same_shape(left: TopoDS_Shape, right: TopoDS_Shape) -> bool:
try:
return bool(left.IsSame(right))
except Exception:
return False
def _surfaces_are_coplanar(left: BRepAdaptor_Surface, right: BRepAdaptor_Surface, tolerance: float) -> bool:
if left.GetType() != GeomAbs_Plane or right.GetType() != GeomAbs_Plane:
return False
left_plane = left.Plane()
right_plane = right.Plane()
left_dir = left_plane.Axis().Direction()
right_dir = right_plane.Axis().Direction()
dot = abs(
left_dir.X() * right_dir.X()
+ left_dir.Y() * right_dir.Y()
+ left_dir.Z() * right_dir.Z()
)
if dot < 1.0 - 1e-7:
return False
left_point = left_plane.Location()
right_point = right_plane.Location()
distance = abs(
(right_point.X() - left_point.X()) * left_dir.X()
+ (right_point.Y() - left_point.Y()) * left_dir.Y()
+ (right_point.Z() - left_point.Z()) * left_dir.Z()
)
return distance <= tolerance
def _mapped_edge_solid_id(
edge: TopoDS_Shape,
solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]],
) -> int:
for solid_id, edge_map in solid_edge_maps:
if edge_map.Contains(edge):
return solid_id
return -1
def _shape_quality_info(label: str, shape: TopoDS_Shape, expect_solid: bool) -> dict[str, object]:
warnings: list[str] = []
if shape.IsNull():
return {
"quality_label": label,
"quality_status": "blocked",
"brep_valid": False,
"solids": 0,
"faces": 0,
"edges": 0,
"vertices": 0,
"quality_warnings": "导出对象是空 shape,不能可靠导出。",
}
try:
brep_valid = BRepCheck_Analyzer(shape).IsValid()
except Exception as exc:
brep_valid = False
warnings.append(f"B-Rep 校验执行失败:{exc}")
topo = TopologyExplorer(shape, ignore_orientation=True)
solids = len(list(topo.solids()))
faces = len(list(topo.faces()))
edges = len(list(topo.edges()))
vertices = len(list(topo.vertices()))
if not brep_valid:
warnings.append("B-Rep 校验未通过,导出后其他 CAD 软件可能无法正常识别。")
if faces == 0:
warnings.append("没有检测到 face,导出结果可能不可用。")
if expect_solid and solids == 0:
warnings.append("没有检测到 solid,导出后可能不是实体。")
elif expect_solid and solids > 1:
warnings.append(
f"检测到 {solids} 个 solid。"
"如果这不是有意的多实体零件,导出后可能看起来像多个体叠在一起或彼此分离。"
)
geometry_info = _shape_volume_info(shape)
volume = geometry_info.get("volume", "")
if expect_solid and isinstance(volume, (int, float)) and abs(float(volume)) <= 1e-9:
warnings.append("实体体积接近 0,请确认导出对象是否为有效实体。")
bounds_info = _shape_bounds_info(shape)
return {
"quality_label": label,
"quality_status": "warning" if warnings else "ok",
"brep_valid": brep_valid,
"solids": solids,
"faces": faces,
"edges": edges,
"vertices": vertices,
"volume": volume,
"bbox_diagonal": bounds_info.get("bbox_diagonal", ""),
"quality_warnings": "".join(warnings),
}
def _shape_faces_polydata(shape: TopoDS_Shape):
import vtk
points = vtk.vtkPoints()
polys = vtk.vtkCellArray()
for face in _explore(shape, TopAbs_FACE):
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
if tri is None:
continue
transform = loc.Transformation()
node_offset = points.GetNumberOfPoints()
for node_index in range(1, tri.NbNodes() + 1):
pnt = tri.Node(node_index).Transformed(transform)
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
reversed_face = face.Orientation() == TopAbs_REVERSED
for tri_index in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tri.Triangle(tri_index).Get()
if reversed_face:
n2, n3 = n3, n2
vtk_tri = vtk.vtkTriangle()
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
polys.InsertNextCell(vtk_tri)
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetPolys(polys)
return poly
def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]:
box = Bnd_Box()
brepbndlib.Add(shape, box)
return box.Get()
def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
dx = xmax - xmin
dy = ymax - ymin
dz = zmax - zmin
return {
"bbox_min": (xmin, ymin, zmin),
"bbox_max": (xmax, ymax, zmax),
"bbox_size": (dx, dy, dz),
"bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz),
}
def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]:
props = GProp_GProps()
try:
brepgprop.VolumeProperties(shape, props)
except Exception:
return {"volume": "unavailable"}
volume = props.Mass()
info: dict[str, object] = {"volume": volume}
if abs(volume) > 1e-9:
info["center_of_mass"] = _point_tuple(props.CentreOfMass())
return info
def _shape_diagonal(shape: TopoDS_Shape) -> float:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
return math.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2)
def _shape_center(shape: TopoDS_Shape) -> tuple[float, float, float]:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0)
def _translated_shape(shape: TopoDS_Shape, direction: tuple[float, float, float], distance: float) -> TopoDS_Shape:
if abs(distance) <= 1e-12:
return shape
trsf = gp_Trsf()
trsf.SetTranslation(
gp_Vec(
float(direction[0]) * distance,
float(direction[1]) * distance,
float(direction[2]) * distance,
)
)
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _translated_shape_by_vector(shape: TopoDS_Shape, vector: tuple[float, float, float]) -> TopoDS_Shape:
if _vector_length(vector) <= 1e-12:
return shape
trsf = gp_Trsf()
trsf.SetTranslation(gp_Vec(float(vector[0]), float(vector[1]), float(vector[2])))
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _rotated_shape(
shape: TopoDS_Shape,
axis_name: str,
angle_degrees: float,
center: tuple[float, float, float],
) -> TopoDS_Shape:
if abs(angle_degrees) <= 1e-12:
return shape
axis_dir = _axis_dir_from_name(axis_name)
trsf = gp_Trsf()
trsf.SetRotation(gp_Ax1(gp_Pnt(*center), axis_dir), math.radians(angle_degrees))
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
def _axis_dir_from_name(axis_name: str) -> gp_Dir:
axis = axis_name.upper()
if axis == "X":
return gp_Dir(1.0, 0.0, 0.0)
if axis == "Y":
return gp_Dir(0.0, 1.0, 0.0)
if axis == "Z":
return gp_Dir(0.0, 0.0, 1.0)
raise ValueError("Rotation axis must be X, Y or Z.")
def _vector_length(vector: tuple[float, float, float]) -> float:
return math.sqrt(float(vector[0]) ** 2 + float(vector[1]) ** 2 + float(vector[2]) ** 2)
def _tuple_or_none(value: object) -> tuple[float, float, float] | None:
if not isinstance(value, (list, tuple)) or len(value) != 3:
return None
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError):
return None
def _tuple_sub(left: tuple[float, float, float], right: tuple[float, float, float]) -> tuple[float, float, float]:
return (left[0] - right[0], left[1] - right[1], left[2] - right[2])
def _tuple_scale(values: tuple[float, float, float], scale: float) -> tuple[float, float, float]:
return (values[0] * scale, values[1] * scale, values[2] * scale)
def _tuple_dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
def _tuple_normalized(value: tuple[float, float, float] | None) -> tuple[float, float, float] | None:
if value is None:
return None
length = _vector_length(value)
if length <= 1e-12:
return None
return (value[0] / length, value[1] / length, value[2] / length)
def _rotation_readiness(axis_name: str, angle_degrees: float) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
axis = axis_name.upper()
if axis not in {"X", "Y", "Z"}:
status = "blocked"
risk = "blocked"
blockers.append("旋转轴必须是 X、Y 或 Z。")
if abs(angle_degrees) <= 1e-9:
status = "blocked"
risk = "blocked"
blockers.append("旋转角度为 0,不需要修改。")
if abs(angle_degrees) > 360.0:
risk = _max_risk(risk, "medium")
warnings.append("旋转角度超过 360 度,请确认输入是否符合预期。")
if blockers:
note = " ".join(blockers + warnings)
elif warnings:
status = "caution"
note = " ".join(warnings)
else:
note = "可以尝试旋转当前对象。"
return {
"rotate_status": status,
"rotate_risk": risk,
"rotate_warnings": "".join(warnings),
"rotate_blockers": "".join(blockers),
"rotate_note": note,
}
def _translation_readiness(vector: tuple[float, float, float], shape: TopoDS_Shape) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
distance = _vector_length(vector)
diagonal = _shape_diagonal(shape)
if distance <= 1e-9:
status = "blocked"
risk = "blocked"
blockers.append("平移向量为 0,不需要修改。")
elif diagonal > 1e-9:
ratio = distance / diagonal
if ratio > 2.0:
risk = "high"
warnings.append("平移距离超过目标包围盒对角线的 2 倍,请确认单位和方向。")
elif ratio > 0.5:
risk = "medium"
warnings.append("平移距离超过目标包围盒对角线的 50%,请确认单位和方向。")
if blockers:
note = " ".join(blockers + warnings)
elif warnings:
status = "caution"
note = " ".join(warnings)
else:
note = "可以尝试平移当前对象。"
return {
"translate_status": status,
"translate_risk": risk,
"translate_warnings": "".join(warnings),
"translate_blockers": "".join(blockers),
"translate_note": note,
}
def _boolean_overlap_distance(shape: TopoDS_Shape, requested_distance: float) -> float:
diagonal = _shape_diagonal(shape)
size_based = diagonal * 1e-5 if diagonal > 0 else 0.01
distance_based = abs(requested_distance) * 0.02
return min(max(size_based, distance_based, 0.001), max(abs(requested_distance) * 0.25, 0.01))
def _shape_cleaning_tolerance(
source_shape: TopoDS_Shape,
profile_shape: TopoDS_Shape,
requested_distance: float,
) -> float:
source_diagonal = _shape_diagonal(source_shape)
profile_diagonal = _shape_diagonal(profile_shape)
reference = max(source_diagonal, profile_diagonal, abs(requested_distance), 1.0)
size_based = reference * 1e-7
distance_based = abs(requested_distance) * 1e-5
lower = max(size_based, distance_based, 1e-5)
upper = max(reference * 1e-4, 0.02)
return min(lower, upper)
def _topology_shape_count(shape: TopoDS_Shape, shape_type) -> int:
explorer = TopExp_Explorer(shape, shape_type)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
def _defeature_faces(shape: TopoDS_Shape, faces: Iterable[TopoDS_Shape]) -> TopoDS_Shape:
builder = BRepAlgoAPI_Defeaturing()
builder.SetShape(shape)
for face in faces:
builder.AddFaceToRemove(topods.Face(face))
return _finalize_builder_result(builder, "existing fillet defeature")
def _find_axis_aligned_edge(
shape: TopoDS_Shape,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
expected_length: float,
reference_radius: float,
) -> TopoDS_Shape | None:
candidates = _axis_aligned_edge_candidates(shape, axis_point, axis_dir, expected_length, reference_radius)
return candidates[0] if candidates else None
def _axis_aligned_edge_candidates(
shape: TopoDS_Shape,
axis_point: gp_Pnt,
axis_dir: gp_Dir,
expected_length: float,
reference_radius: float,
) -> list[TopoDS_Shape]:
candidates: list[tuple[float, TopoDS_Shape]] = []
length_reference = max(expected_length, reference_radius, 1.0)
distance_limit = max(reference_radius * 1.25, length_reference * 0.08, 0.2)
for edge in TopologyExplorer(shape, ignore_orientation=True).edges():
try:
curve = BRepAdaptor_Curve(edge)
if curve.GetType() != GeomAbs_Line:
continue
line = curve.Line()
parallel = abs(_direction_dot(line.Direction(), axis_dir))
if parallel < 0.96:
continue
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
edge_length = props.Mass()
if edge_length <= 1e-9:
continue
line_distance = _point_axis_distance(axis_point, axis_dir, line.Location())
center_distance = _point_axis_distance(axis_point, axis_dir, props.CentreOfMass())
length_penalty = 0.0
if expected_length > 1e-9:
length_penalty = abs(edge_length - expected_length) / expected_length
score = max(line_distance, center_distance) + length_penalty * max(reference_radius * 0.15, 0.05)
if score <= distance_limit:
candidates.append((score, edge))
except Exception:
continue
candidates.sort(key=lambda item: item[0])
return [edge for _score, edge in candidates]
def _finalize_boolean_result(op, operation_name: str) -> TopoDS_Shape:
op.SetNonDestructive(True)
op.Build()
if not op.IsDone():
raise RuntimeError(f"{operation_name} Boolean operation failed.")
raw_result = _ensure_valid_or_repaired_shape(op.Shape(), operation_name)
try:
op.SimplifyResult(True, True)
simplified = _ensure_valid_or_repaired_shape(
op.Shape(), f"{operation_name} simplify"
)
unified = _unify_same_domain_shape(simplified)
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
except Exception:
unified = _unify_same_domain_shape(raw_result)
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape:
builder.Build()
if hasattr(builder, "IsDone") and not builder.IsDone():
raise RuntimeError(f"{operation_name} operation failed.")
result = _ensure_valid_or_repaired_shape(builder.Shape(), operation_name)
unified = _unify_same_domain_shape(result)
return _ensure_valid_or_repaired_shape(unified, f"{operation_name} unify")
def _cleanup_push_pull_result(
result: TopoDS_Shape,
source_shape: TopoDS_Shape,
profile_shape: TopoDS_Shape,
distance: float,
) -> TopoDS_Shape:
base_tolerance = _shape_cleaning_tolerance(source_shape, profile_shape, distance)
cleaned = result
for multiplier in (1.0, 5.0, 20.0):
tolerance = base_tolerance * multiplier
candidate = _unify_same_domain_shape(
cleaned,
linear_tolerance=tolerance,
angular_tolerance=1e-5,
allow_internal_edges=False,
)
candidate = _ensure_valid_or_repaired_shape(candidate, f"push/pull cleanup {multiplier:g}x")
if _topology_shape_count(candidate, TopAbs_SOLID) == _topology_shape_count(result, TopAbs_SOLID):
cleaned = candidate
return cleaned
def _unify_same_domain_shape(
shape: TopoDS_Shape,
linear_tolerance: float | None = None,
angular_tolerance: float | None = None,
allow_internal_edges: bool = False,
) -> TopoDS_Shape:
try:
unifier = ShapeUpgrade_UnifySameDomain(shape, True, True, False)
unifier.SetSafeInputMode(True)
if hasattr(unifier, "AllowInternalEdges"):
unifier.AllowInternalEdges(allow_internal_edges)
if linear_tolerance is not None and hasattr(unifier, "SetLinearTolerance"):
unifier.SetLinearTolerance(max(float(linear_tolerance), 0.0))
if angular_tolerance is not None and hasattr(unifier, "SetAngularTolerance"):
unifier.SetAngularTolerance(max(float(angular_tolerance), 0.0))
unifier.Build()
unified = unifier.Shape()
_ensure_valid_shape(unified)
return unified
except Exception:
return shape
def _ensure_valid_or_repaired_shape(
shape: TopoDS_Shape, operation_name: str
) -> TopoDS_Shape:
try:
_ensure_valid_shape(shape)
return shape
except RuntimeError as original_error:
repaired = _repair_shape(shape)
try:
_ensure_valid_shape(repaired)
return repaired
except RuntimeError:
raise RuntimeError(
f"{operation_name} returned an invalid B-Rep shape, and automatic repair did not fix it."
) from original_error
def _repair_shape(shape: TopoDS_Shape) -> TopoDS_Shape:
if shape.IsNull():
return shape
try:
fixer = ShapeFix_Shape(shape)
fixer.Perform()
repaired = fixer.Shape()
if repaired.IsNull():
return shape
return repaired
except Exception:
return shape
def _ensure_valid_shape(shape: TopoDS_Shape) -> None:
if shape.IsNull():
raise RuntimeError("Operation returned a null shape.")
analyzer = BRepCheck_Analyzer(shape)
if not analyzer.IsValid():
raise RuntimeError("Operation returned an invalid B-Rep shape.")
def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str:
classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6)
state = classifier.State()
if state == TopAbs_IN:
return "inside"
if state == TopAbs_OUT:
return "outside"
return "on/unknown"
def _state_summary(states: list[str]) -> str:
if not states:
return "unknown"
counts: dict[str, int] = {}
for state in states:
counts[state] = counts.get(state, 0) + 1
if len(counts) == 1:
return states[0]
return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items()))
def _cylinder_resize_readiness(
info: dict[str, object],
new_diameter: float | None = None,
) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = float(info.get("angular_span", 0.0))
if guess == "round/fillet candidate":
risk = "high"
warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。")
elif guess == "boss/outer-round candidate":
risk = "high"
warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。")
elif guess != "hole/groove candidate":
risk = "high"
warnings.append("当前圆柱面还没有被识别为孔/槽候选。")
if guess == "hole/groove candidate" and confidence == "low":
risk = _max_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if guess == "hole/groove candidate" and angular_span < math.tau * 0.92:
risk = _max_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。")
if new_diameter is not None:
current_diameter = float(info.get("diameter", 0.0))
height_estimate = float(info.get("height_estimate", 0.0))
if new_diameter <= 0:
status = "blocked"
risk = "blocked"
blockers.append("目标直径必须大于 0。")
elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6):
status = "blocked"
risk = "blocked"
blockers.append("目标直径与当前直径几乎相同,不需要修改。")
else:
diameter_delta = abs(new_diameter - current_diameter)
delta_ratio = diameter_delta / max(current_diameter, 1e-9)
if delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
elif delta_ratio > 0.35:
risk = _max_risk(risk, "medium")
warnings.append("目标直径变化超过当前直径的 35%,请确认预览范围。")
if height_estimate > 0 and new_diameter > height_estimate * 2.0:
risk = _max_risk(risk, "high")
warnings.append("目标直径超过圆柱面估算高度的 2 倍,几何比例异常。")
elif height_estimate > 0 and new_diameter > height_estimate:
risk = _max_risk(risk, "medium")
warnings.append("目标直径超过圆柱面估算高度,可能不是常规孔径修改。")
if new_diameter < current_diameter:
if guess != "hole/groove candidate":
status = "blocked"
risk = "blocked"
blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
else:
risk = _max_risk(risk, "high")
warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。")
if risk in {"medium", "high"} and status != "blocked":
status = "caution"
if not warnings and not blockers:
note = "可以尝试调整圆柱孔径。"
else:
note = " ".join(blockers + warnings)
return {
"resize_status": status,
"resize_risk": risk,
"resize_warnings": "".join(warnings),
"resize_blockers": "".join(blockers),
"resize_note": note,
}
def _cylinder_boss_resize_readiness(
info: dict[str, object],
new_diameter: float | None = None,
) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = float(info.get("angular_span", 0.0))
current_diameter = float(info.get("diameter", 0.0))
height_estimate = float(info.get("height_estimate", 0.0))
if guess != "boss/outer-round candidate":
blockers.append("凸台直径调整第一版只支持明确的凸台/外圆柱候选。")
if angular_span < math.tau * 0.92:
blockers.append("凸台直径调整第一版只支持接近完整圆柱的凸台,不处理局部外圆角或圆角面。")
if current_diameter <= 1e-9:
blockers.append("当前圆柱面的直径估算无效。")
if guess == "boss/outer-round candidate" and confidence != "high":
risk = _max_risk(risk, "medium")
warnings.append("凸台判断置信度不是 high,修改后请重点检查结果。")
if new_diameter is not None:
if new_diameter <= 0:
blockers.append("目标凸台直径必须大于 0。")
elif current_diameter > 1e-9 and abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6):
blockers.append("目标凸台直径与当前直径几乎相同,不需要修改。")
elif current_diameter > 1e-9:
delta_ratio = abs(new_diameter - current_diameter) / current_diameter
if delta_ratio > 0.8:
risk = _max_risk(risk, "high")
warnings.append("目标凸台直径变化超过当前直径的 80%,很可能导致大范围布尔失败。")
elif delta_ratio > 0.3:
risk = _max_risk(risk, "medium")
warnings.append("目标凸台直径变化超过当前直径的 30%,请确认预览范围。")
if new_diameter < current_diameter * 0.15:
risk = _max_risk(risk, "high")
warnings.append("目标凸台直径非常小,可能生成很薄或断开的几何。")
if height_estimate > 1e-9 and new_diameter > height_estimate * 3.0:
risk = _max_risk(risk, "high")
warnings.append("目标凸台直径超过圆柱面估算高度的 3 倍,几何比例异常。")
elif height_estimate > 1e-9 and new_diameter > height_estimate * 1.5:
risk = _max_risk(risk, "medium")
warnings.append("目标凸台直径明显大于圆柱面估算高度,请确认单位。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
if not warnings and not blockers:
note = "可以尝试调整圆柱凸台直径。"
else:
note = " ".join(blockers + warnings)
return {
"boss_resize_status": status,
"boss_resize_risk": risk,
"boss_resize_warnings": "".join(warnings),
"boss_resize_blockers": "".join(blockers),
"boss_resize_note": note,
}
def _cylinder_depth_readiness(
info: dict[str, object],
target_depth: float | None = None,
) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = float(info.get("angular_span", 0.0))
end_type = str(info.get("cylinder_end_type", "unknown"))
current_depth = float(info.get("hole_depth_estimate", 0.0))
if guess != "hole/groove candidate":
blockers.append("孔深调整第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
if end_type != "blind":
blockers.append("孔深调整第一版只支持端部类型为 blind 的盲孔/盲槽。")
if current_depth <= 1e-9:
blockers.append("当前圆柱面没有可靠的深度估算。")
if guess == "hole/groove candidate" and confidence == "low":
risk = _max_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if guess == "hole/groove candidate" and angular_span < math.tau * 0.92:
risk = _max_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。")
if target_depth is not None:
if target_depth <= 0:
blockers.append("目标深度必须大于 0。")
elif current_depth > 1e-9 and abs(target_depth - current_depth) <= max(current_depth * 1e-5, 1e-6):
blockers.append("目标深度与当前深度几乎相同,不需要修改。")
elif current_depth > 1e-9:
delta_ratio = abs(target_depth - current_depth) / current_depth
if delta_ratio > 1.0:
risk = _max_risk(risk, "high")
warnings.append("目标深度变化超过当前深度的 100%,很可能导致贯穿、误切或布尔失败。")
elif delta_ratio > 0.35:
risk = _max_risk(risk, "medium")
warnings.append("目标深度变化超过当前深度的 35%,请确认预览范围。")
if target_depth < current_depth * 0.08:
risk = _max_risk(risk, "high")
warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
if not warnings and not blockers:
note = "可以尝试调整盲孔深度。"
else:
note = " ".join(blockers + warnings)
return {
"depth_status": status,
"depth_risk": risk,
"depth_warnings": "".join(warnings),
"depth_blockers": "".join(blockers),
"depth_note": note,
}
def _cylinder_suppress_readiness(info: dict[str, object]) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = float(info.get("angular_span", 0.0))
end_type = str(info.get("cylinder_end_type", "unknown"))
height = float(info.get("height_estimate", 0.0))
diameter = float(info.get("diameter", 0.0))
if guess != "hole/groove candidate":
blockers.append("封堵圆柱孔第一版只支持孔候选,不支持圆角、凸柱或未明确圆柱面。")
if angular_span < math.tau * 0.92:
blockers.append("封堵圆柱孔第一版只支持接近完整圆柱的孔,不支持半孔/槽。")
if end_type == "closed/internal":
blockers.append("当前圆柱两端都像在材料内部,不像可封堵的外部孔。")
if height <= 1e-9 or diameter <= 1e-9:
blockers.append("当前圆柱孔的直径或高度估算无效。")
if guess == "hole/groove candidate" and confidence != "high":
risk = _max_risk(risk, "medium")
warnings.append("孔判断置信度不是 high,封堵后请重点检查结果。")
if end_type not in {"blind", "through/open-ended"}:
risk = _max_risk(risk, "medium")
warnings.append("孔端部类型不明确,补料范围可能不是期望的孔范围。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
if not warnings and not blockers:
note = "可以尝试封堵该圆柱孔。"
else:
note = " ".join(blockers + warnings)
return {
"suppress_status": status,
"suppress_risk": risk,
"suppress_warnings": "".join(warnings),
"suppress_blockers": "".join(blockers),
"suppress_note": note,
}
def _edge_fillet_readiness(
info: dict[str, object],
radius: float | None = None,
) -> dict[str, object]:
risk = "medium"
status = "caution"
warnings: list[str] = ["STEP 没有建模历史,边倒圆依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"]
blockers: list[str] = []
curve = str(info.get("curve", ""))
length = float(info.get("length", 0.0))
adjacent_count = int(info.get("adjacent_face_count", 0))
if curve != "line":
blockers.append("添加圆角第一版只支持直线 edge。")
if length <= 1e-9:
blockers.append("当前 edge 长度无效。")
if adjacent_count < 2:
blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加圆角。")
elif adjacent_count > 2:
risk = _max_risk(risk, "medium")
warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。")
if radius is not None:
if radius <= 0:
blockers.append("圆角半径必须大于 0。")
elif length > 1e-9:
ratio = radius / length
if ratio >= 0.45:
blockers.append("圆角半径接近或超过 edge 长度的一半,第一版直接阻止。")
elif ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("圆角半径超过 edge 长度的 25%,很容易导致倒圆失败。")
elif ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("圆角半径相对 edge 长度偏大,请确认预览范围。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
if not warnings and not blockers:
note = "可以尝试给该直线边添加圆角。"
else:
note = " ".join(blockers + warnings)
return {
"fillet_status": status,
"fillet_risk": risk,
"fillet_warnings": "".join(warnings),
"fillet_blockers": "".join(blockers),
"fillet_note": note,
}
def _edge_chamfer_readiness(
info: dict[str, object],
distance: float | None = None,
) -> dict[str, object]:
risk = "medium"
status = "caution"
warnings: list[str] = ["STEP 没有建模历史,边倒角依赖当前 B-Rep 拓扑,部分边可能被 OCCT 拒绝。"]
blockers: list[str] = []
curve = str(info.get("curve", ""))
length = float(info.get("length", 0.0))
adjacent_count = int(info.get("adjacent_face_count", 0))
if curve != "line":
blockers.append("添加倒角第一版只支持直线 edge。")
if length <= 1e-9:
blockers.append("当前 edge 长度无效。")
if adjacent_count < 2:
blockers.append("当前 edge 没有检测到至少两个相邻 face,不能可靠添加倒角。")
elif adjacent_count > 2:
risk = _max_risk(risk, "medium")
warnings.append(f"当前 edge 相邻 face 数为 {adjacent_count},可能是复杂交汇边。")
if distance is not None:
if distance <= 0:
blockers.append("倒角距离必须大于 0。")
elif length > 1e-9:
ratio = distance / length
if ratio >= 0.45:
blockers.append("倒角距离接近或超过 edge 长度的一半,第一版直接阻止。")
elif ratio > 0.25:
risk = _max_risk(risk, "high")
warnings.append("倒角距离超过 edge 长度的 25%,很容易导致倒角失败。")
elif ratio > 0.12:
risk = _max_risk(risk, "medium")
warnings.append("倒角距离相对 edge 长度偏大,请确认预览范围。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
if not warnings and not blockers:
note = "可以尝试给该直线边添加倒角。"
else:
note = " ".join(blockers + warnings)
return {
"chamfer_status": status,
"chamfer_risk": risk,
"chamfer_warnings": "".join(warnings),
"chamfer_blockers": "".join(blockers),
"chamfer_note": note,
}
def _max_risk(current: str, candidate: str) -> str:
levels = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
return candidate if levels[candidate] > levels[current] else current
def _resize_mode(current_diameter: float, target_diameter: float) -> str:
return "enlarge" if target_diameter > current_diameter else "shrink"
def _join_nonempty(*values: object) -> str:
return "".join(str(value) for value in values if value not in {"", None})
def _int_values(value: object) -> list[int]:
if value is None or value == "":
return []
if isinstance(value, int):
return [value]
if isinstance(value, (list, tuple, set)):
result: list[int] = []
for item in value:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
return []
def _dir_tuple(direction) -> tuple[float, float, float]:
return (direction.X(), direction.Y(), direction.Z())
def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]:
values = _dir_tuple(direction)
if shape.Orientation() == TopAbs_REVERSED:
return (-values[0], -values[1], -values[2])
return values
def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]:
return (-values[0], -values[1], -values[2])
def _point_tuple(point) -> tuple[float, float, float]:
return (point.X(), point.Y(), point.Z())
def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt:
return gp_Pnt(
axis_point.X() + direction.X() * parameter,
axis_point.Y() + direction.Y() * parameter,
axis_point.Z() + direction.Z() * parameter,
)
def _direction_dot(left, right) -> float:
return left.X() * right.X() + left.Y() * right.Y() + left.Z() * right.Z()
def _axis_parameter(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float:
return (
(point.X() - axis_point.X()) * direction.X()
+ (point.Y() - axis_point.Y()) * direction.Y()
+ (point.Z() - axis_point.Z()) * direction.Z()
)
def _point_axis_distance(axis_point: gp_Pnt, direction, point: gp_Pnt) -> float:
projected = _point_on_axis(axis_point, direction, _axis_parameter(axis_point, direction, point))
return _vec_from_points(projected, point).Magnitude()
def _shape_axis_parameters(shape: TopoDS_Shape, axis_point: gp_Pnt, direction) -> list[float]:
parameters: list[float] = []
try:
for vertex in TopologyExplorer(shape, ignore_orientation=True).vertices():
point = BRep_Tool.Pnt(topods.Vertex(vertex))
parameters.append(_axis_parameter(axis_point, direction, point))
except Exception:
parameters.clear()
try:
parameters.append(_axis_parameter(axis_point, direction, _surface_center(shape)))
except Exception:
pass
return parameters
def _surface_center(shape: TopoDS_Shape) -> gp_Pnt:
props = GProp_GProps()
brepgprop.SurfaceProperties(shape, props)
return props.CentreOfMass()
def _orientation_name(orientation) -> str:
return ORIENTATION_TYPES.get(orientation, f"type {orientation}")
def _format_tuple(values: tuple[float, float, float]) -> str:
return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")"
def _vec_from_points(a: gp_Pnt, b: gp_Pnt) -> gp_Vec:
return gp_Vec(b.X() - a.X(), b.Y() - a.Y(), b.Z() - a.Z())
def _point_distance_sq(
point: tuple[float, float, float],
target: tuple[float, float, float],
) -> float:
dx = point[0] - target[0]
dy = point[1] - target[1]
dz = point[2] - target[2]
return dx * dx + dy * dy + dz * dz
def _point_segment_distance_sq(
point: tuple[float, float, float],
start: tuple[float, float, float],
end: tuple[float, float, float],
) -> float:
vx = end[0] - start[0]
vy = end[1] - start[1]
vz = end[2] - start[2]
wx = point[0] - start[0]
wy = point[1] - start[1]
wz = point[2] - start[2]
length_sq = vx * vx + vy * vy + vz * vz
if length_sq <= 1e-18:
return _point_distance_sq(point, start)
t = (wx * vx + wy * vy + wz * vz) / length_sq
t = max(0.0, min(1.0, t))
projection = (start[0] + t * vx, start[1] + t * vy, start[2] + t * vz)
return _point_distance_sq(point, projection)
def _dot(vec: gp_Vec, direction) -> float:
return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()