"""Shared, declarative geometry helpers for the ANSYS Mechanical adapters.""" from __future__ import annotations import hashlib import json import math import os import sys from datetime import datetime, timezone from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any ANSYS_REVISION = 242 ANSYS_RELEASE = "2024 R2" EXECUTION_GATE = "ZCBOT_ANSYS_242_VALIDATED" INSTALL_ROOT = Path(os.environ.get("AWP_ROOT242", r"C:\Program Files\ANSYS Inc\v242")) def probe(adapter_version: str) -> int: health = "unavailable" software_version = None details: list[str] = [] try: if sys.platform != "win32": raise RuntimeError("ANSYS adapter requires Windows") if not INSTALL_ROOT.is_dir(): raise RuntimeError("ANSYS Mechanical 2024 R2 installation was not found") from ansys.mechanical.core import App # noqa: F401 package_version = version("ansys-mechanical-core") software_version = ANSYS_RELEASE details.append(f"PyMechanical {package_version} and v242 installation detected") if os.environ.get(EXECUTION_GATE) != "1": details.append("real-license acceptance is pending") else: health = "ready" details.append("real-license acceptance gate is enabled") except ( FileNotFoundError, ImportError, OSError, PackageNotFoundError, RuntimeError, ) as exc: details = [str(exc)] print( json.dumps( { "adapter_version": adapter_version, "software": "ANSYS Mechanical", "software_version": software_version, "health": health, "detail": "; ".join(details), }, ensure_ascii=False, ) ) return 0 def atomic_json(path: Path, value: Any) -> None: temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex()) try: with temporary.open("w", encoding="utf-8", newline="\n") as handle: json.dump(value, handle, ensure_ascii=False, indent=2) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def input_file(job_dir: Path, key: str) -> Path: directory = job_dir / "input" / key files = [ path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".") ] if len(files) != 1: raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}") return files[0] def artifact_manifest(path: Path, artifacts: dict[str, tuple[str, str]]) -> dict[str, Any]: artifact_id, media_type = artifacts[path.name] return { "artifact_id": artifact_id, "filename": path.name, "media_type": media_type, "size_bytes": path.stat().st_size, "sha256": file_sha256(path), } def validate_artifact(path: Path) -> None: if not path.is_file() or path.stat().st_size == 0: raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}") if path.suffix.lower() == ".png" and path.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n": raise RuntimeError(f"PNG_EXPORT_INVALID:{path.name}") if path.suffix.lower() == ".mechdat" and path.stat().st_size < 64: raise RuntimeError("MECHDAT_EXPORT_INVALID") def terminal( record: dict[str, Any], status: str, artifacts: list[dict[str, Any]], error: dict[str, str], ) -> dict[str, Any]: return { "job_id": record.get("job_id", ""), "lease_id": record.get("lease_id", ""), "request_digest": record.get("request_digest", ""), "status": status, "error": error, "artifact_manifest": artifacts, "terminal_at": datetime.now(timezone.utc).isoformat(), } def import_geometry(app: Any, api: dict[str, Any], geometry: Path) -> Any: model = app.Model geometry_import = model.GeometryImportGroup.AddGeometryImport() preferences = api["Ansys"].ACT.Mechanical.Utilities.GeometryImportPreferences() preferences.ProcessNamedSelections = True preferences.NamedSelectionKey = "" preferences.ProcessMaterialProperties = False preferences.ProcessCoordinateSystems = False geometry_import.Import( str(geometry), api["Ansys"].Mechanical.DataModel.Enums.GeometryImportPreference.Format.Automatic, preferences, ) return model def _xyz(value: Any) -> list[float]: try: result = [float(value[index]) for index in range(3)] except (IndexError, KeyError, TypeError): names = ("X", "Y", "Z") if hasattr(value, "X") else ("x", "y", "z") result = [float(getattr(value, name)) for name in names] if not all(math.isfinite(item) for item in result): raise ValueError("GEOMETRY_COORDINATE_NOT_FINITE") return result def _normal(face: Any, centroid: list[float]) -> list[float] | None: try: uv = face.ParamAtPoint(centroid) raw = face.NormalAtParam(float(uv[0]), float(uv[1])) normal = _xyz(raw) magnitude = math.sqrt(sum(item * item for item in normal)) if magnitude <= 1e-12: return None return [item / magnitude for item in normal] except Exception: # noqa: BLE001 - vendor faces can reject a centroid parameterization return None def _surface_type(value: Any) -> str: text = str(value or "unknown").split(".")[-1] if text.casefold().startswith("geosurface"): text = text[len("GeoSurface"):] return text.casefold() or "unknown" def _entity_id(value: Any) -> int: return int(value.Id) def _optional_number(value: Any, name: str) -> float | None: try: number = float(getattr(value, name)) except (AttributeError, TypeError, ValueError): return None return number if math.isfinite(number) else None def geometry_inventory(app: Any) -> tuple[dict[str, Any], dict[int, Any]]: geo_data = app.ExtAPI.DataModel.GeoData unit = str(geo_data.Unit) assemblies_out: list[dict[str, Any]] = [] bodies_out: list[dict[str, Any]] = [] faces_out: list[dict[str, Any]] = [] face_entities: dict[int, Any] = {} points: list[list[float]] = [] for assembly_index, assembly in enumerate(geo_data.Assemblies): assembly_name = str(getattr(assembly, "Name", f"assembly-{assembly_index + 1}")) parts = getattr(assembly, "Parts", None) or getattr(assembly, "AllParts", ()) part_count = 0 for part_index, part in enumerate(parts): part_count += 1 part_name = str(getattr(part, "Name", f"part-{part_index + 1}")) for body_index, body in enumerate(part.Bodies): body_id = _entity_id(body) body_name = str(getattr(body, "Name", f"body-{body_index + 1}")) body_face_ids: list[int] = [] for vertex in getattr(body, "Vertices", ()): recorded = False for attribute in ("Point", "Coordinates", "XYZ"): if hasattr(vertex, attribute): try: points.append(_xyz(getattr(vertex, attribute))) recorded = True except (AttributeError, TypeError, ValueError): pass break if not recorded: try: points.append(_xyz(vertex)) except (AttributeError, TypeError, ValueError): pass for face in body.Faces: face_id = _entity_id(face) centroid = _xyz(face.Centroid) points.append(centroid) body_face_ids.append(face_id) face_entities[face_id] = face faces_out.append( { "id": face_id, "assembly": assembly_name, "part": part_name, "body_id": body_id, "body": body_name, "surface_type": _surface_type(getattr(face, "SurfaceType", None)), "centroid": centroid, "normal": _normal(face, centroid), "area": _optional_number(face, "Area"), } ) bodies_out.append( { "id": body_id, "assembly": assembly_name, "part": part_name, "name": body_name, "volume": _optional_number(body, "Volume"), "face_ids": body_face_ids, } ) assemblies_out.append({"name": assembly_name, "part_count": part_count}) if not bodies_out: raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES") if not faces_out: raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_FACES") named_selections: list[dict[str, Any]] = [] container = app.Model.NamedSelections for item in () if container is None else (container.Children or ()): location = getattr(item, "Location", None) ids = [] if location is None else [int(value) for value in (location.Ids or ())] named_selections.append({"name": str(item.Name), "entity_ids": ids}) bounds = { "min": [min(point[index] for point in points) for index in range(3)], "max": [max(point[index] for point in points) for index in range(3)], } return ( { "geometry_unit": unit, "counts": { "assemblies": len(assemblies_out), "bodies": len(bodies_out), "faces": len(faces_out), "named_selections": len(named_selections), }, "bounding_box": bounds, "assemblies": assemblies_out, "bodies": bodies_out, "faces": faces_out, "named_selections": named_selections, }, face_entities, ) def export_geometry_image( app: Any, api: dict[str, Any], path: Path, selection: Any | None = None, ) -> None: if selection is not None: app.ExtAPI.SelectionManager.NewSelection(selection) app.Graphics.Camera.SetFit() settings = api["Ansys"].Mechanical.Graphics.GraphicsImageExportSettings() settings.Resolution = api["GraphicsResolutionType"].EnhancedResolution settings.Width = 1600 settings.Height = 1200 app.Graphics.ExportImage(str(path), api["GraphicsImageExportFormat"].PNG, settings) validate_artifact(path) if selection is not None: app.ExtAPI.SelectionManager.ClearSelection() def _convert_length(api: dict[str, Any], value: float, source_unit: str, target_unit: str) -> float: if source_unit.casefold() == target_unit.casefold(): return float(value) quantity = api["Quantity"](float(value), source_unit) converted = quantity.ConvertUnit(target_unit) return float((converted if converted is not None else quantity).Value) def _expected_count(scope: dict[str, Any], face_ids: list[int]) -> None: expected = scope.get("expected_count") if expected is not None and len(face_ids) != expected: raise RuntimeError( f"GEOMETRY_SCOPE_COUNT_MISMATCH:expected={expected},actual={len(face_ids)}" ) def _axis_index(axis: str) -> int: return {"x": 0, "y": 1, "z": 2}[axis] def _planar_faces(inventory: dict[str, Any]) -> list[dict[str, Any]]: return [ face for face in inventory["faces"] if "plane" in face["surface_type"] and face["normal"] is not None ] def _scope_face_ids( api: dict[str, Any], inventory: dict[str, Any], scope: dict[str, Any], request_length_unit: str, ) -> list[int]: planar = _planar_faces(inventory) if not planar: raise RuntimeError("GEOMETRY_SCOPE_NO_PLANAR_FACES") geo_unit = inventory["geometry_unit"] angle = math.radians(float(scope.get("angle_tolerance_degrees", 5.0))) cosine = math.cos(angle) if scope["type"] == "extreme_face": axis = _axis_index(scope["axis"]) aligned = [face for face in planar if abs(face["normal"][axis]) >= cosine] if not aligned: raise RuntimeError("GEOMETRY_SCOPE_NO_AXIS_ALIGNED_FACES") coordinates = [face["centroid"][axis] for face in aligned] extreme = min(coordinates) if scope["side"] == "min" else max(coordinates) span = inventory["bounding_box"]["max"][axis] - inventory["bounding_box"]["min"][axis] tolerance = scope.get("tolerance") tolerance = ( max(abs(span) * 1e-7, 1e-12) if tolerance is None else _convert_length(api, tolerance, request_length_unit, geo_unit) ) ids = [ face["id"] for face in aligned if abs(face["centroid"][axis] - extreme) <= tolerance ] elif scope["type"] == "planar_faces": raw_normal = [float(item) for item in scope["normal"]] magnitude = math.sqrt(sum(item * item for item in raw_normal)) if magnitude <= 1e-12: raise ValueError("PLANE_NORMAL_MUST_BE_NONZERO") normal = [item / magnitude for item in raw_normal] offset = _convert_length(api, scope["offset"], request_length_unit, geo_unit) tolerance = _convert_length(api, scope["tolerance"], request_length_unit, geo_unit) ids = [] for face in planar: alignment = abs(sum(a * b for a, b in zip(normal, face["normal"], strict=True))) position = sum(a * b for a, b in zip(normal, face["centroid"], strict=True)) if alignment >= cosine and abs(position - offset) <= tolerance: ids.append(face["id"]) else: raise ValueError("GEOMETRY_SCOPE_TYPE_UNSUPPORTED") ids = sorted(set(ids)) if not ids: raise RuntimeError(f"GEOMETRY_SCOPE_EMPTY:{scope['type']}") _expected_count(scope, ids) return ids def resolve_scope( app: Any, api: dict[str, Any], inventory: dict[str, Any], face_entities: dict[int, Any], scope: dict[str, Any], request_length_unit: str, generated_name: str, ) -> dict[str, Any]: if scope["type"] == "named_selection": matches = [ item for item in (() if app.Model.NamedSelections is None else app.Model.NamedSelections.Children) if str(item.Name).casefold() == scope["name"].casefold() ] if len(matches) != 1: code = "NOT_FOUND" if not matches else "AMBIGUOUS" raise RuntimeError(f"NAMED_SELECTION_{code}:{scope['name']}") location = matches[0].Location ids = [int(value) for value in (location.Ids or ())] if not ids: raise RuntimeError(f"NAMED_SELECTION_EMPTY:{scope['name']}") _expected_count(scope, ids) return { "name": str(matches[0].Name), "source": "imported_named_selection", "scope": scope, "entity_ids": ids, "location": matches[0], } face_ids = _scope_face_ids(api, inventory, scope, request_length_unit) if any(face_id not in face_entities for face_id in face_ids): raise RuntimeError("GEOMETRY_SCOPE_ENTITY_NOT_FOUND") conflicts = [ item for item in (() if app.Model.NamedSelections is None else app.Model.NamedSelections.Children) if str(item.Name).casefold() == generated_name.casefold() ] if conflicts: raise RuntimeError(f"GENERATED_SELECTION_NAME_CONFLICT:{generated_name}") location = app.ExtAPI.SelectionManager.CreateSelectionInfo( api["SelectionTypeEnum"].GeometryEntities ) location.Ids = face_ids named_selection = app.Model.AddNamedSelection() named_selection.Name = generated_name named_selection.Location = location return { "name": generated_name, "source": "geometry_query", "scope": scope, "entity_ids": face_ids, "location": named_selection, } def selected_face_records(inventory: dict[str, Any], ids: list[int]) -> list[dict[str, Any]]: selected = set(ids) return [face for face in inventory["faces"] if face["id"] in selected]