feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距

接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
This commit is contained in:
2026-08-19 18:02:47 +08:00
parent a3eb7e2476
commit 4e7877e05c
21 changed files with 5646 additions and 491 deletions
+286 -10
View File
@@ -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):