Source code for py3dtiles.tileset.metadata
"""Description of the 3DTiles metadata, as denoted in
https://docs.ogc.org/cs/22-025r4/22-025r4.html#toc40.
"""
import string
from abc import ABC, abstractmethod
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, Union
import numpy as np
import numpy.typing as npt
from py3dtiles.exceptions import InvalidIdentifierException
MetadataComponentTypeLiteral = Literal[
"INT8",
"UINT8",
"INT16",
"UINT16",
"INT32",
"UINT32",
"INT64",
"UINT64",
"FLOAT32",
"FLOAT64",
]
MetadataNumpyComponentType = Union[
np.int8,
np.uint8,
np.int16,
np.uint16,
np.int32,
np.uint32,
np.int64,
np.uint64,
np.float32,
np.float64,
]
MetadataNumpyEnumType = Union[
np.int8,
np.uint8,
np.int16,
np.uint16,
np.int32,
np.uint32,
np.int64,
np.uint64,
]
CompositeMetadataPropertyTypeLiteral = Literal[
"SCALAR",
"VEC2",
"VEC3",
"VEC4",
"MAT2",
"MAT3",
"MAT4",
]
SimpleMetadataPropertyTypeLiteral = Literal["BOOLEAN", "STRING"]
EnumMetadataPropertyTypeLiteral = Literal["ENUM"]
[docs]
def is_identifier_character_valid(char: str, first_char: bool = True) -> bool:
"""Check if an identifier character is valid.
A metadata identifier should contains (lowercase or uppercase) letters, underscores
:param char: a character within a metadata identifier.
:param first_char: True if this is the starting character, False otherwise.
"""
return (char == "_" or char in string.ascii_letters) or (
char in string.digits and not first_char
)
[docs]
def check_identifier_validity(identifier: str) -> str:
"""Check if an identifier is valid relatively to the 3DTiles standard.
Identifiers are strings that match the regular expression ^[a-zA-Z_][a-zA-Z0-9_]*$: Strings
that consist of upper- or lowercase letters, digits, or underscores, starting with either a
letter or an underscore.
:param identifier: metadata identifier.
:returns: the identifier itself, if it is valid
:raises InvalidIdentifierException: if the provided identifier does not respect the naming
convention.
"""
if not all(
is_identifier_character_valid(char, idx == 0)
for idx, char in enumerate(identifier)
):
raise InvalidIdentifierException(
f"The identifier '{identifier}' does not respect the naming convention."
)
return identifier
[docs]
@dataclass
class MetadataEnum:
"""Define a 3DTile metadata enum."""
values: dict[str, MetadataNumpyEnumType] = field(default_factory=dict)
name: str | None = None
description: str | None = None
[docs]
def to_json(self) -> dict[str, Any]:
"""Convert the metadata enum to a JSON-like dictionary.
:returns: Dictionary version of the metadata enum.
"""
enum_as_json: dict[str, Any] = {}
if self.name is not None and self.name:
enum_as_json["name"] = self.name
if self.description is not None and self.description:
enum_as_json["description"] = self.description
enum_as_json["values"] = [
{"name": key, "value": value} for key, value in self.values.items()
]
return enum_as_json
[docs]
@dataclass
class MetadataProperty(ABC):
"""Define a 3DTiles metadata property."""
property_type: (
CompositeMetadataPropertyTypeLiteral
| SimpleMetadataPropertyTypeLiteral
| EnumMetadataPropertyTypeLiteral
)
_: KW_ONLY
name: str | None = None
description: str | None = None
array: bool = False
required: bool = False
offset: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
scale: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
minimum: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
maximum: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
nodata: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
default: (
MetadataNumpyComponentType | npt.NDArray[MetadataNumpyComponentType] | None
) = None
[docs]
@dataclass
class CompositeMetadataProperty(MetadataProperty):
"""Define a 3DTiles metadata property for SCALAR, VEC and MAT types."""
property_type: CompositeMetadataPropertyTypeLiteral
component_type: MetadataComponentTypeLiteral
[docs]
def to_json(self) -> dict[str, Any]:
"""Convert the property to a JSON-like dictionary.
:returns: Dictionary version of the metadata property.
"""
property_as_json: dict[str, Any] = {"type": self.property_type}
property_as_json["componentType"] = self.component_type
property_as_json.update(
{
"noData" if key == "nodata" else key: value
for key, value in vars(self).items()
if key not in ("identifier", "property_type", "component_type")
and value is not None
and value
}
)
return property_as_json
[docs]
@dataclass
class EnumMetadataProperty(MetadataProperty):
"""Define a 3DTiles metadata property for ENUM types."""
enum_type: str
property_type: EnumMetadataPropertyTypeLiteral = field(default="ENUM", init=False)
[docs]
def to_json(self) -> dict[str, Any]:
"""Convert the property to a JSON-like dictionary.
:returns: Dictionary version of the metadata property.
"""
property_as_json: dict[str, Any] = {"type": self.property_type}
property_as_json["enumType"] = self.enum_type
property_as_json.update(
{
"noData" if key == "nodata" else key: value
for key, value in vars(self).items()
if key not in ("identifier", "property_type", "enum_type")
and value is not None
and value
}
)
return property_as_json
[docs]
@dataclass
class SimpleMetadataProperty(MetadataProperty):
"""Define a 3DTiles metadata property for simple types (BOOLEAN, STRING)."""
property_type: SimpleMetadataPropertyTypeLiteral
[docs]
def to_json(self) -> dict[str, Any]:
"""Convert the property to a JSON-like dictionary.
:returns: Dictionary version of the metadata property.
"""
property_as_json: dict[str, Any] = {"type": self.property_type}
property_as_json.update(
{
"noData" if key == "nodata" else key: value
for key, value in vars(self).items()
if key not in ("identifier", "property_type")
and value is not None
and value
}
)
return property_as_json
[docs]
@dataclass
class MetadataClass:
"""Define a 3DTiles metadata class, composed of enums and properties."""
name: str | None = None
description: str | None = None
properties: dict[str, MetadataProperty] = field(default_factory=dict)
[docs]
def add_property(self, identifier: str, new_property: MetadataProperty) -> None:
"""Add a new property to the metadata class.
:param identifier: ID of the metadata property within the metadata class.
:param new_property: Property to add.
"""
identifier = check_identifier_validity(identifier)
self.properties[identifier] = new_property
[docs]
def to_json(self) -> dict[str, Any]:
"""Convert the metadata class to a JSON-like dictionary.
:returns: Dictionary version of the metadata class.
"""
class_as_json: dict[str, Any] = {}
if self.name is not None and self.name:
class_as_json["name"] = self.name
if self.description is not None and self.description:
class_as_json["description"] = self.description
if len(self.properties) > 0:
class_as_json["properties"] = {
property_id: class_property.to_json()
for property_id, class_property in self.properties.items()
}
return class_as_json
[docs]
@dataclass
class MetadataSchema:
"""Contient un ensemble de classes et enums."""
identifier: str
name: str | None = None
version: str | None = None
description: str | None = None
enums: dict[str, MetadataEnum] = field(default_factory=dict)
classes: dict[str, MetadataClass] = field(default_factory=dict)
def __post_init__(self) -> None:
self.identifier = check_identifier_validity(self.identifier)
[docs]
def add_enum(self, identifier: str, enum: MetadataEnum) -> None:
identifier = check_identifier_validity(identifier)
self.enums[identifier] = enum
[docs]
def add_class(self, identifier: str, cls: MetadataClass) -> None:
identifier = check_identifier_validity(identifier)
for property_identifier, class_property in cls.properties.items():
if (
isinstance(class_property, EnumMetadataProperty)
and class_property.enum_type not in self.enums.keys()
):
raise KeyError(
f"The {property_identifier} property in the provided class "
f"uses an unknown enum types ({class_property.enum_type})."
)
self.classes[identifier] = cls
[docs]
def to_json(self) -> dict[str, Any]:
schema_as_json: dict[str, Any] = {}
if self.name is not None and self.name:
schema_as_json["name"] = self.name
if self.description is not None and self.description:
schema_as_json["description"] = self.description
if self.version is not None and self.version:
schema_as_json["version"] = self.version
if len(self.classes) > 0:
schema_as_json["classes"] = {
class_id: schema_class.to_json()
for class_id, schema_class in self.classes.items()
}
if len(self.enums) > 0:
schema_as_json["enums"] = {
enum_id: schema_enum.to_json()
for enum_id, schema_enum in self.enums.items()
}
return schema_as_json