!> Spheres and ellipsoids: a textured celestial body, as OpenFrames' Sphere.
!>
!> The body is centered on its frame's origin (or at [[set_sphere_position]],
!> turned by [[set_sphere_attitude]], e.g. to orient a texture). Its shape is a
!> sphere of [[radius]], or an ellipsoid with semi-axes radius * [[sphere_scale]];
!> the ellipsoid is built directly (exact normals, geodetic texture mapping; see
!> vkf_geometry), where OpenFrames scales a sphere. The texture is an
!> equirectangular image (longitude -180 to 180, north at the top). The color is
!> used when there is no texture.
!>
!> Lighting: the body is lit by the scene's lights (a frame's light source,
!> e.g. the Sun, or the window's light), with a material ([[set_material]];
!> OpenFrames' default: no specular reflection). The night texture shows where
!> the surface is lit weakly (the night side: city lights), as OpenFrames'
!> setNightTextureMap: weighted by 1 - the lit color (the material's ambient
!> and diffuse colors in the lights' colors and intensities, and its
!> emission), per channel and per pixel, so a brighter light narrows the
!> band where the day fades into the night, and ambient light dims the night
!> texture. The twilight ([[set_twilight]]; not in OpenFrames; default none:
!> the light ends at the terminator) extends the diffuse light past the
!> terminator, to where the light is a given angle below the horizon, fading
!> out: e.g. sunlight scattered by an atmosphere.
!>
!> Translucency (not in OpenFrames): [[set_opacity]] (default 1, opaque) below
!> 1 draws the body translucent: lit as usual, blended over what is behind
!> it, drawn after the opaque scene (farthest first: VSG's depth-sorted bin)
!> without writing depth, front faces only. Whether it is translucent is
!> decided when a window first shows it (as the textures); its opacity can
!> then change at any time (through the material's alpha).
!>
!> Texture files are found with find_data_file (a path, or a name on
!> VKFRAMES_DATA_PATH, e.g. data/'s 2k_earth_daymap.jpg and
!> 2k_earth_nightmap.jpg). The textures and the mesh resolution are read when
!> a window first shows the sphere: set them before that. The radius, scale,
!> color and material can change at any time: the meshes are built once, then
!> scaled by their transform (radii / the built radii), and the color is applied
!> through the material (the shaders multiply the vertex color, white here, by
!> the diffuse color).
!>
!> Level of detail ([[set_auto_lod]], on by default; OpenFrames' setAutoLOD): the
!> body has meshes of 32, 64, ... 1024 segments around (half as many from pole
!> to pole), and each view draws the coarsest whose facets stay within half a
!> pixel of the true outline (src/vsg/vkf_layer.cpp: SphereLod), so that the
!> body isn't faceted however close the view. Meshes are built when a view
!> first wants them (the 128-segment one at once), in the sphere's update.
!> Off: one mesh of [[set_resolution]]'s segments (default 128 x 64).
module vkf_sphere

    use, intrinsic :: iso_c_binding, only: c_ptr, c_null_ptr, c_associated, c_int, c_float
    use vkf_math, only: wp, quat_identity, quat_normalize, frame_matrix, quat_to_matrix
    use vkf_frames, only: ReferenceFrame, PickHit, pick_ellipsoid
    use vkf_pick, only: PickCamera
    use vkf_layer, only: vkf_image_read, vkf_image_delete, vkf_transform_create, vkf_transform_set_matrix, &
                         vkf_node_add_child, vkf_node_delete, mesh_set_material, vkf_mesh_set_twilight, vkf_compile_node, &
                         vkf_sphere_lod_create, vkf_sphere_lod_add, vkf_sphere_lod_requested
    use vkf_geometry, only: ellipsoid_create
    use vkf_files, only: find_data_file

    implicit none

    private

    !> The level-of-detail meshes: segments around the equator (half as many
    !> from pole to pole); the one built at once.
    integer, parameter :: lod_segments(6) = [32, 64, 128, 256, 512, 1024]
    integer, parameter :: lod_first = 3                     !! the index of the mesh built at once (128 segments)

    !> A sphere or ellipsoid (see the module documentation).
    type, extends(ReferenceFrame), public :: Sphere
        private
        real(wp) :: radius_ = 1.0_wp                          !! radius (scene units; default 1)
        real(wp) :: scale_(3) = 1.0_wp                        !! scale along x, y and z: semi-axes radius * scale
        real(wp) :: sphere_position_(3) = 0.0_wp              !! the body's center in the frame (scene units)
        real(wp) :: sphere_attitude_(4) = quat_identity       !! the body's attitude in the frame ([x, y, z, w])
        real(wp) :: built_radii(3) = 1.0_wp                   !! the mesh's semi-axes
        logical :: textured = .false.                         !! (the color is not used)
        character(len=:), allocatable :: texture              !! texture file (unallocated or '': none)
        character(len=:), allocatable :: night_texture        !! night texture file (unallocated or '': none)
        logical :: auto_lod_ = .true.                         !! level of detail (default on)
        integer :: n_lon = 128, n_lat = 64                    !! without level of detail
        ! material (VSG's Phong; OpenGL's default: no specular reflection)
        real(wp) :: ambient_(4) = 1.0_wp                      !! ambient color [r, g, b, a]
        real(wp) :: diffuse_(4) = 1.0_wp                      !! diffuse [r, g, b, a] (times the color if no texture)
        real(wp) :: specular_(4) = [0.0_wp, 0.0_wp, 0.0_wp, 1.0_wp]  !! specular color [r, g, b, a]
        real(wp) :: emission_(4) = [0.0_wp, 0.0_wp, 0.0_wp, 1.0_wp]  !! emission color [r, g, b, a]
        real(wp) :: shininess_ = 0.0_wp                       !! 0 to 128 (0: no specular highlight)
        real(wp) :: twilight_ = 0.0_wp                        !! radians below the horizon (0: none)
        real(wp) :: opacity_ = 1.0_wp                         !! 1 opaque; below 1 translucent (see set_opacity)
        logical :: translucent = .false.                      !! built translucent (opacity below 1 when first shown)
        type(c_ptr) :: sphere_node = c_null_ptr              !! transform of the body within the frame
        type(c_ptr) :: mesh = c_null_ptr                     !! without level of detail
        ! level of detail
        type(c_ptr) :: lod_node = c_null_ptr                 !! chooses among the meshes, per view
        type(c_ptr) :: lod_mesh(size(lod_segments)) = c_null_ptr  !! the level-of-detail meshes (null: not built yet)
        type(c_ptr) :: image = c_null_ptr, night = c_null_ptr   !! the textures, for meshes built later
        logical :: shown = .false.                            !! (meshes built later are compiled)
    contains
        procedure :: set_radius, radius
        procedure :: set_color => sphere_set_color
        procedure :: set_sphere_scale, sphere_scale, radii
        procedure :: set_sphere_position, sphere_position
        procedure :: set_sphere_attitude, sphere_attitude
        procedure :: set_texture_map, texture_map
        procedure :: set_night_texture_map, night_texture_map
        procedure :: set_auto_lod, auto_lod
        procedure :: set_material, material
        procedure :: set_twilight, twilight
        procedure :: set_opacity, opacity
        procedure :: set_resolution
        procedure :: frame_info => sphere_frame_info
        procedure :: bound => sphere_bound
        procedure :: pick => sphere_pick
        procedure :: build_contents => sphere_build_contents
        procedure :: update => sphere_update
        procedure :: num_lod_meshes
    end type Sphere

    !> The Sphere constructor: `Sphere(...)` makes one (see [[new_sphere]] for its
    !> arguments).
    interface Sphere
        module procedure new_sphere
    end interface Sphere

contains

    !> A sphere named `name`, of radius `radius` (default 1) and color `color`
    !> ([r, g, b] or [r, g, b, a]; default white, 90% opaque).
    function new_sphere(name, radius, color) result(s)
        character(len=*), intent(in) :: name                !! the frame's name
        real(wp), intent(in), optional :: radius            !! scene units (default 1)
        real(wp), intent(in), optional :: color(:)          !! [r, g, b] or [r, g, b, a] (0 to 1)
        type(Sphere) :: s
        s%ReferenceFrame = ReferenceFrame(name, color)
        if (present(radius)) s%radius_ = radius
    end function new_sphere

    !> The sphere's radius (scene units, > 0; default 1); can change at any time.
    subroutine set_radius(self, radius)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: radius                      !! scene units (<= 0: ignored)
        if (radius <= 0.0_wp) return
        self%radius_ = radius
        call update_sphere_transform(self)
    end subroutine set_radius

    !> The body's color (used without a texture); [r, g, b] or [r, g, b, a].
    subroutine sphere_set_color(self, color)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: color(:)                    !! [r, g, b] or [r, g, b, a] (0 to 1)
        call self%ReferenceFrame%set_color(color)
        call apply_material(self)
    end subroutine sphere_set_color

    !> The body's opacity, 0 (invisible) to 1 (opaque, the default): below 1
    !> it is drawn translucent (see the module documentation). Set below 1
    !> before a window first shows it to make it translucent; then the
    !> opacity can change at any time (a translucent body stays translucent,
    !> an opaque one opaque).
    subroutine set_opacity(self, opacity)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: opacity                     !! 0 to 1
        self%opacity_ = max(0.0_wp, min(1.0_wp, opacity))
        call apply_material(self)
    end subroutine set_opacity

    !> The body's opacity (set_opacity).
    pure real(wp) function opacity(self)
        class(Sphere), intent(in) :: self
        opacity = self%opacity_
    end function opacity

    !> The sphere's radius (scene units).
    pure real(wp) function radius(self)
        class(Sphere), intent(in) :: self
        radius = self%radius_
    end function radius

    !> Scale the sphere along its x, y and z axes (all > 0), making an
    !> ellipsoid with semi-axes radius * scale.
    subroutine set_sphere_scale(self, scale)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: scale(3)                    !! along x, y and z (all > 0, else ignored)
        if (any(scale <= 0.0_wp)) return
        self%scale_ = scale
        call update_sphere_transform(self)
    end subroutine set_sphere_scale

    !> The scale along the sphere's x, y and z axes.
    pure function sphere_scale(self) result(s)
        class(Sphere), intent(in) :: self
        real(wp) :: s(3)
        s = self%scale_
    end function sphere_scale

    !> The semi-axes: radius * sphere_scale.
    pure function radii(self) result(r)
        class(Sphere), intent(in) :: self
        real(wp) :: r(3)
        r = self%radius_ * self%scale_
    end function radii

    !> Position of the body's center in its frame.
    subroutine set_sphere_position(self, position)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: position(3)                 !! scene units
        self%sphere_position_ = position
        call update_sphere_transform(self)
    end subroutine set_sphere_position

    !> Position of the body's center in its frame (scene units).
    pure function sphere_position(self) result(p)
        class(Sphere), intent(in) :: self
        real(wp) :: p(3)
        p = self%sphere_position_
    end function sphere_position

    !> Attitude of the body in its frame (quaternion [x, y, z, w]), e.g. to
    !> orient its texture.
    subroutine set_sphere_attitude(self, q)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: q(4)                        !! quaternion [x, y, z, w] (normalized here)
        self%sphere_attitude_ = quat_normalize(q)
        call update_sphere_transform(self)
    end subroutine set_sphere_attitude

    !> Attitude of the body in its frame (quaternion [x, y, z, w]).
    pure function sphere_attitude(self) result(q)
        class(Sphere), intent(in) :: self
        real(wp) :: q(4)
        q = self%sphere_attitude_
    end function sphere_attitude

    !> The texture: an equirectangular image file ('' for none).
    subroutine set_texture_map(self, file)
        class(Sphere), intent(inout) :: self
        character(len=*), intent(in) :: file                !! found with find_data_file; '' for none
        self%texture = file
    end subroutine set_texture_map

    !> The texture file ('' for none).
    function texture_map(self) result(file)
        class(Sphere), intent(in) :: self
        character(len=:), allocatable :: file
        file = ''
        if (allocated(self%texture)) file = self%texture
    end function texture_map

    !> The texture for the side away from the light (e.g. city lights; an
    !> equirectangular image file as the texture, '' for none), shown where the
    !> diffuse light is weak.
    subroutine set_night_texture_map(self, file)
        class(Sphere), intent(inout) :: self
        character(len=*), intent(in) :: file                !! found with find_data_file; '' for none
        self%night_texture = file
    end subroutine set_night_texture_map

    !> The night texture file ('' for none).
    function night_texture_map(self) result(file)
        class(Sphere), intent(in) :: self
        character(len=:), allocatable :: file
        file = ''
        if (allocated(self%night_texture)) file = self%night_texture
    end function night_texture_map

    !> The material (OpenFrames' setMaterial; each optional): ambient, diffuse,
    !> specular and emission colors ([r, g, b] or [r, g, b, a], 0 to 1) and
    !> shininess (0 to 128: the specular highlight's sharpness; 0 for none).
    !> The ambient and diffuse colors scale the body's color or texture (VSG's
    !> Phong); the specular color is reflected from the lights' specular parts.
    !> Defaults: ambient and diffuse white, no specular reflection or emission.
    subroutine set_material(self, ambient, diffuse, specular, emission, shininess)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in), optional :: ambient(:), diffuse(:), specular(:), emission(:)  !! [r, g, b(, a)], 0 to 1
        real(wp), intent(in), optional :: shininess         !! 0 to 128 (< 0: 0)
        if (present(ambient)) self%ambient_(1:min(4, size(ambient))) = ambient(1:min(4, size(ambient)))
        if (present(diffuse)) self%diffuse_(1:min(4, size(diffuse))) = diffuse(1:min(4, size(diffuse)))
        if (present(specular)) self%specular_(1:min(4, size(specular))) = specular(1:min(4, size(specular)))
        if (present(emission)) self%emission_(1:min(4, size(emission))) = emission(1:min(4, size(emission)))
        if (present(shininess)) self%shininess_ = max(0.0_wp, shininess)
        call apply_material(self)
    end subroutine set_material

    !> The material (each optional): ambient, diffuse, specular and emission colors
    !> and shininess.
    pure subroutine material(self, ambient, diffuse, specular, emission, shininess)
        class(Sphere), intent(in) :: self
        real(wp), intent(out), optional :: ambient(4), diffuse(4), specular(4), emission(4)  !! [r, g, b, a]
        real(wp), intent(out), optional :: shininess        !! 0 to 128
        if (present(ambient)) ambient = self%ambient_
        if (present(diffuse)) diffuse = self%diffuse_
        if (present(specular)) specular = self%specular_
        if (present(emission)) emission = self%emission_
        if (present(shininess)) shininess = self%shininess_
    end subroutine material

    !> The twilight: the diffuse light extended past the terminator to where
    !> the light is `angle` (radians, 0 to 1.5) below the horizon, fading out
    !> smoothly (where it meets the light on the day side: the same brightness
    !> and slope, so no line shows), and the night texture fading in across it.
    !> 0 (the default): none, the light ends at the terminator (an airless
    !> body). Not in OpenFrames. Can change at any time.
    subroutine set_twilight(self, angle)
        class(Sphere), intent(inout) :: self
        real(wp), intent(in) :: angle                       !! radians below the horizon (clamped to 0 to 1.5)
        self%twilight_ = max(0.0_wp, min(1.5_wp, angle))
        call apply_material(self)
    end subroutine set_twilight

    !> The twilight angle (radians below the horizon; 0: none).
    pure real(wp) function twilight(self)
        class(Sphere), intent(in) :: self
        twilight = self%twilight_
    end function twilight

    !> The material, with the diffuse color times the body's color (without a
    !> texture), and the twilight, on every mesh built.
    subroutine apply_material(self)
        type(Sphere), intent(inout) :: self
        integer :: k
        call apply_material_to(self, self%mesh)
        do k = 1, size(self%lod_mesh)
            call apply_material_to(self, self%lod_mesh(k))
        end do
    end subroutine apply_material

    !> The material, with the diffuse color times the body's color (without a
    !> texture), and the twilight, on mesh `mesh` (if not null).
    subroutine apply_material_to(self, mesh)
        type(Sphere), intent(in) :: self
        type(c_ptr), intent(in) :: mesh                     !! a mesh handle (null: nothing done)
        real(wp) :: diffuse(4)
        if (.not. c_associated(mesh)) return
        diffuse = self%diffuse_
        if (.not. self%textured) diffuse = diffuse * self%color()
        if (self%translucent) diffuse(4) = diffuse(4) * self%opacity_      ! (the shader's alpha: the diffuse color's)
        if (mesh_set_material(mesh, real(self%ambient_, c_float), real(diffuse, c_float), &
                              real(self%specular_, c_float), real(self%emission_, c_float), &
                              real(self%shininess_, c_float)) /= 0) &
            write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not set the material'
        if (vkf_mesh_set_twilight(mesh, real(self%twilight_, c_float)) /= 0) &
            write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not set the twilight'
    end subroutine apply_material_to

    !> Level of detail (default on; see the module documentation): meshes of
    !> several resolutions, the one drawn chosen per view. Off: one mesh of
    !> [[set_resolution]]'s segments. Set it before a window shows the sphere.
    subroutine set_auto_lod(self, lod)
        class(Sphere), intent(inout) :: self
        logical, intent(in) :: lod                          !! true: level of detail; false: one mesh
        self%auto_lod_ = lod
    end subroutine set_auto_lod

    !> Whether level of detail is on.
    pure logical function auto_lod(self)
        class(Sphere), intent(in) :: self
        auto_lod = self%auto_lod_
    end function auto_lod

    !> The mesh resolution without level of detail: segments in longitude and
    !> latitude (default 128 x 64).
    subroutine set_resolution(self, n_lon, n_lat)
        class(Sphere), intent(inout) :: self
        integer, intent(in) :: n_lon                        !! segments in longitude (at least 3)
        integer, intent(in) :: n_lat                        !! segments in latitude (at least 2)
        self%n_lon = max(3, n_lon)
        self%n_lat = max(2, n_lat)
    end subroutine set_resolution

    !> The body's bounding sphere: its center, and its largest semi-axis.
    subroutine sphere_bound(self, center, radius)
        class(Sphere), intent(in) :: self
        real(wp), intent(out) :: center(3), radius          !! the bounding sphere, frame coordinates (scene units)
        center = self%sphere_position_
        radius = maxval(self%radii())
    end subroutine sphere_bound

    !> Picked on the ellipsoid (exact), with the geodetic latitude and longitude
    !> of the point hit (in the body's axes: sphere_attitude); else the frame's
    !> origin.
    subroutine sphere_pick(self, cam, to_root, hits, n)
        class(Sphere), intent(in), target :: self
        type(PickCamera), intent(in) :: cam                 !! the camera and cursor of the pick
        real(wp), intent(in) :: to_root(4,4)                !! the frame's transform to the root frame
        type(PickHit), allocatable, intent(inout) :: hits(:)  !! hits so far, in hits(1:n); added to
        integer, intent(inout) :: n                         !! number of hits
        if (self%contents_shown()) then
            if (pick_ellipsoid(self, cam, to_root, self%sphere_position_, quat_to_matrix(self%sphere_attitude_), &
                          self%radii(), .true., hits, n)) return
        end if
        call self%ReferenceFrame%pick(cam, to_root, hits, n)
    end subroutine sphere_pick

    !> What kind of frame this is: 'Sphere'.
    function sphere_frame_info(self) result(info)
        class(Sphere), intent(in) :: self
        character(len=:), allocatable :: info
        info = 'Sphere'
    end function sphere_frame_info

    !> The body: an ellipsoid mesh under the sphere's transform.
    subroutine sphere_build_contents(self, viewer, group)
        class(Sphere), intent(inout) :: self
        type(c_ptr), intent(in) :: viewer                   !! a window's viewer (null: headless)
        type(c_ptr), intent(in) :: group                    !! the frame's contents group
        type(c_ptr) :: image, night, body
        image = read_texture(self, self%texture)
        night = read_texture(self, self%night_texture)
        self%textured = c_associated(image)
        self%built_radii = self%radii()
        self%translucent = self%opacity_ < 1.0_wp
        if (self%auto_lod_) then                           ! meshes chosen per view, built when wanted
            self%image = image                              ! (kept for those built later)
            self%night = night
            self%shown = c_associated(viewer)
            self%lod_node = vkf_sphere_lod_create(maxval(self%built_radii))
            call build_lod_mesh(self, lod_first, compile=.false.)    ! (compiled with the scene)
            if (.not. c_associated(self%lod_mesh(lod_first))) return
            body = self%lod_node
        else
            self%mesh = ellipsoid_create(self%built_radii, [1.0_c_float, 1.0_c_float, 1.0_c_float, 1.0_c_float], image, &
                                         self%n_lon, self%n_lat, night, translucent=self%translucent)
            if (c_associated(image)) call vkf_image_delete(image)
            if (c_associated(night)) call vkf_image_delete(night)
            if (.not. c_associated(self%mesh)) return
            body = self%mesh
        end if
        call apply_material(self)
        self%sphere_node = vkf_transform_create()
        call update_sphere_transform(self)
        if (vkf_node_add_child(self%sphere_node, body) /= 0 .or. vkf_node_add_child(group, self%sphere_node) /= 0) &
            write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not build the scene'
    end subroutine sphere_build_contents

    !> Build level-of-detail mesh k (lod_segments(k) segments around) and add it
    !> to the level-of-detail node (compiled for the windows if `compile`).
    subroutine build_lod_mesh(self, k, compile)
        type(Sphere), intent(inout) :: self
        integer, intent(in) :: k                            !! index into lod_segments
        logical, intent(in) :: compile                      !! true: compile it now (the sphere is shown)
        type(c_ptr) :: mesh
        mesh = ellipsoid_create(self%built_radii, [1.0_c_float, 1.0_c_float, 1.0_c_float, 1.0_c_float], self%image, &
                                lod_segments(k), lod_segments(k) / 2, self%night, translucent=self%translucent)
        if (.not. c_associated(mesh)) then
            write(*, '(a,i0,a)') 'vkframes: Sphere '//self%name()//': could not build its ', lod_segments(k), '-segment mesh'
            return
        end if
        self%lod_mesh(k) = mesh
        call apply_material_to(self, mesh)
        if (compile) then
            if (vkf_compile_node(mesh) /= 0) write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not compile a mesh'
        end if
        if (vkf_sphere_lod_add(self%lod_node, mesh, int(lod_segments(k), c_int)) /= 0) &
            write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not add a mesh'
    end subroutine build_lod_mesh

    !> Update the frame (followed trajectories, children) and, with level of
    !> detail, build the mesh the views have wanted if it isn't built yet (the
    !> coarsest with as many segments as wanted, or the finest).
    recursive subroutine sphere_update(self, t)
        class(Sphere), intent(inout), target :: self
        real(wp), intent(in) :: t                           !! the scene time
        integer :: wanted, k
        call self%ReferenceFrame%update(t)
        if (.not. c_associated(self%lod_node)) return
        wanted = vkf_sphere_lod_requested(self%lod_node)
        if (wanted <= 0) return
        k = findloc(lod_segments >= wanted, .true., 1)
        if (k == 0) k = size(lod_segments)
        if (.not. c_associated(self%lod_mesh(k))) call build_lod_mesh(self, k, compile=self%shown)
    end subroutine sphere_update

    !> Number of level-of-detail meshes built so far (0 without level of detail).
    integer function num_lod_meshes(self) result(n)
        class(Sphere), intent(in) :: self
        integer :: k
        n = 0
        do k = 1, size(self%lod_mesh)
            if (c_associated(self%lod_mesh(k))) n = n + 1
        end do
    end function num_lod_meshes

    !> An image handle for texture file `file` (null if none or unreadable).
    function read_texture(self, file) result(image)
        type(Sphere), intent(in) :: self
        character(len=:), allocatable, intent(in) :: file   !! found with find_data_file (unallocated or '': none)
        type(c_ptr) :: image
        character(len=:), allocatable :: path
        image = c_null_ptr
        if (.not. allocated(file)) return
        if (len(file) == 0) return
        path = find_data_file(file)
        if (len(path) > 0) image = vkf_image_read(path, len(path, kind=c_int))
        if (.not. c_associated(image)) write(*, '(a)') 'vkframes: Sphere '//self%name()//': could not read texture '//file
    end function read_texture

    !> The body's transform: its position, attitude and scale (radii / the built
    !> radii); nothing before it is built.
    subroutine update_sphere_transform(self)
        type(Sphere), intent(inout) :: self
        if (.not. c_associated(self%sphere_node)) return
        if (vkf_transform_set_matrix(self%sphere_node, frame_matrix(self%sphere_position_, self%sphere_attitude_, &
                                                                    self%radii() / self%built_radii)) /= 0) &
            write(*, '(a)') 'vkframes: Sphere '//self%name()//': transform failed'
    end subroutine update_sphere_transform

end module vkf_sphere
