Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions energyml-utils/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,19 @@ max-line-length = "120"
enable = true
vcs = "git"
style = "pep440"
# The dev number is the CI run number when there is one, not the commit distance: the distance
# repeats (a rerun, or two branches the same number of commits past the tag), and test.pypi
# refuses a filename it has already seen, even once deleted. A `+local` suffix is not an option,
# PyPI rejects local versions. Build and publish jobs of one run share GITHUB_RUN_NUMBER, so
# `poetry publish` still finds the wheel the build job produced. Tagged commits stay clean.
format-jinja = """
{%- set dev_number = env.get("GITHUB_RUN_NUMBER", distance) | int -%}
{%- if distance == 0 -%}
{{ serialize_pep440(base, stage, revision) }}
{%- elif revision is not none -%}
{{ serialize_pep440(base, stage, revision + 1, dev=distance,
) }}
{{ serialize_pep440(base, stage, revision + 1, dev=dev_number) }}
{%- else -%}
{{ serialize_pep440(bump_version(base), stage, revision, dev=distance,
) }}
{{ serialize_pep440(bump_version(base), stage, revision, dev=dev_number) }}
{%- endif -%}
"""

Expand Down
19 changes: 18 additions & 1 deletion energyml-utils/src/energyml/utils/data/export/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
VTKExportOptions,
VTKFormat,
resolve_origin_shift,
MeshNamer,
MeshGroupKey,
by_energyml_object,
group_by_source_object,
group_by_qualified_type,
)
from energyml.utils.data.export._registry import (
FormatSpec,
Expand Down Expand Up @@ -66,7 +71,11 @@
_prepare_geojson_points,
export_geojson,
)
from energyml.utils.data.export.obj import export_obj # noqa: E402
from energyml.utils.data.export.obj import ( # noqa: E402
default_group_name,
default_object_name,
export_obj,
)
from energyml.utils.data.export.off import export_off, export_off_part # noqa: E402
from energyml.utils.data.export.stl import export_stl # noqa: E402
from energyml.utils.data.export.vtk import export_vtk # noqa: E402
Expand Down Expand Up @@ -96,6 +105,14 @@
"export_stl",
# Frame helpers
"resolve_origin_shift",
# Naming / grouping (OBJ today; see FormatSpec.supports_naming)
"MeshNamer",
"MeshGroupKey",
"by_energyml_object",
"group_by_source_object",
"group_by_qualified_type",
"default_object_name",
"default_group_name",
# UI helpers
"supported_formats",
"format_description",
Expand Down
111 changes: 110 additions & 1 deletion energyml-utils/src/energyml/utils/data/export/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import logging
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple

import numpy as np

Expand Down Expand Up @@ -232,6 +232,110 @@ def drop_empty_patches(meshes: Any, raise_when_empty: bool = False) -> List[Any]
return kept


# ---------------------------------------------------------------------------
# Naming / grouping
# ---------------------------------------------------------------------------

#: Callable deriving a display name from a mesh patch (``AbstractMesh`` or ``NumpyMesh``). Used
#: for the OBJ ``o``/``g`` lines today; the same signature is meant to carry over to whichever
#: other format grows an equivalent per-object metadata slot (e.g. VTK block names).
MeshNamer = Callable[[Any], str]

#: Callable deriving a grouping key from a mesh patch, used to split an aggregated export into
#: several named objects instead of one flat one. Two patches sharing a key land in the same
#: object.
MeshGroupKey = Callable[[Any], Any]


def _patch_identifier(mesh: Any) -> Optional[str]:
"""Return the URI-like identifier a reader stamped on *mesh*, if any."""
return getattr(mesh, "identifier", None) or getattr(mesh, "source_uuid", None)


def _source_energyml_object(mesh: Any) -> Any:
"""Return the source energyml object *mesh* was read from, if any.

Both mesh hierarchies carry it (``AbstractMesh.energyml_object`` / ``NumpyMesh.energyml_object``);
it is the one piece of patch metadata that is *not* patch-scoped — unlike ``identifier``,
which the legacy readers suffix with ``_patch{n}``, or ``source_uuid``, which only
``NumpyMesh`` carries at all.
"""
return getattr(mesh, "energyml_object", None)


def by_energyml_object(fn: Callable[[Any], str]) -> MeshNamer:
"""Adapt *fn* — a function of the source **energyml object** — into a :data:`MeshNamer`.

Every mesh patch keeps the energyml object it was read from on ``patch.energyml_object``;
this is the hook to name exports after it, e.g. ``by_energyml_object(get_obj_title)`` or
``by_energyml_object(lambda o: get_obj_uri(o).uuid)``. Falls back to the patch's own
identifier (its URI) when no source object is attached, which is the case for a few
synthetic patches (e.g. the empty-wellbore-trajectory placeholder).
"""

def _namer(mesh: Any) -> str:
obj = _source_energyml_object(mesh)
if obj is not None:
return fn(obj)
return _patch_identifier(mesh) or "mesh"

return _namer


def group_by_source_object(mesh: Any) -> Any:
""":data:`MeshGroupKey` — one group per originating energyml object.

Keys on ``get_obj_uuid(patch.energyml_object)`` rather than the patch's own
``identifier``/``source_uuid``: those are patch-scoped, and the legacy ``AbstractMesh`` (no
``source_uuid`` field, ``identifier`` suffixed ``"..._patch{n}"``) would otherwise put every
patch of the same multi-patch representation in its own group instead of merging them.
"""
obj = _source_energyml_object(mesh)
if obj is not None:
try:
from energyml.utils.introspection import get_obj_uuid

