Files
pythonocc-step-editor/step_editor/features.py
T

1394 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
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
shell_thickness_count = 0
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
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 and shell_thickness_count >= shell_thickness_limit:
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
if plane_count < per_type_limit:
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
if shell_thickness_count < shell_thickness_limit:
feature = self.feature_info(face_id)
if feature.get("shell_region_status") == "candidate":
shell_confidence = str(feature.get("shell_confidence", "low"))
shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high"
candidates.append(
{
"operation_key": "resize_shell_thickness",
"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": "thin-wall opposite plane candidate",
"current_value": feature.get("shell_thickness_estimate"),
"current_value_label": "shell_thickness",
"status": "ready" if shell_confidence == "high" else "caution",
"risk": shell_risk,
"confidence": shell_confidence,
"note": (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部薄壁/壳体厚度。"
),
}
)
shell_thickness_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": "给Edge添加圆角",
"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": "给Edge添加倒角",
"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": "修改Edge长度",
"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": "快速扫描:直线边优先局部形变;圆边优先换算相邻圆柱直径;椭圆/平面曲线会尝试径向缩放;其他边使用后备策略。",
}
)
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,
"resize_shell_thickness": 6,
"push_pull_plane": 7,
"fillet_edge": 8,
"chamfer_edge": 9,
"resize_edge_length": 10,
}
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": "选中Face不是圆柱面",
}
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": "选中Face不是圆柱面",
}
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": "选中Face不是圆柱面",
}
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