.. _api:

.. |check| raw:: html

    <input checked=""  type="checkbox" onclick="return false;">

.. |uncheck| raw:: html

    <input type="checkbox" onclick="return false;">

API usage
=========

Tileset manipulation
--------------------
The tileset module contains all the classes to represent a dataset in 3D Tiles format,
whether it is the `tileset.json` file or the content of the tiles (sub-tileset, pnts or b3dm, there is no i3dm support).

.. note::

    Currently, Py3dtiles partially supports the 3d Tiles standard at version 1.0. There is work to support the 1.1 standard. The main feature of 1.1 is the dropping of specific formats (pnts, b3dm and i3dm) in favor of the gltf format.

There is work to support the 1.1 standard. The main feature of 1.1 is the dropping of specific formats (pnts, b3dm and i3dm)
in favor of the gltf format.

Structure of a tileset in Py3dtiles
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

At the top of the hierarchy, there is the class `TileSet` which will contain a root tile which is an instance of a `Tile`.
Then this root tile can contain content (`TileContent` or again a `TileSet`) and child tiles,
which themselves can contain content and children.

TileSet class
~~~~~~~~~~~~~

This class will contain the properties defined by the standard as well as methods related to reading and writing
an entire tileset. Here is the current state of the properties supported by Py3dtiles:

- |check| `asset`
    - |check| `version`
    - |check| `tilesetVersion`
    - |check| `extensions`
    - |check| `extras` (partially)
- |uncheck| `properties`
- |uncheck| `schema` (version 1.1)
- |uncheck| `schemaUri` (version 1.1)
- |uncheck| `statistics` (version 1.1)
- |uncheck| `groups` (version 1.1)
- |uncheck| `metadata` (version 1.1)
- |check| `geometricError`
- |check| `root` (the name in Py3dtiles is `root_tile`)
- |check| `extensionsUsed`
- |check| `extensionsRequired`
- |check| `extensions`
- |check| `extras`

**Create a tileset from scratch:**

.. code-block:: python

    >>> from py3dtiles.tileset import TileSet
    >>>
    >>> tileset = TileSet()
    >>>
    >>> # when creating a tileset from scratch, the first tile (named root_tile) is initialized
    >>> tileset.root_tile
    <py3dtiles.tileset.tile.Tile object at 0x...>

**Read and write a tileset:**

.. code-block:: python

    >>> # it is possible to load a tileset from the json content
    >>> import json
    >>> from pathlib import Path
    >>>
    >>> from py3dtiles.tileset import TileSet
    >>>
    >>> tileset_path = Path("tests/fixtures/tiles/tileset.json")
    >>> with tileset_path.open() as f:
    ...     tileset = TileSet.from_dict(json.load(f))
    >>> tileset.root_uri = tileset_path.parent
    >>> tileset
    <py3dtiles.tileset.tileset.TileSet object at 0x...>
    >>>
    >>> # or more simply
    >>> tileset = TileSet.from_file(tileset_path)
    >>>
    >>> # a tileset can be written to the disk
    >>> # if you want the content of the tiles to be written too, use write_to_directory
    >>> new_tileset_path = Path("my3dtiles/tileset.json")
    >>> new_tileset_path.parent.mkdir()
    >>> tileset.write_as_json(new_tileset_path)

When reading a tileset, the tile content loading is done lazily, i.e. one loads only the `tileset.json` file and the tile contents is loaded only when needed.

Tile class
~~~~~~~~~~

The Tile class represents a tile in the `tileset.json`. It will contain the properties defined by the standard:

- |check| boundingVolume (only the bounding volume box)
- |uncheck| viewerRequestVolume
- |check| geometricError
- |check| refine
- |check| transform
- |check| content
    - |check| uri
    - |check| boundingVolume (partially)
- |uncheck| contents (version 1.1)
- |uncheck| metadata (version 1.1)
- |uncheck| implicitTiling (version 1.1)
- |check| children
- |check| `extensions`
- |check| `extras`

