packages feed

moonlight-triangulation-1.4.0.2: ffi/bindings/python/src/moonlight_triangulation/_binding.py

from __future__ import annotations

import ctypes
import os
import weakref
from collections.abc import Callable, Iterable, Sequence
from fractions import Fraction
from pathlib import Path
from threading import get_ident
from typing import Self

from ._native_generated import (
    ABI_VERSION,
    ML_OBSTRUCTION_BUFFER_TOO_SMALL,
    ML_STATUS_BUFFER_TOO_SMALL,
    ML_STATUS_OK,
    _NativeApi,
    _NativeMinkowskiReceipt,
    _Obstruction,
)
from .errors import MoonlightError
from .values import (
    MinkowskiOperation,
    MinkowskiReceipt,
    Point,
    PolygonComponent,
    RegionLocation,
    RegionValuations,
    Triangle,
)

_BinaryNativeOperation = Callable[[ctypes.c_void_p, ctypes.c_void_p, object, object], int]
_MorphologyNativeOperation = Callable[[ctypes.c_void_p, ctypes.c_void_p, object, object, object], int]

class Moonlight:
    def __init__(self, library_path: str | os.PathLike[str] | None = None) -> None:
        configured_path = (
            os.environ.get("MOONLIGHT_TRIANGULATION_LIBRARY")
            if library_path is None
            else library_path
        )
        if configured_path is None:
            raise ValueError("set MOONLIGHT_TRIANGULATION_LIBRARY or pass library_path")
        if os.fspath(configured_path) == "":
            raise ValueError("Moonlight native library path must not be empty")
        self._native = _NativeApi(Path(configured_path))
        initialization_status = int(self._native.library.ml_runtime_initialize())
        if initialization_status != ML_STATUS_OK:
            raise RuntimeError(
                f"Moonlight runtime initialization failed with status {initialization_status}"
            )
        observed_version = int(self._native.library.ml_abi_version())
        if observed_version != ABI_VERSION:
            raise RuntimeError(f"unsupported Moonlight ABI version {observed_version}")

    def delaunay(self, points: Sequence[Point]) -> Mesh:
        coordinates, pointer = _coordinate_buffer(points)
        handle = _produce_handle(
            lambda output, obstruction: self._native.library.ml_delaunay_f64(
                pointer, len(coordinates) // 2, output, obstruction
            ),
        )
        return Mesh(self._native, handle)

    def region(self, components: Sequence[PolygonComponent]) -> Region:
        loops, loop_counts, component_counts = _component_layout(components)
        coordinates, coordinate_pointer = _coordinate_buffer(
            point for loop in loops for point in loop
        )
        loop_buffer = _size_buffer(loop_counts)
        component_buffer = _size_buffer(component_counts)
        handle = _produce_handle(
            lambda output, obstruction: self._native.library.ml_region_create_f64(
                coordinate_pointer,
                len(coordinates) // 2,
                loop_buffer,
                len(loop_counts),
                component_buffer,
                len(component_counts),
                output,
                obstruction,
            ),
        )
        return Region(self._native, handle)

    def structuring_element(self, points: Sequence[Point]) -> StructuringElement:
        coordinates, pointer = _coordinate_buffer(points)
        handle = _produce_handle(
            lambda output, obstruction: self._native.library.ml_structuring_element_create_f64(
                pointer, len(coordinates) // 2, output, obstruction
            ),
        )
        return StructuringElement(self._native, handle)


class _OwnedHandle:
    __slots__ = ("_native", "_handle", "_finalizer", "_kind", "_owner_thread", "__weakref__")

    def __init__(self, native: _NativeApi, handle: ctypes.c_void_p, free: Callable[[ctypes.c_void_p], None], kind: str) -> None:
        self._native = native
        self._handle = handle
        self._kind = kind
        self._owner_thread = get_ident()
        self._finalizer = weakref.finalize(self, free, handle)

    def dispose(self) -> None:
        self._require_owner_thread()
        self._finalizer()

    def __enter__(self) -> Self:
        return self

    def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
        self.dispose()

    def _live_handle(self) -> ctypes.c_void_p:
        self._require_owner_thread()
        if not self._finalizer.alive:
            raise RuntimeError(f"{self._kind} is disposed")
        return self._handle

    def _require_owner_thread(self) -> None:
        if get_ident() != self._owner_thread:
            raise RuntimeError(f"{self._kind} belongs to another thread")

    def _require_same_runtime(self, other: _OwnedHandle) -> None:
        if self._native is not other._native:
            raise ValueError("both values must belong to the same Moonlight runtime")


