2026-07-27 18:28:26 +08:00
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
2026-08-07 18:08:32 +08:00
from .recognition_priority import feature_recognition_sort_key
2026-07-27 18:28:26 +08:00
class FeatureMixin :
2026-08-04 18:15:29 +08:00
def _first_level_fact_plan_fields ( self , face_id : int , scope : str ) -> dict [ str , object ]:
try :
return self . face_first_level_facts ( face_id , scope = scope )
except Exception as exc :
resolved_scope = str ( scope or "face" )
return {
"first_level_fact_model" : "STEP/B-Rep first-level fact graph" ,
"first_level_fact_source_model" : resolved_scope ,
"first_level_fact_status" : "unavailable" ,
"first_level_fact_relation_depth" : 1 ,
"first_level_fact_relation_boundary" : "shared-edge" ,
"first_level_fact_scope" : resolved_scope ,
"first_level_fact_subject_role" : "selected Face" ,
"first_level_fact_subject_face_ids" : ( face_id ,),
"first_level_fact_subject_face_count" : 1 ,
"first_level_fact_boundary_edge_ids" : (),
"first_level_fact_boundary_edge_count" : 0 ,
"first_level_fact_boundary_vertex_points" : (),
"first_level_fact_boundary_vertex_count" : 0 ,
"first_level_fact_adjacent_face_ids" : (),
"first_level_fact_adjacent_face_count" : 0 ,
"first_level_fact_adjacent_surface_types" : (),
"first_level_fact_shared_edges_by_face" : (),
"first_level_fact_included_face_ids" : ( face_id ,),
"first_level_fact_included_face_count" : 1 ,
"first_level_fact_role_groups" : (),
"first_level_fact_ignored_relation_depths" : ( "second-level" , "third-level" , "deeper" ),
"first_level_fact_ignored_relation_note" : (
"Only first-level shared-edge relations are considered in this stage."
),
"first_level_fact_summary" : f "当前 Face 的一级事实图暂时无法生成: { exc } " ,
}
2026-08-04 09:35:39 +08:00
def _cylindrical_feature_first_level_plan_fields ( self , face_id : int ) -> dict [ str , object ]:
2026-08-04 18:15:29 +08:00
fact_fields = self . _first_level_fact_plan_fields ( face_id , "cylindrical-feature" )
2026-08-04 09:35:39 +08:00
try :
topology = self . cylindrical_feature_first_level_topology ( face_id )
except Exception as exc :
return {
"topology_relation_depth" : 1 ,
"topology_relation_model" : "STEP/B-Rep cylindrical-feature shared-edge first-level" ,
"topology_relation_status" : "unavailable" ,
"topology_relation_message" : str ( exc ),
"first_level_boundary_edge_ids" : (),
"first_level_boundary_edge_count" : 0 ,
"first_level_boundary_vertex_count" : 0 ,
"first_level_adjacent_face_ids" : (),
"first_level_adjacent_face_count" : 0 ,
"cylindrical_feature_side_face_ids" : ( face_id ,),
"cylindrical_feature_side_face_count" : 1 ,
2026-08-04 18:15:29 +08:00
** fact_fields ,
2026-08-04 09:35:39 +08:00
}
fields = {
"topology_relation_depth" : topology . get ( "topology_relation_depth" , 1 ),
"topology_relation_model" : topology . get ( "topology_relation_model" ),
"topology_relation_scope" : topology . get ( "topology_relation_scope" ),
"topology_relation_boundary" : topology . get ( "topology_relation_boundary" ),
"topology_relation_status" : "ready" ,
"topology_ignored_relation_depths" : topology . get ( "topology_ignored_relation_depths" , ()),
"topology_ignored_relation_note" : topology . get ( "topology_ignored_relation_note" , "" ),
"first_level_boundary_edge_ids" : topology . get ( "cylindrical_feature_boundary_edge_ids" , ()),
"first_level_boundary_edge_count" : topology . get ( "cylindrical_feature_boundary_edge_count" , 0 ),
"first_level_boundary_vertex_count" : topology . get ( "cylindrical_feature_boundary_vertex_count" , 0 ),
"first_level_adjacent_face_ids" : topology . get ( "cylindrical_feature_adjacent_face_ids" , ()),
"first_level_adjacent_face_count" : topology . get ( "cylindrical_feature_adjacent_face_count" , 0 ),
"first_level_adjacent_surface_types" : topology . get ( "cylindrical_feature_adjacent_surface_types" , ()),
"first_level_shared_edges_by_face" : topology . get ( "cylindrical_feature_shared_edges_by_face" , ()),
"first_level_face_ids" : topology . get ( "cylindrical_feature_first_level_face_ids" , ()),
"first_level_face_count" : topology . get ( "cylindrical_feature_first_level_face_count" , 0 ),
"first_level_topology_note" : topology . get ( "first_level_topology_note" , "" ),
"cylindrical_feature_side_face_ids" : topology . get ( "cylindrical_feature_side_face_ids" , ( face_id ,)),
"cylindrical_feature_side_face_count" : topology . get ( "cylindrical_feature_side_face_count" , 1 ),
"cylindrical_feature_boundary_edge_ids" : topology . get ( "cylindrical_feature_boundary_edge_ids" , ()),
"cylindrical_feature_boundary_edge_count" : topology . get ( "cylindrical_feature_boundary_edge_count" , 0 ),
"cylindrical_feature_adjacent_face_ids" : topology . get ( "cylindrical_feature_adjacent_face_ids" , ()),
"cylindrical_feature_adjacent_face_count" : topology . get ( "cylindrical_feature_adjacent_face_count" , 0 ),
"cylindrical_feature_end_face_ids" : topology . get ( "cylindrical_feature_end_face_ids" , ()),
"cylindrical_feature_end_face_count" : topology . get ( "cylindrical_feature_end_face_count" , 0 ),
"cylindrical_feature_bottom_face_ids" : topology . get ( "cylindrical_feature_bottom_face_ids" , ()),
"cylindrical_feature_bottom_face_count" : topology . get ( "cylindrical_feature_bottom_face_count" , 0 ),
"cylindrical_feature_opening_face_ids" : topology . get ( "cylindrical_feature_opening_face_ids" , ()),
"cylindrical_feature_opening_face_count" : topology . get ( "cylindrical_feature_opening_face_count" , 0 ),
"cylindrical_feature_slot_boundary_face_ids" : topology . get (
"cylindrical_feature_slot_boundary_face_ids" ,
(),
),
"cylindrical_feature_slot_boundary_face_count" : topology . get (
"cylindrical_feature_slot_boundary_face_count" ,
0 ,
),
}
2026-08-04 18:15:29 +08:00
fields . update ( fact_fields )
2026-08-04 09:35:39 +08:00
fields [ "first_level_edit_semantics" ] = (
"Cylindrical feature edits currently use only first-level shared-edge topology: "
"the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; "
"second-level and deeper propagation is not automatic yet."
)
return fields
def _cylindrical_first_level_guard_fields (
self ,
topology_fields : dict [ str , object ],
* ,
require_slot_boundary : bool = False ,
require_bottom : bool = False ,
) -> dict [ str , object ]:
blockers : list [ str ] = []
warnings : list [ str ] = []
risk = "low"
status = str ( topology_fields . get ( "topology_relation_status" ) or "" )
if status != "ready" :
blockers . append (
"当前孔/槽的一级关系拓扑无法确认,已阻止局部重建;请换一个更明确的孔壁/槽壁 Face。"
)
side_count = int ( topology_fields . get ( "cylindrical_feature_side_face_count" , 0 ) or 0 )
edge_count = int ( topology_fields . get ( "cylindrical_feature_boundary_edge_count" , 0 ) or 0 )
vertex_count = int ( topology_fields . get ( "first_level_boundary_vertex_count" , 0 ) or 0 )
adjacent_count = int ( topology_fields . get ( "cylindrical_feature_adjacent_face_count" , 0 ) or 0 )
slot_boundary_count = int ( topology_fields . get ( "cylindrical_feature_slot_boundary_face_count" , 0 ) or 0 )
bottom_count = int ( topology_fields . get ( "cylindrical_feature_bottom_face_count" , 0 ) or 0 )
if side_count <= 0 :
blockers . append ( "没有识别到当前孔/槽的圆柱侧壁区域,不能稳定局部修改。" )
if edge_count <= 0 :
blockers . append ( "没有识别到当前孔/槽侧壁的边界 Edge,不能确定一级联动范围。" )
if adjacent_count <= 0 :
blockers . append ( "没有识别到与当前孔/槽直接共边的相邻 Face,不能保证修改后拓扑闭合。" )
if require_slot_boundary and slot_boundary_count <= 0 :
blockers . append ( "当前槽/半孔缺少直接槽边界 Face,不能稳定执行槽的局部重建。" )
if require_bottom and bottom_count <= 0 :
blockers . append ( "当前盲孔/盲槽缺少可靠底面 Face,不能稳定执行局部深度修改。" )
if not blockers :
if side_count > 4 or edge_count > 20 or adjacent_count > 14 :
risk = _max_risk ( risk , "high" )
warnings . append (
"当前孔/槽的一级关系邻域较复杂,局部布尔重建可能影响多个直接相邻面。"
)
elif side_count > 1 or edge_count > 10 or adjacent_count > 8 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "当前孔/槽由多个侧壁或较多相邻面组成,修改后请重点检查一级邻域。" )
note = (
f "一级关系拓扑检查:侧壁 Face { side_count } 个,边界 Edge { edge_count } 条,"
f "边界 Vertex { vertex_count } 个,直接相邻 Face { adjacent_count } 个。"
)
return {
"first_level_topology_status" : "blocked" if blockers else "ready" ,
"first_level_topology_risk" : "blocked" if blockers else risk ,
"first_level_topology_blockers" : "; " . join ( blockers ),
"first_level_topology_warnings" : "; " . join ( warnings ),
"first_level_topology_guard_note" : note ,
}
def _apply_cylindrical_first_level_guard (
self ,
topology_fields : dict [ str , object ],
blockers : list [ str ],
warnings : list [ str ],
risk : str ,
* ,
require_slot_boundary : bool = False ,
require_bottom : bool = False ,
) -> str :
guard = self . _cylindrical_first_level_guard_fields (
topology_fields ,
require_slot_boundary = require_slot_boundary ,
require_bottom = require_bottom ,
)
topology_fields . update ( guard )
blocker_text = str ( guard . get ( "first_level_topology_blockers" ) or "" ) . strip ()
warning_text = str ( guard . get ( "first_level_topology_warnings" ) or "" ) . strip ()
if blocker_text :
blockers . append ( blocker_text )
if warning_text :
warnings . append ( warning_text )
return _max_risk ( risk , str ( guard . get ( "first_level_topology_risk" ) or "low" ))
def _apply_cylindrical_first_level_guard_to_readiness (
self ,
readiness : dict [ str , object ],
topology_fields : dict [ str , object ],
* ,
status_key : str ,
risk_key : str ,
note_key : str ,
warnings_key : str ,
blockers_key : str ,
require_slot_boundary : bool = False ,
require_bottom : bool = False ,
) -> dict [ str , object ]:
guard = self . _cylindrical_first_level_guard_fields (
topology_fields ,
require_slot_boundary = require_slot_boundary ,
require_bottom = require_bottom ,
)
topology_fields . update ( guard )
result = dict ( readiness )
blocker_text = str ( guard . get ( "first_level_topology_blockers" ) or "" ) . strip ()
warning_text = str ( guard . get ( "first_level_topology_warnings" ) or "" ) . strip ()
if blocker_text :
result [ status_key ] = "blocked"
result [ risk_key ] = "blocked"
result [ blockers_key ] = _join_nonempty ( result . get ( blockers_key ), blocker_text )
result [ note_key ] = _join_nonempty ( result . get ( note_key ), blocker_text )
else :
result [ risk_key ] = _max_risk ( str ( result . get ( risk_key ) or "low" ), str ( guard [ "first_level_topology_risk" ]))
if warning_text :
result [ warnings_key ] = _join_nonempty ( result . get ( warnings_key ), warning_text )
result [ note_key ] = _join_nonempty ( result . get ( note_key ), warning_text )
return result
2026-07-27 18:28:26 +08:00
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 ]]:
2026-08-11 18:28:16 +08:00
limit = max ( 1 , int ( limit ))
normalized_max_faces = None if max_scan_faces is None else max ( 0 , int ( max_scan_faces ))
normalized_max_edges = None if max_scan_edges is None else max ( 0 , int ( max_scan_edges ))
cache_key = (
"editable" ,
limit ,
bool ( detailed ),
normalized_max_faces ,
normalized_max_edges ,
)
cache = getattr ( self , "_editable_feature_candidates_cache" , None )
if isinstance ( cache , dict ) and cache_key in cache :
return [ dict ( item ) for item in cache [ cache_key ]]
2026-07-27 18:28:26 +08:00
per_type_limit = max ( 1 , limit // 5 )
candidates : list [ dict [ str , object ]] = []
diameter_count = 0
slot_width_count = 0
2026-07-29 15:43:28 +08:00
slot_depth_count = 0
slot_arc_length_count = 0
slot_angular_span_count = 0
2026-07-27 18:28:26 +08:00
boss_diameter_count = 0
2026-07-29 15:43:28 +08:00
boss_height_count = 0
boss_axis_count = 0
2026-07-27 18:28:26 +08:00
depth_count = 0
suppress_count = 0
existing_fillet_count = 0
2026-08-10 14:56:50 +08:00
existing_chamfer_count = 0
2026-07-27 18:28:26 +08:00
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 ))
2026-08-10 14:56:50 +08:00
existing_chamfer_limit = max ( 2 , min ( per_type_limit , limit // 12 ))
2026-07-27 18:28:26 +08:00
slot_width_limit = max ( 2 , min ( per_type_limit , limit // 10 ))
2026-07-29 15:43:28 +08:00
slot_depth_limit = max ( 2 , min ( per_type_limit , limit // 10 ))
slot_arc_length_limit = max ( 2 , min ( per_type_limit , limit // 10 ))
slot_angular_span_limit = max ( 2 , min ( per_type_limit , limit // 10 ))
2026-07-27 18:28:26 +08:00
cylinder_scan_limit = max ( per_type_limit * 4 , 24 )
for item in self . cylindrical_feature_candidates (
limit = cylinder_scan_limit ,
include_end_info = True ,
2026-08-11 18:28:16 +08:00
max_scan_faces = normalized_max_faces ,
2026-07-27 18:28:26 +08:00
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" ]))
2026-08-07 18:08:32 +08:00
existing_fillet_status = str ( feature . get ( "existing_fillet_status" ) or "candidate" )
if existing_fillet_status == "blocked" :
continue
2026-07-27 18:28:26 +08:00
support_face_ids = tuple ( feature . get ( "feature_existing_fillet_support_face_ids" , ()))
2026-08-10 14:56:50 +08:00
chain_face_ids = tuple ( feature . get ( "feature_existing_fillet_chain_face_ids" , ()))
chain_status = str ( feature . get ( "existing_fillet_chain_status" ) or "" )
is_same_radius_chain = chain_status == "same-radius-chain-candidate" and len ( chain_face_ids ) > 1
2026-07-27 18:28:26 +08:00
support_note = (
f "支撑 Face: { support_face_ids } 。"
if support_face_ids
else "暂未识别出稳定支撑 Face。"
)
2026-08-10 14:56:50 +08:00
fillet_note = (
f "这是简单等半径圆角链候选,链上 Face: { chain_face_ids } ;点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试一起 defeature 后重新倒圆。"
if is_same_radius_chain
else (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试 defeature 后重新倒圆。"
)
)
2026-07-27 18:28:26 +08:00
candidates . append (
{
"operation_key" : "inspect_existing_fillet" ,
2026-08-10 14:56:50 +08:00
"operation" : "修改已有圆角链半径/弧长" if is_same_radius_chain else "修改已有圆角半径/弧长" ,
2026-07-27 18:28:26 +08:00
"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" ,
2026-08-10 14:56:50 +08:00
"risk" : "high" if is_same_radius_chain or len ( support_face_ids ) < 2 else "medium" ,
2026-07-27 18:28:26 +08:00
"confidence" : item [ "confidence" ],
2026-08-10 14:56:50 +08:00
"note" : f " { fillet_note } { support_note } " ,
2026-07-27 18:28:26 +08:00
}
)
existing_fillet_count += 1
2026-08-07 18:08:32 +08:00
if diameter_count < per_type_limit and feature_guess not in {
"round/fillet candidate" ,
"boss/outer-round candidate" ,
2026-08-10 14:56:50 +08:00
} and str ( item . get ( "resize_status" ) or "" ) != "blocked" :
2026-07-27 18:28:26 +08:00
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 (
2026-07-29 15:43:28 +08:00
(
slot_width_count < slot_width_limit
or slot_depth_count < slot_depth_limit
or slot_arc_length_count < slot_arc_length_limit
or slot_angular_span_count < slot_angular_span_limit
)
2026-07-27 18:28:26 +08:00
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" ]))
2026-08-10 14:56:50 +08:00
if str ( feature . get ( "slot_status" ) or "" ) != "candidate" :
continue
2026-07-27 18:28:26 +08:00
slot_width = feature . get ( "slot_chord_width_estimate" )
2026-07-29 15:43:28 +08:00
if slot_width_count < slot_width_limit and isinstance ( slot_width , ( int , float )) and float ( slot_width ) > 0 :
2026-07-27 18:28:26 +08:00
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
2026-07-29 15:43:28 +08:00
slot_depth = feature . get ( "slot_sagitta_depth_estimate" )
if slot_depth_count < slot_depth_limit and isinstance ( slot_depth , ( int , float )) and float ( slot_depth ) > 0 :
candidates . append (
{
"operation_key" : "resize_slot_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" : feature_guess ,
"current_value" : float ( slot_depth ),
"current_value_label" : "slot_depth" ,
"status" : item [ "resize_status" ],
"risk" : item [ "resize_risk" ],
"confidence" : item [ "confidence" ],
"note" : (
"这是槽/半孔候选;点击后会选中该 face,并把槽/半孔深度输入框预填为参考目标值。"
"执行时会把槽深换算为圆柱直径后重建。"
),
}
)
slot_depth_count += 1
slot_arc_length = feature . get ( "slot_arc_length_estimate" )
if (
slot_arc_length_count < slot_arc_length_limit
and isinstance ( slot_arc_length , ( int , float ))
and float ( slot_arc_length ) > 0
):
candidates . append (
{
"operation_key" : "resize_slot_arc_length" ,
"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_arc_length ),
"current_value_label" : "slot_arc_length" ,
"status" : item [ "resize_status" ],
"risk" : item [ "resize_risk" ],
"confidence" : item [ "confidence" ],
"note" : (
"这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧长度。"
"执行时会把圆弧长度换算为圆柱直径后重建。"
),
}
)
slot_arc_length_count += 1
slot_angular_span = feature . get ( "slot_angular_span" , item . get ( "angular_span" ))
if (
slot_angular_span_count < slot_angular_span_limit
and isinstance ( slot_angular_span , ( int , float ))
and 1e-6 < float ( slot_angular_span ) < math . tau * 0.92
):
candidates . append (
{
"operation_key" : "resize_slot_angular_span" ,
"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" : math . degrees ( float ( slot_angular_span )),
"current_value_label" : "slot_angle_degrees" ,
"status" : item [ "resize_status" ],
"risk" : item [ "resize_risk" ],
"confidence" : item [ "confidence" ],
"note" : (
"这是槽/半孔候选;点击后会选中该 face,可在当前选中对象里修改槽/半孔圆弧角度。"
"执行时会保持当前半径并重建局部扇形槽。"
),
}
)
slot_angular_span_count += 1
2026-07-27 18:28:26 +08:00
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
2026-07-29 15:43:28 +08:00
if boss_height_count < per_type_limit and boss_info [ "boss_resize_status" ] != "blocked" :
feature = self . feature_info ( int ( item [ "face_id" ]))
height = feature . get ( "same_domain_height_estimate" , item . get ( "height_estimate" ))
cap_face_ids = _int_values ( feature . get ( "feature_start_end_face_ids" )) + _int_values (
feature . get ( "feature_end_end_face_ids" )
)
if isinstance ( height , ( int , float )) and float ( height ) > 0 and cap_face_ids :
candidates . append (
{
"operation_key" : "resize_boss_height" ,
"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" : float ( height ),
"current_value_label" : "height" ,
"status" : "caution" ,
"risk" : "medium" if item [ "confidence" ] == "high" else "high" ,
"confidence" : item [ "confidence" ],
"note" : (
"这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台高度。"
2026-08-05 15:08:16 +08:00
"执行时会拉伸/切除识别到的端盖 Face。"
2026-07-29 15:43:28 +08:00
),
}
)
boss_height_count += 1
if boss_axis_count < per_type_limit and boss_info [ "boss_resize_status" ] != "blocked" :
feature = self . feature_info ( int ( item [ "face_id" ]))
axis_point = _tuple_or_none ( item . get ( "axis_point" ))
axis_direction = _tuple_or_none ( item . get ( "axis" ))
axis_range = feature . get ( "same_domain_v_range" ) or item . get ( "v_range" )
current_axis_center = None
if (
axis_point is not None
and axis_direction is not None
and isinstance ( axis_range , ( list , tuple ))
and len ( axis_range ) >= 2
):
v_min = _float_or_none ( axis_range [ 0 ])
v_max = _float_or_none ( axis_range [ 1 ])
if v_min is not None and v_max is not None :
v_mid = ( v_min + v_max ) * 0.5
current_axis_center = (
axis_point [ 0 ] + axis_direction [ 0 ] * v_mid ,
axis_point [ 1 ] + axis_direction [ 1 ] * v_mid ,
axis_point [ 2 ] + axis_direction [ 2 ] * v_mid ,
)
if current_axis_center is not None :
candidates . append (
{
"operation_key" : "move_boss_axis" ,
"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_axis_center ,
"current_value_label" : "axis_center" ,
"status" : "caution" ,
"risk" : "medium" if item [ "confidence" ] == "high" else "high" ,
"confidence" : item [ "confidence" ],
"note" : (
"这是完整圆柱凸台候选;点击后会选中该 face,可在当前选中对象里修改凸台轴心坐标。"
"执行时会移除旧凸台包络,再按同直径在目标轴心补出凸台。"
),
}
)
boss_axis_count += 1
2026-07-27 18:28:26 +08:00
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" ,
2026-07-29 15:43:28 +08:00
"operation" : "调整盲孔/盲槽深度" ,
2026-07-27 18:28:26 +08:00
"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
2026-07-28 14:05:14 +08:00
shell_thickness_count = 0
shell_thickness_limit = max ( 2 , min ( per_type_limit , limit // 10 ))
2026-08-10 14:56:50 +08:00
shell_candidate_rows : list [ dict [ str , object ]] = []
shell_scan_pool_limit = max ( shell_thickness_limit * 4 , 12 )
2026-08-11 18:28:16 +08:00
face_scan_limit = len ( self . faces ) if normalized_max_faces is None else min ( len ( self . faces ), normalized_max_faces )
2026-07-27 18:28:26 +08:00
for face_id , face in enumerate ( self . faces [: face_scan_limit ]):
if progress_callback is not None and face_id % 30 == 0 :
progress_callback ()
2026-08-10 14:56:50 +08:00
if (
plane_count >= per_type_limit
and len ( shell_candidate_rows ) >= shell_scan_pool_limit
and existing_chamfer_count >= existing_chamfer_limit
):
2026-07-27 18:28:26 +08:00
break
surf = BRepAdaptor_Surface ( face )
if surf . GetType () != GeomAbs_Plane :
continue
props = GProp_GProps ()
brepgprop . SurfaceProperties ( face , props )
2026-08-10 14:56:50 +08:00
feature : dict [ str , object ] | None = None
quick_info : dict [ str , object ] | None = None
if existing_chamfer_count < existing_chamfer_limit :
quick_info = self . quick_face_info ( face_id )
width = _float_or_none ( quick_info . get ( "local_face_width" ))
height = _float_or_none ( quick_info . get ( "local_face_height" ))
aspect = max ( width or 0.0 , height or 0.0 ) / max ( min ( width or 0.0 , height or 0.0 ), 1e-9 )
if (
int ( quick_info . get ( "boundary_edges" , 0 ) or 0 ) == 4
and not bool ( quick_info . get ( "has_inner_boundaries" ))
and aspect >= 1.8
):
feature = self . feature_info ( face_id )
if str ( feature . get ( "existing_chamfer_status" ) or "" ) == "candidate" :
support_face_ids = tuple ( feature . get ( "feature_existing_chamfer_support_face_ids" , ()))
long_edge_ids = tuple ( feature . get ( "feature_existing_chamfer_long_edge_ids" , ()))
candidates . append (
{
"operation_key" : "inspect_existing_chamfer" ,
"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" : "chamfer candidate" ,
"current_value" : feature . get ( "existing_chamfer_distance_estimate" ),
"current_value_label" : "chamfer_distance" ,
"status" : "caution" ,
"risk" : str ( feature . get ( "existing_chamfer_risk" ) or "medium" ),
"confidence" : str ( feature . get ( "recognition_confidence" ) or "medium" ),
"note" : (
"这是简单已有倒角斜面;点击后会选中该 Face,并把倒角距离填入参数表。"
f " 支撑 Face: { support_face_ids } ; 长边 Edge: { long_edge_ids } 。"
),
}
)
existing_chamfer_count += 1
continue
if plane_count < per_type_limit or len ( shell_candidate_rows ) < shell_scan_pool_limit :
quick_info = quick_info or self . quick_face_info ( face_id )
boundary_edges = int ( quick_info . get ( "boundary_edges" , 0 ) or 0 )
if bool ( quick_info . get ( "has_inner_boundaries" )) or boundary_edges > 4 :
feature = feature or self . feature_info ( face_id )
if feature . get ( "multistep_prismatic_status" ) == "blocked" :
continue
2026-07-28 14:05:14 +08:00
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"
2026-08-05 15:08:16 +08:00
note = "快速扫描:拉伸/切除方向会在选中Face或执行编辑前再详细判断。"
2026-07-28 14:05:14 +08:00
candidates . append (
{
"operation_key" : "push_pull_plane" ,
2026-08-05 15:08:16 +08:00
"operation" : "拉伸/切除平面" ,
2026-07-28 14:05:14 +08:00
"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
2026-08-10 14:56:50 +08:00
if len ( shell_candidate_rows ) < shell_scan_pool_limit :
feature = feature or self . feature_info ( face_id )
2026-07-28 14:05:14 +08:00
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"
2026-08-10 14:56:50 +08:00
shell_note = (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
)
if feature . get ( "open_shell_context_status" ) == "limited" :
shell_note = (
f " { shell_note } 已识别为开口薄壁壳体上下文;"
"完整抽壳/开口面编辑暂未实现,当前只开放壳体厚度、平面推拉或整体缩放。"
)
shell_candidate_rows . append (
2026-07-28 14:05:14 +08:00
{
"operation_key" : "resize_shell_thickness" ,
2026-08-05 15:08:16 +08:00
"operation" : "调整壳体厚度" ,
2026-07-28 14:05:14 +08:00
"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 ,
2026-08-10 14:56:50 +08:00
"note" : shell_note ,
"open_shell_context_status" : feature . get ( "open_shell_context_status" , "" ),
2026-07-28 14:05:14 +08:00
}
)
2026-08-10 14:56:50 +08:00
def _shell_candidate_sort_key ( item : dict [ str , object ]) -> tuple [ int , int , int , float , int ]:
confidence_rank = { "high" : 0 , "medium" : 1 , "low" : 2 } . get ( str ( item . get ( "confidence" ) or "" ), 3 )
risk_rank = { "low" : 0 , "medium" : 1 , "high" : 2 , "blocked" : 3 } . get ( str ( item . get ( "risk" ) or "" ), 3 )
thickness = _float_or_none ( item . get ( "current_value" ))
return (
0 if item . get ( "open_shell_context_status" ) == "limited" else 1 ,
confidence_rank ,
risk_rank ,
thickness if thickness is not None else float ( "inf" ),
int ( item . get ( "face_id" , 10 ** 9 ) or 10 ** 9 ),
)
for shell_candidate in sorted ( shell_candidate_rows , key = _shell_candidate_sort_key )[: shell_thickness_limit ]:
candidates . append ( shell_candidate )
shell_thickness_count += 1
2026-07-27 18:28:26 +08:00
fillet_edge_count = 0
chamfer_edge_count = 0
edge_length_count = 0
2026-07-29 15:43:28 +08:00
circle_edge_radius_count = 0
2026-08-07 18:08:32 +08:00
ellipse_edge_major_radius_count = 0
ellipse_edge_minor_radius_count = 0
2026-07-27 18:28:26 +08:00
edge_type_limit = max ( 1 , per_type_limit // 3 )
2026-08-11 18:28:16 +08:00
edge_scan_limit = len ( self . edges ) if normalized_max_edges is None else min ( len ( self . edges ), normalized_max_edges )
2026-07-27 18:28:26 +08:00
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
2026-07-29 15:43:28 +08:00
and circle_edge_radius_count >= edge_type_limit
2026-07-27 18:28:26 +08:00
):
break
curve = BRepAdaptor_Curve ( edge )
is_line_edge = curve . GetType () == GeomAbs_Line
is_circle_edge = curve . GetType () == GeomAbs_Circle
2026-08-07 18:08:32 +08:00
is_ellipse_edge = curve . GetType () == GeomAbs_Ellipse
2026-07-27 18:28:26 +08:00
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" ,
2026-07-28 14:05:14 +08:00
"operation" : "给Edge添加圆角" ,
2026-07-27 18:28:26 +08:00
"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" ,
2026-07-28 14:05:14 +08:00
"operation" : "给Edge添加倒角" ,
2026-07-27 18:28:26 +08:00
"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
2026-08-07 18:08:32 +08:00
if ( is_line_edge or is_circle_edge ) and edge_length_count < edge_type_limit :
2026-07-27 18:28:26 +08:00
candidates . append (
{
"operation_key" : "resize_edge_length" ,
2026-07-28 14:05:14 +08:00
"operation" : "修改Edge长度" ,
2026-07-27 18:28:26 +08:00
"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" ,
2026-08-07 18:08:32 +08:00
"risk" : "medium" ,
2026-07-27 18:28:26 +08:00
"confidence" : "pending" ,
2026-08-07 18:08:32 +08:00
"note" : "快速扫描:直线边优先局部形变或端面移动;圆边优先换算相邻圆柱直径。" ,
2026-07-27 18:28:26 +08:00
}
)
edge_length_count += 1
2026-07-29 15:43:28 +08:00
if is_circle_edge and circle_edge_radius_count < edge_type_limit :
radius = float ( curve . Circle () . Radius ())
if radius > 1e-9 :
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" : "circular edge radius candidate" ,
"current_value" : radius ,
"current_value_label" : "radius" ,
"status" : "caution" ,
"risk" : "medium" ,
"confidence" : "pending" ,
"note" : (
"快速扫描:点击后会选中圆Edge;可在当前选中对象里修改圆Edge半径或直径,"
"执行时优先换算相邻圆柱直径。"
),
}
)
circle_edge_radius_count += 1
2026-08-07 18:08:32 +08:00
if is_ellipse_edge :
ellipse = curve . Ellipse ()
if ellipse_edge_major_radius_count < edge_type_limit :
major_radius = float ( ellipse . MajorRadius ())
if major_radius > 1e-9 :
candidates . append (
{
"operation_key" : "resize_ellipse_edge_major_radius" ,
"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" : "elliptical edge major radius candidate" ,
"current_value" : major_radius ,
"current_value_label" : "major_radius" ,
"status" : "caution" ,
"risk" : "medium" ,
"confidence" : "pending" ,
"note" : "快速扫描:点击后会选中椭圆Edge;在参数表里按主轴方向单轴缩放所属对象。" ,
}
)
ellipse_edge_major_radius_count += 1
if ellipse_edge_minor_radius_count < edge_type_limit :
minor_radius = float ( ellipse . MinorRadius ())
if minor_radius > 1e-9 :
candidates . append (
{
"operation_key" : "resize_ellipse_edge_minor_radius" ,
"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" : "elliptical edge minor radius candidate" ,
"current_value" : minor_radius ,
"current_value_label" : "minor_radius" ,
"status" : "caution" ,
"risk" : "medium" ,
"confidence" : "pending" ,
"note" : "快速扫描:点击后会选中椭圆Edge;在参数表里按小轴方向单轴缩放所属对象。" ,
}
)
ellipse_edge_minor_radius_count += 1
2026-07-27 18:28:26 +08:00
2026-08-07 18:08:32 +08:00
for candidate in candidates :
candidate [ "recognition_user_priority" ] = feature_recognition_sort_key ( candidate )[ 0 ]
candidates . sort ( key = feature_recognition_sort_key )
2026-08-11 18:28:16 +08:00
result = [ dict ( item ) for item in candidates [: limit ]]
if isinstance ( cache , dict ):
cache [ cache_key ] = [ dict ( item ) for item in result ]
return [ dict ( item ) for item in result ]
2026-07-27 18:28:26 +08:00
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 ]]:
2026-08-11 18:28:16 +08:00
limit = max ( 1 , int ( limit ))
normalized_max_faces = None if max_scan_faces is None else max ( 0 , int ( max_scan_faces ))
cache_key = (
"cylinder" ,
limit ,
bool ( include_end_info ),
normalized_max_faces ,
)
cache = getattr ( self , "_cylindrical_feature_candidates_cache" , None )
if isinstance ( cache , dict ) and cache_key in cache :
return [ dict ( item ) for item in cache [ cache_key ]]
2026-07-27 18:28:26 +08:00
candidates : list [ dict [ str , object ]] = []
2026-08-11 18:28:16 +08:00
face_scan_limit = len ( self . faces ) if normalized_max_faces is None else min ( len ( self . faces ), normalized_max_faces )
2026-07-27 18:28:26 +08:00
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 ))
2026-08-10 14:56:50 +08:00
try :
feature = self . feature_info ( face_id )
for key in (
"feature_guess" ,
"feature_type" ,
"feature_edit_actions" ,
"confidence" ,
"slot_kind" ,
"slot_status" ,
"slot_risk" ,
"slot_blockers" ,
"resize_status" ,
"resize_risk" ,
"resize_blockers" ,
"resize_note" ,
"boss_resize_status" ,
"boss_resize_risk" ,
"boss_resize_blockers" ,
"boss_resize_note" ,
"recognition_risk" ,
"recognition_blockers" ,
):
if feature . get ( key ) not in { None , "" }:
candidate [ key ] = feature . get ( key )
except Exception :
pass
2026-07-27 18:28:26 +08:00
candidates . append ( candidate )
if len ( candidates ) >= limit :
break
2026-08-11 18:28:16 +08:00
result = [ dict ( item ) for item in candidates ]
if isinstance ( cache , dict ):
cache [ cache_key ] = [ dict ( item ) for item in result ]
return [ dict ( item ) for item in result ]
2026-07-27 18:28:26 +08:00
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" ,
2026-07-28 14:05:14 +08:00
"message" : "当前选中的 Face 不是圆柱面,不能执行圆柱切削。" ,
2026-07-27 18:28:26 +08:00
}
current_diameter = float ( info [ "diameter" ])
feature = self . feature_info ( face_id )
2026-08-10 14:56:50 +08:00
if str ( feature . get ( "slot_status" ) or "" ) == "blocked" :
blocker = str ( feature . get ( "slot_blockers" ) or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。" )
return {
"status" : "blocked" ,
"risk" : "blocked" ,
"message" : blocker ,
"warnings" : "" ,
"blockers" : blocker ,
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"current_diameter" : current_diameter ,
"target_diameter" : new_diameter ,
"feature_type" : feature . get ( "feature_type" ),
"feature_guess" : feature . get ( "feature_guess" ),
"slot_status" : feature . get ( "slot_status" ),
"slot_blockers" : blocker ,
}
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-08-13 17:50:20 +08:00
feature_angular_span = (
_float_or_none ( feature . get ( "same_domain_angular_span" ))
or _float_or_none ( feature . get ( "angular_span" ))
or _float_or_none ( info . get ( "same_domain_angular_span" ))
or _float_or_none ( info . get ( "angular_span" ))
)
2026-07-27 18:28:26 +08:00
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" ])
2026-08-13 17:50:20 +08:00
if feature_angular_span is not None :
scoped_info [ "angular_span" ] = feature_angular_span
scoped_info [ "same_domain_angular_span" ] = feature_angular_span
scoped_info [ "is_full_cylinder" ] = feature_angular_span >= math . tau * 0.92
2026-07-27 18:28:26 +08:00
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 )
2026-08-04 09:35:39 +08:00
readiness = self . _apply_cylindrical_first_level_guard_to_readiness (
readiness ,
topology_fields ,
status_key = "resize_status" ,
risk_key = "resize_risk" ,
note_key = "resize_note" ,
warnings_key = "resize_warnings" ,
blockers_key = "resize_blockers" ,
)
2026-07-27 18:28:26 +08:00
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 {}
2026-07-30 17:54:01 +08:00
edit_semantics = (
"缩小孔/槽直径:先用同轴圆柱补料封住旧孔壁,再按目标直径同轴重切;保持当前轴线和估算高度。"
if resize_mode == "shrink"
else "扩大孔/槽直径:沿当前圆柱轴线用有限长度圆柱 cutter 切到目标直径;保持当前轴线和估算高度。"
)
2026-07-27 18:28:26 +08:00
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" ),
"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" ),
2026-07-30 17:54:01 +08:00
"resize_strategy" : "same-axis-bounded-cylinder-recut" ,
"edit_strategy_label" : "同轴圆柱重切" ,
"edit_semantics" : edit_semantics ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-27 18:28:26 +08:00
** cutter_plan ,
** fill_plan ,
2026-08-13 17:50:20 +08:00
"angular_span" : scoped_info . get ( "angular_span" ),
"same_domain_angular_span" : scoped_info . get ( "same_domain_angular_span" ),
"is_full_cylinder" : scoped_info . get ( "is_full_cylinder" , feature . get ( "is_full_cylinder" )),
2026-07-27 18:28:26 +08:00
}
2026-07-29 15:43:28 +08:00
def cylindrical_axis_move_plan (
self ,
face_id : int ,
target_center : tuple [ float , float , 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 )
feature : dict [ str , object ] = {}
try :
feature = self . feature_info ( face_id )
except Exception :
feature = {}
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
blockers : list [ str ] = []
warnings : list [ str ] = [
"Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis."
]
risk = "medium"
2026-08-04 09:35:39 +08:00
risk = self . _apply_cylindrical_first_level_guard ( topology_fields , blockers , warnings , risk )
2026-07-29 15:43:28 +08:00
try :
target_center = ( float ( target_center [ 0 ]), float ( target_center [ 1 ]), float ( target_center [ 2 ]))
except ( TypeError , ValueError , IndexError ):
target_center = ( 0.0 , 0.0 , 0.0 )
blockers . append ( "Target cylinder axis center must be three numeric coordinates." )
current_diameter = _float_or_none ( info . get ( "diameter" ))
angular_span = _float_or_none ( info . get ( "angular_span" ))
feature_guess = str ( info . get ( "feature_guess" , "" ))
confidence = str ( info . get ( "confidence" , "low" ))
surf = BRepAdaptor_Surface ( self . faces [ face_id ])
current_center : tuple [ float , float , float ] | None = None
axis_direction : tuple [ float , float , float ] | None = None
axis_range : dict [ str , object ] = {}
if info . get ( "surface" ) != "cylinder" or surf . GetType () != GeomAbs_Cylinder :
blockers . append ( "Selected Face is not a cylindrical hole/groove face." )
else :
cyl = surf . Cylinder ()
axis = cyl . Axis ()
axis_direction = _dir_tuple ( axis . Direction ())
axis_range = self . _cylindrical_axis_range (
face_id ,
surf ,
_int_values ( feature . get ( "feature_side_face_ids" )),
)
mid_parameter = ( float ( axis_range [ "v_min" ]) + float ( axis_range [ "v_max" ])) * 0.5
current_center = _point_tuple ( _point_on_axis ( axis . Location (), axis . Direction (), mid_parameter ))
if current_diameter is None or current_diameter <= 1e-9 :
blockers . append ( "Selected cylindrical Face has no stable diameter." )
if feature_guess != "hole/groove candidate" :
blockers . append ( "Cylinder axis move currently supports recognized hole/groove candidates only." )
if angular_span is None or angular_span < math . tau * 0.92 :
blockers . append ( "Cylinder axis move currently supports near-full cylindrical holes only; partial slots need a sector-aware tool." )
if current_center is None or axis_direction is None :
blockers . append ( "Could not derive a stable current cylinder axis center." )
movement = ( 0.0 , 0.0 , 0.0 )
move_distance = 0.0
axial_delta = 0.0
radial_distance = 0.0
if current_center is not None and axis_direction is not None :
movement = _tuple_sub ( target_center , current_center )
move_distance = _vector_length ( movement )
axial_delta = _tuple_dot ( movement , axis_direction )
radial_movement = _tuple_sub ( movement , _tuple_scale ( axis_direction , axial_delta ))
radial_distance = _vector_length ( radial_movement )
diagonal = max ( _shape_diagonal ( self . faces [ face_id ]), current_diameter or 0.0 , 1.0 )
if move_distance <= max ( diagonal * 1e-7 , 1e-6 ):
blockers . append ( "Target cylinder axis center is almost the same as the current center." )
if current_diameter is not None and current_diameter > 0 :
ratio = move_distance / current_diameter
if ratio > 4.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target axis move is more than four hole diameters; Boolean cut may affect unrelated geometry." )
elif ratio > 1.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target axis move is larger than one hole diameter; verify nearby walls after editing." )
elif ratio > 0.35 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Target axis move is a moderate local relocation." )
if abs ( axial_delta ) > max ( radial_distance * 0.5 , ( current_diameter or 1.0 ) * 0.25 ):
risk = _max_risk ( risk , "high" )
warnings . append ( "The target center includes a large movement along the cylinder axis; this may change the opening/bottom relationship." )
if confidence == "low" :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Hole/groove recognition confidence is low." )
cutter_plan : dict [ str , object ] = {}
fill_plan : dict [ str , object ] = {}
if not blockers and current_diameter is not None :
cutter_plan = self . _bounded_cylinder_cutter_plan ( face_id , current_diameter , feature )
fill_plan = self . _bounded_cylinder_fill_plan ( face_id )
start = _tuple_or_none ( cutter_plan . get ( "cutter_start_point" ))
axis_point = _tuple_or_none ( cutter_plan . get ( "cutter_axis_point" ))
if start is None or axis_point is None :
blockers . append ( "Could not build the bounded cutter for the moved cylinder axis." )
else :
cutter_plan [ "target_cutter_start_point" ] = _tuple_add ( start , movement )
cutter_plan [ "target_cutter_axis_point" ] = _tuple_add ( axis_point , movement )
if blockers :
status = "blocked"
risk = "blocked"
else :
status = "caution" if risk in { "medium" , "high" } else "ready"
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Cylinder axis move can be attempted."
return {
** cutter_plan ,
** fill_plan ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
"status" : status ,
"risk" : risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"surface" : info . get ( "surface" ),
"feature_type" : feature . get ( "feature_type" ),
"feature_guess" : feature_guess ,
"confidence" : confidence ,
"current_diameter" : current_diameter ,
"target_diameter" : current_diameter ,
"current_radius" : None if current_diameter is None else current_diameter * 0.5 ,
"target_radius" : None if current_diameter is None else current_diameter * 0.5 ,
"current_axis_center" : current_center ,
"target_axis_center" : target_center ,
"axis_move_vector" : movement ,
"axis_move_distance" : move_distance ,
"axis_move_axial_delta" : axial_delta ,
"axis_move_radial_distance" : radial_distance ,
"axis" : axis_direction ,
"angular_span" : angular_span ,
"same_domain_face_ids" : axis_range . get ( "same_domain_face_ids" , ()),
"same_domain_face_count" : axis_range . get ( "same_domain_face_count" , 0 ),
"same_domain_v_range" : ( axis_range . get ( "v_min" ), axis_range . get ( "v_max" )),
"same_domain_range_source" : axis_range . get ( "range_source" , "" ),
"resize_strategy" : "fill-old-cylinder-and-cut-moved-cylinder" ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "填旧孔并切新孔" ,
"edit_semantics" : "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。" ,
2026-07-29 15:43:28 +08:00
}
def cylindrical_slot_resize_plan (
self ,
face_id : int ,
target_value : float ,
mode : str = "width" ,
pair_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 } " )
mode_key = str ( mode or "width" ) . strip () . lower () . replace ( "-" , "_" )
mode_aliases = {
"width" : "width" ,
"slot_width" : "width" ,
"chord_width" : "width" ,
"depth" : "depth" ,
"slot_depth" : "depth" ,
"sagitta_depth" : "depth" ,
"arc" : "arc_length" ,
"arc_length" : "arc_length" ,
"slot_arc" : "arc_length" ,
"slot_arc_length" : "arc_length" ,
}
mode_key = mode_aliases . get ( mode_key , mode_key )
mode_labels = {
"width" : "slot width" ,
"depth" : "slot depth" ,
"arc_length" : "slot arc length" ,
}
info = self . face_info ( face_id )
feature : dict [ str , object ] = {}
try :
feature = self . feature_info ( face_id )
except Exception :
feature = {}
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
current_diameter = _float_or_none ( info . get ( "diameter" ))
if current_diameter is None :
current_diameter = _float_or_none ( feature . get ( "diameter" ))
current_radius = current_diameter / 2.0 if current_diameter is not None else None
angular_span = (
_float_or_none ( feature . get ( "slot_angular_span" ))
or _float_or_none ( info . get ( "slot_angular_span" ))
or _float_or_none ( info . get ( "angular_span" ))
)
slot_kind = str ( feature . get ( "slot_kind" ) or info . get ( "slot_kind" ) or "" )
slot_status = str ( feature . get ( "slot_status" ) or info . get ( "slot_status" ) or "" )
blockers : list [ str ] = []
warnings : list [ str ] = [
"Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume."
]
risk = "medium"
2026-08-04 09:35:39 +08:00
risk = self . _apply_cylindrical_first_level_guard (
topology_fields ,
blockers ,
warnings ,
risk ,
require_slot_boundary = True ,
)
2026-07-29 15:43:28 +08:00
try :
target_value = float ( target_value )
except ( TypeError , ValueError ):
target_value = 0.0
blockers . append ( "Target slot value must be a number." )
if mode_key not in mode_labels :
blockers . append ( f "Unsupported slot resize mode: { mode } ." )
if target_value <= 0 :
blockers . append ( "Target slot value must be greater than 0." )
if info . get ( "surface" ) != "cylinder" or current_diameter is None or current_diameter <= 1e-9 :
blockers . append ( "Selected face is not a measurable cylindrical slot face." )
if slot_kind != "partial-cylindrical-groove" :
blockers . append ( "Selected cylindrical face is not recognized as a slot/half-hole candidate." )
if angular_span is None or angular_span <= 1e-6 or angular_span >= math . tau * 0.92 :
blockers . append ( "Selected slot does not have a stable partial-cylinder angular span." )
if slot_status and slot_status != "candidate" :
2026-08-10 14:56:50 +08:00
blockers . append ( str ( feature . get ( "slot_blockers" ) or info . get ( "slot_blockers" ) or f "Slot candidate status is { slot_status } ." ))
2026-07-29 15:43:28 +08:00
span = min ( max ( float ( angular_span or 0.0 ), 1e-6 ), math . tau - 1e-6 )
sin_half_span = math . sin ( span / 2.0 )
sagitta_factor = 1.0 - math . cos ( min ( span , math . pi ) / 2.0 )
if mode_key == "width" and abs ( sin_half_span ) <= 1e-6 :
blockers . append ( "Slot angular span is too small to derive width reliably." )
if mode_key == "depth" and sagitta_factor <= 1e-6 :
blockers . append ( "Slot angular span is too small to derive depth reliably." )
current_width = _float_or_none ( feature . get ( "slot_chord_width_estimate" ))
if current_width is None and current_diameter is not None :
current_width = current_diameter * sin_half_span
current_depth = _float_or_none ( feature . get ( "slot_sagitta_depth_estimate" ))
if current_depth is None and current_radius is not None :
current_depth = current_radius * sagitta_factor
current_arc_length = _float_or_none ( feature . get ( "slot_arc_length_estimate" ))
if current_arc_length is None and current_radius is not None :
current_arc_length = current_radius * span
target_diameter = 0.0
if not blockers :
if mode_key == "width" :
target_diameter = target_value / sin_half_span
elif mode_key == "depth" :
target_diameter = 2.0 * target_value / sagitta_factor
elif mode_key == "arc_length" :
target_diameter = 2.0 * target_value / span
if target_diameter <= 1e-9 :
blockers . append ( "Target slot value produced an invalid cylinder diameter." )
target_width = target_diameter * sin_half_span if target_diameter > 0 else None
target_depth = target_diameter * 0.5 * sagitta_factor if target_diameter > 0 else None
target_arc_length = target_diameter * 0.5 * span if target_diameter > 0 else None
slot_plan = {
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"surface" : info . get ( "surface" ),
"slot_resize_mode" : mode_key ,
"slot_resize_label" : mode_labels . get ( mode_key , "slot value" ),
"slot_resize_strategy" : "local-sector-fixed-angular-span" ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "局部扇形槽重建" ,
"edit_semantics" : "保持当前槽/半孔圆弧角度,把目标宽度、深度或圆弧长度换算成圆柱直径,再填旧槽并切出新的局部扇形槽。" ,
2026-07-29 15:43:28 +08:00
"slot_kind" : slot_kind ,
"slot_status" : slot_status ,
"slot_target_value" : target_value ,
"slot_target_diameter" : target_diameter if target_diameter > 0 else None ,
"slot_angular_span" : angular_span ,
"slot_chord_factor" : sin_half_span ,
"slot_sagitta_factor" : sagitta_factor ,
"slot_current_width" : current_width ,
"slot_target_width" : target_width ,
"slot_width_delta" : None if current_width is None or target_width is None else target_width - current_width ,
"slot_current_depth" : current_depth ,
"slot_target_depth" : target_depth ,
"slot_depth_delta" : None if current_depth is None or target_depth is None else target_depth - current_depth ,
"slot_current_arc_length" : current_arc_length ,
"slot_target_arc_length" : target_arc_length ,
"slot_arc_length_delta" : None
if current_arc_length is None or target_arc_length is None
else target_arc_length - current_arc_length ,
"feature_slot_face_ids" : feature . get ( "feature_slot_face_ids" ),
"feature_slot_boundary_face_ids" : feature . get ( "feature_slot_boundary_face_ids" ),
"slot_note" : feature . get ( "slot_note" ),
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
}
if blockers :
return {
** slot_plan ,
"status" : "blocked" ,
"risk" : "blocked" ,
"message" : "; " . join ( blockers + warnings ),
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
}
resize_plan = self . cylindrical_resize_plan ( face_id , target_diameter )
resize_status = str ( resize_plan . get ( "status" , "ready" ))
resize_risk = str ( resize_plan . get ( "risk" , "low" ))
if resize_status == "blocked" :
status = "blocked"
risk = "blocked"
blockers . append ( str ( resize_plan . get ( "message" , "" )))
else :
risk = _max_risk ( risk , resize_risk )
status = "caution" if risk in { "medium" , "high" } else "ready"
paired_slot_plan = self . _paired_obround_slot_plan (
face_id ,
target_diameter ,
feature ,
resize_plan ,
pair_face_id = pair_face_id ,
)
2026-07-30 17:54:01 +08:00
edit_strategy_label = "局部扇形槽重建"
edit_semantics = "保持当前槽/半孔圆弧角度,把目标值换算成圆柱直径,再填旧槽并切出新的局部扇形槽。"
2026-07-29 15:43:28 +08:00
if paired_slot_plan :
2026-07-30 17:54:01 +08:00
edit_strategy_label = "配对长圆槽重建"
edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同一槽中心线和目标宽度重切完整长圆槽。"
2026-07-29 15:43:28 +08:00
risk = _max_risk ( risk , str ( paired_slot_plan . get ( "slot_pair_risk" , "medium" )))
pair_warning = str ( paired_slot_plan . get ( "slot_pair_warning" , "" ))
if pair_warning :
warnings . append ( pair_warning )
elif pair_face_id is not None :
risk = _max_risk ( risk , "high" )
warnings . append (
"Manual paired Face ID could not be used as a compatible obround slot end; "
"this edit will fall back to rebuilding only the selected local slot sector."
)
resize_warnings = str ( resize_plan . get ( "warnings" , "" ) or "" )
if resize_warnings :
warnings . append ( resize_warnings )
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Slot resize can be attempted."
return {
** resize_plan ,
** slot_plan ,
** paired_slot_plan ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : edit_strategy_label ,
"edit_semantics" : edit_semantics ,
2026-07-29 15:43:28 +08:00
"status" : status ,
"risk" : risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"target_diameter" : target_diameter ,
"derived_new_diameter" : target_diameter ,
}
def cylindrical_slot_angular_span_plan ( self , face_id : int , target_angular_span : 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 )
feature : dict [ str , object ] = {}
try :
feature = self . feature_info ( face_id )
except Exception :
feature = {}
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
current_diameter = _float_or_none ( info . get ( "diameter" ))
if current_diameter is None :
current_diameter = _float_or_none ( feature . get ( "diameter" ))
current_radius = current_diameter * 0.5 if current_diameter is not None else None
slot_kind = str ( feature . get ( "slot_kind" ) or info . get ( "slot_kind" ) or "" )
slot_status = str ( feature . get ( "slot_status" ) or info . get ( "slot_status" ) or "" )
blockers : list [ str ] = []
warnings : list [ str ] = [
"Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center."
]
risk = "medium"
2026-08-04 09:35:39 +08:00
risk = self . _apply_cylindrical_first_level_guard (
topology_fields ,
blockers ,
warnings ,
risk ,
require_slot_boundary = True ,
)
2026-07-29 15:43:28 +08:00
try :
target_angular_span = float ( target_angular_span )
except ( TypeError , ValueError ):
target_angular_span = 0.0
blockers . append ( "Target slot angular span must be a number." )
surf = BRepAdaptor_Surface ( self . faces [ face_id ])
if surf . GetType () != GeomAbs_Cylinder :
blockers . append ( "Selected face is not a cylindrical slot face." )
current_u_first = 0.0
current_u_last = 0.0
current_span = 0.0
signed_span = 0.0
else :
current_u_first = float ( surf . FirstUParameter ())
current_u_last = float ( surf . LastUParameter ())
signed_span = current_u_last - current_u_first
current_span = abs ( signed_span )
if info . get ( "surface" ) != "cylinder" or current_diameter is None or current_diameter <= 1e-9 :
blockers . append ( "Selected face is not a measurable cylindrical slot face." )
if slot_kind != "partial-cylindrical-groove" :
blockers . append ( "Selected cylindrical face is not recognized as a slot/half-hole candidate." )
if current_span <= 1e-6 or current_span >= math . tau * 0.92 :
blockers . append ( "Selected slot does not have a stable partial-cylinder angular span." )
if target_angular_span <= 1e-6 :
blockers . append ( "Target slot angular span must be greater than 0." )
if target_angular_span >= math . tau * 0.92 :
blockers . append ( "Target slot angular span must remain below a near-full cylinder." )
if slot_status and slot_status != "candidate" :
2026-08-10 14:56:50 +08:00
blockers . append ( str ( feature . get ( "slot_blockers" ) or info . get ( "slot_blockers" ) or f "Slot candidate status is { slot_status } ." ))
2026-07-29 15:43:28 +08:00
delta_span = None if current_span <= 0 else target_angular_span - current_span
delta_ratio = (
None
if current_span <= 1e-9 or delta_span is None
else abs ( delta_span ) / current_span
)
if delta_ratio is not None :
if abs ( delta_span or 0.0 ) <= max ( current_span * 1e-5 , 1e-6 ):
blockers . append ( "Target slot angular span is almost the same as the current span." )
elif delta_ratio > 0.75 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target slot angular span changes by more than 75%." )
elif delta_ratio > 0.35 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Target slot angular span changes by more than 35%." )
cutter_plan : dict [ str , object ] = {}
fill_plan : dict [ str , object ] = {}
pair_plan : dict [ str , object ] = {}
if current_diameter is not None and current_diameter > 0 :
try :
cutter_plan = self . _bounded_cylinder_cutter_plan ( face_id , current_diameter , feature )
fill_plan = self . _bounded_cylinder_fill_plan ( face_id )
pair_plan = self . _paired_obround_slot_plan ( face_id , current_diameter , feature , cutter_plan )
except Exception as exc :
blockers . append ( f "Could not build slot angular-span edit tool plan: { exc } " )
if pair_plan :
risk = _max_risk ( risk , "high" )
warnings . append (
"A paired obround slot end was detected; angular-span edit currently rebuilds the selected local sector only."
)
sign = 1.0 if signed_span >= 0 else - 1.0
center_u = ( current_u_first + current_u_last ) * 0.5
target_signed_span = target_angular_span * sign
target_u_first = center_u - target_signed_span * 0.5
target_u_last = center_u + target_signed_span * 0.5
radius = current_radius if current_radius is not None else 0.0
current_width = 2.0 * radius * math . sin ( min ( max ( current_span , 0.0 ), math . tau ) * 0.5 ) if radius > 0 else None
target_width = 2.0 * radius * math . sin ( min ( max ( target_angular_span , 0.0 ), math . tau ) * 0.5 ) if radius > 0 else None
current_depth = radius * ( 1.0 - math . cos ( min ( max ( current_span , 0.0 ), math . pi ) * 0.5 )) if radius > 0 else None
target_depth = radius * ( 1.0 - math . cos ( min ( max ( target_angular_span , 0.0 ), math . pi ) * 0.5 )) if radius > 0 else None
current_arc_length = radius * current_span if radius > 0 else None
target_arc_length = radius * target_angular_span if radius > 0 else None
status = "blocked" if blockers else "caution" if risk in { "medium" , "high" } else "ready"
if not blockers :
warnings . append ( "This is a B-Rep local-sector rebuild, not a recovered CAD sketch angle parameter." )
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Slot angular-span resize can be attempted."
return {
** cutter_plan ,
** fill_plan ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
"status" : status ,
"risk" : "blocked" if blockers else risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"surface" : info . get ( "surface" ),
"slot_resize_mode" : "angular_span" ,
"slot_resize_label" : "slot angular span" ,
"slot_resize_strategy" : "local-sector-angular-span" ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "局部扇形槽角度重建" ,
"edit_semantics" : "保持当前圆柱半径和轴线,围绕当前角度中心填旧扇形槽并切出目标圆弧角度的新扇形槽。" ,
2026-07-29 15:43:28 +08:00
"resize_mode" : "widen" if ( delta_span or 0.0 ) > 0 else "narrow" ,
"feature_type" : feature . get ( "feature_type" ),
"feature_guess" : info . get ( "feature_guess" ),
"confidence" : info . get ( "confidence" ),
"material_vote_summary" : info . get ( "material_vote_summary" ),
"cylinder_end_type" : info . get ( "cylinder_end_type" ),
"slot_kind" : slot_kind ,
"slot_status" : slot_status ,
"current_diameter" : current_diameter ,
"target_diameter" : current_diameter ,
"slot_target_diameter" : current_diameter ,
"slot_current_angular_span" : current_span ,
"slot_target_angular_span" : target_angular_span ,
"slot_angular_span_delta" : delta_span ,
"slot_angular_span_delta_ratio" : delta_ratio ,
"slot_current_u_first" : current_u_first ,
"slot_current_u_last" : current_u_last ,
"slot_target_u_first" : target_u_first ,
"slot_target_u_last" : target_u_last ,
"slot_angular_center_parameter" : center_u ,
"slot_current_width" : current_width ,
"slot_target_width" : target_width ,
"slot_current_depth" : current_depth ,
"slot_target_depth" : target_depth ,
"slot_current_arc_length" : current_arc_length ,
"slot_target_arc_length" : target_arc_length ,
"slot_pair_face_id" : pair_plan . get ( "slot_pair_face_id" , "" ),
"slot_pair_axis_distance" : pair_plan . get ( "slot_pair_axis_distance" , "" ),
"slot_pair_boundary_overlap_count" : pair_plan . get ( "slot_pair_boundary_overlap_count" , "" ),
"feature_slot_face_ids" : feature . get ( "feature_slot_face_ids" ),
"feature_slot_boundary_face_ids" : feature . get ( "feature_slot_boundary_face_ids" ),
"slot_note" : feature . get ( "slot_note" ),
}
def cylindrical_slot_axis_move_plan (
self ,
face_id : int ,
target_center : tuple [ float , float , 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 )
feature : dict [ str , object ] = {}
try :
feature = self . feature_info ( face_id )
except Exception :
feature = {}
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
blockers : list [ str ] = []
warnings : list [ str ] = [
"Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis."
]
risk = "medium"
2026-08-04 09:35:39 +08:00
risk = self . _apply_cylindrical_first_level_guard (
topology_fields ,
blockers ,
warnings ,
risk ,
require_slot_boundary = True ,
)
2026-07-29 15:43:28 +08:00
try :
target_center = ( float ( target_center [ 0 ]), float ( target_center [ 1 ]), float ( target_center [ 2 ]))
except ( TypeError , ValueError , IndexError ):
target_center = ( 0.0 , 0.0 , 0.0 )
blockers . append ( "Target slot axis center must be three numeric coordinates." )
current_diameter = _float_or_none ( info . get ( "diameter" ))
if current_diameter is None :
current_diameter = _float_or_none ( feature . get ( "diameter" ))
slot_kind = str ( feature . get ( "slot_kind" ) or info . get ( "slot_kind" ) or "" )
slot_status = str ( feature . get ( "slot_status" ) or info . get ( "slot_status" ) or "" )
feature_guess = str ( info . get ( "feature_guess" , "" ))
confidence = str ( info . get ( "confidence" , "low" ))
surf = BRepAdaptor_Surface ( self . faces [ face_id ])
current_center : tuple [ float , float , float ] | None = None
axis_direction : tuple [ float , float , float ] | None = None
axis_range : dict [ str , object ] = {}
current_u_first = 0.0
current_u_last = 0.0
current_span = 0.0
if info . get ( "surface" ) != "cylinder" or surf . GetType () != GeomAbs_Cylinder :
blockers . append ( "Selected Face is not a cylindrical slot/half-hole face." )
else :
cyl = surf . Cylinder ()
axis = cyl . Axis ()
axis_direction = _dir_tuple ( axis . Direction ())
current_u_first = float ( surf . FirstUParameter ())
current_u_last = float ( surf . LastUParameter ())
current_span = abs ( current_u_last - current_u_first )
axis_range = self . _cylindrical_axis_range (
face_id ,
surf ,
_int_values ( feature . get ( "feature_side_face_ids" )),
)
mid_parameter = ( float ( axis_range [ "v_min" ]) + float ( axis_range [ "v_max" ])) * 0.5
current_center = _point_tuple ( _point_on_axis ( axis . Location (), axis . Direction (), mid_parameter ))
if current_diameter is None or current_diameter <= 1e-9 :
blockers . append ( "Selected slot/half-hole has no stable diameter." )
if feature_guess != "hole/groove candidate" or slot_kind != "partial-cylindrical-groove" :
blockers . append ( "Slot axis move currently supports recognized partial cylindrical slot/half-hole candidates only." )
if current_span <= 1e-6 or current_span >= math . tau * 0.92 :
blockers . append ( "Selected slot/half-hole does not have a stable partial-cylinder angular span." )
if current_center is None or axis_direction is None :
blockers . append ( "Could not derive a stable current slot axis center." )
movement = ( 0.0 , 0.0 , 0.0 )
move_distance = 0.0
axial_delta = 0.0
radial_distance = 0.0
if current_center is not None and axis_direction is not None :
movement = _tuple_sub ( target_center , current_center )
move_distance = _vector_length ( movement )
axial_delta = _tuple_dot ( movement , axis_direction )
radial_movement = _tuple_sub ( movement , _tuple_scale ( axis_direction , axial_delta ))
radial_distance = _vector_length ( radial_movement )
diagonal = max ( _shape_diagonal ( self . faces [ face_id ]), current_diameter or 0.0 , 1.0 )
if move_distance <= max ( diagonal * 1e-7 , 1e-6 ):
blockers . append ( "Target slot axis center is almost the same as the current center." )
if current_diameter is not None and current_diameter > 0 :
ratio = move_distance / current_diameter
if ratio > 4.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target slot axis move is more than four slot diameters; Boolean cut may affect unrelated geometry." )
elif ratio > 1.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target slot axis move is larger than one slot diameter; verify nearby walls after editing." )
elif ratio > 0.35 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Target slot axis move is a moderate local relocation." )
if abs ( axial_delta ) > max ( radial_distance * 0.5 , ( current_diameter or 1.0 ) * 0.25 ):
risk = _max_risk ( risk , "high" )
warnings . append ( "The target center includes a large movement along the slot axis; this may change end overlap." )
if confidence == "low" :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Slot recognition confidence is low." )
if slot_status and slot_status != "candidate" :
2026-08-10 14:56:50 +08:00
blockers . append ( str ( feature . get ( "slot_blockers" ) or info . get ( "slot_blockers" ) or f "Slot candidate status is { slot_status } ." ))
2026-07-29 15:43:28 +08:00
cutter_plan : dict [ str , object ] = {}
fill_plan : dict [ str , object ] = {}
pair_plan : dict [ str , object ] = {}
if not blockers and current_diameter is not None :
try :
cutter_plan = self . _bounded_cylinder_cutter_plan ( face_id , current_diameter , feature )
fill_plan = self . _bounded_cylinder_fill_plan ( face_id )
pair_plan = self . _paired_obround_slot_plan ( face_id , current_diameter , feature , cutter_plan )
axis_point = _tuple_or_none ( cutter_plan . get ( "cutter_axis_point" ))
start = _tuple_or_none ( cutter_plan . get ( "cutter_start_point" ))
if axis_point is None or start is None :
blockers . append ( "Could not build the bounded sector cutter for the moved slot axis." )
else :
cutter_plan [ "target_cutter_axis_point" ] = _tuple_add ( axis_point , movement )
cutter_plan [ "target_cutter_start_point" ] = _tuple_add ( start , movement )
except Exception as exc :
blockers . append ( f "Could not build slot axis move tool plan: { exc } " )
2026-07-30 17:54:01 +08:00
resize_strategy = "fill-old-slot-sector-and-cut-moved-sector"
edit_strategy_label = "填旧槽并切新槽"
edit_semantics = "先填补当前槽/半孔扇形区域,再按同宽度、同角度在目标轴心切出新槽;不整体平移零件。"
2026-07-29 15:43:28 +08:00
if pair_plan :
2026-07-30 17:54:01 +08:00
center_1 = _tuple_or_none ( pair_plan . get ( "slot_capsule_start_center_1" ))
center_2 = _tuple_or_none ( pair_plan . get ( "slot_capsule_start_center_2" ))
if center_1 is None or center_2 is None :
risk = _max_risk ( risk , "high" )
warnings . append (
"A paired obround slot end was detected, but the full-slot centerline could not be derived."
)
else :
pair_plan [ "slot_capsule_target_start_center_1" ] = _tuple_add ( center_1 , movement )
pair_plan [ "slot_capsule_target_start_center_2" ] = _tuple_add ( center_2 , movement )
pair_plan [ "slot_target_center_distance" ] = pair_plan . get ( "slot_pair_axis_distance" , "" )
pair_plan [ "slot_current_center_distance" ] = pair_plan . get ( "slot_pair_axis_distance" , "" )
pair_plan [ "slot_pair_current_axis_center_1" ] = current_center
pair_plan [ "slot_pair_target_axis_center_1" ] = target_center
try :
pair_face_id = int ( pair_plan . get ( "slot_pair_face_id" , - 1 ))
pair_surf = BRepAdaptor_Surface ( self . faces [ pair_face_id ])
pair_feature = self . feature_info ( pair_face_id )
pair_axis_range = self . _cylindrical_axis_range (
pair_face_id ,
pair_surf ,
_int_values ( pair_feature . get ( "feature_side_face_ids" )),
)
pair_cyl = pair_surf . Cylinder ()
pair_mid_parameter = ( float ( pair_axis_range [ "v_min" ]) + float ( pair_axis_range [ "v_max" ])) * 0.5
pair_center = _point_tuple (
_point_on_axis ( pair_cyl . Axis () . Location (), pair_cyl . Axis () . Direction (), pair_mid_parameter )
)
pair_plan [ "slot_pair_current_axis_center_2" ] = pair_center
pair_plan [ "slot_pair_target_axis_center_2" ] = _tuple_add ( pair_center , movement )
except Exception :
pair_plan [ "slot_pair_current_axis_center_2" ] = ""
pair_plan [ "slot_pair_target_axis_center_2" ] = ""
if not pair_plan . get ( "slot_pair_target_axis_center_2" ):
blockers . append ( "Could not derive both obround slot end centers for the axis move." )
else :
resize_strategy = "paired-obround-slot-axis-prism"
edit_strategy_label = "整条长圆槽轴心移动"
edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同槽宽、同总长度在目标轴心重切整条长圆槽;不整体平移零件。"
risk = _max_risk ( risk , str ( pair_plan . get ( "slot_pair_risk" , "medium" )))
warnings . append ( "Detected paired partial-cylinder slot ends; axis move will relocate the full obround slot." )
2026-07-29 15:43:28 +08:00
current_radius = current_diameter * 0.5 if current_diameter is not None else None
current_width = (
2.0 * current_radius * math . sin ( min ( max ( current_span , 0.0 ), math . tau ) * 0.5 )
if current_radius is not None and current_radius > 0
else None
)
current_depth = (
current_radius * ( 1.0 - math . cos ( min ( max ( current_span , 0.0 ), math . pi ) * 0.5 ))
if current_radius is not None and current_radius > 0
else None
)
status = "blocked" if blockers else "caution" if risk in { "medium" , "high" } else "ready"
if not blockers :
warnings . append ( "This is a B-Rep local-sector relocation, not a recovered CAD sketch constraint." )
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Slot axis move can be attempted."
return {
** cutter_plan ,
** fill_plan ,
2026-07-30 17:54:01 +08:00
** pair_plan ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
"status" : status ,
"risk" : "blocked" if blockers else risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"surface" : info . get ( "surface" ),
"feature_type" : feature . get ( "feature_type" ),
"feature_guess" : feature_guess ,
"confidence" : confidence ,
"material_vote_summary" : info . get ( "material_vote_summary" ),
"slot_kind" : slot_kind ,
"slot_status" : slot_status ,
"current_diameter" : current_diameter ,
"target_diameter" : current_diameter ,
"slot_target_diameter" : current_diameter ,
"current_radius" : current_radius ,
"target_radius" : current_radius ,
"current_axis_center" : current_center ,
"target_axis_center" : target_center ,
"axis_move_vector" : movement ,
"axis_move_distance" : move_distance ,
"axis_move_axial_delta" : axial_delta ,
"axis_move_radial_distance" : radial_distance ,
"axis" : axis_direction ,
"angular_span" : current_span ,
"slot_current_angular_span" : current_span ,
"slot_target_angular_span" : current_span ,
"slot_current_u_first" : current_u_first ,
"slot_current_u_last" : current_u_last ,
"slot_target_u_first" : current_u_first ,
"slot_target_u_last" : current_u_last ,
"slot_current_width" : current_width ,
"slot_current_depth" : current_depth ,
"slot_pair_face_id" : pair_plan . get ( "slot_pair_face_id" , "" ),
"slot_pair_axis_distance" : pair_plan . get ( "slot_pair_axis_distance" , "" ),
"slot_pair_boundary_overlap_count" : pair_plan . get ( "slot_pair_boundary_overlap_count" , "" ),
"same_domain_face_ids" : axis_range . get ( "same_domain_face_ids" , ()),
"same_domain_face_count" : axis_range . get ( "same_domain_face_count" , 0 ),
"same_domain_v_range" : ( axis_range . get ( "v_min" ), axis_range . get ( "v_max" )),
"same_domain_range_source" : axis_range . get ( "range_source" , "" ),
2026-07-30 17:54:01 +08:00
"resize_strategy" : resize_strategy ,
"edit_strategy_label" : edit_strategy_label ,
"edit_semantics" : edit_semantics ,
2026-07-29 15:43:28 +08:00
}
def _paired_obround_slot_plan (
self ,
face_id : int ,
target_diameter : float ,
feature : dict [ str , object ],
resize_plan : dict [ str , object ],
pair_face_id : int | None = None ,
) -> dict [ str , object ]:
if target_diameter <= 1e-9 :
return {}
face = self . faces [ face_id ]
surf = BRepAdaptor_Surface ( face )
if surf . GetType () != GeomAbs_Cylinder :
return {}
cyl = surf . Cylinder ()
radius = float ( cyl . Radius ())
if radius <= 1e-9 :
return {}
axis_dir = _tuple_normalized ( _dir_tuple ( cyl . Axis () . Direction ()))
if axis_dir is None :
return {}
part_id = self . face_part_ids [ face_id ]
solid_id = self . face_solid_ids [ face_id ]
axis_range = self . _cylindrical_axis_range (
face_id ,
surf ,
_int_values ( feature . get ( "feature_side_face_ids" )),
)
selected_span = _float_or_none ( feature . get ( "slot_angular_span" ))
if selected_span is None :
selected_span = _float_or_none ( feature . get ( "angular_span" ))
selected_span = float ( selected_span or 0.0 )
selected_mid_parameter = ( float ( axis_range [ "v_min" ]) + float ( axis_range [ "v_max" ])) * 0.5
selected_mid = _point_tuple ( _point_on_axis ( cyl . Axis () . Location (), cyl . Axis () . Direction (), selected_mid_parameter ))
selected_boundary = set ( _int_values ( feature . get ( "feature_slot_boundary_face_ids" )))
if not selected_boundary :
selected_boundary = set ( _int_values ( feature . get ( "feature_adjacent_face_ids" ))) - set (
_int_values ( feature . get ( "feature_end_face_ids" ))
)
candidates : list [ tuple [ tuple [ float , float , float , float , int ], dict [ str , object ]]] = []
diagonal = max ( _shape_diagonal ( self . part_by_id ( part_id ) . shape ) if self . part_by_id ( part_id ) is not None else 0.0 , radius , 1.0 )
radius_tolerance = max ( radius * 0.08 , diagonal * 1e-5 , 1e-4 )
height = max ( float ( axis_range [ "v_max" ]) - float ( axis_range [ "v_min" ]), 1e-9 )
manual_pair_face_id = None
if pair_face_id is not None :
try :
manual_pair_face_id = int ( pair_face_id )
except ( TypeError , ValueError ):
return {}
if manual_pair_face_id == face_id or manual_pair_face_id < 0 or manual_pair_face_id >= len ( self . faces ):
return {}
for other_face_id , other_face in enumerate ( self . faces ):
if other_face_id == face_id :
continue
if manual_pair_face_id is not None and other_face_id != manual_pair_face_id :
continue
if self . face_part_ids [ other_face_id ] != part_id :
continue
if solid_id >= 0 and self . face_solid_ids [ other_face_id ] != solid_id :
continue
try :
other_info = self . face_info ( other_face_id )
if other_info . get ( "surface" ) != "cylinder" :
continue
if str ( other_info . get ( "feature_guess" , "" )) != "hole/groove candidate" :
continue
other_surf = BRepAdaptor_Surface ( other_face )
if other_surf . GetType () != GeomAbs_Cylinder :
continue
other_cyl = other_surf . Cylinder ()
other_radius = float ( other_cyl . Radius ())
if abs ( other_radius - radius ) > radius_tolerance :
continue
axis_alignment = abs ( _tuple_dot ( axis_dir , _dir_tuple ( other_cyl . Axis () . Direction ())))
if axis_alignment < 1.0 - 1e-4 :
continue
other_feature = self . feature_info ( other_face_id )
other_span = _float_or_none ( other_feature . get ( "slot_angular_span" ))
if other_span is None :
other_span = _float_or_none ( other_info . get ( "angular_span" ))
other_span = float ( other_span or 0.0 )
if other_span <= 1e-6 or other_span >= math . tau * 0.92 :
continue
other_range = self . _cylindrical_axis_range (
other_face_id ,
other_surf ,
_int_values ( other_feature . get ( "feature_side_face_ids" )),
)
other_height = max ( float ( other_range [ "v_max" ]) - float ( other_range [ "v_min" ]), 1e-9 )
if abs ( other_height - height ) > max ( height , other_height ) * 0.25 :
continue
other_mid_parameter = ( float ( other_range [ "v_min" ]) + float ( other_range [ "v_max" ])) * 0.5
other_mid = _point_tuple (
_point_on_axis ( other_cyl . Axis () . Location (), other_cyl . Axis () . Direction (), other_mid_parameter )
)
raw_offset = _tuple_sub ( other_mid , selected_mid )
axis_offset = _tuple_scale ( axis_dir , _tuple_dot ( raw_offset , axis_dir ))
section_offset = _tuple_sub ( raw_offset , axis_offset )
center_distance = _vector_length ( section_offset )
if center_distance <= max ( radius * 1.2 , diagonal * 1e-5 , 1e-4 ):
continue
other_boundary = set ( _int_values ( other_feature . get ( "feature_slot_boundary_face_ids" )))
if not other_boundary :
other_boundary = set ( _int_values ( other_feature . get ( "feature_adjacent_face_ids" ))) - set (
_int_values ( other_feature . get ( "feature_end_face_ids" ))
)
boundary_overlap = len ( selected_boundary & other_boundary )
both_half_like = (
math . pi * 0.35 <= selected_span <= math . pi * 1.65
and math . pi * 0.35 <= other_span <= math . pi * 1.65
)
if boundary_overlap <= 0 and not both_half_like and manual_pair_face_id is None :
continue
length_dir = _tuple_normalized ( section_offset )
if length_dir is None :
continue
side_dir = _tuple_normalized ( _tuple_cross ( axis_dir , length_dir ))
if side_dir is None :
continue
overlap_score = 0.0 if boundary_overlap >= 2 else 1.0 if boundary_overlap == 1 else 2.0
score = (
overlap_score ,
abs ( other_radius - radius ),
abs ( other_span - selected_span ),
abs ( other_height - height ) / max ( height , other_height , 1e-9 ),
other_face_id ,
)
candidates . append (
(
score ,
{
"slot_pair_face_id" : other_face_id ,
"slot_pair_boundary_overlap_count" : boundary_overlap ,
"slot_pair_axis_distance" : center_distance ,
"slot_pair_selected_span" : selected_span ,
"slot_pair_other_span" : other_span ,
"slot_pair_selected_radius" : radius ,
"slot_pair_other_radius" : other_radius ,
"slot_pair_manual" : manual_pair_face_id is not None ,
"slot_capsule_axis_direction" : axis_dir ,
"slot_capsule_length_direction" : length_dir ,
"slot_capsule_side_direction" : side_dir ,
"slot_capsule_start_center_1" : tuple ( resize_plan . get ( "cutter_start_point" , ())),
"slot_capsule_start_center_2" : (
float ( resize_plan [ "cutter_start_point" ][ 0 ]) + section_offset [ 0 ],
float ( resize_plan [ "cutter_start_point" ][ 1 ]) + section_offset [ 1 ],
float ( resize_plan [ "cutter_start_point" ][ 2 ]) + section_offset [ 2 ],
)
if isinstance ( resize_plan . get ( "cutter_start_point" ), tuple )
and len ( resize_plan . get ( "cutter_start_point" , ())) == 3
else "" ,
},
)
)
except Exception :
continue
if not candidates :
return {}
candidates . sort ( key = lambda item : item [ 0 ])
candidate = candidates [ 0 ][ 1 ]
if not candidate . get ( "slot_capsule_start_center_1" ) or not candidate . get ( "slot_capsule_start_center_2" ):
return {}
warning = ""
pair_risk = "medium"
if candidate . get ( "slot_pair_manual" ):
pair_risk = "high" if int ( candidate . get ( "slot_pair_boundary_overlap_count" , 0 )) <= 0 else "medium"
warning = (
"Using manually specified paired partial-cylinder slot end; "
"please verify the selected pair before applying the obround slot rebuild."
)
elif int ( candidate . get ( "slot_pair_boundary_overlap_count" , 0 )) <= 0 :
pair_risk = "high"
warning = (
"Detected another parallel partial-cylinder slot end, but no shared boundary face was confirmed; "
"the obround slot rebuild is higher risk."
)
else :
warning = "Detected paired partial-cylinder slot ends; the edit will rebuild the full obround slot prism."
return {
** candidate ,
"slot_resize_strategy" : "paired-obround-slot-prism" ,
"slot_pair_risk" : pair_risk ,
"slot_pair_warning" : warning ,
"feature_slot_face_ids" : tuple (
sorted ({ face_id , int ( candidate [ "slot_pair_face_id" ]), * _int_values ( feature . get ( "feature_slot_face_ids" ))})
),
}
def cylindrical_slot_total_length_plan (
self ,
face_id : int ,
target_total_length : float ,
pair_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 )
feature = self . feature_info ( face_id )
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
current_diameter = _float_or_none ( info . get ( "diameter" ))
if current_diameter is None or current_diameter <= 1e-9 :
return {
"status" : "blocked" ,
"risk" : "blocked" ,
"message" : "Selected face is not a measurable cylindrical slot face." ,
"blockers" : "Selected face is not a measurable cylindrical slot face." ,
"warnings" : "" ,
}
try :
target_total_length = float ( target_total_length )
except ( TypeError , ValueError ):
target_total_length = 0.0
cutter_plan = self . _bounded_cylinder_cutter_plan ( face_id , current_diameter , feature )
fill_plan = self . _bounded_cylinder_fill_plan ( face_id )
paired_plan = self . _paired_obround_slot_plan (
face_id ,
current_diameter ,
feature ,
cutter_plan ,
pair_face_id = pair_face_id ,
)
blockers : list [ str ] = []
warnings : list [ str ] = []
risk = "medium"
2026-08-04 09:35:39 +08:00
risk = self . _apply_cylindrical_first_level_guard (
topology_fields ,
blockers ,
warnings ,
risk ,
require_slot_boundary = True ,
)
2026-07-29 15:43:28 +08:00
if not paired_plan :
if pair_face_id is None :
blockers . append ( "Current slot face is not recognized as one end of a paired obround slot." )
else :
blockers . append ( "Manual paired Face ID could not be used as a compatible obround slot end." )
2026-08-10 14:56:50 +08:00
if str ( feature . get ( "slot_status" ) or "" ) not in { "" , "candidate" }:
blockers . append ( str ( feature . get ( "slot_blockers" ) or f "Slot candidate status is { feature . get ( 'slot_status' ) } ." ))
2026-07-29 15:43:28 +08:00
if target_total_length <= 0 :
blockers . append ( "Target slot total length must be greater than 0." )
current_center_distance = _float_or_none ( paired_plan . get ( "slot_pair_axis_distance" ))
current_total_length = (
current_center_distance + current_diameter
if current_center_distance is not None and current_center_distance > 0
else None
)
target_center_distance = target_total_length - current_diameter
if target_center_distance <= max ( current_diameter * 0.08 , 1e-6 ):
blockers . append ( "Target slot total length must be meaningfully greater than the current slot width/diameter." )
delta_length = None if current_total_length is None else target_total_length - current_total_length
delta_ratio = (
None
if current_total_length is None or current_total_length <= 1e-9 or delta_length is None
else abs ( delta_length ) / current_total_length
)
if delta_ratio is not None :
if abs ( delta_length or 0.0 ) <= max ( current_total_length * 1e-5 , 1e-6 ):
blockers . append ( "Target slot total length is almost the same as the current length." )
elif delta_ratio > 0.75 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target slot total length changes by more than 75%." )
elif delta_ratio > 0.35 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Target slot total length changes by more than 35%." )
center_1 = _tuple_or_none ( paired_plan . get ( "slot_capsule_start_center_1" ))
center_2 = _tuple_or_none ( paired_plan . get ( "slot_capsule_start_center_2" ))
length_dir = _tuple_normalized ( _tuple_or_none ( paired_plan . get ( "slot_capsule_length_direction" )))
target_center_1 = None
target_center_2 = None
if center_1 is None or center_2 is None or length_dir is None :
blockers . append ( "Could not derive the obround slot centerline." )
elif target_center_distance > 0 :
midpoint = (
( center_1 [ 0 ] + center_2 [ 0 ]) * 0.5 ,
( center_1 [ 1 ] + center_2 [ 1 ]) * 0.5 ,
( center_1 [ 2 ] + center_2 [ 2 ]) * 0.5 ,
)
half_vector = _tuple_scale ( length_dir , target_center_distance * 0.5 )
target_center_1 = _tuple_sub ( midpoint , half_vector )
target_center_2 = _tuple_add ( midpoint , half_vector )
if blockers :
status = "blocked"
risk = "blocked"
else :
status = "caution" if risk in { "medium" , "high" } else "ready"
warnings . append (
"Slot total length edit fills the old obround slot volume, then cuts a new obround slot with the same width."
)
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Slot total length resize can be attempted."
return {
** cutter_plan ,
** fill_plan ,
** paired_plan ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
"status" : status ,
"risk" : risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
"surface" : info . get ( "surface" ),
"current_diameter" : current_diameter ,
"target_diameter" : current_diameter ,
"slot_target_diameter" : current_diameter ,
"slot_resize_mode" : "total_length" ,
"slot_resize_label" : "slot total length" ,
"slot_resize_strategy" : "paired-obround-slot-length-prism" ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "长圆槽总长度重建" ,
"edit_semantics" : "保持槽宽不变,围绕长圆槽中心线对两端中心距做对称调整,然后填旧槽并重切目标总长度的长圆槽。" ,
2026-07-29 15:43:28 +08:00
"resize_mode" : "lengthen" if ( delta_length or 0.0 ) > 0 else "shorten" ,
"slot_current_total_length" : current_total_length ,
"slot_target_total_length" : target_total_length ,
"slot_total_length_delta" : delta_length ,
"slot_total_length_delta_ratio" : delta_ratio ,
"slot_current_center_distance" : current_center_distance ,
"slot_target_center_distance" : target_center_distance ,
"slot_capsule_target_start_center_1" : target_center_1 ,
"slot_capsule_target_start_center_2" : target_center_2 ,
}
2026-07-30 17:54:01 +08:00
def cylindrical_slot_center_distance_plan (
self ,
face_id : int ,
target_center_distance : float ,
pair_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 )
current_diameter = _float_or_none ( info . get ( "diameter" ))
if current_diameter is None or current_diameter <= 1e-9 :
return {
"status" : "blocked" ,
"risk" : "blocked" ,
"message" : "Selected face is not a measurable cylindrical slot face." ,
"blockers" : "Selected face is not a measurable cylindrical slot face." ,
"warnings" : "" ,
}
try :
target_center_distance = float ( target_center_distance )
except ( TypeError , ValueError ):
target_center_distance = 0.0
target_total_length = target_center_distance + current_diameter
plan = self . cylindrical_slot_total_length_plan (
face_id ,
target_total_length ,
pair_face_id = pair_face_id ,
)
current_center_distance = _float_or_none ( plan . get ( "slot_current_center_distance" ))
delta = (
None
if current_center_distance is None
else target_center_distance - current_center_distance
)
delta_ratio = (
None
if current_center_distance is None or current_center_distance <= 1e-9 or delta is None
else abs ( delta ) / current_center_distance
)
if target_center_distance <= 0 :
blockers = str ( plan . get ( "blockers" ) or "" )
extra = "Target slot center distance must be greater than 0."
plan [ "blockers" ] = "; " . join ( item for item in ( blockers , extra ) if item )
plan [ "message" ] = plan [ "blockers" ]
plan [ "status" ] = "blocked"
plan [ "risk" ] = "blocked"
plan . update (
{
"slot_resize_mode" : "center_distance" ,
"slot_resize_label" : "slot center distance" ,
"slot_resize_strategy" : "paired-obround-slot-center-distance-prism" ,
"edit_strategy_label" : "长圆槽中心距重建" ,
"edit_semantics" : "保持槽宽不变,围绕长圆槽中心线对两端半圆中心距做对称调整,然后填旧槽并重切目标中心距的长圆槽。" ,
"slot_target_center_distance" : target_center_distance ,
"slot_target_total_length" : target_total_length ,
"slot_center_distance_delta" : delta ,
"slot_center_distance_delta_ratio" : delta_ratio ,
}
)
return plan
2026-07-27 18:28:26 +08:00
def cylindrical_boss_resize_plan ( self , face_id : int , new_diameter : float ) -> dict [ str , object ]:
if face_id < 0 or face_id >= len ( self . faces ):
raise ValueError ( f "Unknown face id { face_id } " )
info = self . face_info ( face_id )
if info . get ( "surface" ) != "cylinder" or "diameter" not in info :
return {
"status" : "blocked" ,
"risk" : "blocked" ,
2026-07-28 14:05:14 +08:00
"message" : "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。" ,
2026-07-27 18:28:26 +08:00
}
current_diameter = float ( info [ "diameter" ])
feature = self . feature_info ( face_id )
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-27 18:28:26 +08:00
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 )
2026-08-06 18:15:14 +08:00
readiness = self . _apply_cylindrical_first_level_guard_to_readiness (
readiness ,
topology_fields ,
status_key = "boss_resize_status" ,
risk_key = "boss_resize_risk" ,
note_key = "boss_resize_note" ,
warnings_key = "boss_resize_warnings" ,
blockers_key = "boss_resize_blockers" ,
)
2026-07-27 18:28:26 +08:00
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 )
2026-07-30 17:54:01 +08:00
edit_semantics = (
"扩大凸台直径:按当前凸台轴线和估算高度生成目标圆柱补料体,并与所属特征做局部 Fuse。"
if resize_mode == "enlarge"
else "缩小凸台直径:先移除旧凸台包络,再补回目标直径圆柱;这是局部重建,不是缩放整个零件。"
)
2026-07-27 18:28:26 +08:00
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" ],
2026-08-06 18:15:14 +08:00
** topology_fields ,
2026-07-27 18:28:26 +08:00
"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" ),
2026-07-30 17:54:01 +08:00
"resize_strategy" : "bounded-cylindrical-boss-envelope-rebuild" ,
"edit_strategy_label" : "凸台包络重建" ,
"edit_semantics" : edit_semantics ,
2026-07-27 18:28:26 +08:00
** tool_plan ,
}
2026-07-29 15:43:28 +08:00
def cylindrical_boss_axis_move_plan (
self ,
face_id : int ,
target_center : tuple [ float , float , 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 )
feature : dict [ str , object ] = {}
try :
feature = self . feature_info ( face_id )
except Exception :
feature = {}
2026-08-06 18:15:14 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-29 15:43:28 +08:00
blockers : list [ str ] = []
warnings : list [ str ] = [
"Cylindrical boss axis move removes the old boss envelope, then fuses a same-diameter boss on the target axis."
]
risk = "medium"
2026-08-06 18:15:14 +08:00
risk = self . _apply_cylindrical_first_level_guard ( topology_fields , blockers , warnings , risk )
2026-07-29 15:43:28 +08:00
try :
target_center = ( float ( target_center [ 0 ]), float ( target_center [ 1 ]), float ( target_center [ 2 ]))
except ( TypeError , ValueError , IndexError ):
target_center = ( 0.0 , 0.0 , 0.0 )
blockers . append ( "Target boss axis center must be three numeric coordinates." )
current_diameter = _float_or_none ( info . get ( "diameter" ))
angular_span = _float_or_none ( info . get ( "angular_span" ))
feature_guess = str ( info . get ( "feature_guess" , "" ))
confidence = str ( info . get ( "confidence" , "low" ))
surf = BRepAdaptor_Surface ( self . faces [ face_id ])
current_center : tuple [ float , float , float ] | None = None
axis_direction : tuple [ float , float , float ] | None = None
axis_range : dict [ str , object ] = {}
if info . get ( "surface" ) != "cylinder" or surf . GetType () != GeomAbs_Cylinder :
blockers . append ( "Selected Face is not a cylindrical boss face." )
else :
cyl = surf . Cylinder ()
axis = cyl . Axis ()
axis_direction = _dir_tuple ( axis . Direction ())
axis_range = self . _cylindrical_axis_range (
face_id ,
surf ,
_int_values ( feature . get ( "feature_side_face_ids" )),
)
mid_parameter = ( float ( axis_range [ "v_min" ]) + float ( axis_range [ "v_max" ])) * 0.5
current_center = _point_tuple ( _point_on_axis ( axis . Location (), axis . Direction (), mid_parameter ))
if current_diameter is None or current_diameter <= 1e-9 :
blockers . append ( "Selected cylindrical boss has no stable diameter." )
if feature_guess != "boss/outer-round candidate" :
blockers . append ( "Boss axis move currently supports recognized boss/outer-round candidates only." )
if angular_span is None or angular_span < math . tau * 0.92 :
blockers . append ( "Boss axis move currently supports near-full cylindrical bosses only." )
if current_center is None or axis_direction is None :
blockers . append ( "Could not derive a stable current boss axis center." )
movement = ( 0.0 , 0.0 , 0.0 )
move_distance = 0.0
axial_delta = 0.0
radial_distance = 0.0
if current_center is not None and axis_direction is not None :
movement = _tuple_sub ( target_center , current_center )
move_distance = _vector_length ( movement )
axial_delta = _tuple_dot ( movement , axis_direction )
radial_movement = _tuple_sub ( movement , _tuple_scale ( axis_direction , axial_delta ))
radial_distance = _vector_length ( radial_movement )
diagonal = max ( _shape_diagonal ( self . faces [ face_id ]), current_diameter or 0.0 , 1.0 )
if move_distance <= max ( diagonal * 1e-7 , 1e-6 ):
blockers . append ( "Target boss axis center is almost the same as the current center." )
if current_diameter is not None and current_diameter > 0 :
ratio = move_distance / current_diameter
if ratio > 4.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target boss axis move is more than four diameters; Boolean operations may affect unrelated geometry." )
elif ratio > 1.0 :
risk = _max_risk ( risk , "high" )
warnings . append ( "Target boss axis move is larger than one diameter; verify nearby walls after editing." )
elif ratio > 0.35 :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Target boss axis move is a moderate local relocation." )
if abs ( axial_delta ) > max ( radial_distance * 0.5 , ( current_diameter or 1.0 ) * 0.25 ):
risk = _max_risk ( risk , "high" )
warnings . append ( "The target center includes a large movement along the boss axis; this may change the boss/base overlap." )
if confidence == "low" :
risk = _max_risk ( risk , "medium" )
warnings . append ( "Boss recognition confidence is low." )
tool_plan : dict [ str , object ] = {}
if not blockers and current_diameter is not None :
tool_plan = self . _bounded_boss_resize_tool_plan ( face_id , current_diameter )
start = _tuple_or_none ( tool_plan . get ( "boss_tool_start_point" ))
axis_point = _tuple_or_none ( tool_plan . get ( "boss_tool_axis_point" ))
exact_start = _tuple_or_none ( tool_plan . get ( "boss_tool_exact_start_point" ))
if start is None or axis_point is None :
blockers . append ( "Could not build the bounded boss tool for the moved axis." )
else :
tool_plan [ "target_boss_tool_start_point" ] = _tuple_add ( start , movement )
tool_plan [ "target_boss_tool_axis_point" ] = _tuple_add ( axis_point , movement )
tool_plan [ "target_boss_tool_exact_start_point" ] = (
_tuple_add ( exact_start , movement ) if exact_start is not None else ""
)
tool_plan [ "target_boss_tool_radius" ] = current_diameter * 0.5
if blockers :
status = "blocked"
risk = "blocked"
else :
status = "caution" if risk in { "medium" , "high" } else "ready"
message = "; " . join ( blockers + warnings ) if blockers or warnings else "Cylindrical boss axis move can be attempted."
return {
** tool_plan ,
"status" : status ,
"risk" : risk ,
"message" : message ,
"warnings" : "; " . join ( warnings ),
"blockers" : "; " . join ( blockers ),
"face_id" : face_id ,
"part_id" : info . get ( "part_id" ),
"solid_id" : info . get ( "solid_id" ),
2026-08-06 18:15:14 +08:00
** topology_fields ,
2026-07-29 15:43:28 +08:00
"surface" : info . get ( "surface" ),
"feature_type" : feature . get ( "feature_type" ),
"feature_guess" : feature_guess ,
"confidence" : confidence ,
"current_diameter" : current_diameter ,
"target_diameter" : current_diameter ,
"current_radius" : None if current_diameter is None else current_diameter * 0.5 ,
"target_radius" : None if current_diameter is None else current_diameter * 0.5 ,
"current_axis_center" : current_center ,
"target_axis_center" : target_center ,
"axis_move_vector" : movement ,
"axis_move_distance" : move_distance ,
"axis_move_axial_delta" : axial_delta ,
"axis_move_radial_distance" : radial_distance ,
"axis" : axis_direction ,
"angular_span" : angular_span ,
"same_domain_face_ids" : axis_range . get ( "same_domain_face_ids" , ()),
"same_domain_face_count" : axis_range . get ( "same_domain_face_count" , 0 ),
"same_domain_v_range" : ( axis_range . get ( "v_min" ), axis_range . get ( "v_max" )),
"same_domain_range_source" : axis_range . get ( "range_source" , "" ),
"resize_strategy" : "remove-old-boss-and-fuse-moved-cylinder" ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "移除旧凸台并补新凸台" ,
"edit_semantics" : "先移除当前凸台包络,再按同直径在目标轴心补出新凸台;不整体平移零件。" ,
2026-07-29 15:43:28 +08:00
}
2026-07-27 18:28:26 +08:00
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" ,
2026-07-28 14:05:14 +08:00
"message" : "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。" ,
2026-07-27 18:28:26 +08:00
}
feature = self . feature_info ( face_id )
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-27 18:28:26 +08:00
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 )
2026-08-04 09:35:39 +08:00
readiness = self . _apply_cylindrical_first_level_guard_to_readiness (
readiness ,
topology_fields ,
status_key = "suppress_status" ,
risk_key = "suppress_risk" ,
note_key = "suppress_note" ,
warnings_key = "suppress_warnings" ,
blockers_key = "suppress_blockers" ,
)
2026-07-27 18:28:26 +08:00
fill_plan = self . _bounded_cylinder_fill_plan ( face_id )
2026-07-30 17:54:01 +08:00
fill_plan [ "fill_strategy" ] = "bounded-hole-suppress-fill"
fill_plan [ "fill_note" ] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
2026-07-27 18:28:26 +08:00
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" ),
2026-08-04 18:15:29 +08:00
"axis" : info . get ( "axis" ),
"axis_point" : info . get ( "axis_point" ),
2026-08-13 17:50:20 +08:00
"angular_span" : scoped_info . get ( "angular_span" ),
"same_domain_angular_span" : scoped_info . get ( "same_domain_angular_span" ),
"is_full_cylinder" : scoped_info . get ( "is_full_cylinder" , feature . get ( "is_full_cylinder" )),
2026-07-27 18:28:26 +08:00
"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" ),
2026-07-30 17:54:01 +08:00
"resize_strategy" : "fill-cylindrical-hole-volume" ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "圆柱补料封堵" ,
"edit_semantics" : "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。" ,
2026-07-27 18:28:26 +08:00
** 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" ,
2026-07-29 15:43:28 +08:00
"message" : "当前选中的 Face 不是圆柱面,不能调整盲孔/盲槽深度。" ,
2026-07-27 18:28:26 +08:00
}
feature = self . feature_info ( face_id )
2026-08-04 09:35:39 +08:00
topology_fields = self . _cylindrical_feature_first_level_plan_fields ( face_id )
2026-07-27 18:28:26 +08:00
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" ]
2026-08-04 09:35:39 +08:00
readiness = self . _apply_cylindrical_first_level_guard_to_readiness (
readiness ,
topology_fields ,
status_key = "depth_status" ,
risk_key = "depth_risk" ,
note_key = "depth_note" ,
warnings_key = "depth_warnings" ,
blockers_key = "depth_blockers" ,
)
2026-07-27 18:28:26 +08:00
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" ),
2026-07-30 17:54:01 +08:00
"resize_strategy" : "bounded-blind-depth-cut-or-fill" ,
2026-08-04 09:35:39 +08:00
** topology_fields ,
2026-07-30 17:54:01 +08:00
"edit_strategy_label" : "盲孔/盲槽深度切削或补料" ,
"edit_semantics" : "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。" ,
2026-07-27 18:28:26 +08:00
}
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" ,
2026-07-28 14:05:14 +08:00
"context_message" : "当前选中的 Face 不是圆柱面。" ,
2026-07-27 18:28:26 +08:00
}
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" ,
2026-08-05 15:08:16 +08:00
"context_message" : "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。" ,
2026-07-27 18:28:26 +08:00
}
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" ,
2026-07-28 14:05:14 +08:00
"cutter_note" : "选中Face不是圆柱面" ,
2026-07-27 18:28:26 +08:00
}
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, "
2026-07-28 18:30:58 +08:00
"如果没有同域拆分则退回选中Face范围,减少贯穿整个特征的误切风险。"
2026-07-27 18:28:26 +08:00
),
"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" ,
2026-07-28 14:05:14 +08:00
"fill_note" : "选中Face不是圆柱面" ,
2026-07-27 18:28:26 +08:00
}
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" ,
2026-07-28 14:05:14 +08:00
"boss_tool_note" : "选中Face不是圆柱面" ,
2026-07-27 18:28:26 +08:00
}
cyl = surf . Cylinder ()
old_radius = cyl . Radius ()
new_radius = new_diameter / 2.0
axis_range = self . _cylindrical_axis_range ( face_id , surf )
2026-07-30 17:54:01 +08:00
end_info = self . _cylinder_end_opening_info ( face_id , surf , axis_range )
2026-07-27 18:28:26 +08:00
v_min = float ( axis_range [ "v_min" ])
v_max = float ( axis_range [ "v_max" ])
span = max ( v_max - v_min , 1e-6 )
resize_mode = _resize_mode ( old_radius * 2.0 , new_diameter )
2026-07-30 17:54:01 +08:00
axial_margin = 0.0
start_parameter = v_min
end_parameter = v_max
2026-07-27 18:28:26 +08:00
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 目标半径圆柱重建。"
2026-07-30 17:54:01 +08:00
"补新凸台时使用原凸台轴向范围,避免直径或轴心修改时悄悄改变高度。"
2026-07-27 18:28:26 +08:00
),
"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 ),
2026-07-30 17:54:01 +08:00
** end_info ,
2026-07-27 18:28:26 +08:00
}
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
2026-08-04 18:15:29 +08:00
def _cylindrical_cap_push_pull_direction (
self ,
face_id : int ,
surf : BRepAdaptor_Surface ,
) -> dict [ str , object ] | None :
if face_id < 0 or face_id >= len ( self . faces ):
return None
if surf . GetType () != GeomAbs_Plane :
return None
plane_point = surf . Plane () . Location ()
boundary_edge_ids = self . _face_boundary_edge_ids ( face_id )
adjacent_face_ids = self . _adjacent_face_ids_for_edges ( boundary_edge_ids , face_id )
if not adjacent_face_ids :
return None
candidates : list [ tuple [ float , tuple [ float , float , float ], dict [ str , object ]]] = []
diagonal = _shape_diagonal ( self . shape )
tolerance = min ( max ( diagonal * 1e-7 , 1e-6 ), 1e-3 )
2026-08-13 17:50:20 +08:00
plane_axis = surf . Plane () . Axis () . Direction ()
2026-08-04 18:15:29 +08:00
for adjacent_id in adjacent_face_ids :
if adjacent_id < 0 or adjacent_id >= len ( self . faces ):
continue
try :
side_surf = BRepAdaptor_Surface ( self . faces [ adjacent_id ])
except Exception :
continue
if side_surf . GetType () != GeomAbs_Cylinder :
continue
cylinder = side_surf . Cylinder ()
radius = max ( float ( cylinder . Radius ()), 0.0 )
axis = cylinder . Axis ()
axis_point = axis . Location ()
axis_dir = axis . Direction ()
2026-08-13 17:50:20 +08:00
plane_axis_alignment = abs ( _direction_dot ( plane_axis , axis_dir ))
if plane_axis_alignment < 0.92 :
continue
2026-08-04 18:15:29 +08:00
try :
axis_range = self . _cylindrical_axis_range ( adjacent_id , side_surf )
except Exception :
axis_range = {
"v_min" : min ( float ( side_surf . FirstVParameter ()), float ( side_surf . LastVParameter ())),
"v_max" : max ( float ( side_surf . FirstVParameter ()), float ( side_surf . LastVParameter ())),
}
v_min = float ( axis_range [ "v_min" ])
v_max = float ( axis_range [ "v_max" ])
height = max ( v_max - v_min , 1e-9 )
cap_parameter = _axis_parameter ( axis_point , axis_dir , plane_point )
start_distance = abs ( cap_parameter - v_min )
end_distance = abs ( cap_parameter - v_max )
end_tolerance = max ( height * 0.05 , radius * 0.2 , tolerance * 10.0 , 0.05 )
if start_distance <= end_distance and start_distance <= end_tolerance :
outward = _neg_tuple ( _dir_tuple ( axis_dir ))
end_label = "start"
score = start_distance
elif end_distance <= end_tolerance :
outward = _dir_tuple ( axis_dir )
end_label = "end"
score = end_distance
else :
continue
candidates . append (
(
score ,
outward ,
{
"cap_axis_face_id" : adjacent_id ,
"cap_axis_end" : end_label ,
2026-08-13 17:50:20 +08:00
"cap_plane_axis_alignment" : plane_axis_alignment ,
2026-08-04 18:15:29 +08:00
"cap_axis_parameter" : cap_parameter ,
"cap_axis_start_parameter" : v_min ,
"cap_axis_end_parameter" : v_max ,
},
)
)
if not candidates :
return None
candidates . sort ( key = lambda item : item [ 0 ])
_score , outward , details = candidates [ 0 ]
for _other_score , other_outward , _other_details in candidates [ 1 :]:
if _tuple_dot ( outward , other_outward ) < 0.92 :
return None
return {
"outward_direction" : outward ,
"inward_direction" : _neg_tuple ( outward ),
"plus_side_state" : "cylindrical-cap-axis" ,
"minus_side_state" : "cylindrical-cap-axis" ,
"confidence" : "high" ,
"note" : "cylindrical cap direction inferred from adjacent cylinder axis" ,
"push_pull_outward_direction" : outward ,
"push_pull_inward_direction" : _neg_tuple ( outward ),
"push_pull_plus_side" : "cylindrical-cap-axis" ,
"push_pull_minus_side" : "cylindrical-cap-axis" ,
"push_pull_confidence" : "high" ,
"push_pull_note" : "cylindrical cap direction inferred from adjacent cylinder axis" ,
** details ,
}
2026-07-27 18:28:26 +08:00
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 )
2026-08-04 18:15:29 +08:00
cap_direction = self . _cylindrical_cap_push_pull_direction ( face_id , surf )
if cap_direction is not None :
return cap_direction
2026-07-27 18:28:26 +08:00
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