Files

4617 lines
196 KiB
Python
Raw Permalink Normal View History

2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
2026-07-23 18:35:02 +08:00
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,
)
2026-07-24 17:47:17 +08:00
from OCC.Core.TopExp import TopExp_Explorer, topexp
2026-07-23 18:35:02 +08:00
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
2026-07-24 17:47:17 +08:00
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
2026-07-23 18:35:02 +08:00
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
2026-07-24 17:47:17 +08:00
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
2026-07-23 18:35:02 +08:00
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] = []
2026-07-24 17:47:17 +08:00
self.edge_solid_ids: list[int] = []
2026-07-23 18:35:02 +08:00
self.solids: list[tuple[int, TopoDS_Shape]] = []
2026-07-24 17:47:17 +08:00
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]] = {}
2026-07-23 18:35:02 +08:00
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())),
)
2026-07-24 17:47:17 +08:00
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())),
)
2026-07-23 18:35:02 +08:00
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()
2026-07-24 17:47:17 +08:00
self.edge_solid_ids.clear()
2026-07-23 18:35:02 +08:00
self.solids.clear()
2026-07-24 17:47:17 +08:00
self._face_info_cache.clear()
self._edge_info_cache.clear()
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
2026-07-23 18:35:02 +08:00
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
2026-07-24 17:47:17 +08:00
part_solid_edge_maps: list[tuple[int, TopTools_IndexedDataMapOfShapeListOfShape]] = []
part_solid_entries: list[tuple[int, TopoDS_Shape]] = []
2026-07-23 18:35:02 +08:00
if part_solids:
for solid in part_solids:
2026-07-24 17:47:17 +08:00
current_solid_id = solid_id
2026-07-23 18:35:02 +08:00
self.solids.append((part.id, solid))
2026-07-24 17:47:17 +08:00
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:
2026-07-23 18:35:02 +08:00
for face in _explore(solid, TopAbs_FACE):
2026-07-24 17:47:17 +08:00
face_id = len(self.faces)
2026-07-23 18:35:02 +08:00
self.faces.append(face)
self.face_part_ids.append(part.id)
2026-07-24 17:47:17 +08:00
self.face_solid_ids.append(current_solid_id)
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
2026-07-23 18:35:02 +08:00
else:
for face in _explore(part.shape, TopAbs_FACE):
2026-07-24 17:47:17 +08:00
face_id = len(self.faces)
2026-07-23 18:35:02 +08:00
self.faces.append(face)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(-1)
2026-07-24 17:47:17 +08:00
self._cache_face_edge_links(face_id, face, part_edge_map, part_edge_ids_by_index)
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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]:
2026-07-24 17:47:17 +08:00
if face_id in self._face_info_cache:
return dict(self._face_info_cache[face_id])
2026-07-23 18:35:02 +08:00
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))
2026-07-24 17:47:17 +08:00
info.update(_cylinder_boss_resize_readiness(info))
info.update(_cylinder_depth_readiness(info))
info.update(_cylinder_suppress_readiness(info))
2026-07-23 18:35:02 +08:00
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()
2026-07-24 17:47:17 +08:00
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))
2026-07-23 18:35:02 +08:00
def edge_info(self, edge_id: int) -> dict[str, object]:
2026-07-24 17:47:17 +08:00
if edge_id in self._edge_info_cache:
return dict(self._edge_info_cache[edge_id])
2026-07-23 18:35:02 +08:00
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],
2026-07-24 17:47:17 +08:00
"solid_id": self._edge_solid_id(edge_id),
2026-07-23 18:35:02 +08:00
"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
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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]]:
2026-07-24 17:47:17 +08:00
per_type_limit = max(1, limit // 5)
2026-07-23 18:35:02 +08:00
candidates: list[dict[str, object]] = []
2026-07-24 17:47:17 +08:00
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)
2026-07-23 18:35:02 +08:00
for item in self.cylindrical_feature_candidates(
2026-07-24 17:47:17 +08:00
limit=cylinder_scan_limit,
include_end_info=True,
2026-07-23 18:35:02 +08:00
progress_callback=progress_callback,
):
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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": "推拉平面",
2026-07-24 17:47:17 +08:00
"target_kind": "face",
"target_id": face_id,
2026-07-23 18:35:02 +08:00
"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
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
status_order = {"ready": 0, "caution": 1, "blocked": 2}
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
2026-07-24 17:47:17 +08:00
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,
}
2026-07-23 18:35:02 +08:00
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),
2026-07-24 17:47:17 +08:00
int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))),
2026-07-23 18:35:02 +08:00
)
)
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))
2026-07-24 17:47:17 +08:00
candidate.update(_cylinder_boss_resize_readiness(candidate))
2026-07-23 18:35:02 +08:00
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)
2026-07-24 17:47:17 +08:00
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)
2026-07-23 18:35:02 +08:00
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,
2026-07-24 17:47:17 +08:00
"delta_diameter": delta_diameter,
"diameter_delta_ratio": diameter_delta_ratio,
"target_to_height_ratio": target_to_height_ratio,
2026-07-23 18:35:02 +08:00
"resize_mode": resize_mode,
2026-07-24 17:47:17 +08:00
"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"),
2026-07-23 18:35:02 +08:00
"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,
}
2026-07-24 17:47:17 +08:00
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]:
2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
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 余量。"
2026-07-23 18:35:02 +08:00
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),
2026-07-24 17:47:17 +08:00
"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,
2026-07-23 18:35:02 +08:00
**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),
}
2026-07-24 17:47:17 +08:00
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),
}
2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
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
)
2026-07-23 18:35:02 +08:00
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,
}
2026-07-24 17:47:17 +08:00
if is_partial_cylinder and (is_small_radius or is_fillet_like_partial):
2026-07-23 18:35:02 +08:00
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:
2026-07-24 17:47:17 +08:00
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}")
2026-07-23 18:35:02 +08:00
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}")
2026-07-24 17:47:17 +08:00
_write_step(_prepare_shape_for_step_export(part.shape), Path(filename))
2026-07-23 18:35:02 +08:00
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}")
2026-07-24 17:47:17 +08:00
_write_step(_prepare_shape_for_step_export(self.solids[solid_id][1]), Path(filename))
2026-07-23 18:35:02 +08:00
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))
2026-07-24 17:47:17 +08:00
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))
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
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),
}
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
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}")
2026-07-23 18:35:02 +08:00
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
2026-07-24 17:47:17 +08:00
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),
}
]
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
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"]
2026-07-23 18:35:02 +08:00
vec = gp_Vec(
float(outward[0]) * distance,
float(outward[1]) * distance,
float(outward[2]) * distance,
)
2026-07-24 17:47:17 +08:00
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.")
2026-07-23 18:35:02 +08:00
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}")
2026-07-24 17:47:17 +08:00
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()
2026-07-23 18:35:02 +08:00
op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape)
2026-07-24 17:47:17 +08:00
result = _finalize_boolean_result(op, "push/pull")
result = _cleanup_push_pull_result(result, part.shape, profile_shape, distance)
2026-07-23 18:35:02 +08:00
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}, "
2026-07-24 17:47:17 +08:00
f"tool_overlap={overlap:g}, "
f"scope_faces={len(scope_face_ids)}, "
2026-07-23 18:35:02 +08:00
f"outward_direction={_format_tuple(outward)}, "
2026-07-24 17:47:17 +08:00
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']}."
2026-07-23 18:35:02 +08:00
)
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)
2026-07-24 17:47:17 +08:00
source_shape = _finalize_boolean_result(fuse, "cylinder fill/fuse")
2026-07-23 18:35:02 +08:00
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()
2026-07-24 17:47:17 +08:00
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"
2026-07-23 18:35:02 +08:00
part.shape = result
self.refresh_topology()
return (
2026-07-24 17:47:17 +08:00
f"Blind cylindrical depth completed: depth {float(plan['current_depth']):g} -> {target_depth:g}, "
f"mode={plan['depth_mode']}, action={action}, "
2026-07-23 18:35:02 +08:00
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
2026-07-24 17:47:17 +08:00
f"tool={plan['depth_tool_strategy']}, height={float(plan['depth_tool_height']):g}."
2026-07-23 18:35:02 +08:00
)
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)
2026-07-24 17:47:17 +08:00
def build_edge_polydata(
self,
edge_ids: Iterable[int] | None = None,
part_ids: Iterable[int] | None = None,
deflection: float = 0.8,
):
2026-07-23 18:35:02 +08:00
import vtk
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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):
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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)
2026-07-24 17:47:17 +08:00
part_arr.InsertNextValue(part_id)
2026-07-23 18:35:02 +08:00
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}")
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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
2026-07-24 17:47:17 +08:00
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),
}
2026-07-23 18:35:02 +08:00
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)
2026-07-24 17:47:17 +08:00
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
2026-07-23 18:35:02 +08:00
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))
2026-07-24 17:47:17 +08:00
height_estimate = float(info.get("height_estimate", 0.0))
2026-07-23 18:35:02 +08:00
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("目标直径与当前直径几乎相同,不需要修改。")
2026-07-24 17:47:17 +08:00
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:
2026-07-23 18:35:02 +08:00
risk = _max_risk(risk, "high")
2026-07-24 17:47:17 +08:00
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("缩小孔径会先补料再重切,属于高风险实验功能。")
2026-07-23 18:35:02 +08:00
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,
}
2026-07-24 17:47:17 +08:00
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,
}
2026-07-23 18:35:02 +08:00
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"
2026-07-24 17:47:17 +08:00
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 []
2026-07-23 18:35:02 +08:00
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,
)
2026-07-24 17:47:17 +08:00
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()
2026-07-23 18:35:02 +08:00
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())
2026-07-24 17:47:17 +08:00
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)
2026-07-23 18:35:02 +08:00
def _dot(vec: gp_Vec, direction) -> float:
return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()