class Mesh(_OwnedHandle):
    __slots__ = ()

    def __init__(self, native: _NativeApi, handle: ctypes.c_void_p) -> None:
        super().__init__(native, handle, native.library.ml_mesh_free, "mesh")

    @property
    def vertex_count(self) -> int:
        return self._count(self._native.library.ml_mesh_vertex_count)

    @property
    def triangle_count(self) -> int:
        return self._count(self._native.library.ml_mesh_triangle_count)

    @property
    def vertices(self) -> tuple[Point, ...]:
        count = self.vertex_count
        output = (ctypes.c_double * (count * 2))()
        written = ctypes.c_size_t()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_mesh_copy_vertices_f64(
                self._live_handle(), output, count, ctypes.byref(written), ctypes.byref(obstruction)
            )
        )
        _check_status(status, obstruction)
        return tuple((float(output[index * 2]), float(output[index * 2 + 1])) for index in range(written.value))

    @property
    def triangles(self) -> tuple[Triangle, ...]:
        count = self.triangle_count
        output = (ctypes.c_uint32 * (count * 3))()
        written = ctypes.c_size_t()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_mesh_copy_triangles_u32(
                self._live_handle(), output, count, ctypes.byref(written), ctypes.byref(obstruction)
            )
        )
        _check_status(status, obstruction)
        return tuple(
            (int(output[index * 3]), int(output[index * 3 + 1]), int(output[index * 3 + 2]))
            for index in range(written.value)
        )

    def insert_many(self, points: Sequence[Point]) -> Mesh:
        coordinates, pointer = _coordinate_buffer(points)
        handle = _produce_handle(
            lambda output, obstruction: self._native.library.ml_mesh_insert_many_f64(
                self._live_handle(), pointer, len(coordinates) // 2, output, obstruction
            ),
        )
        return Mesh(self._native, handle)

    def site_union(self, other: Mesh) -> Mesh:
        return self._binary(other, self._native.library.ml_mesh_site_union)

    def site_intersection(self, other: Mesh) -> Mesh:
        return self._binary(other, self._native.library.ml_mesh_site_intersection)

    def site_difference(self, other: Mesh) -> Mesh:
        return self._binary(other, self._native.library.ml_mesh_site_difference)

    def site_symmetric_difference(self, other: Mesh) -> Mesh:
        return self._binary(other, self._native.library.ml_mesh_site_symmetric_difference)

    def _binary(self, other: Mesh, operation: _BinaryNativeOperation) -> Mesh:
        self._require_same_runtime(other)
        handle = _produce_handle(
            lambda output, obstruction: operation(
                self._live_handle(), other._live_handle(), output, obstruction
            ),
        )
        return Mesh(self._native, handle)

    def _count(self, operation: Callable[[ctypes.c_void_p, object, object], int]) -> int:
        output = ctypes.c_size_t()
        obstruction = _Obstruction()
        status = int(operation(self._live_handle(), ctypes.byref(output), ctypes.byref(obstruction)))
        _check_status(status, obstruction)
        return int(output.value)