.. warning::
    In py3dtiles the content data and the content uri are in 2 separate variables.

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> from py3dtiles.tileset import Tile
    >>> from py3dtiles.tileset.content import read_binary_tile_content
    >>>
    >>> tile = Tile()
    >>> # the pnts is loaded and linked to the tile
    >>> tile.tile_content = read_binary_tile_content(Path("tests/fixtures/pointCloudRGB.pnts"))
    >>> # the uri that will be written in the tileset.json (and the path where the pnts will be writen)
    >>> tile.content_uri = Path("tiles/1.pnts")


Bounding volume
~~~~~~~~~~~~~~~
There are 3 types of bounding volume:

- |check| Bounding volume box
- |uncheck| Bounding volume region
- |uncheck| Bounding volume sphere

**Creation of a bounding volume box**

.. code-block:: python

    >>> import numpy as np
    >>>
    >>> from py3dtiles.tileset import BoundingVolumeBox
    >>>
    >>> center = [0, 0, 0]
    >>> x_half_axis = [3, 3, 3]
    >>> y_half_axis = [3, 3, 3]
    >>> z_half_axis = [1, 0, 0]
    >>>
    >>> bounding_box = BoundingVolumeBox.from_list([*center, *x_half_axis, *y_half_axis, *z_half_axis])
    >>> bounding_box.to_dict()
    {'box': [np.float64(0.0), np.float64(0.0), np.float64(0.0), np.float64(3.0), np.float64(3.0), np.float64(3.0), np.float64(3.0), np.float64(3.0), np.float64(3.0), np.float64(1.0), np.float64(0.0), np.float64(0.0)]}
    >>>
    >>> points = np.array((
    ... (1, 0, 0),
    ... (3, 2, 1),
    ... (4, 6, 8),
    ... (-1, -5, -9),
    ... ))
    >>> bounding_box.set_from_points(points)
    >>> bounding_box.to_dict()
    {'box': [np.float64(1.5), np.float64(0.5), np.float64(-0.5), np.float64(2.5), np.float64(0.0), np.float64(0.0), np.float64(0.0), np.float64(5.5), np.float64(0.0), np.float64(0.0), np.float64(0.0), np.float64(8.5)]}

Extension management
~~~~~~~~~~~~~~~~~~~~

When you add an extension somewhere in a tileset, you must add the name of the extension in the attribute `extensionUsed`
of the class `TileSet` like this:

.. code-block:: python

    >>> from py3dtiles.tileset import TileSet
    >>> from py3dtiles.tileset.extension import BatchTableHierarchy
    >>>
    >>> tileset = TileSet()
    >>>
    >>> extension = BatchTableHierarchy()
    >>>
    >>> tileset.extensions_used.add(extension.name)
    >>> # Furthermore, if the extension is necessary to display the tileset, you should add the name in extensionsRequired
    >>> tileset.extensions_required.add(extension.name)

Since these 2 attributes are sets, the name of an extension can be added several times.

.. note::
   This section should be improved.

Specific exceptions
~~~~~~~~~~~~~~~~~~~

If during reading, manipulation or writing, there is a problem related to the standard,
an exception of type `Py3dtilesException` (or inherited from it) will be raised.

.. code-block:: python

    >>> from py3dtiles.tileset import Tile
    >>>
    >>> tile = Tile()
    >>> tile.to_dict()
    Traceback (most recent call last):
    py3dtiles.exceptions.InvalidTilesetError: Bounding volume is not set

Tileset creation example
~~~~~~~~~~~~~~~~~~~~~~~~

