From 181d62c43805a474989d802180dcc03dbfc2134e Mon Sep 17 00:00:00 2001 From: Ben Lewis <7391596+BenLewis-Seequent@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:50:20 +1300 Subject: [PATCH] Add support for triangle mesh and allow casting if the types don't exactly match the table format. --- .../docs/examples/typed-objects.ipynb | 101 +++++- .../src/evo/objects/typed/__init__.py | 10 +- .../src/evo/objects/typed/_data.py | 4 +- .../src/evo/objects/typed/triangle_mesh.py | 228 +++++++++++++ .../evo-objects/src/evo/objects/utils/data.py | 42 ++- .../src/evo/objects/utils/tables.py | 36 ++ .../evo-objects/tests/test_data_client.py | 103 +++++- packages/evo-objects/tests/test_tables.py | 137 ++++++++ .../tests/typed/test_triangle_mesh.py | 313 ++++++++++++++++++ 9 files changed, 946 insertions(+), 28 deletions(-) create mode 100644 packages/evo-objects/src/evo/objects/typed/triangle_mesh.py create mode 100644 packages/evo-objects/tests/typed/test_triangle_mesh.py diff --git a/packages/evo-objects/docs/examples/typed-objects.ipynb b/packages/evo-objects/docs/examples/typed-objects.ipynb index 5d056867..866a431f 100644 --- a/packages/evo-objects/docs/examples/typed-objects.ipynb +++ b/packages/evo-objects/docs/examples/typed-objects.ipynb @@ -270,6 +270,91 @@ "cell_type": "markdown", "id": "20", "metadata": {}, + "source": [ + "## Creating a TriangleMesh object\n", + "\n", + "To create a new 'triangle-mesh' object, we use the `TriangleMeshData` class. A triangle mesh is defined by:\n", + "- `vertices`: A DataFrame with 'x', 'y', 'z' columns for vertex coordinates, plus any vertex attributes\n", + "- `triangles`: A DataFrame with 'n0', 'n1', 'n2' columns containing 0-based vertex indices that define each triangle, plus any triangle attributes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.objects.typed import TriangleMesh, TriangleMeshData\n", + "\n", + "# Create a simple pyramid mesh: 4 triangular faces with a square base\n", + "vertices_df = pd.DataFrame(\n", + " {\n", + " \"x\": [0.0, 10.0, 10.0, 0.0, 5.0], # 4 base vertices + 1 apex\n", + " \"y\": [0.0, 0.0, 10.0, 10.0, 5.0],\n", + " \"z\": [0.0, 0.0, 0.0, 0.0, 8.0],\n", + " \"vertex_id\": [0, 1, 2, 3, 4], # Example vertex attribute\n", + " }\n", + ")\n", + "\n", + "# Define the triangles using vertex indices\n", + "triangles_df = pd.DataFrame(\n", + " {\n", + " \"n0\": [0, 1, 2, 3, 0, 0], # First vertex of each triangle\n", + " \"n1\": [1, 2, 3, 0, 2, 3], # Second vertex of each triangle\n", + " \"n2\": [4, 4, 4, 4, 1, 2], # Third vertex of each triangle\n", + " \"face_type\": pd.Categorical.from_codes([0, 0, 0, 0, 1, 1], [\"side\", \"base\"]), # Example triangle attribute\n", + " }\n", + ")\n", + "\n", + "mesh_data = TriangleMeshData(\n", + " name=\"Test Pyramid Mesh 20240601_123456_2\",\n", + " vertices=vertices_df,\n", + " triangles=triangles_df,\n", + ")\n", + "\n", + "created_mesh = await TriangleMesh.create(manager, mesh_data)\n", + "print(f\"Created triangle-mesh object: {created_mesh.metadata.url}\")" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## Downloading a TriangleMesh object\n", + "\n", + "To download an existing 'triangle-mesh' object, use the `TriangleMesh.from_reference` class method. You can then access the vertices, triangles, and their attributes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "mesh = await TriangleMesh.from_reference(manager, created_mesh.metadata.url)\n", + "\n", + "print(f\"Downloaded triangle-mesh object with name: {mesh.name}\")\n", + "print(f\"Number of vertices: {mesh.num_vertices}\")\n", + "print(f\"Number of triangles: {mesh.num_triangles}\")\n", + "print(f\"Bounding Box: {mesh.bounding_box}\")\n", + "\n", + "# Get vertices and triangles as DataFrames\n", + "vertices_df = await mesh.triangles.get_vertices_dataframe()\n", + "triangles_df = await mesh.triangles.get_indices_dataframe()\n", + "\n", + "print(f\"\\nVertices DataFrame shape: {vertices_df.shape}\")\n", + "print(f\"Triangles DataFrame shape: {triangles_df.shape}\")\n", + "\n", + "vertices_df.head()" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, "source": [ "## Creating a regular masked 3D grid object\n", "\n", @@ -281,7 +366,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -315,7 +400,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "26", "metadata": {}, "source": [ "### Downloading and inspecting a masked grid\n", @@ -326,7 +411,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -347,7 +432,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "28", "metadata": {}, "source": [ "## Creating a tensor 3D grid object\n", @@ -360,7 +445,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -404,7 +489,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "30", "metadata": {}, "source": [ "### Downloading and inspecting a tensor grid\n", @@ -415,7 +500,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -433,7 +518,7 @@ { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "32", "metadata": {}, "outputs": [], "source": [ diff --git a/packages/evo-objects/src/evo/objects/typed/__init__.py b/packages/evo-objects/src/evo/objects/typed/__init__.py index 15029c84..8138b39a 100644 --- a/packages/evo-objects/src/evo/objects/typed/__init__.py +++ b/packages/evo-objects/src/evo/objects/typed/__init__.py @@ -12,7 +12,6 @@ from .attributes import Attribute, Attributes from .base import BaseObject from .pointset import ( - Locations, PointSet, PointSetData, ) @@ -21,7 +20,6 @@ Regular3DGridData, ) from .regular_masked_grid import ( - MaskedCells, RegularMasked3DGrid, RegularMasked3DGridData, ) @@ -30,6 +28,10 @@ Tensor3DGrid, Tensor3DGridData, ) +from .triangle_mesh import ( + TriangleMesh, + TriangleMeshData, +) from .types import BoundingBox, CoordinateReferenceSystem, EpsgCode, Point3, Rotation, Size3d, Size3i __all__ = [ @@ -40,8 +42,6 @@ "BoundingBox", "CoordinateReferenceSystem", "EpsgCode", - "Locations", - "MaskedCells", "Point3", "PointSet", "PointSetData", @@ -54,4 +54,6 @@ "Size3i", "Tensor3DGrid", "Tensor3DGridData", + "TriangleMesh", + "TriangleMeshData", ] diff --git a/packages/evo-objects/src/evo/objects/typed/_data.py b/packages/evo-objects/src/evo/objects/typed/_data.py index 68b6ad7e..deb7266d 100644 --- a/packages/evo-objects/src/evo/objects/typed/_data.py +++ b/packages/evo-objects/src/evo/objects/typed/_data.py @@ -55,7 +55,9 @@ async def _data_to_schema(cls, data: Any, context: IContext, fb: IFeedback = NoF ) data_client = get_data_client(context) - return await data_client.upload_dataframe(data, table_format=cls.table_format, fb=fb) + return await data_client.upload_dataframe( + data, table_format=cls.table_format, fb=fb, allow_casting=cls.table_format is not None + ) class DataTableAndAttributes(SchemaModel): diff --git a/packages/evo-objects/src/evo/objects/typed/triangle_mesh.py b/packages/evo-objects/src/evo/objects/typed/triangle_mesh.py new file mode 100644 index 00000000..2c52d8c2 --- /dev/null +++ b/packages/evo-objects/src/evo/objects/typed/triangle_mesh.py @@ -0,0 +1,228 @@ +# Copyright © 2025 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated, Any, ClassVar + +import pandas as pd + +from evo.common.interfaces import IContext, IFeedback +from evo.common.utils import NoFeedback +from evo.objects import SchemaVersion +from evo.objects.utils.table_formats import FLOAT_ARRAY_3, INDEX_ARRAY_3, KnownTableFormat + +from ._data import DataTable, DataTableAndAttributes +from ._model import DataLocation, SchemaBuilder, SchemaLocation, SchemaModel +from .exceptions import ObjectValidationError +from .spatial import BaseSpatialObject, BaseSpatialObjectData +from .types import BoundingBox + +__all__ = [ + "Indices", + "TriangleMesh", + "TriangleMeshData", + "Triangles", + "Vertices", +] + +_X = "x" +_Y = "y" +_Z = "z" +_VERTEX_COLUMNS = [_X, _Y, _Z] + +_N0 = "n0" +_N1 = "n1" +_N2 = "n2" +_INDEX_COLUMNS = [_N0, _N1, _N2] + + +def _bounding_box_from_dataframe(df: pd.DataFrame) -> BoundingBox: + return BoundingBox.from_points( + df[_X].values, + df[_Y].values, + df[_Z].values, + ) + + +@dataclass(kw_only=True, frozen=True) +class TriangleMeshData(BaseSpatialObjectData): + """Data class for creating a new TriangleMesh object. + + :param name: The name of the object. + :param vertices: A DataFrame containing the vertex data. Must have 'x', 'y', 'z' columns for coordinates. + Any additional columns will be treated as vertex attributes. + :param triangles: A DataFrame containing the triangle indices. Must have 'n0', 'n1', 'n2' columns + as 0-based indices into the vertices. Any additional columns will be treated as triangle attributes. + :param coordinate_reference_system: Optional EPSG code or WKT string for the coordinate reference system. + :param description: Optional description of the object. + :param tags: Optional dictionary of tags for the object. + :param extensions: Optional dictionary of extensions for the object. + """ + + vertices: pd.DataFrame + triangles: pd.DataFrame + + def __post_init__(self): + missing_vertex_cols = set(_VERTEX_COLUMNS) - set(self.vertices.columns) + if missing_vertex_cols: + raise ObjectValidationError( + f"vertices DataFrame must have 'x', 'y', 'z' columns. Missing: {missing_vertex_cols}" + ) + + missing_index_cols = set(_INDEX_COLUMNS) - set(self.triangles.columns) + if missing_index_cols: + raise ObjectValidationError( + f"triangles DataFrame must have 'n0', 'n1', 'n2' columns. Missing: {missing_index_cols}" + ) + + # Validate that triangle indices are within valid range + max_index = self.triangles[_INDEX_COLUMNS].max().max() + num_vertices = len(self.vertices) + if max_index >= num_vertices: + raise ObjectValidationError( + f"Triangle indices reference vertex index {max_index}, but only {num_vertices} vertices exist." + ) + + def compute_bounding_box(self) -> BoundingBox: + return _bounding_box_from_dataframe(self.vertices) + + +class VertexCoordinateTable(DataTable): + """DataTable subclass for vertex coordinates with x, y, z columns.""" + + table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3 + data_columns: ClassVar[list[str]] = _VERTEX_COLUMNS + + async def set_dataframe(self, df: pd.DataFrame, fb: IFeedback = NoFeedback): + """Update the vertex coordinate values and recalculate the bounding box. + + :param df: DataFrame containing x, y, z coordinate columns. + :param fb: Optional feedback object to report upload progress. + """ + await super().set_dataframe(df, fb) + + # Update the bounding box in the parent object context + self._context.root_model.bounding_box = _bounding_box_from_dataframe(df) + + +class TriangleIndexTable(DataTable): + """DataTable subclass for triangle indices with n0, n1, n2 columns.""" + + table_format: ClassVar[KnownTableFormat] = INDEX_ARRAY_3 + data_columns: ClassVar[list[str]] = _INDEX_COLUMNS + + +class Vertices(DataTableAndAttributes): + """A dataset representing the vertices of a TriangleMesh. + + Contains the coordinates of each vertex and optional attributes. + """ + + _table: Annotated[VertexCoordinateTable, SchemaLocation("")] + + +class Indices(DataTableAndAttributes): + """A dataset representing the triangle indices of a TriangleMesh. + + Contains indices into the vertex list defining triangles and optional attributes. + """ + + _table: Annotated[TriangleIndexTable, SchemaLocation("")] + + +@dataclass(kw_only=True, frozen=True) +class _TrianglesData: + """Internal data class for the triangles component.""" + + vertices: pd.DataFrame + triangles: pd.DataFrame + + +class Triangles(SchemaModel): + """A dataset representing the triangles of a TriangleMesh. + + This is the top-level container for the triangles component of the mesh, + containing both vertices and triangle indices. + """ + + vertices: Annotated[Vertices, SchemaLocation("vertices"), DataLocation("vertices")] + indices: Annotated[Indices, SchemaLocation("indices"), DataLocation("triangles")] + + @property + def num_vertices(self) -> int: + """The number of vertices in this mesh.""" + return self.vertices.length + + @property + def num_triangles(self) -> int: + """The number of triangles in this mesh.""" + return self.indices.length + + async def get_vertices_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame: + """Load a DataFrame containing the vertex coordinates and attributes. + + :param fb: Optional feedback object to report download progress. + :return: DataFrame with x, y, z coordinates and additional columns for attributes. + """ + return await self.vertices.get_dataframe(fb=fb) + + async def get_indices_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame: + """Load a DataFrame containing the triangle indices and attributes. + + :param fb: Optional feedback object to report download progress. + :return: DataFrame with n0, n1, n2 indices and additional columns for attributes. + """ + return await self.indices.get_dataframe(fb=fb) + + @classmethod + async def _data_to_schema(cls, data: Any, context: IContext) -> Any: + """Convert triangles data to schema format.""" + builder = SchemaBuilder(cls, context) + await builder.set_sub_model_value("vertices", data.vertices) + await builder.set_sub_model_value("indices", data.triangles) + return builder.document + + +class TriangleMesh(BaseSpatialObject): + """A GeoscienceObject representing a mesh composed of triangles. + + The triangles are defined by triplets of indices into a vertex list. + The object contains a triangles dataset with vertices, indices, and optional attributes + for both vertices and triangles. + """ + + _data_class = TriangleMeshData + + sub_classification = "triangle-mesh" + creation_schema_version = SchemaVersion(major=2, minor=2, patch=0) + + triangles: Annotated[Triangles, SchemaLocation("triangles")] + + @classmethod + async def _data_to_schema(cls, data: TriangleMeshData, context: IContext) -> dict[str, Any]: + """Create an object dictionary suitable for creating a new Geoscience Object.""" + object_dict = await super()._data_to_schema(data, context) + # Create the triangles data structure + triangles_data = _TrianglesData(vertices=data.vertices, triangles=data.triangles) + object_dict["triangles"] = await Triangles._data_to_schema(triangles_data, context) + return object_dict + + @property + def num_vertices(self) -> int: + """The number of vertices in this mesh.""" + return self.triangles.num_vertices + + @property + def num_triangles(self) -> int: + """The number of triangles in this mesh.""" + return self.triangles.num_triangles diff --git a/packages/evo-objects/src/evo/objects/utils/data.py b/packages/evo-objects/src/evo/objects/utils/data.py index 8deb9861..13b59c3a 100644 --- a/packages/evo-objects/src/evo/objects/utils/data.py +++ b/packages/evo-objects/src/evo/objects/utils/data.py @@ -132,7 +132,10 @@ async def upload_referenced_data(self, object_model: dict, fb: IFeedback = NoFee fb.progress(1) def save_table( - self, table: pa.Table, table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None + self, + table: pa.Table, + table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None, + allow_casting: bool = False, ) -> dict: """Save a pyarrow table to a file, returning the table info as a dictionary. @@ -141,16 +144,24 @@ def save_table( If a single KnownTableFormat is provided, that format will be used. Otherwise, the format will be inferred from the table, either by checking against the provided formats or by checking against all known formats. + :param allow_casting: Whether to allow casting the table to match the expected format. Casting will fail if there + is any data loss during the cast operation. This parameter can only be used when a single table_format is provided, + otherwise a ValueError will be raised. :return: Information about the saved table. :raises TableFormatError: If the provided table does not match any of the specified formats. If no table formats are specified, raised when the table does not match any known format. :raises StorageFileNotFoundError: If the destination does not exist or is not a directory. + :raises ValueError: If allow_casting is True and multiple table formats are provided. """ from .table_formats import get_known_format if not isinstance(table_format, KnownTableFormat): + if allow_casting: + raise ValueError("allow_casting can only be used when a single table_format is provided") table_format = get_known_format(table, table_formats=table_format) + if allow_casting: + table = table_format.cast_table(table) table_info = table_format.save_table(table=table, destination=self.cache_location) return table_info @@ -159,6 +170,7 @@ async def upload_table( table: pa.Table, fb: IFeedback = NoFeedback, table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None, + allow_casting: bool = False, ) -> dict: """Upload pyarrow table to the geoscience object service, returning a GO model of the uploaded data. @@ -168,13 +180,17 @@ async def upload_table( If a single KnownTableFormat is provided, that format will be used. Otherwise, the format will be inferred from the table, either by checking against the provided formats or by checking against all known formats. + :param allow_casting: Whether to allow casting the table to match the expected format. Casting will fail if there + is any data loss during the cast operation. This parameter can only be used when a single table_format is provided, + otherwise a ValueError will be raised. :return: A description of the uploaded data. :raises TableFormatError: If the provided table does not match any of the specified formats. If no table formats are specified, raised when the table does not match any known format. + :raises ValueError: If allow_casting is True and multiple table formats are provided. """ - table_info = self.save_table(table, table_format) + table_info = self.save_table(table, table_format, allow_casting=allow_casting) upload = ObjectDataUpload(connector=self._connector, environment=self._environment, name=table_info["data"]) try: await upload.upload_from_cache(cache=self._cache, transport=self._connector.transport, fb=fb) @@ -282,7 +298,10 @@ async def download_table( # Optional support for pandas dataframes. Depends on both pyarrow and pandas. def save_dataframe( - self, dataframe: pd.DataFrame, table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None + self, + dataframe: pd.DataFrame, + table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None, + allow_casting: bool = False, ) -> dict: """Save a pandas dataframe to a file, returning the table info as a dictionary. @@ -291,20 +310,27 @@ def save_dataframe( If a single KnownTableFormat is provided, that format will be used. Otherwise, the format will be inferred from the table, either by checking against the provided formats or by checking against all known formats. + :param allow_casting: Whether to allow casting the table to match the expected format. Casting will fail if there + is any data loss during the cast operation. This parameter can only be used when a single table_format is provided, + otherwise a ValueError will be raised. :return: Information about the saved table. :raises TableFormatError: If the provided table does not match any of the specified formats. If no table formats are specified, raised when the table does not match any known format. :raises StorageFileNotFoundError: If the destination does not exist or is not a directory. + :raises ValueError: If allow_casting is True and multiple table formats are provided. """ - return self.save_table(pa.Table.from_pandas(dataframe), table_format=table_format) + return self.save_table( + pa.Table.from_pandas(dataframe), table_format=table_format, allow_casting=allow_casting + ) async def upload_dataframe( self, dataframe: pd.DataFrame, fb: IFeedback = NoFeedback, table_format: KnownTableFormat | Iterable[KnownTableFormat] | None = None, + allow_casting: bool = False, ) -> dict: """Upload pandas dataframe to the geoscience object service, returning a GO model of the uploaded data. @@ -314,13 +340,19 @@ async def upload_dataframe( If a single KnownTableFormat is provided, that format will be used. Otherwise, the format will be inferred from the table, either by checking against the provided formats or by checking against all known formats. + :param allow_casting: Whether to allow casting the table to match the expected format. Casting will fail if there + is any data loss during the cast operation. This parameter can only be used when a single table_format is provided, + otherwise a ValueError will be raised. :return: A description of the uploaded data. :raises TableFormatError: If the provided table does not match any of the specified formats. If no table formats are specified, raised when the table does not match any known format. + :raises ValueError: If allow_casting is True and multiple table formats are provided. """ - table_info = await self.upload_table(pa.Table.from_pandas(dataframe), table_format=table_format, fb=fb) + table_info = await self.upload_table( + pa.Table.from_pandas(dataframe), table_format=table_format, fb=fb, allow_casting=allow_casting + ) return table_info async def upload_category_dataframe(self, dataframe: pd.DataFrame, fb: IFeedback = NoFeedback) -> CategoryInfo: diff --git a/packages/evo-objects/src/evo/objects/utils/tables.py b/packages/evo-objects/src/evo/objects/utils/tables.py index baae199a..488fa3ea 100644 --- a/packages/evo-objects/src/evo/objects/utils/tables.py +++ b/packages/evo-objects/src/evo/objects/utils/tables.py @@ -391,3 +391,39 @@ def load_table(cls, table_info: dict, source: Path) -> pa.Table: except (TableFormatError, SchemaValidationError) as error: logger.error(f"Could not load table because {error}") raise + + def cast_table(self, table: pa.Table) -> pa.Table: + """Cast the columns of a pyarrow table to match this format. + + Casting will fail if there is any data loss during the cast operation. + + :param table: The table to cast. + + :return: A new pyarrow table with columns cast to match this format. + + :raises TableFormatError: If the provided table cannot be cast to this format. + """ + if not self._multi_dimensional and self.width != table.num_columns: + raise TableFormatError( + f"Column count ({table.num_columns}) does not match expectation ({self.width}) for {self.name}" + ) + + # Attempt to cast each column individually. + cast_columns = [] + for column_index in range(table.num_columns): + column = table.column(column_index) + expected_type = self._columns[ + 0 if self._multi_dimensional else column_index + ].type # Use first column type for multi-dimensional formats. + + # Prevent float to int casting, as this will fail most of the time due to data loss. + if not pa.types.is_floating(expected_type) and pa.types.is_floating(column.type): + raise TableFormatError(f"Cannot cast floating point column {column_index} to type '{expected_type}'") + + try: + cast_column = column.cast(expected_type) + except pa.ArrowInvalid as error: + raise TableFormatError(f"Could not cast column {column_index} to type '{expected_type}'") from error + cast_columns.append(cast_column) + + return pa.table(cast_columns, names=table.schema.names) diff --git a/packages/evo-objects/tests/test_data_client.py b/packages/evo-objects/tests/test_data_client.py index a12d2441..a888f65d 100644 --- a/packages/evo-objects/tests/test_data_client.py +++ b/packages/evo-objects/tests/test_data_client.py @@ -9,6 +9,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import json from unittest import mock from uuid import UUID @@ -68,6 +69,10 @@ def test_save_table(self, _name: str, pass_table_format: bool) -> None: mock_get_known_format.assert_not_called() else: mock_get_known_format.assert_called_once_with(mock_table, table_formats=None) + + # Verify cast_table was NOT called(allow_casting=False by default) + mock_known_format.cast_table.assert_not_called() + mock_known_format.save_table.assert_called_once_with( table=mock_table, destination=self.data_client.cache_location ) @@ -101,6 +106,40 @@ def save_table_with_table_format_list(self) -> None: self.transport.assert_no_requests() self.assertIs(mock_table_info, actual_table_info) + def test_save_table_with_allow_casting_true(self) -> None: + """Test saving a table with allow_casting=True casts the table before saving.""" + with mock.patch("evo.common.io.upload.StorageDestination"): + sample_table, _ = get_sample_table_and_bytes(table_formats.INTEGER_ARRAY_1_INT32, 1) + schema = pa.schema([pa.field(sample_table.schema.names[0], pa.int64())]) + casted_table = sample_table.cast(schema) + + # To allow isinstance checks to work, replace the method on a copy of the known format. + mock_known_format = copy.copy(table_formats.INTEGER_ARRAY_1_INT64) + mock_known_format.save_table = mock.Mock(return_value={}) + + self.data_client.save_table(sample_table, table_format=mock_known_format, allow_casting=True) + + # Verify save_table was called with the casted table + mock_known_format.save_table.assert_called_once_with( + table=casted_table, destination=self.data_client.cache_location + ) + + def test_save_table_with_allow_casting_without_single_format_raises(self) -> None: + """Test that allow_casting=True raises ValueError when not providing a single table format.""" + mock_table = mock.Mock() + + # Test with no table format + with self.assertRaises(ValueError) as ctx: + self.data_client.save_table(mock_table, table_format=None, allow_casting=True) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + + # Test with multiple table formats + with self.assertRaises(ValueError) as ctx: + self.data_client.save_table( + mock_table, table_format=[table_formats.FLOAT_ARRAY_2, table_formats.FLOAT_ARRAY_3], allow_casting=True + ) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + @parameterized.expand( [ ("with_explicit_table_format", True), @@ -199,11 +238,12 @@ async def _mock_upload_file_side_effect(*args, **kwargs): @parameterized.expand( [ - ("with_explicit_table_format", True), - ("with_implicit_table_format", False), + ("with_explicit_table_format_no_cast", True, False), + ("with_implicit_table_format_no_cast", False, False), + ("with_explicit_table_format", True, True), ] ) - async def test_upload_table(self, _name: str, pass_table_format: bool) -> None: + async def test_upload_table(self, _name: str, pass_table_format: bool, cast: bool) -> None: """Test uploading tabular data using pyarrow or pandas.""" put_data_response = load_test_data("put_data.json") with ( @@ -213,6 +253,8 @@ async def test_upload_table(self, _name: str, pass_table_format: bool) -> None: ): mock_table = mock.Mock() mock_get_known_format.return_value = mock_known_format = mock.Mock(spec=KnownTableFormat) + mock_casted_table = mock.Mock() + mock_known_format.cast_table.return_value = mock_casted_table mock_known_format.save_table.return_value = mock_table_info = {} mock_table_info["data"] = mock_data_id = put_data_response[0]["name"] @@ -228,7 +270,9 @@ async def _mock_upload_file_side_effect(*args, **kwargs): mock_destination.upload_file.side_effect = _mock_upload_file_side_effect if pass_table_format: - actual_table_info = await self.data_client.upload_table(mock_table, table_format=mock_known_format) + actual_table_info = await self.data_client.upload_table( + mock_table, table_format=mock_known_format, allow_casting=cast + ) else: actual_table_info = await self.data_client.upload_table(mock_table) @@ -237,7 +281,7 @@ async def _mock_upload_file_side_effect(*args, **kwargs): else: mock_get_known_format.assert_called_once_with(mock_table, table_formats=None) mock_known_format.save_table.assert_called_once_with( - table=mock_table, destination=self.data_client.cache_location + table=mock_casted_table if cast else mock_table, destination=self.data_client.cache_location ) mock_destination.upload_file.assert_called_once() self.assert_request_made( @@ -250,11 +294,12 @@ async def _mock_upload_file_side_effect(*args, **kwargs): @parameterized.expand( [ - ("with_explicit_table_format", True), - ("with_implicit_table_format", False), + ("with_explicit_table_format_no_cast", True, False), + ("with_implicit_table_format_no_cast", False, False), + ("with_explicit_table_format", True, True), ] ) - async def test_upload_dataframe(self, _name: str, pass_table_format: bool) -> None: + async def test_upload_dataframe(self, _name: str, pass_table_format: bool, cast: bool) -> None: """Test uploading tabular data using pyarrow or pandas.""" put_data_response = load_test_data("put_data.json") with ( @@ -265,6 +310,8 @@ async def test_upload_dataframe(self, _name: str, pass_table_format: bool) -> No ): mock_pyarrow_table.from_pandas.return_value = mock_table = mock.Mock() mock_get_known_format.return_value = mock_known_format = mock.Mock(spec=KnownTableFormat) + mock_casted_table = mock.Mock() + mock_known_format.cast_table.return_value = mock_casted_table mock_known_format.save_table.return_value = mock_table_info = {} mock_table_info["data"] = mock_data_id = put_data_response[0]["name"] @@ -282,7 +329,7 @@ async def _mock_upload_file_side_effect(*args, **kwargs): mock_dataframe = mock.Mock() if pass_table_format: actual_table_info = await self.data_client.upload_dataframe( - mock_dataframe, table_format=mock_known_format + mock_dataframe, table_format=mock_known_format, allow_casting=cast ) else: actual_table_info = await self.data_client.upload_dataframe(mock_dataframe) @@ -292,7 +339,7 @@ async def _mock_upload_file_side_effect(*args, **kwargs): else: mock_get_known_format.assert_called_once_with(mock_table, table_formats=None) mock_known_format.save_table.assert_called_once_with( - table=mock_table, destination=self.data_client.cache_location + table=mock_casted_table if cast else mock_table, destination=self.data_client.cache_location ) mock_destination.upload_file.assert_called_once() self.assert_request_made( @@ -384,6 +431,42 @@ async def _mock_upload_file_side_effect(*args, **kwargs): ) self.assertIs(mock_table_info, actual_table_info) + async def test_upload_table_with_allow_casting_without_single_format_raises(self) -> None: + """Test that allow_casting=True raises ValueError when not providing a single table format.""" + mock_table = mock.Mock() + + # Test with no table format + with self.assertRaises(ValueError) as ctx: + await self.data_client.upload_table(mock_table, table_format=None, allow_casting=True) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + + # Test with multiple table formats + with self.assertRaises(ValueError) as ctx: + await self.data_client.upload_table( + mock_table, table_format=[table_formats.FLOAT_ARRAY_2, table_formats.FLOAT_ARRAY_3], allow_casting=True + ) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + + async def test_upload_dataframe_with_allow_casting_without_single_format_raises(self) -> None: + """Test that allow_casting=True raises ValueError when not providing a single table format.""" + with mock.patch("pyarrow.Table") as mock_pyarrow_table: + mock_dataframe = mock.Mock() + mock_pyarrow_table.from_pandas.return_value = mock.Mock() + + # Test with no table format + with self.assertRaises(ValueError) as ctx: + await self.data_client.upload_dataframe(mock_dataframe, table_format=None, allow_casting=True) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + + # Test with multiple table formats + with self.assertRaises(ValueError) as ctx: + await self.data_client.upload_dataframe( + mock_dataframe, + table_format=[table_formats.FLOAT_ARRAY_2, table_formats.FLOAT_ARRAY_3], + allow_casting=True, + ) + self.assertIn("allow_casting can only be used when a single table_format is provided", str(ctx.exception)) + @parameterized.expand( [ ( diff --git a/packages/evo-objects/tests/test_tables.py b/packages/evo-objects/tests/test_tables.py index 9a79d529..2c569d31 100644 --- a/packages/evo-objects/tests/test_tables.py +++ b/packages/evo-objects/tests/test_tables.py @@ -344,3 +344,140 @@ def test_save_table_different_column_types_fails(self) -> None: known_format.save_table(self.sample_table, self.data_dir) self.assertFalse(self.parquet_file.is_file()) + + +class TestCastTable(unittest.TestCase): + """Test the cast_table method of KnownTableFormat.""" + + def test_cast_int64_to_uint64_success(self) -> None: + """Test casting int64 to uint64 when values are non-negative.""" + # Create a table with int64 columns containing only non-negative values + table = pa.table( + { + "n0": pa.array([0, 1, 2, 3, 4], type=pa.int64()), + "n1": pa.array([1, 2, 3, 4, 5], type=pa.int64()), + "n2": pa.array([2, 3, 4, 5, 6], type=pa.int64()), + } + ) + + # Create a format that expects uint64 + target_format = KnownTableFormat( + name="test-format", columns=[pa.uint64(), pa.uint64(), pa.uint64()], field_names=None + ) + + # Cast should succeed + cast_table = target_format.cast_table(table) + + # Verify the cast table has the correct types + self.assertEqual(cast_table.num_columns, 3) + self.assertEqual(cast_table.num_rows, 5) + for column in cast_table.itercolumns(): + self.assertEqual(column.type, pa.uint64()) + + # Verify the values are preserved + self.assertEqual(cast_table.column("n0").to_pylist(), [0, 1, 2, 3, 4]) + self.assertEqual(cast_table.column("n1").to_pylist(), [1, 2, 3, 4, 5]) + self.assertEqual(cast_table.column("n2").to_pylist(), [2, 3, 4, 5, 6]) + + def test_cast_int64_to_uint64_negative_values_fails(self) -> None: + """Test that casting int64 with negative values to uint64 fails.""" + # Create a table with int64 columns containing negative values + table = pa.table( + { + "n0": pa.array([-1, 0, 1], type=pa.int64()), + "n1": pa.array([0, 1, 2], type=pa.int64()), + "n2": pa.array([1, 2, 3], type=pa.int64()), + } + ) + + # Create a format that expects uint64 + target_format = KnownTableFormat( + name="test-format", columns=[pa.uint64(), pa.uint64(), pa.uint64()], field_names=None + ) + + # Cast should fail due to negative value + with self.assertRaises(TableFormatError) as ctx: + target_format.cast_table(table) + + self.assertIn("Could not cast column 0 to type 'uint64'", str(ctx.exception)) + + def test_cast_float64_to_int(self) -> None: + """Test casting float64 to int32 fails.""" + # Create a table with float64 values + table = pa.table( + { + "col0": pa.array([1.0, 2.0, 3.0, 4.0], type=pa.float64()), + } + ) + + # Create a format that expects int32 + target_format = KnownTableFormat(name="test-format", columns=[pa.int32()], field_names=None) + + # Cast should fail, we specifically do not allow float to int casts + with self.assertRaises(TableFormatError) as ctx: + target_format.cast_table(table) + + self.assertIn("Cannot cast floating point column 0 to type 'int32'", str(ctx.exception)) + + def test_cast_multidimensional_format(self) -> None: + """Test casting with multidimensional formats.""" + # Create a table with int64 columns + table = pa.table( + { + "x": pa.array([0, 1, 2], type=pa.int64()), + "y": pa.array([3, 4, 5], type=pa.int64()), + "z": pa.array([6, 7, 8], type=pa.int64()), + } + ) + + # Create a multidimensional format that expects uint64 + target_format = KnownTableFormat(name="test-format", columns=[pa.uint32(), ...], field_names=None) + + # Cast should succeed + cast_table = target_format.cast_table(table) + + # Verify all columns have the correct type + self.assertEqual(cast_table.num_columns, 3) + for column in cast_table.itercolumns(): + self.assertEqual(column.type, pa.uint32()) + + def test_cast_wrong_number_of_columns_fails(self) -> None: + """Test that casting fails when column count doesn't match.""" + # Create a table with 2 columns + table = pa.table( + { + "col0": pa.array([1, 2, 3], type=pa.int64()), + "col1": pa.array([4, 5, 6], type=pa.int64()), + } + ) + + # Create a format that expects 3 columns + target_format = KnownTableFormat( + name="test-format", columns=[pa.uint64(), pa.uint64(), pa.uint64()], field_names=None + ) + + # Cast should fail due to column count mismatch + with self.assertRaises(TableFormatError) as ctx: + target_format.cast_table(table) + + self.assertIn("Column count (2) does not match expectation (3)", str(ctx.exception)) + + def test_cast_mixed_types(self) -> None: + """Test casting with mixed column types.""" + # Create a table with different types + table = pa.table( + { + "id": pa.array([1, 2, 3], type=pa.int64()), + "value": pa.array(["a", "b", "c"], type=pa.string()), + } + ) + + # Create a format with matching types + target_format = KnownTableFormat(name="test-format", columns=[pa.int32(), pa.string()], field_names=None) + + # Cast should succeed + cast_table = target_format.cast_table(table) + + # Verify types are preserved + self.assertEqual(cast_table.column("id").type, pa.int32()) + self.assertEqual(cast_table.column("value").type, pa.string()) diff --git a/packages/evo-objects/tests/typed/test_triangle_mesh.py b/packages/evo-objects/tests/typed/test_triangle_mesh.py new file mode 100644 index 00000000..e75a2828 --- /dev/null +++ b/packages/evo-objects/tests/typed/test_triangle_mesh.py @@ -0,0 +1,313 @@ +# Copyright © 2025 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import contextlib +import uuid +from unittest.mock import patch + +import pandas as pd +from parameterized import parameterized + +from evo.common import Environment, StaticContext +from evo.common.test_tools import BASE_URL, ORG, WORKSPACE_ID, TestWithConnector +from evo.objects import ObjectReference +from evo.objects.typed import BoundingBox, TriangleMesh, TriangleMeshData +from evo.objects.typed.base import BaseObject +from evo.objects.typed.exceptions import ObjectValidationError + +from .helpers import MockClient + + +class TestTriangleMesh(TestWithConnector): + def setUp(self) -> None: + TestWithConnector.setUp(self) + self.environment = Environment(hub_url=BASE_URL, org_id=ORG.id, workspace_id=WORKSPACE_ID) + self.context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + ) + + @contextlib.contextmanager + def _mock_geoscience_objects(self): + mock_client = MockClient(self.environment) + with ( + patch("evo.objects.typed.attributes.get_data_client", lambda _: mock_client), + patch("evo.objects.typed._data.get_data_client", lambda _: mock_client), + patch("evo.objects.typed.base.create_geoscience_object", mock_client.create_geoscience_object), + patch("evo.objects.typed.base.replace_geoscience_object", mock_client.replace_geoscience_object), + patch("evo.objects.DownloadedObject.from_context", mock_client.from_reference), + ): + yield mock_client + + # A simple tetrahedron mesh (4 vertices, 4 triangles) + example_mesh = TriangleMeshData( + name="Test Triangle Mesh", + vertices=pd.DataFrame( + { + "x": [0.0, 1.0, 0.5, 0.5], + "y": [0.0, 0.0, 1.0, 0.5], + "z": [0.0, 0.0, 0.0, 1.0], + "value": [1.0, 2.0, 3.0, 4.0], + } + ), + triangles=pd.DataFrame( + { + "n0": [0, 0, 0, 1], + "n1": [1, 2, 3, 2], + "n2": [2, 3, 1, 3], + "area": [0.5, 0.6, 0.7, 0.8], + } + ), + ) + + def _assert_bounding_box_equal( + self, bbox: BoundingBox, min_x: float, max_x: float, min_y: float, max_y: float, min_z: float, max_z: float + ): + self.assertAlmostEqual(bbox.min_x, min_x) + self.assertAlmostEqual(bbox.max_x, max_x) + self.assertAlmostEqual(bbox.min_y, min_y) + self.assertAlmostEqual(bbox.max_y, max_y) + self.assertAlmostEqual(bbox.min_z, min_z) + self.assertAlmostEqual(bbox.max_z, max_z) + + @parameterized.expand([BaseObject, TriangleMesh]) + async def test_create(self, class_to_call): + with self._mock_geoscience_objects(): + result = await class_to_call.create(context=self.context, data=self.example_mesh) + self.assertIsInstance(result, TriangleMesh) + self.assertEqual(result.name, "Test Triangle Mesh") + self.assertEqual(result.num_vertices, 4) + self.assertEqual(result.num_triangles, 4) + + vertex_df = await result.triangles.get_vertices_dataframe() + pd.testing.assert_frame_equal(vertex_df, self.example_mesh.vertices) + + triangle_df = await result.triangles.get_indices_dataframe() + pd.testing.assert_frame_equal(triangle_df, self.example_mesh.triangles) + + @parameterized.expand([BaseObject, TriangleMesh]) + async def test_replace(self, class_to_call): + # Create a mesh with only coordinates and indices (no attributes) + vertices = pd.DataFrame( + { + "x": [0.0, 1.0, 0.5], + "y": [0.0, 0.0, 1.0], + "z": [0.0, 0.0, 0.0], + } + ) + triangles = pd.DataFrame( + { + "n0": [0], + "n1": [1], + "n2": [2], + } + ) + data = TriangleMeshData( + name="Simple Triangle", + vertices=vertices, + triangles=triangles, + ) + with self._mock_geoscience_objects(): + result = await class_to_call.replace( + context=self.context, + reference=ObjectReference.new( + environment=self.context.get_environment(), + object_id=uuid.uuid4(), + ), + data=data, + ) + self.assertIsInstance(result, TriangleMesh) + self.assertEqual(result.name, "Simple Triangle") + self.assertEqual(result.num_vertices, 3) + self.assertEqual(result.num_triangles, 1) + + actual_vertices = await result.triangles.get_vertices_dataframe() + pd.testing.assert_frame_equal(actual_vertices, vertices) + + @parameterized.expand([BaseObject, TriangleMesh]) + async def test_create_or_replace(self, class_to_call): + with self._mock_geoscience_objects(): + result = await class_to_call.create_or_replace( + context=self.context, + reference=ObjectReference.new( + environment=self.context.get_environment(), + object_id=uuid.uuid4(), + ), + data=self.example_mesh, + ) + self.assertIsInstance(result, TriangleMesh) + self.assertEqual(result.name, "Test Triangle Mesh") + self.assertEqual(result.num_vertices, 4) + self.assertEqual(result.num_triangles, 4) + + @parameterized.expand([BaseObject, TriangleMesh]) + async def test_from_reference(self, class_to_call): + with self._mock_geoscience_objects(): + original = await TriangleMesh.create(context=self.context, data=self.example_mesh) + + result = await class_to_call.from_reference(context=self.context, reference=original.metadata.url) + self.assertEqual(result.name, "Test Triangle Mesh") + self.assertEqual(result.num_vertices, 4) + self.assertEqual(result.num_triangles, 4) + + vertex_df = await result.triangles.get_vertices_dataframe() + pd.testing.assert_frame_equal(vertex_df, self.example_mesh.vertices) + + def test_bounding_box_from_data(self): + """Test that the bounding box is computed correctly from the vertex data.""" + bbox = self.example_mesh.compute_bounding_box() + self._assert_bounding_box_equal(bbox, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) + + async def test_bounding_box_from_object(self): + """Test that the bounding box is stored correctly on the created object.""" + with self._mock_geoscience_objects() as mock_client: + obj = await TriangleMesh.create(context=self.context, data=self.example_mesh) + + bbox = obj.bounding_box + self._assert_bounding_box_equal(bbox, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0) + + # Verify it was saved to the document + bbox_dict = mock_client.objects[str(obj.metadata.url.object_id)]["bounding_box"] + self.assertAlmostEqual(bbox_dict["min_x"], 0.0) + self.assertAlmostEqual(bbox_dict["max_x"], 1.0) + + def test_vertices_validation(self): + """Test that vertices validation works correctly.""" + triangles = pd.DataFrame({"n0": [0], "n1": [1], "n2": [2]}) + + # Missing x column + with self.assertRaises(ObjectValidationError): + TriangleMeshData( + name="Bad Mesh", + vertices=pd.DataFrame({"y": [0.0, 1.0, 0.5], "z": [0.0, 0.0, 0.0]}), + triangles=triangles, + ) + + # Missing y and z columns + with self.assertRaises(ObjectValidationError): + TriangleMeshData( + name="Bad Mesh", + vertices=pd.DataFrame({"x": [0.0, 1.0, 0.5]}), + triangles=triangles, + ) + + def test_triangles_validation(self): + """Test that triangle indices validation works correctly.""" + vertices = pd.DataFrame({"x": [0.0, 1.0, 0.5], "y": [0.0, 0.0, 1.0], "z": [0.0, 0.0, 0.0]}) + + # Missing n0 column + with self.assertRaises(ObjectValidationError): + TriangleMeshData( + name="Bad Mesh", + vertices=vertices, + triangles=pd.DataFrame({"n1": [1], "n2": [2]}), + ) + + # Missing n1 and n2 columns + with self.assertRaises(ObjectValidationError): + TriangleMeshData( + name="Bad Mesh", + vertices=vertices, + triangles=pd.DataFrame({"n0": [0]}), + ) + + def test_triangle_index_out_of_range(self): + """Test that validation fails when triangle indices reference non-existent vertices.""" + vertices = pd.DataFrame({"x": [0.0, 1.0, 0.5], "y": [0.0, 0.0, 1.0], "z": [0.0, 0.0, 0.0]}) + + # Index 5 is out of range (only 3 vertices) + with self.assertRaises(ObjectValidationError): + TriangleMeshData( + name="Bad Mesh", + vertices=vertices, + triangles=pd.DataFrame({"n0": [0], "n1": [1], "n2": [5]}), + ) + + async def test_create_with_geometry_only(self): + """Test creating a mesh with only geometry (no attributes).""" + data = TriangleMeshData( + name="Geometry Only Mesh", + vertices=pd.DataFrame( + { + "x": [0.0, 1.0, 0.5], + "y": [0.0, 0.0, 1.0], + "z": [0.0, 0.0, 0.0], + } + ), + triangles=pd.DataFrame( + { + "n0": [0], + "n1": [1], + "n2": [2], + } + ), + ) + with self._mock_geoscience_objects(): + result = await TriangleMesh.create(context=self.context, data=data) + self.assertEqual(result.num_vertices, 3) + self.assertEqual(result.num_triangles, 1) + + async def test_description_and_tags(self): + """Test setting and getting description and tags.""" + data = TriangleMeshData( + name="Test Mesh", + vertices=self.example_mesh.vertices, + triangles=self.example_mesh.triangles, + description="A test triangle mesh for testing", + tags={"category": "test", "priority": "high"}, + ) + with self._mock_geoscience_objects(): + result = await TriangleMesh.create(context=self.context, data=data) + + self.assertEqual(result.description, "A test triangle mesh for testing") + self.assertEqual(result.tags, {"category": "test", "priority": "high"}) + + async def test_json(self): + """Test the JSON structure of the created object.""" + with self._mock_geoscience_objects() as mock_client: + obj = await TriangleMesh.create(context=self.context, data=self.example_mesh) + + # Get the JSON that was stored (would be sent to the API) + object_json = mock_client.objects[str(obj.metadata.url.object_id)] + + # Verify schema + self.assertEqual(object_json["schema"], "/objects/triangle-mesh/2.2.0/triangle-mesh.schema.json") + + # Verify base properties + self.assertEqual(object_json["name"], "Test Triangle Mesh") + self.assertIn("uuid", object_json) + self.assertIn("bounding_box", object_json) + self.assertEqual(object_json["coordinate_reference_system"], "unspecified") + + # Verify triangles structure + self.assertIn("triangles", object_json) + self.assertIn("vertices", object_json["triangles"]) + self.assertIn("indices", object_json["triangles"]) + + # Verify vertices structure + self.assertIn("data", object_json["triangles"]["vertices"]) + self.assertEqual(object_json["triangles"]["vertices"]["length"], 4) + + # Verify indices structure + self.assertIn("data", object_json["triangles"]["indices"]) + self.assertEqual(object_json["triangles"]["indices"]["length"], 4) + + # Verify vertex attributes structure + self.assertEqual(len(object_json["triangles"]["vertices"]["attributes"]), 1) + self.assertEqual(object_json["triangles"]["vertices"]["attributes"][0]["name"], "value") + self.assertEqual(object_json["triangles"]["vertices"]["attributes"][0]["attribute_type"], "scalar") + + # Verify triangle attributes structure + self.assertEqual(len(object_json["triangles"]["indices"]["attributes"]), 1) + self.assertEqual(object_json["triangles"]["indices"]["attributes"][0]["name"], "area") + self.assertEqual(object_json["triangles"]["indices"]["attributes"][0]["attribute_type"], "scalar")