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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.2.0] - 2025-10-23

### Added

- `list` command can now show a detailed summary of simulation cases with the `--cases` flag, including mesh properties like volume, COG, and cell count.
- `view` command can now visualize a mesh by specifying its case name, in addition to its direct mesh name.

### Changed

- **Breaking Change**: The `view` command interface has been simplified. It now takes the HDF5 file as the first required argument, followed by optional mesh/case names. The `--file` option has been removed for clarity.
- Refactored the `list` command for improved code clarity and maintainability.
- The mesh stored in the database is now the final, immersed, and (if applicable) fully reflected mesh used by Capytaine, ensuring `view` shows the correct geometry.

### Fixed

- Resolved numerous bugs in the `run` command related to mesh transformations, temporary file handling on Windows (`PermissionError`, `OSError`), and the use of symmetric meshes.
- Corrected `isinstance` checks in unit tests when using mocked objects.
- Fixed all `mypy` and `deptry` static analysis errors for a cleaner codebase.

## [0.1.1] - 2025-10-21

### Added
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"numpy>=2.0.2",
"pandas>=2.3.3",
"pydantic>=2.12.2",
"pyglet<2",
"pyyaml>=6.0.3",
"rich>=14.2.0",
"trimesh>=4.8.3",
Expand Down Expand Up @@ -133,7 +134,7 @@ module = [
"PySide6.*",
"rich.*",
"trimesh.*",
"vtk",
"vtk.*",
"yaml",
]
ignore_missing_imports = true
Expand Down Expand Up @@ -226,6 +227,7 @@ source = ["src"]
"mkdocs",
"mkdocs-material",
"mkdocstrings",
"pyglet",
"types-PyYAML",
]

Expand Down
10 changes: 9 additions & 1 deletion src/fleetmaster/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from rich.logging import RichHandler

from . import __version__
from .commands import gui, run
from .commands import gui, list_command, run, view
from .logging_setup import setup_general_logger

logger = setup_general_logger()
Expand Down Expand Up @@ -76,6 +76,12 @@ def cli(verbose: int) -> None:
for h in package_logger.handlers:
h.setLevel(log_level)

# If no subcommand is invoked, show the help message.
ctx = click.get_current_context()
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
return