This basic example aims to show a set of methods to create, manipulate and write a tileset.

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> import laspy
    >>> import numpy as np
    >>>
    >>> from py3dtiles.tileset import Tile, TileSet
    >>> from py3dtiles.tileset.content import Pnts
    >>> from py3dtiles.tileset.content.pnts_feature_table import PntsFeatureTableHeader, SemanticPoint
    >>>
    >>> with laspy.open("tests/fixtures/with_srs_3950.las") as f:
    ...     las_data = f.read()
    >>> points = las_data.points
    >>>
    >>> # Get few points for the root tile
    >>> indexes = np.random.choice(len(points), 100)
    >>> point_part = points[indexes]
    >>> positions = np.vstack((point_part.x, point_part.y, point_part.z)).T
    >>> feature_table_header = PntsFeatureTableHeader.from_semantic(
    ...     SemanticPoint.POSITION, None, None, nb_points = 100
    ... )
    >>> root_tile = Tile(refine_mode="REPLACE", content_uri=Path("root.pnts"))
    >>> root_tile.tile_content = Pnts.from_features(feature_table_header, positions.flatten())
    >>> root_tile.bounding_volume = BoundingVolumeBox.from_points(positions)
    >>>
    >>> # Split the points in 4 parts
    >>> split_len = len(points) // 4
    >>> splits = [
    ...     (0, split_len),
    ...     (split_len, split_len*2),
    ...     (split_len*2, split_len*3),
    ...     (split_len*3, None),
    ... ]
    >>> for start, end in splits:
    ...     point_part = points[start : end]
    ...     positions = np.vstack((point_part.x, point_part.y, point_part.z)).T
    ...     feature_table_header = PntsFeatureTableHeader.from_semantic(
    ...         SemanticPoint.POSITION, None, None, nb_points = len(point_part)
    ...     )
    ...     tile = Tile(content_uri=Path(f"{start}.pnts"))
    ...     tile.tile_content = Pnts.from_features(feature_table_header, positions.flatten())
    ...     tile.bounding_volume = BoundingVolumeBox.from_points(positions)
    ...     root_tile.add_child(tile)
    >>>
    >>> # Create the tileset
    >>> tileset = TileSet()
    >>> tileset.root_tile = root_tile
    >>> tileset_path = Path("my3dtiles2/tileset.json")
    >>> tileset_path.parent.mkdir()
    >>> tileset.write_to_directory(tileset_path)

Tile content
------------

The py3dtiles module provides some classes to fit into the
specification:

- `TileContent` with a header `TileContentHeader` and a body `TileContentBody`
- `TileContentHeader` represents the metadata of the tile (magic value, version, ...)
- `TileContentBody` contains varying semantic and geometric data depending on the the tile's type

Moreover, a utility module `tile_content_reader.py` provides a function `read_binary_tile_content` to read a tile
file as well as a simple command line tool to retrieve basic information about a tile:
**py3dtiles info**. We also provide a utility to generate a tileset from a list of 3D models in
WKB format or stored in a postGIS table.


Point Cloud
~~~~~~~~~~~

Points Tile Format:
https://docs.ogc.org/cs/22-025r4/22-025r4.html#toc29

In the current implementation, the *Pnts* class only contains a *PntsFeatureTable*
(*PntsFeatureTableHeader* and a *PntsFeatureTableBody*, which contains features of type
*Feature*).

