Files
pythonocc-step-editor/step_editor/scdm_feature_mapper.py
T

1138 lines
47 KiB
Python
Raw Normal View History

from __future__ import annotations
import math
from collections.abc import Iterable, Mapping
from pathlib import Path
from .scdm_capabilities import capability_definition, planned_capability_keys, productized_capability_keys
from .scdm_schema import SCDM_CACHE_SCHEMA_VERSION, payload_backend_version, payload_model_fingerprint, read_json, utc_now, write_json
def map_scdm_raw_features(raw_payload: Mapping[str, object]) -> dict[str, object]:
objects = []
diagnostics: dict[str, object] = {
"discovered_not_productized": [],
"planned_not_productized": [],
"blocked": [],
}
raw_diagnostics = _mapping(raw_payload.get("diagnostics"))
available_commands = raw_diagnostics.get("availableCommands")
available_command_names = _available_command_names(available_commands)
if isinstance(available_commands, list):
diagnostics["backend_commands"] = [dict(item) for item in available_commands if isinstance(item, Mapping)]
face_adjacency = raw_diagnostics.get("faceAdjacency")
if isinstance(face_adjacency, list):
diagnostics["face_adjacency"] = [dict(item) for item in face_adjacency if isinstance(item, Mapping)]
edge_geometry_summary = raw_diagnostics.get("edgeGeometrySummary")
if isinstance(edge_geometry_summary, Mapping):
diagnostics["edge_geometry_summary"] = dict(edge_geometry_summary)
feature_inventory = raw_diagnostics.get("featureInventory")
if isinstance(feature_inventory, Mapping):
diagnostics["feature_inventory"] = {
str(key): dict(value)
for key, value in feature_inventory.items()
if isinstance(value, Mapping)
}
raw_summary = raw_payload.get("summary")
if isinstance(raw_summary, Mapping):
diagnostics["raw_summary"] = dict(raw_summary)
raw_objects = raw_payload.get("objects")
if not isinstance(raw_objects, list):
raw_objects = []
cylindrical_group_objects = _cylindrical_face_group_objects(raw_objects)
derived_objects = _derived_feature_objects([*raw_objects, *cylindrical_group_objects])
if derived_objects:
diagnostics["derived_feature_candidates"] = [
{
"objectId": _object_id(item),
"objectType": str(item.get("objectType") or ""),
"geometrySignature": geometry_signature(item),
"rawLimitations": list(item.get("rawLimitations") or []),
}
for item in derived_objects
if isinstance(item, Mapping)
]
diagnostics["geometry_candidate_hints"] = _geometry_candidate_hints(
[*raw_objects, *cylindrical_group_objects, *derived_objects],
raw_diagnostics,
)
for raw_object in [*raw_objects, *derived_objects]:
if not isinstance(raw_object, Mapping):
continue
capability_keys = productized_capability_keys(raw_object)
planned_keys = planned_capability_keys(raw_object)
capabilities = [_capability_payload(key, raw_object, available_command_names=available_command_names) for key in capability_keys]
capabilities = [item for item in capabilities if item is not None]
normalized_object = {
"objectId": _object_id(raw_object),
"objectType": str(raw_object.get("objectType") or ""),
"sourceBackendId": str(raw_object.get("backendId") or ""),
"geometrySignature": geometry_signature(raw_object),
"capabilities": capabilities,
"blockReason": str(raw_object.get("blockReason") or ""),
}
if capabilities:
objects.append(normalized_object)
else:
diagnostics["discovered_not_productized"].append(
{
"objectId": normalized_object["objectId"],
"objectType": normalized_object["objectType"],
"sourceBackendId": normalized_object["sourceBackendId"],
"reason": "no_productized_capability",
}
)
for key in planned_keys:
definition = capability_definition(key)
diagnostics["planned_not_productized"].append(
{
"objectId": normalized_object["objectId"],
"objectType": normalized_object["objectType"],
"sourceBackendId": normalized_object["sourceBackendId"],
"capabilityKey": key,
"displayName": definition.display_name if definition else key,
"roadmapStage": definition.roadmap_stage if definition else "",
"reason": definition.block_reason if definition else "Planned capability is not productized.",
}
)
if normalized_object["blockReason"]:
diagnostics["blocked"].append(
{
"objectId": normalized_object["objectId"],
"objectType": normalized_object["objectType"],
"reason": normalized_object["blockReason"],
}
)
for raw_object in cylindrical_group_objects:
capability_keys = productized_capability_keys(raw_object)
capabilities = [_capability_payload(key, raw_object, available_command_names=available_command_names) for key in capability_keys]
capabilities = [item for item in capabilities if item is not None]
normalized_object = {
"objectId": _object_id(raw_object),
"objectType": str(raw_object.get("objectType") or ""),
"sourceBackendId": str(raw_object.get("backendId") or ""),
"geometrySignature": geometry_signature(raw_object),
"capabilities": capabilities,
"blockReason": str(raw_object.get("blockReason") or ""),
}
if capabilities:
objects.append(normalized_object)
else:
diagnostics["discovered_not_productized"].append(
{
"objectId": normalized_object["objectId"],
"objectType": normalized_object["objectType"],
"sourceBackendId": normalized_object["sourceBackendId"],
"reason": "no_productized_capability",
}
)
return {
"schemaVersion": SCDM_CACHE_SCHEMA_VERSION,
"source": "SCDM",
"createdAt": utc_now(),
"modelFingerprint": payload_model_fingerprint(raw_payload),
"backendVersion": payload_backend_version(raw_payload),
"objects": objects,
"diagnostics": diagnostics,
}
def _geometry_candidate_hints(
raw_objects: list[object],
raw_diagnostics: Mapping[str, object],
) -> list[dict[str, object]]:
inventory = _mapping(raw_diagnostics.get("featureInventory"))
object_counts = _normalized_count_dict(inventory.get("objectTypeCounts"))
surface_counts = _normalized_count_dict(inventory.get("surfaceTypeCounts"))
curve_counts = _normalized_count_dict(inventory.get("curveTypeCounts"))
operation_counts = _normalized_count_dict(inventory.get("operationCounts"))
scanned = _scan_raw_feature_counts(raw_objects)
object_counts = _merge_count_dicts(object_counts, scanned["objectTypeCounts"])
surface_counts = _merge_count_dicts(surface_counts, scanned["surfaceTypeCounts"])
curve_counts = _merge_count_dicts(curve_counts, scanned["curveTypeCounts"])
operation_counts = _merge_count_dicts(operation_counts, scanned["operationCounts"])
edge_summary = _mapping(raw_diagnostics.get("edgeGeometrySummary"))
edge_kind_counts = _normalized_count_dict(edge_summary.get("edgeKindCounts"))
circular_edges = _count_value(edge_summary.get("circularEdgeCount")) or edge_kind_counts.get("circular", 0)
linear_edges = edge_kind_counts.get("linear", 0)
radius_buckets = edge_summary.get("circularRadiusBuckets")
radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0
cylinder_faces = surface_counts.get("cylinder", 0)
planar_faces = surface_counts.get("plane", 0)
round_objects = object_counts.get("round", 0) + object_counts.get("fillet", 0)
slot_objects = object_counts.get("slot", 0) + object_counts.get("obround_slot", 0) + object_counts.get("rectangular_slot", 0)
boss_objects = object_counts.get("boss", 0) + object_counts.get("cylindrical_boss", 0) + object_counts.get("rectangular_boss", 0)
chamfer_objects = object_counts.get("chamfer", 0)
pattern_objects = object_counts.get("pattern", 0) + object_counts.get("linear_pattern", 0)
shell_objects = object_counts.get("shell", 0) + object_counts.get("thin_wall", 0)
hints: list[dict[str, object]] = []
if slot_objects:
_add_hint_group(
hints,
("slot.width", "slot.depth", "slot.position"),
slot_objects,
"medium",
"SCDM raw 已给出槽类对象;进入 S7 第二批真实命令和样例回测。",
"scdm-object-type",
)
elif cylinder_faces and circular_edges and linear_edges:
_add_hint_group(
hints,
("slot.width", "slot.depth", "slot.position"),
min(cylinder_faces, circular_edges),
"low",
"当前模型含圆柱面、圆边和直边,具备槽/长圆孔分类材料;还需要确认具体槽组和编辑命令。",
"scdm-geometry-summary",
)
if boss_objects:
_add_hint_group(
hints,
("boss.height", "boss.diameter", "boss.position"),
boss_objects,
"medium",
"SCDM raw 已给出凸台类对象;进入 S7 第三批真实命令和样例回测。",
"scdm-object-type",
)
elif cylinder_faces and planar_faces:
_add_hint_group(
hints,
("boss.height", "boss.diameter", "boss.position"),
cylinder_faces,
"low",
"当前模型含圆柱面和平面,具备凸台/外圆分类材料;还需要区分孔、外圆、槽和圆角。",
"scdm-geometry-summary",
)
round_candidate_count = (
round_objects
or operation_counts.get("change_round_radius", 0)
or operation_counts.get("delete_round_or_chamfer", 0)
)
if round_candidate_count:
_add_hint_group(
hints,
("round.radius", "feature.delete_round_or_chamfer"),
round_candidate_count,
"medium",
"SCDM raw 已给出圆角/倒圆候选命令;执行器和专门质量校验仍待回测。",
"scdm-command-candidate",
)
elif circular_edges and radius_bucket_count:
_add_hint_group(
hints,
("round.radius",),
circular_edges,
"low",
"当前模型含圆边和半径分组,具备圆角链分类材料;还需要确认支撑面和真实 Round 命令。",
"scdm-edge-summary",
)
if chamfer_objects:
_add_hint_group(
hints,
("chamfer.distance", "feature.delete_round_or_chamfer"),
chamfer_objects,
"medium",
"SCDM raw 已给出倒角对象;进入 S7 第四批真实命令和样例回测。",
"scdm-object-type",
)
elif planar_faces and linear_edges and circular_edges:
_add_hint_group(
hints,
("chamfer.distance",),
min(planar_faces, linear_edges),
"low",
"当前模型含大量平面和直边,具备倒角分类材料;还需要确认倒角斜面和相邻支撑面。",
"scdm-geometry-summary",
)
if pattern_objects:
_add_hint_group(
hints,
("pattern.spacing", "pattern.instance_position"),
pattern_objects,
"medium",
"SCDM raw 已给出阵列对象;进入 S7 第五批真实命令和样例回测。",
"scdm-object-type",
)
elif radius_bucket_count and circular_edges >= 4:
_add_hint_group(
hints,
("pattern.spacing", "pattern.instance_position"),
radius_bucket_count,
"low",
"当前模型含重复圆边半径分组,具备孔/圆角阵列识别材料;还需要确认实例分组和间距方向。",
"scdm-edge-summary",
)
if shell_objects:
_add_hint_group(
hints,
("shell.thickness",),
shell_objects,
"medium",
"SCDM raw 已给出壳体/薄壁对象;进入 S7 第五批真实命令和样例回测。",
"scdm-object-type",
)
elif planar_faces >= 2:
_add_hint_group(
hints,
("shell.thickness",),
planar_faces,
"low",
"当前模型含多组平面,具备薄壁/相对面厚度分类材料;还需要确认成对壁面和偏移方向。",
"scdm-geometry-summary",
)
return hints
def _add_hint_group(
hints: list[dict[str, object]],
keys: tuple[str, ...],
count: int,
confidence: str,
reason: str,
source: str,
) -> None:
evidence_count = max(0, int(count))
if evidence_count <= 0:
return
for key in keys:
definition = capability_definition(key)
if definition is None or definition.productized:
continue
hints.append(
{
"capabilityKey": key,
"displayName": definition.display_name,
"roadmapStage": definition.roadmap_stage,
"evidenceCount": evidence_count,
"confidence": confidence,
"source": source,
"reason": reason,
}
)
def _scan_raw_feature_counts(raw_objects: list[object]) -> dict[str, dict[str, int]]:
result = {
"objectTypeCounts": {},
"surfaceTypeCounts": {},
"curveTypeCounts": {},
"operationCounts": {},
}
for raw_object in raw_objects:
if not isinstance(raw_object, Mapping):
continue
_increment_count(result["objectTypeCounts"], raw_object.get("objectType"))
geometry = _mapping(raw_object.get("geometry"))
if geometry.get("surfaceType") is not None:
_increment_count(result["surfaceTypeCounts"], geometry.get("surfaceType"))
if geometry.get("curveType") is not None:
_increment_count(result["curveTypeCounts"], geometry.get("curveType"))
for command in raw_object.get("backendCommandCandidates", []) or []:
if isinstance(command, Mapping) and command.get("enabled") is not False:
_increment_count(result["operationCounts"], command.get("operation"))
return result
def _normalized_count_dict(value: object) -> dict[str, int]:
if not isinstance(value, Mapping):
return {}
result: dict[str, int] = {}
for key, count in value.items():
normalized = _count_key(key)
number = _count_value(count)
if normalized and number > 0:
result[normalized] = result.get(normalized, 0) + number
return result
def _merge_count_dicts(primary: Mapping[str, int], fallback: Mapping[str, int]) -> dict[str, int]:
result = {str(key): int(value) for key, value in primary.items() if int(value) > 0}
for key, value in fallback.items():
text = str(key)
number = int(value)
if number > result.get(text, 0):
result[text] = number
return result
def _increment_count(counts: dict[str, int], key: object) -> None:
normalized = _count_key(key)
if normalized:
counts[normalized] = counts.get(normalized, 0) + 1
def _count_key(value: object) -> str:
return str(value or "").strip().lower()
def _count_value(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def map_scdm_raw_features_file(raw_path: str | Path, cache_path: str | Path | None = None) -> dict[str, object]:
cache = map_scdm_raw_features(read_json(raw_path))
if cache_path is not None:
write_json(cache_path, cache)
return cache
def _cylindrical_face_group_objects(raw_objects: list[object]) -> list[dict[str, object]]:
groups: dict[tuple[object, ...], list[Mapping[str, object]]] = {}
for raw_object in raw_objects:
if not isinstance(raw_object, Mapping):
continue
geometry = _mapping(raw_object.get("geometry"))
if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "cylinder":
continue
center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter"))
axis = _canonical_axis(_rounded_vector(geometry.get("axis") or geometry.get("normal")))
radius = _rounded_number(geometry.get("radius"))
if len(center) != 3 or len(axis) != 3 or radius is None or radius <= 0:
continue
topology = _mapping(raw_object.get("topologyHint"))
body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex")))
key = (body_index, tuple(center), tuple(axis), radius)
groups.setdefault(key, []).append(raw_object)
result: list[dict[str, object]] = []
for (_body_index, center, axis, radius), members in groups.items():
if len(members) < 2:
continue
locators = [_locator_from_raw_object(member) for member in members]
locators = [item for item in locators if item]
topology = {
"bodyIndex": _body_index,
"faceOrdinal": _first_int(item.get("faceOrdinal") for item in locators),
"faceOrdinals": [item["faceOrdinal"] for item in locators if item.get("faceOrdinal") is not None],
"globalFaceOrdinal": _first_int(item.get("globalFaceOrdinal") for item in locators),
"globalFaceOrdinals": [item["globalFaceOrdinal"] for item in locators if item.get("globalFaceOrdinal") is not None],
"faceIds": _merge_ints(_mapping(member.get("topologyHint")).get("faceIds") for member in members),
"scdmFaceLocators": locators,
}
source_ids = [str(member.get("backendId") or "") for member in members if str(member.get("backendId") or "")]
result.append(
{
"backendId": "cylindrical_group:" + "|".join(source_ids),
"objectType": "cylindrical_face_group",
"geometry": {
"surfaceType": "cylinder",
"center": list(center),
"axis": list(axis),
"radius": radius,
"diameter": radius * 2.0,
"sourceBackendIds": source_ids,
},
"topologyHint": topology,
"backendCommandCandidates": [
{"operation": "change_hole_diameter", "enabled": True, "parameterFields": {"diameter": radius * 2.0}},
{"operation": "move_hole_axis", "enabled": True, "parameterFields": {"center": list(center)}},
{"operation": "fill_feature", "enabled": True, "parameterFields": {}},
],
"rawLimitations": [],
}
)
return result
def _derived_feature_objects(source_objects: list[object]) -> list[dict[str, object]]:
return _derived_linear_pattern_objects(source_objects)
def _derived_linear_pattern_objects(source_objects: list[object]) -> list[dict[str, object]]:
groups: dict[tuple[object, ...], list[dict[str, object]]] = {}
for raw_object in source_objects:
if not isinstance(raw_object, Mapping):
continue
object_type = str(raw_object.get("objectType") or "").strip().lower()
if object_type not in {"hole", "cylindrical_hole", "cylindrical_face_group"}:
continue
geometry = _mapping(raw_object.get("geometry"))
center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter"))
axis = _canonical_axis(_rounded_vector(geometry.get("axis") or geometry.get("normal")))
radius = _rounded_number(geometry.get("radius"))
if radius is None:
diameter = _rounded_number(geometry.get("diameter"))
radius = diameter * 0.5 if diameter is not None else None
if len(center) != 3 or len(axis) != 3 or radius is None or radius <= 0:
continue
topology = _mapping(raw_object.get("topologyHint"))
key = (
_int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))),
tuple(axis),
round(float(radius), 6),
)
groups.setdefault(key, []).append(
{
"objectId": _object_id(raw_object),
"center": center,
"axis": axis,
"radius": float(radius),
"faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")),
"globalFaceOrdinals": _int_list(
topology.get("globalFaceOrdinals")
or topology.get("faceOrdinals")
or [topology.get("globalFaceOrdinal"), topology.get("faceOrdinal")]
),
}
)
result: list[dict[str, object]] = []
seen: set[tuple[object, ...]] = set()
for (body_index, _axis, radius), instances in groups.items():
if len(instances) < 3:
continue
candidates = _linear_pattern_candidates(instances)
for candidate in candidates:
member_ids = tuple(str(item.get("objectId") or "") for item in candidate["members"])
pattern_axis = tuple(round(float(value), 6) for value in candidate["direction"])
spacing = round(float(candidate["spacing"]), 6)
key = (body_index, radius, pattern_axis, spacing, member_ids)
if key in seen:
continue
seen.add(key)
face_ids: list[int] = []
global_face_ordinals: list[int] = []
centers = []
for member in candidate["members"]:
centers.append(list(member["center"]))
face_ids.extend(_int_list(member.get("faceIds")))
global_face_ordinals.extend(_int_list(member.get("globalFaceOrdinals")))
center = [
round(sum(float(item[index]) for item in centers) / len(centers), 6)
for index in range(3)
]
result.append(
{
"backendId": "derived:linear_pattern:" + "|".join(member_ids),
"objectType": "linear_pattern",
"geometry": {
"center": center,
"axis": list(pattern_axis),
"spacing": spacing,
"pitch": spacing,
"instanceCount": len(candidate["members"]),
"instanceCenters": centers,
"radius": radius,
"diameter": radius * 2.0,
"sourceObjectIds": list(member_ids),
},
"topologyHint": {
"bodyIndex": body_index,
"faceIds": sorted(set(face_ids)),
"globalFaceOrdinals": sorted(set(global_face_ordinals)),
},
"backendCommandCandidates": [],
"rawLimitations": [
"Derived from repeated cylindrical feature centers; SCDM edit command is not productized yet.",
],
}
)
if len(result) >= 24:
return result
return result
def _linear_pattern_candidates(instances: list[dict[str, object]]) -> list[dict[str, object]]:
if len(instances) < 3:
return []
points = [list(item["center"]) for item in instances]
diagonal = _point_cloud_diagonal(points)
tolerance = max(diagonal * 1.0e-4, max(float(item.get("radius") or 0.0) for item in instances) * 0.02, 1.0e-5)
candidates: list[dict[str, object]] = []
for left_index in range(len(instances)):
for right_index in range(left_index + 1, len(instances)):
p0 = points[left_index]
p1 = points[right_index]
direction = _unit_vector([p1[index] - p0[index] for index in range(3)])
if len(direction) != 3:
continue
collinear: list[tuple[float, dict[str, object]]] = []
for instance in instances:
point = list(instance["center"])
delta = [point[index] - p0[index] for index in range(3)]
projection = sum(delta[index] * direction[index] for index in range(3))
nearest = [p0[index] + projection * direction[index] for index in range(3)]
distance = math.sqrt(sum((point[index] - nearest[index]) ** 2 for index in range(3)))
if distance <= tolerance:
collinear.append((projection, instance))
if len(collinear) < 3:
continue
collinear.sort(key=lambda item: item[0])
spacings = [
collinear[index + 1][0] - collinear[index][0]
for index in range(len(collinear) - 1)
if collinear[index + 1][0] - collinear[index][0] > tolerance
]
if len(spacings) < 2:
continue
spacing = sum(spacings) / len(spacings)
if spacing <= tolerance:
continue
spacing_tolerance = max(abs(spacing) * 0.03, tolerance * 3.0)
if any(abs(value - spacing) > spacing_tolerance for value in spacings):
continue
members = [item[1] for item in collinear]
canonical_direction = _canonical_axis([round(value, 6) for value in direction])
if not canonical_direction:
continue
member_ids = tuple(str(item.get("objectId") or "") for item in members)
if any(set(member_ids) == set(str(member.get("objectId") or "") for member in existing["members"]) for existing in candidates):
continue
candidates.append({"members": members, "direction": canonical_direction, "spacing": spacing})
if len(candidates) >= 12:
return candidates
candidates.sort(key=lambda item: (-len(item["members"]), float(item["spacing"])))
return candidates
def _point_cloud_diagonal(points: list[list[float]]) -> float:
if not points:
return 0.0
mins = [min(float(point[index]) for point in points) for index in range(3)]
maxs = [max(float(point[index]) for point in points) for index in range(3)]
return math.sqrt(sum((maxs[index] - mins[index]) ** 2 for index in range(3)))
def _unit_vector(values: list[float]) -> list[float]:
length = math.sqrt(sum(float(item) * float(item) for item in values))
if length <= 1.0e-12:
return []
return [float(item) / length for item in values]
def geometry_signature(raw_object: Mapping[str, object]) -> dict[str, object]:
geometry = _mapping(raw_object.get("geometry"))
topology = _mapping(raw_object.get("topologyHint"))
return {
"objectType": str(raw_object.get("objectType") or ""),
"faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")),
"edgeIds": _int_list(topology.get("edgeIds") or geometry.get("edgeIds")),
"bodyIndex": _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))),
"faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))),
"faceOrdinals": _int_list(topology.get("faceOrdinals") or geometry.get("faceOrdinals")),
"edgeOrdinal": _int_or_none(_first_present(topology.get("edgeOrdinal"), geometry.get("edgeOrdinal"))),
"globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))),
"globalFaceOrdinals": _int_list(topology.get("globalFaceOrdinals") or geometry.get("globalFaceOrdinals")),
"globalEdgeOrdinal": _int_or_none(_first_present(topology.get("globalEdgeOrdinal"), geometry.get("globalEdgeOrdinal"))),
"adjacentFaceOrdinals": _int_list(topology.get("adjacentFaceOrdinals") or geometry.get("adjacentFaceOrdinals")),
"adjacentFaceCount": _int_or_none(_first_present(topology.get("adjacentFaceCount"), geometry.get("adjacentFaceCount"))),
"surfaceType": str(geometry.get("surfaceType") or geometry.get("surface") or ""),
"curveType": str(geometry.get("curveType") or ""),
"center": _rounded_vector(geometry.get("center") or geometry.get("axisCenter")),
"axis": _rounded_vector(geometry.get("axis") or geometry.get("normal")),
"startPoint": _rounded_vector(geometry.get("startPoint")),
"endPoint": _rounded_vector(geometry.get("endPoint")),
"midPoint": _rounded_vector(geometry.get("midPoint")),
"length": _rounded_number(geometry.get("length")),
"radius": _rounded_number(geometry.get("radius")),
"diameter": _rounded_number(geometry.get("diameter")),
"spacing": _rounded_number(geometry.get("spacing")),
"pitch": _rounded_number(geometry.get("pitch")),
"instanceCount": _int_or_none(geometry.get("instanceCount")),
"instanceCenters": _rounded_vector_list(geometry.get("instanceCenters")),
"sourceObjectIds": _string_list(geometry.get("sourceObjectIds")),
"planeOffset": _rounded_number(geometry.get("planeOffset")),
"scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators")),
}
def attach_local_face_ids_to_scdm_cache(
cache: Mapping[str, object],
local_face_signatures: Iterable[Mapping[str, object]],
*,
min_score: float = 5.0,
unique_margin: float = 0.75,
) -> dict[str, object]:
local_signatures = [dict(item) for item in local_face_signatures if isinstance(item, Mapping)]
result = dict(cache)
diagnostics = dict(result.get("diagnostics") if isinstance(result.get("diagnostics"), Mapping) else {})
mapping_rows: list[dict[str, object]] = []
objects = []
for raw_object in result.get("objects", []) if isinstance(result.get("objects"), list) else []:
if not isinstance(raw_object, Mapping):
continue
item = dict(raw_object)
signature = dict(item.get("geometrySignature") if isinstance(item.get("geometrySignature"), Mapping) else {})
if not _int_list(signature.get("faceIds")):
if str(signature.get("objectType") or "") in {"cylindrical_face_group", "cylindrical_hole"}:
match = _match_local_face_group_signatures(signature, local_signatures, min_score=min_score, unique_margin=unique_margin)
else:
match = _match_local_face_signature(signature, local_signatures, min_score=min_score, unique_margin=unique_margin)
status = str(match.get("status") or "")
if status in {"unique", "group"}:
face_ids = _int_list(match.get("faceIds"))
face_id = _int_or_none(match.get("faceId"))
if not face_ids and face_id is not None:
face_ids = [face_id]
if face_ids:
signature["faceIds"] = face_ids
signature["localMatchScore"] = match.get("score")
signature["localUnitScale"] = match.get("unitScale")
mapping_rows.append(
{
"objectId": item.get("objectId"),
"status": status,
"faceId": match.get("faceId"),
"faceIds": match.get("faceIds"),
"score": match.get("score"),
"message": match.get("message"),
}
)
item["geometrySignature"] = signature
objects.append(item)
diagnostics["local_face_mapping"] = mapping_rows
result["objects"] = objects
result["diagnostics"] = diagnostics
return result
def _match_local_face_signature(
scdm_signature: Mapping[str, object],
local_signatures: list[dict[str, object]],
*,
min_score: float,
unique_margin: float,
) -> dict[str, object]:
scored = []
for local in local_signatures:
score, scale = _local_signature_score(scdm_signature, local)
if score <= 0:
continue
scored.append({"faceId": local.get("faceId"), "score": score, "unitScale": scale})
scored.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True)
if not scored or float(scored[0].get("score") or 0.0) < min_score:
return {"status": "none", "message": "No local Face signature matched SCDM object.", "candidates": scored[:5]}
if len(scored) > 1:
top = float(scored[0].get("score") or 0.0)
second = float(scored[1].get("score") or 0.0)
if top - second < unique_margin:
return {"status": "multiple", "message": "Multiple local Faces match the same SCDM object.", "candidates": scored[:5]}
return {"status": "unique", "message": "Matched one local Face.", **scored[0]}
def _match_local_face_group_signatures(
scdm_signature: Mapping[str, object],
local_signatures: list[dict[str, object]],
*,
min_score: float,
unique_margin: float,
) -> dict[str, object]:
scored = []
for local in local_signatures:
score, scale = _local_signature_score(scdm_signature, local)
if score < min_score:
continue
scored.append({"faceId": local.get("faceId"), "score": score, "unitScale": scale})
scored.sort(key=lambda item: float(item.get("score") or 0.0), reverse=True)
if not scored:
return {"status": "none", "message": "No local cylindrical Face matched SCDM group.", "candidates": []}
top = float(scored[0].get("score") or 0.0)
selected = [item for item in scored if top - float(item.get("score") or 0.0) <= unique_margin]
face_ids = sorted(set(_int_or_none(item.get("faceId")) for item in selected if _int_or_none(item.get("faceId")) is not None))
if not face_ids:
return {"status": "none", "message": "Local cylindrical group matched no usable Face IDs.", "candidates": scored[:5]}
return {
"status": "group",
"message": "Matched a local cylindrical Face group.",
"faceIds": face_ids,
"score": top,
"unitScale": selected[0].get("unitScale"),
"candidates": scored[:8],
}
def _local_signature_score(scdm_signature: Mapping[str, object], local: Mapping[str, object]) -> tuple[float, float]:
scdm_surface = _surface_key(scdm_signature.get("surfaceType"))
local_surface = _surface_key(local.get("surfaceType"))
if scdm_surface and local_surface and scdm_surface != local_surface:
return 0.0, 1.0
scales = _unit_scale_candidates(scdm_signature, local)
best_score = 0.0
best_scale = 1.0
for scale in scales:
score = 2.0 if scdm_surface and scdm_surface == local_surface else 0.0
score += _axis_match_score(_rounded_vector(scdm_signature.get("axis")), _rounded_vector(local.get("axis")))
if scdm_surface == "plane":
score += _scaled_number_match_score(
_rounded_number(local.get("planeOffset")),
_rounded_number(scdm_signature.get("planeOffset")),
scale,
)
elif scdm_surface == "cylinder":
score += _scaled_number_match_score(
_rounded_number(local.get("radius")),
_rounded_number(scdm_signature.get("radius")),
scale,
)
score += _axis_point_distance_score(
_rounded_vector(local.get("center")),
_rounded_vector(local.get("axis")),
_rounded_vector(scdm_signature.get("center")),
scale,
)
else:
score += _scaled_vector_match_score(
_rounded_vector(local.get("center")),
_rounded_vector(scdm_signature.get("center")),
scale,
)
if score > best_score:
best_score = score
best_scale = scale
return best_score, best_scale
def _unit_scale_candidates(scdm_signature: Mapping[str, object], local: Mapping[str, object]) -> tuple[float, ...]:
values = [1.0, 0.001, 1000.0]
local_radius = _rounded_number(local.get("radius"))
scdm_radius = _rounded_number(scdm_signature.get("radius"))
if local_radius and scdm_radius:
values.append(float(scdm_radius) / float(local_radius))
local_offset = _rounded_number(local.get("planeOffset"))
scdm_offset = _rounded_number(scdm_signature.get("planeOffset"))
if local_offset not in {None, 0.0} and scdm_offset is not None:
values.append(float(scdm_offset) / float(local_offset))
result = []
for value in values:
if value > 0 and all(abs(value - existing) > max(value, existing) * 1e-9 for existing in result):
result.append(value)
return tuple(result)
def _capability_payload(
key: str,
raw_object: Mapping[str, object],
*,
available_command_names: set[str] | None = None,
) -> dict[str, object] | None:
definition = capability_definition(key)
if definition is None:
return None
block_reason = _missing_backend_command_reason(key, available_command_names)
return {
"key": definition.key,
"displayName": definition.display_name,
"valueKind": definition.value_kind,
"currentValue": _current_value(definition.current_fields, raw_object),
"defaultIntent": definition.default_intent,
"backendOperation": definition.backend_operation,
"postCheck": definition.post_check,
"editable": True,
"blockReason": block_reason,
}
def _current_value(fields: tuple[str, ...], raw_object: Mapping[str, object]) -> object:
geometry = _mapping(raw_object.get("geometry"))
for field in fields:
if field == "0":
return 0.0
if field == "1":
return 1
if field == "geometry.radius*2":
radius = _number(geometry.get("radius"))
if radius is not None:
return radius * 2.0
continue
if field.startswith("geometry."):
key = field.split(".", 1)[1]
if key in geometry and geometry.get(key) is not None:
return geometry.get(key)
return None
def _available_command_names(value: object) -> set[str] | None:
if not isinstance(value, list):
return None
result: set[str] = set()
for item in value:
if not isinstance(item, Mapping):
continue
if item.get("available") is not True:
continue
name = str(item.get("name") or "").strip()
if name:
result.add(name)
return result
def _missing_backend_command_reason(key: str, available_command_names: set[str] | None) -> str:
if available_command_names is None:
return ""
definition = capability_definition(key)
groups = definition.required_backend_command_groups if definition is not None else ()
if not groups:
return ""
if any(all(command in available_command_names for command in group) for group in groups):
return ""
readable = " / ".join(" + ".join(group) for group in groups)
return f"SCDM 当前脚本环境缺少 {readable} 命令,暂不能执行该参数。"
def _object_id(raw_object: Mapping[str, object]) -> str:
existing = str(raw_object.get("objectId") or "").strip()
if existing:
return existing
object_type = str(raw_object.get("objectType") or "object").strip() or "object"
backend_id = str(raw_object.get("backendId") or "").strip()
if backend_id:
return f"{object_type}:{backend_id}"
signature = geometry_signature(raw_object)
face_ids = signature.get("faceIds")
if isinstance(face_ids, list) and face_ids:
return f"{object_type}:face:{'-'.join(str(item) for item in face_ids)}"
return object_type
def _locator_from_raw_object(raw_object: Mapping[str, object]) -> dict[str, object]:
geometry = _mapping(raw_object.get("geometry"))
topology = _mapping(raw_object.get("topologyHint"))
return {
"backendId": str(raw_object.get("backendId") or ""),
"bodyIndex": _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex"))),
"faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))),
"globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))),
}
def _face_locators(value: object) -> list[dict[str, object]]:
if not isinstance(value, (list, tuple)):
return []
result: list[dict[str, object]] = []
for item in value:
if not isinstance(item, Mapping):
continue
locator = {
"backendId": str(item.get("backendId") or ""),
"bodyIndex": _int_or_none(item.get("bodyIndex")),
"faceOrdinal": _int_or_none(item.get("faceOrdinal")),
"globalFaceOrdinal": _int_or_none(item.get("globalFaceOrdinal")),
}
if any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")):
result.append(locator)
return result
def _first_int(values: Iterable[object]) -> int | None:
for value in values:
number = _int_or_none(value)
if number is not None:
return number
return None
def _merge_ints(values: Iterable[object]) -> list[int]:
result: list[int] = []
for value in values:
result.extend(_int_list(value))
return sorted(set(result))
def _mapping(value: object) -> Mapping[str, object]:
return value if isinstance(value, Mapping) else {}
def _int_list(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 sorted(set(result))
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _first_present(*values: object) -> object:
for value in values:
if value is not None and value != "":
return value
return None
def _surface_key(value: object) -> str:
text = str(value or "").strip().lower()
if "plane" in text:
return "plane"
if "cylinder" in text:
return "cylinder"
return text
def _canonical_axis(axis: list[float]) -> list[float]:
if len(axis) != 3:
return []
length = math.sqrt(sum(item * item for item in axis))
if length <= 1e-12:
return []
normalized = [item / length for item in axis]
for value in normalized:
if abs(value) > 1e-12:
if value < 0:
normalized = [-item for item in normalized]
break
return [round(item, 6) for item in normalized]
def _axis_match_score(left: list[float], right: list[float]) -> float:
if len(left) != 3 or len(right) != 3:
return 0.0
left_len = math.sqrt(sum(item * item for item in left))
right_len = math.sqrt(sum(item * item for item in right))
if left_len <= 1e-12 or right_len <= 1e-12:
return 0.0
dot = abs(sum(left[index] * right[index] for index in range(3)) / (left_len * right_len))
if dot >= 0.999:
return 3.0
if dot >= 0.99:
return 2.0
return 0.0
def _scaled_number_match_score(local_value: float | None, scdm_value: float | None, scale: float) -> float:
if local_value is None or scdm_value is None:
return 0.0
error = abs(float(local_value) * scale - float(scdm_value))
reference = max(abs(float(scdm_value)), abs(float(local_value) * scale), 1.0e-9)
relative = error / reference
if relative <= 1.0e-5:
return 4.0
if relative <= 1.0e-3:
return 3.0
if relative <= 1.0e-2:
return 1.0
return 0.0
def _scaled_vector_match_score(local_value: list[float], scdm_value: list[float], scale: float) -> float:
if len(local_value) != 3 or len(scdm_value) != 3:
return 0.0
distance = math.sqrt(sum((local_value[index] * scale - scdm_value[index]) ** 2 for index in range(3)))
reference = max(max(abs(item) for item in scdm_value), 1.0e-9)
relative = distance / reference
if relative <= 1.0e-5:
return 4.0
if relative <= 1.0e-3:
return 3.0
if relative <= 1.0e-2:
return 1.0
return 0.0
def _axis_point_distance_score(
local_point: list[float],
local_axis: list[float],
scdm_point: list[float],
scale: float,
) -> float:
if len(local_point) != 3 or len(local_axis) != 3 or len(scdm_point) != 3:
return 0.0
point = [local_point[index] * scale for index in range(3)]
axis_len = math.sqrt(sum(item * item for item in local_axis))
if axis_len <= 1e-12:
return 0.0
axis = [item / axis_len for item in local_axis]
delta = [scdm_point[index] - point[index] for index in range(3)]
projection = sum(delta[index] * axis[index] for index in range(3))
nearest = [point[index] + projection * axis[index] for index in range(3)]
distance = math.sqrt(sum((nearest[index] - scdm_point[index]) ** 2 for index in range(3)))
reference = max(max(abs(item) for item in scdm_point), 1.0e-9)
relative = distance / reference
if relative <= 1.0e-5:
return 3.0
if relative <= 1.0e-3:
return 2.0
if relative <= 1.0e-2:
return 0.75
return 0.0
def _rounded_vector(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]:
number = _number(item)
if number is None:
return []
result.append(round(number, 6))
return result if len(result) == 3 else []
def _rounded_vector_list(value: object) -> list[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[list[float]] = []
for item in values:
rounded = _rounded_vector(item)
if len(rounded) == 3:
result.append(rounded)
return result
def _string_list(value: object) -> list[str]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[str] = []
for item in values:
text = str(item or "").strip()
if text:
result.append(text)
return result
def _rounded_number(value: object) -> float | None:
number = _number(value)
return None if number is None else round(number, 6)
def _number(value: object) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
__all__ = [
"attach_local_face_ids_to_scdm_cache",
"geometry_signature",
"map_scdm_raw_features",
"map_scdm_raw_features_file",
]