Files
pythonocc-step-editor/step_editor/scdm_property_specs.py
T
nikelaluo 4e7877e05c feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
2026-08-19 18:02:47 +08:00

622 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections.abc import Iterable, Mapping
def property_specs_from_scdm_cache(
cache: Mapping[str, object],
*,
selected_face_ids: Iterable[int] = (),
selected_edge_ids: Iterable[int] = (),
execution_ready: bool | Iterable[str] = False,
) -> list[dict[str, object]]:
face_ids = {int(item) for item in selected_face_ids}
edge_ids = {int(item) for item in selected_edge_ids}
if not face_ids and not edge_ids:
return []
objects = cache.get("objects")
if not isinstance(objects, list):
return []
specs: list[dict[str, object]] = []
for item in objects:
if not isinstance(item, Mapping) or not _object_matches(item, face_ids=face_ids, edge_ids=edge_ids):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
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
def _object_matches(raw_object: Mapping[str, object], *, face_ids: set[int], edge_ids: set[int]) -> bool:
signature = raw_object.get("geometrySignature")
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))
def _capability_spec(
raw_object: Mapping[str, object],
capability: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> dict[str, object] | None:
key = str(capability.get("key") or "").strip()
label = str(capability.get("displayName") or key).strip()
if not key or not label:
return None
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_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 = ""
enabled_tip = (
f"SCDM 已识别“{label}”可由 {backend_operation or '后端命令'} 修改;"
f"执行后会用 {post_check or '结果回测'} 校验。"
)
elif block_reason:
enabled_tip = ""
disabled_tip = f"SCDM 已识别该对象,但当前能力被阻止:{block_reason}"
else:
enabled_tip = ""
disabled_tip = "SCDM 已识别该参数,但 S5 修改执行器还没有接入;当前只作为后端识别结果缓存,不开放执行。"
return {
"key": f"scdm:{key}",
"label": label,
"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,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": str(capability.get("defaultIntent") or "SCDM"),
"action": "apply_scdm_property_edit",
"value_type": value_type,
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"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": 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"
positive_suffixes = (".diameter", ".radius", ".width", ".depth", ".height", ".distance", ".thickness", ".spacing", ".segment_spacing")
if key.endswith(positive_suffixes):
return "positive"
return "number"
def _capability_execution_ready(key: str, execution_ready: bool | Iterable[str]) -> bool:
if isinstance(execution_ready, bool):
return execution_ready
try:
return key in {str(item) for item in execution_ready}
except TypeError:
return False
def _format_value(value: object, *, value_type: str) -> str:
if value is None:
return ""
if value_type == "vector3":
values = _float_values(value)
return f"({values[0]:g}, {values[1]:g}, {values[2]:g})" if len(values) == 3 else ""
if isinstance(value, float):
return f"{value:g}"
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 []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[float] = []
for item in values[:3]:
try:
result.append(float(item))
except (TypeError, ValueError):
return []
return result
def _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 _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
__all__ = ["property_specs_from_scdm_cache"]