feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。 支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
This commit is contained in:
+19
-3
@@ -274,7 +274,23 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.scdm_feature_cache_state = "empty"
|
||||
self.scdm_feature_cache_message = ""
|
||||
self.scdm_feature_cache_path = ""
|
||||
self.scdm_edit_runner_ready = {"face.offset", "hole.diameter", "hole.position", "slot.position", "boss.position"}
|
||||
self.scdm_edit_runner_ready = {
|
||||
"face.offset",
|
||||
"hole.diameter",
|
||||
"hole.position",
|
||||
"feature.fill",
|
||||
"slot.width",
|
||||
"slot.depth",
|
||||
"slot.position",
|
||||
"boss.diameter",
|
||||
"boss.height",
|
||||
"boss.position",
|
||||
"round.radius",
|
||||
"chamfer.distance",
|
||||
"feature.delete_round_or_chamfer",
|
||||
"pattern.spacing",
|
||||
"pattern.segment_spacing",
|
||||
}
|
||||
self.load_in_progress = False
|
||||
self.load_thread: QThread | None = None
|
||||
self.load_worker: LoadWorker | None = None
|
||||
@@ -1050,7 +1066,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
|
||||
mode_box = QWidget()
|
||||
mode_box.setMinimumHeight(62)
|
||||
help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。")
|
||||
help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征。")
|
||||
self.mode_section_title = QLabel("选择模式", mode_box)
|
||||
self.mode_section_title.setObjectName("modeSectionTitle")
|
||||
self.mode_section_title.setFixedSize(74, 20)
|
||||
@@ -1085,7 +1101,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.mode_combo.setMaximumWidth(112)
|
||||
help_tip(
|
||||
self.mode_combo,
|
||||
"选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选。",
|
||||
"选择模式决定鼠标点击模型时要选什么:Part、Solid、Face、Edge,或用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征。",
|
||||
)
|
||||
self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
|
||||
mode_pick_layout.addWidget(self.mouse_mode_label)
|
||||
|
||||
@@ -229,17 +229,34 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
def scdm_local_face_signatures(self) -> list[dict[str, object]]:
|
||||
"""Return cheap geometry hints used to map SCDM raw objects back to local Face IDs."""
|
||||
signatures: list[dict[str, object]] = []
|
||||
face_ordinal_by_solid: dict[int, int] = {}
|
||||
for face_id, face in enumerate(self.faces):
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
surface_type = surf.GetType()
|
||||
except Exception:
|
||||
continue
|
||||
solid_id = int(self.face_solid_ids[face_id]) if face_id < len(self.face_solid_ids) else -1
|
||||
part_id = int(self.face_part_ids[face_id]) if face_id < len(self.face_part_ids) else -1
|
||||
face_ordinal = face_ordinal_by_solid.get(solid_id, 0)
|
||||
face_ordinal_by_solid[solid_id] = face_ordinal + 1
|
||||
signature: dict[str, object] = {
|
||||
"faceId": int(face_id),
|
||||
"logicalFaceId": self.face_logical_id(face_id),
|
||||
"partId": part_id,
|
||||
"solidId": solid_id,
|
||||
"bodyIndex": solid_id,
|
||||
"faceOrdinal": face_ordinal,
|
||||
"globalFaceOrdinal": int(face_id),
|
||||
"surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
|
||||
}
|
||||
try:
|
||||
bounds = _shape_bounds_info(face)
|
||||
signature["bboxMin"] = bounds.get("bbox_min")
|
||||
signature["bboxMax"] = bounds.get("bbox_max")
|
||||
signature["bboxSize"] = bounds.get("bbox_size")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if surface_type == GeomAbs_Plane:
|
||||
plane = surf.Plane()
|
||||
|
||||
@@ -144,6 +144,44 @@ def evaluate_relation_formula(
|
||||
return _coerce_formula_value(value)
|
||||
|
||||
|
||||
def validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None:
|
||||
target_to_formula: dict[str, RelationFormula] = {}
|
||||
for formula in formulas:
|
||||
target_token = formula.target.token
|
||||
if target_token in target_to_formula:
|
||||
raise RelationFormulaError(f"同一目标参数只能由一条关系式控制:{target_token}。")
|
||||
target_to_formula[target_token] = formula
|
||||
|
||||
target_tokens = set(target_to_formula)
|
||||
graph: dict[str, list[str]] = {}
|
||||
for target_token, formula in target_to_formula.items():
|
||||
reference_tokens = [ref.token for ref in formula.references]
|
||||
if target_token in reference_tokens:
|
||||
raise RelationFormulaError(f"关系式不能引用自身:{target_token}。")
|
||||
graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens]
|
||||
|
||||
visit_state: dict[str, str] = {}
|
||||
stack: list[str] = []
|
||||
|
||||
def visit(token: str) -> None:
|
||||
state = visit_state.get(token)
|
||||
if state == "visiting":
|
||||
start_index = stack.index(token) if token in stack else 0
|
||||
cycle = [*stack[start_index:], token]
|
||||
raise RelationFormulaError(f"关系式存在循环依赖:{' -> '.join(cycle)}。")
|
||||
if state == "visited":
|
||||
return
|
||||
visit_state[token] = "visiting"
|
||||
stack.append(token)
|
||||
for dependency in graph.get(token, []):
|
||||
visit(dependency)
|
||||
stack.pop()
|
||||
visit_state[token] = "visited"
|
||||
|
||||
for target_token in graph:
|
||||
visit(target_token)
|
||||
|
||||
|
||||
def relation_value_to_text(value: object) -> str:
|
||||
value = _coerce_formula_value(value)
|
||||
if isinstance(value, Vector3):
|
||||
|
||||
@@ -94,22 +94,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
default_intent="修改槽宽",
|
||||
backend_operation="change_slot_width",
|
||||
post_check="target_slot_width",
|
||||
productized=False,
|
||||
required_backend_command_groups=(("OffsetFaces",),),
|
||||
roadmap_stage="S7.2",
|
||||
block_reason="槽宽属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"slot.depth": ScdmCapabilityDefinition(
|
||||
key="slot.depth",
|
||||
display_name="槽深",
|
||||
object_types=("slot", "obround_slot", "rectangular_slot"),
|
||||
value_kind="number",
|
||||
current_fields=("geometry.depth",),
|
||||
current_fields=("geometry.slotInfo.depth", "geometry.depth"),
|
||||
default_intent="修改槽深",
|
||||
backend_operation="change_slot_depth",
|
||||
post_check="target_slot_depth",
|
||||
productized=False,
|
||||
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
|
||||
roadmap_stage="S7.2",
|
||||
block_reason="槽深属于 S7 第二批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"slot.position": ScdmCapabilityDefinition(
|
||||
key="slot.position",
|
||||
@@ -132,9 +130,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
default_intent="修改凸台高度",
|
||||
backend_operation="change_boss_height",
|
||||
post_check="target_boss_height",
|
||||
productized=False,
|
||||
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
|
||||
roadmap_stage="S7.3",
|
||||
block_reason="凸台高度属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"boss.diameter": ScdmCapabilityDefinition(
|
||||
key="boss.diameter",
|
||||
@@ -145,9 +142,8 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
default_intent="修改凸台直径",
|
||||
backend_operation="change_boss_diameter",
|
||||
post_check="target_boss_diameter",
|
||||
productized=False,
|
||||
required_backend_command_groups=(("OffsetFaces",),),
|
||||
roadmap_stage="S7.3",
|
||||
block_reason="凸台直径属于 S7 第三批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"boss.position": ScdmCapabilityDefinition(
|
||||
key="boss.position",
|
||||
@@ -166,28 +162,24 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
display_name="圆角半径",
|
||||
object_types=("round", "fillet"),
|
||||
value_kind="number",
|
||||
current_fields=("geometry.radius",),
|
||||
current_fields=("geometry.roundInfo.radius", "geometry.radius"),
|
||||
default_intent="修改圆角半径",
|
||||
backend_operation="change_round_radius",
|
||||
post_check="target_round_radius",
|
||||
required_backend_command_groups=(("ConstantRound",),),
|
||||
productized=False,
|
||||
roadmap_stage="S7.4",
|
||||
block_reason="圆角半径属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"chamfer.distance": ScdmCapabilityDefinition(
|
||||
key="chamfer.distance",
|
||||
display_name="倒角距离",
|
||||
object_types=("chamfer",),
|
||||
value_kind="number",
|
||||
current_fields=("geometry.distance", "geometry.offset"),
|
||||
current_fields=("geometry.chamferInfo.distance", "geometry.distance", "geometry.offset"),
|
||||
default_intent="修改倒角距离",
|
||||
backend_operation="change_chamfer_distance",
|
||||
post_check="target_chamfer_distance",
|
||||
required_backend_command_groups=(("Chamfer",),),
|
||||
productized=False,
|
||||
roadmap_stage="S7.4",
|
||||
block_reason="倒角距离属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"feature.delete_round_or_chamfer": ScdmCapabilityDefinition(
|
||||
key="feature.delete_round_or_chamfer",
|
||||
@@ -199,9 +191,7 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
backend_operation="delete_round_or_chamfer",
|
||||
post_check="target_feature_removed",
|
||||
required_backend_command_groups=(("Fill",), ("Delete",)),
|
||||
productized=False,
|
||||
roadmap_stage="S7.4",
|
||||
block_reason="删除圆角/倒角属于 S7 第四批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"pattern.spacing": ScdmCapabilityDefinition(
|
||||
key="pattern.spacing",
|
||||
@@ -212,9 +202,20 @@ CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
|
||||
default_intent="修改阵列间距",
|
||||
backend_operation="change_pattern_spacing",
|
||||
post_check="target_pattern_spacing",
|
||||
productized=False,
|
||||
required_backend_command_groups=(("Move",),),
|
||||
roadmap_stage="S7.5",
|
||||
),
|
||||
"pattern.segment_spacing": ScdmCapabilityDefinition(
|
||||
key="pattern.segment_spacing",
|
||||
display_name="局部间距",
|
||||
object_types=("pattern", "linear_pattern"),
|
||||
value_kind="number",
|
||||
current_fields=("geometry.spacing", "geometry.pitch"),
|
||||
default_intent="修改相邻阵列成员间距",
|
||||
backend_operation="change_pattern_segment_spacing",
|
||||
post_check="target_pattern_segment_spacing",
|
||||
required_backend_command_groups=(("Move",),),
|
||||
roadmap_stage="S7.5",
|
||||
block_reason="阵列间距属于 S7 第五批能力,真实 SCDM 命令和 STEP 样例回测尚未完成。",
|
||||
),
|
||||
"pattern.instance_position": ScdmCapabilityDefinition(
|
||||
key="pattern.instance_position",
|
||||
@@ -318,6 +319,7 @@ def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_
|
||||
if object_type in {"pattern", "linear_pattern"}:
|
||||
if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")):
|
||||
keys.append("pattern.spacing")
|
||||
keys.append("pattern.segment_spacing")
|
||||
if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")):
|
||||
keys.append("pattern.instance_position")
|
||||
|
||||
|
||||
+1020
-1
File diff suppressed because it is too large
Load Diff
+1185
-23
File diff suppressed because it is too large
Load Diff
+296
-4
@@ -241,9 +241,18 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" geometry['surfaceType'] = 'plane'\n"
|
||||
" elif 'cylinder' in lowered:\n"
|
||||
" geometry['surfaceType'] = 'cylinder'\n"
|
||||
" slot_info = _slot_info_from_face(face, geometry)\n"
|
||||
" if slot_info:\n"
|
||||
" geometry['slotInfo'] = slot_info\n"
|
||||
" for key in ('width', 'depth', 'center', 'depthAxis'):\n"
|
||||
" if slot_info.get(key) is not None:\n"
|
||||
" geometry[key] = slot_info.get(key)\n"
|
||||
" round_info = _round_info_from_face(face, geometry)\n"
|
||||
" if round_info:\n"
|
||||
" geometry['roundInfo'] = round_info\n"
|
||||
" chamfer_info = _chamfer_info_from_face(face, geometry)\n"
|
||||
" if chamfer_info:\n"
|
||||
" geometry['chamferInfo'] = chamfer_info\n"
|
||||
" return geometry\n"
|
||||
"\n"
|
||||
"def _round_info_from_face(face, geometry):\n"
|
||||
@@ -274,6 +283,101 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" pass\n"
|
||||
" return payload\n"
|
||||
"\n"
|
||||
"def _same_object(left, right):\n"
|
||||
" try:\n"
|
||||
" if left is right:\n"
|
||||
" return True\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" try:\n"
|
||||
" return left == right\n"
|
||||
" except Exception:\n"
|
||||
" return False\n"
|
||||
"\n"
|
||||
"def _slot_info_from_face(face, geometry):\n"
|
||||
" slot_info_type = globals().get('SlotInfo')\n"
|
||||
" if slot_info_type is None:\n"
|
||||
" return {}\n"
|
||||
" try:\n"
|
||||
" info = slot_info_type.Create(face)\n"
|
||||
" except Exception:\n"
|
||||
" return {}\n"
|
||||
" payload = {'available': True, 'type': _safe_name(info)}\n"
|
||||
" for key, attrs in (\n"
|
||||
" ('width', ('Width', 'SlotWidth', 'Diameter')),\n"
|
||||
" ('depth', ('Depth', 'SlotDepth', 'Height')),\n"
|
||||
" ):\n"
|
||||
" value = _float_attr(info, attrs)\n"
|
||||
" if value is not None:\n"
|
||||
" payload[key] = value\n"
|
||||
" center = _xyz(_first_path_value(info, ('Center', 'AxisCenter', 'Frame.Origin')))\n"
|
||||
" if center:\n"
|
||||
" payload['center'] = center\n"
|
||||
" depth_axis = _xyz(_first_path_value(info, ('DepthAxis', 'DepthDirection', 'Direction', 'Frame.DirZ')))\n"
|
||||
" if depth_axis:\n"
|
||||
" payload['depthAxis'] = depth_axis\n"
|
||||
" for attr in ('IsBlind', 'IsThrough', 'IsSlot'):\n"
|
||||
" try:\n"
|
||||
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" for attr in ('BottomFace', 'DepthFace', 'FloorFace'):\n"
|
||||
" try:\n"
|
||||
" if _same_object(getattr(info, attr), face):\n"
|
||||
" payload['depthFaceIsCurrent'] = True\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" for attr in ('BottomFaces', 'DepthFaces', 'FloorFaces'):\n"
|
||||
" try:\n"
|
||||
" for item in _items(getattr(info, attr)):\n"
|
||||
" if _same_object(item, face):\n"
|
||||
" payload['depthFaceIsCurrent'] = True\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" try:\n"
|
||||
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return payload\n"
|
||||
"\n"
|
||||
"def _chamfer_info_from_face(face, geometry):\n"
|
||||
" if str(geometry.get('surfaceType', '')).lower() != 'plane':\n"
|
||||
" return {}\n"
|
||||
" chamfer_info_type = globals().get('ChamferInfo')\n"
|
||||
" if chamfer_info_type is None:\n"
|
||||
" return {}\n"
|
||||
" try:\n"
|
||||
" info = chamfer_info_type.Create(face)\n"
|
||||
" except Exception:\n"
|
||||
" return {}\n"
|
||||
" payload = {'available': True, 'type': _safe_name(info)}\n"
|
||||
" for attr in ('Distance', 'ChamferDistance', 'Offset', 'Width'):\n"
|
||||
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
|
||||
" if value is not None:\n"
|
||||
" payload['distance'] = value\n"
|
||||
" break\n"
|
||||
" distance1 = _float_attr(info, ('Distance1', 'distance1', 'FirstDistance'))\n"
|
||||
" distance2 = _float_attr(info, ('Distance2', 'distance2', 'SecondDistance'))\n"
|
||||
" if distance1 is not None:\n"
|
||||
" payload['distance1'] = distance1\n"
|
||||
" if distance2 is not None:\n"
|
||||
" payload['distance2'] = distance2\n"
|
||||
" if distance1 is not None and distance2 is not None and abs(distance1 - distance2) <= max(abs(distance1), abs(distance2), 1.0) * 1e-6:\n"
|
||||
" payload.setdefault('distance', distance1)\n"
|
||||
" payload['isEqualDistance'] = True\n"
|
||||
" for attr in ('IsEqualDistance', 'IsSymmetric', 'IsChamfer'):\n"
|
||||
" try:\n"
|
||||
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" if payload.get('isSymmetric') is True:\n"
|
||||
" payload['isEqualDistance'] = True\n"
|
||||
" try:\n"
|
||||
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return payload\n"
|
||||
"\n"
|
||||
"def _path_value(value, expr):\n"
|
||||
" current = value\n"
|
||||
" for part in expr.split('.'):\n"
|
||||
@@ -462,10 +566,21 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
|
||||
" if object_type == 'hole' and surface_type == 'cylinder':\n"
|
||||
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n"
|
||||
" if object_type in ('slot', 'obround_slot', 'rectangular_slot'):\n"
|
||||
" if geometry.get('width') is not None:\n"
|
||||
" result.append({'operation': 'change_slot_width', 'enabled': True, 'parameterFields': {'width': geometry.get('width')}})\n"
|
||||
" if geometry.get('depth') is not None:\n"
|
||||
" result.append({'operation': 'change_slot_depth', 'enabled': True, 'parameterFields': {'depth': geometry.get('depth')}})\n"
|
||||
" if geometry.get('center') is not None:\n"
|
||||
" result.append({'operation': 'move_slot', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
|
||||
" round_info = geometry.get('roundInfo')\n"
|
||||
" if isinstance(round_info, dict) and round_info.get('radius') is not None:\n"
|
||||
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n"
|
||||
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
|
||||
" chamfer_info = geometry.get('chamferInfo')\n"
|
||||
" if isinstance(chamfer_info, dict) and chamfer_info.get('distance') is not None:\n"
|
||||
" result.append({'operation': 'change_chamfer_distance', 'enabled': True, 'parameterFields': {'distance': chamfer_info.get('distance')}})\n"
|
||||
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _open_step(path):\n"
|
||||
@@ -503,6 +618,167 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" except Exception:\n"
|
||||
" return value\n"
|
||||
"\n"
|
||||
"def _safe_str(value):\n"
|
||||
" if value is None:\n"
|
||||
" return ''\n"
|
||||
" try:\n"
|
||||
" return str(value)\n"
|
||||
" except Exception:\n"
|
||||
" return _safe_name(value)\n"
|
||||
"\n"
|
||||
"def _matrix_payload(matrix):\n"
|
||||
" if matrix is None:\n"
|
||||
" return {}\n"
|
||||
" payload = {'type': _safe_name(matrix), 'text': _safe_str(matrix)}\n"
|
||||
" translation = _xyz(_path_value(matrix, 'Translation'))\n"
|
||||
" if translation:\n"
|
||||
" payload['translation'] = translation\n"
|
||||
" for attr in ('OffsetX', 'OffsetY', 'OffsetZ'):\n"
|
||||
" value = _float_attr(matrix, (attr, attr[0].lower() + attr[1:]))\n"
|
||||
" if value is not None:\n"
|
||||
" payload[attr] = value\n"
|
||||
" return payload\n"
|
||||
"\n"
|
||||
"def _moniker_text(value):\n"
|
||||
" try:\n"
|
||||
" return _safe_str(getattr(value, 'Moniker'))\n"
|
||||
" except Exception:\n"
|
||||
" return ''\n"
|
||||
"\n"
|
||||
"def _component_name(component):\n"
|
||||
" for attr in ('Name', 'DisplayName'):\n"
|
||||
" try:\n"
|
||||
" text = _safe_str(getattr(component, attr)).strip()\n"
|
||||
" if text:\n"
|
||||
" return text\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return ''\n"
|
||||
"\n"
|
||||
"def _immediate_components(part):\n"
|
||||
" if part is None:\n"
|
||||
" return []\n"
|
||||
" try:\n"
|
||||
" items = _items(getattr(part, 'Components'))\n"
|
||||
" if items:\n"
|
||||
" return items\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return []\n"
|
||||
"\n"
|
||||
"def _component_content(component):\n"
|
||||
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
|
||||
" try:\n"
|
||||
" value = getattr(component, attr)\n"
|
||||
" if value is not None:\n"
|
||||
" return value\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return None\n"
|
||||
"\n"
|
||||
"def _component_locator(component, component_index, component_path):\n"
|
||||
" locator = {\n"
|
||||
" 'backendId': 'component:' + '.'.join(str(item) for item in component_path),\n"
|
||||
" 'componentIndex': component_index,\n"
|
||||
" 'componentPath': list(component_path),\n"
|
||||
" 'componentName': _component_name(component),\n"
|
||||
" }\n"
|
||||
" try:\n"
|
||||
" locator['componentMoniker'] = _moniker_text(component)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" try:\n"
|
||||
" content = getattr(component, 'Content')\n"
|
||||
" locator['contentMoniker'] = _moniker_text(content)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" try:\n"
|
||||
" template = getattr(component, 'Template')\n"
|
||||
" locator['templateMoniker'] = _moniker_text(template)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" try:\n"
|
||||
" placement = _matrix_payload(getattr(component, 'Placement'))\n"
|
||||
" if placement:\n"
|
||||
" locator['placement'] = placement\n"
|
||||
" if placement.get('translation'):\n"
|
||||
" locator['placementTranslation'] = placement.get('translation')\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return locator\n"
|
||||
"\n"
|
||||
"def _component_entries(root):\n"
|
||||
" result = []\n"
|
||||
" queue = [(root, [])]\n"
|
||||
" while queue:\n"
|
||||
" part, path = queue.pop(0)\n"
|
||||
" if part is None or len(path) > 8:\n"
|
||||
" continue\n"
|
||||
" for child_index, component in enumerate(_immediate_components(part)):\n"
|
||||
" component_path = list(path) + [child_index]\n"
|
||||
" content = _component_content(component)\n"
|
||||
" entry = {\n"
|
||||
" 'component': component,\n"
|
||||
" 'content': content,\n"
|
||||
" 'locator': _component_locator(component, len(result), component_path),\n"
|
||||
" }\n"
|
||||
" result.append(entry)\n"
|
||||
" if content is not None:\n"
|
||||
" queue.append((content, component_path))\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _component_body_locator_map(component_entries):\n"
|
||||
" result = {}\n"
|
||||
" for entry in component_entries:\n"
|
||||
" content = entry.get('content')\n"
|
||||
" if content is None:\n"
|
||||
" continue\n"
|
||||
" for component_body_index, body in enumerate(_items(_maybe_call(content, 'Bodies'))):\n"
|
||||
" locator = dict(entry.get('locator') or {})\n"
|
||||
" locator['componentBodyIndex'] = component_body_index\n"
|
||||
" key = str(id(body))\n"
|
||||
" result.setdefault(key, []).append(locator)\n"
|
||||
" try:\n"
|
||||
" master = getattr(body, 'Master')\n"
|
||||
" result.setdefault(str(id(master)), []).append(locator)\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _body_locators_for_body(component_body_locators, body, body_index):\n"
|
||||
" result = [{'bodyIndex': body_index}]\n"
|
||||
" seen = set(['body:' + str(body_index)])\n"
|
||||
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
|
||||
" item = dict(locator)\n"
|
||||
" item['bodyIndex'] = body_index\n"
|
||||
" key = str(item.get('componentIndex')) + ':' + '.'.join(str(value) for value in item.get('componentPath', []) or []) + ':' + str(item.get('componentBodyIndex'))\n"
|
||||
" if key in seen:\n"
|
||||
" continue\n"
|
||||
" seen.add(key)\n"
|
||||
" result.append(item)\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _component_locators_for_body(component_body_locators, body):\n"
|
||||
" result = []\n"
|
||||
" seen = set()\n"
|
||||
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
|
||||
" key = str(locator.get('componentIndex')) + ':' + '.'.join(str(value) for value in locator.get('componentPath', []) or []) + ':' + str(locator.get('componentBodyIndex'))\n"
|
||||
" if key in seen:\n"
|
||||
" continue\n"
|
||||
" seen.add(key)\n"
|
||||
" result.append(dict(locator))\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _component_inventory(component_entries):\n"
|
||||
" result = []\n"
|
||||
" for entry in component_entries:\n"
|
||||
" locator = dict(entry.get('locator') or {})\n"
|
||||
" content = entry.get('content')\n"
|
||||
" locator['contentBodyCount'] = len(_items(_maybe_call(content, 'Bodies'))) if content is not None else 0\n"
|
||||
" locator['childComponentCount'] = len(_immediate_components(content)) if content is not None else 0\n"
|
||||
" result.append(locator)\n"
|
||||
" return result\n"
|
||||
"\n"
|
||||
"def _body_faces(body):\n"
|
||||
" for name in ('Faces', 'GetFaces'):\n"
|
||||
" items = _items(_maybe_call(body, name))\n"
|
||||
@@ -596,7 +872,7 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" return set(str(id(face)) for face in faces)\n"
|
||||
"\n"
|
||||
"def _available_commands():\n"
|
||||
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo')\n"
|
||||
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo', 'ChamferInfo', 'SlotInfo')\n"
|
||||
" result = []\n"
|
||||
" for name in names:\n"
|
||||
" result.append({'name': name, 'available': globals().get(name) is not None})\n"
|
||||
@@ -612,6 +888,8 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" _open_step(model.get('path'))\n"
|
||||
" root = _root_part()\n"
|
||||
" bodies = _all_bodies(root)\n"
|
||||
" component_entries = _component_entries(root)\n"
|
||||
" component_body_locators = _component_body_locator_map(component_entries)\n"
|
||||
" hole_face_markers = _hole_face_markers(bodies)\n"
|
||||
" objects = []\n"
|
||||
" face_adjacency = {}\n"
|
||||
@@ -620,24 +898,37 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" edge_counter = 0\n"
|
||||
" for body_index, body in enumerate(bodies):\n"
|
||||
" body_faces = _body_faces(body)\n"
|
||||
" body_locators = _body_locators_for_body(component_body_locators, body, body_index)\n"
|
||||
" component_locators = _component_locators_for_body(component_body_locators, body)\n"
|
||||
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n"
|
||||
" for face_index, face in enumerate(body_faces):\n"
|
||||
" geometry = _geometry_from_face(face)\n"
|
||||
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n"
|
||||
" if object_type == 'face' and isinstance(geometry.get('slotInfo'), dict) and (geometry.get('depth') is not None or geometry.get('width') is not None):\n"
|
||||
" object_type = 'slot'\n"
|
||||
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n"
|
||||
" object_type = 'round'\n"
|
||||
" if object_type == 'face' and isinstance(geometry.get('chamferInfo'), dict) and geometry.get('chamferInfo', {}).get('distance') is not None:\n"
|
||||
" object_type = 'chamfer'\n"
|
||||
" topology_hint = {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter, 'bodyLocators': body_locators}\n"
|
||||
" if component_locators:\n"
|
||||
" topology_hint['componentLocators'] = component_locators\n"
|
||||
" if object_type == 'slot' and isinstance(geometry.get('slotInfo'), dict) and geometry.get('slotInfo', {}).get('depthFaceIsCurrent') is True:\n"
|
||||
" topology_hint['depthFaceLocators'] = [dict(topology_hint)]\n"
|
||||
" objects.append({\n"
|
||||
" 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n"
|
||||
" 'objectType': object_type,\n"
|
||||
" 'geometry': geometry,\n"
|
||||
" 'topologyHint': {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter},\n"
|
||||
" 'topologyHint': topology_hint,\n"
|
||||
" 'backendCommandCandidates': _command_candidates(object_type, geometry),\n"
|
||||
" 'rawLimitations': [],\n"
|
||||
" })\n"
|
||||
" face_counter += 1\n"
|
||||
" for edge_index, edge in enumerate(_body_edges(body)):\n"
|
||||
" geometry = _geometry_from_edge(edge)\n"
|
||||
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter}\n"
|
||||
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter, 'bodyLocators': body_locators}\n"
|
||||
" if component_locators:\n"
|
||||
" edge_topology['componentLocators'] = component_locators\n"
|
||||
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n"
|
||||
" _add_edge_geometry_summary(edge_geometry_summary, geometry)\n"
|
||||
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n"
|
||||
@@ -661,8 +952,9 @@ def generate_scdm_probe_script(job_path: str | Path) -> str:
|
||||
" 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n"
|
||||
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n"
|
||||
" 'featureInventory': _feature_inventory(objects),\n"
|
||||
" 'componentInstances': _component_inventory(component_entries),\n"
|
||||
" },\n"
|
||||
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers)},\n"
|
||||
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers), 'componentCount': len(component_entries)},\n"
|
||||
" }\n"
|
||||
" _write_json(raw_path, payload)\n"
|
||||
" except Exception as exc:\n"
|
||||
|
||||
@@ -27,9 +27,12 @@ def property_specs_from_scdm_cache(
|
||||
continue
|
||||
for capability in capabilities:
|
||||
if isinstance(capability, Mapping):
|
||||
if str(capability.get("key") or "") == "pattern.segment_spacing":
|
||||
continue
|
||||
spec = _capability_spec(item, capability, execution_ready=execution_ready)
|
||||
if spec is not None:
|
||||
specs.append(spec)
|
||||
specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready))
|
||||
return specs
|
||||
|
||||
|
||||
@@ -38,6 +41,7 @@ def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edg
|
||||
if not isinstance(signature, Mapping):
|
||||
return False
|
||||
object_faces = set(_int_values(signature.get("faceIds")))
|
||||
object_faces.update(_int_values(signature.get("supportFaceIds")))
|
||||
object_edges = set(_int_values(signature.get("edgeIds")))
|
||||
return bool((face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids))
|
||||
|
||||
@@ -55,14 +59,23 @@ def _capability_spec(
|
||||
value_kind = str(capability.get("valueKind") or "number")
|
||||
current = capability.get("currentValue")
|
||||
value_type = _value_type(value_kind, key)
|
||||
signature = raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {}
|
||||
unit_scale = _unit_scale(signature if isinstance(signature, Mapping) else {})
|
||||
current_display = _display_value(current, key=key, value_type=value_type, unit_scale=unit_scale)
|
||||
command_value = value_type == "command"
|
||||
current_text = "可执行" if command_value else _format_value(current, value_type=value_type)
|
||||
target_text = "执行" if command_value else _format_value(current, value_type=value_type)
|
||||
current_text = "可执行" if command_value else _format_value(current_display, value_type=value_type)
|
||||
target_text = "执行" if command_value else _format_value(current_display, value_type=value_type)
|
||||
capability_block = str(capability.get("blockReason") or "").strip()
|
||||
object_block = str(raw_object.get("blockReason") or "").strip()
|
||||
block_reason = capability_block or object_block
|
||||
if not command_value and not _current_value_available(current_display, value_type=value_type):
|
||||
block_reason = block_reason or f"SCDM 已识别“{label}”,但没有返回可用于编辑的当前值。"
|
||||
backend_operation = str(capability.get("backendOperation") or "")
|
||||
post_check = str(capability.get("postCheck") or "")
|
||||
max_value = _display_max_value(key=key, signature=signature if isinstance(signature, Mapping) else {}, unit_scale=unit_scale)
|
||||
range_hint = "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。"
|
||||
if key == "pattern.spacing" and max_value is not None:
|
||||
range_hint = f"该阵列受承载面范围限制,保持阵列中心不变时最大间距约 {max_value:g};超过后会跑出承载面。"
|
||||
can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason)
|
||||
if can_execute:
|
||||
disabled_tip = ""
|
||||
@@ -79,7 +92,9 @@ def _capability_spec(
|
||||
return {
|
||||
"key": f"scdm:{key}",
|
||||
"label": label,
|
||||
"current_raw": current if current is not None else "",
|
||||
"current_raw": current_display if current_display is not None else "",
|
||||
"scdm_current_raw": current if current is not None else "",
|
||||
"scdm_unit_scale": unit_scale,
|
||||
"current_text": current_text,
|
||||
"target_text": target_text,
|
||||
"editable": True,
|
||||
@@ -90,24 +105,443 @@ def _capability_spec(
|
||||
"value_type": value_type,
|
||||
"enabled_tip": enabled_tip,
|
||||
"disabled_tip": disabled_tip,
|
||||
"range_hint": "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。",
|
||||
"range_hint": range_hint,
|
||||
"min_value": 0.0 if value_type == "positive" else None,
|
||||
"min_exclusive": True if value_type == "positive" else False,
|
||||
"max_value": max_value,
|
||||
"scdm_object_id": raw_object.get("objectId"),
|
||||
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
|
||||
"scdm_capability_key": key,
|
||||
"scdm_backend_operation": backend_operation,
|
||||
"scdm_post_check": post_check,
|
||||
"scdm_geometry_signature": raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {},
|
||||
"scdm_geometry_signature": signature if isinstance(signature, Mapping) else {},
|
||||
}
|
||||
|
||||
|
||||
def _unit_scale(signature: Mapping[str, object]) -> float:
|
||||
try:
|
||||
value = float(str(signature.get("localUnitScale")).strip())
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return value if value > 0 else 1.0
|
||||
|
||||
|
||||
def _display_value(value: object, *, key: str, value_type: str, unit_scale: float) -> object:
|
||||
if unit_scale <= 0 or abs(unit_scale - 1.0) <= 1.0e-12 or not _uses_length_units(key, value_type):
|
||||
return value
|
||||
if value_type == "vector3":
|
||||
values = _float_values(value)
|
||||
if len(values) == 3:
|
||||
return [item / unit_scale for item in values]
|
||||
return value
|
||||
try:
|
||||
return float(str(value).strip()) / unit_scale
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
|
||||
|
||||
def _uses_length_units(key: str, value_type: str) -> bool:
|
||||
if value_type == "vector3":
|
||||
return True
|
||||
suffixes = (
|
||||
".diameter",
|
||||
".radius",
|
||||
".offset",
|
||||
".width",
|
||||
".depth",
|
||||
".height",
|
||||
".distance",
|
||||
".thickness",
|
||||
".spacing",
|
||||
".segment_spacing",
|
||||
".position",
|
||||
)
|
||||
return key.endswith(suffixes)
|
||||
|
||||
|
||||
def _display_max_value(*, key: str, signature: Mapping[str, object], unit_scale: float) -> float | None:
|
||||
if key != "pattern.spacing":
|
||||
return None
|
||||
fit = signature.get("supportPatternFit")
|
||||
if not isinstance(fit, Mapping):
|
||||
return None
|
||||
value = fit.get("maxSpacingLocal")
|
||||
try:
|
||||
result = float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
backend_value = fit.get("maxSpacing")
|
||||
try:
|
||||
return float(str(backend_value).strip()) / unit_scale if unit_scale > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if result > 0 else None
|
||||
|
||||
|
||||
def _pattern_segment_spacing_specs(
|
||||
raw_object: Mapping[str, object],
|
||||
*,
|
||||
execution_ready: bool | Iterable[str],
|
||||
) -> list[dict[str, object]]:
|
||||
if str(raw_object.get("objectType") or "").strip().lower() != "linear_pattern":
|
||||
return []
|
||||
signature = raw_object.get("geometrySignature")
|
||||
if not isinstance(signature, Mapping):
|
||||
return []
|
||||
axis = _unit_vector(_float_values(signature.get("axis")))
|
||||
if len(axis) != 3:
|
||||
return []
|
||||
instances = _sorted_pattern_instances(signature, axis)
|
||||
if len(instances) < 2:
|
||||
return []
|
||||
unit_scale = _unit_scale(signature)
|
||||
can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
|
||||
specs: list[dict[str, object]] = []
|
||||
for segment_index in range(len(instances) - 1):
|
||||
left = instances[segment_index]
|
||||
right = instances[segment_index + 1]
|
||||
left_label = _segment_instance_label(left, segment_index + 1)
|
||||
right_label = _segment_instance_label(right, segment_index + 2)
|
||||
segment_label = f"{left_label}-{right_label}间距"
|
||||
current = max(0.0, float(right["projection"]) - float(left["projection"]))
|
||||
if current <= 0:
|
||||
continue
|
||||
current_display = current / unit_scale if unit_scale > 0 else current
|
||||
scope_modes = _segment_scope_modes(
|
||||
signature,
|
||||
instances,
|
||||
segment_index,
|
||||
current,
|
||||
current_display,
|
||||
unit_scale,
|
||||
left_label=left_label,
|
||||
right_label=right_label,
|
||||
segment_label=segment_label,
|
||||
can_execute=can_execute,
|
||||
)
|
||||
default_mode = scope_modes.get("fix_left_move_right", {}) if isinstance(scope_modes, Mapping) else {}
|
||||
max_display = default_mode.get("max_value")
|
||||
range_hint = str(default_mode.get("range_hint") or "")
|
||||
enabled_tip = str(default_mode.get("enabled_tip") or range_hint)
|
||||
segment_signature = default_mode.get("scdm_geometry_signature")
|
||||
if not isinstance(segment_signature, Mapping):
|
||||
segment_signature = _segment_signature(
|
||||
signature,
|
||||
segment_index,
|
||||
current,
|
||||
unit_scale,
|
||||
left_label=left_label,
|
||||
right_label=right_label,
|
||||
moving_side="after",
|
||||
motion_semantics="fix_left_move_right_group",
|
||||
)
|
||||
specs.append(
|
||||
{
|
||||
"key": f"scdm:pattern.segment_spacing:{segment_index}",
|
||||
"label": segment_label,
|
||||
"current_raw": current_display,
|
||||
"scdm_current_raw": current,
|
||||
"scdm_unit_scale": unit_scale,
|
||||
"current_text": _format_value(current_display, value_type="positive"),
|
||||
"target_text": _format_value(current_display, value_type="positive"),
|
||||
"editable": True,
|
||||
"enabled": can_execute,
|
||||
"status_text": "可修改" if can_execute else "暂未接入",
|
||||
"scope_text": "固定前项,移动后侧",
|
||||
"scope_modes": scope_modes,
|
||||
"scope_default": "fix_left_move_right",
|
||||
"action": "apply_scdm_property_edit",
|
||||
"value_type": "positive",
|
||||
"enabled_tip": enabled_tip,
|
||||
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
|
||||
"range_hint": range_hint,
|
||||
"min_value": 0.0,
|
||||
"min_exclusive": True,
|
||||
"max_value": max_display,
|
||||
"scdm_object_id": raw_object.get("objectId"),
|
||||
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
|
||||
"scdm_capability_key": "pattern.segment_spacing",
|
||||
"scdm_backend_operation": "change_pattern_segment_spacing",
|
||||
"scdm_post_check": "target_pattern_segment_spacing",
|
||||
"scdm_geometry_signature": segment_signature,
|
||||
}
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]:
|
||||
value = signature.get("patternInstances")
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
result: list[dict[str, object]] = []
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
center = _float_values(item.get("center") or item.get("instanceCenter"))
|
||||
if len(center) != 3:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"index": index,
|
||||
"source": item,
|
||||
"center": center,
|
||||
"projection": _point_projection(center, axis),
|
||||
}
|
||||
)
|
||||
result.sort(key=lambda item: float(item["projection"]))
|
||||
return result
|
||||
|
||||
|
||||
def _segment_max_spacing_display(
|
||||
signature: Mapping[str, object],
|
||||
instances: list[dict[str, object]],
|
||||
segment_index: int,
|
||||
current_display: float,
|
||||
unit_scale: float,
|
||||
*,
|
||||
moving_side: str = "after",
|
||||
) -> float | None:
|
||||
fit = signature.get("supportPatternFit")
|
||||
if not isinstance(fit, Mapping):
|
||||
return None
|
||||
projection_min = _float_or_none(fit.get("supportProjectionMinLocal"))
|
||||
projection_max = _float_or_none(fit.get("supportProjectionMaxLocal"))
|
||||
member_span = _float_or_none(fit.get("memberSpanLocal"))
|
||||
if projection_min is None or projection_max is None or member_span is None or member_span <= 0:
|
||||
return None
|
||||
first_projection = float(instances[0]["projection"])
|
||||
last_projection = float(instances[-1]["projection"])
|
||||
first_projection_display = first_projection / unit_scale if unit_scale > 0 else first_projection
|
||||
last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection
|
||||
backward_capacity = first_projection_display - projection_min - (member_span * 0.5)
|
||||
forward_capacity = projection_max - (member_span * 0.5) - last_projection_display
|
||||
if moving_side in {"before", "left"}:
|
||||
extra = backward_capacity
|
||||
elif moving_side in {"split", "both", "center"}:
|
||||
extra = 2.0 * min(backward_capacity, forward_capacity)
|
||||
else:
|
||||
extra = forward_capacity
|
||||
return max(current_display, current_display + max(0.0, extra))
|
||||
|
||||
|
||||
def _segment_scope_modes(
|
||||
signature: Mapping[str, object],
|
||||
instances: list[dict[str, object]],
|
||||
segment_index: int,
|
||||
current_backend: float,
|
||||
current_display: float,
|
||||
unit_scale: float,
|
||||
*,
|
||||
left_label: str,
|
||||
right_label: str,
|
||||
segment_label: str,
|
||||
can_execute: bool,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
modes: dict[str, dict[str, object]] = {}
|
||||
for key, label, moving_side, semantics, description in (
|
||||
(
|
||||
"fix_left_move_right",
|
||||
"固定前项,移动后侧",
|
||||
"after",
|
||||
"fix_left_move_right_group",
|
||||
f"固定 {left_label},平移 {right_label} 及其右侧所有阵列成员,右侧已有间距保持不变。",
|
||||
),
|
||||
(
|
||||
"fix_right_move_left",
|
||||
"固定后项,移动前侧",
|
||||
"before",
|
||||
"fix_right_move_left_group",
|
||||
f"固定 {right_label},平移 {left_label} 及其左侧所有阵列成员,左侧已有间距保持不变。",
|
||||
),
|
||||
(
|
||||
"split_keep_center",
|
||||
"两侧均分,中心不变",
|
||||
"split",
|
||||
"split_groups_keep_segment_center",
|
||||
f"{left_label} 及左侧向前移动一半,{right_label} 及右侧向后移动一半,保持这段间距中心不变。",
|
||||
),
|
||||
):
|
||||
max_display = _segment_max_spacing_display(
|
||||
signature,
|
||||
instances,
|
||||
segment_index,
|
||||
current_display,
|
||||
unit_scale,
|
||||
moving_side=moving_side,
|
||||
)
|
||||
mode_signature = _segment_signature(
|
||||
signature,
|
||||
segment_index,
|
||||
current_backend,
|
||||
unit_scale,
|
||||
left_label=left_label,
|
||||
right_label=right_label,
|
||||
moving_side=moving_side,
|
||||
motion_semantics=semantics,
|
||||
max_display=max_display,
|
||||
)
|
||||
range_hint = f"{label}:{description} 对象段:{segment_label},沿阵列方向由 {left_label} 到 {right_label}。"
|
||||
if max_display is not None and max_display > 0:
|
||||
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}。"
|
||||
modes[key] = {
|
||||
"label": label,
|
||||
"enabled": can_execute,
|
||||
"enabled_tip": range_hint,
|
||||
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
|
||||
"range_hint": range_hint,
|
||||
"max_value": max_display,
|
||||
"scdm_geometry_signature": mode_signature,
|
||||
}
|
||||
modes["move_single_right"] = {
|
||||
"label": "只移动后项(未开放)",
|
||||
"enabled": False,
|
||||
"disabled_tip": (
|
||||
f"只移动后项(未开放):只移动 {right_label} 会同时改变它和右侧下一个成员的间距,容易破坏阵列规律;"
|
||||
"需要交互确认后再开放。"
|
||||
),
|
||||
"range_hint": f"只移动后项(未开放):该策略暂不执行。对象段:{segment_label}。",
|
||||
"scdm_geometry_signature": _segment_signature(
|
||||
signature,
|
||||
segment_index,
|
||||
current_backend,
|
||||
unit_scale,
|
||||
left_label=left_label,
|
||||
right_label=right_label,
|
||||
moving_side="single_right",
|
||||
motion_semantics="move_only_right_instance_blocked",
|
||||
),
|
||||
}
|
||||
return modes
|
||||
|
||||
|
||||
def _segment_signature(
|
||||
signature: Mapping[str, object],
|
||||
segment_index: int,
|
||||
current_backend: float,
|
||||
unit_scale: float,
|
||||
*,
|
||||
left_label: str,
|
||||
right_label: str,
|
||||
moving_side: str,
|
||||
motion_semantics: str,
|
||||
max_display: object = None,
|
||||
) -> dict[str, object]:
|
||||
result = dict(signature)
|
||||
segment_fit = dict(result.get("supportPatternFit") if isinstance(result.get("supportPatternFit"), Mapping) else {})
|
||||
max_number = _float_or_none(max_display)
|
||||
if max_number is not None and max_number > 0:
|
||||
segment_fit["maxSegmentSpacingLocal"] = max_number
|
||||
segment_fit["maxSegmentSpacing"] = max_number * unit_scale if unit_scale > 0 else max_number
|
||||
result["supportPatternFit"] = segment_fit
|
||||
result["segmentIndex"] = segment_index
|
||||
result["segmentLabel"] = f"{left_label}-{right_label}"
|
||||
result["segmentLeftLabel"] = left_label
|
||||
result["segmentRightLabel"] = right_label
|
||||
result["segmentSpacing"] = current_backend
|
||||
result["movingSide"] = moving_side
|
||||
result["motionSemantics"] = motion_semantics
|
||||
axis = _unit_vector(_float_values(signature.get("axis")))
|
||||
instances = _sorted_pattern_instances(signature, axis) if len(axis) == 3 else []
|
||||
if segment_index < len(instances) - 1:
|
||||
result["segmentLeft"] = _segment_instance_reference(instances[segment_index], label=left_label)
|
||||
result["segmentRight"] = _segment_instance_reference(instances[segment_index + 1], label=right_label)
|
||||
result["localUnitScale"] = unit_scale
|
||||
return result
|
||||
|
||||
|
||||
def _segment_instance_reference(item: Mapping[str, object], *, label: str = "") -> dict[str, object]:
|
||||
source = item.get("source")
|
||||
if not isinstance(source, Mapping):
|
||||
return {}
|
||||
return {
|
||||
"displayLabel": label,
|
||||
"sourceObjectId": source.get("sourceObjectId"),
|
||||
"faceIds": _int_values(source.get("faceIds")),
|
||||
"bodyIndex": _int_or_none(source.get("bodyIndex")),
|
||||
"componentLocators": source.get("componentLocators") or source.get("bodyLocators") or [],
|
||||
}
|
||||
|
||||
|
||||
def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
|
||||
source = item.get("source")
|
||||
if not isinstance(source, Mapping):
|
||||
return f"成员{ordinal}"
|
||||
instance_kind = str(source.get("instanceKind") or "").strip().lower()
|
||||
if instance_kind in {"body", "part", "component"}:
|
||||
local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")])))
|
||||
if local_solid_ids:
|
||||
return f"Solid{local_solid_ids[0]}{'组' if len(local_solid_ids) > 1 else ''}"
|
||||
local_part_ids = sorted(set(_int_values(source.get("localPartIds") or [source.get("localPartId")])))
|
||||
if local_part_ids:
|
||||
return f"Part{local_part_ids[0]}{'组' if len(local_part_ids) > 1 else ''}"
|
||||
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"), include_index=False)
|
||||
if component_label:
|
||||
return component_label
|
||||
body_index = _int_or_none(source.get("bodyIndex"))
|
||||
if body_index is not None:
|
||||
return f"零件{body_index}"
|
||||
face_ids = sorted(set(_int_values(source.get("faceIds"))))
|
||||
if face_ids:
|
||||
return f"Face{face_ids[0]}{'组' if len(face_ids) > 1 else ''}"
|
||||
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"))
|
||||
if component_label:
|
||||
return component_label
|
||||
body_index = _int_or_none(source.get("bodyIndex"))
|
||||
if body_index is not None:
|
||||
return f"零件{body_index}"
|
||||
source_id = str(source.get("sourceObjectId") or "").strip()
|
||||
if source_id:
|
||||
return source_id
|
||||
return f"成员{ordinal}"
|
||||
|
||||
|
||||
def _component_locator_label(value: object, *, include_index: bool = True) -> str:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ""
|
||||
for locator in value:
|
||||
if not isinstance(locator, Mapping):
|
||||
continue
|
||||
for key in ("componentName", "name", "displayName"):
|
||||
text = str(locator.get(key) or "").strip()
|
||||
if text:
|
||||
return text
|
||||
if not include_index:
|
||||
return ""
|
||||
for locator in value:
|
||||
if not isinstance(locator, Mapping):
|
||||
continue
|
||||
component_index = _int_or_none(locator.get("componentIndex"))
|
||||
if component_index is not None:
|
||||
return f"组件{component_index + 1}"
|
||||
return ""
|
||||
|
||||
|
||||
def _unit_vector(values: list[float]) -> list[float]:
|
||||
if len(values) != 3:
|
||||
return []
|
||||
length = sum(item * item for item in values) ** 0.5
|
||||
if length <= 1.0e-12:
|
||||
return []
|
||||
return [item / length for item in values]
|
||||
|
||||
|
||||
def _point_projection(point: list[float], axis: list[float]) -> float:
|
||||
return sum(float(point[index]) * float(axis[index]) for index in range(3))
|
||||
|
||||
|
||||
def _float_or_none(value: object) -> float | None:
|
||||
try:
|
||||
return float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _value_type(value_kind: str, key: str) -> str:
|
||||
if value_kind == "vector3":
|
||||
return "vector3"
|
||||
if value_kind == "command":
|
||||
return "command"
|
||||
if key.endswith(".diameter") or key.endswith(".radius"):
|
||||
positive_suffixes = (".diameter", ".radius", ".width", ".depth", ".height", ".distance", ".thickness", ".spacing", ".segment_spacing")
|
||||
if key.endswith(positive_suffixes):
|
||||
return "positive"
|
||||
return "number"
|
||||
|
||||
@@ -132,6 +566,19 @@ def _format_value(value: object, *, value_type: str) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _current_value_available(value: object, *, value_type: str) -> bool:
|
||||
if value is None or value == "":
|
||||
return False
|
||||
if value_type == "vector3":
|
||||
return len(_float_values(value)) == 3
|
||||
if value_type in {"number", "positive"}:
|
||||
try:
|
||||
return float(str(value).strip()) > 0 if value_type == "positive" else True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _float_values(value: object) -> list[float]:
|
||||
if isinstance(value, (str, bytes)) or value is None:
|
||||
return []
|
||||
@@ -164,4 +611,11 @@ def _int_values(value: object) -> list[int]:
|
||||
return result
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["property_specs_from_scdm_cache"]
|
||||
|
||||
@@ -79,10 +79,75 @@ def validate_scdm_edit_result(
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
|
||||
if _is_removal_capability(capability_key) and (not before_signature or not after_cache):
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "removal-check-unavailable",
|
||||
"message": "Removed feature cannot be verified without the old feature signature and the new SCDM cache.",
|
||||
"summaryCheck": summary_check,
|
||||
"topologyCheck": topology_check,
|
||||
"brep": brep,
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
|
||||
if capability_key == "pattern.segment_spacing":
|
||||
check = _check_pattern_segment_spacing_edit_result(edit_result, expected_target, tolerance=tolerance)
|
||||
if check.get("ok") is not True:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": str(check.get("reason") or "target-check-failed"),
|
||||
"message": str(check.get("message") or "SCDM result did not reach the target segment spacing."),
|
||||
"targetCheck": check,
|
||||
"summaryCheck": summary_check,
|
||||
"topologyCheck": topology_check,
|
||||
"brep": brep,
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"reason": "ok",
|
||||
"message": "SCDM edit result passed the available validation checks.",
|
||||
"output_step": str(output_step),
|
||||
"matchedObject": None,
|
||||
"targetCheck": check,
|
||||
"removalCheck": {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."},
|
||||
"summaryCheck": summary_check,
|
||||
"topologyCheck": topology_check,
|
||||
"brep": brep,
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
|
||||
matched: dict[str, object] | None = None
|
||||
removal_check: dict[str, object] = {"ok": None, "reason": "not-run", "message": "Removal check is not needed for this capability."}
|
||||
if before_signature and after_cache:
|
||||
match = match_scdm_object_by_signature(before_signature, after_cache, capability_key=capability_key)
|
||||
status = str(match.get("status") or "")
|
||||
if _is_removal_capability(capability_key):
|
||||
removal_check = _check_removed_object_match(match)
|
||||
if removal_check.get("ok") is not True:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": str(removal_check.get("reason") or "feature-still-present"),
|
||||
"message": str(removal_check.get("message") or "Removed feature is still present in the new SCDM cache."),
|
||||
"removalCheck": removal_check,
|
||||
"match": match,
|
||||
"brep": brep,
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
check = {"ok": True, "reason": "removed", "message": "Target feature disappeared from the new SCDM cache."}
|
||||
return {
|
||||
"ok": True,
|
||||
"reason": "ok",
|
||||
"message": "SCDM edit result passed the available validation checks.",
|
||||
"output_step": str(output_step),
|
||||
"matchedObject": None,
|
||||
"targetCheck": check,
|
||||
"removalCheck": removal_check,
|
||||
"summaryCheck": summary_check,
|
||||
"topologyCheck": topology_check,
|
||||
"brep": brep,
|
||||
"editResult": dict(edit_result),
|
||||
}
|
||||
if status != "unique":
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -118,6 +183,7 @@ def validate_scdm_edit_result(
|
||||
"output_step": str(output_step),
|
||||
"matchedObject": matched,
|
||||
"targetCheck": check,
|
||||
"removalCheck": removal_check,
|
||||
"summaryCheck": summary_check,
|
||||
"topologyCheck": topology_check,
|
||||
"brep": brep,
|
||||
@@ -251,6 +317,8 @@ def check_scdm_unedited_objects(
|
||||
continue
|
||||
if edited_signature and _same_signature_subject(signature, edited_signature):
|
||||
continue
|
||||
if edited_signature and _is_expected_pattern_spacing_subject(signature, edited_signature, capability_key=capability_key):
|
||||
continue
|
||||
checked += 1
|
||||
match = match_scdm_object_by_signature(signature, after_cache, capability_key=capability_key)
|
||||
status = str(match.get("status") or "")
|
||||
@@ -324,15 +392,88 @@ def check_scdm_target(
|
||||
actual_vector = _vector(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "center"))
|
||||
expected_vector = _vector(expected_target)
|
||||
return _vector_check(actual_vector, expected_vector, "hole.position", tolerance)
|
||||
if capability_key in {"slot.position", "boss.position", "pattern.instance_position"}:
|
||||
actual_vector = _vector(
|
||||
_capability_value(raw_object, capability_key)
|
||||
or _geometry_value(raw_object, "center")
|
||||
or _geometry_value(raw_object, "axisCenter")
|
||||
or _geometry_value(raw_object, "instanceCenter")
|
||||
)
|
||||
expected_vector = _vector(expected_target)
|
||||
return _vector_check(actual_vector, expected_vector, capability_key, tolerance)
|
||||
if capability_key == "slot.width":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "width"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "slot.width", tolerance)
|
||||
if capability_key == "slot.depth":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "depth"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "slot.depth", tolerance)
|
||||
if capability_key == "boss.height":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "height"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "boss.height", tolerance)
|
||||
if capability_key == "boss.diameter":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "diameter"))
|
||||
if actual is None:
|
||||
radius = _number(_geometry_value(raw_object, "radius"))
|
||||
actual = radius * 2.0 if radius is not None else None
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "boss.diameter", tolerance)
|
||||
if capability_key == "round.radius":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "radius"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "round.radius", tolerance)
|
||||
if capability_key == "chamfer.distance":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "distance") or _geometry_value(raw_object, "offset"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "chamfer.distance", tolerance)
|
||||
if capability_key == "pattern.spacing":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "spacing") or _geometry_value(raw_object, "pitch"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "pattern.spacing", tolerance)
|
||||
if capability_key == "pattern.segment_spacing":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "segmentSpacing") or _geometry_value(raw_object, "spacing") or _geometry_value(raw_object, "pitch"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "pattern.segment_spacing", tolerance)
|
||||
if capability_key == "shell.thickness":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "thickness"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "shell.thickness", tolerance)
|
||||
if capability_key == "face.offset":
|
||||
actual = _number(_capability_value(raw_object, capability_key) or _geometry_value(raw_object, "offset") or _geometry_value(raw_object, "planeOffset"))
|
||||
expected = _number(expected_target)
|
||||
return _number_check(actual, expected, "face.offset", tolerance)
|
||||
if capability_key == "feature.fill":
|
||||
return {"ok": True, "reason": "not-applicable", "message": "feature.fill is checked by object disappearance in the caller."}
|
||||
if _is_removal_capability(capability_key):
|
||||
return {"ok": True, "reason": "not-applicable", "message": f"{capability_key} is checked by object disappearance in the caller."}
|
||||
return {"ok": None, "reason": "unsupported-post-check", "message": f"No target checker is registered for {capability_key}."}
|
||||
|
||||
|
||||
def _check_pattern_segment_spacing_edit_result(
|
||||
edit_result: Mapping[str, object],
|
||||
expected_target: object,
|
||||
*,
|
||||
tolerance: float,
|
||||
) -> dict[str, object]:
|
||||
applied = edit_result.get("applied")
|
||||
nested = edit_result.get("result")
|
||||
if not isinstance(applied, Mapping) and isinstance(nested, Mapping):
|
||||
applied = nested.get("applied")
|
||||
if not isinstance(applied, Mapping):
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "missing-edit-applied",
|
||||
"message": "SCDM edit result did not report the applied local segment spacing.",
|
||||
}
|
||||
actual = _number(applied.get("segmentSpacing") or applied.get("targetSpacing"))
|
||||
expected = _number(expected_target)
|
||||
check = _number_check(actual, expected, "pattern.segment_spacing", tolerance)
|
||||
if check.get("ok") is True:
|
||||
check["segmentIndex"] = applied.get("segmentIndex")
|
||||
check["spacingMode"] = applied.get("spacingMode")
|
||||
return check
|
||||
|
||||
|
||||
def check_scdm_summary_delta(
|
||||
before_cache: Mapping[str, object],
|
||||
after_cache: Mapping[str, object],
|
||||
@@ -401,6 +542,27 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
|
||||
if before_surface and before_surface == after_surface:
|
||||
score += 1.0
|
||||
|
||||
before_components = _component_locator_keys(before.get("componentLocators") or before.get("bodyLocators"))
|
||||
after_components = _component_locator_keys(after.get("componentLocators") or after.get("bodyLocators"))
|
||||
if before_components and after_components:
|
||||
if before_components & after_components:
|
||||
score += 3.0
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
before_body = _int_or_none(before.get("bodyIndex"))
|
||||
after_body = _int_or_none(after.get("bodyIndex"))
|
||||
if before_body is not None and after_body is not None and before_body == after_body:
|
||||
score += 2.0
|
||||
before_face_ordinal = _int_or_none(before.get("faceOrdinal"))
|
||||
after_face_ordinal = _int_or_none(after.get("faceOrdinal"))
|
||||
if before_face_ordinal is not None and before_face_ordinal == after_face_ordinal:
|
||||
score += 1.0
|
||||
before_edge_ordinal = _int_or_none(before.get("edgeOrdinal"))
|
||||
after_edge_ordinal = _int_or_none(after.get("edgeOrdinal"))
|
||||
if before_edge_ordinal is not None and before_edge_ordinal == after_edge_ordinal:
|
||||
score += 1.0
|
||||
|
||||
before_faces = set(_int_values(before.get("faceIds")))
|
||||
after_faces = set(_int_values(after.get("faceIds")))
|
||||
if before_faces and after_faces:
|
||||
@@ -413,8 +575,13 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
|
||||
if before_edges and after_edges and before_edges & after_edges:
|
||||
score += 0.5
|
||||
|
||||
if capability_key != "hole.position":
|
||||
center_score = _vector_distance_score(_vector(before.get("center")), _vector(after.get("center")))
|
||||
if not _is_position_capability(capability_key):
|
||||
before_center = _vector(before.get("center"))
|
||||
after_center = _vector(after.get("center"))
|
||||
if _is_removal_capability(capability_key) and before_center and after_center:
|
||||
if _vector_error(before_center, after_center) > _vector_tolerance(before_center, after_center):
|
||||
return 0.0
|
||||
center_score = _vector_distance_score(before_center, after_center)
|
||||
score += center_score
|
||||
|
||||
axis_score = _axis_score(_vector(before.get("axis")), _vector(after.get("axis")))
|
||||
@@ -426,6 +593,98 @@ def _signature_score(before: Mapping[str, object], after: Mapping[str, object],
|
||||
return score
|
||||
|
||||
|
||||
def _is_position_capability(capability_key: str) -> bool:
|
||||
return capability_key in {"hole.position", "slot.position", "boss.position", "pattern.instance_position"} or capability_key.endswith(".position")
|
||||
|
||||
|
||||
def _is_removal_capability(capability_key: str) -> bool:
|
||||
return capability_key in {"feature.fill", "feature.delete_round_or_chamfer"} or capability_key.endswith(".remove") or capability_key.startswith("feature.delete")
|
||||
|
||||
|
||||
def _check_removed_object_match(match: Mapping[str, object]) -> dict[str, object]:
|
||||
status = str(match.get("status") or "")
|
||||
if status == "none":
|
||||
return {"ok": True, "reason": "removed", "message": "Edited feature is no longer present in the new SCDM cache."}
|
||||
if status == "unique":
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "feature-still-present",
|
||||
"message": "SCDM reported success, but the edited feature still matches an object in the new cache.",
|
||||
"match": dict(match),
|
||||
}
|
||||
if status == "multiple":
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "feature-removal-ambiguous",
|
||||
"message": "SCDM reported success, but multiple new objects still match the edited feature.",
|
||||
"match": dict(match),
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": f"feature-removal-{status or 'failed'}",
|
||||
"message": str(match.get("message") or "Removed feature could not be verified."),
|
||||
"match": dict(match),
|
||||
}
|
||||
|
||||
|
||||
def _is_expected_pattern_spacing_subject(
|
||||
signature: Mapping[str, object],
|
||||
edited_signature: Mapping[str, object],
|
||||
*,
|
||||
capability_key: str,
|
||||
) -> bool:
|
||||
if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}:
|
||||
return False
|
||||
if str(edited_signature.get("objectType") or "") != "linear_pattern":
|
||||
return False
|
||||
touched_faces = set(_int_values(edited_signature.get("faceIds")))
|
||||
touched_bodies = set(_int_values(edited_signature.get("bodyIndices")))
|
||||
touched_components = _component_locator_keys(edited_signature.get("componentLocators"))
|
||||
for instance in _pattern_instances(edited_signature):
|
||||
touched_faces.update(_int_values(instance.get("faceIds")))
|
||||
body_index = _int_or_none(instance.get("bodyIndex"))
|
||||
if body_index is not None:
|
||||
touched_bodies.add(body_index)
|
||||
touched_bodies.update(_int_values(instance.get("bodyIndices")))
|
||||
touched_components.update(_component_locator_keys(instance.get("componentLocators") or instance.get("bodyLocators")))
|
||||
|
||||
subject_faces = set(_int_values(signature.get("faceIds")))
|
||||
if touched_faces and subject_faces and touched_faces.intersection(subject_faces):
|
||||
return True
|
||||
subject_body = _int_or_none(signature.get("bodyIndex"))
|
||||
if subject_body is not None and subject_body in touched_bodies:
|
||||
return True
|
||||
subject_bodies = set(_int_values(signature.get("bodyIndices")))
|
||||
if touched_bodies and subject_bodies and touched_bodies.intersection(subject_bodies):
|
||||
return True
|
||||
subject_components = _component_locator_keys(signature.get("componentLocators") or signature.get("bodyLocators"))
|
||||
return bool(touched_components and subject_components and touched_components.intersection(subject_components))
|
||||
|
||||
|
||||
def _pattern_instances(signature: Mapping[str, object]) -> list[Mapping[str, object]]:
|
||||
value = signature.get("patternInstances")
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _component_locator_keys(value: object) -> set[str]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return set()
|
||||
result: set[str] = set()
|
||||
for locator in value:
|
||||
if not isinstance(locator, Mapping):
|
||||
continue
|
||||
path = _ordered_int_values(locator.get("componentPath"))
|
||||
if path:
|
||||
result.add("path:" + ".".join(str(item) for item in path))
|
||||
continue
|
||||
component_index = _int_or_none(locator.get("componentIndex"))
|
||||
if component_index is not None:
|
||||
result.add("index:" + str(component_index))
|
||||
return result
|
||||
|
||||
|
||||
def _signature_has_enough_identity(signature: Mapping[str, object]) -> bool:
|
||||
if _vector(signature.get("center")) and _vector(signature.get("axis")):
|
||||
return True
|
||||
@@ -473,12 +732,13 @@ def _unchanged_signature_still_matches(before: Mapping[str, object], after: Mapp
|
||||
if abs(float(before_diameter) - float(after_diameter)) > tolerance:
|
||||
return False
|
||||
|
||||
before_offset = _number(before.get("planeOffset"))
|
||||
after_offset = _number(after.get("planeOffset"))
|
||||
if before_offset is not None and after_offset is not None:
|
||||
tolerance = max(abs(before_offset), abs(after_offset), 1.0) * 1.0e-5
|
||||
if abs(float(before_offset) - float(after_offset)) > tolerance:
|
||||
return False
|
||||
for key in ("planeOffset", "width", "depth", "height", "distance", "spacing", "pitch", "thickness"):
|
||||
before_value = _number(before.get(key))
|
||||
after_value = _number(after.get(key))
|
||||
if before_value is not None and after_value is not None:
|
||||
tolerance = max(abs(before_value), abs(after_value), 1.0) * 1.0e-5
|
||||
if abs(float(before_value) - float(after_value)) > tolerance:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -645,6 +905,22 @@ def _int_values(value: object) -> list[int]:
|
||||
return result
|
||||
|
||||
|
||||
def _ordered_int_values(value: object) -> list[int]:
|
||||
if isinstance(value, (str, bytes)) or value is None:
|
||||
return []
|
||||
try:
|
||||
values = list(value) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
return []
|
||||
result: list[int] = []
|
||||
for item in values:
|
||||
try:
|
||||
result.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def _raw_summary(cache: Mapping[str, object]) -> Mapping[str, object]:
|
||||
diagnostics = cache.get("diagnostics")
|
||||
if not isinstance(diagnostics, Mapping):
|
||||
|
||||
@@ -1105,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
|
||||
|
||||
|
||||
SELECTION_MODE_LABELS = {
|
||||
"Part": "零件",
|
||||
"Part": "Part",
|
||||
"Solid": "Solid",
|
||||
"Face": "Face",
|
||||
"Edge": "Edge",
|
||||
"Feature": "特征",
|
||||
"Feature": "Feature",
|
||||
}
|
||||
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
|
||||
SELECTION_MODE_VALUES.update(
|
||||
{
|
||||
"装配零件": "Part",
|
||||
"零件": "Part",
|
||||
"实体": "Solid",
|
||||
"面": "Face",
|
||||
"边": "Edge",
|
||||
"智能特征": "Feature",
|
||||
"Solid": "Solid",
|
||||
"Face": "Face",
|
||||
"Edge": "Edge",
|
||||
"特征": "Feature",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SURFACE_VALUE_LABELS = {
|
||||
|
||||
+86
-16
@@ -21,9 +21,9 @@ from PySide6.QtWidgets import (
|
||||
from .model import StepModel
|
||||
from .asitus_bridge import run_asitus_hole_recognition
|
||||
from .records import OperationRecord
|
||||
from .scdm_feature_mapper import attach_local_face_ids_to_scdm_cache, map_scdm_raw_features
|
||||
from .scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION, attach_local_face_ids_to_scdm_cache, map_scdm_raw_features
|
||||
from .scdm_probe import run_scdm_probe
|
||||
from .scdm_schema import write_json
|
||||
from .scdm_schema import default_scdm_work_dir, file_fingerprint, read_json, write_json
|
||||
from .ui_helpers import * # noqa: F403
|
||||
from .workers import EditWorker, LoadWorker, ScanWorker
|
||||
|
||||
@@ -1308,11 +1308,13 @@ class WindowCoreMixin:
|
||||
f"Loaded {self.step_path.name}; 大模型已跳过全量边线补绘,旋转会更流畅,切到 Edge 选择时再按需生成。"
|
||||
)
|
||||
pending_scdm_reload = isinstance(getattr(self, "pending_scdm_edit_reload", None), dict)
|
||||
if large_interaction_model and not pending_scdm_reload:
|
||||
scdm_cache_restored = False if pending_scdm_reload else self._restore_scdm_feature_cache_from_disk()
|
||||
if large_interaction_model and not pending_scdm_reload and not scdm_cache_restored:
|
||||
self._defer_large_model_recognition_preloads()
|
||||
else:
|
||||
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload)
|
||||
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload))
|
||||
if pending_scdm_reload or not scdm_cache_restored:
|
||||
QTimer.singleShot(240, lambda: self._start_scdm_probe_preload(force=pending_scdm_reload))
|
||||
|
||||
def _large_model_interaction_mode(self, stats: object | None = None) -> bool:
|
||||
if stats is not None:
|
||||
@@ -1354,9 +1356,84 @@ class WindowCoreMixin:
|
||||
if hasattr(self, "_update_current_capability_panel"):
|
||||
self._update_current_capability_panel()
|
||||
|
||||
def _install_scdm_feature_cache(self, cache: dict[str, object], *, cache_path: str = "", message: str = "") -> None:
|
||||
self.scdm_feature_cache = dict(cache)
|
||||
self.scdm_feature_cache_state = "ready"
|
||||
self.scdm_feature_cache_message = message or "SCDM 可修改参数识别完成。"
|
||||
self.scdm_feature_cache_path = str(cache_path or "")
|
||||
if self.model is not None:
|
||||
try:
|
||||
self.model.scdm_feature_cache = dict(cache)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self, "_update_current_capability_panel"):
|
||||
self._update_current_capability_panel()
|
||||
if hasattr(self, "_refresh_property_editor"):
|
||||
self._refresh_property_editor()
|
||||
|
||||
def _restore_scdm_feature_cache_from_disk(self) -> bool:
|
||||
if self.model is None or self.step_path is None:
|
||||
return False
|
||||
step_path = Path(self.step_path)
|
||||
try:
|
||||
fingerprint = file_fingerprint(step_path)
|
||||
except OSError:
|
||||
return False
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
work_dir = default_scdm_work_dir(step_path, project_root=project_root, fingerprint=fingerprint)
|
||||
cache_path = work_dir / "scdm_feature_cache.json"
|
||||
raw_path = work_dir / "scdm_raw_features.json"
|
||||
|
||||
cache: dict[str, object] | None = None
|
||||
if cache_path.is_file():
|
||||
try:
|
||||
candidate = read_json(cache_path)
|
||||
revision = _safe_int_or_none(candidate.get("mapperRevision")) or 0
|
||||
if str(candidate.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION:
|
||||
cache = candidate
|
||||
except Exception:
|
||||
cache = None
|
||||
|
||||
if cache is None and raw_path.is_file():
|
||||
try:
|
||||
raw = read_json(raw_path)
|
||||
raw_model = raw.get("model")
|
||||
raw_fingerprint = str(raw_model.get("fingerprint") or "") if isinstance(raw_model, dict) else ""
|
||||
if raw_fingerprint == fingerprint:
|
||||
cache = attach_local_face_ids_to_scdm_cache(
|
||||
map_scdm_raw_features(raw),
|
||||
self._scdm_local_face_signatures(),
|
||||
)
|
||||
write_json(cache_path, cache)
|
||||
except Exception:
|
||||
cache = None
|
||||
|
||||
if cache is None:
|
||||
return False
|
||||
self._install_scdm_feature_cache(
|
||||
cache,
|
||||
cache_path=str(cache_path),
|
||||
message="已从本地 SCDM 识别缓存恢复;模型变更后会重新识别。",
|
||||
)
|
||||
self.statusBar().showMessage("已恢复本地 SCDM 识别缓存,参数表可直接使用。")
|
||||
return True
|
||||
|
||||
def _current_scdm_feature_cache_matches_loaded_step(self) -> bool:
|
||||
if self.step_path is None or not isinstance(getattr(self, "scdm_feature_cache", None), dict):
|
||||
return False
|
||||
cache = getattr(self, "scdm_feature_cache", None)
|
||||
try:
|
||||
fingerprint = file_fingerprint(Path(self.step_path))
|
||||
except OSError:
|
||||
return False
|
||||
revision = _safe_int_or_none(cache.get("mapperRevision")) or 0
|
||||
return str(cache.get("modelFingerprint") or "") == fingerprint and revision >= SCDM_FEATURE_CACHE_REVISION
|
||||
|
||||
def _start_scdm_probe_preload(self, *, force: bool = False) -> None:
|
||||
if self.model is None or self.step_path is None:
|
||||
return
|
||||
if not force and self._current_scdm_feature_cache_matches_loaded_step():
|
||||
return
|
||||
if not force and self._large_model_interaction_mode():
|
||||
self._defer_large_model_recognition_preloads()
|
||||
return
|
||||
@@ -1476,21 +1553,14 @@ class WindowCoreMixin:
|
||||
return
|
||||
if isinstance(result.get("backend"), dict):
|
||||
self.scdm_backend_status = dict(result["backend"])
|
||||
self.scdm_feature_cache = dict(cache)
|
||||
self.scdm_feature_cache_state = "ready"
|
||||
self.scdm_feature_cache_message = "SCDM 可修改参数识别完成。"
|
||||
self.scdm_feature_cache_path = str(result.get("cache_path") or "")
|
||||
try:
|
||||
self.model.scdm_feature_cache = dict(cache)
|
||||
except Exception:
|
||||
pass
|
||||
self._install_scdm_feature_cache(
|
||||
dict(cache),
|
||||
cache_path=str(result.get("cache_path") or ""),
|
||||
message="SCDM 可修改参数识别完成。",
|
||||
)
|
||||
objects = cache.get("objects")
|
||||
count = len(objects) if isinstance(objects, list) else 0
|
||||
self.statusBar().showMessage(f"SCDM 可修改参数识别完成:{count} 个产品化对象。")
|
||||
if hasattr(self, "_update_current_capability_panel"):
|
||||
self._update_current_capability_panel()
|
||||
if hasattr(self, "_refresh_property_editor"):
|
||||
self._refresh_property_editor()
|
||||
if hasattr(self, "_finish_pending_scdm_edit_reload"):
|
||||
self._finish_pending_scdm_edit_reload(cache_ready=True)
|
||||
finally:
|
||||
|
||||
@@ -42,6 +42,7 @@ from .relation_formulas import (
|
||||
parse_relation_formula,
|
||||
relation_value_to_text,
|
||||
rewrite_relation_formula_ids,
|
||||
validate_relation_formula_graph,
|
||||
)
|
||||
from .scdm_backend import resolve_scdm_backend
|
||||
from .scdm_edit_runner import run_scdm_edit_job
|
||||
@@ -3100,6 +3101,7 @@ class WindowStateMixin:
|
||||
text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else ""
|
||||
try:
|
||||
formula = parse_relation_formula(text)
|
||||
self._validate_relation_formula_graph(formula)
|
||||
if not replay_active:
|
||||
self._validate_relation_formula_references(formula)
|
||||
except RelationFormulaError as exc:
|
||||
@@ -3453,6 +3455,18 @@ class WindowStateMixin:
|
||||
for ref in formula.references:
|
||||
self._relation_value_for_ref(ref)
|
||||
|
||||
def _validate_relation_formula_graph(self, new_formula) -> None:
|
||||
formulas = []
|
||||
for item in getattr(self, "relation_formula_items", []) or []:
|
||||
if not bool(item.get("enabled", True)):
|
||||
continue
|
||||
try:
|
||||
formulas.append(parse_relation_formula(str(item.get("text") or "")))
|
||||
except RelationFormulaError:
|
||||
continue
|
||||
formulas.append(new_formula)
|
||||
validate_relation_formula_graph(formulas)
|
||||
|
||||
def _relation_parameter_supported(self, ref: ObjectParameterRef, *, target: bool = False) -> None:
|
||||
if target and self._relation_visible_spec_for_ref(ref) is not None:
|
||||
return
|
||||
@@ -4565,7 +4579,7 @@ class WindowStateMixin:
|
||||
return inner_wires > 0 or boundary_edges >= 16
|
||||
|
||||
def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str:
|
||||
if self.selected_kind not in {"feature", "face", "edge"}:
|
||||
if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}:
|
||||
return ""
|
||||
if self.model is None:
|
||||
return ""
|
||||
@@ -8915,8 +8929,12 @@ class WindowStateMixin:
|
||||
|
||||
def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object:
|
||||
value_type = str(spec.get("value_type") or "number")
|
||||
unit_scale = _float_or_none(spec.get("scdm_unit_scale"))
|
||||
if unit_scale is None or unit_scale <= 0:
|
||||
unit_scale = 1.0
|
||||
if value_type == "vector3":
|
||||
return list(self._parse_property_vector3(text))
|
||||
values = list(self._parse_property_vector3(text))
|
||||
return [value * unit_scale for value in values] if self._scdm_property_uses_length_units(spec) else values
|
||||
if value_type in {"number", "positive"}:
|
||||
try:
|
||||
value = float(text)
|
||||
@@ -8924,11 +8942,32 @@ class WindowStateMixin:
|
||||
raise ValueError("请输入数字形式的目标值。") from exc
|
||||
if value_type == "positive" and value <= 0:
|
||||
raise ValueError("请输入大于 0 的目标值。")
|
||||
return value
|
||||
return value * unit_scale if self._scdm_property_uses_length_units(spec) else value
|
||||
if value_type == "command":
|
||||
return True
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _scdm_property_uses_length_units(spec: dict[str, object]) -> bool:
|
||||
key = str(spec.get("scdm_capability_key") or spec.get("key") or "")
|
||||
value_type = str(spec.get("value_type") or "")
|
||||
if value_type == "vector3":
|
||||
return True
|
||||
suffixes = (
|
||||
".diameter",
|
||||
".radius",
|
||||
".offset",
|
||||
".width",
|
||||
".depth",
|
||||
".height",
|
||||
".distance",
|
||||
".thickness",
|
||||
".spacing",
|
||||
".segment_spacing",
|
||||
".position",
|
||||
)
|
||||
return key.endswith(suffixes)
|
||||
|
||||
@Slot(object)
|
||||
def _finish_scdm_edit_action(self, result: object) -> None:
|
||||
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_edit_action(result)):
|
||||
|
||||
Reference in New Issue
Block a user