4e7877e05c
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。 支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
2300 lines
100 KiB
Python
2300 lines
100 KiB
Python
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
|
|
|
|
|
|
SCDM_FEATURE_CACHE_REVISION = 4
|
|
|
|
|
|
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)
|
|
}
|
|
component_instances = raw_diagnostics.get("componentInstances")
|
|
if isinstance(component_instances, list):
|
|
diagnostics["component_instances"] = [dict(item) for item in component_instances if isinstance(item, 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,
|
|
"mapperRevision": SCDM_FEATURE_CACHE_REVISION,
|
|
"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),
|
|
*_derived_body_linear_pattern_objects(source_objects),
|
|
*_derived_thin_wall_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"))
|
|
locators = _face_locators(topology.get("scdmFaceLocators"))
|
|
fallback_locator = _locator_from_raw_object(raw_object)
|
|
if not locators and any(fallback_locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")):
|
|
locators = [fallback_locator]
|
|
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")),
|
|
"faceOrdinals": _int_list(topology.get("faceOrdinals") or [topology.get("faceOrdinal")]),
|
|
"globalFaceOrdinals": _int_list(
|
|
topology.get("globalFaceOrdinals")
|
|
or [topology.get("globalFaceOrdinal"), topology.get("faceOrdinal")]
|
|
),
|
|
"scdmFaceLocators": locators,
|
|
}
|
|
)
|
|
|
|
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 = []
|
|
pattern_instances: list[dict[str, object]] = []
|
|
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")))
|
|
pattern_instances.append(
|
|
{
|
|
"sourceObjectId": str(member.get("objectId") or ""),
|
|
"center": list(member["center"]),
|
|
"faceIds": _int_list(member.get("faceIds")),
|
|
"faceOrdinals": _int_list(member.get("faceOrdinals")),
|
|
"globalFaceOrdinals": _int_list(member.get("globalFaceOrdinals")),
|
|
"scdmFaceLocators": _face_locators(member.get("scdmFaceLocators")),
|
|
}
|
|
)
|
|
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),
|
|
"patternInstances": pattern_instances,
|
|
},
|
|
"topologyHint": {
|
|
"bodyIndex": body_index,
|
|
"faceIds": sorted(set(face_ids)),
|
|
"globalFaceOrdinals": sorted(set(global_face_ordinals)),
|
|
},
|
|
"backendCommandCandidates": [
|
|
{"operation": "change_pattern_spacing", "enabled": True, "parameterFields": {"spacing": spacing}},
|
|
],
|
|
"rawLimitations": [
|
|
"Derived from repeated cylindrical feature centers; spacing edit uses SCDM Move on each instance.",
|
|
],
|
|
}
|
|
)
|
|
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 _derived_body_linear_pattern_objects(source_objects: list[object]) -> list[dict[str, object]]:
|
|
body_summaries = _body_pattern_source_instances(source_objects)
|
|
groups: dict[tuple[object, ...], list[dict[str, object]]] = {}
|
|
for item in body_summaries:
|
|
signature = tuple(item.get("shapeSignature") or ())
|
|
if not signature:
|
|
continue
|
|
groups.setdefault(signature, []).append(item)
|
|
|
|
result: list[dict[str, object]] = []
|
|
seen: set[tuple[object, ...]] = set()
|
|
for signature, instances in groups.items():
|
|
if len(instances) < 3:
|
|
continue
|
|
candidates = _linear_pattern_candidates(instances)
|
|
for candidate in candidates:
|
|
members = list(candidate["members"])
|
|
member_ids = tuple(str(item.get("objectId") or "") for item in members)
|
|
pattern_axis = tuple(round(float(value), 6) for value in candidate["direction"])
|
|
spacing = round(float(candidate["spacing"]), 6)
|
|
key = (signature, pattern_axis, spacing, member_ids)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
centers = [list(item["center"]) for item in members]
|
|
center = [
|
|
round(sum(float(item[index]) for item in centers) / len(centers), 6)
|
|
for index in range(3)
|
|
]
|
|
face_ids = sorted(set(value for item in members for value in _int_list(item.get("faceIds"))))
|
|
face_ordinals = sorted(set(value for item in members for value in _int_list(item.get("faceOrdinals"))))
|
|
global_face_ordinals = sorted(set(value for item in members for value in _int_list(item.get("globalFaceOrdinals"))))
|
|
body_indices = [
|
|
value
|
|
for value in (_int_or_none(item.get("bodyIndex")) for item in members)
|
|
if value is not None
|
|
]
|
|
pattern_instances = [
|
|
{
|
|
"sourceObjectId": str(item.get("objectId") or ""),
|
|
"instanceKind": "body",
|
|
"center": list(item["center"]),
|
|
"bodyIndex": _int_or_none(item.get("bodyIndex")),
|
|
"bodyLocators": _body_locators(item.get("bodyLocators")),
|
|
"componentLocators": _component_locators(item.get("componentLocators") or item.get("bodyLocators")),
|
|
"faceIds": _int_list(item.get("faceIds")),
|
|
"faceOrdinals": _int_list(item.get("faceOrdinals")),
|
|
"globalFaceOrdinals": _int_list(item.get("globalFaceOrdinals")),
|
|
"scdmFaceLocators": _face_locators(item.get("scdmFaceLocators")),
|
|
}
|
|
for item in members
|
|
]
|
|
result.append(
|
|
{
|
|
"backendId": "derived:body_linear_pattern:" + "|".join(member_ids),
|
|
"objectType": "linear_pattern",
|
|
"geometry": {
|
|
"patternKind": "body",
|
|
"instanceKind": "body",
|
|
"center": center,
|
|
"axis": list(pattern_axis),
|
|
"spacing": spacing,
|
|
"pitch": spacing,
|
|
"instanceCount": len(members),
|
|
"instanceCenters": centers,
|
|
"sourceObjectIds": list(member_ids),
|
|
"bodyIndices": body_indices,
|
|
"componentInstanceCount": sum(
|
|
1
|
|
for item in members
|
|
if _component_locators(item.get("componentLocators") or item.get("bodyLocators"))
|
|
),
|
|
"patternInstances": pattern_instances,
|
|
},
|
|
"topologyHint": {
|
|
"bodyIndices": body_indices,
|
|
"faceIds": face_ids,
|
|
"faceOrdinals": face_ordinals,
|
|
"globalFaceOrdinals": global_face_ordinals,
|
|
},
|
|
"backendCommandCandidates": [
|
|
{"operation": "change_pattern_spacing", "enabled": True, "parameterFields": {"spacing": spacing}},
|
|
],
|
|
"rawLimitations": [
|
|
"Derived from repeated SCDM Body/Part geometry centers; spacing edit opens only when every member has a component occurrence locator.",
|
|
],
|
|
}
|
|
)
|
|
if len(result) >= 24:
|
|
return result
|
|
return result
|
|
|
|
|
|
def _body_pattern_source_instances(source_objects: list[object]) -> list[dict[str, object]]:
|
|
explicit: list[dict[str, object]] = []
|
|
grouped_faces: dict[int, 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()
|
|
geometry = _mapping(raw_object.get("geometry"))
|
|
topology = _mapping(raw_object.get("topologyHint"))
|
|
body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex")))
|
|
if object_type in {"body", "part", "component"} and body_index is not None:
|
|
center = _rounded_vector(geometry.get("center") or geometry.get("bboxCenter") or geometry.get("axisCenter"))
|
|
if len(center) == 3:
|
|
explicit.append(
|
|
{
|
|
"objectId": _object_id(raw_object),
|
|
"center": center,
|
|
"bodyIndex": body_index,
|
|
"bodyLocators": _body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators") or [{"bodyIndex": body_index}]),
|
|
"componentLocators": _component_locators(topology.get("componentLocators") or geometry.get("componentLocators")),
|
|
"faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")),
|
|
"faceOrdinals": _int_list(topology.get("faceOrdinals") or geometry.get("faceOrdinals")),
|
|
"globalFaceOrdinals": _int_list(topology.get("globalFaceOrdinals") or geometry.get("globalFaceOrdinals")),
|
|
"scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators") or geometry.get("scdmFaceLocators")),
|
|
"shapeSignature": _body_shape_signature_from_object(raw_object),
|
|
}
|
|
)
|
|
continue
|
|
if object_type != "face" or body_index is None:
|
|
continue
|
|
center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter"))
|
|
if len(center) != 3:
|
|
continue
|
|
item = grouped_faces.setdefault(
|
|
int(body_index),
|
|
{
|
|
"objectId": f"body:{int(body_index)}",
|
|
"bodyIndex": int(body_index),
|
|
"centers": [],
|
|
"faceIds": [],
|
|
"faceOrdinals": [],
|
|
"globalFaceOrdinals": [],
|
|
"scdmFaceLocators": [],
|
|
"bodyLocators": [],
|
|
"componentLocators": [],
|
|
"surfaceTypeCounts": {},
|
|
"radiusBuckets": {},
|
|
},
|
|
)
|
|
item["centers"].append(center) # type: ignore[index,union-attr]
|
|
item["faceIds"].extend(_int_list(topology.get("faceIds") or geometry.get("faceIds"))) # type: ignore[index,union-attr]
|
|
face_ordinal = _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal")))
|
|
if face_ordinal is not None:
|
|
item["faceOrdinals"].append(face_ordinal) # type: ignore[index,union-attr]
|
|
global_face_ordinal = _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal")))
|
|
if global_face_ordinal is not None:
|
|
item["globalFaceOrdinals"].append(global_face_ordinal) # type: ignore[index,union-attr]
|
|
locator = _locator_from_raw_object(raw_object)
|
|
if any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")):
|
|
item["scdmFaceLocators"].append(locator) # type: ignore[index,union-attr]
|
|
item["bodyLocators"].extend(_body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators"))) # type: ignore[index,union-attr]
|
|
item["componentLocators"].extend(_component_locators(topology.get("componentLocators") or geometry.get("componentLocators"))) # type: ignore[index,union-attr]
|
|
surface_key = _surface_key(geometry.get("surfaceType") or geometry.get("surface"))
|
|
if surface_key:
|
|
counts = item["surfaceTypeCounts"] # type: ignore[index]
|
|
counts[surface_key] = counts.get(surface_key, 0) + 1 # type: ignore[union-attr]
|
|
radius = _rounded_number(geometry.get("radius"))
|
|
if radius is not None and radius > 0:
|
|
buckets = item["radiusBuckets"] # type: ignore[index]
|
|
bucket_key = round(float(radius), 6)
|
|
buckets[bucket_key] = buckets.get(bucket_key, 0) + 1 # type: ignore[union-attr]
|
|
if explicit:
|
|
return [item for item in explicit if item.get("shapeSignature")]
|
|
result: list[dict[str, object]] = []
|
|
for body_index, item in grouped_faces.items():
|
|
centers = [list(center) for center in item.get("centers", []) if isinstance(center, list) and len(center) == 3]
|
|
locators = _face_locators(item.get("scdmFaceLocators"))
|
|
if len(centers) < 2 or not locators:
|
|
continue
|
|
center = [
|
|
round(sum(float(point[index]) for point in centers) / len(centers), 6)
|
|
for index in range(3)
|
|
]
|
|
signature = _body_shape_signature_from_summary(item)
|
|
if not signature:
|
|
continue
|
|
body_locators = _body_locators(item.get("bodyLocators")) or [{"bodyIndex": int(body_index)}]
|
|
component_locators = _component_locators(item.get("componentLocators") or body_locators)
|
|
result.append(
|
|
{
|
|
"objectId": str(item.get("objectId") or f"body:{body_index}"),
|
|
"center": center,
|
|
"bodyIndex": int(body_index),
|
|
"bodyLocators": body_locators,
|
|
"componentLocators": component_locators,
|
|
"faceIds": sorted(set(_int_list(item.get("faceIds")))),
|
|
"faceOrdinals": sorted(set(_int_list(item.get("faceOrdinals")))),
|
|
"globalFaceOrdinals": sorted(set(_int_list(item.get("globalFaceOrdinals")))),
|
|
"scdmFaceLocators": locators,
|
|
"shapeSignature": signature,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _body_shape_signature_from_object(raw_object: Mapping[str, object]) -> tuple[object, ...]:
|
|
geometry = _mapping(raw_object.get("geometry"))
|
|
bbox_size = _rounded_vector(geometry.get("bboxSize") or geometry.get("size") or geometry.get("boxSize"))
|
|
face_count = _int_or_none(geometry.get("faceCount"))
|
|
edge_count = _int_or_none(geometry.get("edgeCount"))
|
|
signature: list[object] = []
|
|
if len(bbox_size) == 3:
|
|
signature.append(("bbox", tuple(round(abs(float(value)), 5) for value in sorted(bbox_size))))
|
|
if face_count is not None:
|
|
signature.append(("faces", face_count))
|
|
if edge_count is not None:
|
|
signature.append(("edges", edge_count))
|
|
return tuple(signature)
|
|
|
|
|
|
def _body_shape_signature_from_summary(summary: Mapping[str, object]) -> tuple[object, ...]:
|
|
face_count = len(_int_list(summary.get("faceOrdinals"))) or len(_face_locators(summary.get("scdmFaceLocators")))
|
|
if face_count < 2:
|
|
return ()
|
|
surface_counts = _mapping(summary.get("surfaceTypeCounts"))
|
|
radius_buckets = _mapping(summary.get("radiusBuckets"))
|
|
return (
|
|
("faces", face_count),
|
|
("surfaces", tuple(sorted((str(key), int(value)) for key, value in surface_counts.items()))),
|
|
("radii", tuple(sorted((float(key), int(value)) for key, value in radius_buckets.items()))),
|
|
)
|
|
|
|
|
|
def _derived_thin_wall_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
|
|
if str(raw_object.get("objectType") or "").strip().lower() != "face":
|
|
continue
|
|
geometry = _mapping(raw_object.get("geometry"))
|
|
if _surface_key(geometry.get("surfaceType") or geometry.get("surface")) != "plane":
|
|
continue
|
|
center = _rounded_vector(geometry.get("center") or geometry.get("axisCenter"))
|
|
normal = _unit_vector(_rounded_vector(geometry.get("normal") or geometry.get("axis")))
|
|
axis = _canonical_axis(normal)
|
|
if len(center) != 3 or len(axis) != 3:
|
|
continue
|
|
locator = _locator_from_raw_object(raw_object)
|
|
if not any(locator.get(key) is not None for key in ("bodyIndex", "faceOrdinal", "globalFaceOrdinal")):
|
|
continue
|
|
topology = _mapping(raw_object.get("topologyHint"))
|
|
body_index = _int_or_none(_first_present(topology.get("bodyIndex"), geometry.get("bodyIndex")))
|
|
key = (body_index, tuple(axis))
|
|
groups.setdefault(key, []).append(
|
|
{
|
|
"objectId": _object_id(raw_object),
|
|
"center": center,
|
|
"axis": axis,
|
|
"faceIds": _int_list(topology.get("faceIds") or geometry.get("faceIds")),
|
|
"faceOrdinal": _int_or_none(_first_present(topology.get("faceOrdinal"), geometry.get("faceOrdinal"))),
|
|
"globalFaceOrdinal": _int_or_none(_first_present(topology.get("globalFaceOrdinal"), geometry.get("globalFaceOrdinal"))),
|
|
"scdmFaceLocators": [locator],
|
|
}
|
|
)
|
|
|
|
result: list[dict[str, object]] = []
|
|
seen: set[tuple[object, ...]] = set()
|
|
for (body_index, axis_key), faces in groups.items():
|
|
if len(faces) < 2:
|
|
continue
|
|
candidates = _thin_wall_pair_candidates(faces, list(axis_key))
|
|
for candidate in candidates:
|
|
left = candidate["left"]
|
|
right = candidate["right"]
|
|
source_ids = (str(left.get("objectId") or ""), str(right.get("objectId") or ""))
|
|
ordinals = tuple(
|
|
value
|
|
for value in (
|
|
_int_or_none(left.get("faceOrdinal")),
|
|
_int_or_none(right.get("faceOrdinal")),
|
|
)
|
|
if value is not None
|
|
)
|
|
global_ordinals = tuple(
|
|
value
|
|
for value in (
|
|
_int_or_none(left.get("globalFaceOrdinal")),
|
|
_int_or_none(right.get("globalFaceOrdinal")),
|
|
)
|
|
if value is not None
|
|
)
|
|
key = (body_index, tuple(axis_key), round(float(candidate["thickness"]), 6), tuple(sorted(source_ids)))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
locators = [
|
|
locator
|
|
for item in (left, right)
|
|
for locator in _face_locators(item.get("scdmFaceLocators"))
|
|
]
|
|
centers = [list(left["center"]), list(right["center"])]
|
|
center = [
|
|
round((float(centers[0][index]) + float(centers[1][index])) * 0.5, 6)
|
|
for index in range(3)
|
|
]
|
|
result.append(
|
|
{
|
|
"backendId": "derived:thin_wall:" + "|".join(source_ids),
|
|
"objectType": "thin_wall",
|
|
"geometry": {
|
|
"surfaceType": "plane",
|
|
"center": center,
|
|
"axis": list(axis_key),
|
|
"thicknessAxis": list(axis_key),
|
|
"thickness": round(float(candidate["thickness"]), 6),
|
|
"wallFaceCenters": centers,
|
|
"sourceObjectIds": list(source_ids),
|
|
"wallFaceLocators": locators,
|
|
},
|
|
"topologyHint": {
|
|
"bodyIndex": body_index,
|
|
"faceIds": sorted(set(_int_list(left.get("faceIds")) + _int_list(right.get("faceIds")))),
|
|
"faceOrdinals": list(ordinals),
|
|
"globalFaceOrdinals": list(global_ordinals),
|
|
"scdmFaceLocators": locators,
|
|
"wallFaceLocators": locators,
|
|
},
|
|
"backendCommandCandidates": [
|
|
{"operation": "change_shell_thickness", "enabled": True, "parameterFields": {"thickness": round(float(candidate["thickness"]), 6)}},
|
|
],
|
|
"rawLimitations": [
|
|
"Derived from paired planar SCDM faces; shell.thickness remains non-productized until real SCDM edit samples pass.",
|
|
],
|
|
}
|
|
)
|
|
if len(result) >= 24:
|
|
return result
|
|
return result
|
|
|
|
|
|
def _thin_wall_pair_candidates(faces: list[dict[str, object]], axis: list[float]) -> list[dict[str, object]]:
|
|
pairs: list[dict[str, object]] = []
|
|
for left_index in range(len(faces)):
|
|
for right_index in range(left_index + 1, len(faces)):
|
|
left = faces[left_index]
|
|
right = faces[right_index]
|
|
delta = [
|
|
float(right["center"][index]) - float(left["center"][index])
|
|
for index in range(3)
|
|
]
|
|
along = sum(delta[index] * axis[index] for index in range(3))
|
|
thickness = abs(along)
|
|
if thickness <= 1.0e-9:
|
|
continue
|
|
delta_length_sq = sum(value * value for value in delta)
|
|
perpendicular = math.sqrt(max(delta_length_sq - along * along, 0.0))
|
|
if perpendicular > max(thickness * 0.25, 1.0e-4):
|
|
continue
|
|
pairs.append({"left": left, "right": right, "thickness": thickness, "perpendicular": perpendicular})
|
|
if not pairs:
|
|
return []
|
|
min_thickness = min(float(item["thickness"]) for item in pairs)
|
|
tolerance = max(min_thickness * 0.05, 1.0e-6)
|
|
filtered = [item for item in pairs if abs(float(item["thickness"]) - min_thickness) <= tolerance]
|
|
filtered.sort(key=lambda item: (float(item["thickness"]), float(item["perpendicular"])))
|
|
return filtered[:12]
|
|
|
|
|
|
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"))
|
|
round_info = _mapping(geometry.get("roundInfo"))
|
|
chamfer_info = _mapping(geometry.get("chamferInfo"))
|
|
slot_info = _mapping(geometry.get("slotInfo"))
|
|
return {
|
|
"objectType": str(raw_object.get("objectType") or ""),
|
|
"patternKind": str(geometry.get("patternKind") or topology.get("patternKind") or ""),
|
|
"instanceKind": str(geometry.get("instanceKind") or topology.get("instanceKind") 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"))),
|
|
"bodyIndices": _int_list(topology.get("bodyIndices") or geometry.get("bodyIndices")),
|
|
"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"))),
|
|
"heightFaceOrdinals": _int_list(
|
|
topology.get("heightFaceOrdinals")
|
|
or topology.get("topFaceOrdinals")
|
|
or geometry.get("heightFaceOrdinals")
|
|
or geometry.get("topFaceOrdinals")
|
|
),
|
|
"globalHeightFaceOrdinals": _int_list(
|
|
topology.get("globalHeightFaceOrdinals")
|
|
or topology.get("globalTopFaceOrdinals")
|
|
or geometry.get("globalHeightFaceOrdinals")
|
|
or geometry.get("globalTopFaceOrdinals")
|
|
),
|
|
"depthFaceOrdinals": _int_list(
|
|
topology.get("depthFaceOrdinals")
|
|
or topology.get("bottomFaceOrdinals")
|
|
or geometry.get("depthFaceOrdinals")
|
|
or geometry.get("bottomFaceOrdinals")
|
|
),
|
|
"globalDepthFaceOrdinals": _int_list(
|
|
topology.get("globalDepthFaceOrdinals")
|
|
or topology.get("globalBottomFaceOrdinals")
|
|
or geometry.get("globalDepthFaceOrdinals")
|
|
or geometry.get("globalBottomFaceOrdinals")
|
|
),
|
|
"diameterFaceOrdinals": _int_list(
|
|
topology.get("diameterFaceOrdinals")
|
|
or topology.get("sideFaceOrdinals")
|
|
or geometry.get("diameterFaceOrdinals")
|
|
or geometry.get("sideFaceOrdinals")
|
|
),
|
|
"globalDiameterFaceOrdinals": _int_list(
|
|
topology.get("globalDiameterFaceOrdinals")
|
|
or topology.get("globalSideFaceOrdinals")
|
|
or geometry.get("globalDiameterFaceOrdinals")
|
|
or geometry.get("globalSideFaceOrdinals")
|
|
),
|
|
"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")),
|
|
"width": _rounded_number(geometry.get("width")),
|
|
"depth": _rounded_number(_first_present(slot_info.get("depth"), geometry.get("depth"))),
|
|
"depthAxis": _rounded_vector(
|
|
_first_present(
|
|
slot_info.get("depthAxis"),
|
|
slot_info.get("depthDirection"),
|
|
geometry.get("depthAxis"),
|
|
geometry.get("depthDirection"),
|
|
)
|
|
),
|
|
"height": _rounded_number(geometry.get("height")),
|
|
"thickness": _rounded_number(geometry.get("thickness")),
|
|
"thicknessAxis": _rounded_vector(geometry.get("thicknessAxis") or geometry.get("thicknessDirection")),
|
|
"distance": _rounded_number(_first_present(chamfer_info.get("distance"), geometry.get("distance"), geometry.get("offset"))),
|
|
"distance1": _rounded_number(_first_present(chamfer_info.get("distance1"), geometry.get("distance1"))),
|
|
"distance2": _rounded_number(_first_present(chamfer_info.get("distance2"), geometry.get("distance2"))),
|
|
"radius": _rounded_number(_first_present(round_info.get("radius"), geometry.get("radius"))),
|
|
"diameter": _rounded_number(_first_present(round_info.get("diameter"), geometry.get("diameter"))),
|
|
"roundType": str(round_info.get("type") or geometry.get("roundType") or ""),
|
|
"isConstantRound": _bool_or_none(_first_present(round_info.get("isConstant"), geometry.get("isConstant"), geometry.get("constant"))),
|
|
"isRound": _bool_or_none(_first_present(round_info.get("isRound"), geometry.get("isRound"))),
|
|
"chamferType": str(chamfer_info.get("type") or geometry.get("chamferType") or ""),
|
|
"isEqualDistanceChamfer": _bool_or_none(
|
|
_first_present(
|
|
chamfer_info.get("isEqualDistance"),
|
|
chamfer_info.get("isSymmetric"),
|
|
geometry.get("isEqualDistanceChamfer"),
|
|
geometry.get("isEqualDistance"),
|
|
)
|
|
),
|
|
"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")),
|
|
"patternInstances": _pattern_instances(geometry.get("patternInstances") or topology.get("patternInstances")),
|
|
"sourceObjectIds": _string_list(geometry.get("sourceObjectIds")),
|
|
"bodyLocators": _body_locators(topology.get("bodyLocators") or geometry.get("bodyLocators")),
|
|
"componentLocators": _component_locators(topology.get("componentLocators") or geometry.get("componentLocators")),
|
|
"planeOffset": _rounded_number(geometry.get("planeOffset")),
|
|
"scdmFaceLocators": _face_locators(topology.get("scdmFaceLocators")),
|
|
"heightFaceLocators": _face_locators(
|
|
topology.get("heightFaceLocators")
|
|
or topology.get("topFaceLocators")
|
|
or geometry.get("heightFaceLocators")
|
|
or geometry.get("topFaceLocators")
|
|
),
|
|
"depthFaceLocators": _face_locators(
|
|
topology.get("depthFaceLocators")
|
|
or topology.get("bottomFaceLocators")
|
|
or geometry.get("depthFaceLocators")
|
|
or geometry.get("bottomFaceLocators")
|
|
),
|
|
"diameterFaceLocators": _face_locators(
|
|
topology.get("diameterFaceLocators")
|
|
or topology.get("sideFaceLocators")
|
|
or geometry.get("diameterFaceLocators")
|
|
or geometry.get("sideFaceLocators")
|
|
),
|
|
"wallFaceLocators": _face_locators(
|
|
topology.get("wallFaceLocators")
|
|
or geometry.get("wallFaceLocators")
|
|
),
|
|
}
|
|
|
|
|
|
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")):
|
|
ordinal_face_ids = _face_ids_from_signature_ordinals(signature, local_signatures)
|
|
if ordinal_face_ids:
|
|
signature["faceIds"] = ordinal_face_ids
|
|
signature["localOrdinalMatch"] = True
|
|
match = {
|
|
"status": "ordinal",
|
|
"message": "Matched local Face IDs from SCDM ordinal locators.",
|
|
"faceIds": ordinal_face_ids,
|
|
"score": None,
|
|
}
|
|
else:
|
|
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")
|
|
status = str(match.get("status") or "")
|
|
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"),
|
|
}
|
|
)
|
|
unit_scale = _inferred_local_unit_scale(signature, local_signatures)
|
|
if unit_scale is not None:
|
|
signature["localUnitScale"] = unit_scale
|
|
support_face_ids = _pattern_support_face_ids(signature, local_signatures)
|
|
if support_face_ids:
|
|
signature["supportFaceIds"] = support_face_ids
|
|
support_fit = _pattern_support_fit(signature, local_signatures, support_face_ids, unit_scale or 1.0)
|
|
if support_fit:
|
|
signature["supportPatternFit"] = support_fit
|
|
_attach_pattern_instance_local_display_ids(signature, local_signatures)
|
|
item["geometrySignature"] = signature
|
|
objects.append(item)
|
|
diagnostics["local_face_mapping"] = mapping_rows
|
|
result["objects"] = objects
|
|
result["diagnostics"] = diagnostics
|
|
return result
|
|
|
|
|
|
def _face_ids_from_signature_ordinals(
|
|
signature: Mapping[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
) -> list[int]:
|
|
global_lookup: dict[int, int] = {}
|
|
body_lookup: dict[tuple[int, int], int] = {}
|
|
for local in local_signatures:
|
|
face_id = _int_or_none(local.get("faceId"))
|
|
if face_id is None:
|
|
continue
|
|
global_ordinal = _int_or_none(local.get("globalFaceOrdinal"))
|
|
if global_ordinal is not None:
|
|
global_lookup[global_ordinal] = face_id
|
|
body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId")))
|
|
face_ordinal = _int_or_none(local.get("faceOrdinal"))
|
|
if body_index is not None and face_ordinal is not None:
|
|
body_lookup[(body_index, face_ordinal)] = face_id
|
|
|
|
result: list[int] = []
|
|
for value in _int_list(signature.get("globalFaceOrdinals") or [signature.get("globalFaceOrdinal")]):
|
|
face_id = global_lookup.get(value)
|
|
if face_id is not None:
|
|
result.append(face_id)
|
|
|
|
body_indices = _int_list(signature.get("bodyIndices"))
|
|
if not body_indices:
|
|
body_index = _int_or_none(signature.get("bodyIndex"))
|
|
body_indices = [body_index] if body_index is not None else []
|
|
for body_index in body_indices:
|
|
for face_ordinal in _int_list(signature.get("faceOrdinals") or [signature.get("faceOrdinal")]):
|
|
face_id = body_lookup.get((int(body_index), int(face_ordinal)))
|
|
if face_id is not None:
|
|
result.append(face_id)
|
|
|
|
for locator in _face_locators(signature.get("scdmFaceLocators")):
|
|
face_id = None
|
|
global_ordinal = _int_or_none(locator.get("globalFaceOrdinal"))
|
|
if global_ordinal is not None:
|
|
face_id = global_lookup.get(global_ordinal)
|
|
if face_id is None:
|
|
body_index = _int_or_none(locator.get("bodyIndex"))
|
|
face_ordinal = _int_or_none(locator.get("faceOrdinal"))
|
|
if body_index is not None and face_ordinal is not None:
|
|
face_id = body_lookup.get((body_index, face_ordinal))
|
|
if face_id is not None:
|
|
result.append(face_id)
|
|
|
|
return sorted(set(result))
|
|
|
|
|
|
def _attach_pattern_instance_local_display_ids(
|
|
signature: dict[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
) -> None:
|
|
instances = signature.get("patternInstances")
|
|
if not isinstance(instances, list):
|
|
return
|
|
local_by_face_id = {
|
|
int(face_id): local
|
|
for local in local_signatures
|
|
for face_id in [_int_or_none(local.get("faceId"))]
|
|
if face_id is not None
|
|
}
|
|
updated_instances: list[object] = []
|
|
for instance in instances:
|
|
if not isinstance(instance, Mapping):
|
|
updated_instances.append(instance)
|
|
continue
|
|
row = dict(instance)
|
|
face_ids = _int_list(row.get("faceIds"))
|
|
if not face_ids:
|
|
face_ids = _face_ids_from_signature_ordinals(row, local_signatures)
|
|
if face_ids:
|
|
row["faceIds"] = face_ids
|
|
solid_ids = sorted(
|
|
set(
|
|
int(value)
|
|
for face_id in face_ids
|
|
for value in [
|
|
_int_or_none(
|
|
_first_present(
|
|
local_by_face_id.get(int(face_id), {}).get("solidId"),
|
|
local_by_face_id.get(int(face_id), {}).get("bodyIndex"),
|
|
)
|
|
)
|
|
]
|
|
if value is not None and int(value) >= 0
|
|
)
|
|
)
|
|
part_ids = sorted(
|
|
set(
|
|
int(value)
|
|
for face_id in face_ids
|
|
for value in [_int_or_none(local_by_face_id.get(int(face_id), {}).get("partId"))]
|
|
if value is not None and int(value) >= 0
|
|
)
|
|
)
|
|
if solid_ids:
|
|
row["localSolidIds"] = solid_ids
|
|
if len(solid_ids) == 1:
|
|
row["localSolidId"] = solid_ids[0]
|
|
if part_ids:
|
|
row["localPartIds"] = part_ids
|
|
if len(part_ids) == 1:
|
|
row["localPartId"] = part_ids[0]
|
|
updated_instances.append(row)
|
|
signature["patternInstances"] = updated_instances
|
|
|
|
|
|
def _inferred_local_unit_scale(
|
|
signature: Mapping[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
) -> float | None:
|
|
existing = _number(signature.get("localUnitScale"))
|
|
if existing is not None and existing > 0:
|
|
return existing
|
|
if str(signature.get("patternKind") or "").lower() == "body":
|
|
scale = _body_pattern_unit_scale(signature, local_signatures)
|
|
if scale is not None and scale > 0:
|
|
return scale
|
|
by_face_id = {
|
|
int(face_id): local
|
|
for local in local_signatures
|
|
for face_id in [_int_or_none(local.get("faceId"))]
|
|
if face_id is not None
|
|
}
|
|
scored: list[tuple[float, float]] = []
|
|
for face_id in _int_list(signature.get("faceIds"))[:32]:
|
|
local = by_face_id.get(int(face_id))
|
|
if local is None:
|
|
continue
|
|
score, scale = _local_signature_score(signature, local)
|
|
if score > 0 and scale > 0:
|
|
scored.append((score, scale))
|
|
if scored:
|
|
scored.sort(key=lambda item: item[0], reverse=True)
|
|
return scored[0][1]
|
|
return None
|
|
|
|
|
|
def _body_pattern_unit_scale(
|
|
signature: Mapping[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
) -> float | None:
|
|
if str(signature.get("patternKind") or "").lower() != "body":
|
|
return None
|
|
axis = _unit_vector(_rounded_vector(signature.get("axis")))
|
|
if len(axis) != 3:
|
|
return None
|
|
scdm_spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch")))
|
|
if scdm_spacing is None or scdm_spacing <= 0:
|
|
return None
|
|
body_boxes = _local_body_boxes(local_signatures)
|
|
body_indices = _int_list(signature.get("bodyIndices"))
|
|
centers = [_box_center(body_boxes[index]) for index in body_indices if index in body_boxes]
|
|
if len(centers) < 2:
|
|
return None
|
|
projections = sorted(sum(center[index] * axis[index] for index in range(3)) for center in centers)
|
|
spacings = [
|
|
projections[index + 1] - projections[index]
|
|
for index in range(len(projections) - 1)
|
|
if projections[index + 1] - projections[index] > 1.0e-9
|
|
]
|
|
if not spacings:
|
|
return None
|
|
local_spacing = sum(spacings) / len(spacings)
|
|
if local_spacing <= 0:
|
|
return None
|
|
return float(scdm_spacing) / float(local_spacing)
|
|
|
|
|
|
def _pattern_support_face_ids(
|
|
signature: Mapping[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
) -> list[int]:
|
|
if str(signature.get("patternKind") or "").lower() != "body":
|
|
return []
|
|
body_indices = _int_list(signature.get("bodyIndices"))
|
|
if len(body_indices) < 3:
|
|
return []
|
|
body_boxes = _local_body_boxes(local_signatures)
|
|
member_boxes = [body_boxes[index] for index in body_indices if index in body_boxes]
|
|
if len(member_boxes) < 3:
|
|
return []
|
|
pattern_box = _merge_boxes(member_boxes)
|
|
pattern_size = [pattern_box[1][index] - pattern_box[0][index] for index in range(3)]
|
|
diagonal = math.sqrt(sum(item * item for item in pattern_size))
|
|
tolerance = max(diagonal * 1.0e-5, 1.0e-5)
|
|
pattern_body_set = set(body_indices)
|
|
candidates: list[tuple[float, int]] = []
|
|
for local in local_signatures:
|
|
face_id = _int_or_none(local.get("faceId"))
|
|
body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId")))
|
|
if face_id is None or body_index in pattern_body_set:
|
|
continue
|
|
if _surface_key(local.get("surfaceType")) != "plane":
|
|
continue
|
|
axis = _unit_vector(_rounded_vector(local.get("axis")))
|
|
if len(axis) != 3:
|
|
continue
|
|
face_box = _box_from_local_signature(local)
|
|
if face_box is None:
|
|
continue
|
|
plane = _rounded_number(local.get("planeOffset"))
|
|
if plane is None:
|
|
continue
|
|
overlap = _pattern_projection_overlap_score(pattern_box, face_box, axis)
|
|
if overlap <= 0:
|
|
continue
|
|
distance = _pattern_plane_touch_distance(pattern_box, axis, plane)
|
|
if distance > tolerance:
|
|
continue
|
|
area = _number(local.get("area")) or _box_area_estimate(face_box)
|
|
candidates.append((overlap * max(area, 1.0) - distance, int(face_id)))
|
|
candidates.sort(reverse=True)
|
|
return [face_id for _score, face_id in candidates[:4]]
|
|
|
|
|
|
def _pattern_support_fit(
|
|
signature: Mapping[str, object],
|
|
local_signatures: list[dict[str, object]],
|
|
support_face_ids: list[int],
|
|
unit_scale: float,
|
|
) -> dict[str, object]:
|
|
if not support_face_ids:
|
|
return {}
|
|
axis = _unit_vector(_rounded_vector(signature.get("axis")))
|
|
if len(axis) != 3:
|
|
return {}
|
|
body_boxes = _local_body_boxes(local_signatures)
|
|
member_boxes = [body_boxes[index] for index in _int_list(signature.get("bodyIndices")) if index in body_boxes]
|
|
if len(member_boxes) < 3:
|
|
return {}
|
|
member_centers = [_box_center(box) for box in member_boxes]
|
|
center_projections = sorted(_point_projection(center, axis) for center in member_centers)
|
|
if len(center_projections) < 3:
|
|
return {}
|
|
member_span = max(_box_projection_span(box, axis) for box in member_boxes)
|
|
if member_span <= 0:
|
|
return {}
|
|
local_by_face_id = {
|
|
int(face_id): local
|
|
for local in local_signatures
|
|
for face_id in [_int_or_none(local.get("faceId"))]
|
|
if face_id is not None
|
|
}
|
|
support_candidates = []
|
|
for face_id in support_face_ids:
|
|
local = local_by_face_id.get(int(face_id))
|
|
if local is None:
|
|
continue
|
|
box = _box_from_local_signature(local)
|
|
if box is None:
|
|
continue
|
|
projection_min, projection_max = _box_projection_range(box, axis)
|
|
pattern_center_projection = sum(center_projections) / len(center_projections)
|
|
half_member_span = member_span * 0.5
|
|
left_capacity = pattern_center_projection - projection_min - half_member_span
|
|
right_capacity = projection_max - pattern_center_projection - half_member_span
|
|
max_spacing_local = (2.0 * min(left_capacity, right_capacity)) / (len(center_projections) - 1)
|
|
if max_spacing_local <= 0:
|
|
continue
|
|
support_candidates.append(
|
|
{
|
|
"faceId": int(face_id),
|
|
"supportSpanLocal": projection_max - projection_min,
|
|
"supportProjectionMinLocal": projection_min,
|
|
"supportProjectionMaxLocal": projection_max,
|
|
"memberSpanLocal": member_span,
|
|
"patternCenterProjectionLocal": pattern_center_projection,
|
|
"leftCapacityLocal": left_capacity,
|
|
"rightCapacityLocal": right_capacity,
|
|
"maxSpacingLocal": max_spacing_local,
|
|
}
|
|
)
|
|
if not support_candidates:
|
|
return {}
|
|
support_candidates.sort(key=lambda item: float(item["maxSpacingLocal"]), reverse=True)
|
|
selected = support_candidates[0]
|
|
safe_unit_scale = unit_scale if unit_scale > 0 else 1.0
|
|
max_spacing_local = float(selected["maxSpacingLocal"])
|
|
current_spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch")))
|
|
selected.update(
|
|
{
|
|
"supportFaceIds": support_face_ids,
|
|
"instanceCount": len(center_projections),
|
|
"axis": axis,
|
|
"localUnitScale": safe_unit_scale,
|
|
"maxSpacing": max_spacing_local * safe_unit_scale,
|
|
"currentSpacing": current_spacing,
|
|
"currentSpacingLocal": (current_spacing / safe_unit_scale) if current_spacing is not None and safe_unit_scale > 0 else None,
|
|
}
|
|
)
|
|
return selected
|
|
|
|
|
|
def _local_body_boxes(local_signatures: list[dict[str, object]]) -> dict[int, tuple[list[float], list[float]]]:
|
|
boxes: dict[int, tuple[list[float], list[float]]] = {}
|
|
for local in local_signatures:
|
|
body_index = _int_or_none(_first_present(local.get("bodyIndex"), local.get("solidId")))
|
|
box = _box_from_local_signature(local)
|
|
if body_index is None or box is None:
|
|
continue
|
|
if body_index not in boxes:
|
|
boxes[body_index] = ([*box[0]], [*box[1]])
|
|
continue
|
|
existing = boxes[body_index]
|
|
boxes[body_index] = (
|
|
[min(existing[0][index], box[0][index]) for index in range(3)],
|
|
[max(existing[1][index], box[1][index]) for index in range(3)],
|
|
)
|
|
return boxes
|
|
|
|
|
|
def _box_from_local_signature(local: Mapping[str, object]) -> tuple[list[float], list[float]] | None:
|
|
bbox_min = _rounded_vector(local.get("bboxMin"))
|
|
bbox_max = _rounded_vector(local.get("bboxMax"))
|
|
if len(bbox_min) != 3 or len(bbox_max) != 3:
|
|
return None
|
|
return bbox_min, bbox_max
|
|
|
|
|
|
def _merge_boxes(boxes: list[tuple[list[float], list[float]]]) -> tuple[list[float], list[float]]:
|
|
return (
|
|
[min(box[0][index] for box in boxes) for index in range(3)],
|
|
[max(box[1][index] for box in boxes) for index in range(3)],
|
|
)
|
|
|
|
|
|
def _box_center(box: tuple[list[float], list[float]]) -> list[float]:
|
|
return [(box[0][index] + box[1][index]) * 0.5 for index in range(3)]
|
|
|
|
|
|
def _point_projection(point: list[float], axis: list[float]) -> float:
|
|
return sum(float(point[index]) * float(axis[index]) for index in range(3))
|
|
|
|
|
|
def _box_projection_range(box: tuple[list[float], list[float]], axis: list[float]) -> tuple[float, float]:
|
|
values = []
|
|
for x in (box[0][0], box[1][0]):
|
|
for y in (box[0][1], box[1][1]):
|
|
for z in (box[0][2], box[1][2]):
|
|
values.append(_point_projection([x, y, z], axis))
|
|
return (min(values), max(values)) if values else (0.0, 0.0)
|
|
|
|
|
|
def _box_projection_span(box: tuple[list[float], list[float]], axis: list[float]) -> float:
|
|
left, right = _box_projection_range(box, axis)
|
|
return max(0.0, right - left)
|
|
|
|
|
|
def _pattern_projection_overlap_score(
|
|
pattern_box: tuple[list[float], list[float]],
|
|
face_box: tuple[list[float], list[float]],
|
|
normal: list[float],
|
|
) -> float:
|
|
normal_axis = max(range(3), key=lambda index: abs(normal[index]))
|
|
overlaps = []
|
|
for axis_index in range(3):
|
|
if axis_index == normal_axis:
|
|
continue
|
|
overlap = min(pattern_box[1][axis_index], face_box[1][axis_index]) - max(pattern_box[0][axis_index], face_box[0][axis_index])
|
|
reference = max(pattern_box[1][axis_index] - pattern_box[0][axis_index], 1.0e-9)
|
|
overlaps.append(max(0.0, overlap) / reference)
|
|
if not overlaps or any(value <= 0 for value in overlaps):
|
|
return 0.0
|
|
return sum(overlaps) / len(overlaps)
|
|
|
|
|
|
def _pattern_plane_touch_distance(
|
|
pattern_box: tuple[list[float], list[float]],
|
|
normal: list[float],
|
|
plane_offset: float,
|
|
) -> float:
|
|
distances = []
|
|
for x in (pattern_box[0][0], pattern_box[1][0]):
|
|
for y in (pattern_box[0][1], pattern_box[1][1]):
|
|
for z in (pattern_box[0][2], pattern_box[1][2]):
|
|
distances.append(abs(x * normal[0] + y * normal[1] + z * normal[2] - plane_offset))
|
|
return min(distances) if distances else float("inf")
|
|
|
|
|
|
def _box_area_estimate(box: tuple[list[float], list[float]]) -> float:
|
|
sizes = sorted(max(0.0, box[1][index] - box[0][index]) for index in range(3))
|
|
return sizes[1] * sizes[2]
|
|
|
|
|
|
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 = _first_non_empty(
|
|
_missing_backend_command_reason(key, available_command_names),
|
|
_missing_geometry_reason(key, raw_object),
|
|
)
|
|
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."):
|
|
value = _path_value(geometry, field.split(".", 1)[1])
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _missing_geometry_reason(key: str, raw_object: Mapping[str, object]) -> str:
|
|
if key not in {"slot.depth", "boss.height", "boss.diameter", "round.radius", "chamfer.distance", "pattern.spacing"}:
|
|
return ""
|
|
signature = geometry_signature(raw_object)
|
|
if key == "pattern.spacing":
|
|
spacing = _rounded_number(_first_present(signature.get("spacing"), signature.get("pitch")))
|
|
if spacing is None or spacing <= 0:
|
|
return "SCDM 已识别阵列对象,但没有返回可用于编辑的当前阵列间距。"
|
|
if (
|
|
str(signature.get("patternKind") or "").strip().lower() == "body"
|
|
or str(signature.get("instanceKind") or "").strip().lower() in {"body", "part", "component"}
|
|
or _int_list(signature.get("bodyIndices"))
|
|
):
|
|
axis = _rounded_vector(signature.get("axis"))
|
|
if len(axis) != 3:
|
|
return "SCDM 已识别实体/组件阵列间距,但没有返回线性阵列方向,暂不能稳定改间距。"
|
|
instances = _pattern_instances(signature.get("patternInstances"))
|
|
if len(instances) < 3:
|
|
return "SCDM 已识别实体/组件阵列间距,但没有返回阵列成员实例,暂不能稳定改间距。"
|
|
if any(not _component_locators(item.get("componentLocators") or item.get("bodyLocators")) for item in instances):
|
|
return "SCDM 已识别实体/组件阵列间距,但没有返回每个成员的组件实例定位,暂不能稳定改间距。"
|
|
return ""
|
|
axis = _rounded_vector(signature.get("axis"))
|
|
if len(axis) != 3:
|
|
return "SCDM 已识别阵列间距,但没有返回线性阵列方向,暂不能稳定改间距。"
|
|
instances = _pattern_instances(signature.get("patternInstances"))
|
|
if len(instances) < 3:
|
|
return "SCDM 已识别阵列间距,但没有返回至少三个可定位实例,暂不能稳定改间距。"
|
|
if any(not _pattern_instance_has_locator(item) for item in instances):
|
|
return "SCDM 已识别阵列间距,但没有返回每个阵列实例的可定位 Face,暂不能稳定改间距。"
|
|
return ""
|
|
if key == "slot.depth":
|
|
depth = _rounded_number(signature.get("depth"))
|
|
if depth is None or depth <= 0:
|
|
return "SCDM 已识别槽对象,但没有返回可用于编辑的当前槽深。"
|
|
if not _slot_depth_axis(signature):
|
|
return "SCDM 已识别槽深,但没有返回槽深方向,暂不能稳定改槽深。"
|
|
if not _has_depth_face_locator(signature):
|
|
return "SCDM 已识别槽深,但没有返回可推动的槽底面定位信息,暂不能稳定改槽深。"
|
|
return ""
|
|
if key == "round.radius":
|
|
if not _has_scdm_face_locator(signature):
|
|
return "SCDM 已识别圆角半径,但没有返回可定位的圆角面,暂不能稳定改半径。"
|
|
if signature.get("isConstantRound") is not True:
|
|
return "SCDM 已识别圆角半径,但没有返回等半径圆角证据,暂不能稳定改半径。"
|
|
return ""
|
|
if key == "chamfer.distance":
|
|
if not _has_scdm_face_locator(signature):
|
|
return "SCDM 已识别倒角距离,但没有返回可定位的倒角面,暂不能稳定改距离。"
|
|
distance = _rounded_number(signature.get("distance"))
|
|
if distance is None or distance <= 0:
|
|
return "SCDM 已识别倒角对象,但没有返回可用于编辑的当前倒角距离。"
|
|
if signature.get("isEqualDistanceChamfer") is not True:
|
|
return "SCDM 已识别倒角距离,但没有返回等距倒角证据,暂不能稳定改距离。"
|
|
return ""
|
|
if key == "boss.height":
|
|
if (
|
|
_face_locators(signature.get("heightFaceLocators"))
|
|
or _int_list(signature.get("heightFaceOrdinals"))
|
|
or _int_list(signature.get("globalHeightFaceOrdinals"))
|
|
):
|
|
return ""
|
|
return "SCDM 已识别凸台高度,但没有返回可推动的顶面定位信息,暂不能稳定改高度。"
|
|
if (
|
|
_face_locators(signature.get("diameterFaceLocators"))
|
|
or _int_list(signature.get("diameterFaceOrdinals"))
|
|
or _int_list(signature.get("globalDiameterFaceOrdinals"))
|
|
):
|
|
return ""
|
|
return "SCDM 已识别凸台直径,但没有返回可偏移的侧壁定位信息,暂不能稳定改直径。"
|
|
|
|
|
|
def _has_scdm_face_locator(signature: Mapping[str, object]) -> bool:
|
|
if _face_locators(signature.get("scdmFaceLocators")):
|
|
return True
|
|
if _int_list(signature.get("faceOrdinals")) or _int_list(signature.get("globalFaceOrdinals")):
|
|
return True
|
|
if _int_or_none(signature.get("faceOrdinal")) is not None:
|
|
return True
|
|
return _int_or_none(signature.get("globalFaceOrdinal")) is not None
|
|
|
|
|
|
def _has_depth_face_locator(signature: Mapping[str, object]) -> bool:
|
|
if _face_locators(signature.get("depthFaceLocators")):
|
|
return True
|
|
if _int_list(signature.get("depthFaceOrdinals")) or _int_list(signature.get("globalDepthFaceOrdinals")):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _slot_depth_axis(signature: Mapping[str, object]) -> list[float]:
|
|
return _rounded_vector(signature.get("depthAxis"))
|
|
|
|
|
|
def _pattern_instance_has_locator(instance: Mapping[str, object]) -> bool:
|
|
if _component_locators(instance.get("componentLocators") or instance.get("bodyLocators")):
|
|
return True
|
|
if _body_locators(instance.get("bodyLocators")):
|
|
return True
|
|
if _int_or_none(instance.get("bodyIndex")) is not None and str(instance.get("instanceKind") or "").lower() in {"body", "part", "component"}:
|
|
return True
|
|
if _face_locators(instance.get("scdmFaceLocators")):
|
|
return True
|
|
if _int_list(instance.get("faceOrdinals")) or _int_list(instance.get("globalFaceOrdinals")):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _first_non_empty(*values: str) -> str:
|
|
for value in values:
|
|
text = str(value or "").strip()
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
|
|
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"))
|
|
locator = {
|
|
"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"))),
|
|
}
|
|
component_locators = _component_locators(topology.get("componentLocators") or geometry.get("componentLocators"))
|
|
if component_locators:
|
|
locator["componentLocators"] = component_locators
|
|
return locator
|
|
|
|
|
|
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 _body_locators(value: object) -> list[dict[str, object]]:
|
|
if not isinstance(value, (list, tuple)):
|
|
return []
|
|
result: list[dict[str, object]] = []
|
|
seen: set[tuple[object, ...]] = set()
|
|
for item in value:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
locator = {
|
|
"backendId": str(item.get("backendId") or ""),
|
|
"bodyIndex": _int_or_none(item.get("bodyIndex")),
|
|
"componentIndex": _int_or_none(item.get("componentIndex")),
|
|
"componentPath": _ordered_int_list(item.get("componentPath")),
|
|
"componentBodyIndex": _int_or_none(item.get("componentBodyIndex")),
|
|
"componentName": str(item.get("componentName") or ""),
|
|
}
|
|
if locator.get("bodyIndex") is not None or locator.get("componentIndex") is not None or locator.get("componentPath"):
|
|
key = (
|
|
locator.get("bodyIndex"),
|
|
locator.get("componentIndex"),
|
|
tuple(locator.get("componentPath") or []),
|
|
locator.get("componentBodyIndex"),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
result.append(locator)
|
|
return result
|
|
|
|
|
|
def _component_locators(value: object) -> list[dict[str, object]]:
|
|
if not isinstance(value, (list, tuple)):
|
|
return []
|
|
result: list[dict[str, object]] = []
|
|
seen: set[tuple[object, ...]] = set()
|
|
for item in value:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
path = _ordered_int_list(item.get("componentPath"))
|
|
locator = {
|
|
"backendId": str(item.get("backendId") or ""),
|
|
"componentIndex": _int_or_none(item.get("componentIndex")),
|
|
"componentPath": path,
|
|
"componentBodyIndex": _int_or_none(item.get("componentBodyIndex")),
|
|
"bodyIndex": _int_or_none(item.get("bodyIndex")),
|
|
"componentName": str(item.get("componentName") or ""),
|
|
"contentMoniker": str(item.get("contentMoniker") or ""),
|
|
"templateMoniker": str(item.get("templateMoniker") or ""),
|
|
"placementTranslation": _rounded_vector(item.get("placementTranslation")),
|
|
}
|
|
if locator.get("componentIndex") is None and not path:
|
|
continue
|
|
key = (
|
|
locator.get("componentIndex"),
|
|
tuple(path),
|
|
locator.get("componentBodyIndex"),
|
|
locator.get("bodyIndex"),
|
|
locator.get("contentMoniker"),
|
|
locator.get("templateMoniker"),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
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 _path_value(mapping: Mapping[str, object], path: str) -> object:
|
|
current: object = mapping
|
|
for part in path.split("."):
|
|
if not isinstance(current, Mapping):
|
|
return None
|
|
current = current.get(part)
|
|
if current is None:
|
|
return None
|
|
return current
|
|
|
|
|
|
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 _ordered_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 result
|
|
|
|
|
|
def _int_or_none(value: object) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _bool_or_none(value: object) -> bool | None:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
text = value.strip().lower()
|
|
if text in {"true", "1", "yes", "y"}:
|
|
return True
|
|
if text in {"false", "0", "no", "n"}:
|
|
return False
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
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 _pattern_instances(value: object) -> list[dict[str, object]]:
|
|
if isinstance(value, (str, bytes)) or value is None:
|
|
return []
|
|
try:
|
|
values = list(value) # type: ignore[arg-type]
|
|
except TypeError:
|
|
return []
|
|
result: list[dict[str, object]] = []
|
|
for item in values:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
center = _rounded_vector(item.get("center") or item.get("instanceCenter"))
|
|
if len(center) != 3:
|
|
continue
|
|
result.append(
|
|
{
|
|
"sourceObjectId": str(item.get("sourceObjectId") or item.get("objectId") or ""),
|
|
"instanceKind": str(item.get("instanceKind") or ""),
|
|
"center": center,
|
|
"bodyIndex": _int_or_none(item.get("bodyIndex")),
|
|
"bodyLocators": _body_locators(item.get("bodyLocators")),
|
|
"componentLocators": _component_locators(item.get("componentLocators") or item.get("bodyLocators")),
|
|
"faceIds": _int_list(item.get("faceIds")),
|
|
"faceOrdinals": _int_list(item.get("faceOrdinals") or [item.get("faceOrdinal")]),
|
|
"globalFaceOrdinals": _int_list(
|
|
item.get("globalFaceOrdinals")
|
|
or [item.get("globalFaceOrdinal")]
|
|
),
|
|
"scdmFaceLocators": _face_locators(item.get("scdmFaceLocators") or item.get("faceLocators")),
|
|
}
|
|
)
|
|
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",
|
|
"SCDM_FEATURE_CACHE_REVISION",
|
|
]
|