**How to read a .pnts file**

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> from py3dtiles.tileset.content import Pnts, read_binary_tile_content
    >>>
    >>> filename = Path('tests/fixtures/pointCloudRGB.pnts')
    >>>
    >>> # read the file
    >>> pnts = read_binary_tile_content(filename)
    >>>
    >>> # pnts is an instance of the Pnts class
    >>> pnts
    <py3dtiles.tileset.content.pnts.Pnts object at 0x...>
    >>>
    >>> # extract information about the pnts header
    >>> pnts_header = pnts.header
    >>> pnts_header
    <py3dtiles.tileset.content.pnts.PntsHeader object at 0x...>
    >>> pnts_header.magic_value
    b'pnts'
    >>> pnts_header.tile_byte_length
    15176
    >>>
    >>> # extract the feature table
    >>> feature_table = pnts.body.feature_table
    >>> feature_table
    <py3dtiles.tileset.content.pnts_feature_table.PntsFeatureTable object at 0x...>
    >>>
    >>> # display feature table header
    >>> feature_table.header.to_json()
    {'POINTS_LENGTH': 1000, 'RTC_CENTER': [1215012.8828876738, -4736313.051199594, 4081605.22126042], 'POSITION': {'byteOffset': 0}, 'RGB': {'byteOffset': 12000}}
    >>>
    >>> # extract positions and colors of the first point
    >>> feature_table.get_feature_at(0)
    (array([ 2.19396   ,  4.489685  , -0.17107764], dtype=float32), array([ 44, 243, 209], dtype=uint8), None)
    >>> feature_table.get_feature_position_at(0)
    array([ 2.19396   ,  4.489685  , -0.17107764], dtype=float32)
    >>> feature_table.get_feature_color_at(0)
    array([ 44, 243, 209], dtype=uint8)

**How to write a .pnts file**

To write a Point Cloud file, you have to build a numpy array with the
corresponding data type.

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> import numpy as np
    >>>
    >>> from py3dtiles.tileset.content import Pnts
    >>> from py3dtiles.tileset.content.pnts_feature_table import PntsFeatureTableHeader, SemanticPoint
    >>>
    >>> # create a position array of 2 points
    >>> positions = np.array([
    ...     (4.489, 2.19, -0.17),
    ...     (8.65, 12.2, -0.17),
    ... ], dtype=np.float32).flatten()
    >>>
    >>> # create the feature table header that defines the structure of pnts
    >>> feature_table_header = PntsFeatureTableHeader.from_semantic(SemanticPoint.POSITION, None, None, nb_points = 2)
    >>>
    >>> # create the pnts
    >>> pnts = Pnts.from_features(feature_table_header, positions)
    >>>
    >>> # the pnts is complete
    >>> pnts.body.feature_table.header.to_json()
    {'POINTS_LENGTH': 2, 'POSITION': {'byteOffset': 0}}
    >>>
    >>> # to save our tile as a .pnts file
    >>> pnts.save_as(Path("mypoints.pnts"))


Batched 3D Model
~~~~~~~~~~~~~~~~

Batched 3D Model Tile Format:
https://docs.ogc.org/cs/22-025r4/22-025r4.html#toc27

