diff --git a/energyml-utils/pyproject.toml b/energyml-utils/pyproject.toml index 1690e90..29a03dd 100644 --- a/energyml-utils/pyproject.toml +++ b/energyml-utils/pyproject.toml @@ -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 -%} """ diff --git a/energyml-utils/src/energyml/utils/data/export/__init__.py b/energyml-utils/src/energyml/utils/data/export/__init__.py index 66594e2..ee32e6c 100644 --- a/energyml-utils/src/energyml/utils/data/export/__init__.py +++ b/energyml-utils/src/energyml/utils/data/export/__init__.py @@ -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, @@ -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 @@ -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", diff --git a/energyml-utils/src/energyml/utils/data/export/_base.py b/energyml-utils/src/energyml/utils/data/export/_base.py index ace781c..a435081 100644 --- a/energyml-utils/src/energyml/utils/data/export/_base.py +++ b/energyml-utils/src/energyml/utils/data/export/_base.py @@ -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 @@ -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.""" @@ -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", ] diff --git a/energyml-utils/src/energyml/utils/data/export/_registry.py b/energyml-utils/src/energyml/utils/data/export/_registry.py index fd281f8..392acdf 100644 --- a/energyml-utils/src/energyml/utils/data/export/_registry.py +++ b/energyml-utils/src/energyml/utils/data/export/_registry.py @@ -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 @@ -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] = {} @@ -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. @@ -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: @@ -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: diff --git a/energyml-utils/src/energyml/utils/data/export/obj.py b/energyml-utils/src/energyml/utils/data/export/obj.py index 124a6c7..e863bcc 100644 --- a/energyml-utils/src/energyml/utils/data/export/obj.py +++ b/energyml-utils/src/energyml/utils/data/export/obj.py @@ -9,6 +9,8 @@ from energyml.utils.data.export._base import ( ExportFormat, + MeshGroupKey, + MeshNamer, resolve_origin_shift, _get_context_color, _get_export_points, @@ -16,6 +18,7 @@ _normalize_to_patches, _parse_vtk_flat_faces, _parse_vtk_flat_lines, + _patch_identifier, _workspace_from_contexts, ) @@ -32,6 +35,39 @@ # --------------------------------------------------------------------------- +def default_object_name(mesh: Any) -> str: + """Default ``o`` namer: the URI of the patch's source object. + + Reads it fresh off ``patch.energyml_object`` rather than ``patch.identifier`` — the legacy + ``AbstractMesh`` suffixes that identifier with ``_patch{n}``, which is the right label for a + single patch's ``g`` line but not for the ``o`` line naming the whole (possibly multi-patch) + object. Falls back to the patch's own identifier when no source object is attached. + """ + energyml_object = getattr(mesh, "energyml_object", None) + if energyml_object is not None: + try: + from energyml.utils.introspection import get_obj_uri + + uri = get_obj_uri(energyml_object) + if uri.uuid: # a foreign/malformed object resolves to an empty "eml:///" otherwise + return str(uri) + except Exception: + pass + return _patch_identifier(mesh) or "mesh" + + +def default_group_name(mesh: Any) -> str: + """Default ``g`` namer: ``{source_uuid}_{patch_index}``, falling back to the patch label. + + This is the mapping OBJ export always used, kept as the default so existing callers see no + change; pass *group_namer* to :func:`export_obj` to use another one. + """ + source_uuid = getattr(mesh, "source_uuid", None) or getattr(mesh, "uuid", None) + patch_idx = getattr(mesh, "patch_index", None) + patch_label = getattr(mesh, "patch_label", None) or getattr(mesh, "identifier", None) or "mesh" + return f"{source_uuid}_{patch_idx}" if source_uuid and patch_idx is not None else patch_label + + def export_obj( mesh_list: Any, out: BinaryIO, @@ -42,13 +78,20 @@ def export_obj( frame: Optional["PointFrame"] = None, origin_shift: Optional[Any] = None, use_network: bool = False, + object_namer: Optional[MeshNamer] = None, + group_namer: Optional[MeshNamer] = None, + group_by: Optional[MeshGroupKey] = None, ) -> None: """Export mesh data to Wavefront OBJ format. :param mesh_list: One or more meshes (``AbstractMesh``, ``NumpyMesh``, - ``NumpyMultiMesh``, or a list thereof). + ``NumpyMultiMesh``, or a list thereof). Meshes coming from several source energyml + objects can be passed together (nested lists are flattened) — combine that with + *group_by* to keep them distinguishable inside the single output file. :param out: Binary output stream for the ``.obj`` content. - :param obj_name: Optional object name written to the OBJ header. + :param obj_name: Explicit name for the (single, implicit) ``o`` line. Wins over + *object_namer* as long as *group_by* leaves everything in one group — this is what keeps + existing callers (``export_mesh`` passes the output file stem) unchanged. :param contexts: Optional dict of :class:`RepresentationContext` keyed by ``source_uuid``; used to emit companion ``.mtl`` material colours when *mtl_out* is also provided. @@ -56,6 +99,18 @@ def export_obj( Colour requires *contexts* to be supplied. :param use_crs_displacement: When True (default), CRS origin offset and axis transforms are applied to ``NumpyMesh`` points at export time. + :param object_namer: Callable ``mesh -> str`` naming each group's ``o`` line. Called once per + group (see *group_by*) with that group's first patch. Defaults to + :func:`default_object_name` (the patch's URI). Wrap a function of the source energyml + object with :func:`~energyml.utils.data.export.by_energyml_object` to name objects after + e.g. their Citation title. + :param group_namer: Callable ``mesh -> str`` naming each patch's ``g`` line. Defaults to + :func:`default_group_name`, the pre-existing mapping. + :param group_by: Callable ``mesh -> key`` splitting the export into several ``o`` objects — + e.g. :func:`~energyml.utils.data.export.group_by_source_object` (one object per source + representation) or :func:`~energyml.utils.data.export.group_by_qualified_type` (one per + RESQML/EML type). ``None`` (default) keeps every patch under the single implicit object + named by *obj_name* / *object_namer*. """ from energyml.utils.data.mesh import PolylineSetMesh from energyml.utils.data.mesh_numpy import NumpyMesh, NumpyPointSetMesh, NumpyPolylineMesh @@ -64,9 +119,15 @@ def export_obj( workspace = _workspace_from_contexts(contexts) _origin_shift = resolve_origin_shift(patches, use_crs_displacement, workspace, frame, origin_shift, use_network) + group_namer = group_namer or default_group_name + key_fn = group_by or (lambda _mesh: None) + + groups: Dict[Any, list] = {} + for mesh in patches: + groups.setdefault(key_fn(mesh), []).append(mesh) + single_group = len(groups) <= 1 + out.write(b"# Generated by energyml-utils (Geosiris)\n\n") - if obj_name is not None: - out.write(f"o {obj_name}\n\n".encode()) mtl_lib_name = obj_name or "materials" if mtl_out is not None: @@ -75,61 +136,77 @@ def export_obj( point_offset = 0 - for mesh in patches: - pts, _pts_frame, _ = _get_export_points( - mesh, use_crs_displacement, workspace, frame, _origin_shift, use_network - ) - patch_label = getattr(mesh, "patch_label", None) or getattr(mesh, "identifier", None) or "mesh" - source_uuid = getattr(mesh, "source_uuid", None) or getattr(mesh, "uuid", None) - patch_idx = getattr(mesh, "patch_index", None) - group_name = f"{source_uuid}_{patch_idx}" if source_uuid and patch_idx is not None else patch_label - - out.write(f"g {group_name}\n\n".encode()) - - # emit material reference when mtl output is available - if mtl_out is not None: - mat_name = f"mat_{group_name}" - color = _get_context_color(source_uuid, contexts) - if color is None: - color = (200, 200, 200, 255) - r, g, b, _a = color - out.write(f"usemtl {mat_name}\n".encode()) - mtl_out.write(f"newmtl {mat_name}\n".encode()) - mtl_out.write(f"Kd {r / 255:.6f} {g / 255:.6f} {b / 255:.6f}\n\n".encode()) - - # write vertices - for pt in pts: - out.write(f"v {pt[0]} {pt[1]} {pt[2]}\n".encode()) - - # write connectivity - if isinstance(mesh, NumpyMesh): - if isinstance(mesh, NumpyPointSetMesh): - # bare vertex elements - for i in range(len(pts)): - out.write(f"p {i + point_offset + 1}\n".encode()) - elif isinstance(mesh, NumpyPolylineMesh): - for seg in _parse_vtk_flat_lines(mesh.lines): - if len(seg) > 1: - idx_str = " ".join(str(i + point_offset + 1) for i in seg) - out.write(f"l {idx_str}\n".encode()) + for group_patches in groups.values(): + if single_group: + # Exact pre-existing behaviour: obj_name wins, then object_namer, then no `o` line. + if obj_name is not None: + object_name = obj_name + elif object_namer is not None: + object_name = object_namer(group_patches[0]) else: - # NumpySurfaceMesh (or NumpyVolumeMesh — export as faces) - faces_arr = _get_faces_or_cells(mesh) - for face in _parse_vtk_flat_faces(faces_arr): - if len(face) >= 3: - idx_str = " ".join(str(i + point_offset + 1) for i in face) - out.write(f"f {idx_str}\n".encode()) + object_name = None else: - # AbstractMesh legacy path - indices = mesh.get_indices() - elt = "l" if isinstance(mesh, PolylineSetMesh) else "f" - for elem in indices: - if len(elem) > 1: - idx_str = " ".join(str(i + point_offset + 1) for i in elem) - out.write(f"{elt} {idx_str}\n".encode()) + # group_by produced more than one object: they need distinguishing names even when + # the caller supplied neither obj_name nor object_namer, so fall back to + # default_object_name instead of silently omitting every `o` line. + object_name = (object_namer or default_object_name)(group_patches[0]) + + if object_name is not None: + out.write(f"o {object_name}\n\n".encode()) + + for mesh in group_patches: + pts, _pts_frame, _ = _get_export_points( + mesh, use_crs_displacement, workspace, frame, _origin_shift, use_network + ) + group_name = group_namer(mesh) + source_uuid = getattr(mesh, "source_uuid", None) or getattr(mesh, "uuid", None) + + out.write(f"g {group_name}\n\n".encode()) + + # emit material reference when mtl output is available + if mtl_out is not None: + mat_name = f"mat_{group_name}" + color = _get_context_color(source_uuid, contexts) + if color is None: + color = (200, 200, 200, 255) + r, g, b, _a = color + out.write(f"usemtl {mat_name}\n".encode()) + mtl_out.write(f"newmtl {mat_name}\n".encode()) + mtl_out.write(f"Kd {r / 255:.6f} {g / 255:.6f} {b / 255:.6f}\n\n".encode()) + + # write vertices + for pt in pts: + out.write(f"v {pt[0]} {pt[1]} {pt[2]}\n".encode()) + + # write connectivity + if isinstance(mesh, NumpyMesh): + if isinstance(mesh, NumpyPointSetMesh): + # bare vertex elements + for i in range(len(pts)): + out.write(f"p {i + point_offset + 1}\n".encode()) + elif isinstance(mesh, NumpyPolylineMesh): + for seg in _parse_vtk_flat_lines(mesh.lines): + if len(seg) > 1: + idx_str = " ".join(str(i + point_offset + 1) for i in seg) + out.write(f"l {idx_str}\n".encode()) + else: + # NumpySurfaceMesh (or NumpyVolumeMesh — export as faces) + faces_arr = _get_faces_or_cells(mesh) + for face in _parse_vtk_flat_faces(faces_arr): + if len(face) >= 3: + idx_str = " ".join(str(i + point_offset + 1) for i in face) + out.write(f"f {idx_str}\n".encode()) + else: + # AbstractMesh legacy path + indices = mesh.get_indices() + elt = "l" if isinstance(mesh, PolylineSetMesh) else "f" + for elem in indices: + if len(elem) > 1: + idx_str = " ".join(str(i + point_offset + 1) for i in elem) + out.write(f"{elt} {idx_str}\n".encode()) - out.write(b"\n") - point_offset += len(pts) + out.write(b"\n") + point_offset += len(pts) # --------------------------------------------------------------------------- @@ -148,6 +225,9 @@ def _write( frame: Optional["PointFrame"] = None, origin_shift: Optional[Any] = None, companion: Optional[BinaryIO] = None, + object_namer: Optional[MeshNamer] = None, + group_namer: Optional[MeshNamer] = None, + group_by: Optional[MeshGroupKey] = None, ) -> None: """Uniform adapter used by the registry; ``options`` is unused by OBJ.""" export_obj( @@ -159,6 +239,9 @@ def _write( use_crs_displacement, frame=frame, origin_shift=origin_shift, + object_namer=object_namer, + group_namer=group_namer, + group_by=group_by, ) @@ -171,6 +254,7 @@ def _write( binary=True, options_class=None, companion_suffix=".mtl", + supports_naming=True, ) ) @@ -179,4 +263,6 @@ def _write( #: else is not a breaking change, and so `from ... import *` does not leak the imports. __all__ = [ "export_obj", + "default_object_name", + "default_group_name", ]