class Region(_OwnedHandle):
    __slots__ = ()

    def __init__(self, native: _NativeApi, handle: ctypes.c_void_p) -> None:
        super().__init__(native, handle, native.library.ml_region_free, "region")

    @property
    def components(self) -> tuple[PolygonComponent, ...]:
        component_count, loop_count, point_count = self._counts()
        coordinates = (ctypes.c_double * (point_count * 2))()
        loop_offsets = (ctypes.c_size_t * (loop_count + 1))()
        component_offsets = (ctypes.c_size_t * (component_count + 1))()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_region_copy_f64(
                self._live_handle(),
                coordinates,
                point_count,
                loop_offsets,
                loop_count + 1,
                component_offsets,
                component_count + 1,
                ctypes.byref(obstruction),
            )
        )
        _check_status(status, obstruction)
        points = tuple((float(coordinates[index * 2]), float(coordinates[index * 2 + 1])) for index in range(point_count))
        loops = tuple(
            points[int(loop_offsets[index]) : int(loop_offsets[index + 1])]
            for index in range(loop_count)
        )
        component_ranges = tuple(
            (int(component_offsets[index]), int(component_offsets[index + 1]))
            for index in range(component_count)
        )
        if any(start >= end for start, end in component_ranges):
            raise RuntimeError("Moonlight returned a component without an outer loop")
        return tuple(
            PolygonComponent(loops[start], loops[start + 1 : end])
            for start, end in component_ranges
        )

    @property
    def valuations(self) -> RegionValuations:
        return self._measure_with_capacity(128)

    def locate(self, point: Point) -> RegionLocation:
        output = ctypes.c_uint32()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_region_locate_point_f64(
                self._live_handle(), point[0], point[1], ctypes.byref(output), ctypes.byref(obstruction)
            )
        )
        _check_status(status, obstruction)
        return RegionLocation(output.value)

    def union(self, other: Region) -> Region:
        return self._binary(other, self._native.library.ml_region_union)

    def intersection(self, other: Region) -> Region:
        return self._binary(other, self._native.library.ml_region_intersection)

    def difference(self, other: Region) -> Region:
        return self._binary(other, self._native.library.ml_region_difference)

    def symmetric_difference(self, other: Region) -> Region:
        return self._binary(other, self._native.library.ml_region_symmetric_difference)

    def minkowski_sum(self, other: Region) -> tuple[Region, MinkowskiReceipt]:
        self._require_same_runtime(other)
        return self._morph(
            lambda output, receipt, obstruction: self._native.library.ml_region_minkowski_sum(
                self._live_handle(), other._live_handle(), output, receipt, obstruction
            )
        )

    def offset(self, element: StructuringElement) -> tuple[Region, MinkowskiReceipt]:
        return self._with_element(element, self._native.library.ml_region_offset)

    def inset(self, element: StructuringElement) -> tuple[Region, MinkowskiReceipt]:
        return self._with_element(element, self._native.library.ml_region_inset)

    def open(self, element: StructuringElement) -> tuple[Region, MinkowskiReceipt]:
        return self._with_element(element, self._native.library.ml_region_open)

    def close(self, element: StructuringElement) -> tuple[Region, MinkowskiReceipt]:
        return self._with_element(element, self._native.library.ml_region_close)

    def _binary(self, other: Region, operation: _BinaryNativeOperation) -> Region:
        self._require_same_runtime(other)
        handle = _produce_handle(
            lambda output, obstruction: operation(
                self._live_handle(), other._live_handle(), output, obstruction
            ),
        )
        return Region(self._native, handle)

    def _with_element(
        self, element: StructuringElement, operation: _MorphologyNativeOperation
    ) -> tuple[Region, MinkowskiReceipt]:
        self._require_same_runtime(element)
        return self._morph(
            lambda output, receipt, obstruction: operation(
                element._live_handle(), self._live_handle(), output, receipt, obstruction
            )
        )

    def _morph(
        self,
        operation: Callable[[object, object, object], int],
    ) -> tuple[Region, MinkowskiReceipt]:
        handle, native_receipt = _produce_morphology(operation)
        return Region(self._native, handle), _receipt(native_receipt)

    def _counts(self) -> tuple[int, int, int]:
        component_count = ctypes.c_size_t()
        loop_count = ctypes.c_size_t()
        point_count = ctypes.c_size_t()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_region_counts(
                self._live_handle(),
                ctypes.byref(component_count),
                ctypes.byref(loop_count),
                ctypes.byref(point_count),
                ctypes.byref(obstruction),
            )
        )
        _check_status(status, obstruction)
        return int(component_count.value), int(loop_count.value), int(point_count.value)

    def _measure_with_capacity(self, capacity: int) -> RegionValuations:
        euler = ctypes.c_int64()
        area = ctypes.create_string_buffer(capacity)
        area_bytes = ctypes.c_size_t()
        lower = ctypes.c_double()
        upper = ctypes.c_double()
        obstruction = _Obstruction()
        status = int(
            self._native.library.ml_region_measure(
                self._live_handle(),
                ctypes.byref(euler),
                area,
                capacity,
                ctypes.byref(area_bytes),
                ctypes.byref(lower),
                ctypes.byref(upper),
                ctypes.byref(obstruction),
            )
        )
        if (
            status == ML_STATUS_BUFFER_TOO_SMALL
            and obstruction.code == ML_OBSTRUCTION_BUFFER_TOO_SMALL
        ):
            return self._measure_with_capacity(int(area_bytes.value) + 1)
        _check_status(status, obstruction)
        numerator, separator, denominator = area.value.decode("ascii").partition("/")
        if separator != "/":
            raise RuntimeError("Moonlight returned a malformed exact-area ratio")
        return RegionValuations(int(euler.value), Fraction(int(numerator), int(denominator)), (lower.value, upper.value))


