zcbot/windows-node/adapters/ansys_geometry.py

450 lines
16 KiB
Python

"""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]