1362 lines
63 KiB
Python
1362 lines
63 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Callable, Iterable
|
|||
|
|
|
|||
|
|
from OCC.Core.BRep import BRep_Tool
|
|||
|
|
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
|||
|
|
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
|||
|
|
from OCC.Core.BRepBndLib import brepbndlib
|
|||
|
|
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
|||
|
|
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|||
|
|
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
|||
|
|
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
|||
|
|
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
|||
|
|
from OCC.Core.BRepGProp import brepgprop
|
|||
|
|
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
|||
|
|
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
|||
|
|
from OCC.Core.Bnd import Bnd_Box
|
|||
|
|
from OCC.Core.GeomAbs import (
|
|||
|
|
GeomAbs_BSplineCurve,
|
|||
|
|
GeomAbs_BSplineSurface,
|
|||
|
|
GeomAbs_BezierCurve,
|
|||
|
|
GeomAbs_BezierSurface,
|
|||
|
|
GeomAbs_Circle,
|
|||
|
|
GeomAbs_Cone,
|
|||
|
|
GeomAbs_Cylinder,
|
|||
|
|
GeomAbs_Ellipse,
|
|||
|
|
GeomAbs_Hyperbola,
|
|||
|
|
GeomAbs_Line,
|
|||
|
|
GeomAbs_OffsetSurface,
|
|||
|
|
GeomAbs_OtherCurve,
|
|||
|
|
GeomAbs_OtherSurface,
|
|||
|
|
GeomAbs_Parabola,
|
|||
|
|
GeomAbs_Plane,
|
|||
|
|
GeomAbs_Sphere,
|
|||
|
|
GeomAbs_SurfaceOfExtrusion,
|
|||
|
|
GeomAbs_SurfaceOfRevolution,
|
|||
|
|
GeomAbs_Torus,
|
|||
|
|
)
|
|||
|
|
from OCC.Core.GProp import GProp_GProps
|
|||
|
|
from OCC.Core.ShapeFix import ShapeFix_Shape
|
|||
|
|
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
|||
|
|
from OCC.Core.TopAbs import (
|
|||
|
|
TopAbs_EDGE,
|
|||
|
|
TopAbs_EXTERNAL,
|
|||
|
|
TopAbs_FACE,
|
|||
|
|
TopAbs_FORWARD,
|
|||
|
|
TopAbs_IN,
|
|||
|
|
TopAbs_INTERNAL,
|
|||
|
|
TopAbs_OUT,
|
|||
|
|
TopAbs_REVERSED,
|
|||
|
|
TopAbs_SOLID,
|
|||
|
|
)
|
|||
|
|
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
|||
|
|
from OCC.Core.TopLoc import TopLoc_Location
|
|||
|
|
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
|||
|
|
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
|||
|
|
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
|||
|
|
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
|||
|
|
|
|||
|
|
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
|||
|
|
from .geometry_utils import * # noqa: F403
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FeatureMixin:
|
|||
|
|
def editable_feature_candidates(
|
|||
|
|
self,
|
|||
|
|
limit: int = 160,
|
|||
|
|
detailed: bool = False,
|
|||
|
|
max_scan_faces: int | None = None,
|
|||
|
|
max_scan_edges: int | None = None,
|
|||
|
|
progress_callback: Callable[[], None] | None = None,
|
|||
|
|
) -> list[dict[str, object]]:
|
|||
|
|
per_type_limit = max(1, limit // 5)
|
|||
|
|
candidates: list[dict[str, object]] = []
|
|||
|
|
|
|||
|
|
diameter_count = 0
|
|||
|
|
slot_width_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))
|
|||
|
|
slot_width_limit = max(2, min(per_type_limit, limit // 10))
|
|||
|
|
cylinder_scan_limit = max(per_type_limit * 4, 24)
|
|||
|
|
for item in self.cylindrical_feature_candidates(
|
|||
|
|
limit=cylinder_scan_limit,
|
|||
|
|
include_end_info=True,
|
|||
|
|
max_scan_faces=max_scan_faces,
|
|||
|
|
progress_callback=progress_callback,
|
|||
|
|
):
|
|||
|
|
feature_guess = str(item["feature_guess"])
|
|||
|
|
if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate":
|
|||
|
|
feature = self.feature_info(int(item["face_id"]))
|
|||
|
|
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
|
|||
|
|
support_note = (
|
|||
|
|
f"支撑 Face: {support_face_ids}。"
|
|||
|
|
if support_face_ids
|
|||
|
|
else "暂未识别出稳定支撑 Face。"
|
|||
|
|
)
|
|||
|
|
candidates.append(
|
|||
|
|
{
|
|||
|
|
"operation_key": "inspect_existing_fillet",
|
|||
|
|
"operation": "修改已有圆角半径",
|
|||
|
|
"target_kind": "face",
|
|||
|
|
"target_id": item["face_id"],
|
|||
|
|
"face_id": item["face_id"],
|
|||
|
|
"part_id": item["part_id"],
|
|||
|
|
"solid_id": item["solid_id"],
|
|||
|
|
"surface": "cylinder",
|
|||
|
|
"feature_guess": feature_guess,
|
|||
|
|
"current_value": feature.get("existing_fillet_radius_estimate", item["radius"]),
|
|||
|
|
"current_value_label": "radius",
|
|||
|
|
"status": "caution",
|
|||
|
|
"risk": "medium" if len(support_face_ids) >= 2 else "high",
|
|||
|
|
"confidence": item["confidence"],
|
|||
|
|
"note": (
|
|||
|
|
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径,"
|
|||
|
|
"再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。"
|
|||
|
|
f" {support_note}"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
existing_fillet_count += 1
|
|||
|
|
|
|||
|
|
if diameter_count < per_type_limit and feature_guess != "round/fillet candidate":
|
|||
|
|
candidates.append(
|
|||
|
|
{
|
|||
|
|
"operation_key": "resize_cylinder",
|
|||
|
|
"operation": "调整圆柱孔径",
|
|||
|
|
"target_kind": "face",
|
|||
|
|
"target_id": item["face_id"],
|
|||
|
|
"face_id": item["face_id"],
|
|||
|
|
"part_id": item["part_id"],
|
|||
|
|
"solid_id": item["solid_id"],
|
|||
|
|
"surface": "cylinder",
|
|||
|
|
"feature_guess": feature_guess,
|
|||
|
|
"current_value": item["diameter"],
|
|||
|
|
"current_value_label": "diameter",
|
|||
|
|
"status": item["resize_status"],
|
|||
|
|
"risk": item["resize_risk"],
|
|||
|
|
"confidence": item["confidence"],
|
|||
|
|
"note": item["resize_note"],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
diameter_count += 1
|
|||
|
|
if (
|
|||
|
|
slot_width_count < slot_width_limit
|
|||
|
|
and feature_guess == "hole/groove candidate"
|
|||
|
|
and float(item.get("angular_span", 0.0)) < math.tau * 0.92
|
|||
|
|
):
|
|||
|
|
feature = self.feature_info(int(item["face_id"]))
|
|||
|
|
slot_width = feature.get("slot_chord_width_estimate")
|
|||
|
|
if isinstance(slot_width, (int, float)) and float(slot_width) > 0:
|
|||
|
|
candidates.append(
|
|||
|
|
{
|
|||
|
|
"operation_key": "resize_slot_width",
|
|||
|
|
"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": float(slot_width),
|
|||
|
|
"current_value_label": "slot_width",
|
|||
|
|
"status": item["resize_status"],
|
|||
|
|
"risk": item["resize_risk"],
|
|||
|
|
"confidence": item["confidence"],
|
|||
|
|
"note": (
|
|||
|
|
"这是槽/半孔候选;点击后会选中该 face,并把槽/半孔宽度输入框预填为参考目标值。"
|
|||
|
|
"执行时会把槽宽换算为圆柱直径后重建。"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
slot_width_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 slot_width_count >= slot_width_limit
|
|||
|
|
and existing_fillet_count >= existing_fillet_limit
|
|||
|
|
):
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
plane_count = 0
|
|||
|
|
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
|
|||
|
|
for face_id, face in enumerate(self.faces[:face_scan_limit]):
|
|||
|
|
if progress_callback is not None and face_id % 30 == 0:
|
|||
|
|
progress_callback()
|
|||
|
|
if plane_count >= per_type_limit:
|
|||
|
|
break
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Plane:
|
|||
|
|
continue
|
|||
|
|
props = GProp_GProps()
|
|||
|
|
brepgprop.SurfaceProperties(face, props)
|
|||
|
|
if detailed:
|
|||
|
|
direction_info = self._plane_push_pull_direction(face_id, surf)
|
|||
|
|
confidence = str(direction_info["confidence"])
|
|||
|
|
risk = "low" if confidence == "high" else "medium"
|
|||
|
|
status = "ready" if confidence == "high" else "caution"
|
|||
|
|
note = str(direction_info["note"])
|
|||
|
|
else:
|
|||
|
|
confidence = "pending"
|
|||
|
|
risk = "medium"
|
|||
|
|
status = "caution"
|
|||
|
|
note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。"
|
|||
|
|
candidates.append(
|
|||
|
|
{
|
|||
|
|
"operation_key": "push_pull_plane",
|
|||
|
|
"operation": "推拉平面",
|
|||
|
|
"target_kind": "face",
|
|||
|
|
"target_id": face_id,
|
|||
|
|
"face_id": face_id,
|
|||
|
|
"part_id": self.face_part_ids[face_id],
|
|||
|
|
"solid_id": self.face_solid_ids[face_id],
|
|||
|
|
"surface": "plane",
|
|||
|
|
"feature_guess": "planar push/pull candidate",
|
|||
|
|
"current_value": props.Mass(),
|
|||
|
|
"current_value_label": "area",
|
|||
|
|
"status": status,
|
|||
|
|
"risk": risk,
|
|||
|
|
"confidence": confidence,
|
|||
|
|
"note": note,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
plane_count += 1
|
|||
|
|
|
|||
|
|
fillet_edge_count = 0
|
|||
|
|
chamfer_edge_count = 0
|
|||
|
|
edge_length_count = 0
|
|||
|
|
edge_type_limit = max(1, per_type_limit // 3)
|
|||
|
|
edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges)))
|
|||
|
|
for edge_id, edge in enumerate(self.edges[:edge_scan_limit]):
|
|||
|
|
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)
|
|||
|
|
is_line_edge = curve.GetType() == GeomAbs_Line
|
|||
|
|
is_circle_edge = curve.GetType() == GeomAbs_Circle
|
|||
|
|
props = GProp_GProps()
|
|||
|
|
brepgprop.LinearProperties(edge, props)
|
|||
|
|
length = props.Mass()
|
|||
|
|
if length <= 1e-9:
|
|||
|
|
continue
|
|||
|
|
solid_id = self._edge_solid_id(edge_id)
|
|||
|
|
curve_label = CURVE_TYPES.get(curve.GetType(), f"type {curve.GetType()}")
|
|||
|
|
if is_line_edge and 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 is_line_edge and 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": f"{curve_label} edge length candidate",
|
|||
|
|
"current_value": length,
|
|||
|
|
"current_value_label": "length",
|
|||
|
|
"status": "caution",
|
|||
|
|
"risk": "medium" if is_line_edge or is_circle_edge else "high",
|
|||
|
|
"confidence": "pending",
|
|||
|
|
"note": "快速扫描:直线边会优先尝试端面推拉;圆边会尝试换算相邻圆柱直径;其他边会使用几何缩放 fallback。",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
edge_length_count += 1
|
|||
|
|
|
|||
|
|
status_order = {"ready": 0, "caution": 1, "blocked": 2}
|
|||
|
|
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
|
|||
|
|
operation_order = {
|
|||
|
|
"resize_cylinder": 0,
|
|||
|
|
"resize_slot_width": 1,
|
|||
|
|
"resize_boss": 2,
|
|||
|
|
"suppress_cylinder": 3,
|
|||
|
|
"resize_depth": 4,
|
|||
|
|
"inspect_existing_fillet": 5,
|
|||
|
|
"push_pull_plane": 6,
|
|||
|
|
"fillet_edge": 7,
|
|||
|
|
"chamfer_edge": 8,
|
|||
|
|
"resize_edge_length": 9,
|
|||
|
|
}
|
|||
|
|
candidates.sort(
|
|||
|
|
key=lambda item: (
|
|||
|
|
status_order.get(str(item["status"]), 9),
|
|||
|
|
risk_order.get(str(item["risk"]), 9),
|
|||
|
|
operation_order.get(str(item["operation_key"]), 9),
|
|||
|
|
int(item.get("target_id", item.get("face_id", item.get("edge_id", -1)))),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
return candidates[:limit]
|
|||
|
|
|
|||
|
|
def cylindrical_feature_candidates(
|
|||
|
|
self,
|
|||
|
|
limit: int = 100,
|
|||
|
|
include_end_info: bool = False,
|
|||
|
|
max_scan_faces: int | None = None,
|
|||
|
|
progress_callback: Callable[[], None] | None = None,
|
|||
|
|
) -> list[dict[str, object]]:
|
|||
|
|
candidates: list[dict[str, object]] = []
|
|||
|
|
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
|
|||
|
|
for face_id, face in enumerate(self.faces[:face_scan_limit]):
|
|||
|
|
if progress_callback is not None and face_id % 30 == 0:
|
|||
|
|
progress_callback()
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|||
|
|
continue
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
props = GProp_GProps()
|
|||
|
|
brepgprop.SurfaceProperties(face, props)
|
|||
|
|
radius = cyl.Radius()
|
|||
|
|
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
|
|||
|
|
v_span = abs(surf.LastVParameter() - surf.FirstVParameter())
|
|||
|
|
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
|
|||
|
|
height_estimate = props.Mass() / swept_area
|
|||
|
|
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
|
|||
|
|
classification = self._classify_cylindrical_face(face_id, surf)
|
|||
|
|
candidate = {
|
|||
|
|
"face_id": face_id,
|
|||
|
|
"part_id": self.face_part_ids[face_id],
|
|||
|
|
"solid_id": self.face_solid_ids[face_id],
|
|||
|
|
"radius": radius,
|
|||
|
|
"diameter": radius * 2.0,
|
|||
|
|
"axis": _dir_tuple(cyl.Axis().Direction()),
|
|||
|
|
"area": props.Mass(),
|
|||
|
|
"angular_span": u_span,
|
|||
|
|
"height_estimate": height_estimate,
|
|||
|
|
"param_height": v_span,
|
|||
|
|
"boundary_edges": boundary_edges,
|
|||
|
|
"feature_guess": classification["feature_guess"],
|
|||
|
|
"material_toward_axis": classification["toward_axis"],
|
|||
|
|
"material_away_axis": classification["away_axis"],
|
|||
|
|
"material_vote_summary": classification["vote_summary"],
|
|||
|
|
"material_sample_count": classification["sample_count"],
|
|||
|
|
"confidence": classification["confidence"],
|
|||
|
|
"note": classification["note"],
|
|||
|
|
}
|
|||
|
|
if include_end_info:
|
|||
|
|
candidate.update(self._cylinder_end_opening_info(face_id, surf))
|
|||
|
|
candidate.update(_cylinder_resize_readiness(candidate))
|
|||
|
|
candidate.update(_cylinder_boss_resize_readiness(candidate))
|
|||
|
|
candidates.append(candidate)
|
|||
|
|
if len(candidates) >= limit:
|
|||
|
|
break
|
|||
|
|
return candidates
|
|||
|
|
|
|||
|
|
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
|
|||
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|||
|
|
raise ValueError(f"Unknown face id {face_id}")
|
|||
|
|
info = self.face_info(face_id)
|
|||
|
|
if info.get("surface") != "cylinder" or "diameter" not in info:
|
|||
|
|
return {
|
|||
|
|
"status": "blocked",
|
|||
|
|
"risk": "blocked",
|
|||
|
|
"message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。",
|
|||
|
|
}
|
|||
|
|
current_diameter = float(info["diameter"])
|
|||
|
|
feature = self.feature_info(face_id)
|
|||
|
|
axis_range = self._cylindrical_axis_range(
|
|||
|
|
face_id,
|
|||
|
|
BRepAdaptor_Surface(self.faces[face_id]),
|
|||
|
|
_int_values(feature.get("feature_side_face_ids")),
|
|||
|
|
)
|
|||
|
|
scoped_info = dict(info)
|
|||
|
|
scoped_info["height_estimate"] = axis_range["span"]
|
|||
|
|
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
|||
|
|
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
|
|||
|
|
readiness = _cylinder_resize_readiness(scoped_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(scoped_info.get("height_estimate", 0.0))
|
|||
|
|
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else ""
|
|||
|
|
cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter, feature)
|
|||
|
|
fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {}
|
|||
|
|
return {
|
|||
|
|
"status": readiness["resize_status"],
|
|||
|
|
"risk": readiness["resize_risk"],
|
|||
|
|
"message": readiness["resize_note"],
|
|||
|
|
"warnings": readiness["resize_warnings"],
|
|||
|
|
"blockers": readiness["resize_blockers"],
|
|||
|
|
"face_id": face_id,
|
|||
|
|
"part_id": info["part_id"],
|
|||
|
|
"solid_id": info["solid_id"],
|
|||
|
|
"current_diameter": current_diameter,
|
|||
|
|
"target_diameter": new_diameter,
|
|||
|
|
"delta_diameter": delta_diameter,
|
|||
|
|
"diameter_delta_ratio": diameter_delta_ratio,
|
|||
|
|
"target_to_height_ratio": target_to_height_ratio,
|
|||
|
|
"resize_mode": resize_mode,
|
|||
|
|
"feature_type": feature.get("feature_type"),
|
|||
|
|
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
|
|||
|
|
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
|
|||
|
|
"feature_bottom_note": feature.get("feature_bottom_note"),
|
|||
|
|
"feature_guess": info.get("feature_guess"),
|
|||
|
|
"confidence": info.get("confidence"),
|
|||
|
|
"angular_span": info.get("angular_span"),
|
|||
|
|
"height_estimate": scoped_info.get("height_estimate"),
|
|||
|
|
"same_domain_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"same_domain_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
|||
|
|
"same_domain_range_source": axis_range["range_source"],
|
|||
|
|
"material_vote_summary": info.get("material_vote_summary"),
|
|||
|
|
"material_sample_count": info.get("material_sample_count"),
|
|||
|
|
**cutter_plan,
|
|||
|
|
**fill_plan,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
|
|||
|
|
if face_id < 0 or face_id >= len(self.faces):
|
|||
|
|
raise ValueError(f"Unknown face id {face_id}")
|
|||
|
|
info = self.face_info(face_id)
|
|||
|
|
if info.get("surface") != "cylinder" or "diameter" not in info:
|
|||
|
|
return {
|
|||
|
|
"status": "blocked",
|
|||
|
|
"risk": "blocked",
|
|||
|
|
"message": "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
current_diameter = float(info["diameter"])
|
|||
|
|
feature = self.feature_info(face_id)
|
|||
|
|
axis_range = self._cylindrical_axis_range(
|
|||
|
|
face_id,
|
|||
|
|
BRepAdaptor_Surface(self.faces[face_id]),
|
|||
|
|
_int_values(feature.get("feature_side_face_ids")),
|
|||
|
|
)
|
|||
|
|
scoped_info = dict(info)
|
|||
|
|
scoped_info["height_estimate"] = axis_range["span"]
|
|||
|
|
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
|||
|
|
readiness = _cylinder_boss_resize_readiness(scoped_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(scoped_info.get("height_estimate", 0.0))
|
|||
|
|
target_to_height_ratio = new_diameter / height_estimate if height_estimate > 1e-9 else ""
|
|||
|
|
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": scoped_info.get("height_estimate"),
|
|||
|
|
"same_domain_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"same_domain_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
|||
|
|
"same_domain_range_source": axis_range["range_source"],
|
|||
|
|
"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 不是圆柱面,不能封堵圆柱孔。",
|
|||
|
|
}
|
|||
|
|
feature = self.feature_info(face_id)
|
|||
|
|
axis_range = self._cylindrical_axis_range(
|
|||
|
|
face_id,
|
|||
|
|
BRepAdaptor_Surface(self.faces[face_id]),
|
|||
|
|
_int_values(feature.get("feature_side_face_ids")),
|
|||
|
|
)
|
|||
|
|
scoped_info = dict(info)
|
|||
|
|
scoped_info["height_estimate"] = axis_range["span"]
|
|||
|
|
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
|||
|
|
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
|
|||
|
|
readiness = _cylinder_suppress_readiness(scoped_info)
|
|||
|
|
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": scoped_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": scoped_info.get("cylinder_end_type"),
|
|||
|
|
"same_domain_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"same_domain_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
|||
|
|
"same_domain_range_source": axis_range["range_source"],
|
|||
|
|
"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,
|
|||
|
|
bottom_face_id: int | None = None,
|
|||
|
|
) -> 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,
|
|||
|
|
bottom_face_id=bottom_face_id,
|
|||
|
|
)
|
|||
|
|
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"])
|
|||
|
|
depth_info["manual_bottom_face_used"] = bool(context.get("manual_bottom_face_used"))
|
|||
|
|
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": context.get("feature_bottom_face_ids", feature.get("feature_bottom_face_ids")),
|
|||
|
|
"manual_bottom_face_id": context.get("manual_bottom_face_id", ""),
|
|||
|
|
"manual_bottom_face_used": bool(context.get("manual_bottom_face_used")),
|
|||
|
|
"manual_bottom_face_note": context.get("manual_bottom_face_note", ""),
|
|||
|
|
"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,
|
|||
|
|
bottom_face_id: int | None = None,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
face = self.faces[face_id]
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "当前选中的 face 不是圆柱面。",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
manual_bottom_face_id = None
|
|||
|
|
manual_bottom_face_used = bottom_face_id is not None
|
|||
|
|
if bottom_face_id is not None:
|
|||
|
|
manual_bottom_face_id = int(bottom_face_id)
|
|||
|
|
if manual_bottom_face_id < 0 or manual_bottom_face_id >= len(self.faces):
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": f"手动底面 Face ID {manual_bottom_face_id} 不存在。",
|
|||
|
|
}
|
|||
|
|
if manual_bottom_face_id == face_id:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "手动底面不能和当前圆柱侧壁使用同一个 Face ID。",
|
|||
|
|
}
|
|||
|
|
source_solid_id = self.face_solid_ids[face_id]
|
|||
|
|
bottom_solid_id = self.face_solid_ids[manual_bottom_face_id]
|
|||
|
|
if source_solid_id >= 0 and bottom_solid_id >= 0 and source_solid_id != bottom_solid_id:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "手动底面 Face 与当前圆柱面不属于同一个 solid,已阻止孔深修改。",
|
|||
|
|
}
|
|||
|
|
bottom_face_ids = (manual_bottom_face_id,)
|
|||
|
|
manual_bottom_face_note = "使用用户手动指定的底面 Face ID 计算孔深。"
|
|||
|
|
else:
|
|||
|
|
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
|
|||
|
|
manual_bottom_face_note = ""
|
|||
|
|
|
|||
|
|
if not bottom_face_ids:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "第一版孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
radius = float(cyl.Radius())
|
|||
|
|
axis = cyl.Axis()
|
|||
|
|
axis_point = axis.Location()
|
|||
|
|
axis_dir = axis.Direction()
|
|||
|
|
axis_range = self._cylindrical_axis_range(
|
|||
|
|
face_id,
|
|||
|
|
surf,
|
|||
|
|
_int_values(feature.get("feature_side_face_ids")),
|
|||
|
|
)
|
|||
|
|
v_min = float(axis_range["v_min"])
|
|||
|
|
v_max = float(axis_range["v_max"])
|
|||
|
|
start_open = info.get("start_end_open") is True
|
|||
|
|
end_open = info.get("end_end_open") is True
|
|||
|
|
open_direction_source = "axis-end-material-sampling"
|
|||
|
|
if start_open != end_open:
|
|||
|
|
open_parameter = v_min
|
|||
|
|
nominal_bottom_parameter = v_max
|
|||
|
|
direction_sign = 1.0
|
|||
|
|
if not start_open:
|
|||
|
|
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
|
|||
|
|
elif manual_bottom_face_used:
|
|||
|
|
bottom_parameter = self._bottom_face_axis_parameter(
|
|||
|
|
bottom_face_ids,
|
|||
|
|
axis_point,
|
|||
|
|
axis_dir,
|
|||
|
|
(v_min + v_max) * 0.5,
|
|||
|
|
)
|
|||
|
|
if bottom_parameter is None:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "手动底面无法投影到当前圆柱轴线上,不能计算孔深。",
|
|||
|
|
}
|
|||
|
|
if abs(bottom_parameter - v_min) <= abs(bottom_parameter - v_max):
|
|||
|
|
open_parameter = v_max
|
|||
|
|
nominal_bottom_parameter = v_min
|
|||
|
|
direction_sign = -1.0
|
|||
|
|
else:
|
|||
|
|
open_parameter = v_min
|
|||
|
|
nominal_bottom_parameter = v_max
|
|||
|
|
direction_sign = 1.0
|
|||
|
|
current_depth_source = "manual-bottom-face-axis-parameter"
|
|||
|
|
open_direction_source = "manual-bottom-face-nearest-axis-end"
|
|||
|
|
else:
|
|||
|
|
return {
|
|||
|
|
"context_status": "blocked",
|
|||
|
|
"context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。",
|
|||
|
|
}
|
|||
|
|
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_open_direction_source": open_direction_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_scope_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"depth_scope_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"depth_range_source": axis_range["range_source"],
|
|||
|
|
"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)),
|
|||
|
|
"feature_bottom_face_ids": bottom_face_ids,
|
|||
|
|
"manual_bottom_face_id": manual_bottom_face_id if manual_bottom_face_used else "",
|
|||
|
|
"manual_bottom_face_used": manual_bottom_face_used,
|
|||
|
|
"manual_bottom_face_note": manual_bottom_face_note,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _bounded_cylinder_cutter_plan(
|
|||
|
|
self,
|
|||
|
|
face_id: int,
|
|||
|
|
new_diameter: float,
|
|||
|
|
feature: dict[str, object] | None = None,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
face = self.faces[face_id]
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|||
|
|
return {
|
|||
|
|
"cutter_strategy": "unavailable",
|
|||
|
|
"cutter_note": "selected face is not cylindrical",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
old_radius = cyl.Radius()
|
|||
|
|
new_radius = new_diameter / 2.0
|
|||
|
|
feature = feature or self.feature_info(face_id)
|
|||
|
|
axis_range = self._cylindrical_axis_range(
|
|||
|
|
face_id,
|
|||
|
|
surf,
|
|||
|
|
_int_values(feature.get("feature_side_face_ids")),
|
|||
|
|
)
|
|||
|
|
v_min = float(axis_range["v_min"])
|
|||
|
|
v_max = float(axis_range["v_max"])
|
|||
|
|
span = max(v_max - v_min, 0.0)
|
|||
|
|
end_info = self._cylinder_end_opening_info(face_id, surf, axis_range)
|
|||
|
|
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
|
|||
|
|
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
|
|||
|
|
opening_face_ids = tuple(feature.get("feature_opening_face_ids", ()))
|
|||
|
|
bottom_protection = bool(bottom_face_ids)
|
|||
|
|
if bottom_protection:
|
|||
|
|
bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。"
|
|||
|
|
else:
|
|||
|
|
bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。"
|
|||
|
|
start_parameter = v_min - start_margin
|
|||
|
|
end_parameter = v_max + end_margin
|
|||
|
|
height = max(end_parameter - start_parameter, 1e-6)
|
|||
|
|
axis = cyl.Axis()
|
|||
|
|
direction = axis.Direction()
|
|||
|
|
axis_point = axis.Location()
|
|||
|
|
start = gp_Pnt(
|
|||
|
|
axis_point.X() + direction.X() * start_parameter,
|
|||
|
|
axis_point.Y() + direction.Y() * start_parameter,
|
|||
|
|
axis_point.Z() + direction.Z() * start_parameter,
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"cutter_strategy": "bounded-to-selected-cylinder-v-range",
|
|||
|
|
"cutter_note": (
|
|||
|
|
"有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter,"
|
|||
|
|
"如果没有同域拆分则退回选中 face 范围,减少贯穿整个零件的误切风险。"
|
|||
|
|
),
|
|||
|
|
"cutter_scope_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"cutter_scope_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"cutter_range_source": axis_range["range_source"],
|
|||
|
|
"cutter_start_parameter": start_parameter,
|
|||
|
|
"cutter_end_parameter": end_parameter,
|
|||
|
|
"cutter_height": height,
|
|||
|
|
"cutter_margin": base_margin,
|
|||
|
|
"cutter_start_margin": start_margin,
|
|||
|
|
"cutter_end_margin": end_margin,
|
|||
|
|
"cutter_radius": new_radius,
|
|||
|
|
"cutter_axis_point": _point_tuple(axis_point),
|
|||
|
|
"cutter_axis_direction": _dir_tuple(direction),
|
|||
|
|
"cutter_start_point": _point_tuple(start),
|
|||
|
|
"cutter_bottom_protection": bottom_protection,
|
|||
|
|
"cutter_protected_bottom_face_ids": bottom_face_ids,
|
|||
|
|
"cutter_opening_face_ids": opening_face_ids,
|
|||
|
|
"cutter_bottom_note": bottom_note,
|
|||
|
|
**end_info,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]:
|
|||
|
|
face = self.faces[face_id]
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|||
|
|
return {
|
|||
|
|
"fill_strategy": "unavailable",
|
|||
|
|
"fill_note": "selected face is not cylindrical",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
radius = cyl.Radius()
|
|||
|
|
axis_range = self._cylindrical_axis_range(face_id, surf)
|
|||
|
|
v_min = float(axis_range["v_min"])
|
|||
|
|
v_max = float(axis_range["v_max"])
|
|||
|
|
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_scope_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"fill_scope_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"fill_range_source": axis_range["range_source"],
|
|||
|
|
"fill_start_parameter": v_min,
|
|||
|
|
"fill_end_parameter": v_max,
|
|||
|
|
"fill_height": height,
|
|||
|
|
"fill_radius": radius + overlap,
|
|||
|
|
"fill_radius_overlap": overlap,
|
|||
|
|
"fill_start_point": _point_tuple(start),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
|
|||
|
|
face = self.faces[face_id]
|
|||
|
|
surf = BRepAdaptor_Surface(face)
|
|||
|
|
if surf.GetType() != GeomAbs_Cylinder:
|
|||
|
|
return {
|
|||
|
|
"boss_tool_strategy": "unavailable",
|
|||
|
|
"boss_tool_note": "selected face is not cylindrical",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
old_radius = cyl.Radius()
|
|||
|
|
new_radius = new_diameter / 2.0
|
|||
|
|
axis_range = self._cylindrical_axis_range(face_id, surf)
|
|||
|
|
v_min = float(axis_range["v_min"])
|
|||
|
|
v_max = float(axis_range["v_max"])
|
|||
|
|
span = max(v_max - v_min, 1e-6)
|
|||
|
|
delta_radius = abs(new_radius - old_radius)
|
|||
|
|
base_radius = max(old_radius, new_radius)
|
|||
|
|
resize_mode = _resize_mode(old_radius * 2.0, new_diameter)
|
|||
|
|
if resize_mode == "enlarge":
|
|||
|
|
axial_margin = 0.0
|
|||
|
|
start_parameter = v_min
|
|||
|
|
end_parameter = v_max
|
|||
|
|
else:
|
|||
|
|
axial_margin = min(
|
|||
|
|
max(base_radius * 0.001, delta_radius * 0.01, span * 0.001, 0.001),
|
|||
|
|
max(span * 0.01, 0.02),
|
|||
|
|
)
|
|||
|
|
start_parameter = v_min - axial_margin
|
|||
|
|
end_parameter = v_max + axial_margin
|
|||
|
|
radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05)
|
|||
|
|
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)
|
|||
|
|
exact_start = _point_on_axis(axis_point, direction, v_min)
|
|||
|
|
return {
|
|||
|
|
"boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "remove-envelope-then-fuse-target-cylinder",
|
|||
|
|
"boss_tool_note": (
|
|||
|
|
"扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;"
|
|||
|
|
"缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。"
|
|||
|
|
"第一版会给轴向两端保留少量重叠,让布尔结果更容易和原实体合并。"
|
|||
|
|
),
|
|||
|
|
"boss_tool_scope_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"boss_tool_scope_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
"boss_tool_range_source": axis_range["range_source"],
|
|||
|
|
"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),
|
|||
|
|
"boss_tool_exact_start_point": _point_tuple(exact_start),
|
|||
|
|
"boss_tool_exact_height": max(v_max - v_min, 1e-6),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _cylinder_end_opening_info(
|
|||
|
|
self,
|
|||
|
|
face_id: int,
|
|||
|
|
surf: BRepAdaptor_Surface,
|
|||
|
|
axis_range: dict[str, object] | None = None,
|
|||
|
|
) -> 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()
|
|||
|
|
axis_range = axis_range or self._cylindrical_axis_range(face_id, surf)
|
|||
|
|
v_min = float(axis_range["v_min"])
|
|||
|
|
v_max = float(axis_range["v_max"])
|
|||
|
|
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,
|
|||
|
|
"end_sample_range_source": axis_range["range_source"],
|
|||
|
|
"end_sample_scope_face_ids": axis_range["same_domain_face_ids"],
|
|||
|
|
"end_sample_scope_face_count": axis_range["same_domain_face_count"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _classify_cylindrical_face(
|
|||
|
|
self,
|
|||
|
|
face_id: int,
|
|||
|
|
surf: BRepAdaptor_Surface,
|
|||
|
|
detailed: bool = False,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
solid_id = self.face_solid_ids[face_id]
|
|||
|
|
if solid_id < 0 or solid_id >= len(self.solids):
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "cylindrical face",
|
|||
|
|
"toward_axis": "unknown",
|
|||
|
|
"away_axis": "unknown",
|
|||
|
|
"vote_summary": "hole=0, boss=0, unclear=0",
|
|||
|
|
"sample_count": 0,
|
|||
|
|
"confidence": "low",
|
|||
|
|
"note": "no owning solid was found",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
solid = self.solids[solid_id][1]
|
|||
|
|
radius = surf.Cylinder().Radius()
|
|||
|
|
angular_span = abs(surf.LastUParameter() - surf.FirstUParameter())
|
|||
|
|
boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()))
|
|||
|
|
solid_diagonal = _shape_diagonal(solid)
|
|||
|
|
is_partial_cylinder = angular_span < math.tau * 0.92
|
|||
|
|
is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04
|
|||
|
|
is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12
|
|||
|
|
is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05
|
|||
|
|
is_fillet_like_partial = (
|
|||
|
|
is_partial_cylinder
|
|||
|
|
and is_quarter_roundish
|
|||
|
|
and is_fillet_radius
|
|||
|
|
and boundary_edges >= 4
|
|||
|
|
)
|
|||
|
|
samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed)
|
|||
|
|
sample_count = len(samples)
|
|||
|
|
if sample_count == 0:
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "cylindrical face",
|
|||
|
|
"toward_axis": "unknown",
|
|||
|
|
"away_axis": "unknown",
|
|||
|
|
"vote_summary": "hole=0, boss=0, unclear=0",
|
|||
|
|
"sample_count": 0,
|
|||
|
|
"confidence": "low",
|
|||
|
|
"note": "could not sample cylinder material sides",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
toward_states = [sample["toward"] for sample in samples]
|
|||
|
|
away_states = [sample["away"] for sample in samples]
|
|||
|
|
hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside")
|
|||
|
|
boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside")
|
|||
|
|
unclear_votes = sample_count - hole_votes - boss_votes
|
|||
|
|
vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}"
|
|||
|
|
threshold = max(1, math.ceil(sample_count * 0.6))
|
|||
|
|
base = {
|
|||
|
|
"toward_axis": _state_summary(toward_states),
|
|||
|
|
"away_axis": _state_summary(away_states),
|
|||
|
|
"vote_summary": vote_summary,
|
|||
|
|
"sample_count": sample_count,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if hole_votes >= threshold:
|
|||
|
|
confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium"
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "hole/groove candidate",
|
|||
|
|
"confidence": confidence,
|
|||
|
|
"note": "axis side is mostly empty and outer side is mostly material",
|
|||
|
|
**base,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if is_partial_cylinder and (is_small_radius or is_fillet_like_partial):
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "round/fillet candidate",
|
|||
|
|
"confidence": "medium" if boundary_edges >= 4 else "low",
|
|||
|
|
"note": "partial small-radius cylinder; may be a fillet or blend",
|
|||
|
|
**base,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if boss_votes >= threshold:
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "boss/outer-round candidate",
|
|||
|
|
"confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium",
|
|||
|
|
"note": "axis side is mostly material and outer side is mostly empty",
|
|||
|
|
**base,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"feature_guess": "cylindrical face",
|
|||
|
|
"confidence": "low",
|
|||
|
|
"note": "material sampling did not produce a clear inside/outside pattern",
|
|||
|
|
**base,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _sample_cylinder_material_states(
|
|||
|
|
self,
|
|||
|
|
surf: BRepAdaptor_Surface,
|
|||
|
|
solid: TopoDS_Shape,
|
|||
|
|
detailed: bool = False,
|
|||
|
|
) -> list[dict[str, str]]:
|
|||
|
|
cyl = surf.Cylinder()
|
|||
|
|
axis = cyl.Axis()
|
|||
|
|
axis_point = axis.Location()
|
|||
|
|
axis_dir = axis.Direction()
|
|||
|
|
radius = cyl.Radius()
|
|||
|
|
u_first = surf.FirstUParameter()
|
|||
|
|
u_last = surf.LastUParameter()
|
|||
|
|
v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0
|
|||
|
|
u_span = u_last - u_first
|
|||
|
|
fractions = [0.5]
|
|||
|
|
if detailed and abs(u_span) > 0.2:
|
|||
|
|
fractions = [0.25, 0.5, 0.75]
|
|||
|
|
|
|||
|
|
samples: list[dict[str, str]] = []
|
|||
|
|
for fraction in fractions:
|
|||
|
|
u = u_first + u_span * fraction
|
|||
|
|
point = surf.Value(u, v)
|
|||
|
|
axis_to_point = _vec_from_points(axis_point, point)
|
|||
|
|
projection = _dot(axis_to_point, axis_dir)
|
|||
|
|
center = gp_Pnt(
|
|||
|
|
axis_point.X() + axis_dir.X() * projection,
|
|||
|
|
axis_point.Y() + axis_dir.Y() * projection,
|
|||
|
|
axis_point.Z() + axis_dir.Z() * projection,
|
|||
|
|
)
|
|||
|
|
radial = _vec_from_points(center, point)
|
|||
|
|
radial_len = radial.Magnitude()
|
|||
|
|
if radial_len <= 1e-9:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len)
|
|||
|
|
epsilon = min(max(radius * 0.03, 0.05), 1.0)
|
|||
|
|
toward_point = gp_Pnt(
|
|||
|
|
point.X() - unit.X() * epsilon,
|
|||
|
|
point.Y() - unit.Y() * epsilon,
|
|||
|
|
point.Z() - unit.Z() * epsilon,
|
|||
|
|
)
|
|||
|
|
away_point = gp_Pnt(
|
|||
|
|
point.X() + unit.X() * epsilon,
|
|||
|
|
point.Y() + unit.Y() * epsilon,
|
|||
|
|
point.Z() + unit.Z() * epsilon,
|
|||
|
|
)
|
|||
|
|
samples.append(
|
|||
|
|
{
|
|||
|
|
"toward": _solid_state(solid, toward_point),
|
|||
|
|
"away": _solid_state(solid, away_point),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return samples
|
|||
|
|
|
|||
|
|
def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
|
|||
|
|
face = self.faces[face_id]
|
|||
|
|
direction = surf.Plane().Axis().Direction()
|
|||
|
|
axis_tuple = _dir_tuple(direction)
|
|||
|
|
oriented_tuple = _oriented_dir_tuple(direction, face)
|
|||
|
|
fallback = {
|
|||
|
|
"outward_direction": oriented_tuple,
|
|||
|
|
"inward_direction": _neg_tuple(oriented_tuple),
|
|||
|
|
"plus_side_state": "unknown",
|
|||
|
|
"minus_side_state": "unknown",
|
|||
|
|
"confidence": "low",
|
|||
|
|
"note": "falling back to topology-oriented plane normal",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
solid_id = self.face_solid_ids[face_id]
|
|||
|
|
if solid_id < 0 or solid_id >= len(self.solids):
|
|||
|
|
fallback["note"] = "no owning solid was found; using topology-oriented plane normal"
|
|||
|
|
return fallback
|
|||
|
|
|
|||
|
|
solid = self.solids[solid_id][1]
|
|||
|
|
props = GProp_GProps()
|
|||
|
|
brepgprop.SurfaceProperties(face, props)
|
|||
|
|
sample = props.CentreOfMass()
|
|||
|
|
diagonal = _shape_diagonal(solid)
|
|||
|
|
epsilon = min(max(diagonal * 1e-4, 0.05), 1.0)
|
|||
|
|
plus_point = gp_Pnt(
|
|||
|
|
sample.X() + direction.X() * epsilon,
|
|||
|
|
sample.Y() + direction.Y() * epsilon,
|
|||
|
|
sample.Z() + direction.Z() * epsilon,
|
|||
|
|
)
|
|||
|
|
minus_point = gp_Pnt(
|
|||
|
|
sample.X() - direction.X() * epsilon,
|
|||
|
|
sample.Y() - direction.Y() * epsilon,
|
|||
|
|
sample.Z() - direction.Z() * epsilon,
|
|||
|
|
)
|
|||
|
|
plus_state = _solid_state(solid, plus_point)
|
|||
|
|
minus_state = _solid_state(solid, minus_point)
|
|||
|
|
|
|||
|
|
if plus_state == "outside" and minus_state == "inside":
|
|||
|
|
return {
|
|||
|
|
"outward_direction": axis_tuple,
|
|||
|
|
"inward_direction": _neg_tuple(axis_tuple),
|
|||
|
|
"plus_side_state": plus_state,
|
|||
|
|
"minus_side_state": minus_state,
|
|||
|
|
"confidence": "high",
|
|||
|
|
"note": "positive plane normal side is outside material",
|
|||
|
|
}
|
|||
|
|
if plus_state == "inside" and minus_state == "outside":
|
|||
|
|
return {
|
|||
|
|
"outward_direction": _neg_tuple(axis_tuple),
|
|||
|
|
"inward_direction": axis_tuple,
|
|||
|
|
"plus_side_state": plus_state,
|
|||
|
|
"minus_side_state": minus_state,
|
|||
|
|
"confidence": "high",
|
|||
|
|
"note": "negative plane normal side is outside material",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fallback["plus_side_state"] = plus_state
|
|||
|
|
fallback["minus_side_state"] = minus_state
|
|||
|
|
fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal"
|
|||
|
|
return fallback
|
|||
|
|
|