Source code for py3dtiles.tileset.tile

from __future__ import annotations

import copy
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar

import numpy as np
import numpy.typing as npt

from py3dtiles.exceptions import InvalidTilesetError, Py3dtilesException, TilerException
from py3dtiles.typing import RefineType, TileDictType

from .bounding_volume import BoundingVolume
from .bounding_volume_box import BoundingVolumeBox
from .content import TileContent, read_binary_tile_content
from .root_property import RootProperty

if TYPE_CHECKING:
    from py3dtiles.tileset import TileSet

DEFAULT_TRANSFORMATION = np.identity(4, dtype=np.float64)
DEFAULT_TRANSFORMATION.setflags(write=False)

T = TypeVar("T", bound=np.generic)


[docs] class Tile(RootProperty[TileDictType]): def __init__( self, geometric_error: float = 500, bounding_volume: BoundingVolume[Any] | None = None, transform: npt.NDArray[np.float64] = DEFAULT_TRANSFORMATION, refine_mode: RefineType = "ADD", content_uri: Path | None = None, ) -> None: super().__init__() self.bounding_volume = bounding_volume self.geometric_error = geometric_error self._refine: RefineType = "ADD" self.set_refine_mode(refine_mode) self.tile_content: TileContent | TileSet | None = None self.content_uri: Path | None = content_uri self.children: list[Tile] = [] # Some possible valid properties left un-delt with viewerRequestVolume self.transform = transform
[docs] @classmethod def from_dict(cls, tile_dict: TileDictType) -> Tile: if "box" in tile_dict["boundingVolume"]: bounding_volume = BoundingVolumeBox.from_dict(tile_dict["boundingVolume"]) # type: ignore [arg-type] elif ( "region" in tile_dict["boundingVolume"] or "sphere" in tile_dict["boundingVolume"] ): raise NotImplementedError( "The support of bounding volume region and sphere is not implemented yet" ) else: raise InvalidTilesetError( f"The bounding volume {list(tile_dict['boundingVolume'].keys())[0]} is unknown" ) tile = cls( geometric_error=tile_dict["geometricError"], bounding_volume=bounding_volume, ) if "refine" in tile_dict: tile.set_refine_mode(tile_dict["refine"]) if "transform" in tile_dict: tile.transform = np.array(tile_dict["transform"]).reshape((4, 4), order="F") if "children" in tile_dict: for child in tile_dict["children"]: tile.children.append(Tile.from_dict(child)) if "content" in tile_dict: tile.content_uri = Path(tile_dict["content"]["uri"]) tile.set_properties_from_dict(tile_dict) return tile
[docs] def get_or_fetch_content(self, root_uri: Path | None) -> TileContent | TileSet: """ If a `tile_content` content has been set, returns this content. If the tile content is None and a `tile_content` uri has been set, the tile will load the file and return its content. :param root_uri: the base uri which `tile.content_uri` is relative to. Usually the directory containing the tileset containing this tile. """ self._load_tile_content(root_uri) if self.tile_content is None: raise Py3dtilesException( "self.tile_content cannot be None here, seems to be a py3dtiles issue." ) return self.tile_content
[docs] def has_content(self) -> bool: """ Returns if there is a tile content (loaded or not). """ return bool(self.tile_content is not None or self.content_uri)
[docs] def has_content_loaded(self) -> bool: """ Returns if there is a tile content loaded in this tile. """ return self.tile_content is not None
[docs] def delete_on_disk(self, root_uri: Path, delete_sub_tileset: bool = False) -> None: """Deletes all files linked to the tile and its children. The uri of the folder where tileset is, should be defined. :param root_uri: The folder where tileset is. :param delete_sub_tileset: If True, all tilesets present as tile content will be removed as well as their content. If False, the linked tilesets in tiles won't be removed. """ for child in self.children: child.delete_on_disk(root_uri, delete_sub_tileset) # if there is no content_uri, there is no file to remove if self.content_uri is None: return if self.content_uri.is_absolute(): tile_content_path = self.content_uri else: tile_content_path = root_uri / self.content_uri if tile_content_path.suffix == ".json": if delete_sub_tileset: self.get_or_fetch_content(root_uri).delete_on_disk(tile_content_path) # type: ignore else: tile_content_path.unlink(missing_ok=True)
[docs] def set_refine_mode(self, mode: RefineType) -> None: if mode != "ADD" and mode != "REPLACE": raise InvalidTilesetError( f"Unknown refinement mode {mode}. Should be either 'ADD' or 'REPLACE'." ) self._refine = mode
[docs] def get_refine_mode(self) -> RefineType: return self._refine
[docs] def add_child(self, tile: Tile) -> None: self.children.append(tile) if tile.bounding_volume is not None: if self.bounding_volume is None: self.bounding_volume = copy.deepcopy(tile.bounding_volume) self.bounding_volume.transform(tile.transform) else: transformed_bounding_volume = copy.deepcopy(tile.bounding_volume) transformed_bounding_volume.transform(tile.transform) parent_inv_transform = np.linalg.inv(self.transform) transformed_bounding_volume.transform(parent_inv_transform) self.bounding_volume.add(transformed_bounding_volume)
[docs] def get_all_children(self) -> list[Tile]: """ :return: the recursive (across the children tree) list of the children tiles """ descendants = [] for child in self.children: # Add the child... descendants.append(child) # and if (and only if) they are grand-children then recurse if child.children: descendants += child.get_all_children() return descendants
[docs] def sync_bounding_volume_with_children(self) -> None: if self.bounding_volume is None: raise TilerException("This Tile has no bounding volume.") if not isinstance(self.bounding_volume, BoundingVolumeBox): raise NotImplementedError( "Don't know yet how to sync non box bounding volume." ) # We consider that whatever information is present it is the # proper one (in other terms: when they are no sub-tiles this tile # is a leaf-tile and thus is has no synchronization to do) for child in self.children: child.sync_bounding_volume_with_children() # The information that depends on (is defined by) the children # nodes is limited to be bounding volume. self.bounding_volume.sync_with_children(self)
[docs] def transform_coords(self, coords: npt.NDArray[T]) -> npt.NDArray[T]: """ Transform coordinates according to this tile transformation property. NOTE: this only applies the current tile transformation, not the transformation of ancestors """ assert coords.ndim == 2 assert coords.shape[1] == 3 new_coords = np.zeros(coords.shape, dtype=coords.dtype) for (i, coord) in enumerate(coords): new_coords[i] = self.transform_coord(coord) return new_coords
[docs] def transform_coord(self, coord: npt.NDArray[T]) -> npt.NDArray[T]: """ Transform one coordinate according to this tile transformation property. NOTE: this only applies the current tile transformation, not the transformation of ancestors """ [x, y, z] = coord # code inspired from three.js e = self.transform.flatten(order="F") w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]) x = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w y = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w z = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w return np.array([x, y, z])
[docs] def write_content(self, root_uri: Path | None) -> None: """ Write (or overwrite) the tile *content* to the directory specified as parameter and withing the relative filename designated by the tile's content uri. :param root_uri: the base uri of this tile, usually the folder where the tileset is """ if self.tile_content is None: raise TilerException( "The tile has no tile content. " "A tile content should be added in the tile." ) if self.content_uri is None: raise TilerException("tile.content_uri is None, cannot write tile content") if self.content_uri.is_absolute(): content_path = self.content_uri else: if root_uri is None: raise ValueError( "No root_uri given and tile.content_uri is not absolute" ) content_path = root_uri / self.content_uri # Make sure the output directory exists (note that target_dir may # be a sub-directory of 'directory' because the uri might hold its # own path): content_path.parent.mkdir(parents=True, exist_ok=True) if isinstance(self.tile_content, TileContent): self.tile_content.save_as(content_path) else: self.tile_content.write_to_directory(content_path)
[docs] def to_dict(self) -> TileDictType: if self.bounding_volume is not None: bounding_volume = self.bounding_volume else: raise InvalidTilesetError("Bounding volume is not set") bounding_volume_dict = bounding_volume.to_dict() refine = self._refine if refine not in ["ADD", "REPLACE"]: raise InvalidTilesetError( f"refine should be either ADD or REPLACE, currently {refine}." ) # Mandatory items dict_data: TileDictType = { "boundingVolume": bounding_volume_dict, "geometricError": self.geometric_error, "refine": refine, } dict_data = self.add_root_properties_to_dict(dict_data) if not np.array_equal(self.transform, DEFAULT_TRANSFORMATION): dict_data["transform"] = self.transform.flatten("F").tolist() if self.children: # The children list exists indeed (for technical reasons) yet it # happens to be still empty. This would pollute the json output # by adding a "children" entry followed by an empty list. In such # case just remove that attributes entry: dict_data["children"] = [child.to_dict() for child in self.children] if self.content_uri: dict_data["content"] = {"uri": self.content_uri.as_posix()} return dict_data
def _load_tile_content(self, root_uri: Path | None) -> None: if self.tile_content: return if not self.content_uri: raise RuntimeError("Cannot load a tile without a content_uri") if self.content_uri.is_absolute(): uri = self.content_uri else: if root_uri is None: raise RuntimeError( "Cannot load a tile without a root_uri if self.content_uri is relative" ) uri = root_uri / self.content_uri if uri.suffix == ".json": from .tileset import TileSet self.tile_content = TileSet.from_file(uri) else: self.tile_content = read_binary_tile_content(uri)