3476 lines
173 KiB
Python
3476 lines
173 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from pathlib import Path
|
||
from typing import Callable, Iterable
|
||
|
||
from OCC.Core.BRep import BRep_Tool
|
||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
||
from OCC.Core.BRepBndLib import brepbndlib
|
||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||
from OCC.Core.BRepGProp import brepgprop
|
||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
||
from OCC.Core.Bnd import Bnd_Box
|
||
from OCC.Core.GeomAbs import (
|
||
GeomAbs_BSplineCurve,
|
||
GeomAbs_BSplineSurface,
|
||
GeomAbs_BezierCurve,
|
||
GeomAbs_BezierSurface,
|
||
GeomAbs_Circle,
|
||
GeomAbs_Cone,
|
||
GeomAbs_Cylinder,
|
||
GeomAbs_Ellipse,
|
||
GeomAbs_Hyperbola,
|
||
GeomAbs_Line,
|
||
GeomAbs_OffsetSurface,
|
||
GeomAbs_OtherCurve,
|
||
GeomAbs_OtherSurface,
|
||
GeomAbs_Parabola,
|
||
GeomAbs_Plane,
|
||
GeomAbs_Sphere,
|
||
GeomAbs_SurfaceOfExtrusion,
|
||
GeomAbs_SurfaceOfRevolution,
|
||
GeomAbs_Torus,
|
||
)
|
||
from OCC.Core.GProp import GProp_GProps
|
||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||
from OCC.Core.TopAbs import (
|
||
TopAbs_EDGE,
|
||
TopAbs_EXTERNAL,
|
||
TopAbs_FACE,
|
||
TopAbs_FORWARD,
|
||
TopAbs_IN,
|
||
TopAbs_INTERNAL,
|
||
TopAbs_OUT,
|
||
TopAbs_REVERSED,
|
||
TopAbs_SOLID,
|
||
)
|
||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||
from OCC.Core.TopLoc import TopLoc_Location
|
||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
||
|
||
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
||
from .geometry_utils import * # noqa: F403
|
||
from .recognition_priority import feature_recognition_sort_key
|
||
|
||
|
||
class FeatureMixin:
|
||
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
|
||
|
||
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",
|
||
):
|
||
if feature.get(key) not in {None, ""}:
|
||
candidate[key] = feature.get(key)
|
||
except Exception:
|
||
pass
|
||
candidates.append(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)
|
||
axis_range = self._cylindrical_axis_range(
|
||
face_id,
|
||
BRepAdaptor_Surface(self.faces[face_id]),
|
||
_int_values(feature.get("feature_side_face_ids")),
|
||
)
|
||
scoped_info = dict(info)
|
||
scoped_info["height_estimate"] = axis_range["span"]
|
||
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
||
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
|
||
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
|
||
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"),
|
||
"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"),
|
||
"resize_strategy": "same-axis-bounded-cylinder-recut",
|
||
"edit_strategy_label": "同轴圆柱重切",
|
||
"edit_semantics": edit_semantics,
|
||
**topology_fields,
|
||
**cutter_plan,
|
||
**fill_plan,
|
||
}
|
||
|
||
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"))
|
||
angular_span = _float_or_none(info.get("angular_span"))
|
||
feature_guess = str(info.get("feature_guess", ""))
|
||
confidence = str(info.get("confidence", "low"))
|
||
surf = BRepAdaptor_Surface(self.faces[face_id])
|
||
current_center: tuple[float, float, float] | None = None
|
||
axis_direction: tuple[float, float, float] | None = None
|
||
axis_range: dict[str, object] = {}
|
||
if info.get("surface") != "cylinder" or surf.GetType() != GeomAbs_Cylinder:
|
||
blockers.append("Selected Face is not a cylindrical hole/groove face.")
|
||
else:
|
||
cyl = surf.Cylinder()
|
||
axis = cyl.Axis()
|
||
axis_direction = _dir_tuple(axis.Direction())
|
||
axis_range = self._cylindrical_axis_range(
|
||
face_id,
|
||
surf,
|
||
_int_values(feature.get("feature_side_face_ids")),
|
||
)
|
||
mid_parameter = (float(axis_range["v_min"]) + float(axis_range["v_max"])) * 0.5
|
||
current_center = _point_tuple(_point_on_axis(axis.Location(), axis.Direction(), mid_parameter))
|
||
|
||
if current_diameter is None or current_diameter <= 1e-9:
|
||
blockers.append("Selected cylindrical Face has no stable diameter.")
|
||
if feature_guess != "hole/groove candidate":
|
||
blockers.append("Cylinder axis move currently supports recognized hole/groove candidates only.")
|
||
if angular_span is None or angular_span < math.tau * 0.92:
|
||
blockers.append("Cylinder axis move currently supports near-full cylindrical holes only; partial slots need a sector-aware tool.")
|
||
if current_center is None or axis_direction is None:
|
||
blockers.append("Could not derive a stable current cylinder axis center.")
|
||
|
||
movement = (0.0, 0.0, 0.0)
|
||
move_distance = 0.0
|
||
axial_delta = 0.0
|
||
radial_distance = 0.0
|
||
if current_center is not None and axis_direction is not None:
|
||
movement = _tuple_sub(target_center, current_center)
|
||
move_distance = _vector_length(movement)
|
||
axial_delta = _tuple_dot(movement, axis_direction)
|
||
radial_movement = _tuple_sub(movement, _tuple_scale(axis_direction, axial_delta))
|
||
radial_distance = _vector_length(radial_movement)
|
||
diagonal = max(_shape_diagonal(self.faces[face_id]), current_diameter or 0.0, 1.0)
|
||
if move_distance <= max(diagonal * 1e-7, 1e-6):
|
||
blockers.append("Target cylinder axis center is almost the same as the current center.")
|
||
if current_diameter is not None and current_diameter > 0:
|
||
ratio = move_distance / current_diameter
|
||
if ratio > 4.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("Target axis move is more than four hole diameters; Boolean cut may affect unrelated geometry.")
|
||
elif ratio > 1.0:
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("Target axis move is larger than one hole diameter; verify nearby walls after editing.")
|
||
elif ratio > 0.35:
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("Target axis move is a moderate local relocation.")
|
||
if abs(axial_delta) > max(radial_distance * 0.5, (current_diameter or 1.0) * 0.25):
|
||
risk = _max_risk(risk, "high")
|
||
warnings.append("The target center includes a large movement along the cylinder axis; this may change the opening/bottom relationship.")
|
||
if confidence == "low":
|
||
risk = _max_risk(risk, "medium")
|
||
warnings.append("Hole/groove recognition confidence is low.")
|
||
|
||
cutter_plan: dict[str, object] = {}
|
||
fill_plan: dict[str, object] = {}
|
||
if not blockers and current_diameter is not None:
|
||
cutter_plan = self._bounded_cylinder_cutter_plan(face_id, current_diameter, feature)
|
||
fill_plan = self._bounded_cylinder_fill_plan(face_id)
|
||
start = _tuple_or_none(cutter_plan.get("cutter_start_point"))
|
||
axis_point = _tuple_or_none(cutter_plan.get("cutter_axis_point"))
|
||
if start is None or axis_point is None:
|
||
blockers.append("Could not build the bounded cutter for the moved cylinder axis.")
|
||
else:
|
||
cutter_plan["target_cutter_start_point"] = _tuple_add(start, movement)
|
||
cutter_plan["target_cutter_axis_point"] = _tuple_add(axis_point, movement)
|
||
|
||
if blockers:
|
||
status = "blocked"
|
||
risk = "blocked"
|
||
else:
|
||
status = "caution" if risk in {"medium", "high"} else "ready"
|
||
|
||
message = "; ".join(blockers + warnings) if blockers or warnings else "Cylinder axis move can be attempted."
|
||
return {
|
||
**cutter_plan,
|
||
**fill_plan,
|
||
**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,
|
||
"same_domain_face_ids": axis_range.get("same_domain_face_ids", ()),
|
||
"same_domain_face_count": axis_range.get("same_domain_face_count", 0),
|
||
"same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
|
||
"same_domain_range_source": axis_range.get("range_source", ""),
|
||
"resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder",
|
||
"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": info.get("angular_span"),
|
||
"height_estimate": scoped_info.get("height_estimate"),
|
||
"feature_type": feature.get("feature_type"),
|
||
"feature_guess": info.get("feature_guess"),
|
||
"confidence": info.get("confidence"),
|
||
"material_vote_summary": info.get("material_vote_summary"),
|
||
"cylinder_end_type": scoped_info.get("cylinder_end_type"),
|
||
"same_domain_face_ids": axis_range["same_domain_face_ids"],
|
||
"same_domain_face_count": axis_range["same_domain_face_count"],
|
||
"same_domain_v_range": (axis_range["v_min"], axis_range["v_max"]),
|
||
"same_domain_range_source": axis_range["range_source"],
|
||
"feature_bottom_face_ids": feature.get("feature_bottom_face_ids"),
|
||
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
|
||
"feature_bottom_note": feature.get("feature_bottom_note"),
|
||
"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)
|
||
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()
|
||
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_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
|
||
|