from __future__ import annotations import json import os import subprocess from pathlib import Path from typing import Iterable ASITUS_RECOGNIZE_HOLES_ENV = "STEP_EDITOR_ASITUS_RECOGNIZE_HOLES" ASITUS_DISABLE_ENV = "STEP_EDITOR_DISABLE_ASITUS" ASITUS_TIMEOUT_ENV = "STEP_EDITOR_ASITUS_TIMEOUT" def _project_root(project_root: Path | None = None) -> Path: return project_root or Path(__file__).resolve().parent.parent def default_asitus_recognize_holes_path(project_root: Path | None = None) -> Path | None: env_path = os.environ.get(ASITUS_RECOGNIZE_HOLES_ENV, "").strip() if env_path: path = Path(env_path).expanduser() return path if path.is_file() else None if os.environ.get(ASITUS_DISABLE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}: return None root = _project_root(project_root) candidates = ( root / "third_party" / "asitus_probe_tools_build" / "Release" / "recognize_holes.exe", root / "third_party" / "asitus_probe_tools_build" / "RelWithDebInfo" / "recognize_holes.exe", root / "third_party" / "asitus_probe_tools_build" / "Debug" / "recognize_holes.exe", root / "third_party" / "asitus_probe_build" / "Release" / "recognize_holes.exe", root / "third_party" / "asitus_probe_build" / "RelWithDebInfo" / "recognize_holes.exe", root / "third_party" / "asitus_probe_build" / "Debug" / "recognize_holes.exe", ) for candidate in candidates: if candidate.is_file(): return candidate return None def asitus_runtime_path_entries(project_root: Path | None = None) -> list[Path]: root = _project_root(project_root) third_party = root / "third_party" / "3rdparty" candidates = ( root / "third_party" / "AnalysisSitus_build_algo_occt77" / "win64" / "vc14" / "bin", third_party / "OCCT" / "win64" / "vc14" / "bin", third_party / "freeimage-3.17.0-vc14-64" / "bin", third_party / "freetype-2.5.5-vc14-64" / "bin", third_party / "tbb_2021.5-vc14-64" / "bin", third_party / "tcltk-86-64" / "bin", third_party / "ffmpeg-3.3.4-64" / "bin", third_party / "openvr-1.14.15-64" / "bin" / "win64", third_party / "3rdparty-vc14-64" / "freeimage-3.18.0-x64" / "bin", third_party / "3rdparty-vc14-64" / "freetype-2.13.3-x64" / "bin", third_party / "3rdparty-vc14-64" / "tbb-2021.13.0-x64" / "bin", third_party / "3rdparty-vc14-64" / "tcltk-8.6.15-x64" / "bin", third_party / "3rdparty-vc14-64" / "ffmpeg-3.3.4-64" / "bin", third_party / "3rdparty-vc14-64" / "openvr-1.14.15-64" / "bin" / "win64", ) return [path for path in candidates if path.is_dir()] def parse_asitus_hole_groups(payload: object) -> list[tuple[int, ...]]: payload = _json_payload(payload) if not isinstance(payload, dict): return [] groups: list[tuple[int, ...]] = [] holes = payload.get("holes") if isinstance(holes, list): for item in holes: if not isinstance(item, dict): continue group = _int_tuple(item.get("faceIds")) if group: groups.append(group) if groups: return _dedupe_groups(groups) flat_ids = _int_tuple(payload.get("holeFaceIds")) return [flat_ids] if flat_ids else [] def parse_asitus_probe_payload(payload: object) -> dict[str, object]: data = _json_payload(payload) if not isinstance(data, dict): return { "groups": (), "faces": (), "adjacency": (), "geometric_relations": (), "surface_summary": {}, "angle_summary": {}, "geometric_relation_summary": {}, "geometric_relation_mode": "", } faces: list[dict[str, object]] = [] raw_faces = data.get("faces") if isinstance(raw_faces, list): for item in raw_faces: if not isinstance(item, dict): continue face_id = _int_or_none(item.get("id")) if face_id is None: continue faces.append( { "id": face_id, "surface": str(item.get("surface") or ""), "neighbor_ids": _int_tuple(item.get("neighbors")), } ) adjacency: list[dict[str, object]] = [] raw_adjacency = data.get("adjacency") if isinstance(raw_adjacency, list): for item in raw_adjacency: if not isinstance(item, dict): continue face_ids = _int_tuple(item.get("faceIds")) if len(face_ids) != 2: continue adjacency.append( { "face_ids": face_ids, "angle_type": str(item.get("angleType") or item.get("type") or ""), "angle_rad": _float_or_none(item.get("angleRad")), "edge_ids": _int_tuple(item.get("edgeIds")), } ) geometric_relations: list[dict[str, object]] = [] raw_geometric_relations = data.get("geometricRelations") if isinstance(raw_geometric_relations, list): for item in raw_geometric_relations: if not isinstance(item, dict): continue face_ids = _int_tuple(item.get("faceIds")) if len(face_ids) != 2: continue geometric_relations.append( { "face_ids": face_ids, "relation_type": str(item.get("type") or item.get("relationType") or ""), "residual": _float_or_none(item.get("residual")), "source": str(item.get("source") or "analysis-situs-probe"), } ) return { "groups": tuple(parse_asitus_hole_groups(data)), "valid_brep": data.get("validBreP"), "face_count": _int_or_none(data.get("faceCount")), "aag_node_count": _int_or_none(data.get("aagNodeCount")), "faces": tuple(faces), "adjacency": tuple(adjacency), "geometric_relations": tuple(geometric_relations), "surface_summary": _str_int_dict(data.get("surfaceSummary")), "angle_summary": _str_int_dict(data.get("angleSummary")), "geometric_relation_summary": _str_int_dict(data.get("geometricRelationSummary")), "geometric_relation_mode": str(data.get("geometricRelationMode") or ""), } def _json_payload(payload: object) -> object: if not isinstance(payload, str): return payload text = payload.strip() json_start = text.find("{") if json_start > 0: text = text[json_start:] return json.loads(text) def _int_or_none(value: object) -> int | None: try: return int(value) except (TypeError, ValueError): return None def _float_or_none(value: object) -> float | None: try: return float(value) except (TypeError, ValueError): return None def _str_int_dict(value: object) -> dict[str, int]: if not isinstance(value, dict): return {} result: dict[str, int] = {} for key, item in value.items(): try: result[str(key)] = int(item) except (TypeError, ValueError): continue return result def _int_tuple(values: object) -> tuple[int, ...]: if values is None: return () if isinstance(values, (str, bytes)): return () try: items = list(values) # type: ignore[arg-type] except TypeError: return () result: list[int] = [] for item in items: try: result.append(int(item)) except (TypeError, ValueError): continue return tuple(sorted(set(result))) def _dedupe_groups(groups: Iterable[tuple[int, ...]]) -> list[tuple[int, ...]]: result: list[tuple[int, ...]] = [] seen: set[tuple[int, ...]] = set() for group in groups: if not group or group in seen: continue seen.add(group) result.append(group) return result def run_asitus_hole_recognition( step_path: str | Path, *, cli_path: str | Path | None = None, project_root: Path | None = None, timeout_seconds: float | None = None, ) -> dict[str, object]: source = Path(step_path).expanduser() if not source.is_file(): return {"ok": False, "reason": "missing-step", "groups": (), "message": f"STEP file not found: {source}"} cli = Path(cli_path).expanduser() if cli_path else default_asitus_recognize_holes_path(project_root) if cli is None or not cli.is_file(): return {"ok": False, "reason": "missing-cli", "groups": (), "message": "Analysis Situs recognize_holes CLI is not available."} if timeout_seconds is None: try: timeout_seconds = float(os.environ.get(ASITUS_TIMEOUT_ENV, "") or 3.0) except ValueError: timeout_seconds = 3.0 env = os.environ.copy() path_entries = [str(path) for path in asitus_runtime_path_entries(project_root)] env["PATH"] = os.pathsep.join([*path_entries, env.get("PATH", "")]) creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) try: completed = subprocess.run( [str(cli), str(source)], cwd=str(_project_root(project_root)), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=max(float(timeout_seconds), 0.1), creationflags=creationflags, check=False, ) except subprocess.TimeoutExpired: return {"ok": False, "reason": "timeout", "groups": (), "message": "Analysis Situs hole recognition timed out."} except OSError as exc: return {"ok": False, "reason": "launch-failed", "groups": (), "message": str(exc)} if completed.returncode != 0: message = (completed.stderr or completed.stdout or "").strip() return { "ok": False, "reason": "recognizer-failed", "returncode": completed.returncode, "groups": (), "message": message, } try: parsed = parse_asitus_probe_payload(completed.stdout) except (json.JSONDecodeError, TypeError, ValueError) as exc: return {"ok": False, "reason": "bad-json", "groups": (), "message": str(exc), "stdout": completed.stdout} groups = tuple(parsed.get("groups", ())) return { "ok": True, "reason": "ok", "groups": groups, "hole_count": len(groups), "valid_brep": parsed.get("valid_brep"), "face_count": parsed.get("face_count"), "aag_node_count": parsed.get("aag_node_count"), "faces": tuple(parsed.get("faces", ())), "adjacency": tuple(parsed.get("adjacency", ())), "geometric_relations": tuple(parsed.get("geometric_relations", ())), "surface_summary": dict(parsed.get("surface_summary", {}) or {}), "angle_summary": dict(parsed.get("angle_summary", {}) or {}), "geometric_relation_summary": dict(parsed.get("geometric_relation_summary", {}) or {}), "geometric_relation_mode": str(parsed.get("geometric_relation_mode") or ""), "cli": str(cli), "message": f"Analysis Situs recognized {len(groups)} hole groups.", }