class StructuringElement(_OwnedHandle):
    __slots__ = ()

    def __init__(self, native: _NativeApi, handle: ctypes.c_void_p) -> None:
        super().__init__(native, handle, native.library.ml_structuring_element_free, "structuring element")


def _component_layout(
    components: Sequence[PolygonComponent],
) -> tuple[tuple[Sequence[Point], ...], tuple[int, ...], tuple[int, ...]]:
    loops = tuple(
        loop
        for component in components
        for loop in (component.outer, *component.holes)
    )
    loop_counts = tuple(len(loop) for loop in loops)
    component_counts = tuple(len(component.holes) + 1 for component in components)
    return loops, loop_counts, component_counts


def _coordinate_buffer(points: Iterable[Point]) -> tuple[tuple[float, ...], object]:
    coordinates = tuple(component for x, y in points for component in (x, y))
    pointer = None if not coordinates else (ctypes.c_double * len(coordinates))(*coordinates)
    return coordinates, pointer


def _size_buffer(values: Sequence[int]) -> object:
    return (ctypes.c_size_t * len(values))(*values)


def _produce_handle(
    operation: Callable[[object, object], int],
) -> ctypes.c_void_p:
    output = ctypes.c_void_p()
    obstruction = _Obstruction()
    status = int(operation(ctypes.byref(output), ctypes.byref(obstruction)))
    _check_status(status, obstruction)
    return _required_handle(output)


def _produce_morphology(
    operation: Callable[[object, object, object], int],
) -> tuple[ctypes.c_void_p, _NativeMinkowskiReceipt]:
    output = ctypes.c_void_p()
    receipt = _NativeMinkowskiReceipt()
    obstruction = _Obstruction()
    status = int(operation(ctypes.byref(output), ctypes.byref(receipt), ctypes.byref(obstruction)))
    _check_status(status, obstruction)
    return _required_handle(output), receipt


def _receipt(native: _NativeMinkowskiReceipt) -> MinkowskiReceipt:
    return MinkowskiReceipt(
        MinkowskiOperation(native.operation),
        int(native.input_components),
        int(native.convex_pieces),
        int(native.generated_pieces),
        int(native.generated_convolution_edges),
        int(native.overlay_passes),
        int(native.exact_crossings),
        int(native.output_cells),
        int(native.exact_coordinate_bit_growth),
    )


def _check_status(status: int, obstruction: _Obstruction) -> None:
    if status != ML_STATUS_OK:
        raise MoonlightError(status, obstruction)


def _required_handle(handle: ctypes.c_void_p) -> ctypes.c_void_p:
    if not handle.value:
        raise RuntimeError("Moonlight returned success without a handle")
    return handle