How to read a .b3dm file
^^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> from py3dtiles.tileset.content import B3dm, read_binary_tile_content
    >>>
    >>> filename = Path('tests/fixtures/buildings.b3dm')
    >>>
    >>> # read the file
    >>> b3dm = read_binary_tile_content(filename)
    >>>
    >>> # b3dm is an instance of the B3dm class
    >>> b3dm
    <py3dtiles.tileset.content.b3dm.B3dm object at 0x...>
    >>>
    >>> # extract information about the b3dm header
    >>> b3dm_header = b3dm.header
    >>> b3dm_header
    <py3dtiles.tileset.content.b3dm.B3dmHeader object at 0x...>
    >>> b3dm_header.magic_value
    b'b3dm'
    >>> b3dm_header.tile_byte_length
    6328
    >>>
    >>> # extract the glTF
    >>> gltf = b3dm.body.gltf
    >>> gltf
    GLTF2(extensions={}, extras={}, accessors=[Accessor(extensions={}, extras={}, bufferView=0, byteOffset=0, componentType=5126, normalized=False, count=177, type='VEC3', sparse=None, max=[14.71875, 12.5, 10.149993896484375], min=[-20.03125, -16.0, -9.850006103515625], name=None), Accessor(extensions={}, extras={}, bufferView=1, byteOffset=0, componentType=5126, normalized=False, count=177, type='VEC3', sparse=None, max=[1.0, 1.0, 1.0], min=[-1.0, -1.0, -1.0], name=None), Accessor(extensions={}, extras={}, bufferView=2, byteOffset=0, componentType=5126, normalized=False, count=177, type='SCALAR', sparse=None, max=[2.0], min=[0.0], name=None)], animations=[], asset=Asset(extensions={}, extras={}, generator='py3dtiles', copyright=None, version='2.0', minVersion=None), bufferViews=[BufferView(extensions={}, extras={}, buffer=0, byteOffset=0, byteLength=2124, byteStride=None, target=34962, name=None), BufferView(extensions={}, extras={}, buffer=0, byteOffset=2128, byteLength=2124, byteStride=None, target=34962, name=None), BufferView(extensions={}, extras={}, buffer=0, byteOffset=4256, byteLength=708, byteStride=None, target=34962, name=None)], buffers=[Buffer(extensions={}, extras={}, uri=None, byteLength=4968)], cameras=[], extensionsUsed=[], extensionsRequired=[], images=[], materials=[Material(extensions={}, extras={}, pbrMetallicRoughness=PbrMetallicRoughness(extensions={}, extras={}, baseColorFactor=[1.0, 1.0, 1.0, 1.0], metallicFactor=0.0, roughnessFactor=0.0, baseColorTexture=None, metallicRoughnessTexture=None), normalTexture=None, occlusionTexture=None, emissiveFactor=[0.0, 0.0, 0.0], emissiveTexture=None, alphaMode='OPAQUE', alphaCutoff=0.5, doubleSided=False, name='Material0')], meshes=[Mesh(extensions={}, extras={}, primitives=[Primitive(extensions={}, extras={}, attributes=Attributes(POSITION=0, NORMAL=1, TANGENT=None, TEXCOORD_0=None, TEXCOORD_1=None, COLOR_0=None, JOINTS_0=None, WEIGHTS_0=None, _BATCHID=2), indices=None, mode=4, material=0, targets=[])], weights=[], name=None)], nodes=[Node(extensions={}, extras={}, mesh=0, skin=None, rotation=None, translation=None, scale=None, children=[], matrix=[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], camera=None, name=None)], samplers=[], scene=0, scenes=[Scene(extensions={}, extras={}, name=None, nodes=[0])], skins=[], textures=[])
    >>>
    >>> # display gltf header's asset field
    >>> gltf.asset
    Asset(extensions={}, extras={}, generator='py3dtiles', copyright=None, version='2.0', minVersion=None)

How to write a .b3dm file
^^^^^^^^^^^^^^^^^^^^^^^^^

By Hand
.......

Py3dtiles provides 2 helper classes to create gltf meshes and primitives more easily: `GltfMesh` and `GltfPrimitive`. They are intented for a more high-level process than creating pygltflib counterparts directly.

In a nutshell:

.. code-block:: python

    >>> from py3dtiles.tileset.content.gltf_utils import GltfMesh, GltfPrimitive, gltf_from_meshes
    >>> from pygltflib import Material, PbrMetallicRoughness
    >>> import numpy as np
    >>>
    >>> m1 = Material(pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=[1, 0, 0]))
    >>> m2 = Material(pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=[0, 1, 0]))
    >>>
    >>> p1 = GltfPrimitive(triangles=np.array([0, 1, 3], dtype=np.uint8), material=m1)
    >>> p2 = GltfPrimitive(triangles=np.array([1, 2, 3], dtype=np.uint8), material=m2)
    >>> mesh = GltfMesh(points=np.array([[0, 0, 0], [1, 0, 0], [1, 1, 1], [0, 0, 1]], dtype=np.float32), primitives=[p1, p2])
    >>>
    >>> gltf = gltf_from_meshes([mesh])
    >>> gltf.save("test.glb")
    True

.. image:: ./_static/img/glb.png

From Wkb
........

To write a Batched 3D Model file, you have to import the geometry from a wkb file containing
polyhedralsurfaces or multipolygons. For now, `py3dtiles` only supports `ISOWKB`_ format. It is of
user responsibility to check the input WKB format before using the `py3dtiles` API.