if log_level <= logging.INFO:
logger.info(
"🚀 Fleetmaster CLI — ready to start your capytaine simulations.",
Expand All @@ -84,6 +90,8 @@ def cli(verbose: int) -> None:

cli.add_command(run, name="run")
cli.add_command(gui, name="gui")
cli.add_command(list_command, name="list")
cli.add_command(view, name="view")


if __name__ == "__main__":
Expand Down
4 changes: 3 additions & 1 deletion src/fleetmaster/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from .gui import gui
from .list import list_command
from .run import run
from .view import view

__all__ = ["gui", "run"]
__all__ = ["gui", "list_command", "run", "view"]
141 changes: 141 additions & 0 deletions src/fleetmaster/commands/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""CLI command for listing meshes from HDF5 databases."""

import io
import logging
from pathlib import Path
from typing import Any

import click
import h5py
import numpy as np
import trimesh
from trimesh import Trimesh

logger = logging.getLogger(__name__)


def _parse_stl_content(stl_content_dataset: Any) -> Trimesh:
"""
Parses binary STL data from an HDF5 dataset into a trimesh object.

Handles both legacy (numpy.void) and current storage formats.
"""
stl_data = stl_content_dataset[()]
try:
# Accommodate legacy storage format (numpy.void)
stl_bytes = stl_data.tobytes()
except AttributeError:
# Current format is already bytes
stl_bytes = stl_data

mesh = trimesh.load_mesh(io.BytesIO(stl_bytes), file_type="stl")
if not isinstance(mesh, Trimesh):
msg = "Failed to parse STL data into a valid trimesh object."
raise TypeError(msg)
return mesh


def _print_mesh_details(mesh_info_group: Any) -> None:
"""Prints formatted geometric properties of a mesh from its HDF5 group."""
attrs = mesh_info_group.attrs
vol = attrs.get("volume", "N/A")
cog = (attrs.get("cog_x", "N/A"), attrs.get("cog_y", "N/A"), attrs.get("cog_z", "N/A"))
dims = (attrs.get("bbox_lx", "N/A"), attrs.get("bbox_ly", "N/A"), attrs.get("bbox_lz", "N/A"))

num_faces: str | int = "N/A"
bounds: np.ndarray | None = None
if stl_content_dataset := mesh_info_group.get("stl_content"):
try:
mesh = _parse_stl_content(stl_content_dataset)
num_faces = len(mesh.faces)
bounds = mesh.bounding_box.bounds
except (ValueError, TypeError, AttributeError) as e:
mesh_name = Path(mesh_info_group.name).name
logger.debug(f"Failed to parse STL content for mesh '{mesh_name}': {e}")
click.echo(f" Could not parse stored STL content: {e}")

click.echo(f" Cells: {num_faces}")
click.echo(f" Volume: {vol:.4f}" if isinstance(vol, float) else f" Volume: {vol}")
click.echo(
f" COG (x,y,z): ({cog[0]:.3f}, {cog[1]:.3f}, {cog[2]:.3f})"
if all(isinstance(c, float) for c in cog)
else f" COG (x,y,z): {cog}"
)
click.echo(
f" BBox Dims (Lx,Ly,Lz): ({dims[0]:.3f}, {dims[1]:.3f}, {dims[2]:.3f})"
if all(isinstance(d, float) for d in dims)
else f" BBox Dims (Lx,Ly,Lz): {dims}"
)
if bounds is not None:
click.echo(f" BBox Min (x,y,z): ({bounds[0][0]:.3f}, {bounds[0][1]:.3f}, {bounds[0][2]:.3f})")
click.echo(f" BBox Max (x,y,z): ({bounds[1][0]:.3f}, {bounds[1][1]:.3f}, {bounds[1][2]:.3f})")


def _list_cases(stream: Any, hdf5_path: str) -> None:
"""Lists all simulation cases and their mesh properties in an HDF5 file."""
click.echo(f"\nAvailable cases in '{hdf5_path}':")
case_names = [name for name in stream if name != "meshes"]
if not case_names:
click.echo(" No cases found.")
return

for case_name in sorted(case_names):
case_group = stream[case_name]
click.echo(f"\n- Case: {case_name}")

if not (mesh_name := case_group.attrs.get("stl_mesh_name")):
click.echo(" Mesh: [Unknown]")
continue

click.echo(f" Mesh: {mesh_name}")
if mesh_info_group := stream.get(f"meshes/{mesh_name}"):
_print_mesh_details(mesh_info_group)
else:
click.echo(" Mesh properties not found in database.")


def _list_meshes(stream: Any, hdf5_path: str) -> None:
"""Lists all available meshes in an HDF5 file."""
click.echo(f"\nAvailable meshes in '{hdf5_path}':")
if not (meshes_group := stream.get("meshes")):
click.echo(" No 'meshes' group found.")
return

if available_meshes := list(meshes_group.keys()):
for name in sorted(available_meshes):
click.echo(f" - {name}")
else:
click.echo(" No meshes found.")


@click.command(name="list", help="List all meshes available in one or more HDF5 database files.")
@click.argument("files", nargs=-1, type=click.Path())
@click.option(
"--file",
"-f",
"option_files",
multiple=True,
help="Path to one or more HDF5 database files. Can be specified multiple times.",
)
@click.option("--cases", is_flag=True, help="List simulation cases and their properties instead of meshes.")
def list_command(files: tuple[str, ...], option_files: tuple[str, ...], cases: bool) -> None:
"""CLI command to list meshes."""
# Combine positional arguments and optional --file arguments
all_files = set(files) | set(option_files)

# If no files are provided at all, use the default.
final_files = list(all_files) if all_files else ["results.hdf5"]

for hdf5_path in final_files:
db_file = Path(hdf5_path)
if not db_file.exists():
click.echo(f"❌ Error: Database file '{hdf5_path}' not found.", err=True)
continue
try:
with h5py.File(db_file, "r") as stream:
if cases:
_list_cases(stream, hdf5_path)
else:
_list_meshes(stream, hdf5_path)
except Exception as e:
click.echo(f"❌ Error reading '{hdf5_path}': {e}", err=True)
1 change: 1 addition & 0 deletions src/fleetmaster/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,5 @@ def run(stl_files: tuple[str, ...], settings_file: str | None, **kwargs: Any) ->
except (click.UsageError, click.Abort):
raise # Re-raise to let click handle the error and exit
except Exception as e:
logger.exception("An unexpected error occurred")
click.echo(f"❌ An unexpected error occurred: {e}", err=True)
Loading
Loading