diff --git a/CHANGELOG.md b/CHANGELOG.md index 90094e2..da170c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6194c9e..a2b112d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -133,7 +134,7 @@ module = [ "PySide6.*", "rich.*", "trimesh.*", - "vtk", + "vtk.*", "yaml", ] ignore_missing_imports = true @@ -226,6 +227,7 @@ source = ["src"] "mkdocs", "mkdocs-material", "mkdocstrings", + "pyglet", "types-PyYAML", ] diff --git a/src/fleetmaster/cli.py b/src/fleetmaster/cli.py index 8c272dc..a375e94 100644 --- a/src/fleetmaster/cli.py +++ b/src/fleetmaster/cli.py @@ -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() @@ -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.", @@ -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__": diff --git a/src/fleetmaster/commands/__init__.py b/src/fleetmaster/commands/__init__.py index f2d8ce2..5d4b530 100644 --- a/src/fleetmaster/commands/__init__.py +++ b/src/fleetmaster/commands/__init__.py @@ -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"] diff --git a/src/fleetmaster/commands/list.py b/src/fleetmaster/commands/list.py new file mode 100644 index 0000000..66a608f --- /dev/null +++ b/src/fleetmaster/commands/list.py @@ -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) diff --git a/src/fleetmaster/commands/run.py b/src/fleetmaster/commands/run.py index ab9c18e..44bab37 100644 --- a/src/fleetmaster/commands/run.py +++ b/src/fleetmaster/commands/run.py @@ -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) diff --git a/src/fleetmaster/commands/view.py b/src/fleetmaster/commands/view.py new file mode 100644 index 0000000..7af4d05 --- /dev/null +++ b/src/fleetmaster/commands/view.py @@ -0,0 +1,171 @@ +"""CLI command for visualizing meshes from the HDF5 database.""" + +import logging +from itertools import cycle +from pathlib import Path +from typing import Any + +import click +import h5py +import numpy as np +import trimesh +from trimesh import Trimesh + +from fleetmaster.core.io import load_meshes_from_hdf5 + +logger = logging.getLogger(__name__) + +# Try to import vtk, but make it an optional dependency +try: + import vtk + from vtk.util.numpy_support import numpy_to_vtk + + VTK_AVAILABLE = True + # The global import of numpy is sufficient. +except ImportError: + VTK_AVAILABLE = False + +VTK_COLORS = [ + (0.8, 0.8, 1.0), # Light Blue + (1.0, 0.8, 0.8), # Light Red + (0.8, 1.0, 0.8), # Light Green + (1.0, 1.0, 0.8), # Light Yellow +] + + +def show_with_trimesh(mesh: trimesh.Trimesh) -> None: + """Visualizes the mesh using the built-in trimesh viewer.""" + click.echo("🎨 Displaying mesh with trimesh viewer. Close the window to continue.") + mesh.show() + + +def _vtk_actor_from_trimesh(mesh: trimesh.Trimesh, color: tuple[float, float, float]) -> Any: + """Creates a VTK actor from a trimesh object.""" + pts = vtk.vtkPoints() + pts.SetData(numpy_to_vtk(mesh.vertices, deep=True)) + + faces = np.hstack((np.full((len(mesh.faces), 1), 3), mesh.faces)) + cells = vtk.vtkCellArray() + cells.SetCells(len(mesh.faces), numpy_to_vtk(faces.flatten(), array_type=vtk.VTK_ID_TYPE)) + + poly = vtk.vtkPolyData() + poly.SetPoints(pts) + poly.SetPolys(cells) + + mapper = vtk.vtkPolyDataMapper() + mapper.SetInputData(poly) + + actor = vtk.vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().SetColor(color) + return actor + + +def show_with_vtk(meshes: list[Trimesh]) -> None: + """Visualizes the mesh using a VTK pipeline.""" + if not VTK_AVAILABLE: + click.echo("❌ Error: The 'vtk' library is not installed. Please install it with 'pip install vtk'.") + return + + click.echo(f"🎨 Displaying {len(meshes)} mesh(es) with VTK viewer. Close the window to continue.") + renderer = vtk.vtkRenderer() + renderer.SetBackground(0.1, 0.2, 0.3) # Dark blue/gray + for mesh, color in zip(meshes, cycle(VTK_COLORS)): + renderer.AddActor(_vtk_actor_from_trimesh(mesh, color)) + + # Add a global axes actor at the origin + axes_at_origin = vtk.vtkAxesActor() + axes_at_origin.SetTotalLength(1.0, 1.0, 1.0) # Set size of the axes + renderer.AddActor(axes_at_origin) + + # Add an axes actor for context + axes = vtk.vtkAxesActor() + widget = vtk.vtkOrientationMarkerWidget() # This is the small one in the corner + widget.SetOutlineColor(0.9300, 0.5700, 0.1300) + widget.SetOrientationMarker(axes) + + # RenderWindow: The window on the screen + render_window = vtk.vtkRenderWindow() + render_window.AddRenderer(renderer) + render_window.SetSize(800, 600) + render_window.SetWindowName("VTK Mesh Viewer") + + # Interactor: Handles mouse and keyboard interaction + render_window_interactor = vtk.vtkRenderWindowInteractor() + render_window_interactor.SetRenderWindow(render_window) + + # Couple the axes widget to the interactor + widget.SetInteractor(render_window_interactor) + widget.SetEnabled(1) + widget.InteractiveOn() + + # 4. Start the visualization + render_window.Render() + render_window_interactor.Start() + + +@click.command(name="view", help="Visualize meshes from an HDF5 database file.") +@click.argument("hdf5_file", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.argument("mesh_names", nargs=-1) +@click.option("--vtk", is_flag=True, help="Use the VTK viewer instead of the default trimesh viewer.") +@click.option("--show-all", is_flag=True, help="Visualize all meshes found in the specified files.") +def view(hdf5_file: str, mesh_names: tuple[str, ...], vtk: bool, show_all: bool) -> None: + """ + CLI command to load and visualize meshes from HDF5 databases. + + HDF5_FILE: Path to the HDF5 database file. + [MESH_NAMES]...: Optional names of meshes or cases to visualize. + """ + # The HDF5 file is now a required positional argument. + # Mesh names are optional positional arguments. + names_to_resolve = set(mesh_names) + resolved_mesh_names = set() + + if show_all: + # If --show-all, we ignore any provided mesh names and find all meshes in the specified files. + names_to_resolve = set() + db_file = Path(hdf5_file) + with h5py.File(db_file, "r") as f: + resolved_mesh_names.update(f.get("meshes", {}).keys()) + + elif names_to_resolve: + # Resolve provided names: they can be mesh names or case names. + with h5py.File(hdf5_file, "r") as f: + for name in names_to_resolve: + # Check if it's a direct mesh name + if f.get(f"meshes/{name}"): + resolved_mesh_names.add(name) + logger.debug(f"Resolved '{name}' as a direct mesh name.") + # Check if it's a case name + elif (case_group := f.get(name)) and (mesh_name := case_group.attrs.get("stl_mesh_name")): + resolved_mesh_names.add(mesh_name) + logger.debug(f"Resolved case '{name}' to mesh '{mesh_name}'.") + else: + click.echo( + f"❌ Warning: Could not resolve '{name}' as a mesh or a case name.", + err=True, + ) + + if not resolved_mesh_names: + click.echo("No mesh names provided and no meshes found with --show-all.", err=True) + click.echo( + "Usage: fleetmaster view [MESH_NAME...] OR fleetmaster view --show-all", err=True + ) + return + + meshes = load_meshes_from_hdf5(Path(hdf5_file), sorted(resolved_mesh_names)) + if not meshes: + click.echo("No valid meshes could be loaded from the database.", err=True) + return + + if vtk: + show_with_vtk(meshes) + else: + click.echo(f"🎨 Displaying {len(meshes)} mesh(es) with trimesh viewer. Close the window to continue.") + # To avoid potential rendering glitches with the scene object, + # we create a scene with an axis and pass the meshes to show directly. + axis = trimesh.creation.axis(origin_size=0.1) + scene = trimesh.Scene([axis, *meshes]) + + logger.debug("Showing with solid mode. Toggle with w/s to go to wireframe") + scene.show() diff --git a/src/fleetmaster/core/engine.py b/src/fleetmaster/core/engine.py index 0e4b7f2..0389907 100644 --- a/src/fleetmaster/core/engine.py +++ b/src/fleetmaster/core/engine.py @@ -103,43 +103,101 @@ def _setup_output_file(settings: SimulationSettings) -> Path: return output_file -def _prepare_capytaine_body(stl_file: str, lid: bool, grid_symmetry: bool, add_centre_of_mass: bool = False) -> Any: +def _prepare_trimesh_geometry( + stl_file: str, + translation_x: float = 0.0, + translation_y: float = 0.0, + translation_z: float = 0.0, +) -> trimesh.Trimesh: """ - Load an STL file and configure a Capytaine FloatingBody object. + Loads an STL file and applies specified translations. + + Returns: + A trimesh.Trimesh object representing the transformed geometry. """ - hull_mesh = cpt.load_mesh(stl_file) - lid_mesh = hull_mesh.generate_lid(z=-0.01) if lid else None + transformed_mesh = trimesh.load_mesh(stl_file) + + # Apply translation if specified + if translation_x != 0.0 or translation_y != 0.0 or translation_z != 0.0: + translation_vector = np.array([translation_x, translation_y, translation_z]) + logger.debug(f"Applying mesh translation: {translation_vector}") + transform_matrix = trimesh.transformations.translation_matrix(translation_vector) + transformed_mesh.apply_transform(transform_matrix) + + return transformed_mesh - if grid_symmetry: - logger.debug("Applying grid symmetery") - hull_mesh = cpt.ReflectionSymmetricMesh(hull_mesh, plane=cpt.xOz_Plane, name=f"{Path(stl_file).stem}_mesh") - if add_centre_of_mass: - full_mesh = trimesh.load_mesh(stl_file) - cog = full_mesh.center_mass +def _prepare_capytaine_body( + source_mesh: trimesh.Trimesh, + mesh_name: str, + lid: bool, + grid_symmetry: bool, + add_center_of_mass: bool = False, +) -> tuple[Any, trimesh.Trimesh]: + """ + Configures a Capytaine FloatingBody from a pre-prepared trimesh object. + """ + cog = source_mesh.center_mass if add_center_of_mass else None + if cog is not None: logger.debug(f"Adding COG {cog}") - else: - cog = None + + # 1. Save the transformed mesh to a temporary file and load it with Capytaine. + # This is more robust than creating a cpt.Mesh from vertices/faces directly. + # We use NamedTemporaryFile to handle creation and cleanup automatically. + temp_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as temp_file: + temp_path = Path(temp_file.name) + # Step 1: Write to the temporary file. + source_mesh.export(temp_file, file_type="stl") + logger.debug(f"Exported transformed mesh to temporary file: {temp_path}") + + # Step 2: Read from the now-closed temporary file. This avoids race conditions. + hull_mesh = cpt.load_mesh(str(temp_path), name=mesh_name) + + finally: + # Step 3: Ensure the temporary file is always deleted, even if an error occurs. + if temp_path and temp_path.exists(): + logger.debug(f"Deleting temporary file: {temp_path}") + temp_path.unlink() + + # 4. Configure the Capytaine FloatingBody + lid_mesh = hull_mesh.generate_lid(z=-0.01) if lid else None + if grid_symmetry: + logger.debug("Applying grid symmetery") + hull_mesh = cpt.ReflectionSymmetricMesh(hull_mesh, plane=cpt.xOz_Plane) boat = cpt.FloatingBody(mesh=hull_mesh, lid_mesh=lid_mesh, center_of_mass=cog) boat.add_all_rigid_body_dofs() boat.keep_immersed_part() - return boat + # If the mesh is symmetric, convert it back to a full mesh before extracting vertices/faces. + # This ensures the database stores the complete geometry. + if isinstance(boat.mesh, cpt.meshes.ReflectionSymmetricMesh): + boat.mesh = boat.mesh.to_mesh() + + # 5. Extract the final mesh that Capytaine will use for the database. + final_mesh_trimesh = trimesh.Trimesh(vertices=boat.mesh.vertices, faces=boat.mesh.faces) -def add_mesh_to_database(output_file: Path, stl_file: str, overwrite: bool = False) -> None: + return boat, final_mesh_trimesh + + +def add_mesh_to_database( + output_file: Path, mesh_to_add: trimesh.Trimesh, mesh_name: str, overwrite: bool = False +) -> None: """ Adds a mesh and its geometric properties to the HDF5 database under the MESH_GROUP_NAME. Checks if the mesh already exists by comparing SHA256 hashes. If the data is different, it will either raise a warning or overwrite if `overwrite` is True. + + Args: + mesh_to_add: The trimesh object of the mesh to be added. """ - mesh_name = Path(stl_file).stem mesh_group_path = f"{MESH_GROUP_NAME}/{mesh_name}" - # Read new file content and compute hash first - with open(stl_file, "rb") as stl_f: - new_stl_content = stl_f.read() + # Export the trimesh to an in-memory STL binary string and compute its hash. + new_stl_content = mesh_to_add.export(file_type="stl") new_hash = hashlib.sha256(new_stl_content).hexdigest() with h5py.File(output_file, "a") as f: @@ -165,27 +223,29 @@ def add_mesh_to_database(output_file: Path, stl_file: str, overwrite: bool = Fal group = f.create_group(mesh_group_path) # Calculate geometric properties from the new mesh content - new_mesh = trimesh.load_mesh(stl_file) fingerprint_attrs = { - "volume": new_mesh.volume, - "cog_x": new_mesh.center_mass[0], - "cog_y": new_mesh.center_mass[1], - "cog_z": new_mesh.center_mass[2], - "bbox_lx": new_mesh.bounding_box.extents[0], - "bbox_ly": new_mesh.bounding_box.extents[1], - "bbox_lz": new_mesh.bounding_box.extents[2], + "volume": mesh_to_add.volume, + "cog_x": mesh_to_add.center_mass[0], + "cog_y": mesh_to_add.center_mass[1], + "cog_z": mesh_to_add.center_mass[2], + "bbox_lx": mesh_to_add.bounding_box.extents[0], + "bbox_ly": mesh_to_add.bounding_box.extents[1], + "bbox_lz": mesh_to_add.bounding_box.extents[2], } for key, value in fingerprint_attrs.items(): group.attrs[key] = value logger.debug(f" - Wrote {len(fingerprint_attrs)} fingerprint attributes.") - # Add hash as an attribute + # Add hash and original file name as attributes group.attrs["sha256"] = new_hash - group.create_dataset("inertia_tensor", data=new_mesh.moment_inertia) + group.create_dataset("inertia_tensor", data=mesh_to_add.moment_inertia) logger.debug(" - Wrote dataset: inertia_tensor") - group.create_dataset("stl_content", data=memoryview(new_stl_content)) + # Store the binary content of the final, transformed STL + # We must wrap the bytes in np.void to store it as opaque binary data, + # otherwise h5py tries to interpret it as a string and fails on NULL bytes. + group.create_dataset("stl_content", data=np.void(new_stl_content)) logger.debug(" - Wrote dataset: stl_content") @@ -206,7 +266,9 @@ def _generate_case_group_name(mesh_name: str, water_depth: float, water_level: f return f"{mesh_name}_wd_{wd}_wl_{wl}_fs_{fs}" -def _process_single_stl(stl_file: str, settings: SimulationSettings, output_file: Path) -> None: +def _process_single_stl( + stl_file: str, settings: SimulationSettings, output_file: Path, mesh_name_override: str | None = None +) -> None: """ Run the complete processing pipeline for a single STL file. """ @@ -216,10 +278,6 @@ def _process_single_stl(stl_file: str, settings: SimulationSettings, output_file if settings.lid and settings.grid_symmetry: raise LidAndSymmetryEnabledError() - # Add mesh to the database first - add_mesh_to_database(output_file, stl_file, settings.overwrite_meshes) - - # --- Setup simulation parameters --- wave_periods = settings.wave_periods if isinstance(settings.wave_periods, list) else [settings.wave_periods] wave_frequencies = (2 * np.pi / np.array(wave_periods)).tolist() wave_directions = ( @@ -235,14 +293,10 @@ def _process_single_stl(stl_file: str, settings: SimulationSettings, output_file lid = settings.lid grid_symmetry = settings.grid_symmetry - # check is done by Settings, so this should no happen anymore - if lid and grid_symmetry: - raise LidAndSymmetryEnabledError() - output_file = output_file fmt_str = "%-40s: %s" - logger.info(fmt_str % ("STL file", stl_file)) + logger.info(fmt_str % ("Base STL file", stl_file)) logger.info(fmt_str % ("Output file", output_file)) logger.info(fmt_str % ("Grid symmetry", grid_symmetry)) logger.info(fmt_str % ("Use lid", lid)) @@ -251,6 +305,9 @@ def _process_single_stl(stl_file: str, settings: SimulationSettings, output_file logger.info(fmt_str % ("Wave period(s) [s]", wave_periods)) logger.info(fmt_str % ("Water depth(s) [m]", water_depths)) logger.info(fmt_str % ("Water level(s) [m]", water_levels)) + logger.info(fmt_str % ("Translation X", settings.translation_x)) + logger.info(fmt_str % ("Translation Y", settings.translation_y)) + logger.info(fmt_str % ("Translation Z", settings.translation_z)) logger.info(fmt_str % ("Forward speed(s) [m/s]", forwards_speeds)) process_all_cases_for_one_stl( @@ -266,6 +323,10 @@ def _process_single_stl(stl_file: str, settings: SimulationSettings, output_file output_file=output_file, update_cases=settings.update_cases, combine_cases=settings.combine_cases, + translation_x=settings.translation_x, + translation_y=settings.translation_y, + translation_z=settings.translation_z, + mesh_name_override=mesh_name_override, ) @@ -282,12 +343,33 @@ def process_all_cases_for_one_stl( output_file: Path, update_cases: bool = False, combine_cases: bool = False, + translation_x: float = 0.0, + translation_y: float = 0.0, + translation_z: float = 0.0, + mesh_name_override: str | None = None, ) -> None: - mesh_name = Path(stl_file).stem - boat = _prepare_capytaine_body( - stl_file, lid=lid, grid_symmetry=grid_symmetry, add_centre_of_mass=add_center_of_mass + mesh_name = mesh_name_override or Path(stl_file).stem + + # 1. Prepare the base geometry with all transformations + trimesh_geometry = _prepare_trimesh_geometry( + stl_file=stl_file, + translation_x=translation_x, + translation_y=translation_y, + translation_z=translation_z, ) + # 2. Use the prepared geometry to create the Capytaine body + boat, final_mesh = _prepare_capytaine_body( + source_mesh=trimesh_geometry, + mesh_name=mesh_name, + lid=lid, + grid_symmetry=grid_symmetry, + add_center_of_mass=add_center_of_mass, + ) + + # Add the final, transformed, and immersed mesh to the database. + add_mesh_to_database(output_file, final_mesh, mesh_name, overwrite=update_cases) + all_datasets = [] for water_level in water_levels: @@ -368,36 +450,32 @@ def run_simulation_batch(settings: SimulationSettings) -> None: base_mesh_name = Path(base_stl_file).stem logger.info(f"Starting draft generation mode for base mesh: {base_stl_file}") - with tempfile.TemporaryDirectory() as temp_dir: - logger.debug(f"Using temporary directory for generated meshes: {temp_dir}") - generated_files: list[str] = [] + for draft in settings.drafts: + logger.info(f"Processing for draft: {draft}") + + # Create a copy of the settings to modify for this specific draft + draft_settings = settings.model_copy(deep=True) - for draft in settings.drafts: - logger.info(f"Generating mesh for draft: {draft}") - try: - mesh = trimesh.load_mesh(base_stl_file) - transform = trimesh.transformations.translation_matrix([0, 0, -draft]) - mesh.apply_transform(transform) + # Combine the draft with the existing z-translation + # A positive draft means sinking the vessel, so we subtract it. + draft_settings.translation_z -= draft - draft_str = _format_value_for_name(draft) - new_mesh_name = f"{base_mesh_name}_draft_{draft_str}" - new_stl_path = Path(temp_dir) / f"{new_mesh_name}.stl" + # Ensure other translation settings are also passed through + draft_settings.translation_x = settings.translation_x + draft_settings.translation_y = settings.translation_y - mesh.export(new_stl_path) - generated_files.append(str(new_stl_path)) - logger.debug(f"Successfully generated mesh: {new_stl_path}") - except Exception: - logger.exception(f"Failed to generate mesh for draft {draft}") - continue # Continue to the next draft + # Create a unique name for this draft-specific mesh configuration + draft_str = _format_value_for_name(draft) + mesh_name_for_draft = f"{base_mesh_name}_draft_{draft_str}" - # Process the newly generated files - for stl_file in generated_files: - _process_single_stl(stl_file, settings, output_file) + # Process this specific configuration + _process_single_stl(base_stl_file, draft_settings, output_file, mesh_name_override=mesh_name_for_draft) else: # Standard mode: process files as they are logger.info("Starting standard processing for provided STL files.") for stl_file in settings.stl_files: - _process_single_stl(stl_file, settings, output_file) + # In standard mode, also apply the translation settings + _process_single_stl(stl_file, settings, output_file, mesh_name_override=None) logger.info(f"✅ Simulation batch finished. Results saved to {output_file}") diff --git a/src/fleetmaster/core/io.py b/src/fleetmaster/core/io.py new file mode 100644 index 0000000..98f2d83 --- /dev/null +++ b/src/fleetmaster/core/io.py @@ -0,0 +1,33 @@ +import io +import logging +from pathlib import Path + +import h5py +import trimesh + +logger = logging.getLogger(__name__) + + +def load_meshes_from_hdf5( + hdf5_path: Path, + mesh_names: list[str], +) -> list[trimesh.Trimesh]: + """Load and return trimesh objects for the given names from HDF5.""" + meshes: list[trimesh.Trimesh] = [] + if not hdf5_path.exists(): + raise FileNotFoundError(f"{hdf5_path} not found") # noqa: TRY003 + + with h5py.File(hdf5_path, "r") as f: + for name in mesh_names: + group = f.get(f"meshes/{name}") + if not group: + logger.warning("Mesh %r not found", name) + continue + raw = group["stl_content"][()] + try: + mesh = trimesh.load_mesh(io.BytesIO(raw.tobytes()), file_type="stl") + if isinstance(mesh, trimesh.Trimesh): + meshes.append(mesh) + except Exception: + logger.exception("Failed to parse mesh %r", name) + return meshes diff --git a/src/fleetmaster/core/settings.py b/src/fleetmaster/core/settings.py index df22d8b..4e97549 100644 --- a/src/fleetmaster/core/settings.py +++ b/src/fleetmaster/core/settings.py @@ -22,6 +22,9 @@ class SimulationSettings(BaseModel): output_hdf5_file: str = Field(default="results.hdf5", description="Path to the HDF5 output file.") wave_periods: float | list[float] = Field(default=[5.0, 10.0, 15.0, 20.0]) wave_directions: float | list[float] = Field(default=[0.0, 45.0, 90.0, 135.0, 180.0]) + translation_x: float = Field(default=0.0, description="Translation in X-direction to apply to the mesh.") + translation_y: float = Field(default=0.0, description="Translation in Y-direction to apply to the mesh.") + translation_z: float = Field(default=0.0, description="Translation in Z-direction to apply to the mesh.") forward_speed: float | list[float] = 0.0 lid: bool = False add_center_of_mass: bool = False diff --git a/tests/test_engine.py b/tests/test_engine.py index 9422774..89b8ed1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +import trimesh import xarray as xr from fleetmaster.core.engine import ( @@ -104,24 +105,43 @@ def test_setup_output_file_no_overwrite(tmp_path, mock_settings): @patch("fleetmaster.core.engine.cpt") -@patch("fleetmaster.core.engine.trimesh") -def test_prepare_capytaine_body(mock_trimesh, mock_cpt): +@patch("fleetmaster.core.engine.tempfile") +def test_prepare_capytaine_body(mock_tempfile, mock_cpt, tmp_path: Path): """Test _prepare_capytaine_body configures the body correctly.""" + # Arrange + mock_source_mesh = MagicMock(spec=trimesh.Trimesh) + mock_source_mesh.center_mass = [1, 2, 3] + + # Create a real temporary file path for the test to use + temp_file_path = tmp_path / "temp.stl" + mock_tempfile.mkstemp.return_value = (123, str(temp_file_path)) + mock_hull_mesh = MagicMock() mock_cpt.load_mesh.return_value = mock_hull_mesh - mock_trimesh_mesh = MagicMock() - mock_trimesh_mesh.center_mass = [1, 2, 3] - mock_trimesh.load_mesh.return_value = mock_trimesh_mesh + mock_body = MagicMock() mock_cpt.FloatingBody.return_value = mock_body - stl_file = "test.stl" - body = _prepare_capytaine_body(stl_file, lid=True, grid_symmetry=True, add_centre_of_mass=True) + # To make `isinstance(boat.mesh, cpt.meshes.ReflectionSymmetricMesh)` work, + # we define a dummy class and configure the mock to use it. This avoids + # `isinstance()` being called with a mock, which would raise a TypeError. + class _DummySymmetricMesh: + def to_mesh(self): + return MagicMock() + + mock_cpt.meshes.ReflectionSymmetricMesh = _DummySymmetricMesh + mock_cpt.ReflectionSymmetricMesh.return_value = _DummySymmetricMesh() + + # Act + body, _ = _prepare_capytaine_body( + source_mesh=mock_source_mesh, mesh_name="test_mesh", lid=True, grid_symmetry=True, add_center_of_mass=True + ) - mock_cpt.load_mesh.assert_called_once_with(stl_file) + # Assert + mock_source_mesh.export.assert_called_once() + mock_cpt.load_mesh.assert_called_once() mock_hull_mesh.generate_lid.assert_called_once() mock_cpt.ReflectionSymmetricMesh.assert_called_once() - mock_trimesh.load_mesh.assert_called_once_with(stl_file) mock_cpt.FloatingBody.assert_called_once_with( mesh=ANY, lid_mesh=mock_hull_mesh.generate_lid.return_value, center_of_mass=[1, 2, 3] ) @@ -133,24 +153,26 @@ def test_prepare_capytaine_body(mock_trimesh, mock_cpt): def test_add_mesh_to_database_new(tmp_path): """Test adding a new mesh to the HDF5 database.""" output_file = tmp_path / "db.h5" - stl_file = tmp_path / "mesh.stl" - stl_content = b"This is a dummy stl file" - stl_file.write_bytes(stl_content) + stl_content = b"This is a dummy stl file content" - mock_mesh = MagicMock() + mock_mesh = MagicMock(spec=trimesh.Trimesh) mock_mesh.volume = 1.0 mock_mesh.center_mass = [0.1, 0.2, 0.3] mock_mesh.bounding_box.extents = [1.0, 2.0, 3.0] mock_mesh.moment_inertia = np.eye(3) + mock_mesh.export.return_value = stl_content - with patch("fleetmaster.core.engine.trimesh.load_mesh", return_value=mock_mesh): - add_mesh_to_database(output_file, str(stl_file), overwrite=False) + add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) + file_hash = hashlib.sha256(stl_content).hexdigest() with h5py.File(output_file, "r") as f: group = f["meshes/mesh"] - assert "sha256" in group.attrs + # Check that all attributes and datasets are correctly written + assert group.attrs["sha256"] == file_hash assert group.attrs["volume"] == 1.0 assert group.attrs["cog_x"] == 0.1 + assert group.attrs["bbox_ly"] == 2.0 + assert len(group.attrs) == 8 assert "inertia_tensor" in group # type: ignore[reportOperatorIssue] assert "stl_content" in group # type: ignore[reportOperatorIssue] assert group["stl_content"][()].tobytes() == stl_content # type: ignore[reportAttributeAccessIssue] @@ -159,17 +181,18 @@ def test_add_mesh_to_database_new(tmp_path): def test_add_mesh_to_database_skip_existing(tmp_path, caplog): """Test that an existing mesh with the same hash is skipped.""" output_file = tmp_path / "db.h5" - stl_file = tmp_path / "mesh.stl" stl_content = b"dummy stl" - stl_file.write_bytes(stl_content) file_hash = hashlib.sha256(stl_content).hexdigest() + mock_mesh = MagicMock(spec=trimesh.Trimesh) + mock_mesh.export.return_value = stl_content + with h5py.File(output_file, "w") as f: group = f.create_group("meshes/mesh") group.attrs["sha256"] = file_hash with caplog.at_level(logging.INFO): - add_mesh_to_database(output_file, str(stl_file), overwrite=False) + add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) assert "has the same SHA256 hash. Skipping." in caplog.text @@ -177,15 +200,15 @@ def test_add_mesh_to_database_skip_existing(tmp_path, caplog): def test_add_mesh_to_database_overwrite_warning(tmp_path, caplog): """Test warning when mesh is different and overwrite is False.""" output_file = tmp_path / "db.h5" - stl_file = tmp_path / "mesh.stl" - stl_file.write_bytes(b"new content") + mock_mesh = MagicMock(spec=trimesh.Trimesh) + mock_mesh.export.return_value = b"new content" with h5py.File(output_file, "w") as f: group = f.create_group("meshes/mesh") group.attrs["sha256"] = "old_hash" with caplog.at_level(logging.WARNING): - add_mesh_to_database(output_file, str(stl_file), overwrite=False) + add_mesh_to_database(output_file, mock_mesh, "mesh", overwrite=False) assert "is different from the one in the database" in caplog.text @@ -210,15 +233,13 @@ def test_generate_case_group_name(): @patch("fleetmaster.core.engine.process_all_cases_for_one_stl") -@patch("fleetmaster.core.engine.add_mesh_to_database") -def test_process_single_stl(mock_add_mesh, mock_process_all, mock_settings): +def test_process_single_stl(mock_process_all, mock_settings): """Test the main processing pipeline for a single STL file.""" stl_file = "/path/to/dummy.stl" output_file = Path("/fake/output.hdf5") _process_single_stl(stl_file, mock_settings, output_file) - mock_add_mesh.assert_called_once_with(output_file, stl_file, mock_settings.overwrite_meshes) mock_process_all.assert_called_once() _, kwargs = mock_process_all.call_args assert kwargs["stl_file"] == stl_file @@ -246,45 +267,39 @@ def test_run_simulation_batch_standard(mock_setup, mock_process, mock_settings): mock_setup.assert_called_once_with(mock_settings) assert mock_process.call_count == 2 mock_process.assert_has_calls([ - call("file1.stl", mock_settings, output_file), - call("file2.stl", mock_settings, output_file), + call("file1.stl", mock_settings, output_file, mesh_name_override=None), + call("file2.stl", mock_settings, output_file, mesh_name_override=None), ]) -@patch("fleetmaster.core.engine.tempfile.TemporaryDirectory") -@patch("fleetmaster.core.engine.trimesh") @patch("fleetmaster.core.engine._process_single_stl") @patch("fleetmaster.core.engine._setup_output_file", autospec=True) -def test_run_simulation_batch_drafts( - mock_setup, mock_process, mock_trimesh, mock_tempfile, mock_settings, tmp_path: Path -): +def test_run_simulation_batch_drafts(mock_setup, mock_process, mock_settings, tmp_path: Path): """Test run_simulation_batch in draft generation mode.""" # Arrange mock_setup.return_value = tmp_path / "output.hdf5" - mock_tempfile.return_value.__enter__.return_value = str(tmp_path / "temp") - (tmp_path / "temp").mkdir() - - mock_mesh = MagicMock() - mock_trimesh.load_mesh.return_value = mock_mesh - mock_trimesh.transformations.translation_matrix.return_value = np.eye(4) mock_settings.stl_files = ["base_mesh.stl"] mock_settings.drafts = [1.0, 2.5] + mock_settings.translation_z = 5.0 # Act run_simulation_batch(mock_settings) # Assert - assert mock_trimesh.load_mesh.call_count == 2 - assert mock_mesh.apply_transform.call_count == 2 - assert mock_mesh.export.call_count == 2 - - # Check that the generated files are processed assert mock_process.call_count == 2 - expected_path1 = str(tmp_path / "temp" / "base_mesh_draft_1.stl") - expected_path2 = str(tmp_path / "temp" / "base_mesh_draft_2.5.stl") - mock_process.assert_any_call(expected_path1, mock_settings, mock_setup.return_value) - mock_process.assert_any_call(expected_path2, mock_settings, mock_setup.return_value) + + # Check call for first draft + args1, kwargs1 = mock_process.call_args_list[0] + assert args1[0] == "base_mesh.stl" + assert args1[1].translation_z == 4.0 # 5.0 - 1.0 + assert kwargs1["mesh_name_override"] == "base_mesh_draft_1" + + # Check call for second draft + args2, kwargs2 = mock_process.call_args_list[1] + assert args2[0] == "base_mesh.stl" + assert args2[1].translation_z == 2.5 # 5.0 - 2.5 + assert kwargs2["mesh_name_override"] == "base_mesh_draft_2.5" def test_run_simulation_batch_drafts_wrong_stl_count(mock_settings): diff --git a/uv.lock b/uv.lock index 0a5a871..db585c5 100644 --- a/uv.lock +++ b/uv.lock @@ -567,6 +567,7 @@ dependencies = [ { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pandas" }, { name = "pydantic" }, + { name = "pyglet" }, { name = "pyyaml" }, { name = "rich" }, { name = "trimesh" }, @@ -629,6 +630,7 @@ requires-dist = [ { name = "pandas-stubs", marker = "extra == 'dev'", specifier = ">=2.3.2.250926" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.3.0" }, { name = "pydantic", specifier = ">=2.12.2" }, + { name = "pyglet", specifier = "<2" }, { name = "pyside6-essentials", marker = "extra == 'dev'", specifier = ">=6.10.0" }, { name = "pyside6-essentials", marker = "extra == 'gui'", specifier = ">=6.10.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.2.0" }, @@ -1850,6 +1852,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] +[[package]] +name = "pyglet" +version = "1.5.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/c0/59130a7edbcc8f84e35870b00a712538ca05415ff02d17181277b8ef8f05/pyglet-1.5.31.zip", hash = "sha256:a5e422b4c27b0fc99e92103bf493109cca5c18143583b868b3b4631a98ae9417", size = 6900712, upload-time = "2024-12-24T07:10:58.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/43/46aa0ab49f2f5145d201780c7595cf0c305fb4fb5d00d6639792a3d0e770/pyglet-1.5.31-py3-none-any.whl", hash = "sha256:f68413564bbec380e4815898fef0fb7a4a494dc3f8718bfbf28ce2a802634c88", size = 1143660, upload-time = "2024-12-24T07:10:50.769Z" }, +] + [[package]] name = "pygments" version = "2.19.2"