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

3583 lines
179 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import math
from pathlib import Path
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.GeomAbs import (
GeomAbs_BSplineCurve,
GeomAbs_BSplineSurface,
GeomAbs_BezierCurve,
GeomAbs_BezierSurface,
GeomAbs_Circle,
GeomAbs_Cone,
GeomAbs_Cylinder,
GeomAbs_Ellipse,
GeomAbs_Hyperbola,
GeomAbs_Line,
GeomAbs_OffsetSurface,
GeomAbs_OtherCurve,
GeomAbs_OtherSurface,
GeomAbs_Parabola,
GeomAbs_Plane,
GeomAbs_Sphere,
GeomAbs_SurfaceOfExtrusion,
GeomAbs_SurfaceOfRevolution,
GeomAbs_Torus,
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
)
from OCC.Core.TopExp import TopExp_Explorer, topexp
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .recognition_priority import external_relation_score_bonus, feature_recognition_sort_key
class FeatureMixin:
def _external_candidate_relation_fields(self, candidate: dict[str, object]) -> dict[str, object]:
face_ids = (
_int_values(candidate.get("feature_highlight_face_ids"))
or _int_values(candidate.get("feature_face_ids"))
or _int_values(candidate.get("face_region_ids"))
or _int_values(candidate.get("face_id"))
)
if not face_ids and str(candidate.get("target_kind") or "") == "face":
face_ids = _int_values(candidate.get("target_id"))
relation_summary_by_type: dict[str, int] = {}
relation_face_count = 0
summary_getter = getattr(self, "_asitus_face_summary_fields", None)
if not callable(summary_getter):
return {}
for face_id in sorted(set(face_ids)):
try:
fields = summary_getter(face_id)
except Exception:
continue
if not isinstance(fields, dict):
continue
relation_count = int(fields.get("asitus_geometric_relation_count") or 0)
if relation_count <= 0:
continue
relation_face_count += 1
for relation_type in fields.get("asitus_geometric_relation_types", ()) or ():
relation_key = str(relation_type or "").strip()
if relation_key:
relation_summary_by_type[relation_key] = relation_summary_by_type.get(relation_key, 0) + 1
if not relation_summary_by_type:
return {}
relation_summary = ", ".join(
f"{key}:{value}" for key, value in sorted(relation_summary_by_type.items())
)
result = {
"external_recognition_relation_source": "analysis-situs",
"external_recognition_relation_summary": relation_summary,
"external_recognition_relation_types": tuple(sorted(relation_summary_by_type)),
"external_recognition_relation_count": sum(relation_summary_by_type.values()),
"external_recognition_relation_face_count": relation_face_count,
}
hint_getter = getattr(self, "_asitus_cylindrical_feature_hint_fields", None)
if callable(hint_getter) and len(set(face_ids)) == 1:
try:
hint_face_id = int(face_ids[0])
hint_context = {**candidate, **result}
cached_feature_getter = getattr(self, "cached_feature_info", None)
cached_feature = cached_feature_getter(hint_face_id) if callable(cached_feature_getter) else None
if isinstance(cached_feature, dict):
hint_context = {**cached_feature, **hint_context}
result.update(hint_getter(hint_face_id, hint_context))
except Exception:
pass
result["recognition_external_relation_score_bonus"] = external_relation_score_bonus(result)
return result
def _with_external_candidate_relation_support(self, candidate: dict[str, object]) -> dict[str, object]:
result = dict(candidate)
fields = self._external_candidate_relation_fields(result)
if not fields:
return result
result.update(fields)
bonus = int(result.get("recognition_external_relation_score_bonus") or 0)
confidence = str(result.get("confidence") or "")
if bonus >= 8 and confidence in {"", "pending", "unchecked", "none", "low"}:
result["confidence"] = "medium"
return result
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}",
}
def _cylindrical_feature_first_level_plan_fields(self, face_id: int) -> dict[str, object]:
fact_fields = self._first_level_fact_plan_fields(face_id, "cylindrical-feature")
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,
**fact_fields,
}
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,
),
}
fields.update(fact_fields)
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
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]]:
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]]
per_type_limit = max(1, limit // 5)
candidates: list[dict[str, object]] = []
diameter_count = 0
slot_width_count = 0
slot_depth_count = 0
slot_arc_length_count = 0
slot_angular_span_count = 0
boss_diameter_count = 0
boss_height_count = 0
boss_axis_count = 0
depth_count = 0
suppress_count = 0
existing_fillet_count = 0
existing_chamfer_count = 0
depth_limit = max(2, min(per_type_limit, limit // 12))
suppress_limit = max(2, min(per_type_limit, limit // 12))
existing_fillet_limit = max(2, min(per_type_limit, limit // 12))
existing_chamfer_limit = max(2, min(per_type_limit, limit // 12))
slot_width_limit = max(2, min(per_type_limit, limit // 10))
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))
cylinder_scan_limit = max(per_type_limit * 4, 24)
for item in self.cylindrical_feature_candidates(
limit=cylinder_scan_limit,
include_end_info=True,
max_scan_faces=normalized_max_faces,
progress_callback=progress_callback,
):
feature_guess = str(item["feature_guess"])
if existing_fillet_count < existing_fillet_limit and feature_guess == "round/fillet candidate":
feature = self.feature_info(int(item["face_id"]))
existing_fillet_status = str(feature.get("existing_fillet_status") or "candidate")
if existing_fillet_status == "blocked":
continue
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
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
support_note = (
f"支撑 Face: {support_face_ids}。"
if support_face_ids
else "暂未识别出稳定支撑 Face。"
)
fillet_note = (
f"这是简单等半径圆角链候选,链上 Face: {chain_face_ids};点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试一起 defeature 后重新倒圆。"
if is_same_radius_chain
else (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试 defeature 后重新倒圆。"
)
)
candidates.append(
{
"operation_key": "inspect_existing_fillet",
"operation": "修改已有圆角链半径/弧长" if is_same_radius_chain else "修改已有圆角半径/弧长",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": feature_guess,
"current_value": feature.get("existing_fillet_radius_estimate", item["radius"]),
"current_value_label": "radius",
"status": "caution",
"risk": "high" if is_same_radius_chain or len(support_face_ids) < 2 else "medium",
"confidence": item["confidence"],
"note": f"{fillet_note} {support_note}",
}
)
existing_fillet_count += 1
if diameter_count < per_type_limit and feature_guess not in {
"round/fillet candidate",
"boss/outer-round candidate",
} and str(item.get("resize_status") or "") != "blocked":
candidates.append(
{
"operation_key": "resize_cylinder",
"operation": "调整圆柱孔径",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": feature_guess,
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": item["resize_status"],
"risk": item["resize_risk"],
"confidence": item["confidence"],
"note": item["resize_note"],
}
)
diameter_count += 1
if (
(
slot_width_count < slot_width_limit
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
)
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"]))
if str(feature.get("slot_status") or "") != "candidate":
continue
slot_width = feature.get("slot_chord_width_estimate")
if slot_width_count < slot_width_limit and isinstance(slot_width, (int, float)) and float(slot_width) > 0:
candidates.append(
{
"operation_key": "resize_slot_width",
"operation": "调整槽/半孔宽度",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": feature_guess,
"current_value": float(slot_width),
"current_value_label": "slot_width",
"status": item["resize_status"],
"risk": item["resize_risk"],
"confidence": item["confidence"],
"note": (
"这是槽/半孔候选;点击后会选中该 face,并把槽/半孔宽度输入框预填为参考目标值。"
"执行时会把槽宽换算为圆柱直径后重建。"
),
}
)
slot_width_count += 1
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
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
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,可在当前选中对象里修改凸台高度。"
"执行时会拉伸/切除识别到的端盖 Face。"
),
}
)
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
suppress_info = _cylinder_suppress_readiness(item)
if suppress_count < suppress_limit and suppress_info["suppress_status"] != "blocked":
candidates.append(
{
"operation_key": "suppress_cylinder",
"operation": "封堵圆柱孔",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": suppress_info["suppress_status"],
"risk": suppress_info["suppress_risk"],
"confidence": item["confidence"],
"note": suppress_info["suppress_note"],
}
)
suppress_count += 1
depth_info = _cylinder_depth_readiness(item)
if depth_count < depth_limit and depth_info["depth_status"] != "blocked":
feature = self.feature_info(int(item["face_id"]))
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
if not bottom_face_ids:
continue
depth_context = self._blind_cylindrical_depth_context(
int(item["face_id"]),
item,
feature,
float(item["hole_depth_estimate"]),
)
current_depth = float(depth_context.get("depth_current_depth", item["hole_depth_estimate"]))
candidates.append(
{
"operation_key": "resize_depth",
"operation": "调整盲孔/盲槽深度",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": current_depth,
"current_value_label": "depth",
"status": depth_info["depth_status"],
"risk": depth_info["depth_risk"],
"confidence": item["confidence"],
"note": (
f"{depth_info['depth_note']} "
f"底面: {bottom_face_ids}; "
f"来源: {feature.get('feature_bottom_detection')}; "
f"深度来源: {depth_context.get('depth_current_depth_source', 'cylinder-v-range')}。"
),
}
)
depth_count += 1
if (
diameter_count >= per_type_limit
and boss_diameter_count >= per_type_limit
and suppress_count >= suppress_limit
and depth_count >= depth_limit
and slot_width_count >= slot_width_limit
and existing_fillet_count >= existing_fillet_limit
):
break
plane_count = 0
shell_thickness_count = 0
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
shell_candidate_rows: list[dict[str, object]] = []
shell_scan_pool_limit = max(shell_thickness_limit * 4, 12)
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if (
plane_count >= per_type_limit
and len(shell_candidate_rows) >= shell_scan_pool_limit
and existing_chamfer_count >= existing_chamfer_limit
):
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
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
if plane_count < per_type_limit:
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
confidence = str(direction_info["confidence"])
risk = "low" if confidence == "high" else "medium"
status = "ready" if confidence == "high" else "caution"
note = str(direction_info["note"])
else:
confidence = "pending"
risk = "medium"
status = "caution"
note = "快速扫描:拉伸/切除方向会在选中Face或执行编辑前再详细判断。"
candidates.append(
{
"operation_key": "push_pull_plane",
"operation": "拉伸/切除平面",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "planar push/pull candidate",
"current_value": props.Mass(),
"current_value_label": "area",
"status": status,
"risk": risk,
"confidence": confidence,
"note": note,
}
)
plane_count += 1
if len(shell_candidate_rows) < shell_scan_pool_limit:
feature = feature or self.feature_info(face_id)
if feature.get("shell_region_status") == "candidate":
shell_confidence = str(feature.get("shell_confidence", "low"))
shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high"
shell_note = (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
)
if feature.get("open_shell_context_status") == "limited":
shell_note = (
f"{shell_note} 已识别为开口薄壁壳体上下文;"
"完整抽壳/开口面编辑暂未实现,当前只开放壳体厚度、平面推拉或整体缩放。"
)
shell_candidate_rows.append(
{
"operation_key": "resize_shell_thickness",
"operation": "调整壳体厚度",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "thin-wall opposite plane candidate",
"current_value": feature.get("shell_thickness_estimate"),
"current_value_label": "shell_thickness",
"status": "ready" if shell_confidence == "high" else "caution",
"risk": shell_risk,
"confidence": shell_confidence,
"note": shell_note,
"open_shell_context_status": feature.get("open_shell_context_status", ""),
}
)
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
fillet_edge_count = 0
chamfer_edge_count = 0
edge_length_count = 0
circle_edge_radius_count = 0
ellipse_edge_major_radius_count = 0
ellipse_edge_minor_radius_count = 0
edge_type_limit = max(1, per_type_limit // 3)
edge_scan_limit = len(self.edges) if normalized_max_edges is None else min(len(self.edges), normalized_max_edges)
for edge_id, edge in enumerate(self.edges[:edge_scan_limit]):
if progress_callback is not None and edge_id % 80 == 0:
progress_callback()
if (
fillet_edge_count >= edge_type_limit
and chamfer_edge_count >= edge_type_limit
and edge_length_count >= edge_type_limit
and circle_edge_radius_count >= edge_type_limit
):
break
curve = BRepAdaptor_Curve(edge)
is_line_edge = curve.GetType() == GeomAbs_Line
is_circle_edge = curve.GetType() == GeomAbs_Circle
is_ellipse_edge = curve.GetType() == GeomAbs_Ellipse
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
length = props.Mass()
if length <= 1e-9:
continue
solid_id = self._edge_solid_id(edge_id)
curve_label = CURVE_TYPES.get(curve.GetType(), f"type {curve.GetType()}")
if is_line_edge and fillet_edge_count < edge_type_limit:
candidates.append(
{
"operation_key": "fillet_edge",
"operation": "给Edge添加圆角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": "linear edge fillet candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:添加圆角半径会在执行前根据边长和相邻面再详细判断。",
}
)
fillet_edge_count += 1
if is_line_edge and chamfer_edge_count < edge_type_limit:
candidates.append(
{
"operation_key": "chamfer_edge",
"operation": "给Edge添加倒角",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": "linear edge chamfer candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:倒角距离会在执行前根据边长和相邻面再详细判断。",
}
)
chamfer_edge_count += 1
if (is_line_edge or is_circle_edge) and edge_length_count < edge_type_limit:
candidates.append(
{
"operation_key": "resize_edge_length",
"operation": "修改Edge长度",
"target_kind": "edge",
"target_id": edge_id,
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"solid_id": solid_id,
"surface": "edge",
"feature_guess": f"{curve_label} edge length candidate",
"current_value": length,
"current_value_label": "length",
"status": "caution",
"risk": "medium",
"confidence": "pending",
"note": "快速扫描:直线边优先局部形变或端面移动;圆边优先换算相邻圆柱直径。",
}
)
edge_length_count += 1
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
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
candidates = [self._with_external_candidate_relation_support(candidate) for candidate in candidates]
for candidate in candidates:
candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0]
candidates.sort(key=feature_recognition_sort_key)
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]
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]]:
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]]
candidates: list[dict[str, object]] = []
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
v_span = abs(surf.LastVParameter() - surf.FirstVParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
height_estimate = props.Mass() / swept_area
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
classification = self._classify_cylindrical_face(face_id, surf)
candidate = {
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"radius": radius,
"diameter": radius * 2.0,
"axis": _dir_tuple(cyl.Axis().Direction()),
"area": props.Mass(),
"angular_span": u_span,
"height_estimate": height_estimate,
"param_height": v_span,
"boundary_edges": boundary_edges,
"feature_guess": classification["feature_guess"],
"material_toward_axis": classification["toward_axis"],
"material_away_axis": classification["away_axis"],
"material_vote_summary": classification["vote_summary"],
"material_sample_count": classification["sample_count"],
"confidence": classification["confidence"],
"note": classification["note"],
}
if include_end_info:
candidate.update(self._cylinder_end_opening_info(face_id, surf))
candidate.update(_cylinder_resize_readiness(candidate))
candidate.update(_cylinder_boss_resize_readiness(candidate))
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",
"analysis_situs_feature_hint_status",
"analysis_situs_feature_hint_preferred",
"analysis_situs_feature_hint_label",
"analysis_situs_feature_hint_score",
"analysis_situs_feature_hint_summary",
"analysis_situs_feature_hint_related_face_ids",
"analysis_situs_slot_hint_score",
"analysis_situs_boss_hint_score",
"analysis_situs_fillet_hint_score",
):
if feature.get(key) not in {None, ""}:
candidate[key] = feature.get(key)
except Exception:
pass
candidates.append(self._with_external_candidate_relation_support(candidate))
if len(candidates) >= limit:
break
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]
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是圆柱面,不能执行圆柱切削。",
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
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,
}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"))
)
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"])
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
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)
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",
)
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 {}
edit_semantics = (
"缩小孔/槽直径:先用同轴圆柱补料封住旧孔壁,再按目标直径同轴重切;保持当前轴线和估算高度。"
if resize_mode == "shrink"
else "扩大孔/槽直径:沿当前圆柱轴线用有限长度圆柱 cutter 切到目标直径;保持当前轴线和估算高度。"
)
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"),
"resize_strategy": "same-axis-bounded-cylinder-recut",
"edit_strategy_label": "同轴圆柱重切",
"edit_semantics": edit_semantics,
**topology_fields,
**cutter_plan,
**fill_plan,
"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")),
}
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 = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
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"))
selected_angular_span = _float_or_none(info.get("angular_span"))
angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or selected_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,
**topology_fields,
"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,
"selected_angular_span": selected_angular_span,
"same_domain_angular_span": feature.get("same_domain_angular_span") or info.get("same_domain_angular_span"),
"is_full_cylinder": feature.get("is_full_cylinder", info.get("is_full_cylinder")),
"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", ""),
"supports_isolation": True,
"resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder",
"edit_strategy_label": "填旧孔并切新孔",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标位置切出新孔;这会改变孔的位置,不会整体平移零件。",
}
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 = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
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":
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
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",
"edit_strategy_label": "局部扇形槽重建",
"edit_semantics": "保持当前槽/半孔圆弧角度,把目标宽度、深度或圆弧长度换算成圆柱直径,再填旧槽并切出新的局部扇形槽。",
"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"),
**topology_fields,
}
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,
)
edit_strategy_label = "局部扇形槽重建"
edit_semantics = "保持当前槽/半孔圆弧角度,把目标值换算成圆柱直径,再填旧槽并切出新的局部扇形槽。"
if paired_slot_plan:
edit_strategy_label = "配对长圆槽重建"
edit_semantics = "识别到长圆槽另一端后,会填充旧长圆槽包络,再按同一槽中心线和目标宽度重切完整长圆槽。"
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,
"edit_strategy_label": edit_strategy_label,
"edit_semantics": edit_semantics,
"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 = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
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":
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
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,
**topology_fields,
"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",
"edit_strategy_label": "局部扇形槽角度重建",
"edit_semantics": "保持当前圆柱半径和轴线,围绕当前角度中心填旧扇形槽并切出目标圆弧角度的新扇形槽。",
"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 = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
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":
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
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}")
resize_strategy = "fill-old-slot-sector-and-cut-moved-sector"
edit_strategy_label = "填旧槽并切新槽"
edit_semantics = "先填补当前槽/半孔扇形区域,再按同宽度、同角度在目标轴心切出新槽;不整体平移零件。"
if pair_plan:
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.")
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,
**pair_plan,
**topology_fields,
"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", ""),
"resize_strategy": resize_strategy,
"edit_strategy_label": edit_strategy_label,
"edit_semantics": edit_semantics,
}
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)
topology_fields = self._cylindrical_feature_first_level_plan_fields(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_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"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
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.")
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')}."))
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,
**topology_fields,
"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",
"edit_strategy_label": "长圆槽总长度重建",
"edit_semantics": "保持槽宽不变,围绕长圆槽中心线对两端中心距做对称调整,然后填旧槽并重切目标总长度的长圆槽。",
"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,
}
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
def cylindrical_boss_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是圆柱面,不能调整圆柱凸台直径。",
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
_int_values(feature.get("feature_side_face_ids")),
)
scoped_info = dict(info)
scoped_info["height_estimate"] = axis_range["span"]
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
readiness = _cylinder_boss_resize_readiness(scoped_info, new_diameter)
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",
)
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)
edit_semantics = (
"扩大凸台直径:按当前凸台轴线和估算高度生成目标圆柱补料体,并与所属特征做局部 Fuse。"
if resize_mode == "enlarge"
else "缩小凸台直径:先移除旧凸台包络,再补回目标直径圆柱;这是局部重建,不是缩放整个零件。"
)
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"],
**topology_fields,
"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"),
"resize_strategy": "bounded-cylindrical-boss-envelope-rebuild",
"edit_strategy_label": "凸台包络重建",
"edit_semantics": edit_semantics,
**tool_plan,
}
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 = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
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"
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
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"),
**topology_fields,
"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",
"edit_strategy_label": "移除旧凸台并补新凸台",
"edit_semantics": "先移除当前凸台包络,再按同直径在目标轴心补出新凸台;不整体平移零件。",
}
def cylindrical_suppress_plan(self, face_id: int) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
_int_values(feature.get("feature_side_face_ids")),
)
scoped_info = dict(info)
scoped_info["height_estimate"] = axis_range["span"]
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_suppress_readiness(scoped_info)
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",
)
fill_plan = self._bounded_cylinder_fill_plan(face_id)
fill_plan["fill_strategy"] = "bounded-hole-suppress-fill"
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
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"),
"axis": info.get("axis"),
"axis_point": info.get("axis_point"),
"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")),
"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"),
"resize_strategy": "fill-cylindrical-hole-volume",
**topology_fields,
"edit_strategy_label": "圆柱补料封堵",
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
**fill_plan,
}
def cylindrical_depth_plan(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 Face 不是圆柱面,不能调整盲孔/盲槽深度。",
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
context = self._blind_cylindrical_depth_context(
face_id,
info,
feature,
target_depth,
bottom_face_id=bottom_face_id,
)
depth_info = dict(info)
if context.get("context_status") == "ready" and isinstance(context.get("depth_current_depth"), (int, float)):
depth_info["hole_depth_estimate"] = float(context["depth_current_depth"])
depth_info["manual_bottom_face_used"] = bool(context.get("manual_bottom_face_used"))
readiness = _cylinder_depth_readiness(depth_info, target_depth)
if context.get("context_status") == "blocked":
readiness = dict(readiness)
readiness["depth_status"] = "blocked"
readiness["depth_risk"] = "blocked"
readiness["depth_blockers"] = _join_nonempty(
readiness.get("depth_blockers"),
context.get("context_message"),
)
readiness["depth_note"] = readiness["depth_blockers"]
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",
)
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"),
"resize_strategy": "bounded-blind-depth-cut-or-fill",
**topology_fields,
"edit_strategy_label": "盲孔/盲槽深度切削或补料",
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
}
plan.update(context)
return plan
def _blind_cylindrical_depth_context(
self,
face_id: int,
info: dict[str, object],
feature: dict[str, object],
target_depth: float,
bottom_face_id: int | None = None,
) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"context_status": "blocked",
"context_message": "当前选中的 Face 不是圆柱面。",
}
manual_bottom_face_id = None
manual_bottom_face_used = bottom_face_id is not None
if bottom_face_id is not None:
manual_bottom_face_id = int(bottom_face_id)
if manual_bottom_face_id < 0 or manual_bottom_face_id >= len(self.faces):
return {
"context_status": "blocked",
"context_message": f"手动底面 Face ID {manual_bottom_face_id} 不存在。",
}
if manual_bottom_face_id == face_id:
return {
"context_status": "blocked",
"context_message": "手动底面不能和当前圆柱侧壁使用同一个 Face ID。",
}
source_solid_id = self.face_solid_ids[face_id]
bottom_solid_id = self.face_solid_ids[manual_bottom_face_id]
if source_solid_id >= 0 and bottom_solid_id >= 0 and source_solid_id != bottom_solid_id:
return {
"context_status": "blocked",
"context_message": "手动底面 Face 与当前圆柱面不属于同一个 solid,已阻止孔深修改。",
}
bottom_face_ids = (manual_bottom_face_id,)
manual_bottom_face_note = "使用用户手动指定的底面 Face ID 计算孔深。"
else:
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
manual_bottom_face_note = ""
if not bottom_face_ids:
return {
"context_status": "blocked",
"context_message": "当前版本的孔深调整需要疑似底面;如果自动识别失败,请手动填写底面 Face ID。",
}
cyl = surf.Cylinder()
radius = float(cyl.Radius())
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
axis_range = self._cylindrical_axis_range(
face_id,
surf,
_int_values(feature.get("feature_side_face_ids")),
)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
start_open = info.get("start_end_open") is True
end_open = info.get("end_end_open") is True
open_direction_source = "axis-end-material-sampling"
if start_open != end_open:
open_parameter = v_min
nominal_bottom_parameter = v_max
direction_sign = 1.0
if not start_open:
open_parameter = v_max
nominal_bottom_parameter = v_min
direction_sign = -1.0
bottom_parameter = self._bottom_face_axis_parameter(
bottom_face_ids,
axis_point,
axis_dir,
nominal_bottom_parameter,
)
current_depth_source = "bottom-face-axis-parameter" if bottom_parameter is not None else "cylinder-v-range"
if bottom_parameter is None:
bottom_parameter = nominal_bottom_parameter
elif manual_bottom_face_used:
bottom_parameter = self._bottom_face_axis_parameter(
bottom_face_ids,
axis_point,
axis_dir,
(v_min + v_max) * 0.5,
)
if bottom_parameter is None:
return {
"context_status": "blocked",
"context_message": "手动底面无法投影到当前圆柱轴线上,不能计算孔深。",
}
if abs(bottom_parameter - v_min) <= abs(bottom_parameter - v_max):
open_parameter = v_max
nominal_bottom_parameter = v_min
direction_sign = -1.0
else:
open_parameter = v_min
nominal_bottom_parameter = v_max
direction_sign = 1.0
current_depth_source = "manual-bottom-face-axis-parameter"
open_direction_source = "manual-bottom-face-nearest-axis-end"
else:
return {
"context_status": "blocked",
"context_message": "圆柱端部开口方向不唯一,不能可靠判断孔深方向。",
}
current_depth = max(abs(bottom_parameter - open_parameter), 1e-9)
target_bottom_parameter = open_parameter + direction_sign * target_depth
delta_depth = target_depth - current_depth
depth_mode = "deepen" if delta_depth > 0 else "shallow"
tool_direction = (
axis_dir.X() * direction_sign,
axis_dir.Y() * direction_sign,
axis_dir.Z() * direction_sign,
)
open_margin = min(max(radius * 0.05, abs(delta_depth) * 0.2, 0.02), max(current_depth * 0.1, 0.2))
bottom_overlap = min(max(radius * 0.02, abs(delta_depth) * 0.05, 0.01), max(current_depth * 0.03, 0.08))
if depth_mode == "deepen":
start_parameter = open_parameter - direction_sign * open_margin
end_parameter = target_bottom_parameter
tool_height = target_depth + open_margin
tool_role = "cutter"
tool_strategy = "bounded-blind-depth-cut"
tool_radius = radius
radius_overlap = 0.0
tool_note = "加深盲孔:沿识别出的开口到疑似底面方向,使用有限长度圆柱 cutter 延伸切削。"
else:
start_parameter = target_bottom_parameter
end_parameter = bottom_parameter + direction_sign * bottom_overlap
tool_height = current_depth - target_depth + bottom_overlap
tool_role = "fill"
tool_strategy = "bounded-bottom-fill"
radius_overlap = min(max(radius * 0.001, 0.001), 0.05)
tool_radius = radius + radius_overlap
tool_note = "变浅盲孔:从目标新底面到旧底面方向补料,并让补料半径略有重叠以便和原实体合并。"
return {
"context_status": "ready",
"depth_tool_strategy": tool_strategy,
"depth_tool_role": tool_role,
"depth_tool_note": tool_note,
"depth_axis_direction": tool_direction,
"depth_open_parameter": open_parameter,
"depth_bottom_parameter": bottom_parameter,
"depth_nominal_bottom_parameter": nominal_bottom_parameter,
"depth_bottom_parameter_source": current_depth_source,
"depth_current_depth": current_depth,
"depth_current_depth_source": current_depth_source,
"depth_open_direction_source": open_direction_source,
"depth_target_bottom_parameter": target_bottom_parameter,
"depth_tool_start_parameter": start_parameter,
"depth_tool_end_parameter": end_parameter,
"depth_tool_height": max(tool_height, 1e-6),
"depth_tool_radius": tool_radius,
"depth_tool_radius_overlap": radius_overlap,
"depth_scope_face_ids": axis_range["same_domain_face_ids"],
"depth_scope_face_count": axis_range["same_domain_face_count"],
"depth_range_source": axis_range["range_source"],
"depth_open_point": _point_tuple(_point_on_axis(axis_point, axis_dir, open_parameter)),
"depth_current_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, bottom_parameter)),
"depth_target_bottom_point": _point_tuple(_point_on_axis(axis_point, axis_dir, target_bottom_parameter)),
"depth_tool_start_point": _point_tuple(_point_on_axis(axis_point, axis_dir, start_parameter)),
"feature_bottom_face_ids": bottom_face_ids,
"manual_bottom_face_id": manual_bottom_face_id if manual_bottom_face_used else "",
"manual_bottom_face_used": manual_bottom_face_used,
"manual_bottom_face_note": manual_bottom_face_note,
}
def _bounded_cylinder_cutter_plan(
self,
face_id: int,
new_diameter: float,
feature: dict[str, object] | None = None,
) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"cutter_strategy": "unavailable",
"cutter_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
feature = feature or self.feature_info(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
surf,
_int_values(feature.get("feature_side_face_ids")),
)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
span = max(v_max - v_min, 0.0)
end_info = self._cylinder_end_opening_info(face_id, surf, axis_range)
base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2))
closed_margin = min(base_margin, max(span * 0.005, 0.02))
start_margin = base_margin if end_info["start_end_open"] else closed_margin
end_margin = base_margin if end_info["end_end_open"] else closed_margin
bottom_face_ids = tuple(feature.get("feature_bottom_face_ids", ()))
opening_face_ids = tuple(feature.get("feature_opening_face_ids", ()))
bottom_protection = bool(bottom_face_ids)
if bottom_protection:
bottom_note = "检测到疑似盲孔底面,封闭端 cutter 只保留很小余量,避免明显加深孔。"
else:
bottom_note = "未检测到明确疑似底面,按端部开口/封闭采样设置 cutter 余量。"
start_parameter = v_min - start_margin
end_parameter = v_max + end_margin
height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = gp_Pnt(
axis_point.X() + direction.X() * start_parameter,
axis_point.Y() + direction.Y() * start_parameter,
axis_point.Z() + direction.Z() * start_parameter,
)
return {
"cutter_strategy": "bounded-to-selected-cylinder-v-range",
"cutter_note": (
"有限长度切削:优先按同域圆柱侧壁整体 V 范围生成 cutter"
"如果没有同域拆分则退回选中Face范围,减少贯穿整个特征的误切风险。"
),
"cutter_scope_face_ids": axis_range["same_domain_face_ids"],
"cutter_scope_face_count": axis_range["same_domain_face_count"],
"cutter_range_source": axis_range["range_source"],
"cutter_start_parameter": start_parameter,
"cutter_end_parameter": end_parameter,
"cutter_height": height,
"cutter_margin": base_margin,
"cutter_start_margin": start_margin,
"cutter_end_margin": end_margin,
"cutter_radius": new_radius,
"cutter_axis_point": _point_tuple(axis_point),
"cutter_axis_direction": _dir_tuple(direction),
"cutter_start_point": _point_tuple(start),
"cutter_bottom_protection": bottom_protection,
"cutter_protected_bottom_face_ids": bottom_face_ids,
"cutter_opening_face_ids": opening_face_ids,
"cutter_bottom_note": bottom_note,
**end_info,
}
def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"fill_strategy": "unavailable",
"fill_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
radius = cyl.Radius()
axis_range = self._cylindrical_axis_range(face_id, surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
height = max(v_max - v_min, 1e-6)
overlap = min(max(radius * 0.001, 0.001), 0.05)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = _point_on_axis(axis_point, direction, v_min)
return {
"fill_strategy": "bounded-fill-then-recut",
"fill_note": (
"缩小孔径实验策略:先在同域圆柱侧壁范围内补料,再按目标直径重切。"
"补料不向开口端外伸。"
),
"fill_scope_face_ids": axis_range["same_domain_face_ids"],
"fill_scope_face_count": axis_range["same_domain_face_count"],
"fill_range_source": axis_range["range_source"],
"fill_start_parameter": v_min,
"fill_end_parameter": v_max,
"fill_height": height,
"fill_radius": radius + overlap,
"fill_radius_overlap": overlap,
"fill_start_point": _point_tuple(start),
}
def _bounded_boss_resize_tool_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"boss_tool_strategy": "unavailable",
"boss_tool_note": "选中Face不是圆柱面",
}
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
axis_range = self._cylindrical_axis_range(face_id, surf)
end_info = self._cylinder_end_opening_info(face_id, surf, axis_range)
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)
axial_margin = 0.0
start_parameter = v_min
end_parameter = v_max
radial_overlap = min(max(old_radius * 0.001, 0.001), 0.05)
height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = _point_on_axis(axis_point, direction, start_parameter)
exact_start = _point_on_axis(axis_point, direction, v_min)
return {
"boss_tool_strategy": "bounded-cylinder-fuse" if resize_mode == "enlarge" else "remove-envelope-then-fuse-target-cylinder",
"boss_tool_note": (
"扩大凸台会在同域圆柱侧壁整体 V 范围内生成目标半径圆柱并 Fuse;"
"缩小凸台会先用旧半径包络体移除原凸台范围,再 Fuse 目标半径圆柱重建。"
"补新凸台时使用原凸台轴向范围,避免直径或轴心修改时悄悄改变高度。"
),
"boss_tool_scope_face_ids": axis_range["same_domain_face_ids"],
"boss_tool_scope_face_count": axis_range["same_domain_face_count"],
"boss_tool_range_source": axis_range["range_source"],
"boss_tool_start_parameter": start_parameter,
"boss_tool_end_parameter": end_parameter,
"boss_tool_height": height,
"boss_tool_axial_margin": axial_margin,
"boss_tool_radius": new_radius,
"boss_tool_old_radius": old_radius,
"boss_tool_outer_radius": old_radius + radial_overlap if resize_mode == "shrink" else new_radius,
"boss_tool_inner_radius": new_radius if resize_mode == "shrink" else "",
"boss_tool_radial_overlap": radial_overlap if resize_mode == "shrink" else "",
"boss_tool_axis_point": _point_tuple(axis_point),
"boss_tool_axis_direction": _dir_tuple(direction),
"boss_tool_start_point": _point_tuple(start),
"boss_tool_exact_start_point": _point_tuple(exact_start),
"boss_tool_exact_height": max(v_max - v_min, 1e-6),
**end_info,
}
def _cylinder_end_opening_info(
self,
face_id: int,
surf: BRepAdaptor_Surface,
axis_range: dict[str, object] | None = None,
) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
fallback = {
"cylinder_end_type": "unknown",
"hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()),
"start_end_state": "unknown",
"end_end_state": "unknown",
"start_end_open": False,
"end_end_open": False,
"open_end_count": 0,
"closed_end_count": 0,
"end_sample_offset": "",
"end_sample_note": "no owning solid was found",
}
if solid_id < 0 or solid_id >= len(self.solids):
return fallback
solid = self.solids[solid_id][1]
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
direction = axis.Direction()
axis_range = axis_range or self._cylindrical_axis_range(face_id, surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
span = max(v_max - v_min, 0.0)
radius = cyl.Radius()
offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2))
start_probe = _point_on_axis(axis_point, direction, v_min - offset)
end_probe = _point_on_axis(axis_point, direction, v_max + offset)
start_state = _solid_state(solid, start_probe)
end_state = _solid_state(solid, end_probe)
start_open = start_state == "outside"
end_open = end_state == "outside"
start_closed = start_state == "inside"
end_closed = end_state == "inside"
open_count = int(start_open) + int(end_open)
closed_count = int(start_closed) + int(end_closed)
if open_count == 2:
end_type = "through/open-ended"
note = "both axis-end probes are outside material"
elif open_count == 1 and closed_count == 1:
end_type = "blind"
note = "one axis-end probe is outside material and the other is inside material"
elif closed_count == 2:
end_type = "closed/internal"
note = "both axis-end probes are inside material"
else:
end_type = "unclear"
note = "axis-end probes did not produce a clear open/closed pattern"
return {
"cylinder_end_type": end_type,
"hole_depth_estimate": span,
"start_end_state": start_state,
"end_end_state": end_state,
"start_end_open": start_open,
"end_end_open": end_open,
"open_end_count": open_count,
"closed_end_count": closed_count,
"end_sample_offset": offset,
"end_sample_note": note,
"end_sample_range_source": axis_range["range_source"],
"end_sample_scope_face_ids": axis_range["same_domain_face_ids"],
"end_sample_scope_face_count": axis_range["same_domain_face_count"],
}
def _classify_cylindrical_face(
self,
face_id: int,
surf: BRepAdaptor_Surface,
detailed: bool = False,
) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "no owning solid was found",
}
solid = self.solids[solid_id][1]
radius = surf.Cylinder().Radius()
angular_span = abs(surf.LastUParameter() - surf.FirstUParameter())
boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()))
solid_diagonal = _shape_diagonal(solid)
is_partial_cylinder = angular_span < math.tau * 0.92
is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04
is_fillet_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.12
is_quarter_roundish = 0.15 <= angular_span <= math.pi * 1.05
is_fillet_like_partial = (
is_partial_cylinder
and is_quarter_roundish
and is_fillet_radius
and boundary_edges >= 4
)
samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed)
sample_count = len(samples)
if sample_count == 0:
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "could not sample cylinder material sides",
}
toward_states = [sample["toward"] for sample in samples]
away_states = [sample["away"] for sample in samples]
hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside")
boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside")
unclear_votes = sample_count - hole_votes - boss_votes
vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}"
threshold = max(1, math.ceil(sample_count * 0.6))
base = {
"toward_axis": _state_summary(toward_states),
"away_axis": _state_summary(away_states),
"vote_summary": vote_summary,
"sample_count": sample_count,
}
if hole_votes >= threshold:
confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium"
return {
"feature_guess": "hole/groove candidate",
"confidence": confidence,
"note": "axis side is mostly empty and outer side is mostly material",
**base,
}
if is_partial_cylinder and (is_small_radius or is_fillet_like_partial):
return {
"feature_guess": "round/fillet candidate",
"confidence": "medium" if boundary_edges >= 4 else "low",
"note": "partial small-radius cylinder; may be a fillet or blend",
**base,
}
if boss_votes >= threshold:
return {
"feature_guess": "boss/outer-round candidate",
"confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium",
"note": "axis side is mostly material and outer side is mostly empty",
**base,
}
return {
"feature_guess": "cylindrical face",
"confidence": "low",
"note": "material sampling did not produce a clear inside/outside pattern",
**base,
}
def _sample_cylinder_material_states(
self,
surf: BRepAdaptor_Surface,
solid: TopoDS_Shape,
detailed: bool = False,
) -> list[dict[str, str]]:
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
radius = cyl.Radius()
u_first = surf.FirstUParameter()
u_last = surf.LastUParameter()
v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0
u_span = u_last - u_first
fractions = [0.5]
if detailed and abs(u_span) > 0.2:
fractions = [0.25, 0.5, 0.75]
samples: list[dict[str, str]] = []
for fraction in fractions:
u = u_first + u_span * fraction
point = surf.Value(u, v)
axis_to_point = _vec_from_points(axis_point, point)
projection = _dot(axis_to_point, axis_dir)
center = gp_Pnt(
axis_point.X() + axis_dir.X() * projection,
axis_point.Y() + axis_dir.Y() * projection,
axis_point.Z() + axis_dir.Z() * projection,
)
radial = _vec_from_points(center, point)
radial_len = radial.Magnitude()
if radial_len <= 1e-9:
continue
unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len)
epsilon = min(max(radius * 0.03, 0.05), 1.0)
toward_point = gp_Pnt(
point.X() - unit.X() * epsilon,
point.Y() - unit.Y() * epsilon,
point.Z() - unit.Z() * epsilon,
)
away_point = gp_Pnt(
point.X() + unit.X() * epsilon,
point.Y() + unit.Y() * epsilon,
point.Z() + unit.Z() * epsilon,
)
samples.append(
{
"toward": _solid_state(solid, toward_point),
"away": _solid_state(solid, away_point),
}
)
return samples
def _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)
plane_axis = surf.Plane().Axis().Direction()
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()
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92:
continue
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,
"cap_plane_axis_alignment": plane_axis_alignment,
"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,
}
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)
cap_direction = self._cylindrical_cap_push_pull_direction(face_id, surf)
if cap_direction is not None:
return cap_direction
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