uuid = get_obj_uuid(obj)
if uuid:
return uuid
except Exception:
pass
return getattr(mesh, "source_uuid", None) or _patch_identifier(mesh)


def group_by_qualified_type(mesh: Any) -> str:
""":data:`MeshGroupKey` — one group per RESQML/EML qualified type.

Reads the type off ``patch.energyml_object`` first: the mesh *container* class
(``SurfaceMesh``, ``NumpySurfaceMesh``, …) is shared by several RESQML types, and the
patch's own identifier is not reliably parseable as a URI (the legacy readers append
``_patch{n}`` to it). Falls back to parsing the identifier, then to the container class name,
for patches with no source object attached. Returns a plain string, so it doubles as an
*object_namer* when an export should be grouped and named by type in one pass.
"""
obj = _source_energyml_object(mesh)
if obj is not None:
try:
from energyml.utils.introspection import get_qualified_type_from_class

qualified_type = get_qualified_type_from_class(obj)
if qualified_type:
return qualified_type
except Exception:
pass
identifier = _patch_identifier(mesh)
if identifier:
try:
from energyml.utils.uri import Uri

parsed = Uri.parse(str(identifier))
if parsed.object_type:
return parsed.object_type
except Exception:
pass
return getattr(mesh, "source_type", None) or type(mesh).__name__


def _parse_vtk_flat_faces(flat: np.ndarray) -> List[np.ndarray]:
"""Decode VTK flat face array ``[nv, v0, …, nv, v0, …]`` into a list of
per-face index arrays."""
Expand Down Expand Up @@ -410,4 +514,9 @@ def _get_faces_or_cells(mesh: Any) -> np.ndarray:
"EmptyMeshError",
"drop_empty_patches",
"resolve_origin_shift",
"MeshNamer",
"MeshGroupKey",
"by_energyml_object",
"group_by_source_object",
"group_by_qualified_type",
]
39 changes: 37 additions & 2 deletions energyml-utils/src/energyml/utils/data/export/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union

from energyml.utils.data.export._base import ExportFormat, ExportOptions, drop_empty_patches
from energyml.utils.data.export._base import (
ExportFormat,
ExportOptions,
MeshGroupKey,
MeshNamer,
drop_empty_patches,
)

if TYPE_CHECKING:
from energyml.utils.data.crs import PointFrame
Expand Down Expand Up @@ -72,6 +78,13 @@ class FormatSpec:
are provided. OBJ uses it for its ``.mtl`` material file.
"""

supports_naming: bool = False
"""
Whether the writer accepts ``object_namer`` / ``group_namer`` / ``group_by`` (see
:func:`export_mesh`). Only OBJ does today; ``export_mesh`` only forwards those kwargs when
this is set, so a format whose writer does not accept them is never called with them.
"""


_REGISTRY: Dict[ExportFormat, FormatSpec] = {}

Expand Down Expand Up @@ -116,12 +129,17 @@ def export_mesh(
use_crs_displacement: bool = True,
frame: Optional["PointFrame"] = None,
origin_shift: Optional[Any] = None,
object_namer: Optional[MeshNamer] = None,
group_namer: Optional[MeshNamer] = None,
group_by: Optional[MeshGroupKey] = None,
) -> None:
"""Export mesh data to a file.

Format is auto-detected from the file extension when *format* is None.

:param mesh_list: Meshes to export.
:param mesh_list: Meshes to export. Several source objects can be combined into one file by
passing their mesh lists together (nested lists are flattened) — pair that with
*group_by* so the output still distinguishes them.
:param output_path: Destination file path.
:param format: Explicit format; auto-detected from the extension when None.
:param options: Format-specific options.
Expand All @@ -135,6 +153,14 @@ def export_mesh(
from the coordinates. ``"auto"`` recentres on the bounding-box centre of the whole export,
computed once and applied identically to every patch. Useful for projected coordinates,
whose 6-7 significant digits lose precision when a viewer reads the file as float32.
:param object_namer: Callable ``mesh -> str`` naming each group's top-level object (OBJ's
``o`` line). Ignored by formats whose :class:`FormatSpec` does not set
``supports_naming`` — currently only OBJ. See :func:`~.obj.export_obj`.
:param group_namer: Callable ``mesh -> str`` naming each patch's sub-group (OBJ's ``g``
line). Same support scope as *object_namer*.
:param group_by: Callable ``mesh -> key`` splitting the export into several named objects,
e.g. :func:`~._base.group_by_source_object` or :func:`~._base.group_by_qualified_type`.
Same support scope as *object_namer*.
"""
path = Path(output_path)
if format is None:
Expand All @@ -157,6 +183,15 @@ def export_mesh(
"origin_shift": origin_shift,
}

if spec.supports_naming:
kwargs["object_namer"] = object_namer
kwargs["group_namer"] = group_namer
kwargs["group_by"] = group_by
elif object_namer is not None or group_namer is not None or group_by is not None:
logger.warning(
"%s does not support object_namer/group_namer/group_by — ignoring them.", format.value
)

mesh_list = drop_empty_patches(mesh_list, raise_when_empty=True)

with ExitStack() as stack:
Expand Down
Loading
Loading