2026-08-19 10:28:09 +08:00
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Callable
from .scdm_backend import ScdmBackendInfo , resolve_scdm_backend , save_scdm_backend_cache , scdm_run_script_command
from .scdm_schema import ScdmProbeJob , default_scdm_work_dir , file_fingerprint , read_json , utc_now , write_json
def prepare_scdm_probe_job (
step_path : str | Path ,
* ,
output_dir : str | Path | None = None ,
project_root : str | Path | None = None ,
backend : ScdmBackendInfo | None = None ,
unit : str = "model" ,
scan_scope : str = "all" ,
) -> dict [ str , object ]:
source = Path ( step_path ) . expanduser ()
if not source . is_file ():
return { "ok" : False , "reason" : "missing-step" , "message" : f "STEP file not found: { source } " }
fingerprint = file_fingerprint ( source )
work_dir = Path ( output_dir ) . expanduser () if output_dir else default_scdm_work_dir ( source , project_root = project_root , fingerprint = fingerprint )
work_dir = work_dir . resolve ( strict = False )
work_dir . mkdir ( parents = True , exist_ok = True )
job = ScdmProbeJob (
step_path = source . resolve ( strict = False ),
output_dir = work_dir ,
raw_features_path = work_dir / "scdm_raw_features.json" ,
error_path = work_dir / "error.json" ,
model_fingerprint = fingerprint ,
unit = unit ,
scan_scope = scan_scope ,
backend_path = str ( backend . path ) if backend else "" ,
backend_version = backend . version if backend else "" ,
)
job_path = work_dir / "scdm_probe_job.json"
script_path = work_dir / "scdm_probe.py"
write_json ( job_path , job . to_payload ())
script_path . write_text ( generate_scdm_probe_script ( job_path ), encoding = "utf-8" )
return {
"ok" : True ,
"reason" : "ok" ,
"work_dir" : str ( work_dir ),
"job_path" : str ( job_path ),
"script_path" : str ( script_path ),
"raw_features_path" : str ( job . raw_features_path ),
"error_path" : str ( job . error_path ),
"model_fingerprint" : fingerprint ,
}
def run_scdm_probe (
step_path : str | Path ,
* ,
backend : ScdmBackendInfo | None = None ,
output_dir : str | Path | None = None ,
project_root : str | Path | None = None ,
timeout_seconds : float = 120.0 ,
runner : Callable [ ... , subprocess . CompletedProcess [ str ]] | None = None ,
) -> dict [ str , object ]:
if backend is None :
resolved = resolve_scdm_backend ( project_root_override = project_root , validate = False )
if not resolved . get ( "ok" ) or not isinstance ( resolved . get ( "backend" ), ScdmBackendInfo ):
return {
"ok" : False ,
"reason" : str ( resolved . get ( "reason" ) or "missing-scdm" ),
"message" : str ( resolved . get ( "message" ) or "SCDM backend is not available." ),
"backend_resolution" : {
"ok" : bool ( resolved . get ( "ok" )),
"reason" : str ( resolved . get ( "reason" ) or "" ),
"message" : str ( resolved . get ( "message" ) or "" ),
},
}
backend = resolved [ "backend" ] # type: ignore[assignment]
prepared = prepare_scdm_probe_job ( step_path , output_dir = output_dir , project_root = project_root , backend = backend )
if not prepared . get ( "ok" ):
return { "backend" : backend . to_cache (), ** prepared }
script_path = Path ( str ( prepared [ "script_path" ]))
raw_path = Path ( str ( prepared [ "raw_features_path" ]))
error_path = Path ( str ( prepared [ "error_path" ]))
command = scdm_run_script_command ( backend . path , script_path )
run = runner or subprocess . run
try :
completed = run (
command ,
cwd = str ( script_path . parent ),
capture_output = True ,
text = True ,
encoding = "utf-8" ,
errors = "replace" ,
timeout = max ( float ( timeout_seconds ), 0.1 ),
creationflags = getattr ( subprocess , "CREATE_NO_WINDOW" , 0 ),
check = False ,
)
except subprocess . TimeoutExpired :
write_json ( error_path , { "ok" : False , "reason" : "timeout" , "message" : "SCDM probe timed out." })
return { "ok" : False , "reason" : "timeout" , "message" : "SCDM probe timed out." , "backend" : backend . to_cache (), ** prepared }
except OSError as exc :
write_json ( error_path , { "ok" : False , "reason" : "launch-failed" , "message" : str ( exc )})
return { "ok" : False , "reason" : "launch-failed" , "message" : str ( exc ), "backend" : backend . to_cache (), ** prepared }
returncode = int ( getattr ( completed , "returncode" , - 1 ))
if returncode != 0 :
message = ( str ( getattr ( completed , "stderr" , "" ) or "" ) or str ( getattr ( completed , "stdout" , "" ) or "" )) . strip ()
write_json (
error_path ,
{
"ok" : False ,
"reason" : "probe-failed" ,
"returncode" : returncode ,
"message" : message ,
},
)
return { "ok" : False , "reason" : "probe-failed" , "returncode" : returncode , "message" : message , "backend" : backend . to_cache (), ** prepared }
if not raw_path . is_file ():
write_json ( error_path , { "ok" : False , "reason" : "missing-raw-output" , "message" : "SCDM probe did not write raw features." })
return {
"ok" : False ,
"reason" : "missing-raw-output" ,
"message" : "SCDM probe did not write raw features." ,
"backend" : backend . to_cache (),
** prepared ,
}
raw = read_json ( raw_path )
verified_backend = _probe_verified_backend ( backend )
try :
save_scdm_backend_cache ( verified_backend , project_root_override = project_root )
except Exception :
pass
return { "ok" : True , "reason" : "ok" , "raw" : raw , "backend" : verified_backend . to_cache (), ** prepared }
def _probe_verified_backend ( backend : ScdmBackendInfo ) -> ScdmBackendInfo :
return ScdmBackendInfo (
path = backend . path ,
source = backend . source ,
version = backend . version ,
verified_at = utc_now (),
run_script_ok = True ,
license_ok = True ,
message = "SCDM probe completed." ,
)
def generate_scdm_probe_script ( job_path : str | Path ) -> str :
job_literal = repr ( str ( Path ( job_path ) . expanduser ()))
return (
"from __future__ import print_function \n "
"import json \n "
"import traceback \n "
f "JOB_PATH = { job_literal } \n "
" \n "
"def _write_json(path, payload): \n "
" handle = open(path, 'w') \n "
" try: \n "
" handle.write(json.dumps(payload, indent=2)) \n "
" finally: \n "
" handle.close() \n "
" \n "
"def _safe_name(value): \n "
" try: \n "
" return type(value).__name__ \n "
" except Exception: \n "
" return '' \n "
" \n "
"def _float_attr(value, names): \n "
" for name in names: \n "
" try: \n "
" result = getattr(value, name) \n "
" return float(result) \n "
" except Exception: \n "
" pass \n "
" return None \n "
" \n "
"def _xyz(value): \n "
" if value is None: \n "
" return [] \n "
" result = [] \n "
" for name in ('X', 'Y', 'Z'): \n "
" try: \n "
" result.append(float(getattr(value, name))) \n "
" except Exception: \n "
" return [] \n "
" return result \n "
" \n "
"def _items(collection): \n "
" if collection is None: \n "
" return [] \n "
" try: \n "
" return list(collection) \n "
" except Exception: \n "
" items = [] \n "
" try: \n "
" count = int(collection.Count) \n "
" for index in range(count): \n "
" items.append(collection[index]) \n "
" except Exception: \n "
" pass \n "
" return items \n "
" \n "
"def _geometry_from_face(face): \n "
" geometry = {} \n "
" surface = None \n "
" for expr in ('Shape.Geometry', 'Geometry', 'Surface'): \n "
" try: \n "
" current = face \n "
" for part in expr.split('.'): \n "
" current = getattr(current, part) \n "
" surface = current \n "
" break \n "
" except Exception: \n "
" pass \n "
" surface_name = _safe_name(surface) \n "
" geometry['surfaceType'] = surface_name \n "
" lowered = surface_name.lower() \n "
" radius = _float_attr(surface, ('Radius', 'radius')) \n "
" if radius is not None: \n "
" geometry['radius'] = radius \n "
" geometry['diameter'] = radius * 2.0 \n "
" try: \n "
" geometry['center'] = _xyz(surface.Frame.Origin) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" geometry['axis'] = _xyz(surface.Frame.DirZ) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" center = geometry.get('center') or [] \n "
" axis = geometry.get('axis') or [] \n "
" if len(center) == 3 and len(axis) == 3: \n "
" geometry['planeOffset'] = center[0] * axis[0] + center[1] * axis[1] + center[2] * axis[2] \n "
" except Exception: \n "
" pass \n "
" if 'plane' in lowered: \n "
" geometry['surfaceType'] = 'plane' \n "
" elif 'cylinder' in lowered: \n "
" geometry['surfaceType'] = 'cylinder' \n "
2026-08-19 18:02:47 +08:00
" slot_info = _slot_info_from_face(face, geometry) \n "
" if slot_info: \n "
" geometry['slotInfo'] = slot_info \n "
" for key in ('width', 'depth', 'center', 'depthAxis'): \n "
" if slot_info.get(key) is not None: \n "
" geometry[key] = slot_info.get(key) \n "
2026-08-19 10:28:09 +08:00
" round_info = _round_info_from_face(face, geometry) \n "
" if round_info: \n "
" geometry['roundInfo'] = round_info \n "
2026-08-19 18:02:47 +08:00
" chamfer_info = _chamfer_info_from_face(face, geometry) \n "
" if chamfer_info: \n "
" geometry['chamferInfo'] = chamfer_info \n "
2026-08-19 10:28:09 +08:00
" return geometry \n "
" \n "
"def _round_info_from_face(face, geometry): \n "
" if str(geometry.get('surfaceType', '')).lower() != 'cylinder': \n "
" return {} \n "
" round_info_type = globals().get('RoundInfo') \n "
" if round_info_type is None: \n "
" return {} \n "
" try: \n "
" info = round_info_type.Create(face) \n "
" except Exception: \n "
" return {} \n "
" payload = {'available': True, 'type': _safe_name(info)} \n "
" for attr in ('Radius', 'RoundRadius', 'ConstantRadius'): \n "
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:])) \n "
" if value is not None: \n "
" payload['radius'] = value \n "
" payload['diameter'] = value * 2.0 \n "
" break \n "
" for attr in ('IsConstant', 'IsRound'): \n "
" try: \n "
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr)) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40] \n "
" except Exception: \n "
" pass \n "
" return payload \n "
" \n "
2026-08-19 18:02:47 +08:00
"def _same_object(left, right): \n "
" try: \n "
" if left is right: \n "
" return True \n "
" except Exception: \n "
" pass \n "
" try: \n "
" return left == right \n "
" except Exception: \n "
" return False \n "
" \n "
"def _slot_info_from_face(face, geometry): \n "
" slot_info_type = globals().get('SlotInfo') \n "
" if slot_info_type is None: \n "
" return {} \n "
" try: \n "
" info = slot_info_type.Create(face) \n "
" except Exception: \n "
" return {} \n "
" payload = {'available': True, 'type': _safe_name(info)} \n "
" for key, attrs in ( \n "
" ('width', ('Width', 'SlotWidth', 'Diameter')), \n "
" ('depth', ('Depth', 'SlotDepth', 'Height')), \n "
" ): \n "
" value = _float_attr(info, attrs) \n "
" if value is not None: \n "
" payload[key] = value \n "
" center = _xyz(_first_path_value(info, ('Center', 'AxisCenter', 'Frame.Origin'))) \n "
" if center: \n "
" payload['center'] = center \n "
" depth_axis = _xyz(_first_path_value(info, ('DepthAxis', 'DepthDirection', 'Direction', 'Frame.DirZ'))) \n "
" if depth_axis: \n "
" payload['depthAxis'] = depth_axis \n "
" for attr in ('IsBlind', 'IsThrough', 'IsSlot'): \n "
" try: \n "
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr)) \n "
" except Exception: \n "
" pass \n "
" for attr in ('BottomFace', 'DepthFace', 'FloorFace'): \n "
" try: \n "
" if _same_object(getattr(info, attr), face): \n "
" payload['depthFaceIsCurrent'] = True \n "
" except Exception: \n "
" pass \n "
" for attr in ('BottomFaces', 'DepthFaces', 'FloorFaces'): \n "
" try: \n "
" for item in _items(getattr(info, attr)): \n "
" if _same_object(item, face): \n "
" payload['depthFaceIsCurrent'] = True \n "
" except Exception: \n "
" pass \n "
" try: \n "
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40] \n "
" except Exception: \n "
" pass \n "
" return payload \n "
" \n "
"def _chamfer_info_from_face(face, geometry): \n "
" if str(geometry.get('surfaceType', '')).lower() != 'plane': \n "
" return {} \n "
" chamfer_info_type = globals().get('ChamferInfo') \n "
" if chamfer_info_type is None: \n "
" return {} \n "
" try: \n "
" info = chamfer_info_type.Create(face) \n "
" except Exception: \n "
" return {} \n "
" payload = {'available': True, 'type': _safe_name(info)} \n "
" for attr in ('Distance', 'ChamferDistance', 'Offset', 'Width'): \n "
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:])) \n "
" if value is not None: \n "
" payload['distance'] = value \n "
" break \n "
" distance1 = _float_attr(info, ('Distance1', 'distance1', 'FirstDistance')) \n "
" distance2 = _float_attr(info, ('Distance2', 'distance2', 'SecondDistance')) \n "
" if distance1 is not None: \n "
" payload['distance1'] = distance1 \n "
" if distance2 is not None: \n "
" payload['distance2'] = distance2 \n "
" if distance1 is not None and distance2 is not None and abs(distance1 - distance2) <= max(abs(distance1), abs(distance2), 1.0) * 1e-6: \n "
" payload.setdefault('distance', distance1) \n "
" payload['isEqualDistance'] = True \n "
" for attr in ('IsEqualDistance', 'IsSymmetric', 'IsChamfer'): \n "
" try: \n "
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr)) \n "
" except Exception: \n "
" pass \n "
" if payload.get('isSymmetric') is True: \n "
" payload['isEqualDistance'] = True \n "
" try: \n "
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40] \n "
" except Exception: \n "
" pass \n "
" return payload \n "
" \n "
2026-08-19 10:28:09 +08:00
"def _path_value(value, expr): \n "
" current = value \n "
" for part in expr.split('.'): \n "
" try: \n "
" current = getattr(current, part) \n "
" except Exception: \n "
" return None \n "
" return current \n "
" \n "
"def _first_path_value(value, exprs): \n "
" for expr in exprs: \n "
" result = _path_value(value, expr) \n "
" if result is not None: \n "
" return result \n "
" return None \n "
" \n "
"def _geometry_from_edge(edge): \n "
" geometry = {} \n "
" shape = getattr(edge, 'Shape', edge) \n "
" curve = _first_path_value(edge, ('Shape.Geometry', 'Geometry', 'Shape.Curve', 'Curve', 'Shape')) or shape \n "
" geometry['curveShapeType'] = _safe_name(shape) \n "
" geometry['curveType'] = _safe_name(curve) \n "
" length = _float_attr(edge, ('Length', 'length')) \n "
" if length is None: \n "
" length = _float_attr(shape, ('Length', 'length')) \n "
" if length is not None: \n "
" geometry['length'] = length \n "
" start = _xyz(_first_path_value(edge, ('StartPoint', 'Shape.StartPoint'))) \n "
" end = _xyz(_first_path_value(edge, ('EndPoint', 'Shape.EndPoint'))) \n "
" if start: \n "
" geometry['startPoint'] = start \n "
" if end: \n "
" geometry['endPoint'] = end \n "
" if len(start) == 3 and len(end) == 3: \n "
" geometry['midPoint'] = [(start[i] + end[i]) * 0.5 for i in range(3)] \n "
" radius = _float_attr(curve, ('Radius', 'radius')) \n "
" if radius is not None: \n "
" geometry['radius'] = radius \n "
" geometry['diameter'] = radius * 2.0 \n "
" center = _xyz(_first_path_value(curve, ('Frame.Origin', 'Circle.Frame.Origin'))) \n "
" if center: \n "
" geometry['center'] = center \n "
" axis = _xyz(_first_path_value(curve, ('Frame.DirZ', 'Circle.Frame.DirZ'))) \n "
" if axis: \n "
" geometry['axis'] = axis \n "
" return geometry \n "
" \n "
"def _edge_adjacent_face_ordinals(edge, face_ordinals_by_marker): \n "
" faces = [] \n "
" for expr in ('Faces', 'Shape.Faces', 'GetFaces'): \n "
" value = _path_value(edge, expr) \n "
" if value is None and expr == 'GetFaces': \n "
" value = _maybe_call(edge, 'GetFaces') \n "
" faces = _items(value) \n "
" if faces: \n "
" break \n "
" ordinals = [] \n "
" for face in faces: \n "
" marker = str(id(face)) \n "
" if marker in face_ordinals_by_marker: \n "
" ordinals.append(face_ordinals_by_marker[marker]) \n "
" return {'adjacentFaceCount': len(faces), 'adjacentFaceOrdinals': ordinals} \n "
" \n "
"def _int_or_none(value): \n "
" try: \n "
" return int(value) \n "
" except Exception: \n "
" return None \n "
" \n "
"def _edge_kind(geometry): \n "
" curve_type = str(geometry.get('curveType', '') or geometry.get('curveShapeType', '')).lower() \n "
" if geometry.get('radius') is not None or 'circle' in curve_type or 'arc' in curve_type: \n "
" return 'circular' \n "
" if 'line' in curve_type or 'segment' in curve_type: \n "
" return 'linear' \n "
" return 'other' \n "
" \n "
"def _add_edge_geometry_summary(summary, geometry): \n "
" summary['totalEdgeCount'] = int(summary.get('totalEdgeCount', 0)) + 1 \n "
" kind = _edge_kind(geometry) \n "
" kind_counts = summary.setdefault('edgeKindCounts', {} ) \n "
" kind_counts[kind] = int(kind_counts.get(kind, 0)) + 1 \n "
" radius = geometry.get('radius') \n "
" if radius is not None: \n "
" try: \n "
" radius = float(radius) \n "
" summary['circularEdgeCount'] = int(summary.get('circularEdgeCount', 0)) + 1 \n "
" values = summary.setdefault('circularRadii', []) \n "
" if len(values) < 80: \n "
" values.append(radius) \n "
" except Exception: \n "
" pass \n "
" length = geometry.get('length') \n "
" if length is not None: \n "
" try: \n "
" length = float(length) \n "
" summary['minEdgeLength'] = min(float(summary.get('minEdgeLength', length)), length) \n "
" summary['maxEdgeLength'] = max(float(summary.get('maxEdgeLength', length)), length) \n "
" except Exception: \n "
" pass \n "
" \n "
"def _final_edge_geometry_summary(summary): \n "
" result = dict(summary) \n "
" radii = result.get('circularRadii') \n "
" if isinstance(radii, list) and radii: \n "
" buckets = {} \n "
" for value in radii: \n "
" try: \n "
" key = ' %.6g ' % f loat(value) \n "
" buckets[key] = int(buckets.get(key, 0)) + 1 \n "
" except Exception: \n "
" pass \n "
" result['circularRadiusBuckets'] = [ \n "
" {'radius': key, 'count': buckets[key]} for key in sorted(buckets.keys())[:40] \n "
" ] \n "
" result.pop('circularRadii', None) \n "
" return result \n "
" \n "
"def _record_face_adjacency(adjacency_map, body_index, edge_topology, geometry): \n "
" ordinals = [] \n "
" for value in edge_topology.get('adjacentFaceOrdinals', []) or []: \n "
" number = _int_or_none(value) \n "
" if number is not None and number not in ordinals: \n "
" ordinals.append(number) \n "
" if len(ordinals) < 2: \n "
" return \n "
" ordinals.sort() \n "
" kind = _edge_kind(geometry) \n "
" for left_index in range(len(ordinals)): \n "
" for right_index in range(left_index + 1, len(ordinals)): \n "
" left = ordinals[left_index] \n "
" right = ordinals[right_index] \n "
" key = (body_index, left, right) \n "
" item = adjacency_map.setdefault( \n "
" key, \n "
" {'bodyIndex': body_index, 'faceOrdinals': [left, right], 'edgeCount': 0, 'edgeKinds': {} , 'edges': []}, \n "
" ) \n "
" item['edgeCount'] = int(item.get('edgeCount', 0)) + 1 \n "
" edge_kinds = item.setdefault('edgeKinds', {} ) \n "
" edge_kinds[kind] = int(edge_kinds.get(kind, 0)) + 1 \n "
" edges = item.setdefault('edges', []) \n "
" if len(edges) < 6: \n "
" edges.append({ \n "
" 'edgeOrdinal': edge_topology.get('edgeOrdinal'), \n "
" 'globalEdgeOrdinal': edge_topology.get('globalEdgeOrdinal'), \n "
" 'curveType': geometry.get('curveType'), \n "
" 'kind': kind, \n "
" 'length': geometry.get('length'), \n "
" 'radius': geometry.get('radius'), \n "
" }) \n "
" \n "
"def _face_adjacency_rows(adjacency_map): \n "
" rows = list(adjacency_map.values()) \n "
" rows.sort(key=lambda item: (int(item.get('bodyIndex') or 0), item.get('faceOrdinals') or [])) \n "
" return rows \n "
" \n "
"def _count_key(counts, key): \n "
" key = str(key or '').strip() or 'unknown' \n "
" counts[key] = int(counts.get(key, 0)) + 1 \n "
" \n "
"def _feature_inventory(objects): \n "
" result = {'objectTypeCounts': {} , 'surfaceTypeCounts': {} , 'curveTypeCounts': {} , 'operationCounts': {} } \n "
" for item in objects: \n "
" if not isinstance(item, dict): \n "
" continue \n "
" _count_key(result['objectTypeCounts'], item.get('objectType')) \n "
" geometry = item.get('geometry') \n "
" if not isinstance(geometry, dict): \n "
" geometry = {} \n "
" if geometry.get('surfaceType') is not None: \n "
" _count_key(result['surfaceTypeCounts'], geometry.get('surfaceType')) \n "
" if geometry.get('curveType') is not None: \n "
" _count_key(result['curveTypeCounts'], geometry.get('curveType')) \n "
" for command in item.get('backendCommandCandidates', []) or []: \n "
" if isinstance(command, dict): \n "
" _count_key(result['operationCounts'], command.get('operation')) \n "
" return result \n "
" \n "
"def _command_candidates(object_type, geometry): \n "
" surface_type = str(geometry.get('surfaceType', '')).lower() \n "
" result = [] \n "
" if object_type == 'face' and surface_type == 'plane': \n "
" result.append({'operation': 'pull_face_offset', 'enabled': True, 'parameterFields': {'distance': 0}}) \n "
" if object_type in ('face', 'hole') and surface_type == 'cylinder': \n "
" result.append({'operation': 'change_hole_diameter', 'enabled': True, 'parameterFields': {'diameter': geometry.get('diameter')}}) \n "
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}}) \n "
" if object_type == 'hole' and surface_type == 'cylinder': \n "
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {} }) \n "
2026-08-19 18:02:47 +08:00
" if object_type in ('slot', 'obround_slot', 'rectangular_slot'): \n "
" if geometry.get('width') is not None: \n "
" result.append({'operation': 'change_slot_width', 'enabled': True, 'parameterFields': {'width': geometry.get('width')}}) \n "
" if geometry.get('depth') is not None: \n "
" result.append({'operation': 'change_slot_depth', 'enabled': True, 'parameterFields': {'depth': geometry.get('depth')}}) \n "
" if geometry.get('center') is not None: \n "
" result.append({'operation': 'move_slot', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}}) \n "
2026-08-19 10:28:09 +08:00
" round_info = geometry.get('roundInfo') \n "
" if isinstance(round_info, dict) and round_info.get('radius') is not None: \n "
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}}) \n "
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {} }) \n "
2026-08-19 18:02:47 +08:00
" chamfer_info = geometry.get('chamferInfo') \n "
" if isinstance(chamfer_info, dict) and chamfer_info.get('distance') is not None: \n "
" result.append({'operation': 'change_chamfer_distance', 'enabled': True, 'parameterFields': {'distance': chamfer_info.get('distance')}}) \n "
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {} }) \n "
2026-08-19 10:28:09 +08:00
" return result \n "
" \n "
"def _open_step(path): \n "
" errors = [] \n "
" for opener in ('DocumentOpen.Execute', 'Application.OpenDocument'): \n "
" try: \n "
" current = globals() \n "
" target = None \n "
" for part in opener.split('.'): \n "
" target = current.get(part) if isinstance(current, dict) else getattr(current, part) \n "
" current = target \n "
" target(path) \n "
" return \n "
" except Exception as exc: \n "
" errors.append(str(exc)) \n "
" raise Exception('Could not open STEP: ' + '; '.join(errors)) \n "
" \n "
"def _root_part(): \n "
" try: \n "
" return GetRootPart() \n "
" except Exception: \n "
" pass \n "
" try: \n "
" return Application.ActiveWindow.Document.MainPart \n "
" except Exception: \n "
" return None \n "
" \n "
"def _maybe_call(target, name): \n "
" try: \n "
" value = getattr(target, name) \n "
" except Exception: \n "
" return None \n "
" try: \n "
" return value() \n "
" except Exception: \n "
" return value \n "
" \n "
2026-08-19 18:02:47 +08:00
"def _safe_str(value): \n "
" if value is None: \n "
" return '' \n "
" try: \n "
" return str(value) \n "
" except Exception: \n "
" return _safe_name(value) \n "
" \n "
"def _matrix_payload(matrix): \n "
" if matrix is None: \n "
" return {} \n "
" payload = {'type': _safe_name(matrix), 'text': _safe_str(matrix)} \n "
" translation = _xyz(_path_value(matrix, 'Translation')) \n "
" if translation: \n "
" payload['translation'] = translation \n "
" for attr in ('OffsetX', 'OffsetY', 'OffsetZ'): \n "
" value = _float_attr(matrix, (attr, attr[0].lower() + attr[1:])) \n "
" if value is not None: \n "
" payload[attr] = value \n "
" return payload \n "
" \n "
"def _moniker_text(value): \n "
" try: \n "
" return _safe_str(getattr(value, 'Moniker')) \n "
" except Exception: \n "
" return '' \n "
" \n "
"def _component_name(component): \n "
" for attr in ('Name', 'DisplayName'): \n "
" try: \n "
" text = _safe_str(getattr(component, attr)).strip() \n "
" if text: \n "
" return text \n "
" except Exception: \n "
" pass \n "
" return '' \n "
" \n "
"def _immediate_components(part): \n "
" if part is None: \n "
" return [] \n "
" try: \n "
" items = _items(getattr(part, 'Components')) \n "
" if items: \n "
" return items \n "
" except Exception: \n "
" pass \n "
" return [] \n "
" \n "
"def _component_content(component): \n "
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'): \n "
" try: \n "
" value = getattr(component, attr) \n "
" if value is not None: \n "
" return value \n "
" except Exception: \n "
" pass \n "
" return None \n "
" \n "
"def _component_locator(component, component_index, component_path): \n "
" locator = { \n "
" 'backendId': 'component:' + '.'.join(str(item) for item in component_path), \n "
" 'componentIndex': component_index, \n "
" 'componentPath': list(component_path), \n "
" 'componentName': _component_name(component), \n "
" } \n "
" try: \n "
" locator['componentMoniker'] = _moniker_text(component) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" content = getattr(component, 'Content') \n "
" locator['contentMoniker'] = _moniker_text(content) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" template = getattr(component, 'Template') \n "
" locator['templateMoniker'] = _moniker_text(template) \n "
" except Exception: \n "
" pass \n "
" try: \n "
" placement = _matrix_payload(getattr(component, 'Placement')) \n "
" if placement: \n "
" locator['placement'] = placement \n "
" if placement.get('translation'): \n "
" locator['placementTranslation'] = placement.get('translation') \n "
" except Exception: \n "
" pass \n "
" return locator \n "
" \n "
"def _component_entries(root): \n "
" result = [] \n "
" queue = [(root, [])] \n "
" while queue: \n "
" part, path = queue.pop(0) \n "
" if part is None or len(path) > 8: \n "
" continue \n "
" for child_index, component in enumerate(_immediate_components(part)): \n "
" component_path = list(path) + [child_index] \n "
" content = _component_content(component) \n "
" entry = { \n "
" 'component': component, \n "
" 'content': content, \n "
" 'locator': _component_locator(component, len(result), component_path), \n "
" } \n "
" result.append(entry) \n "
" if content is not None: \n "
" queue.append((content, component_path)) \n "
" return result \n "
" \n "
"def _component_body_locator_map(component_entries): \n "
" result = {} \n "
" for entry in component_entries: \n "
" content = entry.get('content') \n "
" if content is None: \n "
" continue \n "
" for component_body_index, body in enumerate(_items(_maybe_call(content, 'Bodies'))): \n "
" locator = dict(entry.get('locator') or {} ) \n "
" locator['componentBodyIndex'] = component_body_index \n "
" key = str(id(body)) \n "
" result.setdefault(key, []).append(locator) \n "
" try: \n "
" master = getattr(body, 'Master') \n "
" result.setdefault(str(id(master)), []).append(locator) \n "
" except Exception: \n "
" pass \n "
" return result \n "
" \n "
"def _body_locators_for_body(component_body_locators, body, body_index): \n "
" result = [{'bodyIndex': body_index}] \n "
" seen = set(['body:' + str(body_index)]) \n "
" for locator in component_body_locators.get(str(id(body)), []) or []: \n "
" item = dict(locator) \n "
" item['bodyIndex'] = body_index \n "
" key = str(item.get('componentIndex')) + ':' + '.'.join(str(value) for value in item.get('componentPath', []) or []) + ':' + str(item.get('componentBodyIndex')) \n "
" if key in seen: \n "
" continue \n "
" seen.add(key) \n "
" result.append(item) \n "
" return result \n "
" \n "
"def _component_locators_for_body(component_body_locators, body): \n "
" result = [] \n "
" seen = set() \n "
" for locator in component_body_locators.get(str(id(body)), []) or []: \n "
" key = str(locator.get('componentIndex')) + ':' + '.'.join(str(value) for value in locator.get('componentPath', []) or []) + ':' + str(locator.get('componentBodyIndex')) \n "
" if key in seen: \n "
" continue \n "
" seen.add(key) \n "
" result.append(dict(locator)) \n "
" return result \n "
" \n "
"def _component_inventory(component_entries): \n "
" result = [] \n "
" for entry in component_entries: \n "
" locator = dict(entry.get('locator') or {} ) \n "
" content = entry.get('content') \n "
" locator['contentBodyCount'] = len(_items(_maybe_call(content, 'Bodies'))) if content is not None else 0 \n "
" locator['childComponentCount'] = len(_immediate_components(content)) if content is not None else 0 \n "
" result.append(locator) \n "
" return result \n "
" \n "
2026-08-19 10:28:09 +08:00
"def _body_faces(body): \n "
" for name in ('Faces', 'GetFaces'): \n "
" items = _items(_maybe_call(body, name)) \n "
" if items: \n "
" return items \n "
" return [] \n "
" \n "
"def _body_edges(body): \n "
" for name in ('Edges', 'GetEdges'): \n "
" items = _items(_maybe_call(body, name)) \n "
" if items: \n "
" return items \n "
" return [] \n "
" \n "
"def _child_parts(part): \n "
" children = [] \n "
" for name in ('Components', 'GetAllComponents'): \n "
" for component in _items(_maybe_call(part, name)): \n "
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'): \n "
" try: \n "
" value = getattr(component, attr) \n "
" if value is not None: \n "
" children.append(value) \n "
" break \n "
" except Exception: \n "
" pass \n "
" return children \n "
" \n "
"def _all_bodies(root): \n "
" if root is None: \n "
" return [] \n "
" for name in ('GetAllBodies', 'Bodies'): \n "
" items = _items(_maybe_call(root, name)) \n "
" if items: \n "
" return items \n "
" bodies = [] \n "
" queue = [root] \n "
" seen = set() \n "
" while queue: \n "
" part = queue.pop(0) \n "
" marker = str(id(part)) \n "
" if marker in seen: \n "
" continue \n "
" seen.add(marker) \n "
" bodies.extend(_items(_maybe_call(part, 'Bodies'))) \n "
" queue.extend(_child_parts(part)) \n "
" return bodies \n "
" \n "
"def _hole_face_markers(bodies): \n "
" standard_holes = globals().get('StandardHoles') \n "
" if standard_holes is None: \n "
" return set() \n "
" faces = [] \n "
" options = None \n "
" options_cls = globals().get('FindStandardHoleOptions') \n "
" if options_cls is not None: \n "
" try: \n "
" options = options_cls() \n "
" except Exception: \n "
" options = None \n "
" identified = [] \n "
" find = getattr(standard_holes, 'Find', None) \n "
" if find is not None: \n "
" for args in ((bodies, options, None), (bodies, options), (options, None), (options,), (None,)): \n "
" try: \n "
" identified = _items(find(*args)) \n "
" if identified: \n "
" break \n "
" except Exception: \n "
" pass \n "
" if identified: \n "
" try: \n "
" faces = _items(standard_holes.GetHoleFaces(identified)) \n "
" except Exception: \n "
" faces = [] \n "
" if not faces: \n "
" for hole in identified: \n "
" try: \n "
" faces.extend(_items(getattr(hole, 'Faces'))) \n "
" except Exception: \n "
" pass \n "
" if faces: \n "
" return set(str(id(face)) for face in faces) \n "
" for args in ((bodies,), ()): \n "
" try: \n "
" faces = _items(standard_holes.GetHoleFaces(*args)) \n "
" if faces: \n "
" break \n "
" except Exception: \n "
" pass \n "
" return set(str(id(face)) for face in faces) \n "
" \n "
"def _available_commands(): \n "
2026-08-19 18:02:47 +08:00
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo', 'ChamferInfo', 'SlotInfo') \n "
2026-08-19 10:28:09 +08:00
" result = [] \n "
" for name in names: \n "
" result.append({'name': name, 'available': globals().get(name) is not None}) \n "
" return result \n "
" \n "
"def main(): \n "
" job = json.load(open(JOB_PATH, 'r')) \n "
" model = job.get('model', {} ) \n "
" outputs = job.get('outputs', {} ) \n "
" raw_path = outputs.get('rawFeatures') \n "
" error_path = outputs.get('error') \n "
" try: \n "
" _open_step(model.get('path')) \n "
" root = _root_part() \n "
" bodies = _all_bodies(root) \n "
2026-08-19 18:02:47 +08:00
" component_entries = _component_entries(root) \n "
" component_body_locators = _component_body_locator_map(component_entries) \n "
2026-08-19 10:28:09 +08:00
" hole_face_markers = _hole_face_markers(bodies) \n "
" objects = [] \n "
" face_adjacency = {} \n "
" edge_geometry_summary = {} \n "
" face_counter = 0 \n "
" edge_counter = 0 \n "
" for body_index, body in enumerate(bodies): \n "
" body_faces = _body_faces(body) \n "
2026-08-19 18:02:47 +08:00
" body_locators = _body_locators_for_body(component_body_locators, body, body_index) \n "
" component_locators = _component_locators_for_body(component_body_locators, body) \n "
2026-08-19 10:28:09 +08:00
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces)) \n "
" for face_index, face in enumerate(body_faces): \n "
" geometry = _geometry_from_face(face) \n "
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face' \n "
2026-08-19 18:02:47 +08:00
" if object_type == 'face' and isinstance(geometry.get('slotInfo'), dict) and (geometry.get('depth') is not None or geometry.get('width') is not None): \n "
" object_type = 'slot' \n "
2026-08-19 10:28:09 +08:00
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {} ).get('radius') is not None: \n "
" object_type = 'round' \n "
2026-08-19 18:02:47 +08:00
" if object_type == 'face' and isinstance(geometry.get('chamferInfo'), dict) and geometry.get('chamferInfo', {} ).get('distance') is not None: \n "
" object_type = 'chamfer' \n "
" topology_hint = {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter, 'bodyLocators': body_locators} \n "
" if component_locators: \n "
" topology_hint['componentLocators'] = component_locators \n "
" if object_type == 'slot' and isinstance(geometry.get('slotInfo'), dict) and geometry.get('slotInfo', {} ).get('depthFaceIsCurrent') is True: \n "
" topology_hint['depthFaceLocators'] = [dict(topology_hint)] \n "
2026-08-19 10:28:09 +08:00
" objects.append({ \n "
" 'backendId': 'body: %d /face: %d ' % (body_index, face_index), \n "
" 'objectType': object_type, \n "
" 'geometry': geometry, \n "
2026-08-19 18:02:47 +08:00
" 'topologyHint': topology_hint, \n "
2026-08-19 10:28:09 +08:00
" 'backendCommandCandidates': _command_candidates(object_type, geometry), \n "
" 'rawLimitations': [], \n "
" }) \n "
" face_counter += 1 \n "
" for edge_index, edge in enumerate(_body_edges(body)): \n "
" geometry = _geometry_from_edge(edge) \n "
2026-08-19 18:02:47 +08:00
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter, 'bodyLocators': body_locators} \n "
" if component_locators: \n "
" edge_topology['componentLocators'] = component_locators \n "
2026-08-19 10:28:09 +08:00
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker)) \n "
" _add_edge_geometry_summary(edge_geometry_summary, geometry) \n "
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry) \n "
" objects.append({ \n "
" 'backendId': 'body: %d /edge: %d ' % (body_index, edge_index), \n "
" 'objectType': 'edge', \n "
" 'geometry': geometry, \n "
" 'topologyHint': edge_topology, \n "
" 'backendCommandCandidates': [], \n "
" 'rawLimitations': [], \n "
" }) \n "
" edge_counter += 1 \n "
" payload = { \n "
" 'schemaVersion': 1, \n "
" 'backend': job.get('backend', {} ), \n "
" 'model': model, \n "
" 'scan': job.get('scan', {} ), \n "
" 'objects': objects, \n "
" 'diagnostics': { \n "
" 'availableCommands': _available_commands(), \n "
" 'faceAdjacency': _face_adjacency_rows(face_adjacency), \n "
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary), \n "
" 'featureInventory': _feature_inventory(objects), \n "
2026-08-19 18:02:47 +08:00
" 'componentInstances': _component_inventory(component_entries), \n "
2026-08-19 10:28:09 +08:00
" }, \n "
2026-08-19 18:02:47 +08:00
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers), 'componentCount': len(component_entries)}, \n "
2026-08-19 10:28:09 +08:00
" } \n "
" _write_json(raw_path, payload) \n "
" except Exception as exc: \n "
" _write_json(error_path, {'ok': False, 'reason': 'probe-exception', 'message': str(exc), 'traceback': traceback.format_exc()}) \n "
" raise \n "
" \n "
"main() \n "
)
__all__ = [
"generate_scdm_probe_script" ,
"prepare_scdm_probe_job" ,
"run_scdm_probe" ,
]