feat: 完善 SCDM-first 参数化编辑交付版

This commit is contained in:
2026-08-20 17:12:01 +08:00
parent 4e7877e05c
commit b4feab24d2
21 changed files with 3015 additions and 1729 deletions
+381 -33
View File
@@ -50,6 +50,8 @@ def prepare_scdm_edit_job(
"roadmap_stage": definition.roadmap_stage,
}
converted_target = _target_value_for_job(target_value, value_kind=definition.value_kind)
# 先在本软件侧做快速守门,避免为了明显非法或证据不足的输入启动 SCDM,
# 否则用户会等很久才得到一个本可提前说明的失败。
preflight = _preflight_scdm_edit_job(
definition.key,
converted_target,
@@ -74,8 +76,11 @@ def prepare_scdm_edit_job(
script_path = work_dir / "scdm_edit.py"
operation = backend_operation or definition.backend_operation
# 旧 result.step/error.json 不能留着复用;否则 SCDM 启动失败时可能被误判为成功。
_clear_stale_outputs((output_path, result_path, error_path))
# 这是本软件和 SCDM 的后台协议。UI 不直接调用 SCDM API,
# 而是写 job JSON + 临时脚本,再用 SpaceClaim.exe /RunScript 执行。
job = {
"schemaVersion": SCDM_EDIT_SCHEMA_VERSION,
"adapter": SCDM_EDIT_ADAPTER,
@@ -210,6 +215,8 @@ def run_prepared_scdm_edit_job(
command = scdm_run_script_command(backend.path, script_path)
run = runner or subprocess.run
try:
# SpaceClaim 是外部 GUI/几何进程,必须独立启动并加超时;
# Python/Qt 主进程只等待结果文件,避免 SCDM 卡死时拖垮界面。
completed = run(
command,
cwd=str(script_path.parent),
@@ -286,9 +293,17 @@ def _preflight_scdm_edit_job(
target_value: object,
object_signature: Mapping[str, object],
) -> dict[str, object]:
basic = _preflight_basic_target(capability_key, target_value, object_signature)
if basic.get("ok") is False:
return basic
required = _preflight_required_geometry(capability_key, object_signature)
if required.get("ok") is False:
return required
if capability_key not in {"pattern.spacing", "pattern.segment_spacing"}:
return {"ok": True, "reason": "ok"}
if _is_body_pattern_signature(object_signature):
# 实体/组件阵列必须有 occurrence locator。只有 bodyIndex 时移动的可能是共享定义,
# 不是用户眼里那个独立阵列成员。
if not _body_pattern_has_component_locators(object_signature):
return {
"ok": False,
@@ -345,6 +360,203 @@ def _preflight_scdm_edit_job(
}
def _preflight_basic_target(
capability_key: str,
target_value: object,
object_signature: Mapping[str, object],
) -> dict[str, object]:
if capability_key in _positive_number_capabilities():
target = _float_or_none(target_value)
if target is not None and target <= 0:
return {
"ok": False,
"reason": "target-value-illegal",
"message": "目标值必须大于 0。",
}
current_number = _current_number_for_capability(capability_key, object_signature)
target_number = _float_or_none(target_value)
if current_number is not None and target_number is not None:
tolerance = max(abs(current_number), abs(target_number), 1.0) * 1.0e-9
if abs(current_number - target_number) <= tolerance:
return {
"ok": False,
"reason": "target-already-current",
"message": "目标值与当前值相同,不需要修改。",
"current": current_number,
"target": target_number,
}
current_vector = _current_vector_for_capability(capability_key, object_signature)
target_vector = _parse_vector3(target_value)
if len(current_vector) == 3 and len(target_vector) == 3:
error = sum((current_vector[index] - target_vector[index]) ** 2 for index in range(3)) ** 0.5
scale = max(max(abs(item) for item in current_vector + target_vector), 1.0)
if error <= scale * 1.0e-9:
return {
"ok": False,
"reason": "target-already-current",
"message": "目标位置与当前位置相同,不需要修改。",
"current": current_vector,
"target": target_vector,
}
return {"ok": True, "reason": "ok"}
def _preflight_required_geometry(capability_key: str, signature: Mapping[str, object]) -> dict[str, object]:
if capability_key == "slot.depth":
if _positive_number(signature.get("depth")) is None:
return _missing_signature("object-signature-missing-depth", "SCDM 已识别槽深,但缓存里没有当前槽深,不能稳定执行。")
if not _has_vector3(signature.get("depthAxis") or signature.get("depthDirection")):
return _missing_signature("object-signature-missing-depth-axis", "SCDM 已识别槽深,但缓存里没有槽深方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("depthFaceLocators", "bottomFaceLocators"), ("depthFaceOrdinals", "bottomFaceOrdinals"), ("globalDepthFaceOrdinals", "globalBottomFaceOrdinals")):
return _missing_signature("object-signature-missing-depth-face-locator", "SCDM 已识别槽深,但缓存里没有可推动的槽底面定位,不能稳定执行。")
elif capability_key == "boss.height":
if _positive_number(signature.get("height")) is None:
return _missing_signature("object-signature-missing-height", "SCDM 已识别凸台高度,但缓存里没有当前高度,不能稳定执行。")
if not _has_vector3(signature.get("axis")):
return _missing_signature("object-signature-missing-axis", "SCDM 已识别凸台高度,但缓存里没有高度方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("heightFaceLocators", "topFaceLocators"), ("heightFaceOrdinals", "topFaceOrdinals"), ("globalHeightFaceOrdinals", "globalTopFaceOrdinals")):
return _missing_signature("object-signature-missing-height-face-locator", "SCDM 已识别凸台高度,但缓存里没有可推动的顶面定位,不能稳定执行。")
elif capability_key == "boss.diameter":
if _positive_number(signature.get("diameter"), _twice(signature.get("radius"))) is None:
return _missing_signature("object-signature-missing-diameter", "SCDM 已识别凸台直径,但缓存里没有当前直径,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("diameterFaceLocators", "sideFaceLocators"), ("diameterFaceOrdinals", "sideFaceOrdinals"), ("globalDiameterFaceOrdinals", "globalSideFaceOrdinals")):
return _missing_signature("object-signature-missing-diameter-face-locator", "SCDM 已识别凸台直径,但缓存里没有可偏移的侧壁定位,不能稳定执行。")
elif capability_key == "round.radius":
if signature.get("isConstantRound") is not True:
return _missing_signature("object-signature-missing-constant-round-evidence", "SCDM 没有返回等半径圆角证据,不能稳定修改圆角半径。")
if _positive_number(signature.get("radius")) is None:
return _missing_signature("object-signature-missing-round-radius", "SCDM 已识别圆角,但缓存里没有当前半径,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return _missing_signature("object-signature-missing-round-face-locator", "SCDM 已识别圆角,但缓存里没有可定位的圆角面,不能稳定执行。")
elif capability_key == "chamfer.distance":
if signature.get("isEqualDistanceChamfer") is not True:
return _missing_signature("object-signature-missing-equal-distance-chamfer-evidence", "SCDM 没有返回等距倒角证据,不能稳定修改倒角距离。")
if _positive_number(signature.get("distance"), signature.get("offset")) is None:
return _missing_signature("object-signature-missing-chamfer-distance", "SCDM 已识别倒角,但缓存里没有当前距离,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("scdmFaceLocators",), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return _missing_signature("object-signature-missing-chamfer-face-locator", "SCDM 已识别倒角,但缓存里没有可定位的倒角面,不能稳定执行。")
elif capability_key == "shell.thickness":
if _positive_number(signature.get("thickness")) is None:
return _missing_signature("object-signature-missing-shell-thickness", "SCDM 已识别壳体厚度,但缓存里没有当前厚度,不能稳定执行。")
if not _has_vector3(signature.get("thicknessAxis") or signature.get("axis")):
return _missing_signature("object-signature-missing-shell-thickness-axis", "SCDM 已识别壳体厚度,但缓存里没有厚度方向,不能稳定执行。")
if not _has_face_locator_evidence(signature, ("wallFaceLocators", "scdmFaceLocators"), ("wallFaceOrdinals", "faceOrdinals"), ("globalWallFaceOrdinals", "globalFaceOrdinals"), minimum=2):
return _missing_signature("object-signature-missing-wall-face-locator", "SCDM 已识别壳体厚度,但缓存里没有两侧墙面定位,不能稳定执行。")
elif capability_key == "pattern.instance_position":
if not _has_vector3(signature.get("center") or signature.get("instanceCenter")):
return _missing_signature("object-signature-missing-pattern-instance-center", "SCDM 已识别阵列实例,但缓存里没有当前实例位置,不能稳定执行。")
if not _has_pattern_instance_locator(signature):
return _missing_signature("object-signature-missing-pattern-instance-locator", "SCDM 已识别阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定执行。")
return {"ok": True, "reason": "ok"}
def _missing_signature(reason: str, message: str) -> dict[str, object]:
return {"ok": False, "reason": reason, "message": message}
def _has_vector3(value: object) -> bool:
return len(_parse_vector3(value)) == 3
def _has_face_locator_evidence(
signature: Mapping[str, object],
locator_keys: tuple[str, ...],
ordinal_keys: tuple[str, ...],
global_ordinal_keys: tuple[str, ...],
*,
minimum: int = 1,
allow_scalar_face: bool = False,
) -> bool:
count = 0
for key in locator_keys:
value = signature.get(key)
if isinstance(value, (list, tuple)):
count += sum(1 for item in value if isinstance(item, Mapping))
for key in ordinal_keys + global_ordinal_keys:
value = signature.get(key)
if isinstance(value, (list, tuple)):
count += sum(1 for item in value if _int_or_none_value(item) is not None)
if count >= minimum:
return True
if allow_scalar_face and minimum <= 1:
return any(_int_or_none_value(signature.get(key)) is not None for key in ("faceOrdinal", "globalFaceOrdinal"))
return False
def _has_pattern_instance_locator(signature: Mapping[str, object]) -> bool:
if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"):
return True
if _has_face_locator_evidence(signature, (), ("faceOrdinals",), ("globalFaceOrdinals",), allow_scalar_face=True):
return True
instance_kind = str(signature.get("instanceKind") or "").strip().lower()
return instance_kind in {"body", "part", "component"} and _int_or_none_value(signature.get("bodyIndex")) is not None
def _positive_number_capabilities() -> set[str]:
return {
"hole.diameter",
"slot.width",
"slot.depth",
"boss.height",
"boss.diameter",
"round.radius",
"chamfer.distance",
"pattern.spacing",
"pattern.segment_spacing",
"shell.thickness",
}
def _current_number_for_capability(capability_key: str, signature: Mapping[str, object]) -> float | None:
if capability_key == "hole.diameter":
return _first_number(signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "face.offset":
return _first_number(signature.get("planeOffset"), signature.get("offset"), 0.0)
if capability_key == "slot.width":
return _first_number(signature.get("width"), signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "slot.depth":
return _first_number(signature.get("depth"))
if capability_key == "boss.height":
return _first_number(signature.get("height"))
if capability_key == "boss.diameter":
return _first_number(signature.get("diameter"), _twice(signature.get("radius")))
if capability_key == "round.radius":
return _first_number(signature.get("radius"))
if capability_key == "chamfer.distance":
return _first_number(signature.get("distance"), signature.get("offset"))
if capability_key == "pattern.spacing":
return _first_number(signature.get("spacing"), signature.get("pitch"))
if capability_key == "pattern.segment_spacing":
return _first_number(signature.get("segmentSpacing"))
if capability_key == "shell.thickness":
return _first_number(signature.get("thickness"))
return None
def _current_vector_for_capability(capability_key: str, signature: Mapping[str, object]) -> list[float]:
if capability_key in {"hole.position", "slot.position", "boss.position", "pattern.instance_position"}:
return _parse_vector3(signature.get("center") or signature.get("axisCenter") or signature.get("instanceCenter"))
return []
def _first_number(*values: object) -> float | None:
for value in values:
number = _float_or_none(value)
if number is not None:
return number
return None
def _positive_number(*values: object) -> float | None:
number = _first_number(*values)
return number if number is not None and number > 0 else None
def _twice(value: object) -> float | None:
number = _float_or_none(value)
return None if number is None else number * 2.0
def _float_or_none(value: object) -> float | None:
try:
return float(str(value).strip())
@@ -935,6 +1147,18 @@ def _locate_depth_faces(signature):
raise Exception('object_signature_missing_depth_face_locator')
def _locate_wall_faces(signature):
faces = _locate_named_faces(
signature,
('wallFaceLocators', 'scdmFaceLocators'),
('wallFaceOrdinals', 'faceOrdinals'),
('globalWallFaceOrdinals', 'globalFaceOrdinals'),
)
if len(faces) >= 2:
return faces
raise Exception('object_signature_missing_wall_face_locator')
def _locate_pattern_instance_faces(instance, fallback_body_index):
signature = dict(instance)
if signature.get('bodyIndex') is None and fallback_body_index is not None:
@@ -1246,41 +1470,53 @@ def _translate_selection(selection, delta):
raise Exception('capability_not_implemented: Move.Translate failed; ' + '; '.join(result.get('errors') or []))
def _delete_selection(selection, fill_errors=None):
delete = _command_type('Delete')
if delete is None:
errors = list(fill_errors or [])
errors.append('Delete command not available')
raise Exception('capability_not_implemented: Fill/Delete command not available; ' + '; '.join(errors))
variants = ((selection,), (selection, None))
fallback = _call_variants('Delete.Execute', delete.Execute, variants)
if fallback.get('ok'):
return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': list(fill_errors or [])}
errors = list(fill_errors or [])
errors.extend(fallback.get('errors') or [])
raise Exception('capability_not_implemented: Delete.Execute failed; ' + '; '.join(errors))
def _fill_selection(selection):
fill = _command_type('Fill')
if fill is None:
raise Exception('capability_not_implemented: Fill command not available')
options = _new_options('FillOptions')
for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)):
try:
if options is not None and hasattr(options, attr):
setattr(options, attr, value)
except Exception:
pass
secondary = _empty_selection()
mode = _fill_mode_three_d()
variants = []
if mode is not None:
variants.append((selection, secondary, options, mode, None))
variants.append((selection, secondary, options, mode))
variants.append((selection, None, options, mode, None))
variants.append((selection, None, options, mode))
variants.append((selection, secondary, options, None, None))
variants.append((selection, secondary, options, None))
variants.append((selection, options, None))
variants.append((selection, options))
variants.append((selection, None))
variants.append((selection,))
result = _call_variants('Fill.Execute', fill.Execute, variants)
if result.get('ok'):
return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')}
delete = _command_type('Delete')
if delete is not None:
fallback = _call_variants('Delete.Execute', delete.Execute, ((selection,),))
if fallback.get('ok'):
return {'command': 'Delete.Execute', 'apiSignature': fallback.get('signature'), 'fillErrors': result.get('errors') or []}
result['errors'].extend(fallback.get('errors') or [])
raise Exception('capability_not_implemented: Fill.Execute failed; ' + '; '.join(result.get('errors') or []))
fill_errors = []
if fill is not None:
options = _new_options('FillOptions')
for attr, value in (('AutoExtendFillArea', True), ('PatchBlend', True), ('ZipLaminarEdges', True)):
try:
if options is not None and hasattr(options, attr):
setattr(options, attr, value)
except Exception:
pass
secondary = _empty_selection()
mode = _fill_mode_three_d()
variants = []
if mode is not None:
variants.append((selection, secondary, options, mode, None))
variants.append((selection, secondary, options, mode))
variants.append((selection, None, options, mode, None))
variants.append((selection, None, options, mode))
variants.append((selection, secondary, options, None, None))
variants.append((selection, secondary, options, None))
variants.append((selection, options, None))
variants.append((selection, options))
variants.append((selection, None))
variants.append((selection,))
result = _call_variants('Fill.Execute', fill.Execute, variants)
if result.get('ok'):
return {'command': 'Fill.Execute', 'apiSignature': result.get('signature')}
fill_errors.extend(result.get('errors') or [])
else:
fill_errors.append('Fill command not available')
return _delete_selection(selection, fill_errors)
def _float_value(value):
@@ -1794,6 +2030,18 @@ def change_pattern_segment_spacing(job):
for item in located[segment_index + 1:]
)
spacing_mode = 'segment_split'
elif moving_side in ('single_left', 'only_left'):
delta = [axis_unit[index] * -delta_distance for index in range(3)]
moves = [
{'items': located[segment_index]['items'], 'kind': located[segment_index]['kind'], 'delta': delta, 'itemCount': len(located[segment_index]['items'])}
]
spacing_mode = 'segment_single_left'
elif moving_side in ('single_right', 'only_right'):
delta = [axis_unit[index] * delta_distance for index in range(3)]
moves = [
{'items': located[segment_index + 1]['items'], 'kind': located[segment_index + 1]['kind'], 'delta': delta, 'itemCount': len(located[segment_index + 1]['items'])}
]
spacing_mode = 'segment_single_right'
else:
raise Exception('capability_not_implemented: unsupported pattern segment spacing motion semantics: ' + moving_side)
if not moves:
@@ -1833,6 +2081,37 @@ def change_pattern_segment_spacing(job):
}
def move_pattern_instance(job):
target = _vector3(job['target']['value'])
signature = job['object'].get('geometrySignature') or {}
center = signature.get('center') or signature.get('instanceCenter') or []
if not isinstance(center, list) or len(center) != 3:
raise Exception('object_signature_missing_pattern_instance_center')
delta = [float(target[index]) - float(center[index]) for index in range(3)]
if _vector_length(delta) <= 1e-12:
return {'command': 'noop', 'delta': delta, 'reason': 'target already reached'}
body_index = _int_or_none(signature.get('bodyIndex'))
try:
located = _locate_pattern_instance_items(signature, body_index)
items = located.get('items') or []
if not items:
raise Exception('object_signature_missing_pattern_instance_locator')
if located.get('kind') == 'component':
if len(items) != 1:
raise Exception('component_pattern_instance_not_unique')
applied = _translate_component_occurrence(items[0], delta)
else:
applied = _translate_selection(_selection(items), delta)
applied['targetCenter'] = target
applied['previousCenter'] = center
applied['delta'] = delta
applied['targetKind'] = located.get('kind') or 'face'
applied['itemCount'] = len(items)
return applied
except Exception as exc:
raise Exception('capability_not_implemented: pattern.instance_position adapter failed; ' + str(exc))
def change_boss_height(job):
target = _float_value(job['target']['value'])
if target <= 0:
@@ -2038,6 +2317,71 @@ def change_chamfer_distance(job):
raise Exception('capability_not_implemented: chamfer.distance adapter failed; ' + str(exc))
def change_shell_thickness(job):
target = _float_value(job['target']['value'])
if target <= 0:
raise Exception('target_value_illegal: shell thickness must be positive')
signature = job['object'].get('geometrySignature') or {}
current = None
try:
current = float(signature.get('thickness'))
except Exception:
current = None
if current is None or current <= 0:
raise Exception('object_signature_missing_shell_thickness')
axis = signature.get('thicknessAxis') or signature.get('axis') or []
if not isinstance(axis, list) or len(axis) != 3:
raise Exception('object_signature_missing_shell_thickness_axis')
axis_length = _vector_length([float(axis[0]), float(axis[1]), float(axis[2])])
if axis_length <= 1e-12:
raise Exception('object_signature_missing_shell_thickness_axis')
delta_distance = target - current
if abs(delta_distance) <= 1e-12:
return {'command': 'noop', 'targetThickness': target, 'reason': 'target already reached'}
faces = _locate_wall_faces(signature)
centers = signature.get('wallFaceCenters') or []
sign = 1.0
if isinstance(centers, list) and len(centers) >= 2:
left = centers[0]
right = centers[1]
if isinstance(left, list) and len(left) == 3 and isinstance(right, list) and len(right) == 3:
projection_delta = sum((float(right[index]) - float(left[index])) * float(axis[index]) for index in range(3))
if projection_delta < 0:
sign = -1.0
moving_side = str(signature.get('movingSide') or 'after').strip().lower()
axis_unit = [float(axis[index]) / axis_length for index in range(3)]
if moving_side in ('before', 'left'):
moving_faces = [faces[0]]
delta = [axis_unit[index] * -sign * delta_distance for index in range(3)]
elif moving_side in ('split', 'both', 'center'):
left_delta = [axis_unit[index] * -sign * delta_distance * 0.5 for index in range(3)]
right_delta = [axis_unit[index] * sign * delta_distance * 0.5 for index in range(3)]
left_applied = _translate_selection(_selection([faces[0]]), left_delta)
right_applied = _translate_selection(_selection([faces[-1]]), right_delta)
return {
'command': 'Move.Translate shell wall pair',
'targetThickness': target,
'previousThickness': current,
'thicknessDelta': delta_distance,
'movingSide': moving_side,
'wallFaceCount': len(faces),
'moves': [left_applied, right_applied],
}
else:
moving_faces = [faces[-1]]
delta = [axis_unit[index] * sign * delta_distance for index in range(3)]
try:
applied = _translate_selection(_selection(moving_faces), delta)
applied['targetThickness'] = target
applied['previousThickness'] = current
applied['thicknessDelta'] = delta_distance
applied['movingSide'] = moving_side
applied['wallFaceCount'] = len(faces)
return applied
except Exception as exc:
raise Exception('capability_not_implemented: shell.thickness adapter failed; ' + str(exc))
def pull_face_offset(job):
target = _float_value(job['target']['value'])
signature = job['object'].get('geometrySignature') or {}
@@ -2097,6 +2441,8 @@ def _apply_edit(job):
return change_pattern_spacing(job)
if operation == 'change_pattern_segment_spacing':
return change_pattern_segment_spacing(job)
if operation == 'move_pattern_instance':
return move_pattern_instance(job)
if operation == 'change_boss_height':
return change_boss_height(job)
if operation == 'change_boss_diameter':
@@ -2105,6 +2451,8 @@ def _apply_edit(job):
return change_round_radius(job)
if operation == 'change_chamfer_distance':
return change_chamfer_distance(job)
if operation == 'change_shell_thickness':
return change_shell_thickness(job)
if operation == 'pull_face_offset':
return pull_face_offset(job)
if operation == 'fill_feature':