.. _ISOWKB: https://libgeos.org/specifications/wkb/#iso-wkb

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> import numpy as np
    >>>
    >>> from py3dtiles.tilers.b3dm.wkb_utils import TriangleSoup
    >>> from py3dtiles.tileset.content import B3dm
    >>>
    >>> # load a wkb file (ISO WKB format only)
    >>> wkb = open('tests/fixtures/building/building.wkb', 'rb').read()
    >>>
    >>> # define the geometry's bounding box
    >>> box = [[-8.75, -7.36, -2.05], [8.80, 7.30, 2.05]]
    >>>
    >>> # define the geometry's world transformation
    >>> transform = np.array([
    ...             [1, 0, 0, 1842015.125],
    ...             [0, 1, 0, 5177109.25],
    ...             [0, 0, 1, 247.87364196777344],
    ...             [0, 0, 0, 1]], dtype=float)
    >>>
    >>> # use the TriangleSoup helper class to transform the wkb into arrays
    >>> # of points and triangles
    >>> ts = TriangleSoup.from_wkb_multipolygon(wkb)
    >>> positions = ts.get_position_array()
    >>> normals = ts.get_normal_array()
    >>> # Create a b3dm directly from the arrays of geometries.
    >>> # It stores the data in GlTF format in the tile body.
    >>> tile_content = B3dm.from_numpy_arrays(
    ...     ts.vertices,
    ...     ts.triangle_indices,
    ...     normal=ts.compute_normals(),
    ...     transform=transform,
    ... )
    >>> # to save our tile content as a .b3dm file
    >>> tile_content.save_as(Path("mymodel.b3dm"))

Tiler tools
-----------

Here is an example of calling the conversion tool. An input CRS is needed as the `crs_out` parameter is specified. As the `.las` file contains this information, it is not necessary to specify it.

The CRS can be overwritten by specifying a value for the `crs_in` parameter and by setting the `force_crs_in` parameter to `True`.

In the snippet below, the number of jobs is set to 2. The main process will manage 2 processes that will read the laz file, transform and write the 3D Tiles.

.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> from pyproj import CRS
    >>>
    >>> from py3dtiles.convert import convert
    >>>
    >>> las_path = Path("tests/fixtures/with_srs_3857.las")
    >>>
    >>> convert(
    ...     las_path, # the Path to the file to convert, it can be a list of Path
    ...     outfolder=Path("3dtiles_output/"),
    ...     crs_out=CRS.from_epsg(4978),
    ...     jobs=2,
    ...     verbose=-1
    ... )
    >>>

Customization of the conversion process
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The conversion process can be customized by passing custom instances of tilers.

Instead of using the `convert` function, create an instance of the :class:`py3dtiles.convert.Converter` class in the same module.


.. code-block:: python

    >>> from pathlib import Path
    >>>
    >>> from py3dtiles.convert import Converter
    >>> from py3dtiles.tilers.point.point_tiler import PointTiler
    >>>
    >>> las_path = Path("tests/fixtures/with_srs_3857.las")
    >>>
    >>> # For demo purpose, we'll inherit PointTiler
    >>> class MyPointTiler(PointTiler):
    ...     def initialize(self, *args, **kwargs) -> None:
    ...         print("Custom initialization")
    ...         super().initialize(*args, **kwargs)
    >>>
    >>> my_point_tiler = MyPointTiler(verbosity=-1)
    >>> converter = Converter([my_point_tiler])
    >>> # let's check if the tiler is indeed used
    >>> converter.convert(las_path, Path("3dtiles_output/"), overwrite=True)
    Custom initialization

Writing a custom tiler is an involved operation. Do it only if you wish complete control over how tiles are generated or if you want to support a whole different type of data currently supported by py3dtiles. The :class:`py3dtiles.tilers.base_tiler.Tiler` documentation details what is expected of a custom ``Tiler`` instance.
